Files
Khalid Abdi d237504af9 frontend: add Somali, Arabic (RTL) & German languages
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>
2026-07-02 23:03:13 +03:00

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);