Files
HealthLog/scripts/verify-message-catalogs.mjs
Marc-André Bombeck 7fda400a8c fix(i18n): serve English for an unreadable catalog, refuse to boot without it
The server reads messages/<locale>.json from disk on first use. A missing
or corrupt file threw, and since the matchers iterate every locale, one bad
non-English catalog turned every request that touched them into a 500.
That locale is now served the English catalog, and the failure is logged
once as i18n.catalog.fallback with the locale and the reason. English is
the fallback itself: a process whose messages/en.json is missing or does
not parse refuses to start, with the file and the reason in the message.

The image build checks the catalogs twice with
scripts/verify-message-catalogs.mjs: in the builder against the locale
list in the source, and in the runner stage against the copies the image
carries.
2026-10-22 18:43:03 +02:00

99 lines
3.1 KiB
JavaScript

#!/usr/bin/env node
/**
* Checks that every shipped locale has a message catalog that parses.
*
* The server reads `messages/<locale>.json` from disk at run time
* (`src/lib/i18n/server-catalogs.ts`); nothing is bundled, so an image that
* lost the directory, or one file of it, builds and starts clean and only
* shows the gap when a page asks for that locale. The Dockerfile runs this
* twice: in the builder against the locale list in `src/lib/i18n/config.ts`,
* writing that list out, and in the runner stage against the catalogs the
* image actually carries.
*
* node scripts/verify-message-catalogs.mjs --dir messages \
* --config src/lib/i18n/config.ts [--write .message-locales]
* node verify-message-catalogs.mjs --dir /app/messages \
* --locales .message-locales
*
* Exits 1 with every problem listed when a catalog is missing, does not parse
* or is not a JSON object, or when the locale list cannot be read.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
function arg(name) {
const index = process.argv.indexOf(`--${name}`);
return index === -1 ? null : (process.argv[index + 1] ?? null);
}
function localesFromConfig(path) {
const source = readFileSync(path, "utf8");
const block = /export const locales = \[([^\]]*)\]/.exec(source);
if (!block) throw new Error(`no \`export const locales = [...]\` in ${path}`);
const list = [...block[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
if (list.length === 0) throw new Error(`the locale list in ${path} is empty`);
return list;
}
function localesFromFile(path) {
const list = readFileSync(path, "utf8").split("\n").filter(Boolean);
if (list.length === 0) throw new Error(`the locale list in ${path} is empty`);
return list;
}
const dir = arg("dir");
const config = arg("config");
const listFile = arg("locales");
if (!dir || (!config && !listFile)) {
console.error(
"usage: verify-message-catalogs.mjs --dir <messages> (--config <config.ts> | --locales <file>) [--write <file>]",
);
process.exit(2);
}
let locales;
try {
locales = config ? localesFromConfig(config) : localesFromFile(listFile);
} catch (error) {
console.error(`ERROR: ${error.message}`);
process.exit(1);
}
const problems = [];
for (const locale of locales) {
const path = join(dir, `${locale}.json`);
let text;
try {
text = readFileSync(path, "utf8");
} catch (error) {
problems.push(
`${path}: ${error.code === "ENOENT" ? "missing" : error.message}`,
);
continue;
}
try {
const parsed = JSON.parse(text);
if (
parsed === null ||
typeof parsed !== "object" ||
Array.isArray(parsed)
) {
problems.push(`${path}: not a JSON object`);
}
} catch (error) {
problems.push(`${path}: does not parse (${error.message})`);
}
}
if (problems.length > 0) {
console.error(
"ERROR: message catalogs the server reads at run time are broken:",
);
for (const problem of problems) console.error(` ${problem}`);
process.exit(1);
}
const write = arg("write");
if (write) writeFileSync(write, `${locales.join("\n")}\n`);
console.log(`message catalogs ok: ${locales.join(", ")} in ${dir}`);