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
This commit is contained in:
deytenit committed 2026-03-15 23:07:04 +03:00
1 parent 8915ef15b2
commit 51ddb28453
8 files changed
+136 -49

No files matched your search

+5 -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: {
@@ -41,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', () => {
+20 -18
View File
@@ -3,25 +3,23 @@ import { LOCALE, LOCALES, type Locale } from './locale.js';
/**
* Resolves any href value to its locale-aware form.
*
* - Absolute internal paths (`/...`) are prefixed with the current build locale.
* - Everything else (external URLs, mailto/tel, relative paths, hash anchors)
* is returned unchanged.
* - Idempotent: paths already carrying any known locale prefix are not modified.
*
* This means `localePath` can be called unconditionally on any `href` value —
* no need for `href.startsWith('/')` guards at the call-site.
* 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, unchanged)
* localePath('mailto:a@b.com') → 'mailto:a@b.com' (unchanged)
* localePath('#section') → '#section' (unchanged)
* 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;
@@ -31,9 +29,7 @@ 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;
// Only rewrite absolute internal paths.
if (!href.startsWith('/')) return href;
// Already carries any locale prefix — leave it alone.
if (LOCALES.some((l) => href === `/${l}` || href === `/${l}/` || href.startsWith(`/${l}/`))) {
return href;
}
@@ -42,12 +38,18 @@ export function localePath(href: string | null | undefined): string | null | und
}
/**
* 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;