a005acb93d
CI / full-gate (push) Successful in 2m38s
README loses the competitor comparison, the personas and the repeated philosophy; the
five near-identical E2E command blocks become a table plus one command, and the file
map a clause per entry. AGENTS.md keeps every decision but drops the narrative around
them. todo.md's completed items collapse to their task line — git holds the rest.
Comments lose restatement, README duplication and history ("used to", "originally",
dated notes). AGENTS.md gains a Prose discipline section making this a standing pass on
every change rather than a one-off cleanup.
src/compose.test.ts now expects 6 documented E2E run commands, not 10, since the README
states the command once instead of per suite.
64 lines
2.6 KiB
TypeScript
64 lines
2.6 KiB
TypeScript
// The translator: a key + vars → the string to render. Two rules the rest of the app leans on:
|
|
// · the lookup walks a catalog chain (plugin locale → plugin en-US → core locale → core en-US)
|
|
// and returns the key itself when nothing has it — so a plain nav label like "Shifts" is its
|
|
// own fallback and a manifest needs no catalog to keep working.
|
|
// · the result is raw text, escaped by the view with <%= %> like any other value, so a
|
|
// translation is never double-escaped and one carrying markup is rendered with <%- %>.
|
|
|
|
import { isPluralMessage, type Catalog, type PluralMessage } from "./catalog.ts";
|
|
|
|
export type TranslateVars = Record<string, number | string>;
|
|
export type Translate = (key: string, vars?: TranslateVars) => string;
|
|
|
|
export interface TranslatorOptions {
|
|
catalogs: Catalog[]; // lookup order, most specific first
|
|
locale: string;
|
|
}
|
|
|
|
const PLACEHOLDER = /\{\{(\w+)\}\}/g;
|
|
const pluralRules = new Map<string, Intl.PluralRules>();
|
|
|
|
export function createTranslator({ catalogs, locale }: TranslatorOptions): Translate {
|
|
return (key, vars) => {
|
|
for (const catalog of catalogs) {
|
|
// Own keys only: `t("toString")` must fall through to the key itself like any other unknown
|
|
// one, not pick up Object.prototype.
|
|
if (!Object.hasOwn(catalog, key)) continue;
|
|
const message = catalog[key];
|
|
if (message === undefined) continue;
|
|
return interpolate(isPluralMessage(message) ? selectPlural(message, locale, vars?.["count"]) : message, vars);
|
|
}
|
|
return key;
|
|
};
|
|
}
|
|
|
|
// The form for `count` in this locale, falling back to `other` (and then to any form present, so a
|
|
// half-filled catalog still renders words rather than a blank).
|
|
function selectPlural(message: PluralMessage, locale: string, count: number | string | undefined): string {
|
|
const category = count === undefined ? "other" : rulesFor(locale).select(Number(count));
|
|
return message[category] ?? message.other ?? Object.values(message)[0] ?? "";
|
|
}
|
|
|
|
function rulesFor(locale: string): Intl.PluralRules {
|
|
let rules = pluralRules.get(locale);
|
|
if (!rules) {
|
|
try {
|
|
rules = new Intl.PluralRules(locale);
|
|
} catch {
|
|
rules = new Intl.PluralRules("en-US");
|
|
}
|
|
pluralRules.set(locale, rules);
|
|
}
|
|
return rules;
|
|
}
|
|
|
|
// An unsupplied {{var}} is left standing: a visible placeholder beats a silent blank.
|
|
function interpolate(text: string, vars: TranslateVars | undefined): string {
|
|
if (vars === undefined) return text;
|
|
return text.replace(PLACEHOLDER, (whole, name: string) => {
|
|
if (!Object.hasOwn(vars, name)) return whole;
|
|
const value = vars[name];
|
|
return value === undefined ? whole : String(value);
|
|
});
|
|
}
|