import { readFileSync, readdirSync, statSync } from "node:fs"; import { dirname, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; const desktopDir = dirname(dirname(fileURLToPath(import.meta.url))); const sourceDir = join(desktopDir, "src"); const localeFiles = ["en.json", "zh-Hans.json", "zh-Hant.json"]; const supplementalLocaleFiles = [ ["ui-copy.en.json", "ui-dynamic.en.json"], ["ui-copy.zh-Hans.json", "ui-dynamic.zh-Hans.json"], ["ui-copy.zh-Hant.json", "ui-dynamic.zh-Hant.json"], ]; function flatten(value, prefix = "", result = {}) { for (const [key, child] of Object.entries(value)) { const path = prefix ? `${prefix}.${key}` : key; if (child && typeof child === "object" && !Array.isArray(child)) { flatten(child, path, result); } else { result[path] = String(child); } } return result; } function placeholders(value) { return [...value.matchAll(/\{\{\s*([^},\s]+)[^}]*\}\}/g)] .map((match) => match[1]) .sort(); } const catalogs = localeFiles.map((file, index) => ({ file, entries: flatten( Object.assign( JSON.parse(readFileSync(join(sourceDir, "locales", file), "utf8")), ...supplementalLocaleFiles[index].map((supplementalFile) => JSON.parse( readFileSync(join(sourceDir, "locales", supplementalFile), "utf8"), ), ), ), ), })); const referenceKeys = Object.keys(catalogs[0].entries); for (const catalog of catalogs.slice(1)) { const keys = new Set(Object.keys(catalog.entries)); const missing = referenceKeys.filter((key) => !keys.has(key)); const extra = [...keys].filter((key) => !(key in catalogs[0].entries)); if (missing.length || extra.length) { throw new Error( `${catalog.file} locale mismatch\nmissing: ${missing.join(", ")}\nextra: ${extra.join(", ")}`, ); } } for (const key of referenceKeys) { const expected = JSON.stringify(placeholders(catalogs[0].entries[key])); for (const catalog of catalogs.slice(1)) { const actual = JSON.stringify(placeholders(catalog.entries[key])); if (actual !== expected) { throw new Error(`${catalog.file} placeholder mismatch for ${key}`); } } } const references = new Map(); function scanDirectory(directory) { for (const name of readdirSync(directory)) { if (["__snapshots__", "locales", "testing"].includes(name)) continue; const path = join(directory, name); if (statSync(path).isDirectory()) { scanDirectory(path); continue; } if (!/\.(mjs|ts|tsx)$/.test(name) || /\.(spec|test)\./.test(name)) continue; const source = readFileSync(path, "utf8"); for (const match of source.matchAll(/\bt\(\s*["']([^"']+)["']/g)) { const key = match[1]; if (!/^[A-Za-z][\w-]*(?:\.[A-Za-z][\w-]*)+$/.test(key)) continue; if (!references.has(key)) references.set(key, relative(desktopDir, path)); } for (const match of source.matchAll(/\bi18nKey\s*=\s*["']([^"']+)["']/g)) { const key = match[1]; if (!/^[A-Za-z][\w-]*(?:\.[A-Za-z][\w-]*)+$/.test(key)) continue; if (!references.has(key)) references.set(key, relative(desktopDir, path)); } } } scanDirectory(sourceDir); const knownKeys = new Set(referenceKeys); const missingReferences = [...references].filter( ([key]) => !knownKeys.has(key) && !knownKeys.has(`${key}_one`) && !knownKeys.has(`${key}_other`), ); if (missingReferences.length) { throw new Error( `Missing translation keys:\n${missingReferences .map(([key, file]) => `${key} (${file})`) .join("\n")}`, ); } console.log( `i18n check passed: ${referenceKeys.length} keys, ${references.size} static references`, );