Compare commits

...
10 Commits
Author SHA1 Message Date
deytenit 07af9bb0a7 fix(ci): build and distribute i18n artifact in ui/static workflows; fix unit tests
- Add `@ermnvldmr/i18n` build step and artifact upload/download to
  packages-ui and services-static pr/next workflows so the package dist
  is available during build, typecheck, lint and test jobs
- Add `packages/i18n/**` to paths triggers for those workflows
- Update Breadcrumbs.test to expect locale-prefixed href (/en/test-url)
  now that the Link component transparently calls localePath()
- Add functional localStorage polyfill to vitest setup to fix
  window.localStorage.clear() TypeError in ThemeSwitch tests (jsdom quirk)

Made-with: Cursor
2026-03-15 23:51:18 +03:00
deytenit 561118b274 feat(www): add Russian localization for homepage and articles
Complete Russian translations for homepage copy and all current www articles so the RU locale has native-quality content parity with EN.

Made-with: Cursor
2026-03-15 23:34:30 +03:00
deytenit bb4c71d51c style: prettier formatting
Made-with: Cursor
2026-03-15 23:23:01 +03:00
deytenit 51ddb28453 fix(i18n): resolve all lint errors across i18n package
- locale.ts: replace type assertions with isLocale() guard, widen
  LOCALES via assignment (no cast), remove unnecessary optional chain
- dev.ts: fix import order, use isLocale() for locale detection,
  remove superfluous ?? [] spread on config.plugins
- keyset.ts: add JSDoc, use typeof t.en === 'object' discriminant
  instead of assertion in isPluralTranslation, constrain Keys to
  keyof K & string, remove unreachable !entry guards, use
  type-predicate every() to narrow result array in interpolateRaw
- path.ts: fix JSDoc multi-line indentation, use RegExp#exec()
  in localeHref, expand @example blocks
- pluralize.ts: add JSDoc description and @example to PluralForms
  and pluralize

Made-with: Cursor
2026-03-15 23:07:04 +03:00
deytenit 8915ef15b2 fix(www): use neutral-400/500 gray for LocaleSwitch in both themes
Made-with: Cursor
2026-03-15 22:56:44 +03:00
deytenit c0abd7993d fix(www): style LocaleSwitch as muted Link/s, place below ThemeSwitch
Made-with: Cursor
2026-03-15 22:54:51 +03:00
deytenit bac1aa3705 refactor(i18n): localePath proxies null/undefined with correct overloads
Made-with: Cursor
2026-03-15 22:52:42 +03:00
deytenit 7ae8673016 refactor(i18n): make localePath a universal href resolver
localePath now handles any href unconditionally — external URLs,
mailto/tel, relative paths, and anchors pass through unchanged.
Call-sites in Link and Breadcrumbs.Item no longer need startsWith('/')
guards; both are simplified to a direct localePath(href) call.

Made-with: Cursor
2026-03-15 22:51:30 +03:00
deytenit c568e3da92 feat(ui): auto-prefix internal hrefs with locale in Link and Breadcrumbs.Item
localePath is now idempotent (skips paths already carrying any locale
prefix), so explicit localePath() call-sites remain safe.

Link and Breadcrumbs.Item auto-apply localePath to any href starting
with '/', eliminating the need to wrap every internal href manually.
External URLs (http/https/mailto/tel) and relative paths are untouched.

Adds @ermnvldmr/i18n as a runtime dependency of @ermnvldmr/ui following
the established stl -> i18n -> ui dependency order.

Made-with: Cursor
2026-03-15 22:49:48 +03:00
deytenit 0764c24a1c fix(i18n): serve dev under locale base instead of NODE_ENV branching
Add server.base = /${locale} to localeRsbuildConfig so the Rsbuild dev
server serves pages at /en/... by default, matching the localePath()
output. Revert the NODE_ENV check — localePath is now environment-agnostic.

Made-with: Cursor
2026-03-15 22:43:45 +03:00
26 changed files with 872 additions and 108 deletions

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
+10 -4
View File
@@ -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 }) => {
+2 -1
View File
@@ -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({
+76 -19
View File
@@ -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;
}
+20 -7
View File
@@ -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
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest';
import { localePath, localeHref } from './path.js';
describe('localePath', () => {
+38 -20
View File
@@ -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
View File
@@ -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' };
+11
View File
@@ -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;
+16
View File
@@ -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; },
},
});
}
+1
View File
@@ -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}
+3 -1
View File
@@ -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}
>
+3
View File
@@ -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/)
+8 -5
View File
@@ -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>
+45
View File
@@ -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: 'Хотя остальная часть пространства еще дорабатывается, каналы связи уже работают в полном объеме. Если хотите обсудить технологии, поделиться мнением о прочитанном или просто познакомиться, почта всегда открыта:',
},
});
+41 -35
View File
@@ -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>
);
};