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:
1 parent
8915ef15b2
commit
51ddb28453
8 files changed
+136
-49
No files matched your search
@@ -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 }) => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
Reference in new issue
Block a user