import fs from 'node:fs'; import path from 'node:path'; const LOCALES_DIR = path.join(process.cwd(), 'lib', 'i18n', 'locales'); const SOURCE_LOCALE = 'en-US.json'; function isPlainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); } function formatPath(keyPath) { return keyPath || ''; } function collectLeafKeys(value, fileName, keyPath = '', keys = new Set()) { if (Array.isArray(value)) { throw new Error( `${fileName} has an array at "${formatPath(keyPath)}". Locale values must not be arrays.`, ); } if (isPlainObject(value)) { const entries = Object.entries(value); if (entries.length === 0) { throw new Error( `${fileName} has an empty object at "${formatPath(keyPath)}". Locale objects must not be empty.`, ); } for (const [key, child] of entries) { const nextPath = keyPath ? `${keyPath}.${key}` : key; collectLeafKeys(child, fileName, nextPath, keys); } return keys; } if (!keyPath) { throw new Error(`${fileName} must contain a JSON object at the root.`); } keys.add(keyPath); return keys; } function readLocaleKeys(filePath) { const raw = fs.readFileSync(filePath, 'utf8'); const parsed = JSON.parse(raw); const fileName = path.basename(filePath); if (!isPlainObject(parsed)) { throw new Error(`${fileName} must contain a JSON object at the root.`); } return [...collectLeafKeys(parsed, fileName)].sort(); } function main() { const localeFiles = fs .readdirSync(LOCALES_DIR) .filter((name) => name.endsWith('.json')) .sort(); if (!localeFiles.includes(SOURCE_LOCALE)) { throw new Error(`Missing source locale: ${SOURCE_LOCALE}`); } const sourceKeys = new Set(readLocaleKeys(path.join(LOCALES_DIR, SOURCE_LOCALE))); const reports = []; for (const localeFile of localeFiles) { if (localeFile === SOURCE_LOCALE) continue; const localeKeys = new Set(readLocaleKeys(path.join(LOCALES_DIR, localeFile))); const missing = [...sourceKeys].filter((key) => !localeKeys.has(key)).sort(); const extra = [...localeKeys].filter((key) => !sourceKeys.has(key)).sort(); if (missing.length > 0 || extra.length > 0) { reports.push({ file: localeFile, missing, extra }); } } if (reports.length === 0) { console.log( `i18n key alignment check passed (${localeFiles.length} locale files, source: ${SOURCE_LOCALE}).`, ); return; } console.error(`i18n key alignment check failed against ${SOURCE_LOCALE}:`); for (const report of reports) { console.error(`\n- ${report.file}`); if (report.missing.length > 0) { console.error(` Missing keys (${report.missing.length}):`); for (const key of report.missing) { console.error(` - ${key}`); } } if (report.extra.length > 0) { console.error(` Extra keys (${report.extra.length}):`); for (const key of report.extra) { console.error(` - ${key}`); } } } process.exit(1); } main();