Compare commits
10
Commits
333cc94815
...
07af9bb0a7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07af9bb0a7 | ||
|
|
561118b274 | ||
|
|
bb4c71d51c | ||
|
|
51ddb28453 | ||
|
|
8915ef15b2 | ||
|
|
c0abd7993d | ||
|
|
bac1aa3705 | ||
|
|
7ae8673016 | ||
|
|
c568e3da92 | ||
|
|
0764c24a1c |
No files matched your search
@@ -6,6 +6,7 @@ on:
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/packages-ui-next-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
@@ -64,6 +65,8 @@ jobs:
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Build Storybook config
|
||||
run: pnpm --filter @ermnvldmr/storybook-config build
|
||||
- name: Build i18n
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload Rsbuild Config
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -76,6 +79,12 @@ jobs:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
retention-days: 1
|
||||
- name: Upload i18n dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-ui:
|
||||
name: Lint - UI
|
||||
@@ -103,6 +112,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Lint UI package
|
||||
run: pnpm --filter @ermnvldmr/ui lint
|
||||
|
||||
@@ -132,6 +146,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Typecheck UI package
|
||||
run: pnpm --filter @ermnvldmr/ui typecheck
|
||||
|
||||
@@ -161,6 +180,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Test UI package
|
||||
run: pnpm --filter @ermnvldmr/ui test:coverage
|
||||
- name: Upload coverage to Codecov
|
||||
@@ -197,6 +221,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Build UI Storybook
|
||||
run: pnpm --filter @ermnvldmr/ui build-storybook
|
||||
|
||||
@@ -226,6 +255,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm --filter @ermnvldmr/ui exec playwright install --with-deps chromium
|
||||
- name: Build UI Storybook
|
||||
@@ -266,5 +300,10 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Build UI package
|
||||
run: pnpm --filter @ermnvldmr/ui build
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/packages-ui-pr-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
@@ -64,6 +65,8 @@ jobs:
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Build Storybook config
|
||||
run: pnpm --filter @ermnvldmr/storybook-config build
|
||||
- name: Build i18n
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload Rsbuild Config
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -76,6 +79,12 @@ jobs:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
retention-days: 1
|
||||
- name: Upload i18n dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-ui:
|
||||
name: Lint - UI
|
||||
@@ -103,6 +112,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Lint UI package
|
||||
run: pnpm --filter @ermnvldmr/ui lint
|
||||
|
||||
@@ -132,6 +146,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Typecheck UI package
|
||||
run: pnpm --filter @ermnvldmr/ui typecheck
|
||||
|
||||
@@ -161,6 +180,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Test UI package
|
||||
run: pnpm --filter @ermnvldmr/ui test:coverage
|
||||
- name: Upload coverage to Codecov
|
||||
@@ -197,6 +221,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Build UI Storybook
|
||||
run: pnpm --filter @ermnvldmr/ui build-storybook
|
||||
- name: Upload Storybook build
|
||||
@@ -232,6 +261,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm --filter @ermnvldmr/ui exec playwright install --with-deps chromium
|
||||
- name: Download Storybook build
|
||||
@@ -275,5 +309,10 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Build UI package
|
||||
run: pnpm --filter @ermnvldmr/ui build
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
- 'services/static/**'
|
||||
- 'packages/ui/**'
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/services-static-next-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
@@ -63,12 +64,20 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build Rsbuild config
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Build i18n
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload Config Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
retention-days: 1
|
||||
- name: Upload i18n dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-static:
|
||||
name: Lint - Static
|
||||
@@ -91,6 +100,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Lint static service
|
||||
run: pnpm --filter @ermnvldmr/static lint
|
||||
|
||||
@@ -115,6 +129,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Typecheck static service
|
||||
run: pnpm --filter @ermnvldmr/static typecheck
|
||||
|
||||
@@ -139,6 +158,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Build static service
|
||||
run: pnpm --filter @ermnvldmr/static build
|
||||
- name: Upload build artifacts
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
- 'services/static/**'
|
||||
- 'packages/ui/**'
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/services-static-pr-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
@@ -63,12 +64,20 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build Rsbuild config
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Build i18n
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload Config Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
retention-days: 1
|
||||
- name: Upload i18n dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-static:
|
||||
name: Lint - Static
|
||||
@@ -91,6 +100,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Lint static service
|
||||
run: pnpm --filter @ermnvldmr/static lint
|
||||
|
||||
@@ -115,6 +129,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Typecheck static service
|
||||
run: pnpm --filter @ermnvldmr/static typecheck
|
||||
|
||||
@@ -139,6 +158,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Build static service
|
||||
run: pnpm --filter @ermnvldmr/static build
|
||||
- name: Upload build artifacts
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DEFAULT_LOCALE, isLocale, type Locale } from './lib/locale.js';
|
||||
|
||||
import type { RsbuildConfig } from '@rsbuild/core';
|
||||
import { DEFAULT_LOCALE, type Locale } from './lib/locale.js';
|
||||
|
||||
/**
|
||||
* Returns a partial Rsbuild config that enables locale-aware builds.
|
||||
@@ -11,12 +12,12 @@ import { DEFAULT_LOCALE, type Locale } from './lib/locale.js';
|
||||
* import { localeRsbuildConfig } from '@ermnvldmr/i18n/dev';
|
||||
* import { defineServiceConfig, mergeConfig } from '@ermnvldmr/rsbuild-config/dev';
|
||||
*
|
||||
* const locale = process.env.LOCALE ?? 'en';
|
||||
* export default defineServiceConfig(mergeConfig(localeRsbuildConfig(), { ... }));
|
||||
* ```
|
||||
*/
|
||||
export function localeRsbuildConfig(): RsbuildConfig {
|
||||
const locale: Locale = (process.env.LOCALE as Locale | undefined) ?? DEFAULT_LOCALE;
|
||||
const rawLocale = process.env.LOCALE ?? '';
|
||||
const locale: Locale = isLocale(rawLocale) ? rawLocale : DEFAULT_LOCALE;
|
||||
|
||||
return {
|
||||
source: {
|
||||
@@ -24,6 +25,11 @@ export function localeRsbuildConfig(): RsbuildConfig {
|
||||
'process.env.LOCALE': JSON.stringify(locale),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// Dev server serves pages under the locale base so localePath() links
|
||||
// resolve correctly in dev without any NODE_ENV branching.
|
||||
base: `/${locale}`,
|
||||
},
|
||||
output: {
|
||||
distPath: {
|
||||
root: `dist/${locale}`,
|
||||
@@ -36,7 +42,7 @@ export function localeRsbuildConfig(): RsbuildConfig {
|
||||
// The canonical import is always `.en.mdx`; other locales are resolved here.
|
||||
if (locale !== DEFAULT_LOCALE) {
|
||||
config.plugins = [
|
||||
...(config.plugins ?? []),
|
||||
...config.plugins,
|
||||
new rspack.NormalModuleReplacementPlugin(
|
||||
/\.en\.mdx$/,
|
||||
(resource: { request: string }) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import React from 'react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { createKeyset } from './keyset.js';
|
||||
|
||||
const { t, tRaw } = createKeyset({
|
||||
|
||||
@@ -1,63 +1,120 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { LOCALE } from './locale.js';
|
||||
import { pluralize, type PluralForms } from './pluralize.js';
|
||||
|
||||
type PlainTranslation = { en: string; ru: string };
|
||||
type PluralTranslation = { en: PluralForms; ru: PluralForms };
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/** A translation entry with a plain string per locale. */
|
||||
interface PlainTranslation { en: string; ru: string }
|
||||
|
||||
/** A translation entry with plural forms per locale. */
|
||||
interface PluralTranslation { en: PluralForms; ru: PluralForms }
|
||||
|
||||
/** Union of all supported translation shapes. */
|
||||
type Translation = PlainTranslation | PluralTranslation;
|
||||
|
||||
/** A map of translation keys to their translation entries. */
|
||||
type Keyset = Record<string, Translation>;
|
||||
|
||||
/**
|
||||
* Returns `true` when the translation entry uses plural forms.
|
||||
*
|
||||
* @param t - The translation entry to inspect.
|
||||
* @returns Type predicate narrowing `t` to {@link PluralTranslation}.
|
||||
* @example
|
||||
* isPluralTranslation({ en: 'hello', ru: 'привет' }) // → false
|
||||
*/
|
||||
function isPluralTranslation(t: Translation): t is PluralTranslation {
|
||||
return typeof (t as PluralTranslation)[LOCALE] === 'object';
|
||||
return typeof t.en === 'object';
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces `{key}` placeholders in `template` with values from `params`.
|
||||
*
|
||||
* @param template - String containing `{key}` placeholders.
|
||||
* @param params - Map of placeholder names to string/number values.
|
||||
* @returns The interpolated string.
|
||||
* @example
|
||||
* interpolate('Hello {name}!', { name: 'World' }) // → 'Hello World!'
|
||||
*/
|
||||
function interpolate(template: string, params: Record<string, string | number>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (_, key: string) =>
|
||||
key in params ? String(params[key]) : `{${key}}`
|
||||
);
|
||||
}
|
||||
|
||||
function interpolateRaw(
|
||||
template: string,
|
||||
params: Record<string, ReactNode>
|
||||
): ReactNode {
|
||||
/**
|
||||
* Splits `template` on `{key}` placeholders and substitutes ReactNode values.
|
||||
*
|
||||
* @param template - String containing `{key}` placeholders.
|
||||
* @param params - Map of placeholder names to ReactNode values.
|
||||
* @returns A single string when all parts are strings, otherwise a ReactNode array.
|
||||
* @example
|
||||
* interpolateRaw('Click {link}', { link: <a href="/">here</a> })
|
||||
*/
|
||||
function interpolateRaw(template: string, params: Record<string, ReactNode>): ReactNode {
|
||||
const parts = template.split(/(\{[^}]+\})/g);
|
||||
const result = parts.map((part) => {
|
||||
const match = part.match(/^\{(\w+)\}$/);
|
||||
const match = /^\{(\w+)\}$/.exec(part);
|
||||
if (match) {
|
||||
const key = match[1];
|
||||
return key in params ? params[key] : part;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
if (result.every((p) => typeof p === 'string')) {
|
||||
return (result as string[]).join('');
|
||||
if (result.every((p): p is string => typeof p === 'string')) {
|
||||
return result.join('');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a typed `t` and `tRaw` pair bound to `keyset`.
|
||||
*
|
||||
* @param keyset - An object mapping translation keys to their locale entries.
|
||||
* @returns `{ t, tRaw }` translation helpers.
|
||||
* @example
|
||||
* const { t } = createKeyset({ greeting: { en: 'Hello', ru: 'Привет' } });
|
||||
* t('greeting') // → 'Hello' (in EN build)
|
||||
*/
|
||||
export function createKeyset<const K extends Keyset>(keyset: K) {
|
||||
type Keys = keyof K;
|
||||
/** String union of all keys defined in `keyset`. */
|
||||
type Keys = keyof K & string;
|
||||
|
||||
/**
|
||||
* Returns the translated string for `key`, with optional interpolation and pluralisation.
|
||||
*
|
||||
* @param key - A key defined in the keyset.
|
||||
* @param params - Optional interpolation/pluralisation params.
|
||||
* @returns The translated string for the current build locale.
|
||||
* @example
|
||||
* t('greeting') // → 'Hello'
|
||||
* t('items {count}', { count: 3 }) // → '3 items'
|
||||
*/
|
||||
function t(key: Keys, params?: Record<string, string | number>): string {
|
||||
const entry = keyset[key as string];
|
||||
if (!entry) return String(key);
|
||||
const entry = keyset[key];
|
||||
|
||||
if (isPluralTranslation(entry)) {
|
||||
const forms = entry[LOCALE];
|
||||
const count = (params?.count as number) ?? 0;
|
||||
const count = typeof params?.count === 'number' ? params.count : 0;
|
||||
const template = pluralize(count, forms);
|
||||
return params ? interpolate(template, params) : template;
|
||||
}
|
||||
|
||||
const template = (entry as PlainTranslation)[LOCALE];
|
||||
const template = entry[LOCALE];
|
||||
return params ? interpolate(template, params) : template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the translated ReactNode for `key`, allowing ReactNode interpolation values.
|
||||
*
|
||||
* @param key - A key defined in the keyset.
|
||||
* @param params - Optional interpolation params; values may be any ReactNode.
|
||||
* @returns The translated string or ReactNode array for the current build locale.
|
||||
* @example
|
||||
* tRaw('click {link}', { link: <a href="/">here</a> })
|
||||
*/
|
||||
function tRaw(key: Keys, params?: Record<string, ReactNode>): ReactNode {
|
||||
const entry = keyset[key as string];
|
||||
if (!entry) return String(key);
|
||||
const entry = keyset[key];
|
||||
|
||||
if (isPluralTranslation(entry)) {
|
||||
const forms = entry[LOCALE];
|
||||
@@ -67,7 +124,7 @@ export function createKeyset<const K extends Keyset>(keyset: K) {
|
||||
return params ? interpolateRaw(template, params) : template;
|
||||
}
|
||||
|
||||
const template = (entry as PlainTranslation)[LOCALE];
|
||||
const template = entry[LOCALE];
|
||||
return params ? interpolateRaw(template, params) : template;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
/** The supported locale identifiers. */
|
||||
export type Locale = 'en' | 'ru';
|
||||
|
||||
export const LOCALES: readonly Locale[] = ['en', 'ru'] as const;
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = 'en';
|
||||
|
||||
// Widened once so inclusion checks need no type assertions.
|
||||
const LOCALES_STRINGS: readonly string[] = LOCALES;
|
||||
|
||||
/**
|
||||
* Returns `true` when `value` is a known {@link Locale} identifier.
|
||||
*
|
||||
* @param value - The string to test.
|
||||
* @returns Type predicate narrowing `value` to `Locale`.
|
||||
* @example
|
||||
* isLocale('en') // → true
|
||||
* isLocale('fr') // → false
|
||||
*/
|
||||
export function isLocale(value: string): value is Locale {
|
||||
return LOCALES_STRINGS.includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The active locale for the current build.
|
||||
* Injected at compile time via source.define → process.env.LOCALE.
|
||||
* Falls back to DEFAULT_LOCALE in non-locale-aware builds (tests, Storybook).
|
||||
*/
|
||||
export const LOCALE: Locale =
|
||||
(process.env.LOCALE as Locale | undefined) ?? DEFAULT_LOCALE;
|
||||
|
||||
export function isLocale(value: string): value is Locale {
|
||||
return (LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
const rawLocale = process.env.LOCALE ?? '';
|
||||
export const LOCALE: Locale = isLocale(rawLocale) ? rawLocale : DEFAULT_LOCALE;
|
||||
|
||||
/**
|
||||
* The other (non-active) locale for the current build.
|
||||
@@ -34,4 +47,4 @@ const SWITCH_LABELS: Record<Locale, Partial<Record<Locale, string>>> = {
|
||||
};
|
||||
|
||||
export const OTHER_LOCALE_LABEL: string =
|
||||
SWITCH_LABELS[LOCALE]?.[OTHER_LOCALE] ?? OTHER_LOCALE.toUpperCase();
|
||||
SWITCH_LABELS[LOCALE][OTHER_LOCALE] ?? OTHER_LOCALE.toUpperCase();
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { localePath, localeHref } from './path.js';
|
||||
|
||||
describe('localePath', () => {
|
||||
|
||||
@@ -1,37 +1,55 @@
|
||||
import { LOCALE, LOCALES, type Locale } from './locale.js';
|
||||
|
||||
/**
|
||||
* Prepends the current build locale to an absolute path.
|
||||
* Resolves any href value to its locale-aware form.
|
||||
*
|
||||
* - Production (SSG): returns `/${LOCALE}${path}` so links match the deployed
|
||||
* locale-prefixed directory structure.
|
||||
* - Development: returns the plain path because Rsbuild's dev server routes
|
||||
* entries without an `assetPrefix` (the prefix applies only to static assets).
|
||||
* Only absolute internal paths (starting with `/`) are rewritten; everything else
|
||||
* (external URLs, mailto/tel, relative paths, hash anchors) passes through unchanged.
|
||||
* The function is idempotent: paths already carrying any known locale prefix are returned as-is.
|
||||
*
|
||||
* Works identically in dev and production: the dev server is configured with
|
||||
* `server.base = /${LOCALE}` via `localeRsbuildConfig`.
|
||||
*
|
||||
* @param href - The href value to resolve.
|
||||
* @returns The locale-prefixed href, or the original value when no rewrite is needed.
|
||||
* @example
|
||||
* // production, LOCALE = 'en'
|
||||
* localePath('/articles') → '/en/articles'
|
||||
* localePath('/') → '/en/'
|
||||
*
|
||||
* // development
|
||||
* localePath('/articles') → '/articles'
|
||||
* localePath('/') → '/'
|
||||
* // LOCALE = 'en'
|
||||
* localePath('/articles') // → '/en/articles'
|
||||
* localePath('/') // → '/en/'
|
||||
* localePath('/en/articles') // → '/en/articles' (idempotent)
|
||||
* localePath('https://github.com') // → 'https://github.com' (external)
|
||||
* localePath('mailto:a@b.com') // → 'mailto:a@b.com'
|
||||
* localePath('#section') // → '#section'
|
||||
*/
|
||||
export function localePath(path: string): string {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return path;
|
||||
export function localePath(href: string): string;
|
||||
export function localePath(href: null): null;
|
||||
export function localePath(href: undefined): undefined;
|
||||
export function localePath(href: string | undefined): string | undefined;
|
||||
export function localePath(href: string | null): string | null;
|
||||
export function localePath(href: string | null | undefined): string | null | undefined;
|
||||
export function localePath(href: string | null | undefined): string | null | undefined {
|
||||
if (href == null) return href;
|
||||
if (!href.startsWith('/')) return href;
|
||||
if (LOCALES.some((l) => href === `/${l}` || href === `/${l}/` || href.startsWith(`/${l}/`))) {
|
||||
return href;
|
||||
}
|
||||
if (path === '/') return `/${LOCALE}/`;
|
||||
return `/${LOCALE}${path}`;
|
||||
if (href === '/') return `/${LOCALE}/`;
|
||||
return `/${LOCALE}${href}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a URL for a specific locale given the current URL.
|
||||
* e.g. localeHref('/en/articles', 'ru') → '/ru/articles'
|
||||
* Builds a URL for a specific locale given the current locale-prefixed URL.
|
||||
*
|
||||
* @param currentPath - The current page path, e.g. `/en/articles`.
|
||||
* @param locale - The target locale.
|
||||
* @returns The equivalent path for `locale`, e.g. `/ru/articles`.
|
||||
* @example
|
||||
* localeHref('/en/articles', 'ru') // → '/ru/articles'
|
||||
* localeHref('/en/', 'ru') // → '/ru/'
|
||||
*/
|
||||
export function localeHref(currentPath: string, locale: Locale): string {
|
||||
const localePrefix = new RegExp(`^\\/(${LOCALES.join('|')})(.*)`);
|
||||
const match = currentPath.match(localePrefix);
|
||||
const match = localePrefix.exec(currentPath);
|
||||
if (match) {
|
||||
return `/${locale}${match[2] || '/'}`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { pluralize } from './pluralize.js';
|
||||
|
||||
const forms = { none: 'none', one: 'one', some: 'some', many: 'many' };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** The four plural categories used across EN and RU grammar rules. */
|
||||
export interface PluralForms {
|
||||
none: string;
|
||||
one: string;
|
||||
@@ -8,6 +9,16 @@ export interface PluralForms {
|
||||
/**
|
||||
* Selects the correct plural form for the given count.
|
||||
* Covers both English (none/one/many) and Russian (none/one/some/many) rules.
|
||||
*
|
||||
* @param count - The numeric value to pluralise.
|
||||
* @param forms - The set of plural forms to select from.
|
||||
* @returns The appropriate plural form string.
|
||||
* @example
|
||||
* const forms = { none: 'no items', one: 'one item', some: '{n} items', many: '{n} items' };
|
||||
* pluralize(0, forms) // → 'no items'
|
||||
* pluralize(1, forms) // → 'one item'
|
||||
* pluralize(3, forms) // → '{n} items' (some)
|
||||
* pluralize(21, forms) // → 'one item' (Russian rule: 21 ends in 1)
|
||||
*/
|
||||
export function pluralize(count: number, forms: PluralForms): string {
|
||||
if (count === 0) return forms.none;
|
||||
|
||||
@@ -22,3 +22,19 @@ Object.defineProperty(window, 'matchMedia', {
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// jsdom sometimes does not expose localStorage.clear(); provide a functional polyfill
|
||||
if (typeof window.localStorage.clear !== 'function') {
|
||||
const store = new Map<string, string>();
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
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(); },
|
||||
key: (index: number): string | null => [...store.keys()][index] ?? null,
|
||||
get length(): number { return store.size; },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -41,6 +41,7 @@
|
||||
"clean": "rm -rf dist coverage storybook-static"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ermnvldmr/i18n": "workspace:*",
|
||||
"@ermnvldmr/stl": "workspace:*",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('components/Breadcrumbs', () => {
|
||||
</Breadcrumbs>
|
||||
);
|
||||
const link = screen.getByRole('link', { name: 'Test Link' });
|
||||
expect(link).toHaveAttribute('href', '/test-url');
|
||||
expect(link).toHaveAttribute('href', '/en/test-url');
|
||||
});
|
||||
|
||||
it('supports onClick on Item', () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { localePath } from '@ermnvldmr/i18n';
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import React, { memo } from 'react';
|
||||
@@ -56,7 +57,8 @@ const BreadcrumbsItem = memo(function BreadcrumbsItem({
|
||||
onClick,
|
||||
'data-testid': testId,
|
||||
}: BreadcrumbsItemProps) {
|
||||
const isClickable = !!(href ?? onClick);
|
||||
const resolvedHref = localePath(href);
|
||||
const isClickable = !!(resolvedHref ?? onClick);
|
||||
|
||||
return (
|
||||
<li
|
||||
@@ -69,13 +71,13 @@ const BreadcrumbsItem = memo(function BreadcrumbsItem({
|
||||
data-testid={testId}
|
||||
>
|
||||
<Text
|
||||
as={href ? 'a' : onClick ? 'button' : 'span'}
|
||||
as={resolvedHref ? 'a' : onClick ? 'button' : 'span'}
|
||||
className={cn(
|
||||
'transition-all duration-200',
|
||||
!isCurrent && 'group-hover:text-[var(--rb-text)] group-hover:underline underline-offset-4'
|
||||
)}
|
||||
color={isCurrent ? 'default' : 'muted'}
|
||||
href={href}
|
||||
href={resolvedHref}
|
||||
size="m"
|
||||
type="label"
|
||||
onClick={onClick}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { localePath } from '@ermnvldmr/i18n';
|
||||
import { castRef, cn } from '@ermnvldmr/stl';
|
||||
import React, { memo, useRef } from 'react';
|
||||
import { useLink } from 'react-aria';
|
||||
@@ -50,6 +51,7 @@ export const Link = memo(function Link(props: LinkProps) {
|
||||
);
|
||||
|
||||
if (href) {
|
||||
const resolvedHref = localePath(href);
|
||||
const isAutoExternal = isExternal ?? href.startsWith('http');
|
||||
const externalProps = isAutoExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {};
|
||||
|
||||
@@ -62,7 +64,7 @@ export const Link = memo(function Link(props: LinkProps) {
|
||||
className={sharedClasses}
|
||||
color={color}
|
||||
delay={delay}
|
||||
href={href}
|
||||
href={resolvedHref}
|
||||
size={size}
|
||||
type={type}
|
||||
>
|
||||
|
||||
Generated
+3
@@ -241,6 +241,9 @@ importers:
|
||||
|
||||
packages/ui:
|
||||
dependencies:
|
||||
'@ermnvldmr/i18n':
|
||||
specifier: workspace:*
|
||||
version: link:../i18n
|
||||
'@ermnvldmr/stl':
|
||||
specifier: workspace:*
|
||||
version: link:../stl
|
||||
|
||||
@@ -5,6 +5,276 @@ createdDate: 2026-02-15
|
||||
tags: ['matrix', 'deployment', 'guide']
|
||||
---
|
||||
|
||||
{/* TODO: Russian translation pending */}
|
||||
Это руководство предназначено **только для образовательных целей**. В нем показано, как развернуть приватную коммуникационную инфраструктуру, чтобы лучше понять децентрализованный протокол Matrix и техническую реализацию медиа-релеев.
|
||||
|
||||
> Это руководство предназначено **только для образовательных целей**.
|
||||
## Зачем поднимать сервер самостоятельно?
|
||||
|
||||
Централизованные платформы нередко жертвуют приватностью данных ради аналитики или зависят от внешних изменений политики. При развертывании собственного Matrix-инстанса вы получаете:
|
||||
|
||||
- **Суверенитет данных**: железо и база данных находятся под вашим контролем.
|
||||
- **Сквозное шифрование (E2EE)**: сообщения шифруются локально на устройствах, сервер передает только шифртекст.
|
||||
- **Устойчивость инфраструктуры**: ваши коммуникации не зависят от простоев крупных провайдеров.
|
||||
- **Приватность метаданных**: вы сами определяете, какие логи о взаимодействиях пользователей сохраняются.
|
||||
|
||||
---
|
||||
|
||||
## Обзор технического стека
|
||||
|
||||
1. **Synapse**: "homeserver", который реализует Matrix-протокол, логику учетных записей и синхронизацию сообщений.
|
||||
2. **Postgres**: база данных. Для production обязательна по соображениям производительности (SQLite подходит только для тестов).
|
||||
3. **Coturn (STUN/TURN)**: при звонках между двумя участниками фаерволы часто мешают прямому соединению. Coturn работает как релей и помогает обойти NAT-ограничения.
|
||||
4. **LiveKit**: современный медиадвижок (SFU). Если Coturn нужен в первую очередь для 1:1-звонков, то LiveKit эффективно обрабатывает групповые звонки через мультиплексирование аудио/видеопотоков.
|
||||
5. **lk-jwt-service**: сервис-аутентификатор. Проверяет, что пользователь авторизован в вашем Synapse-сервере, и выдает временный токен (JWT) для входа в звонок.
|
||||
|
||||
---
|
||||
|
||||
## 1. Стек развертывания
|
||||
|
||||
Эта конфигурация универсальна, использует **Docker Volumes** и настроена под **UID 1000:1000** для базового харднинга.
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
proxy:
|
||||
name: matrix-proxy
|
||||
internal:
|
||||
internal: true
|
||||
|
||||
volumes:
|
||||
synapse_data:
|
||||
postgres_data:
|
||||
traefik_certs:
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.0
|
||||
container_name: matrix-proxy
|
||||
command:
|
||||
- '--providers.docker=true'
|
||||
- '--providers.docker.exposedbydefault=false'
|
||||
- '--entrypoints.web.address=:80'
|
||||
- '--entrypoints.websecure.address=:443'
|
||||
- '--entrypoints.web.http.redirections.entrypoint.to=websecure'
|
||||
- '--certificatesresolvers.myresolver.acme.dnschallenge.provider=cloudflare'
|
||||
- '--certificatesresolvers.myresolver.acme.email=${ADMIN_EMAIL}'
|
||||
- '--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json'
|
||||
environment:
|
||||
- CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN}
|
||||
ports:
|
||||
- '80:80'
|
||||
- '443:443'
|
||||
volumes:
|
||||
- traefik_certs:/letsencrypt
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- proxy
|
||||
restart: unless-stopped
|
||||
|
||||
synapse:
|
||||
image: ghcr.io/element-hq/synapse:v1.146.0
|
||||
container_name: matrix-synapse
|
||||
user: '1000:1000'
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
volumes:
|
||||
- synapse_data:/data
|
||||
environment:
|
||||
SYNAPSE_SERVER_NAME: '${DOMAIN}'
|
||||
SYNAPSE_REPORT_STATS: 'no'
|
||||
SYNAPSE_CONFIG_PATH: '/data/homeserver.yaml'
|
||||
networks:
|
||||
- proxy
|
||||
- internal
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.synapse.rule=Host(`${DOMAIN}`)'
|
||||
- 'traefik.http.services.synapse.loadbalancer.server.port=8008'
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: matrix-db
|
||||
user: '1000:1000'
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_DB: synapse
|
||||
POSTGRES_USER: synapse
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_INITDB_ARGS: '--encoding=UTF-8 --lc-collate=C --lc-ctype=C'
|
||||
networks:
|
||||
- internal
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U synapse']
|
||||
interval: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.9
|
||||
container_name: matrix-livekit
|
||||
command: --config /etc/livekit.yaml --node-ip ${SERVER_IP}
|
||||
environment:
|
||||
LIVEKIT_KEYS: '${LK_KEY}: ${LK_SECRET}'
|
||||
volumes:
|
||||
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||
ports:
|
||||
- '7880:7880'
|
||||
- '7881:7881'
|
||||
- '50000-50020:50000-50020/udp'
|
||||
networks:
|
||||
- proxy
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.lk.rule=Host(`${DOMAIN}`) && PathPrefix(`/livekit`)'
|
||||
- 'traefik.http.middlewares.lk-strip.stripprefix.prefixes=/livekit'
|
||||
- 'traefik.http.routers.lk.middlewares=lk-strip'
|
||||
restart: unless-stopped
|
||||
|
||||
lk-jwt:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.4.1
|
||||
container_name: matrix-lk-jwt
|
||||
environment:
|
||||
LIVEKIT_URL: wss://${DOMAIN}/livekit
|
||||
LIVEKIT_KEY: '${LK_KEY}'
|
||||
LIVEKIT_SECRET: '${LK_SECRET}'
|
||||
networks:
|
||||
- proxy
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.lk-jwt.rule=Host(`${DOMAIN}`) && PathPrefix(`/livekit/jwt`)'
|
||||
- 'traefik.http.middlewares.lk-jwt-strip.stripprefix.prefixes=/livekit/jwt'
|
||||
- 'traefik.http.routers.lk-jwt.middlewares=lk-jwt-strip'
|
||||
restart: unless-stopped
|
||||
|
||||
coturn:
|
||||
image: coturn/coturn:4.8-alpine
|
||||
container_name: matrix-coturn
|
||||
user: '1000:1000'
|
||||
ports:
|
||||
- '3478:3478'
|
||||
- '3478:3478/udp'
|
||||
- '5349:5349'
|
||||
- '5349:5349/udp'
|
||||
- '49152-49172:49152-49172/udp'
|
||||
volumes:
|
||||
- ./turnserver.conf:/etc/coturn/turnserver.conf:ro
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Конфигурация
|
||||
|
||||
Создайте файл `.env` в той же директории. **Не пропускайте шаг генерации секретов ниже.**
|
||||
|
||||
```bash
|
||||
# --- Infrastructure ---
|
||||
DOMAIN=matrix.example.com
|
||||
SERVER_IP=1.2.3.4
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
|
||||
# --- Cloudflare (for Traefik SSL) ---
|
||||
CF_DNS_API_TOKEN=your_cloudflare_api_token_here
|
||||
|
||||
# --- Database ---
|
||||
DB_PASSWORD=generate_this_with_openssl
|
||||
|
||||
# --- LiveKit Secrets ---
|
||||
# API Key can be any alphanumeric string
|
||||
LK_KEY=matrix_lk_key
|
||||
# Secret MUST be generated
|
||||
LK_SECRET=generate_this_with_openssl
|
||||
|
||||
# --- Coturn / Synapse VoIP ---
|
||||
# Used for STUN/TURN authentication
|
||||
TURN_SECRET=generate_this_with_openssl
|
||||
|
||||
```
|
||||
|
||||
### Генерация секретов
|
||||
|
||||
Используйте `openssl`, чтобы создать надежные случайные строки для параметров в `.env`:
|
||||
|
||||
```bash
|
||||
# Generate DB_PASSWORD, LK_SECRET, and TURN_SECRET
|
||||
openssl rand -hex 32
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Вариант с Nginx и сертификатами
|
||||
|
||||
Если вы не используете Traefik, получите сертификаты через **Certbot**:
|
||||
|
||||
```bash
|
||||
sudo certbot certonly --standalone -d matrix.example.com
|
||||
|
||||
```
|
||||
|
||||
### Конфиг Nginx
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name matrix.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
|
||||
|
||||
location /_matrix {
|
||||
proxy_pass http://synapse:8008;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /livekit/ {
|
||||
rewrite ^/livekit/(.*) /$1 break;
|
||||
proxy_pass http://livekit:7880;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
}
|
||||
|
||||
location /livekit/jwt/ {
|
||||
rewrite ^/livekit/jwt/(.*) /$1 break;
|
||||
proxy_pass http://lk-jwt:8080;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Регистрация пользователей
|
||||
|
||||
### Ручная регистрация (администратором)
|
||||
|
||||
Для приватных серверов отключите публичную регистрацию в `homeserver.yaml` и добавляйте пользователей через CLI:
|
||||
|
||||
```bash
|
||||
docker exec -it matrix-synapse register_new_matrix_user \
|
||||
-c /data/homeserver.yaml http://localhost:8008 \
|
||||
--user <username> --password <password> --admin
|
||||
|
||||
```
|
||||
|
||||
### SMTP (внешняя регистрация)
|
||||
|
||||
Добавьте в `homeserver.yaml`, чтобы включить подтверждение по email:
|
||||
|
||||
```yaml
|
||||
email:
|
||||
smtp_host: 'smtp.gmail.com'
|
||||
smtp_port: 587
|
||||
smtp_user: 'yourname@gmail.com'
|
||||
smtp_pass: 'xxxx xxxx xxxx xxxx' # Google App Password
|
||||
force_tls: true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ресурсы
|
||||
|
||||
- [Synapse GitHub](https://github.com/element-hq/synapse)
|
||||
- [LiveKit GitHub](https://github.com/livekit/livekit)
|
||||
- [lk-jwt-service GitHub](https://github.com/element-hq/lk-jwt-service)
|
||||
- [Element X Apps](https://www.google.com/search?q=https://element.io/element-x)
|
||||
@@ -5,6 +5,11 @@ createdDate: 2026-01-31
|
||||
tags: ['test', 'general']
|
||||
---
|
||||
|
||||
{/* TODO: Russian translation pending */}
|
||||
## Раздел 1
|
||||
|
||||
> Это руководство предназначено **только для образовательных целей**.
|
||||
Немного содержимого здесь.
|
||||
|
||||
```typescript
|
||||
const hello = 'world';
|
||||
console.log(hello);
|
||||
```
|
||||
@@ -5,6 +5,178 @@ createdDate: 2026-03-08
|
||||
tags: ['networking', 'guide']
|
||||
---
|
||||
|
||||
{/* TODO: Russian translation pending */}
|
||||
> Это руководство предназначено **исключительно для образовательных целей**. Оно демонстрирует маршрутизацию в 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.
|
||||
|
||||
---
|
||||
|
||||
## Обзор технического стека
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 1. Стек развертывания
|
||||
|
||||
Эта конфигурация описывает центральный Hub (сервер A), который выступает как Portal для подключений Spoke-узлов. На Hub размещены централизованный DNS и fallback-маскировка через Nginx.
|
||||
|
||||
```yaml
|
||||
name: 'a'
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Конфигурация и маршрутизация
|
||||
|
||||
### Генерация UUID-идентификаторов
|
||||
|
||||
Каждому соединению нужен собственный криптографический "ключ". Для сопоставления с конфигами вам понадобятся 4 UUID.
|
||||
|
||||
```bash
|
||||
# Generate UUIDs for the Server B and Server C mappings
|
||||
docker run --rm ghcr.io/xtls/xray-core uuid
|
||||
```
|
||||
|
||||
Соответствие такое:
|
||||
|
||||
- `UUID_B_REVERSE`: управляющий туннель для сервера B.
|
||||
- `UUID_B_DIRECT`: пользовательский трафик сервера B.
|
||||
- `UUID_C_REVERSE`: управляющий туннель для сервера C.
|
||||
- `UUID_C_DIRECT`: пользовательский трафик сервера C.
|
||||
|
||||
### Маршрутизация по политикам (таблица 100)
|
||||
|
||||
Примените скрипт маршрутизации на **каждом** серверном узле, чтобы настроить параметры ядра и таблицу маршрутизации 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/)
|
||||
@@ -43,11 +43,14 @@ function ArticlesList(): React.JSX.Element {
|
||||
className="h-full"
|
||||
headline={article.title}
|
||||
href={localePath(`/articles/${article.slug}`)}
|
||||
subHeadline={article.createdDate.toLocaleDateString(DATE_LOCALE_MAP[LOCALE] ?? 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
subHeadline={article.createdDate.toLocaleDateString(
|
||||
DATE_LOCALE_MAP[LOCALE] ?? 'en-US',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}
|
||||
)}
|
||||
>
|
||||
{article.description}
|
||||
</Article>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createKeyset } from '@ermnvldmr/i18n';
|
||||
|
||||
export const { t, tRaw } = createKeyset({
|
||||
'Thoughts and creations of mine': {
|
||||
en: 'Thoughts and creations of mine',
|
||||
ru: 'Мои мысли и проекты',
|
||||
},
|
||||
'Welcome to my corner of the internet.': {
|
||||
en: 'Welcome to my corner of the internet.',
|
||||
ru: 'Добро пожаловать в мой уголок интернета.',
|
||||
},
|
||||
'You arrived right as the lights turned on. The core foundation is solid and the site is officially live, but the metaphorical boxes are still being unpacked. Expect this space to evolve continuously as everything finds its proper place.':
|
||||
{
|
||||
en: 'You arrived right as the lights turned on. The core foundation is solid and the site is officially live, but the metaphorical boxes are still being unpacked. Expect this space to evolve continuously as everything finds its proper place.',
|
||||
ru: 'Вы зашли как раз в тот момент, когда здесь включили свет. Базовый фундамент уже крепкий, и сайт официально запущен, но метафорические коробки все еще распаковываются. Ожидайте, что это пространство будет постоянно развиваться, пока все не встанет на свои места.',
|
||||
},
|
||||
'Sharing the underlying architecture is a core philosophy here. If you are curious about the specifics of how it is all built, you can inspect the {repositoryLink} to see the complete blueprints.':
|
||||
{
|
||||
en: 'Sharing the underlying architecture is a core philosophy here. If you are curious about the specifics of how it is all built, you can inspect the {repositoryLink} to see the complete blueprints.',
|
||||
ru: 'Открыто рассказывать о внутренней архитектуре - один из ключевых принципов этого места. Если вам интересно, как именно все устроено, загляните в {repositoryLink}, чтобы увидеть полную картину.',
|
||||
},
|
||||
'ermnvldmr.com repository': {
|
||||
en: 'ermnvldmr.com repository',
|
||||
ru: 'репозиторий ermnvldmr.com',
|
||||
},
|
||||
'For a look at the world away from terminal screens and code editors, the {instagramLink} visual diary captures those moments.':
|
||||
{
|
||||
en: 'For a look at the world away from terminal screens and code editors, the {instagramLink} visual diary captures those moments.',
|
||||
ru: 'Если хочется посмотреть на мир вне терминалов и редакторов кода, эти моменты собраны в визуальном дневнике {instagramLink}.',
|
||||
},
|
||||
'Whenever you are ready to look around, feel free to dive into {articlesLink}. It is a dedicated space covering everything from technical deep-dives to everyday reflections.':
|
||||
{
|
||||
en: 'Whenever you are ready to look around, feel free to dive into {articlesLink}. It is a dedicated space covering everything from technical deep-dives to everyday reflections.',
|
||||
ru: 'Когда будете готовы осмотреться, загляните в {articlesLink}. Это отдельное пространство для всего - от глубоких технических разборов до повседневных наблюдений.',
|
||||
},
|
||||
'the growing collection of articles': {
|
||||
en: 'the growing collection of articles',
|
||||
ru: 'коллекцию статей, которая постоянно растет',
|
||||
},
|
||||
'Even though the rest of the environment is still a work in progress, the communication lines are fully operational. Whether you want to discuss tech, share a perspective on a recent read, or simply make a connection, the mailbox is open at...':
|
||||
{
|
||||
en: 'Even though the rest of the environment is still a work in progress, the communication lines are fully operational. Whether you want to discuss tech, share a perspective on a recent read, or simply make a connection, the mailbox is open at...',
|
||||
ru: 'Хотя остальная часть пространства еще дорабатывается, каналы связи уже работают в полном объеме. Если хотите обсудить технологии, поделиться мнением о прочитанном или просто познакомиться, почта всегда открыта:',
|
||||
},
|
||||
});
|
||||
@@ -2,16 +2,17 @@ import { localePath } from '@ermnvldmr/i18n';
|
||||
import { Paragraph, Link, VStack, Header } from '@ermnvldmr/ui';
|
||||
import React from 'react';
|
||||
|
||||
import { t, tRaw } from './index.i18n';
|
||||
import { DefaultLayout } from '../components/Layout/DefaultLayout';
|
||||
import { createPage } from '../lib/core/createPage';
|
||||
import { SITE_DESCRIPTION, SITE_TITLE } from '../lib/shared/constants';
|
||||
import { SITE_TITLE } from '../lib/shared/constants';
|
||||
|
||||
/**
|
||||
* Home page component.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* createPage(HomePage, { title: SITE_TITLE, description: SITE_DESCRIPTION });
|
||||
* createPage(HomePage, { title: SITE_TITLE, description: t('Thoughts and creations of mine') });
|
||||
* ```
|
||||
*/
|
||||
function HomePage(): React.JSX.Element {
|
||||
@@ -23,49 +24,54 @@ function HomePage(): React.JSX.Element {
|
||||
>
|
||||
<VStack gap={24}>
|
||||
<Header delay={0} level={1}>
|
||||
Welcome to my corner of the internet.
|
||||
{t('Welcome to my corner of the internet.')}
|
||||
</Header>
|
||||
<VStack gap={12}>
|
||||
<Paragraph delay={300} size="l" type="title">
|
||||
You are arrived right as the lights turned on. The core foundation is solid and the
|
||||
site is officially live,
|
||||
<br />
|
||||
but the metaphorical boxes are still being unpacked. Expect this space to evolve
|
||||
continuously as everything finds its proper place.
|
||||
{t(
|
||||
'You arrived right as the lights turned on. The core foundation is solid and the site is officially live, but the metaphorical boxes are still being unpacked. Expect this space to evolve continuously as everything finds its proper place.'
|
||||
)}
|
||||
</Paragraph>
|
||||
<Paragraph delay={350} size="l" type="title">
|
||||
Sharing the underlying architecture is a core philosophy here. If you are curious
|
||||
about the specifics of how it is all built,
|
||||
<br />
|
||||
you can inspect the{' '}
|
||||
<Link href="https://github.com/deytenit/ermnvldmr.com/" size="l" type="title">
|
||||
ermnvldmr.com repository
|
||||
</Link>{' '}
|
||||
to see the complete blueprints.
|
||||
{tRaw(
|
||||
'Sharing the underlying architecture is a core philosophy here. If you are curious about the specifics of how it is all built, you can inspect the {repositoryLink} to see the complete blueprints.',
|
||||
{
|
||||
repositoryLink: (
|
||||
<Link href="https://github.com/deytenit/ermnvldmr.com/" size="l" type="title">
|
||||
{t('ermnvldmr.com repository')}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Paragraph>
|
||||
<Paragraph delay={400} size="l" type="title">
|
||||
For a look at the world away from terminal screens and code editors, the{' '}
|
||||
<Link href="https://ermnvldmr.com/instagram" size="l" type="title">
|
||||
@deytenit
|
||||
</Link>{' '}
|
||||
visual diary captures those moments.
|
||||
{tRaw(
|
||||
'For a look at the world away from terminal screens and code editors, the {instagramLink} visual diary captures those moments.',
|
||||
{
|
||||
instagramLink: (
|
||||
<Link href="https://ermnvldmr.com/instagram" size="l" type="title">
|
||||
@deytenit
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Paragraph>
|
||||
<Paragraph delay={450} size="l" type="title">
|
||||
Whenever you are ready to look around, feel free to dive into{' '}
|
||||
<Link href={localePath('/articles')} size="l" type="title">
|
||||
the growing collection of articles
|
||||
</Link>
|
||||
.
|
||||
<br />
|
||||
It is a dedicated space covering everything from technical deep-dives to everyday
|
||||
reflections.
|
||||
{tRaw(
|
||||
'Whenever you are ready to look around, feel free to dive into {articlesLink}. It is a dedicated space covering everything from technical deep-dives to everyday reflections.',
|
||||
{
|
||||
articlesLink: (
|
||||
<Link href={localePath('/articles')} size="l" type="title">
|
||||
{t('the growing collection of articles')}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Paragraph>
|
||||
<Paragraph delay={500} size="l" type="title">
|
||||
Even though the rest of the environment is still a work in progress, the communication
|
||||
lines are fully operational.
|
||||
<br />
|
||||
Whether you want to discuss tech, share a perspective on a recent read, or simply make
|
||||
a connection, the mailbox is open at...
|
||||
{t(
|
||||
'Even though the rest of the environment is still a work in progress, the communication lines are fully operational. Whether you want to discuss tech, share a perspective on a recent read, or simply make a connection, the mailbox is open at...'
|
||||
)}
|
||||
</Paragraph>
|
||||
</VStack>
|
||||
<Link delay={1000} href="mailto:personal@ermnvldmr.com" size="l" type="title">
|
||||
@@ -76,5 +82,5 @@ function HomePage(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
createPage(HomePage, { title: SITE_TITLE, description: SITE_DESCRIPTION });
|
||||
createPage(HomePage, { title: SITE_TITLE, description: t('Thoughts and creations of mine') });
|
||||
export default HomePage;
|
||||
@@ -43,10 +43,10 @@ export const Footer = ({ currentPath }: FooterProps) => {
|
||||
CC-BY 4.0
|
||||
</Link>
|
||||
</VStack>
|
||||
<HStack gap={8}>
|
||||
<LocaleSwitch currentPath={currentPath} />
|
||||
<VStack align="end" gap={4}>
|
||||
<ThemeSwitch />
|
||||
</HStack>
|
||||
<LocaleSwitch currentPath={currentPath} />
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Container>
|
||||
</VStack>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LOCALE, OTHER_LOCALE, OTHER_LOCALE_LABEL, localeHref } from '@ermnvldmr/i18n';
|
||||
import { Link } from '@ermnvldmr/ui';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
@@ -7,7 +8,7 @@ import React from 'react';
|
||||
* EN build → "In Russian", RU build → "На Английском".
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @returns An anchor element linking to the equivalent page in the other locale.
|
||||
* @returns A Link element linking to the equivalent page in the other locale.
|
||||
*/
|
||||
export interface LocaleSwitchProps {
|
||||
/** Current page path (e.g. "/en/articles") — required for accurate SSG rendering */
|
||||
@@ -21,8 +22,12 @@ export const LocaleSwitch: React.FC<LocaleSwitchProps> = ({ currentPath }) => {
|
||||
const href = localeHref(path, OTHER_LOCALE);
|
||||
|
||||
return (
|
||||
<a aria-label={`Switch language to ${OTHER_LOCALE}`} href={href}>
|
||||
<Link
|
||||
className="text-neutral-400 hover:text-neutral-500 dark:text-neutral-500 dark:hover:text-neutral-400"
|
||||
href={href}
|
||||
size="s"
|
||||
>
|
||||
{OTHER_LOCALE_LABEL}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user