release: WRNexusJS 0.8.0
This commit is contained in:
+60
-134
@@ -1,167 +1,93 @@
|
||||
# @wrnexus/i18n
|
||||
|
||||
> Per-request translations plus locale-aware number, date, and currency formatting for WrNexus apps.
|
||||
Recursive locale loading, fallback resolution, SSR/browser translations, locale formatting, and language UI blocks for WRNexusJS.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
## Locale files
|
||||
|
||||
## Overview
|
||||
Both layouts can be used together:
|
||||
|
||||
`@wrnexus/i18n` loads locale files from `app/locales/<lang>.json`, resolves the
|
||||
active language for each request (cookie → `Accept-Language` → default), and
|
||||
builds a `t(key, params)` translator used both in server code and in `.wrn`
|
||||
views. It also ships Intl-based formatting helpers and a tiny client runtime that
|
||||
wires up a language switcher. Translation lookup, language resolution, and HTML
|
||||
marker rewriting run server-side; only the small `I18N_RUNTIME` snippet runs in
|
||||
the browser.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/i18n
|
||||
```text
|
||||
app/locales/en.json
|
||||
app/locales/en/common.json
|
||||
app/locales/en/auth.json
|
||||
app/locales/mr/common.json
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
## API
|
||||
|
||||
### Loading & resolving
|
||||
|
||||
| Export | Signature | Description |
|
||||
| ------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `loadLocales` | `(dir: string) => Record<string, Messages>` | Reads every `<lang>.json` in `dir` into a `{ lang: messages }` map. Missing dir → `{}`; a bad file is warned and skipped. |
|
||||
| `resolveI18n` | `(messages: Record<string, Messages>, config?: I18nConfig) => ResolvedI18n` | Merges loaded messages + config into a resolved bundle (default lang, supported langs, messages). |
|
||||
| `resolveLang` | `(i18n: ResolvedI18n, cookieValue: string \| undefined, acceptLanguage: string \| null) => string` | Picks the active language: matching cookie → best `Accept-Language` tag (falls back to base tag, e.g. `en-US` → `en`) → `i18n.default`. |
|
||||
| `makeT` | `(i18n: ResolvedI18n, lang: string) => TFunction` | Builds a translator resolving current language → default → the key itself, with `{param}` interpolation. |
|
||||
|
||||
### Types & constants
|
||||
|
||||
| Export | Kind | Notes |
|
||||
| -------------- | ----------- | ------------------------------------------------------------------------------ |
|
||||
| `Messages` | `type` | `Record<string, unknown>` — a locale's messages (supports nested/dotted keys). |
|
||||
| `I18nConfig` | `interface` | `{ default?: string; locales?: string[] }`. |
|
||||
| `ResolvedI18n` | `interface` | `{ default: string; langs: string[]; messages: Record<string, Messages> }`. |
|
||||
| `LANG_COOKIE` | `const` | `"wire-lang"` — the cookie the language is read from / written to. |
|
||||
| `I18N_JS_HREF` | `const` | `"/__wrnexus/i18n.js"` — URL the client runtime is served at. |
|
||||
|
||||
### HTML & client runtime
|
||||
|
||||
| Export | Signature | Description |
|
||||
| ---------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `translateHtml` | `(html: string, t: TFunction) => string` | Rewrites markers in rendered HTML: `t:<attr>="key"` → `<attr>="<translation>"` (attribute-escaped) and `<tag data-t="key">…</tag>` → element text becomes the translation (HTML-escaped). No-op unless a marker is present. |
|
||||
| `renderI18nData` | `(i18n: ResolvedI18n, lang: string) => string` | JS snippet setting `window.__wireI18n = { lang, langs, default }` for the client switcher. |
|
||||
| `I18N_RUNTIME` | `const string` | Browser IIFE that binds `[data-wire-lang-set="es"]` clicks and `select[data-wire-lang]` changes to set the `wire-lang` cookie and reload. Exposes `window.__wireLang.set(lang)`. |
|
||||
|
||||
### Formatting helpers (re-exported from `./format.ts`)
|
||||
|
||||
| Export | Signature | Example |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
|
||||
| `formatNumber` | `(value: number, lang: string, options?: Intl.NumberFormatOptions) => string` | `1234.5 → "1,234.5"` |
|
||||
| `formatCurrency` | `(value: number, currency: string, lang: string) => string` | `9.99, "USD" → "$9.99"` |
|
||||
| `formatDate` | `(value: Date \| number \| string, lang: string, options?: Intl.DateTimeFormatOptions) => string` | defaults to `{ dateStyle: "medium" }` |
|
||||
| `formatRelativeTime` | `(value: number, unit: Intl.RelativeTimeFormatUnit, lang: string) => string` | `-3, "day" → "3 days ago"` (`numeric: "auto"`) |
|
||||
| `plural` | `(count: number, forms: Partial<Record<Intl.LDMLPluralRule, string>>, lang: string) => string` | picks CLDR form; `#` is replaced by `count` |
|
||||
|
||||
## Usage
|
||||
|
||||
### Server: load, resolve, translate
|
||||
Namespaced files become keys such as `common.save` and `auth.signIn`.
|
||||
|
||||
```ts
|
||||
import {
|
||||
loadLocales,
|
||||
resolveI18n,
|
||||
resolveLang,
|
||||
makeT,
|
||||
translateHtml,
|
||||
LANG_COOKIE,
|
||||
} from "@wrnexus/i18n";
|
||||
import { loadLocales, makeT, resolveI18n, resolveLang } from "@wrnexus/i18n";
|
||||
|
||||
// app/locales/en.json, app/locales/es.json
|
||||
const messages = loadLocales("app/locales");
|
||||
const i18n = resolveI18n(messages, { default: "en", locales: ["en", "es"] });
|
||||
const i18n = resolveI18n(loadLocales("app/locales", { strict: true }), {
|
||||
default: "en",
|
||||
locales: ["en", "mr", "hi"],
|
||||
fallbacks: { "mr-IN": ["mr", "en"] },
|
||||
cookie: { name: "wire-lang", sameSite: "Lax", secure: true },
|
||||
});
|
||||
|
||||
// Per request:
|
||||
const lang = resolveLang(i18n, req.cookies?.[LANG_COOKIE], req.headers.get("accept-language"));
|
||||
const lang = resolveLang(i18n, cookieValue, request.headers.get("accept-language"));
|
||||
const t = makeT(i18n, lang);
|
||||
|
||||
t("nav.home"); // dotted key → "Home"
|
||||
t("greeting", { name: "Ada" }); // "Hello, {name}" → "Hello, Ada"
|
||||
|
||||
// After rendering a .wrn view, resolve translation markers in the HTML:
|
||||
const finalHtml = translateHtml(renderedHtml, t);
|
||||
t("common.hello", { name: "Ajay" });
|
||||
```
|
||||
|
||||
`app/locales/en.json`:
|
||||
## Resolution behavior
|
||||
|
||||
```json
|
||||
{
|
||||
"nav": { "home": "Home" },
|
||||
"greeting": "Hello, {name}"
|
||||
}
|
||||
```
|
||||
- normalized BCP-47-style locale names
|
||||
- cookie preference
|
||||
- weighted `Accept-Language`
|
||||
- wildcard language ranges
|
||||
- regional base fallback
|
||||
- explicit fallback chains
|
||||
- configured default language
|
||||
- automatic RTL for Arabic, Hebrew, Persian, Urdu, and related languages
|
||||
|
||||
### Views: translation markers
|
||||
Locale JSON is size-limited and rejects prototype-pollution keys. Recursive namespace collisions are resolved safely.
|
||||
|
||||
## Views and runtime
|
||||
|
||||
```html
|
||||
<h1 data-t="nav.home">Home</h1>
|
||||
<h1 data-t="dashboard.title">Dashboard</h1>
|
||||
<input t:placeholder="search.placeholder" />
|
||||
```
|
||||
|
||||
`translateHtml` replaces the element text for `data-t` and the attribute value for
|
||||
any `t:<attr>` (e.g. `t:placeholder`, `t:aria-label`).
|
||||
Text and translated attributes are resolved during SSR. Active/fallback messages are serialized safely for the language runtime, which rebinds `data-t` markers after client navigation.
|
||||
|
||||
### Client: language switcher
|
||||
Enable `i18nPlugin()` to use:
|
||||
|
||||
```ts
|
||||
import { renderI18nData, I18N_RUNTIME, I18N_JS_HREF } from "@wrnexus/i18n";
|
||||
- `<LanguageSwitcher />`
|
||||
- `<LocaleStatus />`
|
||||
|
||||
// In the document <head>:
|
||||
const head = `
|
||||
<script>${renderI18nData(i18n, lang)}</script>
|
||||
<script src="${I18N_JS_HREF}"></script>
|
||||
`;
|
||||
`LanguageSwitcher` renders a native `select[data-wire-lang]`. The packaged runtime validates the
|
||||
selection against the configured locales, writes the configured language cookie, updates the
|
||||
document `lang`/`dir` attributes, emits `wrnexus:language-change`, and reloads so the next SSR
|
||||
request uses the same cookie. No application-owned browser script is required.
|
||||
|
||||
// Serve I18N_RUNTIME at I18N_JS_HREF; then in markup:
|
||||
// <button data-wire-lang-set="es">Español</button>
|
||||
// <select data-wire-lang>…</select>
|
||||
```
|
||||
## Formatting
|
||||
|
||||
### Formatting
|
||||
- `formatNumber`
|
||||
- `formatCurrency`
|
||||
- `formatDate`
|
||||
- `formatRelativeTime`
|
||||
- `plural`
|
||||
- `createLocaleFormatter`
|
||||
- `translationCoverage`
|
||||
Localization tooling can extract statically discoverable `t("key")`,
|
||||
`i18n.t("key")`, and `data-i18n="key"` usage, compare every locale with a
|
||||
reference, and create layout-stressing pseudo-locales:
|
||||
|
||||
```ts
|
||||
import {
|
||||
formatNumber,
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
formatRelativeTime,
|
||||
plural,
|
||||
auditLocaleKeys,
|
||||
createPseudoLocale,
|
||||
extractTranslationKeysFromFiles,
|
||||
} from "@wrnexus/i18n";
|
||||
|
||||
formatNumber(1234.5, lang); // "1,234.5"
|
||||
formatCurrency(9.99, "USD", lang); // "$9.99"
|
||||
formatDate(Date.now(), lang); // "Jul 4, 2026"
|
||||
formatRelativeTime(-3, "day", lang); // "3 days ago"
|
||||
plural(2, { one: "# item", other: "# items" }, lang); // "2 items"
|
||||
const used = extractTranslationKeysFromFiles(sourceFiles);
|
||||
const coverage = auditLocaleKeys(messages, "en");
|
||||
const enXA = createPseudoLocale(messages.en);
|
||||
const arXB = createPseudoLocale(messages.en, { rtl: true });
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
`resolveI18n` accepts an `I18nConfig`:
|
||||
|
||||
- `default` — fallback language; used when nothing else matches. Ignored if it has
|
||||
no loaded messages, in which case the first supported language is used.
|
||||
- `locales` — explicit supported-language list; defaults to the loaded locale names.
|
||||
|
||||
Language resolution order at request time (`resolveLang`): a supported `wire-lang`
|
||||
cookie value → the first matching `Accept-Language` tag (or its base subtag) → the
|
||||
resolved default.
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only.** Locale loading uses `node:fs` (`existsSync`, `readdirSync`,
|
||||
`readFileSync`) and `node:path`; formatting relies on the platform `Intl` APIs.
|
||||
- Works with [`@wrnexus/core`](../core) — `TFunction` (the `t(key, params)` type)
|
||||
comes from core, and the resolved translator is exposed as `ctx.t` / `ctx.lang`
|
||||
in request handling.
|
||||
- Nested message objects are supported: keys are looked up whole first, then split
|
||||
on `.` to walk the object tree.
|
||||
Pseudo-localization preserves interpolation placeholders and markup tags. RTL
|
||||
pseudo output uses Unicode direction controls, while runtime direction detection
|
||||
continues to derive `rtl` from Arabic and other RTL language subtags.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
component LanguageSwitcher {
|
||||
outputs {
|
||||
change(payload: { locale: string })
|
||||
}
|
||||
props {
|
||||
locales: unknown[] = []
|
||||
current: string = "en"
|
||||
label: string = "Language"
|
||||
placeholder: string = "Choose language"
|
||||
helperText: string = ""
|
||||
variant: string = "select"
|
||||
compact: boolean = false
|
||||
responsive: boolean = true
|
||||
fullWidth: boolean = false
|
||||
showLabel: boolean = true
|
||||
showHelper: boolean = true
|
||||
icon: string = "icon-[lucide--languages]"
|
||||
color: string = "primary"
|
||||
size: string = "md"
|
||||
class: string = ""
|
||||
}
|
||||
functions {
|
||||
client function changed(sourceEvent) {
|
||||
output.change({ locale: sourceEvent.currentTarget.value })
|
||||
}
|
||||
client function selected(locale, sourceEvent) {
|
||||
output.change({ locale: locale })
|
||||
}
|
||||
}
|
||||
view {
|
||||
<div
|
||||
{...attrs}
|
||||
class='wire-language-switcher {fullWidth ? "wire-language-switcher--full" : ""} {responsive ? "wire-language-switcher--responsive" : ""} {compact || variant == "compact" ? "wire-language-switcher--compact" : ""} {class}'
|
||||
data-color='{color}'
|
||||
data-size='{size}'
|
||||
data-variant='{variant}'
|
||||
data-current-language='{current}'
|
||||
>
|
||||
{#if variant == "segmented"}
|
||||
<fieldset class="wire-language-switcher__fieldset">
|
||||
{#if showLabel}<legend class="wire-language-switcher__label">{label}</legend>{/if}
|
||||
<div class="wire-language-switcher__segments" role="group" aria-label='{label}'>
|
||||
{#each locales as locale}
|
||||
<button
|
||||
type="button"
|
||||
class="wire-language-switcher__segment"
|
||||
data-wire-lang-set='{locale.value}'
|
||||
aria-pressed='{locale.value == current}'
|
||||
disabled='{locale.disabled}'
|
||||
@click='selected(locale.value, event)'
|
||||
><span class="wire-language-switcher__long-label">{locale.label}</span><span class="wire-language-switcher__short-label">{locale.shortLabel}</span></button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if showHelper && helperText}<span class="wire-language-switcher__helper">{helperText}</span>{/if}
|
||||
</fieldset>
|
||||
{/if}
|
||||
{#if variant != "segmented"}
|
||||
<label class="wire-language-switcher__field">
|
||||
{#if showLabel}<span class="wire-language-switcher__label">{label}</span>{/if}
|
||||
<span class="wire-language-switcher__control">
|
||||
{#if icon}<span class='{icon} wire-language-switcher__icon' aria-hidden="true"></span>{/if}
|
||||
<select
|
||||
data-wire-lang
|
||||
name="language"
|
||||
aria-label='{label}'
|
||||
class="wire-language-switcher__select"
|
||||
@change='changed(event)'
|
||||
>
|
||||
{#if placeholder}<option value="" disabled selected='{current == ""}'>{placeholder}</option>{/if}
|
||||
{#each locales as locale}<option value='{locale.value}' selected='{locale.value == current}' disabled='{locale.disabled}' data-wire-lang-option data-label-long='{locale.label}' data-label-short='{locale.shortLabel}'>{locale.label}</option>{/each}
|
||||
</select>
|
||||
<span class="icon-[lucide--chevron-down] wire-language-switcher__chevron" aria-hidden="true"></span>
|
||||
</span>
|
||||
{#if showHelper && helperText}<span class="wire-language-switcher__helper">{helperText}</span>{/if}
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wire-language-switcher { min-width: 13rem; color: var(--wire-color-text); }
|
||||
.wire-language-switcher--full, .wire-language-switcher--full .wire-language-switcher__field { width: 100%; }
|
||||
.wire-language-switcher__field, .wire-language-switcher__fieldset { display: flex; margin: 0; padding: 0; border: 0; flex-direction: column; gap: .4rem; }
|
||||
.wire-language-switcher__label { padding: 0; font-size: .8125rem; line-height: 1.2; font-weight: 650; color: var(--wire-color-text); }
|
||||
.wire-language-switcher__control { position: relative; display: flex; align-items: center; }
|
||||
.wire-language-switcher__select { width: 100%; min-height: 2.75rem; appearance: none; border: 1px solid var(--wire-color-border); border-radius: var(--wire-radius-lg); background: var(--wire-color-surface); padding: .65rem 2.5rem .65rem 2.35rem; color: var(--wire-color-text); font: inherit; font-size: .875rem; font-weight: 550; box-shadow: 0 1px 2px color-mix(in srgb, var(--wire-color-text) 7%, transparent); outline: none; transition: border-color .16s ease, box-shadow .16s ease, background .16s ease; }
|
||||
.wire-language-switcher__select:hover { border-color: color-mix(in srgb, var(--wire-color-primary) 45%, var(--wire-color-border)); }
|
||||
.wire-language-switcher__select:focus-visible { border-color: var(--wire-color-primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--wire-color-primary) 18%, transparent); }
|
||||
.wire-language-switcher__icon, .wire-language-switcher__chevron { position: absolute; z-index: 1; width: 1rem; height: 1rem; pointer-events: none; color: var(--wire-color-muted); }
|
||||
.wire-language-switcher__icon { left: .8rem; }
|
||||
.wire-language-switcher__chevron { right: .8rem; }
|
||||
.wire-language-switcher__helper { font-size: .72rem; line-height: 1.35; color: var(--wire-color-muted); }
|
||||
.wire-language-switcher--compact { min-width: 0; }
|
||||
.wire-language-switcher--compact .wire-language-switcher__label, .wire-language-switcher--compact .wire-language-switcher__helper { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }
|
||||
.wire-language-switcher--compact .wire-language-switcher__select { min-height: 2.25rem; border-radius: 999px; padding-top: .4rem; padding-bottom: .4rem; }
|
||||
.wire-language-switcher__segments { display: inline-flex; flex-wrap: wrap; gap: .25rem; border: 1px solid var(--wire-color-border); border-radius: var(--wire-radius-xl); background: var(--wire-color-surface-2); padding: .25rem; }
|
||||
.wire-language-switcher__segment { min-height: 2.25rem; border: 0; border-radius: var(--wire-radius-lg); background: transparent; padding: .45rem .8rem; color: var(--wire-color-muted); font: inherit; font-size: .8125rem; font-weight: 650; cursor: pointer; transition: color .16s ease, background .16s ease, box-shadow .16s ease; }
|
||||
.wire-language-switcher__segment:hover { color: var(--wire-color-text); }
|
||||
.wire-language-switcher__segment[aria-pressed="true"] { background: var(--wire-color-surface); color: var(--wire-color-primary); box-shadow: 0 1px 3px color-mix(in srgb, var(--wire-color-text) 12%, transparent); }
|
||||
.wire-language-switcher__segment:focus-visible { outline: 2px solid var(--wire-color-primary); outline-offset: 2px; }
|
||||
.wire-language-switcher__short-label { display: none; }
|
||||
@media (max-width: 640px) { .wire-language-switcher--responsive { width: 100%; min-width: 0; } .wire-language-switcher--responsive .wire-language-switcher__segments { display: grid; width: 100%; grid-template-columns: repeat(auto-fit, minmax(3.5rem, 1fr)); } .wire-language-switcher--responsive .wire-language-switcher__long-label { display: none; } .wire-language-switcher--responsive .wire-language-switcher__short-label { display: inline; } }
|
||||
@media (prefers-reduced-motion: reduce) { .wire-language-switcher__select, .wire-language-switcher__segment { transition: none; } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
component LocaleStatus {
|
||||
props {
|
||||
locale: string = "en"
|
||||
direction: string = "ltr"
|
||||
translated: number = 0
|
||||
total: number = 0
|
||||
label: string = "Current language"
|
||||
color: string = "primary"
|
||||
size: string = "sm"
|
||||
class: string = ""
|
||||
}
|
||||
view {
|
||||
<Card {...attrs} title='{label}' color='{color}' size='{size}' class='{class}'>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge label='{locale}' color='{color}' variant="soft" size='{size}' />
|
||||
<Badge label='{direction == "rtl" ? "RTL" : "LTR"}' color="neutral" variant="outline" size='{size}' />
|
||||
{#if total > 0}<span class="text-sm text-[var(--wire-color-muted)]">{translated}/{total} translations</span>{/if}
|
||||
</div>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,36 @@
|
||||
{
|
||||
"name": "@wrnexus/i18n",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"main": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": "./src/index.ts",
|
||||
"./plugin": "./src/plugin.ts",
|
||||
"./components/*": "./components/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*"
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/plugin": "workspace:*",
|
||||
"@wrnexus/ui": "workspace:*"
|
||||
},
|
||||
"description": "Locale loading, fallback resolution, SSR/browser translations, formatters, and WRNexusJS language components.",
|
||||
"types": "./src/index.ts",
|
||||
"files": [
|
||||
"src",
|
||||
"components",
|
||||
"README.md"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^5.9.2",
|
||||
"@wrnexus/syntax": "workspace:*"
|
||||
},
|
||||
"wrnexus": {
|
||||
"plugin": {
|
||||
"plugin": "./src/plugin.ts",
|
||||
"export": "default",
|
||||
"factory": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,11 @@ export interface LocaleFormatter {
|
||||
list(values: string[], options?: Intl.ListFormatOptions): string;
|
||||
}
|
||||
|
||||
export function createLocaleFormatter(locale: string, timeZone?: string): LocaleFormatter {
|
||||
export function createLocaleFormatter(
|
||||
locale: string,
|
||||
timeZone?: string,
|
||||
calendar?: string,
|
||||
): LocaleFormatter {
|
||||
const numberCache = new Map<string, Intl.NumberFormat>();
|
||||
const dateCache = new Map<string, Intl.DateTimeFormat>();
|
||||
const key = (value: unknown) => JSON.stringify(value ?? {});
|
||||
@@ -93,7 +97,11 @@ export function createLocaleFormatter(locale: string, timeZone?: string): Locale
|
||||
);
|
||||
},
|
||||
date(value, options = { dateStyle: "medium" }) {
|
||||
const resolved = { ...options, ...(timeZone ? { timeZone } : {}) };
|
||||
const resolved = {
|
||||
...options,
|
||||
...(timeZone ? { timeZone } : {}),
|
||||
...(calendar ? { calendar } : {}),
|
||||
};
|
||||
const cacheKey = key(resolved);
|
||||
let format = dateCache.get(cacheKey);
|
||||
if (!format) {
|
||||
@@ -108,18 +116,60 @@ export function createLocaleFormatter(locale: string, timeZone?: string): Locale
|
||||
};
|
||||
}
|
||||
|
||||
/** Lightweight plural templates: `{count, plural, one {# item} other {# items}}`. */
|
||||
function complexExpression(template: string, start: number) {
|
||||
const header = /^\{(\w+),\s*(plural|select),\s*/.exec(template.slice(start));
|
||||
if (!header) return null;
|
||||
let cursor = start + header[0].length;
|
||||
const choices: Record<string, string> = {};
|
||||
while (cursor < template.length) {
|
||||
while (/\s/.test(template[cursor] ?? "")) cursor++;
|
||||
if (template[cursor] === "}")
|
||||
return { name: header[1]!, kind: header[2]!, choices, end: cursor + 1 };
|
||||
const key = /^(=?[\w-]+)/.exec(template.slice(cursor));
|
||||
if (!key) return null;
|
||||
cursor += key[0].length;
|
||||
while (/\s/.test(template[cursor] ?? "")) cursor++;
|
||||
if (template[cursor] !== "{") return null;
|
||||
const bodyStart = ++cursor;
|
||||
let depth = 1;
|
||||
while (cursor < template.length && depth) {
|
||||
if (template[cursor] === "{") depth++;
|
||||
else if (template[cursor] === "}") depth--;
|
||||
cursor++;
|
||||
}
|
||||
if (depth) return null;
|
||||
choices[key[0]] = template.slice(bodyStart, cursor - 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** ICU-style plural/select templates with exact values and recursive interpolation. */
|
||||
export function formatMessage(
|
||||
template: string,
|
||||
params: Record<string, string | number>,
|
||||
locale: string,
|
||||
): string {
|
||||
const plural = /\{(\w+),\s*plural,\s*one\s*\{([^{}]*)\}\s*other\s*\{([^{}]*)\}\s*\}/g;
|
||||
let result = template.replace(plural, (_match, name: string, one: string, other: string) => {
|
||||
const value = Number(params[name] ?? 0);
|
||||
const selected = new Intl.PluralRules(locale).select(value) === "one" ? one : other;
|
||||
return selected.replace(/#/g, String(value));
|
||||
});
|
||||
let result = "";
|
||||
for (let cursor = 0; cursor < template.length;) {
|
||||
const expression = template[cursor] === "{" ? complexExpression(template, cursor) : null;
|
||||
if (!expression) {
|
||||
result += template[cursor++]!;
|
||||
continue;
|
||||
}
|
||||
const raw = params[expression.name];
|
||||
const selector = expression.kind === "plural" ? `=${Number(raw ?? 0)}` : String(raw ?? "other");
|
||||
const category =
|
||||
expression.kind === "plural"
|
||||
? new Intl.PluralRules(locale).select(Number(raw ?? 0))
|
||||
: selector;
|
||||
const selected =
|
||||
expression.choices[selector] ??
|
||||
expression.choices[category] ??
|
||||
expression.choices.other ??
|
||||
"";
|
||||
result += formatMessage(selected.replace(/#/g, String(raw ?? 0)), params, locale);
|
||||
cursor = expression.end;
|
||||
}
|
||||
result = result.replace(/\{(\w+)\}/g, (_match, name: string) =>
|
||||
name in params ? String(params[name]) : `{${name}}`,
|
||||
);
|
||||
|
||||
+449
-93
@@ -1,105 +1,373 @@
|
||||
/**
|
||||
* @wrnexus/i18n — translations for pages and API responses.
|
||||
*
|
||||
* Locales live in `app/locales/<lang>.json`. Per request the active language is
|
||||
* resolved from the `wire-lang` cookie, then Accept-Language, then the default.
|
||||
* `ctx.t(key, params)` translates on the server; in `.wrn` views `{t:key}` and
|
||||
* `t:attr="key"` markers are resolved by `translateHtml` before the HTML is sent.
|
||||
* @wrnexus/i18n — deterministic locale loading, fallback resolution, SSR
|
||||
* translation markers, browser translation helpers, and UI language controls.
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { basename, extname, join, relative, sep } from "node:path";
|
||||
import type { TFunction } from "@wrnexus/core";
|
||||
|
||||
export { formatNumber, formatCurrency, formatDate, formatRelativeTime, plural } from "./format.ts";
|
||||
export {
|
||||
extractTranslationKeys,
|
||||
extractTranslationKeysFromFiles,
|
||||
flattenMessageKeys,
|
||||
auditLocaleKeys,
|
||||
pseudoLocalize,
|
||||
createPseudoLocale,
|
||||
} from "./tooling.ts";
|
||||
export type { ExtractedTranslationKey } from "./tooling.ts";
|
||||
|
||||
export type Messages = Record<string, unknown>;
|
||||
|
||||
/** Load `<dir>/<lang>.json` files into a `{ lang: messages }` map. */
|
||||
export function loadLocales(dir: string): Record<string, Messages> {
|
||||
const out: Record<string, Messages> = {};
|
||||
if (!existsSync(dir)) return out;
|
||||
for (const file of readdirSync(dir)) {
|
||||
if (!file.endsWith(".json")) continue;
|
||||
const lang = file.replace(/\.json$/, "");
|
||||
try {
|
||||
out[lang] = JSON.parse(readFileSync(join(dir, file), "utf8")) as Messages;
|
||||
} catch (err) {
|
||||
console.warn(`[wrnexus] failed to load locale '${lang}'`, err);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
export interface LocaleLoadOptions {
|
||||
/** Throw on invalid JSON instead of warning and continuing. */
|
||||
strict?: boolean;
|
||||
/** Maximum JSON file size. Default 1 MiB. */
|
||||
maxFileBytes?: number;
|
||||
}
|
||||
|
||||
export interface I18nCookieConfig {
|
||||
name?: string;
|
||||
maxAge?: number;
|
||||
path?: string;
|
||||
sameSite?: "Strict" | "Lax" | "None";
|
||||
secure?: boolean;
|
||||
}
|
||||
|
||||
export interface I18nConfig {
|
||||
/** Default language, used as the fallback and when nothing else matches. */
|
||||
default?: string;
|
||||
/** Explicit set of supported languages (defaults to the loaded locale names). */
|
||||
locales?: string[];
|
||||
/** Human-readable locale names used by package language controls. */
|
||||
labels?: Record<string, string>;
|
||||
/** Per-locale fallback override. Example: `{ "fr-CA": ["fr", "en"] }`. */
|
||||
fallbacks?: Record<string, string[]>;
|
||||
/** Locale direction overrides. Arabic/Hebrew/Persian/Urdu are RTL automatically. */
|
||||
direction?: Record<string, "ltr" | "rtl">;
|
||||
cookie?: I18nCookieConfig;
|
||||
strict?: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedI18n {
|
||||
default: string;
|
||||
langs: string[];
|
||||
messages: Record<string, Messages>;
|
||||
fallbacks: Record<string, string[]>;
|
||||
direction: Record<string, "ltr" | "rtl">;
|
||||
labels: Record<string, string>;
|
||||
cookie: Required<I18nCookieConfig>;
|
||||
}
|
||||
|
||||
export const LANG_COOKIE = "wire-lang";
|
||||
export const I18N_JS_HREF = "/__wrnexus/i18n.js";
|
||||
|
||||
/** Merge loaded locale messages + config into a resolved i18n bundle. */
|
||||
export function resolveI18n(messages: Record<string, Messages>, config?: I18nConfig): ResolvedI18n {
|
||||
const langs = config?.locales ?? Object.keys(messages);
|
||||
const fallback = langs[0] ?? "en";
|
||||
const def = config?.default && messages[config.default] ? config.default : fallback;
|
||||
return { default: def, langs, messages };
|
||||
const UNSAFE_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
||||
const RTL_LANGS = new Set(["ar", "dv", "fa", "he", "ku", "ps", "sd", "ug", "ur", "yi"]);
|
||||
|
||||
export function normalizeLocale(locale: string): string {
|
||||
const value = locale.trim().replace(/_/g, "-");
|
||||
if (!value || !/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(value)) return "";
|
||||
const parts = value.split("-");
|
||||
return parts
|
||||
.map((part, index) => {
|
||||
if (index === 0) return part.toLowerCase();
|
||||
if (part.length === 2) return part.toUpperCase();
|
||||
if (part.length === 4) return part[0]!.toUpperCase() + part.slice(1).toLowerCase();
|
||||
return part;
|
||||
})
|
||||
.join("-");
|
||||
}
|
||||
|
||||
/** Look up a possibly-dotted key in a messages object. */
|
||||
function lookup(messages: Messages | undefined, key: string): string | undefined {
|
||||
if (!messages) return undefined;
|
||||
if (key in messages && typeof messages[key] === "string") return messages[key] as string;
|
||||
function safeObject(value: unknown, path: string): Messages {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(`Locale file '${path}' must contain a JSON object.`);
|
||||
}
|
||||
const output: Messages = Object.create(null) as Messages;
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (UNSAFE_KEYS.has(key)) throw new TypeError(`Unsafe translation key '${key}' in ${path}.`);
|
||||
output[key] =
|
||||
child && typeof child === "object" && !Array.isArray(child)
|
||||
? safeObject(child, `${path}.${key}`)
|
||||
: child;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function mergeMessages(target: Messages, source: Messages): Messages {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (UNSAFE_KEYS.has(key)) continue;
|
||||
const current = target[key];
|
||||
target[key] =
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? mergeMessages(
|
||||
current && typeof current === "object" && !Array.isArray(current)
|
||||
? (current as Messages)
|
||||
: (Object.create(null) as Messages),
|
||||
value as Messages,
|
||||
)
|
||||
: value;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function localeFiles(dir: string): string[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
const files: string[] = [];
|
||||
const visit = (current: string) => {
|
||||
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
||||
const path = join(current, entry.name);
|
||||
if (entry.isDirectory()) visit(path);
|
||||
else if (entry.isFile() && entry.name.endsWith(".json")) files.push(path);
|
||||
}
|
||||
};
|
||||
visit(dir);
|
||||
return files.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load both supported layouts:
|
||||
* - `locales/en.json`
|
||||
* - `locales/en/common.json`, `locales/en/auth.json`
|
||||
*
|
||||
* Namespaced files become `messages.en.common` and `messages.en.auth`.
|
||||
*/
|
||||
export function loadLocales(
|
||||
dir: string,
|
||||
options: LocaleLoadOptions = {},
|
||||
): Record<string, Messages> {
|
||||
const output: Record<string, Messages> = Object.create(null) as Record<string, Messages>;
|
||||
const maxFileBytes = Math.max(1_024, options.maxFileBytes ?? 1_048_576);
|
||||
for (const file of localeFiles(dir)) {
|
||||
const rel = relative(dir, file).split(sep);
|
||||
const rootFile = rel.length === 1;
|
||||
const rawLocale = rootFile ? basename(file, extname(file)) : rel[0]!;
|
||||
const locale = normalizeLocale(rawLocale);
|
||||
if (!locale) {
|
||||
const error = new TypeError(`Invalid locale name '${rawLocale}' in ${file}.`);
|
||||
if (options.strict) throw error;
|
||||
console.warn(`[wrnexus] ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const info = statSync(file);
|
||||
if (info.size > maxFileBytes) throw new Error(`Locale file exceeds ${maxFileBytes} bytes.`);
|
||||
const parsed = safeObject(JSON.parse(readFileSync(file, "utf8")), file);
|
||||
const messages = (output[locale] ??= Object.create(null) as Messages);
|
||||
if (rootFile) mergeMessages(messages, parsed);
|
||||
else {
|
||||
const namespace = rel
|
||||
.slice(1)
|
||||
.join("/")
|
||||
.replace(/\.json$/, "")
|
||||
.replace(/\//g, ".");
|
||||
const parts = namespace.split(".").filter(Boolean);
|
||||
let node = messages;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
const current = node[part];
|
||||
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
||||
node[part] = Object.create(null) as Messages;
|
||||
}
|
||||
node = node[part] as Messages;
|
||||
}
|
||||
const leaf = parts.at(-1);
|
||||
if (leaf) {
|
||||
const current = node[leaf];
|
||||
const target =
|
||||
current && typeof current === "object" && !Array.isArray(current)
|
||||
? (current as Messages)
|
||||
: (Object.create(null) as Messages);
|
||||
node[leaf] = mergeMessages(target, parsed);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (options.strict) throw error;
|
||||
console.warn(`[wrnexus] failed to load locale '${locale}' from ${file}`, error);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function localeDirection(
|
||||
locale: string,
|
||||
overrides: Record<string, "ltr" | "rtl"> = {},
|
||||
): "ltr" | "rtl" {
|
||||
const normalized = normalizeLocale(locale);
|
||||
return (
|
||||
overrides[normalized] ??
|
||||
overrides[normalized.split("-")[0]!] ??
|
||||
(RTL_LANGS.has(normalized.split("-")[0]!) ? "rtl" : "ltr")
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveI18n(
|
||||
messages: Record<string, Messages>,
|
||||
config: I18nConfig = {},
|
||||
): ResolvedI18n {
|
||||
const normalizedMessages: Record<string, Messages> = Object.create(null) as Record<
|
||||
string,
|
||||
Messages
|
||||
>;
|
||||
for (const [locale, value] of Object.entries(messages)) {
|
||||
const normalized = normalizeLocale(locale);
|
||||
if (normalized) normalizedMessages[normalized] = value;
|
||||
}
|
||||
const configured = (config.locales ?? Object.keys(normalizedMessages))
|
||||
.map(normalizeLocale)
|
||||
.filter((locale, index, values) => locale && values.indexOf(locale) === index);
|
||||
const langs = configured.filter((locale) => normalizedMessages[locale]);
|
||||
const fallback = langs[0] ?? (normalizeLocale(config.default ?? "en") || "en");
|
||||
const requestedDefault = normalizeLocale(config.default ?? "");
|
||||
const defaultLocale =
|
||||
requestedDefault && normalizedMessages[requestedDefault] ? requestedDefault : fallback;
|
||||
if (config.strict && !normalizedMessages[defaultLocale]) {
|
||||
throw new Error(`WRN-I18N-DEFAULT-MISSING: ${defaultLocale}`);
|
||||
}
|
||||
const fallbacks: Record<string, string[]> = Object.create(null) as Record<string, string[]>;
|
||||
const direction: Record<string, "ltr" | "rtl"> = Object.create(null) as Record<
|
||||
string,
|
||||
"ltr" | "rtl"
|
||||
>;
|
||||
const labels: Record<string, string> = Object.create(null) as Record<string, string>;
|
||||
for (const locale of langs) {
|
||||
const custom = config.fallbacks?.[locale] ?? config.fallbacks?.[locale.toLowerCase()] ?? [];
|
||||
const base = locale.split("-")[0]!;
|
||||
fallbacks[locale] = [
|
||||
...new Set([
|
||||
locale,
|
||||
...(base !== locale ? [base] : []),
|
||||
...custom.map(normalizeLocale),
|
||||
defaultLocale,
|
||||
]),
|
||||
].filter((entry) => entry && normalizedMessages[entry]);
|
||||
direction[locale] = localeDirection(locale, config.direction);
|
||||
labels[locale] =
|
||||
config.labels?.[locale] ?? config.labels?.[locale.toLowerCase()] ?? locale.toUpperCase();
|
||||
}
|
||||
return {
|
||||
default: defaultLocale,
|
||||
langs,
|
||||
messages: normalizedMessages,
|
||||
fallbacks,
|
||||
direction,
|
||||
labels,
|
||||
cookie: {
|
||||
name: config.cookie?.name ?? LANG_COOKIE,
|
||||
maxAge: config.cookie?.maxAge ?? 31_536_000,
|
||||
path: config.cookie?.path ?? "/",
|
||||
sameSite: config.cookie?.sameSite ?? "Lax",
|
||||
secure: config.cookie?.sameSite === "None" ? true : (config.cookie?.secure ?? false),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function lookupMessage(messages: Messages | undefined, key: string): string | undefined {
|
||||
if (!messages || !key) return undefined;
|
||||
let node: unknown = messages;
|
||||
for (const part of key.split(".")) {
|
||||
if (node && typeof node === "object" && part in (node as Record<string, unknown>)) {
|
||||
node = (node as Record<string, unknown>)[part];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
if (UNSAFE_KEYS.has(part)) return undefined;
|
||||
if (node && typeof node === "object" && !Array.isArray(node) && part in (node as Messages)) {
|
||||
node = (node as Messages)[part];
|
||||
} else return undefined;
|
||||
}
|
||||
return typeof node === "string" ? node : undefined;
|
||||
}
|
||||
|
||||
/** Interpolate `{param}` placeholders in a message. */
|
||||
function interpolate(message: string, params?: Record<string, string | number>): string {
|
||||
export function interpolate(message: string, params?: Record<string, string | number>): string {
|
||||
if (!params) return message;
|
||||
return message.replace(/\{(\w+)\}/g, (_m, name: string) =>
|
||||
name in params ? String(params[name]) : `{${name}}`,
|
||||
return message.replace(/\{([A-Za-z0-9_.-]+)\}/g, (_match, name: string) =>
|
||||
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : `{${name}}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function translationChain(i18n: ResolvedI18n, locale: string): string[] {
|
||||
const normalized = normalizeLocale(locale);
|
||||
return (
|
||||
i18n.fallbacks[normalized] ??
|
||||
[...new Set([normalized, normalized.split("-")[0], i18n.default])].filter(
|
||||
(entry) => entry && i18n.messages[entry],
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Build a `t()` for a language: current → default → the key itself. */
|
||||
export function makeT(i18n: ResolvedI18n, lang: string): TFunction {
|
||||
const chain = translationChain(i18n, lang);
|
||||
return (key, params) => {
|
||||
const message =
|
||||
lookup(i18n.messages[lang], key) ?? lookup(i18n.messages[i18n.default], key) ?? key;
|
||||
return interpolate(message, params);
|
||||
for (const locale of chain) {
|
||||
const message = lookupMessage(i18n.messages[locale], key);
|
||||
if (message !== undefined) return interpolate(message, params);
|
||||
}
|
||||
return key;
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve the active language from a cookie, Accept-Language, then default. */
|
||||
/** Deeply apply tenant-specific translations without mutating the shared locale bundle. */
|
||||
export function withTenantMessages(
|
||||
i18n: ResolvedI18n,
|
||||
overrides: Record<string, Messages>,
|
||||
): ResolvedI18n {
|
||||
const messages: Record<string, Messages> = Object.create(null) as Record<string, Messages>;
|
||||
for (const [locale, value] of Object.entries(i18n.messages))
|
||||
messages[locale] = mergeMessages(
|
||||
safeObject(structuredClone(value), `tenant:${locale}`),
|
||||
overrides[locale]
|
||||
? safeObject(structuredClone(overrides[locale]), `tenant:${locale}:override`)
|
||||
: {},
|
||||
);
|
||||
return { ...i18n, messages };
|
||||
}
|
||||
|
||||
/** Load only common and route-specific messages for one locale. */
|
||||
export function loadRouteMessages(directory: string, locale: string, route: string): Messages {
|
||||
const normalized = normalizeLocale(locale);
|
||||
if (!normalized) throw new Error("WRN-I18N-ROUTE-LOCALE: invalid locale.");
|
||||
const cleanRoute = route.replace(/^\/+|\/+$/g, "").replace(/\[[^\]]+\]/g, "_") || "index";
|
||||
if (!/^[A-Za-z0-9_/-]+$/.test(cleanRoute) || cleanRoute.includes(".."))
|
||||
throw new Error("WRN-I18N-ROUTE-PATH: invalid route namespace.");
|
||||
const result = Object.create(null) as Messages;
|
||||
const candidates = [
|
||||
join(directory, `${normalized}.json`),
|
||||
join(directory, normalized, "common.json"),
|
||||
join(directory, normalized, "routes", `${cleanRoute}.json`),
|
||||
];
|
||||
for (const file of candidates) {
|
||||
if (!existsSync(file)) continue;
|
||||
mergeMessages(result, safeObject(JSON.parse(readFileSync(file, "utf8")), file));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseAcceptLanguage(value: string | null): string[] {
|
||||
return (value ?? "")
|
||||
.split(",")
|
||||
.map((part, index) => {
|
||||
const [tag, ...params] = part.trim().split(";");
|
||||
const quality = params.map((entry) => /^q=([0-9.]+)$/i.exec(entry.trim())?.[1]).find(Boolean);
|
||||
const normalizedTag = tag?.trim() === "*" ? "*" : normalizeLocale(tag ?? "");
|
||||
return { locale: normalizedTag, quality: quality ? Number(quality) : 1, index };
|
||||
})
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.locale && Number.isFinite(entry.quality) && entry.quality > 0 && entry.quality <= 1,
|
||||
)
|
||||
.sort((left, right) => right.quality - left.quality || left.index - right.index)
|
||||
.map((entry) => entry.locale);
|
||||
}
|
||||
|
||||
export function resolveLang(
|
||||
i18n: ResolvedI18n,
|
||||
cookieValue: string | undefined,
|
||||
acceptLanguage: string | null,
|
||||
): string {
|
||||
if (cookieValue && i18n.langs.includes(cookieValue)) return cookieValue;
|
||||
for (const part of (acceptLanguage ?? "").split(",")) {
|
||||
const tag = part.split(";")[0]!.trim().toLowerCase();
|
||||
if (!tag) continue;
|
||||
if (i18n.langs.includes(tag)) return tag;
|
||||
const base = tag.split("-")[0]!;
|
||||
if (i18n.langs.includes(base)) return base;
|
||||
const cookie = normalizeLocale(cookieValue ?? "");
|
||||
if (cookie && i18n.langs.includes(cookie)) return cookie;
|
||||
for (const locale of parseAcceptLanguage(acceptLanguage)) {
|
||||
if (locale === "*") return i18n.default;
|
||||
if (i18n.langs.includes(locale)) return locale;
|
||||
const base = locale.split("-")[0]!;
|
||||
const match = i18n.langs.find(
|
||||
(supported) => supported === base || supported.startsWith(`${base}-`),
|
||||
);
|
||||
if (match) return match;
|
||||
}
|
||||
return i18n.default;
|
||||
}
|
||||
@@ -111,72 +379,158 @@ function attrEscape(value: string): string {
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function htmlEscape(value: string): string {
|
||||
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve translation markers in rendered HTML:
|
||||
* t:<attr>="key" → <attr>="<translation>" (e.g. t:placeholder, t:aria-label)
|
||||
* <tag data-t="key">…</tag> → element text becomes the translation
|
||||
* Only runs when the HTML actually contains a marker.
|
||||
*/
|
||||
export function translateHtml(html: string, t: TFunction): string {
|
||||
if (!html.includes("data-t=") && !html.includes("t:")) return html;
|
||||
|
||||
let out = html.replace(
|
||||
/\bt:([A-Za-z][A-Za-z0-9:_-]*)="([^"]*)"/g,
|
||||
(_m, attr: string, key: string) => `${attr}="${attrEscape(t(key))}"`,
|
||||
let output = html.replace(
|
||||
/\bt:([A-Za-z][A-Za-z0-9:_-]*)=(?:"([^"]*)"|'([^']*)')/g,
|
||||
(_match, attr: string, doubleKey: string | undefined, singleKey: string | undefined) => {
|
||||
const key = doubleKey ?? singleKey ?? "";
|
||||
const translated = t(key);
|
||||
return translated === key ? `${attr}=""` : `${attr}="${attrEscape(translated)}"`;
|
||||
},
|
||||
);
|
||||
|
||||
out = out.replace(
|
||||
/<([A-Za-z][A-Za-z0-9-]*)\b([^>]*\bdata-t="([^"]*)"[^>]*)>([\s\S]*?)<\/\1>/g,
|
||||
(_m, tag: string, attrs: string, key: string) =>
|
||||
`<${tag}${attrs}>${htmlEscape(t(key))}</${tag}>`,
|
||||
output = output.replace(
|
||||
/<([A-Za-z][A-Za-z0-9-]*)\b([^>]*?)\sdata-t=(?:"([^"]*)"|'([^']*)')([^>]*)>([\s\S]*?)<\/\1>/g,
|
||||
(
|
||||
_match,
|
||||
tag: string,
|
||||
before: string,
|
||||
doubleKey: string | undefined,
|
||||
singleKey: string | undefined,
|
||||
after: string,
|
||||
content: string,
|
||||
) => {
|
||||
const key = doubleKey ?? singleKey ?? "";
|
||||
const translated = t(key);
|
||||
return `<${tag}${before} data-t="${attrEscape(key)}"${after}>${
|
||||
translated === key ? content : htmlEscape(translated)
|
||||
}</${tag}>`;
|
||||
},
|
||||
);
|
||||
|
||||
return out;
|
||||
return output;
|
||||
}
|
||||
|
||||
function safeJson(value: unknown): string {
|
||||
return JSON.stringify(value)
|
||||
.replace(/</g, "\\u003c")
|
||||
.replace(/>/g, "\\u003e")
|
||||
.replace(/&/g, "\\u0026")
|
||||
.replace(/\u2028/g, "\\u2028")
|
||||
.replace(/\u2029/g, "\\u2029");
|
||||
}
|
||||
|
||||
/** `window.__wireI18n = { lang, langs }` for the client language switcher. */
|
||||
export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
|
||||
return `window.__wireI18n=${JSON.stringify({ lang, langs: i18n.langs, default: i18n.default })};`;
|
||||
const active = i18n.langs.includes(lang) ? lang : i18n.default;
|
||||
return `window.__wireI18n=${safeJson({
|
||||
lang: active,
|
||||
langs: i18n.langs,
|
||||
default: i18n.default,
|
||||
messages: i18n.messages[active] ?? {},
|
||||
fallbackMessages: active === i18n.default ? {} : (i18n.messages[i18n.default] ?? {}),
|
||||
direction: i18n.direction[active] ?? "ltr",
|
||||
directions: i18n.direction,
|
||||
labels: i18n.labels,
|
||||
cookie: i18n.cookie,
|
||||
})};`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client runtime: binds `[data-wire-lang-set="es"]` elements to set the
|
||||
* `wire-lang` cookie and reload, so the server re-renders in the new language.
|
||||
*/
|
||||
export const I18N_RUNTIME = String.raw`
|
||||
(function () {
|
||||
var COOKIE = "${LANG_COOKIE}";
|
||||
function lookup(messages, key) {
|
||||
var node = messages;
|
||||
var parts = String(key || "").split(".");
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (parts[i] === "__proto__" || parts[i] === "prototype" || parts[i] === "constructor") return undefined;
|
||||
if (!node || typeof node !== "object" || !Object.prototype.hasOwnProperty.call(node, parts[i])) return undefined;
|
||||
node = node[parts[i]];
|
||||
}
|
||||
return typeof node === "string" ? node : undefined;
|
||||
}
|
||||
function interpolate(message, params) {
|
||||
return String(message).replace(/\{([A-Za-z0-9_.-]+)\}/g, function (_, name) {
|
||||
return params && Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : "{" + name + "}";
|
||||
});
|
||||
}
|
||||
function state() { return window.__wireI18n || {}; }
|
||||
function t(key, params) {
|
||||
var current = state();
|
||||
return interpolate(lookup(current.messages, key) || lookup(current.fallbackMessages, key) || key, params);
|
||||
}
|
||||
function set(lang) {
|
||||
document.cookie = COOKIE + "=" + encodeURIComponent(lang) + ";path=/;max-age=31536000;samesite=lax";
|
||||
var current = state();
|
||||
if (current.langs && current.langs.indexOf(lang) === -1) return false;
|
||||
var cookie = current.cookie || {};
|
||||
var secure = cookie.secure || location.protocol === "https:";
|
||||
document.cookie = encodeURIComponent(cookie.name || "${LANG_COOKIE}") + "=" + encodeURIComponent(lang) +
|
||||
";path=" + (cookie.path || "/") + ";max-age=" + (cookie.maxAge || 31536000) +
|
||||
";samesite=" + (cookie.sameSite || "Lax") + (secure ? ";secure" : "");
|
||||
document.documentElement.lang = lang;
|
||||
document.documentElement.dir = (current.directions && current.directions[lang]) || "ltr";
|
||||
window.dispatchEvent(new CustomEvent("wrnexus:language-change", { detail: { locale: lang } }));
|
||||
location.reload();
|
||||
return true;
|
||||
}
|
||||
function bind(root) {
|
||||
var currentLang = (window.__wireI18n && window.__wireI18n.lang) || document.documentElement.lang;
|
||||
(root || document).querySelectorAll("[data-wrn-preferences]").forEach(function (switcher) {
|
||||
var current = state();
|
||||
var currentLang = current.lang || document.documentElement.lang;
|
||||
document.documentElement.lang = currentLang || current.default || "en";
|
||||
if (current.direction) document.documentElement.dir = current.direction;
|
||||
var scope = root || document;
|
||||
var elements = [];
|
||||
if (scope.nodeType === 1) elements.push(scope);
|
||||
scope.querySelectorAll("*").forEach(function (node) { elements.push(node); });
|
||||
elements.forEach(function (node) {
|
||||
Array.from(node.attributes || []).forEach(function (attribute) {
|
||||
if (attribute.name.indexOf("t:") !== 0) return;
|
||||
var target = attribute.name.slice(2);
|
||||
if (!target) return;
|
||||
var translated = t(attribute.value);
|
||||
node.setAttribute(target, translated === attribute.value ? "" : translated);
|
||||
node.removeAttribute(attribute.name);
|
||||
});
|
||||
});
|
||||
scope.querySelectorAll("[data-t]").forEach(function (node) {
|
||||
var key = node.getAttribute("data-t");
|
||||
if (key) node.textContent = t(key);
|
||||
});
|
||||
function updateResponsiveLabels() {
|
||||
var short = window.matchMedia && window.matchMedia("(max-width: 640px)").matches;
|
||||
scope.querySelectorAll("option[data-wire-lang-option]").forEach(function (option) {
|
||||
option.textContent = option.getAttribute(short ? "data-label-short" : "data-label-long") || option.value;
|
||||
});
|
||||
}
|
||||
updateResponsiveLabels();
|
||||
if (!window.__wireI18nResponsiveBound && window.matchMedia) {
|
||||
window.__wireI18nResponsiveBound = true;
|
||||
window.matchMedia("(max-width: 640px)").addEventListener("change", function () { bind(document); });
|
||||
}
|
||||
scope.querySelectorAll("[data-wrn-preferences]").forEach(function (switcher) {
|
||||
switcher.setAttribute("data-current-language", currentLang);
|
||||
var currentLabel = switcher.querySelector(".wire-preferences__current-language");
|
||||
if (currentLabel) currentLabel.textContent = String(currentLang || "en").toUpperCase();
|
||||
var label = switcher.querySelector(".wire-preferences__current-language");
|
||||
if (label) label.textContent = String(currentLang || "en").toUpperCase();
|
||||
});
|
||||
(root || document).querySelectorAll("[data-wire-lang-set]").forEach(function (n) {
|
||||
n.setAttribute("aria-pressed", String(n.getAttribute("data-wire-lang-set") === currentLang));
|
||||
if (n.__wireLangBound) return; n.__wireLangBound = 1;
|
||||
n.addEventListener("click", function () { set(n.getAttribute("data-wire-lang-set")); });
|
||||
scope.querySelectorAll("[data-wire-lang-set]").forEach(function (node) {
|
||||
node.setAttribute("aria-pressed", String(node.getAttribute("data-wire-lang-set") === currentLang));
|
||||
if (node.__wireLangBound) return; node.__wireLangBound = 1;
|
||||
node.addEventListener("click", function () { set(node.getAttribute("data-wire-lang-set")); });
|
||||
});
|
||||
(root || document).querySelectorAll("select[data-wire-lang]").forEach(function (n) {
|
||||
if (n.__wireLangBound) return; n.__wireLangBound = 1;
|
||||
n.addEventListener("change", function () { set(n.value); });
|
||||
scope.querySelectorAll("select[data-wire-lang]").forEach(function (node) {
|
||||
node.value = currentLang;
|
||||
if (node.__wireLangBound) return; node.__wireLangBound = 1;
|
||||
node.addEventListener("change", function () { set(node.value); });
|
||||
});
|
||||
}
|
||||
window.__wireLang = { set: set };
|
||||
window.__wireLang = { set: set, t: t, bind: bind, get lang() { return state().lang; } };
|
||||
window.__wireI18n = Object.assign(state(), { t: t, set: set });
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", function () { bind(document); });
|
||||
else bind(document);
|
||||
})();
|
||||
`.trim();
|
||||
|
||||
export {
|
||||
flattenMessages,
|
||||
localeFallbacks,
|
||||
@@ -185,3 +539,5 @@ export {
|
||||
formatMessage,
|
||||
} from "./advanced.ts";
|
||||
export type { LocaleFormatter } from "./advanced.ts";
|
||||
export { i18nPlugin, i18nComponentsDir } from "./plugin.ts";
|
||||
export type { I18nPluginOptions } from "./plugin.ts";
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { definePlugin } from "@wrnexus/plugin";
|
||||
|
||||
export interface I18nPluginOptions {
|
||||
components?: boolean;
|
||||
componentDir?: string;
|
||||
}
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
export function i18nComponentsDir(): string {
|
||||
return join(packageRoot, "components");
|
||||
}
|
||||
export function i18nPlugin(options: I18nPluginOptions = {}) {
|
||||
return definePlugin({
|
||||
name: "@wrnexus/i18n",
|
||||
version: "0.8.0",
|
||||
componentDirs:
|
||||
options.components === false ? [] : [options.componentDir ?? i18nComponentsDir()],
|
||||
});
|
||||
}
|
||||
export default i18nPlugin;
|
||||
@@ -0,0 +1,104 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { Messages } from "./index.ts";
|
||||
|
||||
export interface ExtractedTranslationKey {
|
||||
key: string;
|
||||
file?: string;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export function extractTranslationKeys(source: string, file?: string): ExtractedTranslationKey[] {
|
||||
const found = new Map<string, ExtractedTranslationKey>();
|
||||
const patterns = [
|
||||
/(?:\b(?:t|\$t)|\bi18n\.t)\s*\(\s*(["'])([^"']+)\1/g,
|
||||
/\bdata-i18n\s*=\s*(["'])([^"']+)\1/g,
|
||||
/\{t:([A-Za-z0-9_.:-]+)\}/g,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
const key = (match[2] ?? match[1])!.trim();
|
||||
if (key && !found.has(key)) found.set(key, { key, file, offset: match.index });
|
||||
}
|
||||
}
|
||||
return [...found.values()].sort((left, right) => left.key.localeCompare(right.key));
|
||||
}
|
||||
|
||||
export function extractTranslationKeysFromFiles(
|
||||
files: Iterable<string>,
|
||||
): ExtractedTranslationKey[] {
|
||||
const found = new Map<string, ExtractedTranslationKey>();
|
||||
for (const file of files) {
|
||||
for (const item of extractTranslationKeys(readFileSync(file, "utf8"), file)) {
|
||||
found.set(`${item.file}:${item.key}`, item);
|
||||
}
|
||||
}
|
||||
return [...found.values()].sort(
|
||||
(left, right) =>
|
||||
String(left.file).localeCompare(String(right.file)) || left.key.localeCompare(right.key),
|
||||
);
|
||||
}
|
||||
|
||||
export function flattenMessageKeys(messages: Messages, prefix = ""): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const [name, value] of Object.entries(messages)) {
|
||||
const key = prefix ? `${prefix}.${name}` : name;
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
keys.push(...flattenMessageKeys(value as Messages, key));
|
||||
} else keys.push(key);
|
||||
}
|
||||
return keys.sort();
|
||||
}
|
||||
|
||||
export function auditLocaleKeys(
|
||||
messages: Record<string, Messages>,
|
||||
referenceLocale: string,
|
||||
): Record<string, { missing: string[]; extra: string[] }> {
|
||||
const reference = new Set(flattenMessageKeys(messages[referenceLocale] ?? {}));
|
||||
const result: Record<string, { missing: string[]; extra: string[] }> = {};
|
||||
for (const [locale, value] of Object.entries(messages)) {
|
||||
const keys = new Set(flattenMessageKeys(value));
|
||||
result[locale] = {
|
||||
missing: [...reference].filter((key) => !keys.has(key)).sort(),
|
||||
extra: [...keys].filter((key) => !reference.has(key)).sort(),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const ACCENTS: Record<string, string> = {
|
||||
a: "à",
|
||||
e: "ë",
|
||||
i: "ï",
|
||||
o: "ô",
|
||||
u: "ü",
|
||||
A: "À",
|
||||
E: "Ë",
|
||||
I: "Ï",
|
||||
O: "Ô",
|
||||
U: "Ü",
|
||||
};
|
||||
|
||||
export function pseudoLocalize(value: string, options: { rtl?: boolean } = {}): string {
|
||||
const parts = value.split(/(\{[^{}]+\}|<[^>]+>)/g);
|
||||
const transformed = parts
|
||||
.map((part) =>
|
||||
/^\{[^{}]+\}$|^<[^>]+>$/.test(part)
|
||||
? part
|
||||
: part.replace(/[aeiouAEIOU]/g, (character) => ACCENTS[character] ?? character),
|
||||
)
|
||||
.join("");
|
||||
return options.rtl ? `\u202e[${transformed}]\u202c` : `[${transformed}~~~]`;
|
||||
}
|
||||
|
||||
export function createPseudoLocale(messages: Messages, options: { rtl?: boolean } = {}): Messages {
|
||||
const output: Messages = Object.create(null) as Messages;
|
||||
for (const [key, value] of Object.entries(messages)) {
|
||||
output[key] =
|
||||
typeof value === "string"
|
||||
? pseudoLocalize(value, options)
|
||||
: value && typeof value === "object" && !Array.isArray(value)
|
||||
? createPseudoLocale(value as Messages, options)
|
||||
: value;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
createLocaleFormatter,
|
||||
formatMessage,
|
||||
loadRouteMessages,
|
||||
resolveI18n,
|
||||
withTenantMessages,
|
||||
} from "../src/index.ts";
|
||||
|
||||
test("formats ICU plural, exact and gender-aware select messages", () => {
|
||||
expect(
|
||||
formatMessage("{count, plural, =0 {None} one {# item} other {# items}}", { count: 2 }, "en"),
|
||||
).toBe("2 items");
|
||||
expect(
|
||||
formatMessage(
|
||||
"{gender, select, female {She} male {He} other {They}} approved",
|
||||
{ gender: "female" },
|
||||
"en",
|
||||
),
|
||||
).toBe("She approved");
|
||||
});
|
||||
test("supports calendars, tenant overrides and route-scoped loading", () => {
|
||||
const formatter = createLocaleFormatter("en-US", "UTC", "indian");
|
||||
expect(
|
||||
formatter.date(new Date("2026-08-02T00:00:00Z"), { year: "numeric", calendar: "indian" }),
|
||||
).toBeTruthy();
|
||||
const base = resolveI18n({ en: { title: "Default" } }, { default: "en" });
|
||||
expect(withTenantMessages(base, { en: { title: "Tenant" } }).messages.en?.title).toBe("Tenant");
|
||||
expect(base.messages.en?.title).toBe("Default");
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-i18n-route-"));
|
||||
mkdirSync(join(root, "en/routes/users"), { recursive: true });
|
||||
writeFileSync(join(root, "en/common.json"), JSON.stringify({ common: "Common" }));
|
||||
writeFileSync(join(root, "en/routes/users/index.json"), JSON.stringify({ title: "Users" }));
|
||||
expect(loadRouteMessages(root, "en", "/users/index")).toEqual({
|
||||
common: "Common",
|
||||
title: "Users",
|
||||
});
|
||||
});
|
||||
@@ -38,3 +38,13 @@ test("translateHtml is a no-op without markers", () => {
|
||||
const t = makeT(i18n, "en");
|
||||
expect(translateHtml("<p>plain</p>", t)).toBe("<p>plain</p>");
|
||||
});
|
||||
|
||||
test("translateHtml preserves authored fallback text when a key is unavailable", () => {
|
||||
const t = (key: string) => key;
|
||||
expect(translateHtml('<h1 data-t="missing.title">Readable fallback</h1>', t)).toBe(
|
||||
'<h1 data-t="missing.title">Readable fallback</h1>',
|
||||
);
|
||||
expect(translateHtml('<input t:placeholder="missing.placeholder" />', t)).toBe(
|
||||
'<input placeholder="" />',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
loadLocales,
|
||||
localeDirection,
|
||||
makeT,
|
||||
renderI18nData,
|
||||
resolveI18n,
|
||||
resolveLang,
|
||||
} from "../src/index.ts";
|
||||
|
||||
describe("i18n package kit", () => {
|
||||
test("loads top-level and namespaced locale files recursively", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "wrn-i18n-"));
|
||||
try {
|
||||
writeFileSync(join(directory, "en.json"), JSON.stringify({ app: { title: "Portal" } }));
|
||||
mkdirSync(join(directory, "fr"));
|
||||
writeFileSync(
|
||||
join(directory, "fr", "common.json"),
|
||||
JSON.stringify({ hello: "Bonjour {name}" }),
|
||||
);
|
||||
const i18n = resolveI18n(loadLocales(directory, { strict: true }), {
|
||||
default: "en",
|
||||
locales: ["en", "fr"],
|
||||
});
|
||||
expect(makeT(i18n, "fr")("common.hello", { name: "Ajay" })).toBe("Bonjour Ajay");
|
||||
expect(resolveLang(i18n, undefined, "en;q=0.6, fr;q=0.9")).toBe("fr");
|
||||
expect(resolveLang(i18n, undefined, "*")).toBe("en");
|
||||
expect(renderI18nData(i18n, "fr")).toContain("Bonjour");
|
||||
expect(renderI18nData(i18n, "fr")).toContain('"directions"');
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("ships a cookie-backed language switcher that targets the native select", () => {
|
||||
const source = readFileSync(
|
||||
join(import.meta.dir, "../components/LanguageSwitcher.wrn"),
|
||||
"utf8",
|
||||
);
|
||||
expect(source).toContain("<select");
|
||||
expect(source).toContain("data-wire-lang");
|
||||
expect(source).toMatch(/<select[\s\S]*data-wire-lang/);
|
||||
expect(source).toContain("selected='{locale.value == current}'");
|
||||
expect(source).toContain('variant == "segmented"');
|
||||
expect(source).toContain("wire-language-switcher--compact");
|
||||
});
|
||||
|
||||
test("replaces primitive namespace collisions safely", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "wrn-i18n-collision-"));
|
||||
try {
|
||||
writeFileSync(join(directory, "en.json"), JSON.stringify({ common: "legacy" }));
|
||||
mkdirSync(join(directory, "en"));
|
||||
writeFileSync(join(directory, "en", "common.json"), JSON.stringify({ save: "Save" }));
|
||||
const i18n = resolveI18n(loadLocales(directory, { strict: true }), { default: "en" });
|
||||
expect(makeT(i18n, "en")("common.save")).toBe("Save");
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves RTL direction from language subtags", () => {
|
||||
expect(localeDirection("ar-IN")).toBe("rtl");
|
||||
expect(localeDirection("en-IN")).toBe("ltr");
|
||||
});
|
||||
|
||||
test("rejects prototype-polluting locale keys", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "wrn-i18n-unsafe-"));
|
||||
try {
|
||||
writeFileSync(join(directory, "en.json"), '{"constructor":{"prototype":{"polluted":true}}}');
|
||||
expect(() => loadLocales(directory, { strict: true })).toThrow();
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
auditLocaleKeys,
|
||||
createPseudoLocale,
|
||||
extractTranslationKeys,
|
||||
flattenMessageKeys,
|
||||
localeDirection,
|
||||
pseudoLocalize,
|
||||
} from "../src/index.ts";
|
||||
|
||||
test("extracts static translation keys from code and WRN attributes", () => {
|
||||
const keys = extractTranslationKeys(`
|
||||
const title = t("account.title")
|
||||
const copy = i18n.t('account.copy')
|
||||
const dynamic = t(key)
|
||||
<p data-i18n="account.help"></p>
|
||||
`);
|
||||
expect(keys.map(({ key }) => key)).toEqual(["account.copy", "account.help", "account.title"]);
|
||||
});
|
||||
|
||||
test("audits locale completeness against a reference locale", () => {
|
||||
const messages = {
|
||||
en: { common: { hello: "Hello", bye: "Bye" } },
|
||||
fr: { common: { hello: "Bonjour", extra: "Extra" } },
|
||||
};
|
||||
expect(flattenMessageKeys(messages.en)).toEqual(["common.bye", "common.hello"]);
|
||||
expect(auditLocaleKeys(messages, "en").fr).toEqual({
|
||||
missing: ["common.bye"],
|
||||
extra: ["common.extra"],
|
||||
});
|
||||
});
|
||||
|
||||
test("pseudo-locales preserve placeholders and exercise LTR and RTL layouts", () => {
|
||||
expect(pseudoLocalize("Hello {name} <strong>today</strong>")).toContain("{name}");
|
||||
const pseudo = createPseudoLocale({ greeting: "Hello {name}", nested: { save: "Save" } });
|
||||
expect(pseudo.greeting).toBe("[Hëllô {name}~~~]");
|
||||
expect((pseudo.nested as Record<string, string>).save).toBe("[Sàvë~~~]");
|
||||
expect(pseudoLocalize("Hello {name}", { rtl: true }).startsWith("\u202e[")).toBe(true);
|
||||
expect(localeDirection("ar-XB")).toBe("rtl");
|
||||
});
|
||||
Reference in New Issue
Block a user