109 lines
3.7 KiB
JavaScript
109 lines
3.7 KiB
JavaScript
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
// @ts-expect-error Node 24 executes erasable TypeScript for this build-time gate.
|
|
import { EN_MESSAGES, KO_MESSAGES, MESSAGE_CATALOGS } from "../src/presentation/i18n/catalog.ts";
|
|
|
|
const fixtureMode = process.argv.includes("--fixture");
|
|
const failures = [];
|
|
const sourceExtensions = /\.(?:js|jsx|mjs|ts|tsx|mts)$/;
|
|
const koreanLiteral = /[가-힣]/;
|
|
const rawFailureRender =
|
|
/(?<!\$)\{\s*(?:failure|error|response|backend)(?:\?\.|\.)[\w?.]*message\s*\}/;
|
|
|
|
/** @param {string} directory @returns {Promise<string[]>} */
|
|
async function listSourceFiles(directory) {
|
|
const result = [];
|
|
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
const target = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) result.push(...(await listSourceFiles(target)));
|
|
else if (sourceExtensions.test(entry.name)) result.push(target);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** @param {string} template */
|
|
function placeholders(template) {
|
|
return [...template.matchAll(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g)]
|
|
.map((match) => match[1])
|
|
.sort();
|
|
}
|
|
|
|
if (!fixtureMode) {
|
|
const canonicalKeys = Object.keys(KO_MESSAGES).sort();
|
|
for (const [locale, catalog] of Object.entries(MESSAGE_CATALOGS)) {
|
|
const readableCatalog =
|
|
/** @type {Readonly<Record<string, string>>} */ (catalog);
|
|
const canonicalCatalog =
|
|
/** @type {Readonly<Record<string, string>>} */ (KO_MESSAGES);
|
|
const keys = Object.keys(catalog).sort();
|
|
if (JSON.stringify(keys) !== JSON.stringify(canonicalKeys)) {
|
|
failures.push(`${locale} catalog keys do not match ko-KR`);
|
|
}
|
|
for (const key of canonicalKeys) {
|
|
if (
|
|
JSON.stringify(placeholders(readableCatalog[key] ?? "")) !==
|
|
JSON.stringify(placeholders(canonicalCatalog[key] ?? ""))
|
|
) {
|
|
failures.push(`${locale}:${key} interpolation parameters do not match`);
|
|
}
|
|
}
|
|
}
|
|
if (Object.keys(EN_MESSAGES).length !== canonicalKeys.length) {
|
|
failures.push("en-US catalog key count does not match ko-KR");
|
|
}
|
|
}
|
|
|
|
const commonDirectories = [
|
|
"src/presentation/boundaries",
|
|
"src/presentation/components",
|
|
"src/presentation/design-system",
|
|
"src/presentation/forms",
|
|
"src/presentation/layouts",
|
|
"src/presentation/routes",
|
|
"src/presentation/templates",
|
|
];
|
|
const sources = fixtureMode
|
|
? await listSourceFiles("tests/fixtures/i18n/forbidden")
|
|
: (
|
|
await Promise.all(commonDirectories.map((directory) => listSourceFiles(directory)))
|
|
).flat();
|
|
|
|
for (const file of sources) {
|
|
const source = await readFile(file, "utf8");
|
|
if (koreanLiteral.test(source)) {
|
|
failures.push(`hardcoded common user-facing locale literal in ${file}`);
|
|
}
|
|
if (rawFailureRender.test(source)) {
|
|
failures.push(`raw backend/error message rendered in ${file}`);
|
|
}
|
|
if (source.includes("dangerouslySetInnerHTML")) {
|
|
failures.push(`untrusted HTML interpolation boundary in ${file}`);
|
|
}
|
|
}
|
|
|
|
const report = {
|
|
schemaVersion: 1,
|
|
mode: fixtureMode ? "negative-fixture" : "source",
|
|
localeCount: Object.keys(MESSAGE_CATALOGS).length + 1,
|
|
messageKeyCount: Object.keys(KO_MESSAGES).length,
|
|
checkedFiles: sources.length,
|
|
failures,
|
|
passed: failures.length === 0,
|
|
};
|
|
await mkdir("artifacts/quality", { recursive: true });
|
|
await writeFile(
|
|
fixtureMode
|
|
? "artifacts/quality/i18n-fixture.json"
|
|
: "artifacts/quality/i18n.json",
|
|
`${JSON.stringify(report, null, 2)}\n`,
|
|
);
|
|
|
|
if (failures.length > 0) {
|
|
process.stderr.write(`I18n contract failed:\n${failures.join("\n")}\n`);
|
|
process.exit(1);
|
|
}
|
|
process.stdout.write(
|
|
`I18n contract: ${report.messageKeyCount} keys across ${report.localeCount} locales PASS\n`,
|
|
);
|