import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, ".."); const sourceDirectory = path.join(projectRoot, "src", "i18n", "source"); const generatedDirectory = path.join(projectRoot, "src", "i18n", "generated"); const activeLocales = ["da", "en", "sv", "de", "no"]; const mode = process.argv.includes("--check") ? "check" : "write"; const linkTokenPattern = /@(?\.[\p{L}]+)?:(?:\{'(?[^']+)'\}|(?[\p{L}\p{N}_.-]+))/gu; const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value); const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, "")); const stableJson = (value) => `${JSON.stringify(value, null, 2)}\n`; const writeJson = (filePath, value) => { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, stableJson(value), "utf8"); }; const listJsonFiles = (directory) => { if (!fs.existsSync(directory)) { return []; } const entries = fs.readdirSync(directory, { withFileTypes: true }); return entries .flatMap((entry) => { const entryPath = path.join(directory, entry.name); if (entry.isDirectory()) { return listJsonFiles(entryPath); } return entry.isFile() && entry.name.endsWith(".json") ? [entryPath] : []; }) .sort((left, right) => left.localeCompare(right, "en", { sensitivity: "base" })); }; const mergeMessages = (left = {}, right = {}) => { const merged = { ...left }; for (const [key, value] of Object.entries(right ?? {})) { if (isPlainObject(value) && isPlainObject(merged[key])) { merged[key] = mergeMessages(merged[key], value); } else { merged[key] = value; } } return merged; }; const readJsonFragments = (directory) => { let merged = {}; for (const filePath of listJsonFiles(directory)) { merged = mergeMessages(merged, readJson(filePath)); } return merged; }; const mapSourcePathToRuntimePath = (keyPath) => { if (keyPath.startsWith("terms.glossary.")) { return `words.generated.${keyPath.slice("terms.glossary.".length)}`; } if (keyPath.startsWith("terms.")) { return `words.${keyPath.slice("terms.".length)}`; } if (keyPath.startsWith("phrases.compat.")) { return `templates.generated.compat.${keyPath.slice("phrases.compat.".length)}`; } if (keyPath.startsWith("phrases.")) { return `templates.${keyPath.slice("phrases.".length)}`; } return keyPath; }; const rewriteLinks = (value) => { if (typeof value === "string") { return value.replace(linkTokenPattern, (token, modifier = "", literalTarget, pathTarget) => { const target = literalTarget ?? pathTarget; const mappedTarget = mapSourcePathToRuntimePath(target); const quoteTarget = literalTarget || /[^\p{L}\p{N}_.-]/u.test(mappedTarget); return quoteTarget ? `@${modifier}:{'${mappedTarget}'}` : `@${modifier}:${mappedTarget}`; }); } if (Array.isArray(value)) { return value.map((entry) => rewriteLinks(entry)); } if (!isPlainObject(value)) { return value; } return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, rewriteLinks(entry)])); }; const compileWords = (sourceTerms) => { const { glossary, ...terms } = sourceTerms; return { grammar: terms.grammar ?? {}, actions: terms.actions ?? {}, entities: terms.entities ?? {}, statuses: terms.statuses ?? {}, domain: terms.domain ?? {}, services: terms.services ?? {}, units: terms.units ?? {}, replication: terms.replication ?? {}, generated: glossary ?? {}, }; }; const compileTemplates = (sourcePhrases) => { const { compat, ...phrases } = sourcePhrases; return { ...phrases, generated: { compat: compat ?? {}, }, }; }; const compileLocale = (locale) => { const localeSourceDirectory = path.join(sourceDirectory, locale); const sourceTerms = readJsonFragments(path.join(localeSourceDirectory, "terms")); const sourcePhrases = readJsonFragments(path.join(localeSourceDirectory, "phrases")); return rewriteLinks({ words: compileWords(sourceTerms), templates: compileTemplates(sourcePhrases), }); }; const compileGlobal = () => { const shared = readJsonFragments(path.join(sourceDirectory, "global", "shared")); const locales = {}; for (const locale of activeLocales) { locales[locale] = readJsonFragments(path.join(sourceDirectory, "global", "locales", locale)); } return rewriteLinks({ shared, locales, }); }; const compiledFiles = new Map([ [path.join(generatedDirectory, "global-v2.json"), compileGlobal()], ...activeLocales.map((locale) => [path.join(generatedDirectory, `${locale}-v2.json`), compileLocale(locale)]), ]); if (mode === "check") { const mismatches = []; for (const [filePath, value] of compiledFiles.entries()) { const expected = stableJson(value); const actual = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, "") : ""; if (actual !== expected) { mismatches.push(path.relative(projectRoot, filePath)); } } if (mismatches.length > 0) { console.error("Generated i18n v2 runtime files are out of date:"); for (const mismatch of mismatches) { console.error(` ${mismatch}`); } console.error("Run npm run i18n:v2:compile."); process.exit(1); } console.log("Generated i18n v2 runtime files are up to date."); } else { for (const [filePath, value] of compiledFiles.entries()) { writeJson(filePath, value); } console.log(`Compiled i18n v2 runtime files to ${path.relative(projectRoot, generatedDirectory)}.`); }