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 + +![Joplin Web App Interface](https://static.ermnvldmr.com/www/articles/2026/joplin-web-deployment-guide/joplin-web-interface.webp) + +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` + +![Joplin Web App Setup](https://static.ermnvldmr.com/www/articles/2026/joplin-web-deployment-guide/joplin-web-setup.webp) + +_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 Web](https://static.ermnvldmr.com/www/articles/2026/joplin-web-deployment-guide/joplin-web-interface.webp) + +По сути, это то же самое мобильное приложение 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 Web](https://static.ermnvldmr.com/www/articles/2026/joplin-web-deployment-guide/joplin-web-setup.webp) + +_Наслаждайтесь!_ + +## Ресурсы + +- [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 ) : (