mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
d237504af9
Add three UI locales (so/ar/de) with full ~1,660-key translations alongside en/fr, selectable in Settings → Profile. Arabic gets full right-to-left support: - config.ts registers the locales and exports a `dirFor` helper; an inline <head> script in layout.tsx sets <html dir/lang> before first paint (no RTL flash), and i18n-provider keeps them in sync on language change. - ~160 physical direction utilities converted to logical (ms/me/ps/pe/ start/end/text-start/text-end); directional chevrons/arrows get rtl:rotate-180; chat-bubble align variants fixed to logical. - IBM Plex Sans Arabic appended to the sans/heading font stacks for per-character Arabic fallback. - Language persists to the backend user_settings and re-applies on sign-in so it roams across devices (localStorage stays the offline source of truth). - New scripts/check-locales.mjs (npm run check-locales) enforces key/placeholder parity and Arabic CLDR plural completeness. Bump to 0.3.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
120 lines
4.1 KiB
JavaScript
120 lines
4.1 KiB
JavaScript
#!/usr/bin/env node
|
|
// Locale parity check: every locale under lib/i18n/locales must have the same set
|
|
// of logical keys as English, with matching {{interpolation}} placeholders.
|
|
//
|
|
// "Logical key" collapses i18next plural suffixes: `foo`, `foo_one`, `foo_other`,
|
|
// `foo_zero`, `foo_two`, `foo_few`, `foo_many` all map to the logical key `foo`.
|
|
// This lets Arabic carry six CLDR plural forms where English has two, without the
|
|
// check flagging a mismatch.
|
|
//
|
|
// Exit code is non-zero if any locale is missing/extra keys or has a placeholder
|
|
// mismatch. Arabic count-keys missing the full six CLDR forms are warnings only.
|
|
|
|
import { readFileSync, readdirSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { dirname, join } from "node:path";
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const localesDir = join(here, "..", "lib", "i18n", "locales");
|
|
const REFERENCE = "en";
|
|
const PLURAL_SUFFIXES = ["zero", "one", "two", "few", "many", "other"];
|
|
const AR_REQUIRED = ["zero", "one", "two", "few", "many", "other"];
|
|
|
|
/** Flatten a nested object into dotted paths → string values. */
|
|
function flatten(obj, prefix = "", out = {}) {
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
const path = prefix ? `${prefix}.${key}` : key;
|
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
flatten(value, path, out);
|
|
} else {
|
|
out[path] = value;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Strip a trailing i18next plural suffix, returning [logicalKey, suffix|null]. */
|
|
function splitPlural(path) {
|
|
for (const suffix of PLURAL_SUFFIXES) {
|
|
if (path.endsWith(`_${suffix}`)) {
|
|
return [path.slice(0, -(suffix.length + 1)), suffix];
|
|
}
|
|
}
|
|
return [path, null];
|
|
}
|
|
|
|
/** All {{placeholder}} names in a string, sorted and de-duplicated. */
|
|
function placeholders(value) {
|
|
if (typeof value !== "string") return [];
|
|
const found = new Set();
|
|
for (const match of value.matchAll(/\{\{\s*([\w.-]+)\s*\}\}/g)) {
|
|
found.add(match[1]);
|
|
}
|
|
return [...found].sort();
|
|
}
|
|
|
|
function logicalKeys(flat) {
|
|
// logicalKey → { suffixes: Set, placeholders: string[] }
|
|
const map = new Map();
|
|
for (const [path, value] of Object.entries(flat)) {
|
|
const [logical, suffix] = splitPlural(path);
|
|
const entry = map.get(logical) ?? { suffixes: new Set(), placeholders: [] };
|
|
if (suffix) entry.suffixes.add(suffix);
|
|
// Placeholders should be identical across plural forms; take the richest set.
|
|
const ph = placeholders(value);
|
|
if (ph.length > entry.placeholders.length) entry.placeholders = ph;
|
|
map.set(logical, entry);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
const load = (lng) =>
|
|
JSON.parse(readFileSync(join(localesDir, lng, "translation.json"), "utf8"));
|
|
|
|
const reference = logicalKeys(flatten(load(REFERENCE)));
|
|
const locales = readdirSync(localesDir, { withFileTypes: true })
|
|
.filter((d) => d.isDirectory() && d.name !== REFERENCE)
|
|
.map((d) => d.name);
|
|
|
|
let hadError = false;
|
|
|
|
for (const lng of locales) {
|
|
const flat = flatten(load(lng));
|
|
const keys = logicalKeys(flat);
|
|
const errors = [];
|
|
const warnings = [];
|
|
|
|
for (const [key, ref] of reference) {
|
|
if (!keys.has(key)) {
|
|
errors.push(`missing key: ${key}`);
|
|
continue;
|
|
}
|
|
const got = keys.get(key);
|
|
const refPh = ref.placeholders.join(",");
|
|
const gotPh = got.placeholders.join(",");
|
|
if (refPh !== gotPh) {
|
|
errors.push(`placeholder mismatch at ${key}: en[${refPh}] vs ${lng}[${gotPh}]`);
|
|
}
|
|
if (lng === "ar" && ref.suffixes.size > 0) {
|
|
const missing = AR_REQUIRED.filter((s) => !got.suffixes.has(s));
|
|
if (missing.length) {
|
|
warnings.push(`ar plural ${key} missing CLDR forms: ${missing.join(", ")}`);
|
|
}
|
|
}
|
|
}
|
|
for (const key of keys.keys()) {
|
|
if (!reference.has(key)) errors.push(`extra key not in en: ${key}`);
|
|
}
|
|
|
|
if (errors.length) {
|
|
hadError = true;
|
|
console.error(`\n✖ ${lng}: ${errors.length} error(s)`);
|
|
for (const e of errors) console.error(` - ${e}`);
|
|
} else {
|
|
console.log(`✔ ${lng}: parity OK (${reference.size} keys)`);
|
|
}
|
|
for (const w of warnings) console.warn(` ⚠ ${w}`);
|
|
}
|
|
|
|
process.exit(hadError ? 1 : 0);
|