Various build shenenigans (#5)

This PR extends the build pipeline of the www service to support
locale-prefixed static builds (EN/RU) with build-time SSG output
injection, while also refactoring UI typography to a tokenized Tailwind
v4 @theme-driven approach.

Changes:

- Add build-time SSG plugin to @ermnvldmr/rsbuild-config and wire
multi-locale builds (/en/, /ru/) into services/www.
- Introduce new @ermnvldmr/i18n package (locale constants, path helpers,
keysets, pluralization) and apply it to www navigation/text.
- Refactor ui typography to a shared getTypographyClassNames utility +
Tailwind token definitions, with stl/cn updated for correct
Tailwind-merge behavior.
This commit is contained in:
deytenit authored and GitHub committed 2026-03-16 00:00:20 +03:00
1 parent ac9c455e5a
commit b84ef775b8
68 files changed
+2991 -397

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
{
"name": "@ermnvldmr/i18n",
"version": "1.0.0",
"description": "Internationalization utilities for ermnvldmr projects",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./src/index.ts"
},
"./dev": {
"import": "./dist/dev.js",
"types": "./dist/dev.d.ts",
"default": "./src/dev.ts"
},
"./testing": {
"import": "./dist/testing.js",
"types": "./dist/testing.d.ts",
"default": "./src/testing.ts"
}
},
"files": ["dist"],
"scripts": {
"build": "rsbuild build",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src --max-warnings 0",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist coverage"
},
"peerDependencies": {
"react": "^19.1.0"
},
"devDependencies": {
"@ermnvldmr/eslint-config": "workspace:*",
"@ermnvldmr/rsbuild-config": "workspace:*",
"@rsbuild/core": "^1.2.11",
"@types/react": "^19.1.2",
"@vitest/coverage-v8": "^4.0.18",
"react": "^19.1.0",
"typescript": "^5.8.3",
"vitest": "^4.0.18"
}
}
+27
View File
@@ -0,0 +1,27 @@
import { definePackageConfig } from '@ermnvldmr/rsbuild-config/dev';
export default definePackageConfig({
source: {
entry: {
index: './src/index.ts',
dev: './src/dev.ts',
testing: './src/testing.ts',
},
alias: { '@': './src' },
},
output: {
target: 'web',
distPath: { root: 'dist', js: '' },
filename: { js: '[name].js' },
cleanDistPath: true,
},
tools: {
htmlPlugin: false,
rspack: {
output: { library: { type: 'module' } },
experiments: { outputModule: true },
externals: ['react', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
externalsType: 'module',
},
},
});
+60
View File
@@ -0,0 +1,60 @@
import { DEFAULT_LOCALE, isLocale, type Locale } from './lib/locale.js';
import type { RsbuildConfig } from '@rsbuild/core';
/**
* Returns a partial Rsbuild config that enables locale-aware builds.
*
* Designed to be merged with defineServiceConfig() in each service's rsbuild.config.ts:
*
* @example
* ```ts
* import { localeRsbuildConfig } from '@ermnvldmr/i18n/dev';
* import { defineServiceConfig, mergeConfig } from '@ermnvldmr/rsbuild-config/dev';
*
* export default defineServiceConfig(mergeConfig(localeRsbuildConfig(), { ... }));
* ```
*/
export function localeRsbuildConfig(): RsbuildConfig {
const rawLocale = process.env.LOCALE ?? '';
const locale: Locale = isLocale(rawLocale) ? rawLocale : DEFAULT_LOCALE;
return {
source: {
define: {
'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}`,
},
assetPrefix: `/${locale}/`,
},
tools: {
rspack: (config, { rspack }) => {
// Resolve .en.mdx imports to the locale-specific file when LOCALE != 'en'.
// The canonical import is always `.en.mdx`; other locales are resolved here.
if (locale !== DEFAULT_LOCALE) {
config.plugins = [
...config.plugins,
new rspack.NormalModuleReplacementPlugin(
/\.en\.mdx$/,
(resource: { request: string }) => {
resource.request = resource.request.replace(/\.en\.mdx$/, `.${locale}.mdx`);
}
),
];
}
return config;
},
},
};
}
export { LOCALES, DEFAULT_LOCALE, type Locale } from './lib/locale.js';
+4
View File
@@ -0,0 +1,4 @@
export * from './lib/locale.js';
export * from './lib/pluralize.js';
export * from './lib/keyset.js';
export * from './lib/path.js';
+30
View File
@@ -0,0 +1,30 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { createKeyset } from './keyset.js';
const { t, tRaw } = createKeyset({
greeting: { en: 'Hello', ru: 'Привет' },
'count {value}': { en: 'count {value}', ru: 'счёт {value}' },
items: {
en: { none: 'no items', one: 'one item', some: '{count} items', many: '{count} items' },
ru: { none: 'нет элементов', one: 'один элемент', some: '{count} элемента', many: '{count} элементов' },
},
});
describe('t', () => {
it('returns plain translation', () => expect(t('greeting')).toBe('Hello'));
it('interpolates {value}', () => expect(t('count {value}', { value: '42' })).toBe('count 42'));
it('pluralizes none', () => expect(t('items', { count: 0 })).toBe('no items'));
it('pluralizes one', () => expect(t('items', { count: 1 })).toBe('one item'));
it('pluralizes many with interpolation', () =>
expect(t('items', { count: 5 })).toBe('5 items'));
});
describe('tRaw', () => {
it('returns string node for plain key', () => expect(tRaw('greeting')).toBe('Hello'));
it('interpolates ReactNode', () => {
const node = tRaw('count {value}', { value: React.createElement('strong', null, '42') });
expect(Array.isArray(node)).toBe(true);
});
});
+132
View File
@@ -0,0 +1,132 @@
import { LOCALE } from './locale.js';
import { pluralize, type PluralForms } from './pluralize.js';
import type { ReactNode } from 'react';
/** A translation entry with a plain string per locale. */
interface PlainTranslation { en: string; ru: string }
/** 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.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}}`
);
}
/**
* 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 = /^\{(\w+)\}$/.exec(part);
if (match) {
const key = match[1];
return key in params ? params[key] : part;
}
return part;
});
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) {
/** 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];
if (isPluralTranslation(entry)) {
const forms = entry[LOCALE];
const count = typeof params?.count === 'number' ? params.count : 0;
const template = pluralize(count, forms);
return params ? interpolate(template, params) : template;
}
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];
if (isPluralTranslation(entry)) {
const forms = entry[LOCALE];
const countParam = params?.count;
const count = typeof countParam === 'number' ? countParam : 0;
const template = pluralize(count, forms);
return params ? interpolateRaw(template, params) : template;
}
const template = entry[LOCALE];
return params ? interpolateRaw(template, params) : template;
}
return { t, tRaw };
}
+50
View File
@@ -0,0 +1,50 @@
/** 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).
*/
const rawLocale = process.env.LOCALE ?? '';
export const LOCALE: Locale = isLocale(rawLocale) ? rawLocale : DEFAULT_LOCALE;
/**
* The other (non-active) locale for the current build.
* Constant-folded at build time.
*/
export const OTHER_LOCALE: Locale = LOCALES.find((l) => l !== LOCALE) ?? DEFAULT_LOCALE;
/**
* Human-readable label for switching to the other locale,
* expressed in the CURRENT locale's language.
* - EN build → "In Russian"
* - RU build → "На Английском"
*/
const SWITCH_LABELS: Record<Locale, Partial<Record<Locale, string>>> = {
en: { ru: 'In Russian' },
ru: { en: 'На Английском' },
};
export const OTHER_LOCALE_LABEL: string =
SWITCH_LABELS[LOCALE][OTHER_LOCALE] ?? OTHER_LOCALE.toUpperCase();
+21
View File
@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import { localePath, localeHref } from './path.js';
describe('localePath', () => {
it('prepends LOCALE to absolute path', () =>
expect(localePath('/articles')).toBe('/en/articles'));
it('handles root path', () =>
expect(localePath('/')).toBe('/en/'));
it('handles nested path', () =>
expect(localePath('/articles/2026/test')).toBe('/en/articles/2026/test'));
});
describe('localeHref', () => {
it('replaces locale prefix in URL', () =>
expect(localeHref('/en/articles', 'ru')).toBe('/ru/articles'));
it('replaces locale at root', () =>
expect(localeHref('/en/', 'ru')).toBe('/ru/'));
it('falls back to path if no prefix found', () =>
expect(localeHref('/articles', 'ru')).toBe('/ru/articles'));
});
+57
View File
@@ -0,0 +1,57 @@
import { LOCALE, LOCALES, type Locale } from './locale.js';
/**
* Resolves any href value to its locale-aware form.
*
* 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
* // 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(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 (href === '/') return `/${LOCALE}/`;
return `/${LOCALE}${href}`;
}
/**
* 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 = localePrefix.exec(currentPath);
if (match) {
return `/${locale}${match[2] || '/'}`;
}
return `/${locale}${currentPath}`;
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { pluralize } from './pluralize.js';
const forms = { none: 'none', one: 'one', some: 'some', many: 'many' };
describe('pluralize', () => {
it('returns none for 0', () => expect(pluralize(0, forms)).toBe('none'));
it('returns one for 1', () => expect(pluralize(1, forms)).toBe('one'));
it('returns some for 2', () => expect(pluralize(2, forms)).toBe('some'));
it('returns some for 4', () => expect(pluralize(4, forms)).toBe('some'));
it('returns many for 5', () => expect(pluralize(5, forms)).toBe('many'));
it('returns many for 11', () => expect(pluralize(11, forms)).toBe('many'));
it('returns many for 12', () => expect(pluralize(12, forms)).toBe('many'));
it('returns many for 100', () => expect(pluralize(100, forms)).toBe('many'));
it('returns one for 21', () => expect(pluralize(21, forms)).toBe('one'));
it('returns some for 22', () => expect(pluralize(22, forms)).toBe('some'));
it('returns many for 111', () => expect(pluralize(111, forms)).toBe('many'));
});
+30
View File
@@ -0,0 +1,30 @@
/** The four plural categories used across EN and RU grammar rules. */
export interface PluralForms {
none: string;
one: string;
some: string;
many: string;
}
/**
* 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;
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return forms.one;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return forms.some;
return forms.many;
}
+2
View File
@@ -0,0 +1,2 @@
export { LOCALE, DEFAULT_LOCALE, isLocale } from './lib/locale.js';
export { localePath, localeHref } from './lib/path.js';
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2020", "DOM"],
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src"]
}