Initial commit of Descrybe v2 without local scratch artifacts.

Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
import { DEFAULT_UI_LOCALE, UI_LOCALES } from "./locales.ts";
import {
MESSAGE_CATALOG,
isRegisteredCatalogLocale,
messagesFor
} from "./messages/catalog.ts";
import type { MessageDict } from "./messages/types.ts";
export type LocaleCoverage = {
code: string;
label: string;
total: number;
translated: number;
missing: number;
missing_keys: string[];
registered: boolean;
};
export function baseMessageKeys(base: MessageDict = MESSAGE_CATALOG[DEFAULT_UI_LOCALE] ?? {}): string[] {
return Object.keys(base).sort((a, b) => a.localeCompare(b));
}
/** Keys present in the base (en) catalog but missing or blank in `locale`. */
export function missingKeysForLocale(locale: string, baseKeys?: string[]): string[] {
const keys = baseKeys ?? baseMessageKeys();
const pack = messagesFor(locale);
const missing: string[] = [];
for (const key of keys) {
const value = pack[key];
if (typeof value !== "string" || !value.trim()) missing.push(key);
}
return missing;
}
export function coverageForLocale(locale: string, baseKeys?: string[]): LocaleCoverage {
const keys = baseKeys ?? baseMessageKeys();
const meta = UI_LOCALES.find((l) => l.code === locale);
const missing = missingKeysForLocale(locale, keys);
return {
code: locale,
label: meta?.label ?? locale,
total: keys.length,
translated: keys.length - missing.length,
missing: missing.length,
missing_keys: missing,
registered: isRegisteredCatalogLocale(locale)
};
}
export function coverageSummary(): LocaleCoverage[] {
const keys = baseMessageKeys();
return UI_LOCALES.map((l) => coverageForLocale(l.code, keys));
}
export function catalogSnapshot(): Record<string, MessageDict> {
const out: Record<string, MessageDict> = {};
for (const locale of UI_LOCALES) {
out[locale.code] = { ...messagesFor(locale.code) };
}
return out;
}
+95
View File
@@ -0,0 +1,95 @@
import { browser } from "$app/environment";
import {
DEFAULT_UI_LOCALE,
UI_LOCALE_KEY,
htmlLangFor,
isUILocale,
normalizeUILocale,
persistUILocale
} from "./locales.ts";
import { loadMessages } from "./messages/catalog.ts";
import { resolveMessage } from "./resolve.ts";
export { UI_LOCALE_KEY } from "./locales.ts";
function readStoredLocale(): string {
if (!browser) return DEFAULT_UI_LOCALE;
try {
const fromWindow = window.__UI_LOCALE__;
if (isUILocale(fromWindow)) return normalizeUILocale(fromWindow);
return normalizeUILocale(localStorage.getItem(UI_LOCALE_KEY));
} catch {
return DEFAULT_UI_LOCALE;
}
}
function paintHtmlLang(code: string): void {
if (!browser) return;
document.documentElement.lang = htmlLangFor(code);
}
function persistLocale(code: string): void {
if (!browser) return;
persistUILocale(code);
}
function createI18n() {
// SSR always starts on English (static pack). Client applies preferred locale after load.
let locale = $state(DEFAULT_UI_LOCALE);
let loadGeneration = 0;
function applyLocale(code: string): void {
locale = code;
if (!browser) return;
persistLocale(locale);
paintHtmlLang(locale);
}
/**
* Switch UI locale after the pack is loaded (dynamic import).
* Until then resolveMessage falls back to English.
*/
function setLocale(next: string): Promise<void> {
const code = normalizeUILocale(next);
const gen = ++loadGeneration;
return loadMessages(code).then(() => {
if (gen !== loadGeneration) return;
applyLocale(code);
});
}
/**
* Resolve a message key for the active UI locale.
* Falls back to English, then to the key itself.
* Interpolated vars are HTML-escaped so t() is safe in text bindings
* even if a future caller uses {@html} with translated strings.
*/
function t(key: string, vars?: Record<string, string | number>): string {
return resolveMessage(locale, key, vars);
}
function syncFromStorage(): Promise<void> {
return setLocale(readStoredLocale());
}
if (browser) {
void setLocale(readStoredLocale());
}
return {
get locale() {
return locale;
},
setLocale,
syncFromStorage,
t
};
}
export const i18n = createI18n();
declare global {
interface Window {
__UI_LOCALE__?: string;
}
}
+308
View File
@@ -0,0 +1,308 @@
/**
* UI i18n unit tests (node:test).
* Pure modules only — no $app / i18n.svelte.ts ($state) imports.
*
* Run from apps/web:
* node --experimental-strip-types --test src/lib/i18n/*.test.ts
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
DEFAULT_UI_LOCALE,
UI_LOCALE_KEY,
UI_LOCALES,
buildUILocaleCookie,
isUILocale,
normalizeUILocale,
persistUILocale,
preferredAcceptLanguage,
preferredAcceptLanguageFor,
readPreferredUILocale,
resolvePreferredUILocale
} from "./locales.ts";
import {
MESSAGE_CATALOG,
isRegisteredCatalogLocale,
loadAllMessages,
loadMessages,
messagesFor
} from "./messages/catalog.ts";
import { en } from "./messages/en.ts";
import {
baseMessageKeys,
coverageSummary,
missingKeysForLocale
} from "./coverage.ts";
import { resolveMessage } from "./resolve.ts";
/** Mirrors setLocale's in-memory switch (normalize + active locale). */
function setLocale(next: string): string {
return normalizeUILocale(next);
}
describe("setLocale / locale switching", () => {
it("normalizes supported codes and rejects unknown to English", () => {
assert.equal(setLocale("es"), "es");
assert.equal(setLocale("FR"), "fr");
assert.equal(setLocale(" de "), "de");
assert.equal(setLocale("xx"), DEFAULT_UI_LOCALE);
assert.equal(setLocale(""), DEFAULT_UI_LOCALE);
assert.equal(setLocale(null as unknown as string), DEFAULT_UI_LOCALE);
});
it("isUILocale matches every UI_LOCALES entry", () => {
for (const { code } of UI_LOCALES) {
assert.equal(isUILocale(code), true, code);
}
assert.equal(isUILocale("xx"), false);
});
it("switching locale changes resolved pack strings", async () => {
await loadMessages("es");
const key = "common.cancel";
const enText = resolveMessage("en", key);
const esText = resolveMessage(setLocale("es"), key);
assert.equal(enText, en[key]);
assert.notEqual(esText, enText);
assert.equal(esText, messagesFor("es")[key]);
});
});
describe("English fallback for missing keys", () => {
it("falls back to English when the active pack omits a key", () => {
const key = "common.cancel";
const english = en[key];
assert.ok(english);
const missingLocale = "zz";
assert.deepEqual(messagesFor(missingLocale), {});
assert.equal(resolveMessage(missingLocale, key), english);
});
it("falls back to the key itself when English also lacks it", () => {
assert.equal(resolveMessage("en", "totally.missing.key"), "totally.missing.key");
assert.equal(resolveMessage("es", "totally.missing.key"), "totally.missing.key");
});
it("HTML-escapes interpolation vars", () => {
const sampleKey = Object.keys(en).find((k) => en[k]?.includes("{name}"));
if (!sampleKey) {
assert.equal(resolveMessage("en", "totally.missing.key"), "totally.missing.key");
return;
}
const out = resolveMessage("en", sampleKey, { name: "<script>x</script>" });
assert.match(out, /&lt;script&gt;/);
assert.doesNotMatch(out, /<script>/);
});
});
describe("pack key parity across UI_LOCALES vs en", () => {
it("MESSAGE_CATALOG registers every UI_LOCALES code", () => {
for (const { code } of UI_LOCALES) {
assert.equal(
isRegisteredCatalogLocale(code),
true,
`missing catalog entry for ${code}`
);
}
assert.ok(MESSAGE_CATALOG.en);
});
it("every UI locale has the same keys as English (no blanks)", async () => {
await loadAllMessages();
const baseKeys = baseMessageKeys();
assert.ok(baseKeys.length >= 350, `expected a full en catalog, got ${baseKeys.length}`);
const summary = coverageSummary();
assert.equal(summary.length, UI_LOCALES.length);
for (const row of summary) {
assert.equal(row.total, baseKeys.length, row.code);
assert.equal(
row.missing,
0,
`${row.code} missing: ${row.missing_keys.slice(0, 5).join(", ")}`
);
assert.equal(row.translated, baseKeys.length, row.code);
assert.equal(row.registered, true, row.code);
assert.deepEqual(missingKeysForLocale(row.code, baseKeys), []);
}
});
it("feed schedule keys distinguish auto interval from manual Sync", () => {
const interval = en["feeds.field.interval"];
const hint = en["feeds.field.intervalHint"];
const manual = en["feeds.syncManualTitle"];
assert.ok(interval && /auto/i.test(interval), `interval label should say auto: ${interval}`);
assert.ok(hint && /sync/i.test(hint) && /interval|schedule/i.test(hint), `hint should contrast Sync vs schedule: ${hint}`);
assert.ok(manual && /one-off|manual|now/i.test(manual), `manual title should clarify one-off: ${manual}`);
assert.notEqual(interval, "Sync interval (minutes)");
});
it("English key count is positive and matches Object.keys(en)", () => {
const keys = baseMessageKeys();
assert.equal(keys.length, Object.keys(en).length);
assert.ok(keys.length >= 350, `expected a full catalog, got ${keys.length}`);
});
});
type LocaleWindow = Window & { __UI_LOCALE__?: string };
function installLocaleStorageMocks(protocol: "http:" | "https:" = "http:") {
const store = new Map<string, string>();
let cookieJar = "";
let lastCookieAssignment = "";
const localStorageMock = {
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
removeItem(key: string) {
store.delete(key);
},
clear() {
store.clear();
}
};
const documentMock = {
get cookie() {
return cookieJar;
},
set cookie(value: string) {
lastCookieAssignment = String(value);
const pair = String(value).split(";")[0] ?? "";
const eq = pair.indexOf("=");
if (eq <= 0) return;
const name = pair.slice(0, eq);
const rest = cookieJar
.split(";")
.map((p) => p.trim())
.filter((p) => p && !p.startsWith(`${name}=`));
rest.push(pair);
cookieJar = rest.join("; ");
}
};
const win = { __UI_LOCALE__: undefined as string | undefined } as LocaleWindow;
const g = globalThis as typeof globalThis & {
window?: LocaleWindow;
localStorage?: typeof localStorageMock;
document?: typeof documentMock;
location?: { protocol: string };
};
const prev = {
window: g.window,
localStorage: g.localStorage,
document: g.document,
location: g.location
};
g.window = win as typeof g.window;
g.localStorage = localStorageMock as typeof g.localStorage;
g.document = documentMock as typeof g.document;
g.location = { protocol } as typeof g.location;
return {
store,
getCookie: () => cookieJar,
getLastCookieAssignment: () => lastCookieAssignment,
win,
restore() {
if (prev.window === undefined) (g as { window?: unknown }).window = undefined;
else g.window = prev.window;
if (prev.localStorage === undefined) (g as { localStorage?: unknown }).localStorage = undefined;
else g.localStorage = prev.localStorage;
if (prev.document === undefined) (g as { document?: unknown }).document = undefined;
else g.document = prev.document;
if (prev.location === undefined) (g as { location?: unknown }).location = undefined;
else g.location = prev.location;
}
};
}
describe("persistUILocale / cookie+localStorage round-trip", () => {
it("buildUILocaleCookie uses UI_LOCALE_KEY and optional Secure", () => {
assert.equal(UI_LOCALE_KEY, "descrybe-ui-locale");
assert.equal(
buildUILocaleCookie("es"),
`${UI_LOCALE_KEY}=es; Path=/; SameSite=Lax; Max-Age=31536000`
);
assert.equal(
buildUILocaleCookie("FR", true),
`${UI_LOCALE_KEY}=fr; Path=/; SameSite=Lax; Max-Age=31536000; Secure`
);
});
it("set locale → descrybe-ui-locale → read back → normalize", () => {
const mock = installLocaleStorageMocks("http:");
try {
const written = persistUILocale(" ES ");
assert.equal(written, "es");
assert.equal(mock.store.get(UI_LOCALE_KEY), "es");
assert.equal(mock.win.__UI_LOCALE__, "es");
assert.match(mock.getCookie(), new RegExp(`(?:^|;\\s*)${UI_LOCALE_KEY}=es(?:;|$)`));
assert.equal(readPreferredUILocale(), "es");
// Prefer localStorage when window marker is cleared (reload / FOUC path).
delete mock.win.__UI_LOCALE__;
assert.equal(readPreferredUILocale(), "es");
assert.equal(persistUILocale("FR"), "fr");
assert.equal(mock.store.get(UI_LOCALE_KEY), "fr");
assert.equal(readPreferredUILocale(), "fr");
assert.equal(persistUILocale("xx"), DEFAULT_UI_LOCALE);
assert.equal(mock.store.get(UI_LOCALE_KEY), DEFAULT_UI_LOCALE);
assert.equal(readPreferredUILocale(), DEFAULT_UI_LOCALE);
} finally {
mock.restore();
}
});
it("writes Secure cookie when location is https", () => {
const mock = installLocaleStorageMocks("https:");
try {
persistUILocale("de");
assert.equal(mock.store.get(UI_LOCALE_KEY), "de");
assert.equal(
mock.getLastCookieAssignment(),
`${UI_LOCALE_KEY}=de; Path=/; SameSite=Lax; Max-Age=31536000; Secure`
);
} finally {
mock.restore();
}
});
it("SSR / no window: normalize only, no throw", () => {
assert.equal(typeof window, "undefined");
assert.equal(persistUILocale("ja"), "ja");
assert.equal(readPreferredUILocale(), DEFAULT_UI_LOCALE);
});
});
describe("preferredAcceptLanguage / readPreferredUILocale → header tag", () => {
it("resolvePreferredUILocale prefers window override over stored", () => {
assert.equal(resolvePreferredUILocale("nl", "en"), "nl");
assert.equal(resolvePreferredUILocale("XX", "fr"), "fr");
assert.equal(resolvePreferredUILocale(null, "de"), "de");
assert.equal(resolvePreferredUILocale(undefined, undefined), DEFAULT_UI_LOCALE);
assert.equal(resolvePreferredUILocale("", " ES "), "es");
});
it("preferredAcceptLanguageFor maps UI locale to BCP 47 Accept-Language tag", () => {
for (const { code, htmlLang } of UI_LOCALES) {
assert.equal(preferredAcceptLanguageFor(code), htmlLang, code);
}
assert.equal(preferredAcceptLanguageFor("NL"), "nl");
assert.equal(preferredAcceptLanguageFor("xx"), "en");
});
it("preferredAcceptLanguage without window defaults to English tag", () => {
assert.equal(typeof window, "undefined");
assert.equal(readPreferredUILocale(), DEFAULT_UI_LOCALE);
assert.equal(preferredAcceptLanguage(), "en");
assert.equal(
preferredAcceptLanguageFor(resolvePreferredUILocale("nl", "en")),
"nl"
);
});
});
+33
View File
@@ -0,0 +1,33 @@
export {
DEFAULT_UI_LOCALE,
UI_LOCALE_KEY,
UI_LOCALES,
buildUILocaleCookie,
htmlLangFor,
isUILocale,
normalizeUILocale,
persistUILocale,
preferredAcceptLanguage,
preferredAcceptLanguageFor,
readPreferredUILocale,
resolvePreferredUILocale,
type UILocale
} from "./locales.ts";
export { i18n } from "./i18n.svelte";
export type { MessageDict } from "./messages/types.ts";
export {
MESSAGE_CATALOG,
isRegisteredCatalogLocale,
loadAllMessages,
loadMessages,
messagesFor
} from "./messages/catalog.ts";
export { resolveMessage } from "./resolve.ts";
export {
baseMessageKeys,
catalogSnapshot,
coverageForLocale,
coverageSummary,
missingKeysForLocale,
type LocaleCoverage
} from "./coverage.ts";
+106
View File
@@ -0,0 +1,106 @@
/**
* UI locale codes the dashboard can plug message catalogs into.
* Distinct from content languages (companies.language / content-languages.ts).
*/
export type UILocale = {
code: string;
label: string;
/** BCP 47 tag for <html lang> (may equal code). */
htmlLang: string;
};
export const DEFAULT_UI_LOCALE = "en";
/** Preference key for dashboard UI locale (independent of companies.language). */
export const UI_LOCALE_KEY = "descrybe-ui-locale";
export const UI_LOCALES: UILocale[] = [
{ code: "en", label: "English", htmlLang: "en" },
{ code: "es", label: "Español", htmlLang: "es" },
{ code: "fr", label: "Français", htmlLang: "fr" },
{ code: "de", label: "Deutsch", htmlLang: "de" },
{ code: "it", label: "Italiano", htmlLang: "it" },
{ code: "pt", label: "Português", htmlLang: "pt" },
{ code: "nl", label: "Nederlands", htmlLang: "nl" },
{ code: "pl", label: "Polski", htmlLang: "pl" },
{ code: "ja", label: "日本語", htmlLang: "ja" }
];
const UI_LOCALE_CODES = new Set(UI_LOCALES.map((l) => l.code));
export function isUILocale(code: string | null | undefined): boolean {
return UI_LOCALE_CODES.has(String(code ?? "").trim().toLowerCase());
}
export function normalizeUILocale(
code: string | null | undefined,
fallback: string = DEFAULT_UI_LOCALE
): string {
const next = String(code ?? "")
.trim()
.toLowerCase();
return UI_LOCALE_CODES.has(next) ? next : fallback;
}
export function htmlLangFor(code: string | null | undefined): string {
const normalized = normalizeUILocale(code);
return UI_LOCALES.find((l) => l.code === normalized)?.htmlLang ?? "en";
}
/** Cookie header value for the UI locale preference (Path=/, SameSite=Lax, 1y). */
export function buildUILocaleCookie(code: string, secure = false): string {
const normalized = normalizeUILocale(code);
const securePart = secure ? "; Secure" : "";
return `${UI_LOCALE_KEY}=${encodeURIComponent(normalized)}; Path=/; SameSite=Lax; Max-Age=31536000${securePart}`;
}
/** Persist normalized UI locale to localStorage, window.__UI_LOCALE__, and document.cookie. No-ops writes when window is undefined; returns the normalized code. */
export function persistUILocale(code: string): string {
const normalized = normalizeUILocale(code);
if (typeof globalThis.window === "undefined") return normalized;
try {
globalThis.localStorage.setItem(UI_LOCALE_KEY, normalized);
} catch {
/* ignore */
}
(globalThis.window as Window & { __UI_LOCALE__?: string }).__UI_LOCALE__ = normalized;
try {
const secure =
typeof globalThis.location !== "undefined" &&
globalThis.location.protocol === "https:";
globalThis.document.cookie = buildUILocaleCookie(normalized, secure);
} catch {
/* ignore */
}
return normalized;
}
/** Pure: resolve UI locale from optional window override + stored preference. */
export function resolvePreferredUILocale(
windowLocale: string | null | undefined,
storedLocale: string | null | undefined
): string {
if (isUILocale(windowLocale)) return normalizeUILocale(windowLocale);
return normalizeUILocale(storedLocale);
}
/** Pure: BCP 47 Accept-Language / <html lang> tag for a UI locale code. */
export function preferredAcceptLanguageFor(locale: string | null | undefined): string {
return htmlLangFor(locale);
}
/** Read stored UI locale for non-component callers (e.g. Accept-Language). */
export function readPreferredUILocale(): string {
if (typeof globalThis.window === "undefined") return DEFAULT_UI_LOCALE;
try {
const fromWindow = (globalThis.window as Window & { __UI_LOCALE__?: string }).__UI_LOCALE__;
return resolvePreferredUILocale(fromWindow, globalThis.localStorage.getItem(UI_LOCALE_KEY));
} catch {
return DEFAULT_UI_LOCALE;
}
}
/** BCP 47 tag for Accept-Language / <html lang>. */
export function preferredAcceptLanguage(): string {
return preferredAcceptLanguageFor(readPreferredUILocale());
}
+54
View File
@@ -0,0 +1,54 @@
import type { MessageDict } from "./types.ts";
import { en } from "./en.ts";
type LocaleModule = Record<string, MessageDict>;
/**
* Per-locale dynamic import factories. Only `en` is statically resident
* (SSR default + missing-key fallback). Other packs load on demand.
*/
const LOCALE_LOADERS: Record<string, () => Promise<LocaleModule>> = {
en: async () => ({ en }),
es: () => import("./es.ts"),
fr: () => import("./fr.ts"),
de: () => import("./de.ts"),
it: () => import("./it.ts"),
pt: () => import("./pt.ts"),
nl: () => import("./nl.ts"),
pl: () => import("./pl.ts"),
ja: () => import("./ja.ts")
};
/**
* Sync cache of packs that have been loaded. English is always present.
* Missing keys still fall back to `en` via resolveMessage / i18n.t.
*/
export const MESSAGE_CATALOG: Record<string, MessageDict> = {
en
};
export function isRegisteredCatalogLocale(locale: string): boolean {
return Object.prototype.hasOwnProperty.call(LOCALE_LOADERS, locale);
}
export function messagesFor(locale: string): MessageDict {
return MESSAGE_CATALOG[locale] ?? {};
}
/** Load (and cache) one locale pack. Idempotent; safe on SSR and client. */
export async function loadMessages(locale: string): Promise<MessageDict> {
const cached = MESSAGE_CATALOG[locale];
if (cached) return cached;
const loader = LOCALE_LOADERS[locale];
if (!loader) return {};
const mod = await loader();
const pack = mod[locale] ?? {};
MESSAGE_CATALOG[locale] = pack;
return pack;
}
/** Load every registered locale (tests / offline coverage tools). */
export async function loadAllMessages(): Promise<Record<string, MessageDict>> {
await Promise.all(Object.keys(LOCALE_LOADERS).map((code) => loadMessages(code)));
return { ...MESSAGE_CATALOG };
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
/** Flat message dictionary. Prefer dotted keys: "nav.dashboard". */
export type MessageDict = Record<string, string>;
+32
View File
@@ -0,0 +1,32 @@
import { messagesFor } from "./messages/catalog.ts";
import { en } from "./messages/en.ts";
function escapeMessageValue(value: string | number): string {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
/**
* Resolve a message key for a UI locale.
* Falls back to English, then to the key itself.
* Interpolated vars are HTML-escaped so callers stay safe in text bindings.
*/
export function resolveMessage(
locale: string,
key: string,
vars?: Record<string, string | number>
): string {
const pack = messagesFor(locale);
let text = pack[key] ?? en[key] ?? key;
if (vars) {
for (const [name, value] of Object.entries(vars)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue;
text = text.replaceAll(`{${name}}`, escapeMessageValue(value));
}
}
return text;
}