Stability fixes: plugin-scoped contexts for owned pages, absent-href guard, checked locale mounts
CI / full-gate (push) Successful in 2m37s

This commit is contained in:
2026-08-03 23:51:40 +02:00
parent 2b20497785
commit be3bc2bdbb
28 changed files with 176 additions and 58 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ export function checkCatalog({ baseline, baselineLocale, catalog, locale }: Pari
}
for (const key of Object.keys(catalog)) {
if (!(key in baseline)) problems.push(`unknown key "${key}" — add it to ${baselineLocale} first`);
if (!Object.hasOwn(baseline, key)) problems.push(`unknown key "${key}" — add it to ${baselineLocale} first`);
}
return problems;
+14 -9
View File
@@ -21,9 +21,10 @@ const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
export const LOCALES_DIR = join(dirname(fileURLToPath(import.meta.url)), "locales");
export const MOUNTED_LOCALES_DIR = join(rootDir, "locales");
// A catalog file is named for the full locale it holds — sv-SE.ts, never sv.ts. Anything else in
// the folder is a mistake worth stopping for.
const LOCALE_FILE = /^([a-z]{2,3}-[A-Z]{2})\.ts$/;
// A catalog file is named for the full locale it holds — sv-SE.ts, never sv.ts — with the script
// subtag when the language needs one (sr-Latn-RS). Anything else in the folder is a mistake worth
// stopping for.
const LOCALE_FILE = /^([a-z]{2,3}(?:-[A-Z][a-z]{3})?-[A-Z]{2})\.ts$/;
export interface LoadI18nOptions {
localesDir?: string;
@@ -46,10 +47,15 @@ export async function loadI18n(options: LoadI18nOptions = {}): Promise<LoadedI18
const logger = options.logger ?? console;
const errors: string[] = [];
const core = await readSet(localesDir, "core", errors);
const shipped = await readSet(localesDir, "core", errors);
// The SHIPPED en-US stays the baseline even when the mount replaces it — otherwise a mounted
// en-US would only ever be compared against itself, and a one-key rewording would boot green with
// the whole UI rendering bare keys.
const baseline = shipped.get(DEFAULT_LOCALE);
if (!baseline) errors.push(`core: no ${DEFAULT_LOCALE}.ts — it is the baseline every other locale is checked against`);
const core = new Map(shipped);
for (const [locale, catalog] of await readSet(mountedDir, "locales", errors)) core.set(locale, catalog);
if (!core.has(DEFAULT_LOCALE)) errors.push(`core: no ${DEFAULT_LOCALE}.ts — it is the baseline every other locale is checked against`);
checkSet(core, "core", errors);
checkSet(core, "core", baseline, errors);
const available = [...core.keys()].sort();
const plugins = new Map<string, Map<string, Catalog>>();
@@ -62,7 +68,7 @@ export async function loadI18n(options: LoadI18nOptions = {}): Promise<LoadedI18
for (const locale of set.keys()) {
if (!available.includes(locale)) errors.push(`plugins/${id}: ${locale} is not installed — add src/i18n/locales/${locale}.ts first`);
}
checkSet(set, `plugins/${id}`, errors);
checkSet(set, `plugins/${id}`, set.get(DEFAULT_LOCALE), errors);
// Legitimate — the plugin's strings fall back to en-US on that page — but an operator who
// installed a locale should hear about the gap at deploy time, not see English islands later.
const gaps = available.filter((locale) => !set.has(locale));
@@ -103,8 +109,7 @@ async function readSet(dir: string, label: string, errors: string[]): Promise<Ma
return set;
}
function checkSet(set: Map<string, Catalog>, label: string, errors: string[]): void {
const baseline = set.get(DEFAULT_LOCALE);
function checkSet(set: Map<string, Catalog>, label: string, baseline: Catalog | undefined, errors: string[]): void {
if (baseline === undefined) return; // already reported; nothing to compare against
for (const [locale, catalog] of set) {
for (const problem of checkCatalog({ baseline, baselineLocale: DEFAULT_LOCALE, catalog, locale })) {
+4
View File
@@ -59,6 +59,10 @@ test("localeHref carries the locale on host-relative links only", () => {
assert.equal(localeHref("https://example.com/x", "sv-SE"), "https://example.com/x"); // off-site
assert.equal(localeHref("//example.com/x", "sv-SE"), "//example.com/x"); // protocol-relative is off-site too
assert.equal(localeHref("", "sv-SE"), "");
// The building blocks document href as optional (an unlinked page item, a header with no sort
// target) — an absent one must not throw, or the page breaks only for visitors who chose a language.
assert.equal(localeHref(undefined as unknown as string, "sv-SE"), undefined);
assert.equal(localeHref(null as unknown as string, "sv-SE"), null);
});
test("textDirection reads the script direction, defaulting to ltr", () => {
+10 -3
View File
@@ -58,10 +58,17 @@ export function resolveLocale({ acceptLanguage, available, param }: ResolveInput
// Carry `locale` on a host-relative link. Off-site and protocol-relative URLs are left alone — the
// locale is ours to state, not theirs. `locale` null (the visitor never asked for one) ⇒ unchanged.
export function localeHref(href: string, locale: string | null): string {
if (locale === null || href === "" || !href.startsWith("/") || href.startsWith("//")) return href;
const url = new URL(href, "http://localhost");
// An absent href is a shape the building blocks document as optional (a non-linked page item, a
// header with no sort target) — it must not throw here, or a page renders for every visitor
// except the ones who chose a language.
if (locale === null || !href || href.startsWith("//")) return href;
// A query-only href ("?" — the filter bar's documented "clear" target) keeps that shape; anything
// else must be host-relative, or it is someone else's URL to state a language for.
const queryOnly = href.startsWith("?");
if (!queryOnly && !href.startsWith("/")) return href;
const url = new URL(href, "http://localhost/");
url.searchParams.set("locale", locale);
return `${url.pathname}${url.search}${url.hash}`;
return queryOnly ? `${url.search}${url.hash}` : `${url.pathname}${url.search}${url.hash}`;
}
// Both are asked for on every render (the <html> tag, the language picker) but depend only on the
-1
View File
@@ -45,7 +45,6 @@ const messages = {
"common.edit": "Edit",
"common.new": "New",
"common.remove": "Remove",
"common.save": "Save",
"consent.allow": "Allow",
"consent.deny": "Deny",
-1
View File
@@ -36,7 +36,6 @@ const messages: CoreMessages = {
"common.edit": "Redigera",
"common.new": "Ny",
"common.remove": "Ta bort",
"common.save": "Spara",
"consent.allow": "Tillåt",
"consent.deny": "Neka",
+4
View File
@@ -24,6 +24,9 @@ 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);
@@ -56,6 +59,7 @@ function rulesFor(locale: string): Intl.PluralRules {
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);
});
+32
View File
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { ENGLISH } from "./english.ts";
import { localeHref } from "./locale.ts";
import { i18nLocals, type I18nRequest } from "./view-locals.ts";
const request = (overrides: Partial<I18nRequest> = {}): I18nRequest => ({
locale: "sv-SE",
localeHref: (href) => href,
locales: ["en-US", "sv-SE"],
t: ENGLISH,
url: new URL("http://localhost/admin/users?q=ada"),
...overrides,
});
test("localeSwitch offers this same page in every installed locale, marking the current one", () => {
const locals = i18nLocals(request());
assert.deepEqual(locals.localeSwitch.map((c) => c.href), ["/admin/users?q=ada&locale=en-US", "/admin/users?q=ada&locale=sv-SE"]);
assert.deepEqual(locals.localeSwitch.map((c) => c.current), [false, true]);
assert.match(locals.localeSwitch[1]?.label ?? "", /svenska/i); // named in its own language
});
test("localeParam is the tag only when the URL asked — it is what the GET forms carry", () => {
// The probe asks the very function that decides, so the two can't drift apart.
assert.equal(i18nLocals(request()).localeParam, null); // identity localeHref ⇒ nothing was chosen
assert.equal(i18nLocals(request({ localeHref: (href) => localeHref(href, "sv-SE") })).localeParam, "sv-SE");
});
test("dir follows the locale's script", () => {
assert.equal(i18nLocals(request()).dir, "ltr");
assert.equal(i18nLocals(request({ locale: "ar-EG" })).dir, "rtl");
});