#!/usr/bin/env node // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= /* global console, process */ /** * Validates the complete frontend locale contract against `en-us`: * - exact locale and resource-file coverage; * - valid JSON without duplicate object keys; * - identical leaf keys, non-empty string values, placeholders, rich-text tags, * and boundary whitespace; * - every literal `t()`, `i18n.t()`, and `` key in `src` exists. * * Run from repo root: `node scripts/check-i18n-locale-parity.js` */ import fs from 'fs'; import path from 'path'; import ts from 'typescript'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const projectRoot = path.resolve(__dirname, '..'); const localesDir = path.join(projectRoot, 'src', 'i18n', 'locales'); const sourceDir = path.join(projectRoot, 'src'); const referenceLocale = 'en-us'; const expectedLocales = [ 'ar', 'de', 'en-us', 'es', 'fr', 'it', 'ja', 'ko', 'ru', 'zh-Hans', 'zh-Hant', ].sort(); const expectedResourceFiles = [ 'agents.json', 'chat.json', 'common.json', 'connectors.json', 'dashboard.json', 'folder.json', 'layout.json', 'markdown.json', 'setting.json', 'triggers.json', 'update.json', 'workforce.json', ].sort(); const pluralCategorySuffixes = ['zero', 'one', 'two', 'few', 'many', 'other']; const localeLanguageTags = { ar: 'ar', de: 'de', 'en-us': 'en-US', es: 'es', fr: 'fr', it: 'it', ja: 'ja', ko: 'ko', ru: 'ru', 'zh-Hans': 'zh-Hans', 'zh-Hant': 'zh-Hant', }; let failed = false; function report(message) { console.error(message); failed = true; } function difference(left, right) { const rightSet = new Set(right); return left.filter((value) => !rightSet.has(value)); } function arraysEqual(left, right) { return ( left.length === right.length && left.every((value, index) => value === right[index]) ); } function pluralCategoriesFor(locale) { return [ ...new Intl.PluralRules(localeLanguageTags[locale]).resolvedOptions() .pluralCategories, ].sort(); } function parsePluralLeaf(key) { const match = key.match( new RegExp(`^(.+)_(${pluralCategorySuffixes.join('|')})$`) ); if (!match) return null; return { base: match[1], category: match[2] }; } function collectPluralFamilies(leaves) { const families = new Map(); for (const [key, value] of leaves) { const plural = parsePluralLeaf(key); if (!plural) continue; const family = families.get(plural.base) ?? new Map(); family.set(plural.category, value); families.set(plural.base, family); } return families; } function pluralLeafKeys(families) { const keys = new Set(); for (const [base, family] of families) { for (const category of family.keys()) { keys.add(`${base}_${category}`); } } return keys; } function flattenLeaves(value, prefix = '', out = new Map()) { if (value !== null && typeof value === 'object' && !Array.isArray(value)) { for (const [key, child] of Object.entries(value)) { flattenLeaves(child, prefix ? `${prefix}.${key}` : key, out); } return out; } out.set(prefix, value); return out; } function extractPlaceholders(value) { return [...value.matchAll(/{{\s*([^},\s]+)(?:\s*,[^}]*)?}}/g)] .map((match) => match[1]) .sort(); } function extractRichTextTags(value) { return [...value.matchAll(/<\/?([A-Za-z0-9][\w-]*)\b[^>]*>/g)] .filter((match) => !match[0].endsWith('/>')) .map((match) => `${match[0].startsWith(' findDuplicateKeys( element, sourceFile, locale, relativeFile, `${prefix}[${index}]` ) ); } } function readResource(locale, relativeFile) { const filePath = path.join(localesDir, locale, relativeFile); let text; try { text = fs.readFileSync(filePath, 'utf8'); } catch (error) { report(`Could not read [${locale}] ${relativeFile}: ${error.message}`); return null; } const sourceFile = ts.parseJsonText(filePath, text); for (const diagnostic of sourceFile.parseDiagnostics) { const location = diagnostic.start !== undefined ? sourceFile.getLineAndCharacterOfPosition(diagnostic.start) : null; const suffix = location ? `:${location.line + 1}:${location.character + 1}` : ''; report( `Invalid JSON [${locale}] ${relativeFile}${suffix}: ${ts.flattenDiagnosticMessageText( diagnostic.messageText, '\n' )}` ); } for (const statement of sourceFile.statements) { if (ts.isExpressionStatement(statement)) { findDuplicateKeys(statement.expression, sourceFile, locale, relativeFile); } } try { return JSON.parse(text); } catch (error) { if (sourceFile.parseDiagnostics.length === 0) { report(`Invalid JSON [${locale}] ${relativeFile}: ${error.message}`); } return null; } } function validateLeafValues(locale, relativeFile, leaves) { for (const [key, value] of leaves) { if (typeof value !== 'string') { report( `Non-string value [${locale}] ${relativeFile}: ${key} (${value === null ? 'null' : typeof value})` ); } else if (!value.trim()) { report(`Empty value [${locale}] ${relativeFile}: ${key}`); } } } function compareValueContract( locale, relativeFile, key, referenceValue, targetValue ) { if (typeof referenceValue !== typeof targetValue) { report( `Value type mismatch [${locale}] ${relativeFile}: ${key} (${typeof referenceValue} != ${typeof targetValue})` ); return; } if (typeof referenceValue !== 'string' || typeof targetValue !== 'string') { return; } const referencePlaceholders = extractPlaceholders(referenceValue); const targetPlaceholders = extractPlaceholders(targetValue); if (!arraysEqual(referencePlaceholders, targetPlaceholders)) { report( `Placeholder mismatch [${locale}] ${relativeFile}: ${key} (en-us=[${referencePlaceholders.join( ', ' )}], locale=[${targetPlaceholders.join(', ')}])` ); } const referenceTags = extractRichTextTags(referenceValue); const targetTags = extractRichTextTags(targetValue); if (!arraysEqual(referenceTags, targetTags)) { report( `Rich-text tag mismatch [${locale}] ${relativeFile}: ${key} (en-us=[${referenceTags.join( ', ' )}], locale=[${targetTags.join(', ')}])` ); } const referenceWhitespace = extractBoundaryWhitespace(referenceValue); const targetWhitespace = extractBoundaryWhitespace(targetValue); const leadingWhitespaceMatches = referenceWhitespace.leading === targetWhitespace.leading || (referenceWhitespace.leading.length > 0 && targetWhitespace.leading.length === 0 && startsWithBoundaryPunctuation(targetValue)); if ( !leadingWhitespaceMatches || referenceWhitespace.trailing !== targetWhitespace.trailing ) { report( `Boundary whitespace mismatch [${locale}] ${relativeFile}: ${key} (en-us=${JSON.stringify( referenceWhitespace )}, locale=${JSON.stringify(targetWhitespace)})` ); } } function validatePluralFamilyCoverage(locale, relativeFile, families) { const expectedCategories = pluralCategoriesFor(locale); for (const [base, family] of families) { const actualCategories = [...family.keys()].sort(); for (const category of difference(expectedCategories, actualCategories)) { report( `Missing plural category [${locale}] ${relativeFile}: ${base}_${category}` ); } for (const category of difference(actualCategories, expectedCategories)) { report( `Unexpected plural category [${locale}] ${relativeFile}: ${base}_${category}` ); } } } function compareResources(locale, relativeFile, referenceLeaves, targetLeaves) { const referenceFamilies = collectPluralFamilies(referenceLeaves); const targetFamilies = collectPluralFamilies(targetLeaves); const referencePluralLeaves = pluralLeafKeys(referenceFamilies); const targetPluralLeaves = pluralLeafKeys(targetFamilies); for (const [key, referenceValue] of referenceLeaves) { if (referencePluralLeaves.has(key)) continue; if (!targetLeaves.has(key)) { report(`Missing key [${locale}] ${relativeFile}: ${key}`); continue; } compareValueContract( locale, relativeFile, key, referenceValue, targetLeaves.get(key) ); } for (const key of targetLeaves.keys()) { if (targetPluralLeaves.has(key)) { const plural = parsePluralLeaf(key); if (plural && !referenceFamilies.has(plural.base)) { report(`Extra key [${locale}] ${relativeFile}: ${key}`); } } else if (!referenceLeaves.has(key)) { report(`Extra key [${locale}] ${relativeFile}: ${key}`); } } const requiredTargetCategories = pluralCategoriesFor(locale); for (const [base, referenceFamily] of referenceFamilies) { const targetFamily = targetFamilies.get(base) ?? new Map(); for (const category of requiredTargetCategories) { if (!targetFamily.has(category)) { report( `Missing plural category [${locale}] ${relativeFile}: ${base}_${category}` ); continue; } const referenceValue = referenceFamily.get(category) ?? referenceFamily.get('other'); if (referenceValue === undefined) continue; compareValueContract( locale, relativeFile, `${base}_${category}`, referenceValue, targetFamily.get(category) ); } for (const category of targetFamily.keys()) { if (!requiredTargetCategories.includes(category)) { report( `Unexpected plural category [${locale}] ${relativeFile}: ${base}_${category}` ); } } } } function collectSourceFiles(directory) { const files = []; for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { const fullPath = path.join(directory, entry.name); if (entry.isDirectory()) { files.push(...collectSourceFiles(fullPath)); } else if (entry.isFile() && /\.(?:[jt]sx?)$/.test(entry.name)) { files.push(fullPath); } } return files; } function scriptKindFor(filePath) { if (filePath.endsWith('.tsx')) return ts.ScriptKind.TSX; if (filePath.endsWith('.jsx')) return ts.ScriptKind.JSX; if (filePath.endsWith('.js')) return ts.ScriptKind.JS; return ts.ScriptKind.TS; } function isLiteral(node) { return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node); } function isTranslationCall(expression) { if (ts.isIdentifier(expression)) return expression.text === 't'; return ( ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.expression) && (expression.expression.text === 'i18n' || expression.expression.text === 'i18next') && expression.name.text === 't' ); } function recordSourceKey(keys, key, sourceFile, node, filePath) { const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1; const relativePath = path.relative(projectRoot, filePath); const references = keys.get(key) ?? new Set(); references.add(`${relativePath}:${line}`); keys.set(key, references); } function collectLiteralSourceKeys() { const keys = new Map(); for (const filePath of collectSourceFiles(sourceDir)) { const text = fs.readFileSync(filePath, 'utf8'); const sourceFile = ts.createSourceFile( filePath, text, ts.ScriptTarget.Latest, true, scriptKindFor(filePath) ); const visit = (node) => { if ( ts.isCallExpression(node) && isTranslationCall(node.expression) && node.arguments[0] && isLiteral(node.arguments[0]) ) { recordSourceKey( keys, node.arguments[0].text, sourceFile, node, filePath ); } if (ts.isJsxAttribute(node) && node.name.text === 'i18nKey') { const tag = node.parent?.parent?.tagName; const tagName = tag?.getText(sourceFile); if ( tagName === 'Trans' && node.initializer && ts.isStringLiteral(node.initializer) ) { recordSourceKey( keys, node.initializer.text, sourceFile, node, filePath ); } } ts.forEachChild(node, visit); }; visit(sourceFile); } return keys; } function validateSourceKeys(referenceKeys) { const missing = [...collectLiteralSourceKeys()] .filter(([key]) => !referenceKeys.has(key)) .sort(([left], [right]) => left.localeCompare(right)); if (missing.length === 0) return; report(`\nMissing literal source translation keys (${missing.length}):`); const grouped = new Map(); for (const [key, references] of missing) { const prefix = key.split('.')[0]; const entries = grouped.get(prefix) ?? []; entries.push([key, references]); grouped.set(prefix, entries); } for (const [prefix, entries] of [...grouped].sort(([a], [b]) => a.localeCompare(b) )) { console.error(` [${prefix}] (${entries.length})`); for (const [key, references] of entries) { console.error(` ${key} <- ${[...references].sort().join(', ')}`); } } } function main() { const actualLocales = fs .readdirSync(localesDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .sort(); for (const locale of difference(expectedLocales, actualLocales)) { report(`Missing locale directory: ${locale}`); } for (const locale of difference(actualLocales, expectedLocales)) { report(`Unexpected locale directory: ${locale}`); } const availableLocales = expectedLocales.filter((locale) => actualLocales.includes(locale) ); const resourcesByLocale = new Map(); for (const locale of availableLocales) { const localePath = path.join(localesDir, locale); const actualFiles = fs .readdirSync(localePath, { withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) .map((entry) => entry.name) .sort(); for (const file of difference(expectedResourceFiles, actualFiles)) { report(`Missing resource file [${locale}]: ${file}`); } for (const file of difference(actualFiles, expectedResourceFiles)) { report(`Unexpected resource file [${locale}]: ${file}`); } const localeResources = new Map(); for (const relativeFile of expectedResourceFiles) { if (!actualFiles.includes(relativeFile)) continue; const resource = readResource(locale, relativeFile); if (resource === null) continue; const leaves = flattenLeaves(resource); validateLeafValues(locale, relativeFile, leaves); localeResources.set(relativeFile, leaves); } resourcesByLocale.set(locale, localeResources); } const referenceResources = resourcesByLocale.get(referenceLocale); if (!referenceResources) { report(`Reference locale could not be loaded: ${referenceLocale}`); } else { for (const [relativeFile, leaves] of referenceResources) { validatePluralFamilyCoverage( referenceLocale, relativeFile, collectPluralFamilies(leaves) ); } for (const locale of availableLocales) { if (locale === referenceLocale) continue; const localeResources = resourcesByLocale.get(locale); for (const relativeFile of expectedResourceFiles) { const referenceLeaves = referenceResources.get(relativeFile); const targetLeaves = localeResources?.get(relativeFile); if (!referenceLeaves || !targetLeaves) continue; compareResources(locale, relativeFile, referenceLeaves, targetLeaves); } } const referenceKeys = new Set(); for (const [relativeFile, leaves] of referenceResources) { const prefix = path.basename(relativeFile, '.json'); for (const key of leaves.keys()) { referenceKeys.add(`${prefix}.${key}`); } for (const [base, family] of collectPluralFamilies(leaves)) { const expectedCategories = pluralCategoriesFor(referenceLocale); if (expectedCategories.every((category) => family.has(category))) { referenceKeys.add(`${prefix}.${base}`); } } } validateSourceKeys(referenceKeys); } if (failed) { console.error('\ncheck-i18n-locale-parity: FAILED'); process.exit(1); } console.log('check-i18n-locale-parity: OK'); } main();