diff --git a/GEMINI.md b/GEMINI.md
index a275ebc..c2e8448 100644
--- a/GEMINI.md
+++ b/GEMINI.md
@@ -33,7 +33,7 @@
│ ├── stl/ # Standard Type Library (utilities)
│ └── ui/ # Shared React component library
├── services/ # Independent web services
-│ ├── www/ # Main website (Astro + React)
+│ ├── www/ # Main website (Rsbuild + React)
│ └── docs/ # Documentation site (Hugo)
├── .github/workflows/ # CI/CD automation
├── pnpm-workspace.yaml # Workspace configuration
@@ -44,7 +44,7 @@
#### Configuration Packages (`packages/`)
-- **eslint-config**: Shared ESLint rules for TypeScript, React, and Astro
+- **eslint-config**: Shared ESLint rules for TypeScript and React
- **rsbuild-config**: Shared Rsbuild and Rspack configurations for library and service builds
- **storybook-config**: Shared Storybook setup and configurations
@@ -52,10 +52,12 @@
- **stl** (Standard Type Library): Common utilities, types, and helper functions
- **ui**: React component library with design system and Storybook stories
+- **i18n**: Internationalization utilities
#### Service Packages (`services/`)
-- **www**: Main website built with Astro.js, React, and Tailwind CSS
+- **www**: Main website built with Rsbuild, React, and Tailwind CSS
+- **static**: Static landing pages service built with Rsbuild and React
- **docs**: Documentation site built with Hugo and Hextra theme
### Key Directory Rules
@@ -140,7 +142,9 @@ All packages use the `@ermnvldmr/` namespace:
- `@ermnvldmr/storybook-config` - Shared Storybook configurations
- `@ermnvldmr/stl` - Standard Type Library
- `@ermnvldmr/ui` - Component library
+- `@ermnvldmr/i18n` - Internationalization utilities
- `@ermnvldmr/www` - Main website service
+- `@ermnvldmr/static` - Static landing pages service
- `@ermnvldmr/docs` - Documentation service
### TypeScript Guidelines
@@ -175,6 +179,15 @@ export function cn(...inputs: ClassValue[]): string {
}
```
+### Internationalization (i18n) Guidelines
+
+- **Package**: Use the `@ermnvldmr/i18n` package for all translations across services.
+- **Keyset Pattern**: Create `.i18n.ts` files alongside components that require translation.
+- **Russian Translation Style**: Embrace the Russian language fully. Prefer phonetic transliteration/transcription for global brand names and descriptive terms over keeping them in English.
+ - **Brands**: 'GitHub' → 'ГитХаб', 'Instagram' → 'Инстаграм', 'Telegram' → 'Телеграм'.
+ - **Descriptive**: 'Email' → 'Электропочта'.
+- **Source of Truth**: The English locale (`en`) serves as the primary reference for keys and structure.
+
### Component Development Patterns
#### UI Package Components
@@ -306,7 +319,7 @@ Each configuration package provides standardized setups:
#### ESLint Configuration
```javascript
-// Provides: base, react, astro configurations
+// Provides: base, react configurations
import { baseConfig } from '@ermnvldmr/eslint-config/dev';
```
@@ -421,3 +434,11 @@ describe('components/paper/Button/Button', () => {
---
_This guide serves as the architectural foundation for the monorepo. Focus on these stable patterns and structures rather than specific implementation details that may change over time._
+
+## 🤖 AI Agent Best Practices
+
+The following are explicit best practices and preferences intended for AI tools and agents operating within this repository:
+
+- **Build System Preference**: Rsbuild and Rspack are the standard and strongly preferred build tools for all projects in this monorepo due to their Rust-based architecture and performance. Do not introduce Vite, Astro, or Webpack unless strictly necessary.
+- **Framework Preference**: The monorepo has migrated away from Astro in favor of React + Rsbuild (with MDX support).
+- **Content Sites**: Rspress is the preferred infrastructure for any new content-heavy or documentation sites.
diff --git a/README.md b/README.md
index d50a200..57c9e76 100644
--- a/README.md
+++ b/README.md
@@ -23,13 +23,15 @@ Composite (see [Copyright & License](#copyright--license))
#### Services
-- **[www](./services/www)**: Main website, built with Astro and React.
+- **[www](./services/www)**: Main website, built with Rsbuild, React, and MDX.
+- **[static](./services/static)**: Static landing pages, built with Rsbuild and React.
- **[docs](./services/docs)**: Documentation site, built with Hugo and the Hextra theme.
#### Packages
- **[@ermnvldmr/ui](./packages/ui)**: Shared React component library and design system.
- **[@ermnvldmr/stl](./packages/stl)**: Standard Template Library – common utilities and reusable logic.
+- **[@ermnvldmr/i18n](./packages/i18n)**: Internationalization utilities.
- **[@ermnvldmr/eslint-config](./packages/eslint-config)**: Shared linting configurations.
- **[@ermnvldmr/rsbuild-config](./packages/rsbuild-config)**: Shared build configurations (Rsbuild & Rspack).
- **[@ermnvldmr/storybook-config](./packages/storybook-config)**: Shared Storybook configurations.
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 49d8d3e..2106895 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -1,6 +1,5 @@
// @ts-check
import { baseConfig } from '@ermnvldmr/eslint-config/dev';
-import eslintPluginAstro from 'eslint-plugin-astro';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
@@ -22,20 +21,10 @@ export default [
'**/coverage/**',
'**/storybook-static/**',
'**/.next/**',
- '**/.astro/**',
'**/*.d.ts',
],
},
- // Astro
- ...eslintPluginAstro.configs.recommended,
- {
- files: ['services/www/**/*.astro', 'services/static/**/*.astro'],
- rules: {
- // Astro specific rules can go here
- },
- },
-
// Storybook - recommended configuration for flat config
...storybook.configs['flat/recommended'],
diff --git a/package.json b/package.json
index 230e604..314a751 100644
--- a/package.json
+++ b/package.json
@@ -43,7 +43,9 @@
"typecheck": "pnpm -r typecheck",
"test": "pnpm -r test",
"test:coverage": "pnpm -r test:coverage",
- "clean": "pnpm -r clean && rm -rf node_modules"
+ "clean": "pnpm -r clean && rm -rf node_modules",
+ "build:deps": "pnpm -r --filter='./packages/*-config' build && pnpm --filter='./packages/i18n' build",
+ "postinstall": "pnpm build:deps"
},
"devDependencies": {
"globals": "^16.0.0",
diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json
index 8366911..83d8577 100644
--- a/packages/eslint-config/package.json
+++ b/packages/eslint-config/package.json
@@ -13,7 +13,6 @@
"dependencies": {
"@eslint/js": "^9.39.2",
"eslint-config-prettier": "^10.1.2",
- "eslint-plugin-astro": "^1.3.1",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsdoc": "^61.5.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
diff --git a/packages/i18n/package.json b/packages/i18n/package.json
index 7bfe821..b54134e 100644
--- a/packages/i18n/package.json
+++ b/packages/i18n/package.json
@@ -22,7 +22,9 @@
"default": "./src/testing.ts"
}
},
- "files": ["dist"],
+ "files": [
+ "dist"
+ ],
"scripts": {
"build": "rsbuild build",
"test": "vitest run",
diff --git a/packages/i18n/src/dev.ts b/packages/i18n/src/dev.ts
index 6ebfcbb..f44a0a1 100644
--- a/packages/i18n/src/dev.ts
+++ b/packages/i18n/src/dev.ts
@@ -35,7 +35,7 @@ export function localeRsbuildConfig(): RsbuildConfig {
// The canonical import is always `.en.mdx`; other locales are resolved here.
api.modifyRspackConfig((config, { rspack }) => {
config.plugins = [
- ...(config.plugins ?? []),
+ ...config.plugins,
new rspack.NormalModuleReplacementPlugin(
/\.en\.mdx$/,
(resource: { request: string }) => {
diff --git a/packages/i18n/src/lib/keyset.test.tsx b/packages/i18n/src/lib/keyset.test.tsx
index 37f4356..3a4f9cd 100644
--- a/packages/i18n/src/lib/keyset.test.tsx
+++ b/packages/i18n/src/lib/keyset.test.tsx
@@ -8,7 +8,12 @@ const { t, tRaw } = createKeyset({
'count {value}': { en: 'count {value}', ru: 'счёт {value}' },
items: {
en: { none: 'no items', one: 'one item', some: '{count} items', many: '{count} items' },
- ru: { none: 'нет элементов', one: 'один элемент', some: '{count} элемента', many: '{count} элементов' },
+ ru: {
+ none: 'нет элементов',
+ one: 'один элемент',
+ some: '{count} элемента',
+ many: '{count} элементов',
+ },
},
});
@@ -17,8 +22,7 @@ describe('t', () => {
it('interpolates {value}', () => expect(t('count {value}', { value: '42' })).toBe('count 42'));
it('pluralizes none', () => expect(t('items', { count: 0 })).toBe('no items'));
it('pluralizes one', () => expect(t('items', { count: 1 })).toBe('one item'));
- it('pluralizes many with interpolation', () =>
- expect(t('items', { count: 5 })).toBe('5 items'));
+ it('pluralizes many with interpolation', () => expect(t('items', { count: 5 })).toBe('5 items'));
});
describe('tRaw', () => {
diff --git a/packages/i18n/src/lib/keyset.ts b/packages/i18n/src/lib/keyset.ts
index 69e9f1b..fdd93e1 100644
--- a/packages/i18n/src/lib/keyset.ts
+++ b/packages/i18n/src/lib/keyset.ts
@@ -4,10 +4,16 @@ import { pluralize, type PluralForms } from './pluralize.js';
import type { ReactNode } from 'react';
/** A translation entry with a plain string per locale. */
-interface PlainTranslation { en: string; ru: string }
+interface PlainTranslation {
+ en: string;
+ ru: string;
+}
/** A translation entry with plural forms per locale. */
-interface PluralTranslation { en: PluralForms; ru: PluralForms }
+interface PluralTranslation {
+ en: PluralForms;
+ ru: PluralForms;
+}
/** Union of all supported translation shapes. */
type Translation = PlainTranslation | PluralTranslation;
diff --git a/packages/i18n/src/lib/path.test.ts b/packages/i18n/src/lib/path.test.ts
index 5353c5b..512a37d 100644
--- a/packages/i18n/src/lib/path.test.ts
+++ b/packages/i18n/src/lib/path.test.ts
@@ -5,8 +5,7 @@ import { localePath, localeHref } from './path.js';
describe('localePath', () => {
it('prepends LOCALE to absolute path', () =>
expect(localePath('/articles')).toBe('/en/articles'));
- it('handles root path', () =>
- expect(localePath('/')).toBe('/en/'));
+ it('handles root path', () => expect(localePath('/')).toBe('/en/'));
it('handles nested path', () =>
expect(localePath('/articles/2026/test')).toBe('/en/articles/2026/test'));
});
@@ -14,8 +13,7 @@ describe('localePath', () => {
describe('localeHref', () => {
it('replaces locale prefix in URL', () =>
expect(localeHref('/en/articles', 'ru')).toBe('/ru/articles'));
- it('replaces locale at root', () =>
- expect(localeHref('/en/', 'ru')).toBe('/ru/'));
+ it('replaces locale at root', () => expect(localeHref('/en/', 'ru')).toBe('/ru/'));
it('falls back to path if no prefix found', () =>
expect(localeHref('/articles', 'ru')).toBe('/ru/articles'));
});
diff --git a/packages/rsbuild-config/src/lib/config/ssg.ts b/packages/rsbuild-config/src/lib/config/ssg.ts
index 97588fd..b26ca46 100644
--- a/packages/rsbuild-config/src/lib/config/ssg.ts
+++ b/packages/rsbuild-config/src/lib/config/ssg.ts
@@ -122,10 +122,10 @@ export function ssgPlugin(): RsbuildPlugin {
const critters = new Critters({
path: webDistPath,
publicPath: configuredAssetPrefix,
- preload: 'swap', //
- inlineFonts: false, // fonts are handled via separate tags
- pruneSource: false, // keep full CSS file for post-hydration correctness
- reduceInlineStyles: false, // preserve theme init styles (prevents .dark rule from being stripped)
+ preload: 'swap', //
+ inlineFonts: false, // fonts are handled via separate tags
+ pruneSource: false, // keep full CSS file for post-hydration correctness
+ reduceInlineStyles: false, // preserve theme init styles (prevents .dark rule from being stripped)
});
for (const entryName of entryNames) {
diff --git a/packages/stl/src/lib/functions/cn/cn.ts b/packages/stl/src/lib/functions/cn/cn.ts
index 2e8028c..811129c 100644
--- a/packages/stl/src/lib/functions/cn/cn.ts
+++ b/packages/stl/src/lib/functions/cn/cn.ts
@@ -4,9 +4,7 @@ import { extendTailwindMerge } from 'tailwind-merge';
const RB_TYPES = ['display', 'headline', 'title', 'body', 'label'] as const;
const RB_SIZES = ['l', 'm', 's'] as const;
-const rbFontSizeClasses = RB_TYPES.flatMap((type) =>
- RB_SIZES.map((size) => `${type}-${size}`)
-);
+const rbFontSizeClasses = RB_TYPES.flatMap((type) => RB_SIZES.map((size) => `${type}-${size}`));
const twMerge = extendTailwindMerge({
extend: {
diff --git a/packages/stl/src/lib/window/localStorage.ts b/packages/stl/src/lib/window/localStorage.ts
index 3d7dd39..26b6a88 100644
--- a/packages/stl/src/lib/window/localStorage.ts
+++ b/packages/stl/src/lib/window/localStorage.ts
@@ -38,8 +38,12 @@ export function createLocalStorage(key: string, defaultValue: T): StorageMana
return {
key,
get: () => defaultValue,
- set: () => { /* no-op in SSR/SSG context */ },
- remove: () => { /* no-op in SSR/SSG context */ },
+ set: () => {
+ /* no-op in SSR/SSG context */
+ },
+ remove: () => {
+ /* no-op in SSR/SSG context */
+ },
};
}
diff --git a/packages/ui/.config/vitest/setup.ts b/packages/ui/.config/vitest/setup.ts
index 9e9adb9..5bc6e55 100644
--- a/packages/ui/.config/vitest/setup.ts
+++ b/packages/ui/.config/vitest/setup.ts
@@ -30,11 +30,19 @@ if (typeof window.localStorage.clear !== 'function') {
configurable: true,
value: {
getItem: (key: string): string | null => store.get(key) ?? null,
- setItem: (key: string, value: string): void => { store.set(key, value); },
- removeItem: (key: string): void => { store.delete(key); },
- clear: (): void => { store.clear(); },
+ setItem: (key: string, value: string): void => {
+ store.set(key, value);
+ },
+ removeItem: (key: string): void => {
+ store.delete(key);
+ },
+ clear: (): void => {
+ store.clear();
+ },
key: (index: number): string | null => [...store.keys()][index] ?? null,
- get length(): number { return store.size; },
+ get length(): number {
+ return store.size;
+ },
},
});
}
diff --git a/packages/ui/src/components/Markdown/Markdown.tsx b/packages/ui/src/components/Markdown/Markdown.tsx
index fe82155..e6cd3ce 100644
--- a/packages/ui/src/components/Markdown/Markdown.tsx
+++ b/packages/ui/src/components/Markdown/Markdown.tsx
@@ -51,9 +51,6 @@ const MarkdownComponent = memo(function Markdown({ children, className }: Markdo
* Title
* Content goes here.
*
- *
- * // Or with Astro:
- *
* ```
*/
export const Markdown = Object.assign(MarkdownComponent, {
diff --git a/packages/ui/src/components/Markdown/components.ts b/packages/ui/src/components/Markdown/components.ts
index 1926173..5c94a50 100644
--- a/packages/ui/src/components/Markdown/components.ts
+++ b/packages/ui/src/components/Markdown/components.ts
@@ -24,7 +24,7 @@ import { MarkdownUL } from './MarkdownUL/MarkdownUL';
import type React from 'react';
/**
- * A map of HTML tags to Markdown components for use with MDX or Astro Content.
+ * A map of HTML tags to Markdown components for use with MDX or other Markdown systems.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const MARKDOWN_COMPONENTS: Record> = {
diff --git a/packages/ui/src/components/Page/PageHead/PageHead.test.tsx b/packages/ui/src/components/Page/PageHead/PageHead.test.tsx
index eeabc59..636349b 100644
--- a/packages/ui/src/components/Page/PageHead/PageHead.test.tsx
+++ b/packages/ui/src/components/Page/PageHead/PageHead.test.tsx
@@ -32,7 +32,7 @@ describe('PageHead', () => {
it('applies the correct classes for the strategy', () => {
render();
const header = screen.getByRole('banner');
- expect(header).toHaveClass('fixed');
+ expect(header).toHaveClass('sticky');
expect(header).toHaveClass('shadow-md');
});
});
diff --git a/packages/ui/src/components/Page/PageHead/PageHead.tsx b/packages/ui/src/components/Page/PageHead/PageHead.tsx
index 239b179..abd50b8 100644
--- a/packages/ui/src/components/Page/PageHead/PageHead.tsx
+++ b/packages/ui/src/components/Page/PageHead/PageHead.tsx
@@ -1,5 +1,5 @@
-import { cn, useScroll, useResizeObserver } from '@ermnvldmr/stl';
-import React, { type ReactNode, useRef, useEffect, useState } from 'react';
+import { cn, useScroll } from '@ermnvldmr/stl';
+import React, { type ReactNode, useEffect, useState } from 'react';
import { Header } from '../../Header/Header';
import { HStack } from '../../HStack/HStack';
@@ -68,8 +68,6 @@ export const PageHead = ({
}: PageHeadProps) => {
const { y: scrollY } = useScroll();
const [isCollapsed, setIsCollapsed] = useState(strategy.includes('always-collapsed'));
- const headerRef = useRef(null);
- const { height } = useResizeObserver(headerRef);
useEffect(() => {
if (strategy === 'collapsible-sticky') {
@@ -78,7 +76,7 @@ export const PageHead = ({
}, [scrollY, strategy]);
const rootClasses = cn(
- 'page-head w-full transition-all duration-200 bg-[var(--rb-background)] fixed top-0 z-10 border-b border-[var(--rb-outline)]/10',
+ 'page-head w-full transition-all duration-200 bg-[var(--rb-background)] sticky top-0 z-10 border-b border-[var(--rb-outline)]/10',
{
'shadow-md bg-[var(--rb-background)]/90 backdrop-blur-md': isCollapsed,
},
@@ -124,28 +122,25 @@ export const PageHead = ({
);
return (
- <>
-
-
-
{expandedContent}
-
-
-
{collapsedContent}
-
-
-
- >
+
+
+
{expandedContent}
+
+
+
{collapsedContent}
+
+
);
};
diff --git a/packages/ui/src/components/Text/Text.test.tsx b/packages/ui/src/components/Text/Text.test.tsx
index e0f0951..700f45b 100644
--- a/packages/ui/src/components/Text/Text.test.tsx
+++ b/packages/ui/src/components/Text/Text.test.tsx
@@ -132,29 +132,49 @@ describe('components/Text', () => {
// TDD: fails before implementation (old code uses text-[14px], not text-rb-body-m)
it('applies text-rb-{type}-{size} class', () => {
- render(Content);
+ render(
+
+ Content
+
+ );
expect(screen.getByText('Content')).toHaveClass('text-rb-body-m');
});
it('does not apply legacy px-based font-size classes', () => {
- render(Content);
+ render(
+
+ Content
+
+ );
expect(screen.getByText('Content')).not.toHaveClass('text-[14px]');
expect(screen.getByText('Content')).not.toHaveClass('leading-[20px]');
});
// Regression guards: pass before and after
it('applies font-medium for label type', () => {
- render(Label);
+ render(
+
+ Label
+
+ );
expect(screen.getByText('Label')).toHaveClass('font-medium');
});
it('applies font-sans for body type', () => {
- render(Body);
+ render(
+
+ Body
+
+ );
expect(screen.getByText('Body')).toHaveClass('font-sans');
});
it('applies font-serif for display type', () => {
- render(Display);
+ render(
+
+ Display
+
+ );
expect(screen.getByText('Display')).toHaveClass('font-serif');
});
});
diff --git a/packages/ui/src/components/Text/Text.tsx b/packages/ui/src/components/Text/Text.tsx
index 3750bfe..859016c 100644
--- a/packages/ui/src/components/Text/Text.tsx
+++ b/packages/ui/src/components/Text/Text.tsx
@@ -119,7 +119,19 @@ const TextComponent = forwardRef(function Text(
{...props}
ref={internalRef}
className={cn(
- getTypographyClassNames({ type, size, color, bold, italic, underline, strike, align, wrap, overflow, maxLines }),
+ getTypographyClassNames({
+ type,
+ size,
+ color,
+ bold,
+ italic,
+ underline,
+ strike,
+ align,
+ wrap,
+ overflow,
+ maxLines,
+ }),
delay !== undefined &&
(isVisible
? 'animate-in fade-in slide-in-from-bottom-2 duration-1000 ease-out'
diff --git a/packages/ui/src/lib/typography/getTypographyClassNames/getTypographyClassNames.test.ts b/packages/ui/src/lib/typography/getTypographyClassNames/getTypographyClassNames.test.ts
index 522a0bd..f71eb60 100644
--- a/packages/ui/src/lib/typography/getTypographyClassNames/getTypographyClassNames.test.ts
+++ b/packages/ui/src/lib/typography/getTypographyClassNames/getTypographyClassNames.test.ts
@@ -7,8 +7,12 @@ describe('lib/typography/getTypographyClassNames', () => {
});
it('returns correct class for each type/size combination', () => {
- expect(getTypographyClassNames({ type: 'display', size: 'l' })).toContain('text-rb-display-l');
- expect(getTypographyClassNames({ type: 'headline', size: 'm' })).toContain('text-rb-headline-m');
+ expect(getTypographyClassNames({ type: 'display', size: 'l' })).toContain(
+ 'text-rb-display-l'
+ );
+ expect(getTypographyClassNames({ type: 'headline', size: 'm' })).toContain(
+ 'text-rb-headline-m'
+ );
expect(getTypographyClassNames({ type: 'title', size: 's' })).toContain('text-rb-title-s');
expect(getTypographyClassNames({ type: 'body', size: 'l' })).toContain('text-rb-body-l');
expect(getTypographyClassNames({ type: 'label', size: 'm' })).toContain('text-rb-label-m');
@@ -54,7 +58,9 @@ describe('lib/typography/getTypographyClassNames', () => {
it('applies each color variant', () => {
expect(getTypographyClassNames({ color: 'muted' })).toContain('text-[var(--rb-muted-text)]');
- expect(getTypographyClassNames({ color: 'primary' })).toContain('text-[var(--rb-primary-text)]');
+ expect(getTypographyClassNames({ color: 'primary' })).toContain(
+ 'text-[var(--rb-primary-text)]'
+ );
expect(getTypographyClassNames({ color: 'error' })).toContain('text-[var(--rb-error-text)]');
expect(getTypographyClassNames({ color: 'inherit' })).toContain('text-inherit');
});
diff --git a/packages/ui/src/static/styles.css b/packages/ui/src/static/styles.css
index edc8238..1d66ae5 100644
--- a/packages/ui/src/static/styles.css
+++ b/packages/ui/src/static/styles.css
@@ -191,29 +191,62 @@
@theme {
/* display */
- --text-rb-display-l: 3.5625rem; --text-rb-display-l--line-height: 4rem; --text-rb-display-l--letter-spacing: -0.015625rem;
- --text-rb-display-m: 2.8125rem; --text-rb-display-m--line-height: 3.25rem; --text-rb-display-m--letter-spacing: 0rem;
- --text-rb-display-s: 2.25rem; --text-rb-display-s--line-height: 2.75rem; --text-rb-display-s--letter-spacing: 0rem;
+ --text-rb-display-l: 3.5625rem;
+ --text-rb-display-l--line-height: 4rem;
+ --text-rb-display-l--letter-spacing: -0.015625rem;
+ --text-rb-display-m: 2.8125rem;
+ --text-rb-display-m--line-height: 3.25rem;
+ --text-rb-display-m--letter-spacing: 0rem;
+ --text-rb-display-s: 2.25rem;
+ --text-rb-display-s--line-height: 2.75rem;
+ --text-rb-display-s--letter-spacing: 0rem;
/* headline */
- --text-rb-headline-l: 2rem; --text-rb-headline-l--line-height: 2.5rem; --text-rb-headline-l--letter-spacing: 0rem;
- --text-rb-headline-m: 1.75rem; --text-rb-headline-m--line-height: 2.25rem; --text-rb-headline-m--letter-spacing: 0rem;
- --text-rb-headline-s: 1.5rem; --text-rb-headline-s--line-height: 2rem; --text-rb-headline-s--letter-spacing: 0rem;
+ --text-rb-headline-l: 2rem;
+ --text-rb-headline-l--line-height: 2.5rem;
+ --text-rb-headline-l--letter-spacing: 0rem;
+ --text-rb-headline-m: 1.75rem;
+ --text-rb-headline-m--line-height: 2.25rem;
+ --text-rb-headline-m--letter-spacing: 0rem;
+ --text-rb-headline-s: 1.5rem;
+ --text-rb-headline-s--line-height: 2rem;
+ --text-rb-headline-s--letter-spacing: 0rem;
/* title */
- --text-rb-title-l: 1.375rem; --text-rb-title-l--line-height: 1.75rem; --text-rb-title-l--letter-spacing: 0rem;
- --text-rb-title-m: 1rem; --text-rb-title-m--line-height: 1.5rem; --text-rb-title-m--letter-spacing: 0.009375rem;
- --text-rb-title-s: 0.875rem; --text-rb-title-s--line-height: 1.25rem; --text-rb-title-s--letter-spacing: 0.00625rem;
+ --text-rb-title-l: 1.375rem;
+ --text-rb-title-l--line-height: 1.75rem;
+ --text-rb-title-l--letter-spacing: 0rem;
+ --text-rb-title-m: 1rem;
+ --text-rb-title-m--line-height: 1.5rem;
+ --text-rb-title-m--letter-spacing: 0.009375rem;
+ --text-rb-title-s: 0.875rem;
+ --text-rb-title-s--line-height: 1.25rem;
+ --text-rb-title-s--letter-spacing: 0.00625rem;
/* body — baseline raised: m = 1rem (was 14px/0.875rem) */
- --text-rb-body-l: 1.125rem; --text-rb-body-l--line-height: 1.75rem; --text-rb-body-l--letter-spacing: 0.03125rem;
- --text-rb-body-m: 1rem; --text-rb-body-m--line-height: 1.5rem; --text-rb-body-m--letter-spacing: 0.015625rem;
- --text-rb-body-s: 0.875rem; --text-rb-body-s--line-height: 1.25rem; --text-rb-body-s--letter-spacing: 0.025rem;
+ --text-rb-body-l: 1.125rem;
+ --text-rb-body-l--line-height: 1.75rem;
+ --text-rb-body-l--letter-spacing: 0.03125rem;
+ --text-rb-body-m: 1rem;
+ --text-rb-body-m--line-height: 1.5rem;
+ --text-rb-body-m--letter-spacing: 0.015625rem;
+ --text-rb-body-s: 0.875rem;
+ --text-rb-body-s--line-height: 1.25rem;
+ --text-rb-body-s--letter-spacing: 0.025rem;
/* label */
- --text-rb-label-l: 0.875rem; --text-rb-label-l--line-height: 1.25rem; --text-rb-label-l--letter-spacing: 0.00625rem; --text-rb-label-l--font-weight: 500;
- --text-rb-label-m: 0.75rem; --text-rb-label-m--line-height: 1rem; --text-rb-label-m--letter-spacing: 0.03125rem; --text-rb-label-m--font-weight: 500;
- --text-rb-label-s: 0.6875rem; --text-rb-label-s--line-height: 1rem; --text-rb-label-s--letter-spacing: 0.03125rem; --text-rb-label-s--font-weight: 500;
+ --text-rb-label-l: 0.875rem;
+ --text-rb-label-l--line-height: 1.25rem;
+ --text-rb-label-l--letter-spacing: 0.00625rem;
+ --text-rb-label-l--font-weight: 500;
+ --text-rb-label-m: 0.75rem;
+ --text-rb-label-m--line-height: 1rem;
+ --text-rb-label-m--letter-spacing: 0.03125rem;
+ --text-rb-label-m--font-weight: 500;
+ --text-rb-label-s: 0.6875rem;
+ --text-rb-label-s--line-height: 1rem;
+ --text-rb-label-s--letter-spacing: 0.03125rem;
+ --text-rb-label-s--font-weight: 500;
}
:root {
@@ -285,34 +318,70 @@
@layer components {
/* ── Scale parents — set CSS vars for all type children below ── */
.rb-scale-s {
- --rb-sz-display: var(--text-rb-display-s); --rb-lh-display: var(--text-rb-display-s--line-height);
- --rb-sz-headline: var(--text-rb-headline-s); --rb-lh-headline: var(--text-rb-headline-s--line-height);
- --rb-sz-title: var(--text-rb-title-s); --rb-lh-title: var(--text-rb-title-s--line-height);
- --rb-sz-body: var(--text-rb-body-s); --rb-lh-body: var(--text-rb-body-s--line-height);
- --rb-sz-label: var(--text-rb-label-s); --rb-lh-label: var(--text-rb-label-s--line-height);
+ --rb-sz-display: var(--text-rb-display-s);
+ --rb-lh-display: var(--text-rb-display-s--line-height);
+ --rb-sz-headline: var(--text-rb-headline-s);
+ --rb-lh-headline: var(--text-rb-headline-s--line-height);
+ --rb-sz-title: var(--text-rb-title-s);
+ --rb-lh-title: var(--text-rb-title-s--line-height);
+ --rb-sz-body: var(--text-rb-body-s);
+ --rb-lh-body: var(--text-rb-body-s--line-height);
+ --rb-sz-label: var(--text-rb-label-s);
+ --rb-lh-label: var(--text-rb-label-s--line-height);
}
.rb-scale-m {
- --rb-sz-display: var(--text-rb-display-m); --rb-lh-display: var(--text-rb-display-m--line-height);
- --rb-sz-headline: var(--text-rb-headline-m); --rb-lh-headline: var(--text-rb-headline-m--line-height);
- --rb-sz-title: var(--text-rb-title-m); --rb-lh-title: var(--text-rb-title-m--line-height);
- --rb-sz-body: var(--text-rb-body-m); --rb-lh-body: var(--text-rb-body-m--line-height);
- --rb-sz-label: var(--text-rb-label-m); --rb-lh-label: var(--text-rb-label-m--line-height);
+ --rb-sz-display: var(--text-rb-display-m);
+ --rb-lh-display: var(--text-rb-display-m--line-height);
+ --rb-sz-headline: var(--text-rb-headline-m);
+ --rb-lh-headline: var(--text-rb-headline-m--line-height);
+ --rb-sz-title: var(--text-rb-title-m);
+ --rb-lh-title: var(--text-rb-title-m--line-height);
+ --rb-sz-body: var(--text-rb-body-m);
+ --rb-lh-body: var(--text-rb-body-m--line-height);
+ --rb-sz-label: var(--text-rb-label-m);
+ --rb-lh-label: var(--text-rb-label-m--line-height);
}
.rb-scale-l {
- --rb-sz-display: var(--text-rb-display-l); --rb-lh-display: var(--text-rb-display-l--line-height);
- --rb-sz-headline: var(--text-rb-headline-l); --rb-lh-headline: var(--text-rb-headline-l--line-height);
- --rb-sz-title: var(--text-rb-title-l); --rb-lh-title: var(--text-rb-title-l--line-height);
- --rb-sz-body: var(--text-rb-body-l); --rb-lh-body: var(--text-rb-body-l--line-height);
- --rb-sz-label: var(--text-rb-label-l); --rb-lh-label: var(--text-rb-label-l--line-height);
+ --rb-sz-display: var(--text-rb-display-l);
+ --rb-lh-display: var(--text-rb-display-l--line-height);
+ --rb-sz-headline: var(--text-rb-headline-l);
+ --rb-lh-headline: var(--text-rb-headline-l--line-height);
+ --rb-sz-title: var(--text-rb-title-l);
+ --rb-lh-title: var(--text-rb-title-l--line-height);
+ --rb-sz-body: var(--text-rb-body-l);
+ --rb-lh-body: var(--text-rb-body-l--line-height);
+ --rb-sz-label: var(--text-rb-label-l);
+ --rb-lh-label: var(--text-rb-label-l--line-height);
}
/* ── Type children — declare type, read from parent scale vars ── */
/* Fallback (second arg to var()) applies m-size when no rb-scale-* parent exists */
- .rb-text-display { font-family: var(--font-serif); font-size: var(--rb-sz-display, var(--text-rb-display-m)); line-height: var(--rb-lh-display, var(--text-rb-display-m--line-height)); }
- .rb-text-headline { font-family: var(--font-serif); font-size: var(--rb-sz-headline, var(--text-rb-headline-m)); line-height: var(--rb-lh-headline, var(--text-rb-headline-m--line-height)); }
- .rb-text-title { font-family: var(--font-sans); font-size: var(--rb-sz-title, var(--text-rb-title-m)); line-height: var(--rb-lh-title, var(--text-rb-title-m--line-height)); }
- .rb-text-body { font-family: var(--font-sans); font-size: var(--rb-sz-body, var(--text-rb-body-m)); line-height: var(--rb-lh-body, var(--text-rb-body-m--line-height)); }
- .rb-text-label { font-family: var(--font-sans); font-weight: 500; font-size: var(--rb-sz-label, var(--text-rb-label-m)); line-height: var(--rb-lh-label, var(--text-rb-label-m--line-height)); }
+ .rb-text-display {
+ font-family: var(--font-serif);
+ font-size: var(--rb-sz-display, var(--text-rb-display-m));
+ line-height: var(--rb-lh-display, var(--text-rb-display-m--line-height));
+ }
+ .rb-text-headline {
+ font-family: var(--font-serif);
+ font-size: var(--rb-sz-headline, var(--text-rb-headline-m));
+ line-height: var(--rb-lh-headline, var(--text-rb-headline-m--line-height));
+ }
+ .rb-text-title {
+ font-family: var(--font-sans);
+ font-size: var(--rb-sz-title, var(--text-rb-title-m));
+ line-height: var(--rb-lh-title, var(--text-rb-title-m--line-height));
+ }
+ .rb-text-body {
+ font-family: var(--font-sans);
+ font-size: var(--rb-sz-body, var(--text-rb-body-m));
+ line-height: var(--rb-lh-body, var(--text-rb-body-m--line-height));
+ }
+ .rb-text-label {
+ font-family: var(--font-sans);
+ font-weight: 500;
+ font-size: var(--rb-sz-label, var(--text-rb-label-m));
+ line-height: var(--rb-lh-label, var(--text-rb-label-m--line-height));
+ }
}
@keyframes shimmer {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 630c753..77cddba 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -33,9 +33,6 @@ importers:
eslint-config-prettier:
specifier: ^10.1.2
version: 10.1.8(eslint@9.39.2(jiti@2.6.1))
- eslint-plugin-astro:
- specifier: ^1.3.1
- version: 1.5.0(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-import:
specifier: ^2.32.0
version: 2.32.0(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1))
@@ -231,7 +228,7 @@ importers:
version: 7.0.1
storybook-react-rsbuild:
specifier: ^1.0.3
- version: 1.0.3(@rsbuild/core@1.7.3)(@storybook/test@8.6.15(storybook@8.6.17(prettier@3.8.1)))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.56.0)(storybook@8.6.17(prettier@3.8.1))(typescript@5.8.3)(webpack@5.105.1)
+ version: 1.0.3(@rsbuild/core@1.7.3)(@storybook/test@8.6.15(storybook@8.6.17(prettier@3.8.1)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.56.0)(storybook@8.6.17(prettier@3.8.1))(typescript@5.8.3)(webpack@5.105.1)
typescript:
specifier: ^5.8.3
version: 5.8.3
@@ -672,9 +669,6 @@ packages:
resolution: {integrity: sha512-Hb4o6h1Pf6yRUAX07DR4JVY7dmQw+RVQMW5/m55GoiAT/VRoKCWBtIUPPOnqDVhbx1Cjfil9b6EDrgJsUAujEQ==}
engines: {node: '>= 10'}
- '@astrojs/compiler@2.13.0':
- resolution: {integrity: sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw==}
-
'@astrojs/compiler@2.13.1':
resolution: {integrity: sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==}
@@ -1749,10 +1743,6 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
- '@pkgr/core@0.2.9':
- resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
- engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
-
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -4209,16 +4199,6 @@ packages:
resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==}
hasBin: true
- astro-eslint-parser@1.2.2:
- resolution: {integrity: sha512-JepyLROIad6f44uyqMF6HKE2QbunNzp3mYKRcPoDGt0QkxXmH222FAFC64WTyQu2Kg8NNEXHTN/sWuUId9sSxw==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
- astrojs-compiler-sync@1.1.1:
- resolution: {integrity: sha512-0mKvB9sDQRIZPsEJadw6OaFbGJ92cJPPR++ICca9XEyiUAZqgVuk25jNmzHPT0KF80rI94trSZrUR5iHFXGGOQ==}
- engines: {node: ^18.18.0 || >=20.9.0}
- peerDependencies:
- '@astrojs/compiler': '>=0.27.0'
-
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
@@ -4923,12 +4903,6 @@ packages:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
- eslint-compat-utils@0.6.5:
- resolution: {integrity: sha512-vAUHYzue4YAa2hNACjB8HvUQj5yehAZgiClyFVVom9cP8z5NSFq3PwB/TtJslN2zAMgRX6FCFCjYBbQh71g5RQ==}
- engines: {node: '>=12'}
- peerDependencies:
- eslint: '>=6.0.0'
-
eslint-config-prettier@10.1.8:
resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==}
hasBin: true
@@ -4959,12 +4933,6 @@ packages:
eslint-import-resolver-webpack:
optional: true
- eslint-plugin-astro@1.5.0:
- resolution: {integrity: sha512-IWy4kY3DKTJxd7g652zIWpBGFuxw7NIIt16kyqc8BlhnIKvI8yGJj+Maua0DiNYED3F/D8AmzoTTTA6A95WX9g==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- peerDependencies:
- eslint: '>=8.57.0'
-
eslint-plugin-import@2.32.0:
resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==}
engines: {node: '>=4'}
@@ -6664,10 +6632,6 @@ packages:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
engines: {node: '>=4'}
- postcss-selector-parser@7.1.1:
- resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
- engines: {node: '>=4'}
-
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
@@ -7389,10 +7353,6 @@ packages:
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
- synckit@0.11.12:
- resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==}
- engines: {node: ^14.18.0 || >=16.0.0}
-
tailwind-merge@3.4.0:
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
@@ -8101,8 +8061,6 @@ snapshots:
'@ast-grep/napi-win32-ia32-msvc': 0.37.0
'@ast-grep/napi-win32-x64-msvc': 0.37.0
- '@astrojs/compiler@2.13.0': {}
-
'@astrojs/compiler@2.13.1':
optional: true
@@ -9291,8 +9249,6 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
- '@pkgr/core@0.2.9': {}
-
'@polka/url@1.0.0-next.29': {}
'@radix-ui/primitive@1.1.3': {}
@@ -12819,28 +12775,6 @@ snapshots:
astring@1.9.0: {}
- astro-eslint-parser@1.2.2:
- dependencies:
- '@astrojs/compiler': 2.13.0
- '@typescript-eslint/scope-manager': 8.53.1
- '@typescript-eslint/types': 8.53.1
- astrojs-compiler-sync: 1.1.1(@astrojs/compiler@2.13.0)
- debug: 4.4.3
- entities: 6.0.1
- eslint-scope: 8.4.0
- eslint-visitor-keys: 4.2.1
- espree: 10.4.0
- fast-glob: 3.3.3
- is-glob: 4.0.3
- semver: 7.7.3
- transitivePeerDependencies:
- - supports-color
-
- astrojs-compiler-sync@1.1.1(@astrojs/compiler@2.13.0):
- dependencies:
- '@astrojs/compiler': 2.13.0
- synckit: 0.11.12
-
async-function@1.0.0: {}
async@3.2.6: {}
@@ -13668,11 +13602,6 @@ snapshots:
escape-string-regexp@5.0.0: {}
- eslint-compat-utils@0.6.5(eslint@9.39.2(jiti@2.6.1)):
- dependencies:
- eslint: 9.39.2(jiti@2.6.1)
- semver: 7.7.3
-
eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)):
dependencies:
eslint: 9.39.2(jiti@2.6.1)
@@ -13695,20 +13624,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-plugin-astro@1.5.0(eslint@9.39.2(jiti@2.6.1)):
- dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
- '@jridgewell/sourcemap-codec': 1.5.5
- '@typescript-eslint/types': 8.53.1
- astro-eslint-parser: 1.2.2
- eslint: 9.39.2(jiti@2.6.1)
- eslint-compat-utils: 0.6.5(eslint@9.39.2(jiti@2.6.1))
- globals: 16.5.0
- postcss: 8.5.6
- postcss-selector-parser: 7.1.1
- transitivePeerDependencies:
- - supports-color
-
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1)):
dependencies:
'@rtsao/scc': 1.1.0
@@ -16023,11 +15938,6 @@ snapshots:
cssesc: 3.0.0
util-deprecate: 1.0.2
- postcss-selector-parser@7.1.1:
- dependencies:
- cssesc: 3.0.0
- util-deprecate: 1.0.2
-
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
@@ -16840,7 +16750,7 @@ snapshots:
- tslib
- webpack
- storybook-react-rsbuild@1.0.3(@rsbuild/core@1.7.3)(@storybook/test@8.6.15(storybook@8.6.17(prettier@3.8.1)))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.56.0)(storybook@8.6.17(prettier@3.8.1))(typescript@5.8.3)(webpack@5.105.1):
+ storybook-react-rsbuild@1.0.3(@rsbuild/core@1.7.3)(@storybook/test@8.6.15(storybook@8.6.17(prettier@3.8.1)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.56.0)(storybook@8.6.17(prettier@3.8.1))(typescript@5.8.3)(webpack@5.105.1):
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.56.0)
'@rsbuild/core': 1.7.3
@@ -17023,10 +16933,6 @@ snapshots:
symbol-tree@3.2.4: {}
- synckit@0.11.12:
- dependencies:
- '@pkgr/core': 0.2.9
-
tailwind-merge@3.4.0: {}
tailwindcss@4.1.18: {}
diff --git a/services/static/src/env.d.ts b/services/static/src/env.d.ts
index 21a969c..be5e30b 100644
--- a/services/static/src/env.d.ts
+++ b/services/static/src/env.d.ts
@@ -1,7 +1,6 @@
/**
* Type definitions for the static service
*/
-///
interface PerformanceNavigationTiming {
responseStatus?: number;
diff --git a/services/static/tsconfig.json b/services/static/tsconfig.json
index eaa5560..095a752 100644
--- a/services/static/tsconfig.json
+++ b/services/static/tsconfig.json
@@ -9,6 +9,6 @@
"@ermnvldmr/ui/*": ["../../../packages/ui/src/*"]
}
},
- "include": [".astro/types.d.ts", "src/**/*"],
+ "include": ["src/**/*"],
"exclude": ["dist", "node_modules", "**/*.config.*"]
}
diff --git a/services/www/content/articles/2026/joplin-web-deployment-guide.en.mdx b/services/www/content/articles/2026/joplin-web-deployment-guide.en.mdx
new file mode 100644
index 0000000..27d68bd
--- /dev/null
+++ b/services/www/content/articles/2026/joplin-web-deployment-guide.en.mdx
@@ -0,0 +1,227 @@
+---
+title: 'Joplin Web Deployment Guide'
+description: 'Joplin Server & Web App deployment from scratch'
+createdDate: 2026-03-20
+tags: ['joplin', 'deployment', 'guide']
+---
+
+This guide describes how to deploy a private note-taking infrastructure using the Joplin Server and its web client.
+
+## Why I Chose Joplin?
+
+I have been using Joplin for a long time, and it has become my primary note-taking tool. There are several reasons why I chose it over other alternatives:
+
+- **Local-First**: Notes are stored locally on your device, ensuring fast access and full ownership (especially when the internet is inaccessible).
+- **Markdown Format**: Joplin uses Markdown for all notes, making it incredibly easy to export, import, or move your data to another platform if needed.
+- **Easy Sharing**: The ability to share notes via the web makes collaboration or public sharing straightforward.
+- **Backups**: Joplin provides robust backup options, giving me peace of mind that my data is safe.
+
+One thing that was missing for me was the ability to quickly add or view notes when I don't have access to my primary devices (for example, at work). This article provides the solution: a self-hosted web client.
+
+## Technical Stack Overview
+
+1. **Joplin Server**: The synchronization server. It handles the Joplin sync protocol, account logic, and note synchronization across devices.
+2. **Postgres**: The database. Essential for production performance.
+3. **Joplin Web App**: A browser-based client built from the Joplin mobile app source code using React Native for Web.
+
+## 1. The Deployment Stack
+
+This configuration is generic, uses **Docker Volumes**, and provides a minimal setup for deploying the full stack.
+
+### Main docker-compose.yml
+
+This compose file is a minimal configuration to run the Joplin Server and the web app. It assumes you understand how to set up a reverse proxy, or better yet, already have your own Joplin setup.
+
+```yaml
+x-environment: &shared-environment
+ TZ: 'Europe/Moscow'
+ POSTGRES_DB: 'joplin'
+ POSTGRES_USER: ${SHARED_USERNAME?:error}
+ POSTGRES_PASSWORD: ${SHARED_PASSWORD?:error}
+
+services:
+ postgres:
+ image: postgres:16
+ container_name: joplin-postgres
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ environment:
+ <<: *shared-environment
+ restart: unless-stopped
+
+ server:
+ image: joplin/server:3.5.2
+ container_name: joplin-server
+ depends_on:
+ - postgres
+ environment:
+ <<: *shared-environment
+ APP_PORT: 22300
+ APP_BASE_URL: 'https://example.com'
+ DB_CLIENT: 'pg'
+ POSTGRES_HOST: 'joplin-postgres'
+ POSTGRES_PORT: 5432
+ POSTGRES_DATABASE: 'joplin'
+ ports:
+ - '22300:22300'
+ restart: unless-stopped
+
+ app:
+ build:
+ context: ./app
+ args:
+ - GIT_TAG=v3.5.13
+ container_name: joplin-app
+ environment:
+ <<: *shared-environment
+ ports:
+ - '8080:8080'
+ restart: unless-stopped
+
+volumes:
+ postgres_data:
+```
+
+### Dockerfile
+
+Create an `app` directory and place this `Dockerfile` inside it to build the web client. We build our own image since the original authors don't provide one.
+
+While there are repositories with pre-built images, it's easy enough to just create our own.
+
+```dockerfile
+# Stage 1: Build
+FROM node:20-bookworm-slim AS builder
+
+ARG GIT_TAG=v3.5.13
+
+# Install build dependencies for Joplin monorepo
+RUN apt-get update && apt-get install -y \
+ git python3 build-essential rsync \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+# Configure regional mirror and timeout for reliability
+RUN corepack enable && \
+ yarn config set registry https://registry.npmmirror.com
+
+# Clone specific tag
+RUN git clone --depth 1 --branch ${GIT_TAG} https://github.com/laurent22/joplin.git .
+
+# Install dependencies and build the web distribution
+RUN yarn install --network-timeout 1000000
+RUN cd packages/app-mobile/ && yarn web
+
+# Stage 2: Production Server
+FROM nginx:alpine
+
+# Remove default assets
+RUN rm -rf /usr/share/nginx/html/*
+
+# Copy built app and custom nginx config
+COPY --from=builder /app/packages/app-mobile/web/dist /usr/share/nginx/html/app
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+
+EXPOSE 8080
+CMD ["nginx", "-g", "daemon off;"]
+```
+
+### Nginx configuration
+
+Place this `nginx.conf` alongside the `Dockerfile` in the `app` directory. Joplin is an SPA, meaning it doesn't matter where it's deployed since all navigation is handled internally without location dependency. So it's alright to just deploy it on a path that doesn't overlap with the main server, such as `/app`.
+
+> This is a pretty minimal configuration; it assumes your reverse proxy is good enough to cover all questionable items, but you can expand it as you like.
+
+```nginx
+server {
+ listen 8080;
+ server_name _;
+
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # Gzip compression for performance
+ gzip on;
+ gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
+ gzip_min_length 1000;
+
+ # SPA Routing: Fall back to index.html if a file isn't found
+ location /app/ {
+ try_files $uri $uri/ /app/index.html;
+ }
+
+ # Cache static assets
+ location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg|webmanifest)$ {
+ expires 6M;
+ access_log off;
+ add_header Cache-Control "public, max-age=15552000, immutable";
+ }
+}
+```
+
+## 2. Configuration
+
+Create a `.env` file in the same directory as your `docker-compose.yml`. **Do not skip the secret generation step below.**
+
+```bash
+# --- Database Credentials ---
+SHARED_USERNAME=joplin
+SHARED_PASSWORD=generate_this_with_openssl
+```
+
+### Generating Secrets
+
+Use `openssl` to easily generate secure, random strings for your `.env` entries:
+
+```bash
+# Generate SHARED_PASSWORD
+openssl rand -hex 32
+```
+
+## 3. Deployment
+
+Start your stack by running the following command:
+
+```bash
+docker compose up -d --build
+```
+
+## 4. Initial Server Setup
+
+Before configuring the web app, you must initialize your Joplin Server:
+
+Follow the official guide: [Joplin Server README](https://github.com/laurent22/joplin/blob/dev/packages/server/README.md)
+
+Essentially, you need to ensure that basic synchronization via the app is functional.
+
+## 5. Web App Showcase & Setup
+
+Once the web app is running at `https://example.com/app/`, you will be greeted by it.
+
+### About the Web App
+
+
+
+It is basically the same mobile Joplin app, but in-browser. It leverages the LocalStorage API for data handling. Everything is still local-first, so after logging in, it will take some time to synchronize all remote notes.
+
+But most importantly: _The Joplin web interface allows you to view and edit your notes directly from the browser._
+
+> It is said that it's only guaranteed to work on Chrome due to the required API compatibility; however, I have had great success with Firefox as well. Browsers are catching up quickly.
+
+### Authentication Setup
+
+To connect to your self-hosted Joplin Server, go to **Settings > Synchronization** and configure the following parameters:
+
+- **Sync Target**: Joplin Server
+- **Joplin Server URL**: `https://example.com`
+- **Joplin Server Email**: `your-email@example.com` (the one you just created/configured)
+- **Joplin Server Password**: `your-password`
+
+
+
+_Enjoy!_
+
+## Resources
+
+- [Joplin GitHub](https://github.com/laurent22/joplin)
+- [Joplin Server README](https://github.com/laurent22/joplin/blob/dev/packages/server/README.md)
diff --git a/services/www/content/articles/2026/joplin-web-deployment-guide.ru.mdx b/services/www/content/articles/2026/joplin-web-deployment-guide.ru.mdx
new file mode 100644
index 0000000..1ea2374
--- /dev/null
+++ b/services/www/content/articles/2026/joplin-web-deployment-guide.ru.mdx
@@ -0,0 +1,227 @@
+---
+title: 'Руководство по развертыванию Joplin Web'
+description: 'Развертывание Joplin Server и веб-приложения с нуля'
+createdDate: 2026-03-20
+tags: ['joplin', 'deployment', 'guide']
+---
+
+Это руководство описывает, как развернуть приватную инфраструктуру для заметок, используя Joplin Server и его веб-клиент.
+
+## Почему я выбрал Joplin?
+
+Я давно пользуюсь Joplin, и он стал моим основным инструментом для ведения заметок. Есть несколько причин, по которым я выбрал его среди других альтернатив:
+
+- **Local-First**: Заметки хранятся локально на вашем устройстве, что обеспечивает быстрый доступ и полное владение данными (особенно когда интернет недоступен).
+- **Формат Markdown**: Joplin использует Markdown для всех заметок, что позволяет невероятно легко экспортировать, импортировать или переносить данные на другую платформу при необходимости.
+- **Удобный обмен**: Возможность делиться заметками через веб делает совместную работу или публичный доступ простыми.
+- **Резервное копирование**: Joplin предоставляет надежные варианты резервного копирования, давая мне уверенность в сохранности моих данных.
+
+Одной вещи мне не хватало — возможности быстро добавить или просмотреть заметки, когда у меня нет доступа к моим основным устройствам (например, на работе). Эта статья предлагает решение: самостоятельно размещенный (self-hosted) веб-клиент.
+
+## Обзор технологического стека
+
+1. **Joplin Server**: Сервер синхронизации. Он обрабатывает протокол синхронизации Joplin, логику учетных записей и синхронизацию заметок между устройствами.
+2. **Postgres**: База данных. Необходима для производительности в рабочей среде.
+3. **Joplin Web App**: Браузерный клиент, созданный из исходного кода мобильного приложения Joplin с использованием React Native for Web.
+
+## 1. Стек развертывания
+
+Эта конфигурация является универсальной, использует **Docker Volumes** и предоставляет минимальную настройку для развертывания полного стека.
+
+### Основной docker-compose.yml
+
+Этот compose-файл представляет собой минимальную конфигурацию для запуска Joplin Server и веб-приложения. Предполагается, что вы понимаете, как настроить обратный прокси-сервер, или, что еще лучше, у вас уже есть собственная установка Joplin.
+
+```yaml
+x-environment: &shared-environment
+ TZ: 'Europe/Moscow'
+ POSTGRES_DB: 'joplin'
+ POSTGRES_USER: ${SHARED_USERNAME?:error}
+ POSTGRES_PASSWORD: ${SHARED_PASSWORD?:error}
+
+services:
+ postgres:
+ image: postgres:16
+ container_name: joplin-postgres
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ environment:
+ <<: *shared-environment
+ restart: unless-stopped
+
+ server:
+ image: joplin/server:3.5.2
+ container_name: joplin-server
+ depends_on:
+ - postgres
+ environment:
+ <<: *shared-environment
+ APP_PORT: 22300
+ APP_BASE_URL: 'https://example.com'
+ DB_CLIENT: 'pg'
+ POSTGRES_HOST: 'joplin-postgres'
+ POSTGRES_PORT: 5432
+ POSTGRES_DATABASE: 'joplin'
+ ports:
+ - '22300:22300'
+ restart: unless-stopped
+
+ app:
+ build:
+ context: ./app
+ args:
+ - GIT_TAG=v3.5.13
+ container_name: joplin-app
+ environment:
+ <<: *shared-environment
+ ports:
+ - '8080:8080'
+ restart: unless-stopped
+
+volumes:
+ postgres_data:
+```
+
+### Dockerfile
+
+Создайте директорию `app` и поместите в нее этот `Dockerfile` для сборки веб-клиента. Мы собираем собственный образ, так как авторы оригинала его не предоставляют.
+
+Хотя существуют репозитории с готовыми образами, создать свой достаточно просто.
+
+```dockerfile
+# Stage 1: Build
+FROM node:20-bookworm-slim AS builder
+
+ARG GIT_TAG=v3.5.13
+
+# Install build dependencies for Joplin monorepo
+RUN apt-get update && apt-get install -y \
+ git python3 build-essential rsync \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+# Configure regional mirror and timeout for reliability
+RUN corepack enable && \
+ yarn config set registry https://registry.npmmirror.com
+
+# Clone specific tag
+RUN git clone --depth 1 --branch ${GIT_TAG} https://github.com/laurent22/joplin.git .
+
+# Install dependencies and build the web distribution
+RUN yarn install --network-timeout 1000000
+RUN cd packages/app-mobile/ && yarn web
+
+# Stage 2: Production Server
+FROM nginx:alpine
+
+# Remove default assets
+RUN rm -rf /usr/share/nginx/html/*
+
+# Copy built app and custom nginx config
+COPY --from=builder /app/packages/app-mobile/web/dist /usr/share/nginx/html/app
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+
+EXPOSE 8080
+CMD ["nginx", "-g", "daemon off;"]
+```
+
+### Конфигурация Nginx
+
+Поместите этот `nginx.conf` рядом с `Dockerfile` в директории `app`. Joplin — это SPA (Single Page Application), а значит, не имеет значения, где оно развернуто, так как вся навигация обрабатывается внутренне без зависимости от местоположения. Поэтому вполне нормально развернуть его по пути, который не пересекается с основным сервером, например `/app`.
+
+> Это довольно минималистичная конфигурация; предполагается, что ваш обратный прокси достаточно хорош, чтобы закрыть все спорные моменты, но вы можете расширить ее по своему усмотрению.
+
+```nginx
+server {
+ listen 8080;
+ server_name _;
+
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # Gzip-сжатие для производительности
+ gzip on;
+ gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
+ gzip_min_length 1000;
+
+ # SPA-роутинг: возврат к index.html, если файл не найден
+ location /app/ {
+ try_files $uri $uri/ /app/index.html;
+ }
+
+ # Кэширование статических ресурсов
+ location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg|webmanifest)$ {
+ expires 6M;
+ access_log off;
+ add_header Cache-Control "public, max-age=15552000, immutable";
+ }
+}
+```
+
+## 2. Конфигурация
+
+Создайте файл `.env` в той же директории, что и `docker-compose.yml`. **Не пропускайте шаг генерации секретов ниже.**
+
+```bash
+# --- Учетные данные базы данных ---
+SHARED_USERNAME=joplin
+SHARED_PASSWORD=generate_this_with_openssl
+```
+
+### Генерация секретов
+
+Используйте `openssl`, чтобы легко создать безопасные случайные строки для записей в вашем `.env`:
+
+```bash
+# Генерация SHARED_PASSWORD
+openssl rand -hex 32
+```
+
+## 3. Развертывание
+
+Запустите стек, выполнив следующую команду:
+
+```bash
+docker compose up -d --build
+```
+
+## 4. Начальная настройка сервера
+
+Перед настройкой веб-приложения необходимо инициализировать ваш Joplin Server:
+
+Следуйте официальному руководству: [Joplin Server README](https://github.com/laurent22/joplin/blob/dev/packages/server/README.md)
+
+По сути, вам нужно убедиться, что базовая синхронизация через приложение работает.
+
+## 5. Демонстрация и настройка веб-приложения
+
+Как только веб-приложение запустится по адресу `https://example.com/app/`, оно поприветствует вас.
+
+### О веб-приложении
+
+
+
+По сути, это то же самое мобильное приложение Joplin, но в браузере. Оно использует LocalStorage API для обработки данных. Все по-прежнему работает по принципу local-first, поэтому после входа в систему потребуется время для синхронизации всех удаленных заметок.
+
+Но самое главное: _Веб-интерфейс Joplin позволяет просматривать и редактировать заметки прямо из браузера._
+
+> Говорят, что работа гарантирована только в Chrome из-за требований к совместимости API; однако я добился отличных результатов и в Firefox. Браузеры быстро догоняют друг друга.
+
+### Настройка аутентификации
+
+Чтобы подключиться к вашему self-hosted серверу Joplin, перейдите в **Settings > Synchronization** и настройте следующие параметры:
+
+- **Sync Target**: Joplin Server
+- **Joplin Server URL**: `https://example.com`
+- **Joplin Server Email**: `your-email@example.com` (тот, который вы только что создали/настроили)
+- **Joplin Server Password**: `your-password`
+
+
+
+_Наслаждайтесь!_
+
+## Ресурсы
+
+- [Joplin GitHub](https://github.com/laurent22/joplin)
+- [Joplin Server README](https://github.com/laurent22/joplin/blob/dev/packages/server/README.md)
diff --git a/services/www/content/articles/2026/xray-core-subnet-mimicry.en.mdx b/services/www/content/articles/2026/xray-core-subnet-mimicry.en.mdx
index 50cafca..8481b8c 100644
--- a/services/www/content/articles/2026/xray-core-subnet-mimicry.en.mdx
+++ b/services/www/content/articles/2026/xray-core-subnet-mimicry.en.mdx
@@ -1,182 +1,154 @@
---
-title: 'Xray Subnet Mimicry'
-description: 'Building a distributed 10.0.0.0/8 private network with Xray-core'
-createdDate: 2026-03-08
-tags: ['networking', 'guide']
+title: 'Xray Subnet Mimicry (10.0.0.0/8)'
+description: 'Building a distributed private network with Xray-core and reverse tunneling'
+createdDate: 2026-03-20
+tags: ['networking', 'xray', 'guide']
---
-> This guide is for **educational purposes** only. It is designed as a technical demonstration of Xray-core's routing capabilities and private subnet mimicry. It is NOT intended for restriction evasion or illegal activities. Use this knowledge responsibly and in compliance with your local laws.
+> **EDUCATIONAL PURPOSES ONLY**
+> This project is designed as a technical demonstration of Xray-core's routing capabilities and private subnet mimicry. It is NOT intended for restriction evasion or illegal activities. Use this knowledge responsibly and in compliance with your local laws.
-## Why Subnet Mimicry?
+This guide describes how to deploy a private note-taking infrastructure (or any distributed service) using a contiguous `10.0.0.0/8` private subnet mimicked across distinct servers using **Xray-core** and **reverse tunneling**.
-By constructing a "Flat Network" (Layer 3) over the public internet, you achieve a unified private address space (`10.0.0.0/8`) distributed across distinct servers.
+## Technical Deep-Dive & Architecture
-- **Distributed Topology**: Each node is an authoritative gateway for its own `/16` segment (e.g., Hub on `10.1.0.0/16`, Spokes on `10.2.0.0/16` and `10.3.0.0/16`).
-- **NAT Traversal**: Spokes (Bridges) actively initiate connections to the Hub (Portal), allowing nodes behind NAT to be reachable.
-- **Centralized Resolution**: Enforce network-wide DNS policies through a central Hub resolver.
+### The Virtual Subnet Concept
----
+We are constructing a "Flat Network" (Layer 3) over the public internet. Each node acts as an authoritative gateway for its own `/16` segment of the `10.0.0.0/8` range. This allows seamless communication between servers as if they were on the same local network.
-## Technical Stack Overview
+### The Science of Interception (TPROXY)
-1. **Xray-core**: The routing engine handling VLESS connections, reverse tunneling, and TPROXY interception.
-2. **TPROXY**: Transparent proxying in the Linux Mangle table that intercepts TCP/UDP traffic while preserving original destination IPs.
-3. **Policy Routing**: Using `ip rule` and routing Table 100 to force intercepted packets into the local loopback for Xray to process.
-4. **Mux.cool**: A multiplexing protocol that packs hundreds of virtual connections (TCP/UDP streams) into a single physical TLS connection.
-5. **AdGuard Home**: The centralized DNS resolver for the entire subnet.
-6. **Nginx**: Used as camouflage on the main public inbound port to hide the Xray instance.
+Unlike `REDIRECT`, which only works for TCP and modifies the destination IP, **TPROXY** (Transparent Proxy) allows Xray to intercept both TCP and UDP while preserving the original packet headers.
----
+- **Mechanism**: When a packet destined for `10.x.x.x` enters the `PREROUTING` or `OUTPUT` chain, the Linux Mangle table marks it with `0x1`.
+- **Preservation**: Xray's `tunnel` inbound uses the `tproxy` socket option to read the original destination IP from the socket metadata, enabling it to route the packet correctly.
-## 1. The Deployment Stack
+### Policy Routing (Table 100)
-This configuration represents the Central Hub (Server A) which acts as the Portal for Spoke connections. The Hub hosts the centralized DNS and the fallback Nginx camouflage.
+While firewall marks identify intercepted packets, they don't inherently change the routing path. To redirect this traffic to Xray, we use **Policy Routing**:
-```yaml
-name: 'a'
+1. **The Rule (`ip rule`)**: `ip rule add fwmark 1 lookup 100` instructs the networking stack to use a custom routing table for marked packets.
+2. **The Table (`ip route`)**: Table 100 is configured with `local default dev lo`, treating all destination IPs as local and routing them to the loopback interface.
+3. **The Handoff**: Xray, listening on `127.0.0.1:12345` with the `tproxy` option, intercepts the packet from the loopback interface.
+4. **Loop Prevention**: We use `mark: 255` in Xray's outbounds to exempt its own traffic from interception, avoiding infinite routing loops.
-services:
- xray:
- image: ghcr.io/xtls/xray-core:26.1.23
- container_name: a-xray
- network_mode: host
- volumes:
- - ./xray.jsonc:/etc/xray/config.jsonc:ro
- - /etc/letsencrypt/live:/etc/letsencrypt/live:ro
- command: -c '/etc/xray/config.jsonc'
- restart: unless-stopped
- cap_add:
- - NET_ADMIN
- - NET_RAW
+### The Reverse Proxy Mechanism
- nginx:
- image: nginxinc/nginx-unprivileged:1.29-alpine
- container_name: a-nginx
- networks:
- direct:
- ipv4_address: 10.1.1.4
- restart: unless-stopped
- security_opt:
- - no-new-privileges:true
+To reach servers behind NAT (Spokes) from a central Hub, Xray employs a sophisticated Reverse Proxy architecture:
- adguard:
- image: adguard/adguardhome:v0.107.72
- container_name: root-adguard
- volumes:
- - adguard-work:/opt/adguardhome/work
- - adguard-conf:/opt/adguardhome/conf
- networks:
- direct:
- ipv4_address: 10.1.1.3
- restart: unless-stopped
- cap_add:
- - NET_BIND_SERVICE
- - SYS_NICE
- - CAP_CHOWN
- security_opt:
- - no-new-privileges:true
+- **Bridge (Spoke)**: Initiates the connection to the Portal. It acts as a client for the tunnel but functionally behaves as a server once the connection is established.
+- **Portal (Hub)**: Listens for Bridge connections and uses "Dummy Domains" to identify them, relaying traffic through the established tunnels.
+- **Multiplexing (Mux)**: Uses [Mux.cool](https://xtls.github.io/en/development/protocols/muxcool.html) to pack hundreds of virtual streams into a single physical TLS connection, reducing overhead and bypassing NAT timeouts.
-networks:
- direct:
- name: a-direct
- driver: bridge
- ipam:
- config:
- - subnet: 10.1.0.0/16 # Using the full /16 as defined for Server A
- gateway: 10.1.0.1
+## Setup Guide
-volumes:
- adguard-work:
- name: a-adguard-work
- adguard-conf:
- name: a-adguard-conf
-```
+### Phase 0: Prerequisites (Server A only)
----
+Server A (the Hub) requires a valid SSL certificate for XTLS-Vision. Point your domain (`A-DOMAIN`) to Server A's public IP and ensure certificates are available (e.g., via Certbot) at `/etc/letsencrypt/live/A-DOMAIN/`.
-## 2. Configuration & Routing
+### Phase 1: Security & Identity
-### Generating Identity UUIDs
-
-Each connection needs its own cryptographic "Key". You need 4 UUIDs mapped to your config files.
-
-```bash
-# Generate UUIDs for the Server B and Server C mappings
-docker run --rm ghcr.io/xtls/xray-core uuid
-```
-
-The mapping is as follows:
+Each connection requires a unique UUID. Generate 4 UUIDs to map the following connections:
- `UUID_B_REVERSE`: Tunnel control for Server B.
- `UUID_B_DIRECT`: Data traffic for Server B.
- `UUID_C_REVERSE`: Tunnel control for Server C.
- `UUID_C_DIRECT`: Data traffic for Server C.
-### Policy Routing (Table 100)
+### Phase 2: Host Networking
-Apply the routing configuration script on **EVERY** server node to set up the kernel parameters and routing Table 100. This tricks the kernel into handing the packet to our TPROXY inbound listening on `127.0.0.1:12345`.
+On **EVERY** server node, perform the following:
-```bash
-#!/usr/bin/env bash
+1. **Initialize Routing**: Run the routing setup script to configure kernel parameters and Table 100.
+2. **Firewall Configuration**: Update `/etc/ufw/before.rules`. Insert the specific node rules immediately after the `*filter` header. These rules handle the `mangle` table logic required for TPROXY.
+3. **Reload**: Apply changes with `sudo ufw reload`.
-# 1. Enable IP Forwarding
-sudo sysctl -w net.ipv4.ip_forward=1
+### Phase 3: Deployment
-# 2. IP Rule for mark 0x1 -> table 100
-sudo ip rule add fwmark 0x1 lookup 100 priority 100
+Update your `xray.jsonc` configurations with the generated UUIDs, your domain, and public IPs. Launch the stack using `docker compose up -d`. On Server A, the compose setup will also initialize the internal bridge network for services like AdGuard and Nginx.
-# 3. Local route in table 100
-sudo ip route add local 0.0.0.0/0 dev lo table 100
+## Configuration Examples
+
+### Server A: Hub (10.1.0.0/16)
+
+The Hub handles reverse tunnel entries and routes traffic to the appropriate Spoke based on the 10.x.x.x range.
+Full configuration available here: [a/xray.jsonc](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/a/xray.jsonc)
+
+```jsonc
+{
+ "inbounds": [
+ {
+ "port": 443,
+ "protocol": "vless",
+ "tag": "proxy-direct",
+ "settings": {
+ "clients": [
+ { "id": "REPLACE_WITH_UUID_B_REVERSE", "reverse": { "tag": "reverse-b" } },
+ { "id": "REPLACE_WITH_UUID_B_DIRECT" },
+ // ... repeat for other Spokes
+ ],
+ },
+ },
+ {
+ "tag": "proxy-enclave",
+ "protocol": "tunnel",
+ "streamSettings": { "sockopt": { "tproxy": "tproxy", "mark": 255 } },
+ },
+ ],
+ "routing": {
+ "rules": [
+ { "ip": ["10.2.0.0/16"], "outboundTag": "reverse-b" },
+ { "ip": ["10.3.0.0/16"], "outboundTag": "reverse-c" },
+ ],
+ },
+}
```
----
+### Server B: Spoke (10.2.0.0/16)
-## 3. Firewall Interception
+The Spoke initiates the reverse connection to the Hub and handles local subnet breakout.
+Full configuration available here: [b/xray.jsonc](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/b/xray.jsonc)
-Open `/etc/ufw/before.rules` and insert the specific node rules immediately after the `*filter` header requirements. Below is the configuration for Server A (Hub, `10.1.0.0/16`):
-
-```text
-*mangle
-:XRAY - [0:0]
-:XRAY_MASK - [0:0]
-:DIVERT - [0:0]
-
-# --- PREROUTING CHAIN (Traffic from other containers/nodes) ---
--A XRAY -d 10.1.0.0/16 -j RETURN
--A XRAY ! -d 10.0.0.0/8 -j RETURN
--A XRAY -m mark --mark 0xff -j RETURN
--A XRAY -p udp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
--A XRAY -p tcp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
--A PREROUTING -j XRAY
-
-# --- OUTPUT CHAIN (Traffic originating from the server itself) ---
--A XRAY_MASK -d 10.1.0.0/16 -j RETURN
--A XRAY_MASK ! -d 10.0.0.0/8 -j RETURN
--A XRAY_MASK -m mark --mark 0xff -j RETURN
--A XRAY_MASK -p udp -j MARK --set-mark 1
--A XRAY_MASK -p tcp -j MARK --set-mark 1
--A OUTPUT -j XRAY_MASK
-
-# --- DIVERT Optimization ---
--A DIVERT -j MARK --set-mark 1
--A DIVERT -j ACCEPT
--I PREROUTING -p tcp -m socket -j DIVERT
-
-COMMIT
+```jsonc
+{
+ "outbounds": [
+ {
+ "protocol": "vless",
+ "tag": "forward-a-reverse",
+ "settings": {
+ "address": "A-EXTERNAL-IP",
+ "id": "REPLACE_WITH_UUID_B_REVERSE",
+ "reverse": { "tag": "reverse-b" },
+ },
+ },
+ {
+ "protocol": "vless",
+ "tag": "forward-a",
+ "settings": {
+ "address": "A-EXTERNAL-IP",
+ "id": "REPLACE_WITH_UUID_B_DIRECT",
+ },
+ },
+ ],
+ "routing": {
+ "rules": [
+ { "inboundTag": ["proxy-enclave"], "outboundTag": "forward-a" },
+ { "inboundTag": ["reverse-b"], "outboundTag": "forward-direct" },
+ { "ip": ["10.2.0.0/16"], "outboundTag": "forward-direct" },
+ { "ip": ["10.0.0.0/8"], "outboundTag": "forward-a" },
+ ],
+ },
+}
```
-Reload UFW once applied:
+## Resources & Reference
-```bash
-sudo ufw reload
-```
+For a complete, automated example of this architecture, check out the following repository:
----
+- [xray-core-subnet-mimicry on GitHub](https://github.com/deytenit/xray-core-subnet-mimicry/tree/main)
-## Resources
+Additional technical references:
- [Xray-core Official Documentation](https://xtls.github.io/)
-- [Xray Reverse Proxy Documentation](https://xtls.github.io/en/config/reverse.html)
-- [Project X (Xray) GitHub](https://github.com/XTLS/Xray-core)
+- [Understanding TPROXY (Cloudflare Blog)](https://blog.cloudflare.com/how-we-built-spectrum/)
- [Linux Kernel TPROXY Documentation](https://www.kernel.org/doc/Documentation/networking/tproxy.txt)
-- [Cloudflare Blog: TPROXY](https://blog.cloudflare.com/how-we-built-spectrum/)
diff --git a/services/www/content/articles/2026/xray-core-subnet-mimicry.ru.mdx b/services/www/content/articles/2026/xray-core-subnet-mimicry.ru.mdx
index ec8b048..151b7f4 100644
--- a/services/www/content/articles/2026/xray-core-subnet-mimicry.ru.mdx
+++ b/services/www/content/articles/2026/xray-core-subnet-mimicry.ru.mdx
@@ -1,182 +1,154 @@
---
-title: 'Мимикрия подсетей Xray'
-description: 'Построение распределённой приватной сети 10.0.0.0/8 с использованием Xray-core'
-createdDate: 2026-03-08
-tags: ['networking', 'guide']
+title: 'Имитация подсети Xray (10.0.0.0/8)'
+description: 'Создание распределенной частной сети с использованием Xray-core и обратного туннелирования'
+createdDate: 2026-03-20
+tags: ['networking', 'xray', 'guide']
---
-> Это руководство предназначено **исключительно для образовательных целей**. Оно демонстрирует маршрутизацию в Xray-core и подход к мимикрии приватной подсети. Материал НЕ предназначен для обхода ограничений или незаконной деятельности. Используйте эти знания ответственно и в соответствии с местным законодательством.
+> **ТОЛЬКО ДЛЯ ОБРАЗОВАТЕЛЬНЫХ ЦЕЛЕЙ**
+> Этот проект разработан как техническая демонстрация возможностей маршрутизации Xray-core и имитации частных подсетей. Он НЕ предназначен для обхода ограничений или незаконной деятельности. Используйте эти знания ответственно и в соответствии с местным законодательством.
-## Зачем нужна мимикрия подсети?
+Это руководство описывает, как развернуть распределенную инфраструктуру (например, для заметок или любого другого сервиса), используя непрерывную частную подсеть `10.0.0.0/8`, имитируемую на различных серверах с помощью **Xray-core** и **обратного туннелирования**.
-Если построить "плоскую сеть" (Layer 3) поверх публичного интернета, можно получить единое приватное адресное пространство (`10.0.0.0/8`), распределенное между несколькими отдельными серверами.
+## Технический разбор и архитектура
-- **Распределенная топология**: каждый узел выступает в роли основного шлюза для своего сегмента `/16` (например, Hub для `10.1.0.0/16`, Spoke для `10.2.0.0/16` и `10.3.0.0/16`).
-- **Преодоление NAT**: Spoke-узлы (Bridges) сами инициируют соединения с Hub-узлом (Portal), благодаря чему даже узлы за NAT остаются доступными.
-- **Централизованное разрешение DNS**: сетевые DNS-политики единообразно применяются через центральный резолвер на Hub.
+### Концепция виртуальной подсети
----
+Мы создаем «плоскую сеть» (Layer 3) поверх публичного интернета. Каждый узел выступает в качестве полномочного шлюза для своего собственного сегмента `/16` из диапазона `10.0.0.0/8`. Это обеспечивает бесперебойную связь между серверами, как если бы они находились в одной локальной сети.
-## Обзор технического стека
+### Наука перехвата (TPROXY)
-1. **Xray-core**: движок маршрутизации для VLESS-соединений, reverse-туннелей и TPROXY-перехвата.
-2. **TPROXY**: прозрачное проксирование в таблице Linux Mangle, которое перехватывает TCP/UDP-трафик и сохраняет исходный IP назначения.
-3. **Policy Routing**: использование `ip rule` и таблицы маршрутизации 100 для принудительной отправки перехваченных пакетов в локальный loopback, где их обрабатывает Xray.
-4. **Mux.cool**: протокол мультиплексирования, который упаковывает сотни виртуальных соединений (TCP/UDP-потоков) в одно физическое TLS-соединение.
-5. **AdGuard Home**: централизованный DNS-резолвер для всей подсети.
-6. **Nginx**: используется как маскировка на основном публичном входном порту, чтобы скрыть инстанс Xray.
+В отличие от `REDIRECT`, который работает только для TCP и изменяет IP-адрес назначения, **TPROXY** (Transparent Proxy) позволяет Xray перехватывать как TCP, так и UDP, сохраняя при этом исходные заголовки пакетов.
----
+- **Механизм**: Когда пакет, предназначенный для `10.x.x.x`, попадает в цепочку `PREROUTING` или `OUTPUT`, таблица Linux Mangle помечает его меткой `0x1`.
+- **Сохранение**: Входящий поток `tunnel` в Xray использует опцию сокета `tproxy` для чтения исходного IP-адреса назначения из метаданных сокета, что позволяет правильно маршрутизировать пакет.
-## 1. Стек развертывания
+### Политика маршрутизации (Таблица 100)
-Эта конфигурация описывает центральный Hub (сервер A), который выступает как Portal для подключений Spoke-узлов. На Hub размещены централизованный DNS и fallback-маскировка через Nginx.
+Хотя метки брандмауэра идентифицируют перехваченные пакеты, они сами по себе не изменяют путь маршрутизации. Чтобы перенаправить этот трафик в Xray, мы используем **Policy Routing**:
-```yaml
-name: 'a'
+1. **Правило (`ip rule`)**: `ip rule add fwmark 1 lookup 100` предписывает сетевому стеку использовать пользовательскую таблицу маршрутизации для помеченных пакетов.
+2. **Таблица (`ip route`)**: Таблица 100 настроена с параметром `local default dev lo`, что заставляет систему рассматривать все IP-адреса назначения как локальные и направлять их на интерфейс петли (loopback).
+3. **Передача**: Xray, прослушивающий `127.0.0.1:12345` с опцией `tproxy`, перехватывает пакет с интерфейса loopback.
+4. **Предотвращение петель**: Мы используем `mark: 255` в исходящих потоках Xray, чтобы исключить его собственный трафик из перехвата, предотвращая бесконечные циклы маршрутизации.
-services:
- xray:
- image: ghcr.io/xtls/xray-core:26.1.23
- container_name: a-xray
- network_mode: host
- volumes:
- - ./xray.jsonc:/etc/xray/config.jsonc:ro
- - /etc/letsencrypt/live:/etc/letsencrypt/live:ro
- command: -c '/etc/xray/config.jsonc'
- restart: unless-stopped
- cap_add:
- - NET_ADMIN
- - NET_RAW
+### Механизм обратного прокси
- nginx:
- image: nginxinc/nginx-unprivileged:1.29-alpine
- container_name: a-nginx
- networks:
- direct:
- ipv4_address: 10.1.1.4
- restart: unless-stopped
- security_opt:
- - no-new-privileges:true
+Для доступа к серверам за NAT (Spokes) из центрального хаба Xray использует сложную архитектуру обратного прокси:
- adguard:
- image: adguard/adguardhome:v0.107.72
- container_name: root-adguard
- volumes:
- - adguard-work:/opt/adguardhome/work
- - adguard-conf:/opt/adguardhome/conf
- networks:
- direct:
- ipv4_address: 10.1.1.3
- restart: unless-stopped
- cap_add:
- - NET_BIND_SERVICE
- - SYS_NICE
- - CAP_CHOWN
- security_opt:
- - no-new-privileges:true
+- **Bridge (Spoke)**: Инициирует соединение с порталом. Он действует как клиент для туннеля, но функционально ведет себя как сервер после установления соединения.
+- **Portal (Hub)**: Прослушивает входящие соединения от Bridge и использует «фиктивные домены» (Dummy Domains) для их идентификации, ретранслируя трафик через установленные туннели.
+- **Мультиплексирование (Mux)**: Использует [Mux.cool](https://xtls.github.io/en/development/protocols/muxcool.html) для упаковки сотен виртуальных потоков в одно физическое TLS-соединение, что снижает накладные расходы и позволяет обходить тайм-ауты NAT.
-networks:
- direct:
- name: a-direct
- driver: bridge
- ipam:
- config:
- - subnet: 10.1.0.0/16 # Using the full /16 as defined for Server A
- gateway: 10.1.0.1
+## Руководство по настройке
-volumes:
- adguard-work:
- name: a-adguard-work
- adguard-conf:
- name: a-adguard-conf
+### Этап 0: Предварительные требования (только для сервера А)
+
+Серверу А (Hub) требуется действующий SSL-сертификат для XTLS-Vision. Направьте ваш домен (`A-DOMAIN`) на публичный IP сервера А и убедитесь, что сертификаты доступны (например, через Certbot) по адресу `/etc/letsencrypt/live/A-DOMAIN/`.
+
+### Этап 1: Безопасность и идентификация
+
+Для каждого соединения требуется уникальный UUID. Сгенерируйте 4 UUID для сопоставления следующих соединений:
+
+- `UUID_B_REVERSE`: Управление туннелем для сервера B.
+- `UUID_B_DIRECT`: Трафик данных для сервера B.
+- `UUID_C_REVERSE`: Управление туннелем для сервера C.
+- `UUID_C_DIRECT`: Трафик данных для сервера C.
+
+### Этап 2: Настройка хоста
+
+На **КАЖДОМ** узле сервера выполните следующее:
+
+1. **Инициализация маршрутизации**: Запустите скрипт настройки маршрутизации для конфигурации параметров ядра и таблицы 100.
+2. **Настройка брандмауэра**: Обновите `/etc/ufw/before.rules`. Вставьте специфические правила узла сразу после заголовка `*filter`. Эти правила обрабатывают логику таблицы `mangle`, необходимую для TPROXY.
+3. **Перезагрузка**: Примените изменения с помощью `sudo ufw reload`.
+
+### Этап 3: Развертывание
+
+Обновите конфигурации `xray.jsonc`, указав сгенерированные UUID, ваш домен и публичные IP-адреса. Запустите стек с помощью `docker compose up -d`. На сервере А установка compose также инициализирует внутреннюю мостовую сеть для таких сервисов, как AdGuard и Nginx.
+
+## Примеры конфигурации
+
+### Сервер A: Хаб (10.1.0.0/16)
+
+Хаб обрабатывает входы обратных туннелей и маршрутизирует трафик в соответствующий Spoke на основе диапазона 10.x.x.x.
+Полная конфигурация доступна здесь: [a/xray.jsonc](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/a/xray.jsonc)
+
+```jsonc
+{
+ "inbounds": [
+ {
+ "port": 443,
+ "protocol": "vless",
+ "tag": "proxy-direct",
+ "settings": {
+ "clients": [
+ { "id": "REPLACE_WITH_UUID_B_REVERSE", "reverse": { "tag": "reverse-b" } },
+ { "id": "REPLACE_WITH_UUID_B_DIRECT" },
+ // ... повторите для других Spokes
+ ],
+ },
+ },
+ {
+ "tag": "proxy-enclave",
+ "protocol": "tunnel",
+ "streamSettings": { "sockopt": { "tproxy": "tproxy", "mark": 255 } },
+ },
+ ],
+ "routing": {
+ "rules": [
+ { "ip": ["10.2.0.0/16"], "outboundTag": "reverse-b" },
+ { "ip": ["10.3.0.0/16"], "outboundTag": "reverse-c" },
+ ],
+ },
+}
```
----
+### Сервер B: Узел Spoke (10.2.0.0/16)
-## 2. Конфигурация и маршрутизация
+Spoke инициирует обратное соединение с Хабом и обрабатывает выход в локальную подсеть.
+Полная конфигурация доступна здесь: [b/xray.jsonc](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/b/xray.jsonc)
-### Генерация UUID-идентификаторов
-
-Каждому соединению нужен собственный криптографический "ключ". Для сопоставления с конфигами вам понадобятся 4 UUID.
-
-```bash
-# Generate UUIDs for the Server B and Server C mappings
-docker run --rm ghcr.io/xtls/xray-core uuid
+```jsonc
+{
+ "outbounds": [
+ {
+ "protocol": "vless",
+ "tag": "forward-a-reverse",
+ "settings": {
+ "address": "A-EXTERNAL-IP",
+ "id": "REPLACE_WITH_UUID_B_REVERSE",
+ "reverse": { "tag": "reverse-b" },
+ },
+ },
+ {
+ "protocol": "vless",
+ "tag": "forward-a",
+ "settings": {
+ "address": "A-EXTERNAL-IP",
+ "id": "REPLACE_WITH_UUID_B_DIRECT",
+ },
+ },
+ ],
+ "routing": {
+ "rules": [
+ { "inboundTag": ["proxy-enclave"], "outboundTag": "forward-a" },
+ { "inboundTag": ["reverse-b"], "outboundTag": "forward-direct" },
+ { "ip": ["10.2.0.0/16"], "outboundTag": "forward-direct" },
+ { "ip": ["10.0.0.0/8"], "outboundTag": "forward-a" },
+ ],
+ },
+}
```
-Соответствие такое:
+## Ресурсы и ссылки
-- `UUID_B_REVERSE`: управляющий туннель для сервера B.
-- `UUID_B_DIRECT`: пользовательский трафик сервера B.
-- `UUID_C_REVERSE`: управляющий туннель для сервера C.
-- `UUID_C_DIRECT`: пользовательский трафик сервера C.
+Для получения полного автоматизированного примера этой архитектуры посетите следующий репозиторий:
-### Маршрутизация по политикам (таблица 100)
+- [xray-core-subnet-mimicry на GitHub](https://github.com/deytenit/xray-core-subnet-mimicry/tree/main)
-Примените скрипт маршрутизации на **каждом** серверном узле, чтобы настроить параметры ядра и таблицу маршрутизации 100. Это заставляет ядро передавать пакет в наш TPROXY inbound, который слушает `127.0.0.1:12345`.
-
-```bash
-#!/usr/bin/env bash
-
-# 1. Enable IP Forwarding
-sudo sysctl -w net.ipv4.ip_forward=1
-
-# 2. IP Rule for mark 0x1 -> table 100
-sudo ip rule add fwmark 0x1 lookup 100 priority 100
-
-# 3. Local route in table 100
-sudo ip route add local 0.0.0.0/0 dev lo table 100
-```
-
----
-
-## 3. Перехват в firewall
-
-Откройте `/etc/ufw/before.rules` и сразу после обязательного заголовка `*filter` добавьте правила конкретного узла. Ниже пример для сервера A (Hub, `10.1.0.0/16`):
-
-```text
-*mangle
-:XRAY - [0:0]
-:XRAY_MASK - [0:0]
-:DIVERT - [0:0]
-
-# --- PREROUTING CHAIN (Traffic from other containers/nodes) ---
--A XRAY -d 10.1.0.0/16 -j RETURN
--A XRAY ! -d 10.0.0.0/8 -j RETURN
--A XRAY -m mark --mark 0xff -j RETURN
--A XRAY -p udp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
--A XRAY -p tcp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
--A PREROUTING -j XRAY
-
-# --- OUTPUT CHAIN (Traffic originating from the server itself) ---
--A XRAY_MASK -d 10.1.0.0/16 -j RETURN
--A XRAY_MASK ! -d 10.0.0.0/8 -j RETURN
--A XRAY_MASK -m mark --mark 0xff -j RETURN
--A XRAY_MASK -p udp -j MARK --set-mark 1
--A XRAY_MASK -p tcp -j MARK --set-mark 1
--A OUTPUT -j XRAY_MASK
-
-# --- DIVERT Optimization ---
--A DIVERT -j MARK --set-mark 1
--A DIVERT -j ACCEPT
--I PREROUTING -p tcp -m socket -j DIVERT
-
-COMMIT
-```
-
-После применения перезагрузите UFW:
-
-```bash
-sudo ufw reload
-```
-
----
-
-## Ресурсы
+Дополнительные технические материалы:
- [Официальная документация Xray-core](https://xtls.github.io/)
-- [Документация Xray Reverse Proxy](https://xtls.github.io/en/config/reverse.html)
-- [Project X (Xray) на GitHub](https://github.com/XTLS/Xray-core)
-- [Документация Linux Kernel по TPROXY](https://www.kernel.org/doc/Documentation/networking/tproxy.txt)
-- [Блог Cloudflare: TPROXY](https://blog.cloudflare.com/how-we-built-spectrum/)
+- [Понимание TPROXY (блог Cloudflare)](https://blog.cloudflare.com/how-we-built-spectrum/)
+- [Документация TPROXY в ядре Linux](https://www.kernel.org/doc/Documentation/networking/tproxy.txt)
diff --git a/services/www/content/registry.ts b/services/www/content/registry.ts
index 6fd994b..4bbf411 100644
--- a/services/www/content/registry.ts
+++ b/services/www/content/registry.ts
@@ -2,6 +2,7 @@ import React from 'react';
import * as TestArticle from './articles/2026/test-article.en.mdx';
import * as MatrixServerDeploymentGuide from './articles/2026/matrix-server-deployment-guide.en.mdx';
import * as XrayCoreSubnetMimicry from './articles/2026/xray-core-subnet-mimicry.en.mdx';
+import * as JoplinWebDeploymentGuide from './articles/2026/joplin-web-deployment-guide.en.mdx';
export interface Article {
slug: string;
@@ -47,6 +48,10 @@ export const articles: Record = {
'2026/matrix-server-deployment-guide'
),
'xray-core-subnet-mimicry': register(XrayCoreSubnetMimicry, '2026/xray-core-subnet-mimicry'),
+ 'joplin-web-deployment-guide': register(
+ JoplinWebDeploymentGuide,
+ '2026/joplin-web-deployment-guide'
+ ),
};
export const sortedArticles = Object.values(articles).sort(
diff --git a/services/www/public/favicon.svg b/services/www/public/favicon.svg
deleted file mode 100644
index f157bd1..0000000
--- a/services/www/public/favicon.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-
diff --git a/services/www/src/app/articles/2026/joplin-web-deployment-guide.tsx b/services/www/src/app/articles/2026/joplin-web-deployment-guide.tsx
new file mode 100644
index 0000000..232a322
--- /dev/null
+++ b/services/www/src/app/articles/2026/joplin-web-deployment-guide.tsx
@@ -0,0 +1,43 @@
+import { localePath } from '@ermnvldmr/i18n';
+import { MARKDOWN_COMPONENTS } from '@ermnvldmr/ui';
+import React from 'react';
+
+import { articles } from '../../../../content/registry';
+import { ArticleLayout } from '../../../components/Layout/ArticleLayout';
+import { createPage } from '../../../lib/core/createPage';
+
+const article = articles['joplin-web-deployment-guide'];
+
+// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+if (!article) {
+ throw new Error('Article not found');
+}
+
+/**
+ * Joplin web deployment guide article page.
+ *
+ * @example
+ * ```tsx
+ * createPage(JoplinWebDeploymentGuidePage, { title: '...', description: '...' });
+ * ```
+ */
+function JoplinWebDeploymentGuidePage(): React.JSX.Element {
+ return (
+
+
+
+ );
+}
+
+createPage(JoplinWebDeploymentGuidePage, {
+ title: `${article.title} - Vladimir Eremin`,
+ description: article.description,
+});
+export default JoplinWebDeploymentGuidePage;
diff --git a/services/www/src/components/Layout/ArticleLayout/ArticleLayout.i18n.ts b/services/www/src/components/Layout/ArticleLayout/ArticleLayout.i18n.ts
new file mode 100644
index 0000000..9d12228
--- /dev/null
+++ b/services/www/src/components/Layout/ArticleLayout/ArticleLayout.i18n.ts
@@ -0,0 +1,7 @@
+import { createKeyset } from '@ermnvldmr/i18n';
+
+export const { t } = createKeyset({
+ Articles: { en: 'Articles', ru: 'Статьи' },
+ Home: { en: 'Home', ru: 'Главная' },
+ Updated: { en: 'Updated', ru: 'Обновлено' },
+});
diff --git a/services/www/src/components/Layout/ArticleLayout/ArticleLayout.stories.tsx b/services/www/src/components/Layout/ArticleLayout/ArticleLayout.stories.tsx
index b2a376e..bbde27f 100644
--- a/services/www/src/components/Layout/ArticleLayout/ArticleLayout.stories.tsx
+++ b/services/www/src/components/Layout/ArticleLayout/ArticleLayout.stories.tsx
@@ -22,11 +22,11 @@ const meta: Meta = {
component: ArticleLayout,
tags: ['autodocs'],
args: {
- title: 'Building a Modern Content System with Astro 5',
+ title: 'Building a Modern Content System with Rsbuild',
description:
- "A deep dive into building a scalable, file-based content architecture using Astro's new Content Layer API and MDX.",
- createdDate: new Date('2026-01-31'),
- tags: ['engineering', 'astro', 'react'],
+ "A deep dive into building a scalable, file-based content architecture using Rsbuild's MDX support and Rspack.",
+ createdDate: new Date('2026-03-19'),
+ tags: ['engineering', 'rsbuild', 'react'],
},
};
@@ -45,9 +45,9 @@ export const Default: Story = {
Introduction
- Astro 5 introduces the Content Layer API, a powerful way to fetch content from any source,
- including local files, remote APIs, and CMSs. In this article, we'll explore how to set
- up a robust system for articles and projects.
+ Rsbuild provides a modern, high-performance build toolchain based on Rspack. In this
+ article, we'll explore how to set up a robust system for articles and projects using
+ MDX and React.
@@ -56,46 +56,45 @@ export const Default: Story = {
- Let's look at some code. Our new content.config.ts uses the{' '}
- glob loader to pull in MDX files from outside the src directory:
+ Let's look at some code. Our rsbuild.config.ts uses the{' '}
+ pluginMdx to handle MDX files:
-
+
- {`// services/www/src/content.config.ts
-import { defineCollection, z } from 'astro:content';
-import { glob } from 'astro/loaders';
+ {`import { defineConfig } from '@rsbuild/core';
+import { pluginReact } from '@rsbuild/plugin-react';
+import { pluginMdx } from '@rsbuild/plugin-mdx';
-const articles = defineCollection({
- loader: glob({ pattern: "**/*.mdx", base: "./content/articles" }),
- schema: z.object({
- title: z.string(),
- description: z.string().optional(),
- createdDate: z.date(),
- updatedDate: z.date().optional(),
- tags: z.array(z.string()).optional(),
- }),
-});
-
-export const collections = { articles };`}
+export default defineConfig({
+ plugins: [
+ pluginReact(),
+ pluginMdx({
+ mdxOptions: {
+ remarkPlugins: [],
+ rehypePlugins: [],
+ },
+ }),
+ ],
+});`}
- Why Astro 5?
+ Why Rsbuild?
- Astro's zero-JS by default approach is perfect for content-heavy sites. By moving our
- content outside of the src directory, we keep our project clean and organized.
+ Rsbuild's performance is unparalleled thanks to Rspack. By using a Rust-based bundler,
+ we keep our development loop fast and our builds efficient.
- Better performance: Only what you need is shipped.
+ Better performance: Rust-powered builds and HMR.
- Improved developer experience: Fast HMR and great tooling.
+ Improved developer experience: Sensible defaults and great tooling.
- Type safety: Zod schemas validate your frontmatter.
+ Type safety: Full TypeScript support out of the box.
diff --git a/services/www/src/components/Layout/ArticleLayout/ArticleLayout.tsx b/services/www/src/components/Layout/ArticleLayout/ArticleLayout.tsx
index 1a6ea16..27ea90f 100644
--- a/services/www/src/components/Layout/ArticleLayout/ArticleLayout.tsx
+++ b/services/www/src/components/Layout/ArticleLayout/ArticleLayout.tsx
@@ -2,6 +2,7 @@ import { localePath } from '@ermnvldmr/i18n';
import { Markdown, Time, Text, PageColumns, PageColumn, Breadcrumbs, HStack } from '@ermnvldmr/ui';
import React from 'react';
+import { t } from './ArticleLayout.i18n';
import { DefaultLayout } from '../DefaultLayout/DefaultLayout';
/**
@@ -50,7 +51,7 @@ export const ArticleLayout: React.FC = ({
}) => {
const displayDate = updatedDate ? (
- Updated
+ {t('Updated')}
) : (
@@ -73,9 +74,9 @@ export const ArticleLayout: React.FC = ({
const breadcrumbs = (
- Home
+ {t('Home')}
- Articles
+ {t('Articles')}{title}
diff --git a/services/www/src/components/PageHead/PageHead.i18n.ts b/services/www/src/components/PageHead/PageHead.i18n.ts
new file mode 100644
index 0000000..aeaece7
--- /dev/null
+++ b/services/www/src/components/PageHead/PageHead.i18n.ts
@@ -0,0 +1,6 @@
+import { createKeyset } from '@ermnvldmr/i18n';
+
+export const { t } = createKeyset({
+ Articles: { en: 'Articles', ru: 'Статьи' },
+ GitHub: { en: 'GitHub', ru: 'ГитХаб' },
+});
diff --git a/services/www/src/components/PageHead/PageHead.tsx b/services/www/src/components/PageHead/PageHead.tsx
index 848f429..cef1749 100644
--- a/services/www/src/components/PageHead/PageHead.tsx
+++ b/services/www/src/components/PageHead/PageHead.tsx
@@ -1,6 +1,8 @@
import { PageHead as PageHeadOriginal, HStack, Link } from '@ermnvldmr/ui';
import React from 'react';
+import { t } from './PageHead.i18n';
+
import type { PageHeadStrategy } from '@ermnvldmr/ui';
/**
@@ -34,8 +36,8 @@ export interface PageHeadProps {
export const PageHead = ({ heading, caption, breadcrumbs, metadata, strategy }: PageHeadProps) => {
const nav = (
- Articles
- GitHub
+ {t('Articles')}
+ {t('GitHub')}
);
diff --git a/services/www/tsconfig.json b/services/www/tsconfig.json
index 2af9f6a..884e652 100644
--- a/services/www/tsconfig.json
+++ b/services/www/tsconfig.json
@@ -9,6 +9,6 @@
"@ermnvldmr/ui/*": ["../../../packages/ui/src/*"]
}
},
- "include": [".astro/types.d.ts", "src/**/*", ".storybook/**/*"],
+ "include": ["src/**/*", ".storybook/**/*"],
"exclude": ["dist", "coverage", "node_modules", "**/*.config.*"]
}