src/dashboard/check-i18n.js
Rama master · réplica de lectura del repositorio original.
#!/usr/bin/env node
/**
* I18n Regression Protection Script
*
* This script checks for:
* 1. Hardcoded Chinese text in dashboard HTML/JS
* 2. Missing translation keys in locale files
* 3. Keys present in one locale but not the other
*
* Usage: node check-i18n.js
* Exit code: 0 if all checks pass, 1 if violations found
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const RED = '\x1b[31m';
const GREEN = '\x1b[32m';
const YELLOW = '\x1b[33m';
const RESET = '\x1b[0m';
let exitCode = 0;
let violations = 0;
function logError(msg) {
console.log(`${RED}✗${RESET} ${msg}`);
violations++;
exitCode = 1;
}
function logWarn(msg) {
console.log(`${YELLOW}⚠${RESET} ${msg}`);
}
function logOk(msg) {
console.log(`${GREEN}✓${RESET} ${msg}`);
}
// Chinese character regex
const CHINESE_REGEX = /[\u4e00-\u9fff]/;
// Patterns that are allowed to contain Chinese (whitelisted)
const WHITELIST_PATTERNS = [
/data-i18n(?:-[\w-]+)?=/, // i18n attributes are ok
/id="(?:mobile-)?lang-indicator"/, // compact language toggle label
/indicator\.textContent\s*=/, // compact language toggle label
/\/\/.*/, // comments
/https?:\/\//, // URLs
/windsurf\.com/, // windsurf domain
/firebase/, // Firebase references
];
function isWhitelisted(line) {
return WHITELIST_PATTERNS.some(p => p.test(line));
}
// ---- Check #8 helpers: hardcoded English literals in the App <script> region ----
// DOM sinks whose assigned string is rendered to the user.
const COPY_SINK = '(?:textContent|innerText|placeholder|title|value|label|alt|ariaLabel)';
const HTML_SINK = '(?:innerHTML|outerHTML)';
const QUOTE = "['\"`]";
// Technical tokens that read identically in any locale — never worth translating.
const UNIVERSAL_TERMS = new Set([
'pid', 'rss', 'cpu', 'ram', 'gpu', 'sqlite', 'url', 'uri', 'api', 'id', 'ip',
'http', 'https', 'json', 'html', 'css', 'dns', 'tls', 'ssl', 'ok', 'uuid',
'cap', 'sse', 'acp', 'ls', 'rpc', 'jwt', 'ttl', 'md5', 'sha', 'utc', 'io',
]);
// Distinguish user-facing display copy from code (identifiers, CSS, URLs, enums,
// acronyms, config samples). Multi-word phrases are almost always copy; single
// tokens must clear a stack of code-shaped exclusions.
function looksLikeDisplayCopy(text) {
const t = String(text).trim();
if (!t) return false;
if (t.includes('${') || t.includes('{{')) return false; // interpolated
if (!/[A-Za-z]{2,}/.test(t)) return false; // needs real letters
if (/^[\d.,\s%+×x/:()[\]|·—–-]+$/.test(t)) return false; // pure numbers/units/symbols
if (/\n/.test(t)) return false; // multiline = code/config sample
if (/^[#/]/.test(t)) return false; // comment / path / hash
if (/[A-Za-z0-9_]+=[^=]/.test(t) && !/\s\S+\s/.test(t)) return false; // KEY=value with no prose
if (/[a-z-]+\s*:\s*[^;]+;/.test(t)) return false; // inline css declaration
// JS expression fragments that leak through the ${…} strip when a template has
// nested braces (e.g. an inline IIFE): "0) + (tt.cache_read", "a && b", "x.y".
// Real display copy never contains these operator/member-access shapes.
if (/[)(]/.test(t) && /[+\-*/%]|&&|\|\||=>|\breturn\b/.test(t)) return false; // arithmetic/logic w/ parens
if (/\|\||&&|=>|\?\.|\.\w+\s*\(/.test(t)) return false; // logic ops / optional chain / method call
// Member access `obj.prop` — but ONLY when it looks like code, not prose or a
// decimal. Require the left side to START with a letter/underscore (excludes
// "95.5") and the property to be a snake_case/camelCase identifier of 2+ chars
// that isn't a natural sentence boundary. This still catches "tt.cache_read" /
// "obj.method" while NOT skipping real copy like "v2.0", "95.5%", or "e.g.".
if (/(?:^|[^A-Za-z0-9])[A-Za-z_]\w*\.[a-z_]\w+(?![A-Za-z0-9. ])/.test(t) && /_|[a-z][A-Z]/.test(t)) return false;
const isMultiWord = /\S\s+\S/.test(t);
if (!isMultiWord) {
if (/^[a-z][\w-]*$/.test(t)) return false; // camel/kebab identifier
if (/^[A-Z][A-Z0-9_]+$/.test(t)) return false; // CONSTANT
if (/^[\w-]+\.[\w.-]+$/.test(t)) return false; // dotted key / filename
if (/^https?:/.test(t) || /\.(js|css|html|json|svg|png)$/.test(t)) return false;
if (/^\d*\s*(ms|px|em|rem|s|kb|mb|gb|%)$/i.test(t)) return false; // unit
const bare = t.replace(/[^A-Za-z0-9]/g, '');
if (bare.length < 4) return false; // short = abbreviation/acronym
if (UNIVERSAL_TERMS.has(bare.toLowerCase())) return false;
}
return true;
}
// Scan the App <script>...</script> region for hardcoded English string literals
// that reach the user. Returns [{ kind, text, line }]. Deliberately conservative:
// tuned to 0 findings on the current index.html so any hit is a genuine regression.
// Not exported: this module runs checks + process.exit() at load, so it must not
// be imported. Check #8 is exercised via subprocess in test/check-i18n.test.js.
function scanScriptRegionForEnglish(htmlContent) {
const bodyStart = htmlContent.indexOf('<body>');
const scriptStart = htmlContent.indexOf('<script>', bodyStart >= 0 ? bodyStart : 0);
if (scriptStart < 0) return [];
const script = htmlContent.slice(scriptStart);
const baseLine = htmlContent.slice(0, scriptStart).split('\n').length - 1;
const lineAt = (idx) => baseLine + script.slice(0, idx).split('\n').length;
const findings = [];
const litRe = '(' + QUOTE + ')((?:\\\\.|(?!\\1)[^\\\\])*)\\1';
let m;
// Copy sinks: the whole RHS literal is user text.
const copyRe = new RegExp('\\.' + COPY_SINK + '\\s*\\+?=\\s*' + litRe, 'g');
while ((m = copyRe.exec(script)) !== null) {
if (m[1] === '`' && m[2].includes('${')) continue;
if (looksLikeDisplayCopy(m[2])) findings.push({ kind: 'copy', text: m[2].slice(0, 60), line: lineAt(m.index) });
}
// HTML sinks: extract text nodes between tags. Strip ${...} interpolations first
// (their JS — arrow =>, comparisons — carries stray < > that fake tag/text
// boundaries), then split metadata rows ("PID … · RSS …") on separators so an
// acronym label list does not read as one blob while real prose still flags.
const htmlRe = new RegExp('\\.' + HTML_SINK + '\\s*\\+?=\\s*' + litRe, 'g');
while ((m = htmlRe.exec(script)) !== null) {
const frag = m[2].replace(/\$\{[^}]*\}/g, ' ');
const textRe = />([^<>]+)</g;
let t;
while ((t = textRe.exec(frag)) !== null) {
for (const phrase of t[1].split(/\s*[·|]\s*/)) {
if (looksLikeDisplayCopy(phrase)) findings.push({ kind: 'html', text: phrase.trim().slice(0, 60), line: lineAt(m.index) });
}
}
}
return findings;
}
function checkFileForChinese(filePath, content) {
const lines = content.split('\n');
let found = false;
lines.forEach((line, idx) => {
if (CHINESE_REGEX.test(line) && !isWhitelisted(line)) {
logError(`${filePath}:${idx + 1}: ${line.trim().slice(0, 80)}`);
found = true;
}
});
return found;
}
function extractKeys(obj, prefix = '') {
const keys = [];
for (const [k, v] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${k}` : k;
if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
keys.push(...extractKeys(v, fullKey));
} else {
keys.push(fullKey);
}
}
return keys;
}
function compareLocales(enKeys, zhKeys) {
const enSet = new Set(enKeys);
const zhSet = new Set(zhKeys);
const onlyInEn = enKeys.filter(k => !zhSet.has(k));
const onlyInZh = zhKeys.filter(k => !enSet.has(k));
return { onlyInEn, onlyInZh };
}
// Main checks
console.log('\n📋 I18n Regression Check\n');
// 1. Check HTML file for hardcoded Chinese
const htmlPath = path.join(__dirname, 'index.html');
const htmlContent = fs.readFileSync(htmlPath, 'utf-8');
console.log('Checking index.html for hardcoded Chinese...');
const htmlHasChinese = checkFileForChinese('index.html', htmlContent);
if (!htmlHasChinese) {
logOk('No hardcoded Chinese found in HTML');
}
// 2. Check API files for Chinese error messages
const apiPath = path.join(__dirname, '../dashboard/api.js');
if (fs.existsSync(apiPath)) {
const apiContent = fs.readFileSync(apiPath, 'utf-8');
console.log('\nChecking api.js for Chinese error messages...');
// Look for Chinese in error messages (not in comments)
const lines = apiContent.split('\n');
let foundApiChinese = false;
lines.forEach((line, idx) => {
// Skip comments
if (/^\s*\/\//.test(line)) return;
// Check for Chinese in error: or throw new Error(
if (/error.*:.*'[^']*[一-鿿]/.test(line) || /throw new Error\('[^']*[一-鿿]/.test(line)) {
if (!isWhitelisted(line)) {
logError(`api.js:${idx + 1}: ${line.trim().slice(0, 80)}`);
foundApiChinese = true;
}
}
});
if (!foundApiChinese) {
logOk('No Chinese error messages found in API');
}
}
// 3. Compare locale files
console.log('\nComparing locale files...');
const enPath = path.join(__dirname, 'i18n/en.json');
const zhPath = path.join(__dirname, 'i18n/zh-CN.json');
const enJson = JSON.parse(fs.readFileSync(enPath, 'utf-8'));
const zhJson = JSON.parse(fs.readFileSync(zhPath, 'utf-8'));
const enKeys = extractKeys(enJson).sort();
const zhKeys = extractKeys(zhJson).sort();
const { onlyInEn, onlyInZh } = compareLocales(enKeys, zhKeys);
if (onlyInEn.length > 0) {
logError(`Keys in en.json but missing in zh-CN.json (${onlyInEn.length}):`);
onlyInEn.forEach(k => console.log(` - ${k}`));
}
if (onlyInZh.length > 0) {
logError(`Keys in zh-CN.json but missing in en.json (${onlyInZh.length}):`);
onlyInZh.forEach(k => console.log(` - ${k}`));
}
if (onlyInEn.length === 0 && onlyInZh.length === 0) {
logOk('Locale files are synchronized');
}
// Runtime interpolation only recognizes {{name}}. A single-brace `{name}`
// silently reaches the UI verbatim; this happened in the OTA rollback copy
// even though the locale key-sync gate stayed green. The one allowlisted key
// intentionally DISPLAYS the literal template syntax to the operator.
console.log('\nChecking locale placeholder syntax...');
const singleBracePlaceholder = /(^|[^\{])\{([A-Za-z_]\w*)\}(?!\})/;
const literalSingleBraceKeys = new Set([
'experimental.missingPlaceholder',
]);
let badPlaceholderCount = 0;
function checkPlaceholderSyntax(obj, locale, prefix = '') {
for (const [key, value] of Object.entries(obj || {})) {
const pathKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
checkPlaceholderSyntax(value, locale, pathKey);
} else if (
typeof value === 'string'
&& singleBracePlaceholder.test(value)
&& !literalSingleBraceKeys.has(pathKey)
) {
logError(`${locale}:${pathKey}: use {{name}} placeholders, not single braces`);
badPlaceholderCount++;
}
}
}
checkPlaceholderSyntax(enJson, 'en.json');
checkPlaceholderSyntax(zhJson, 'zh-CN.json');
if (badPlaceholderCount === 0) {
logOk('Locale placeholders use the supported {{name}} syntax');
}
// 4. Check for data-i18n usage consistency
console.log('\nChecking data-i18n attribute usage...');
const i18nRegex = /data-i18n="([^"]+)"/g;
const usedKeys = [];
let match;
while ((match = i18nRegex.exec(htmlContent)) !== null) {
usedKeys.push(match[1]);
}
// Check if all used keys exist in locales
const missingInLocales = [];
for (const key of usedKeys) {
if (key.includes('${')) continue;
// Navigate nested key path
const parts = key.split('.');
let enVal = enJson;
let zhVal = zhJson;
let exists = true;
for (const part of parts) {
enVal = enVal?.[part];
zhVal = zhVal?.[part];
if (enVal === undefined || zhVal === undefined) {
exists = false;
break;
}
}
if (!exists) {
missingInLocales.push(key);
}
}
if (missingInLocales.length > 0) {
logError(`data-i18n keys missing in locales (${missingInLocales.length}):`);
missingInLocales.forEach(k => console.log(` - ${k}`));
} else {
logOk('All data-i18n keys exist in locales');
}
// 5. Check for I18n.t() calls in JavaScript code
console.log('\nChecking I18n.t() calls in JavaScript code...');
const i18nCallRegex = /I18n\.t\(['"`]([^'"`]+)['"`]/g;
const jsKeys = [];
while ((match = i18nCallRegex.exec(htmlContent)) !== null) {
jsKeys.push(match[1]);
}
// Also check for I18n.t() with variables (e.g., I18n.t(key))
const i18nVarRegex = /I18n\.t\(\s*([^)]+)\s*\)/g;
while ((match = i18nVarRegex.exec(htmlContent)) !== null) {
const keyExpr = match[1].trim();
// Skip quoted strings (already captured), template literals, and bare identifiers
// (variables like `key`, `errKey`, `errCode` — runtime-resolved, not literal keys)
if (/^[`'"']/.test(keyExpr)) continue;
if (/^\${/.test(keyExpr)) continue;
if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(keyExpr)) continue;
jsKeys.push(keyExpr);
}
// Deduplicate
const uniqueJsKeys = [...new Set(jsKeys)];
// Check if all I18n.t() keys exist in locales
const missingJsKeys = [];
for (const key of uniqueJsKeys) {
// Skip template expressions and variables
if (key.includes('${') || key === 'key' || key.includes('?') || key.includes('vars') || key.endsWith('.') || key.includes('+')) continue;
// Navigate nested key path
const parts = key.split('.');
let enVal = enJson;
let zhVal = zhJson;
let exists = true;
for (const part of parts) {
enVal = enVal?.[part];
zhVal = zhVal?.[part];
if (enVal === undefined || zhVal === undefined) {
exists = false;
break;
}
}
if (!exists) {
missingJsKeys.push(key);
}
}
if (missingJsKeys.length > 0) {
logError(`I18n.t() keys missing in locales (${missingJsKeys.length}):`);
missingJsKeys.forEach(k => console.log(` - ${k}`));
} else {
logOk('All I18n.t() keys exist in locales');
}
// 6. Chinese in localizable attributes without a data-i18n-* counterpart.
// The whole-line whitelist above (any `data-i18n`) used to hide a hardcoded
// Chinese title/placeholder on an element that only had data-i18n for its text
// — so English users saw Chinese tooltips. Check each attribute individually.
console.log('\nChecking title / placeholder / aria-label attributes for un-i18n Chinese...');
const ATTR_PAIRS = [
['title', 'data-i18n-title'],
['placeholder', 'data-i18n-placeholder'],
['aria-label', 'data-i18n-aria-label'],
];
const openTagRe = /<[a-zA-Z][^>]*>/g;
let attrChineseViolations = 0;
let tagMatch;
while ((tagMatch = openTagRe.exec(htmlContent)) !== null) {
const tag = tagMatch[0];
if (tag.includes('${')) continue; // dynamic value — resolved at runtime
for (const [attr, i18nAttr] of ATTR_PAIRS) {
const am = new RegExp(`\\b${attr}="([^"]*)"`).exec(tag);
if (am && CHINESE_REGEX.test(am[1]) && !tag.includes(i18nAttr + '=')) {
const ln = htmlContent.slice(0, tagMatch.index).split('\n').length;
logError(`index.html:${ln}: <${attr}> has un-i18n Chinese (add ${i18nAttr}): ${am[1].slice(0, 50)}`);
attrChineseViolations++;
}
}
}
if (attrChineseViolations === 0) {
logOk('No un-i18n Chinese in title / placeholder / aria-label attributes');
}
// 7. Hardcoded English text nodes in the static HTML body (Prev/Next class).
// The Chinese check can't catch these; without them a plain >Next</button> ships
// untranslated. Scope to the static body (before the App <script>), skip dynamic
// (${}) and non-copy tags, and allow a few brand/legal tokens.
console.log('\nChecking static HTML body for hardcoded (non-i18n) English text...');
const bodyStart = htmlContent.indexOf('<body>');
const appScriptStart = htmlContent.indexOf('<script>', bodyStart);
const bodyStartLine = htmlContent.slice(0, bodyStart).split('\n').length - 1;
const bodyHtml = bodyStart >= 0 && appScriptStart >= 0 ? htmlContent.slice(bodyStart, appScriptStart) : '';
const TEXT_ALLOW = /^(WindsurfAPI|bydwgx1337|©\s*Windsurf|windsurf\.com|v\d)/;
const SKIP_TAGS = /^(svg|path|option|br|hr|script|style|code|pre)/i;
const tokenRe = /<([a-zA-Z/!][^>]*)>|([^<]+)/g;
let lastOpenTag = '';
let tok;
let hardcodedEnglishViolations = 0;
while ((tok = tokenRe.exec(bodyHtml)) !== null) {
if (tok[1] !== undefined) {
if (!/^\//.test(tok[1])) lastOpenTag = tok[1];
continue;
}
const text = tok[2].trim();
if (!text || text.includes('${') || text.includes('{{') || CHINESE_REGEX.test(text)) continue;
if (!/[A-Za-z]{2,}/.test(text)) continue; // needs real words
if (/^[\d.,\s%+×x/:()[\]|·—–-]+$/.test(text)) continue; // pure numbers/units/symbols
if (TEXT_ALLOW.test(text)) continue; // brand / legal / version
const tagName = lastOpenTag.split(/[\s>]/)[0].toLowerCase();
if (SKIP_TAGS.test(tagName)) continue;
if (/data-i18n(?:-[\w-]+)?=/.test(lastOpenTag)) continue; // element carries an i18n default
const ln = bodyStartLine + bodyHtml.slice(0, tok.index).split('\n').length;
logError(`index.html:${ln}: hardcoded English text (add data-i18n): "${text.slice(0, 50)}"`);
hardcodedEnglishViolations++;
}
if (hardcodedEnglishViolations === 0) {
logOk('No hardcoded English text nodes in static HTML body');
}
// 8. Hardcoded English string literals in the App <script> region.
// Checks #6/#7 stop at the <script> tag, so the entire JS half — where most
// runtime copy is generated — was unguarded: `el.textContent = 'Save'` or an
// innerHTML template with English text nodes ships untranslated with a green
// gate (and `I18n.t('k') || 'English'` fallbacks are a fake safety net). This
// scan flags literals assigned to DOM copy sinks / text nodes inside innerHTML
// that bypass I18n.t(). It is calibrated to 0 findings on the current file;
// WARN-FIRST (never touches exitCode) so a future regression is visible without
// breaking the tree — promote to logError once the codebase stays clean.
console.log('\nChecking App <script> region for hardcoded (non-i18n) English string literals...');
const jsEnglishFindings = scanScriptRegionForEnglish(htmlContent);
if (jsEnglishFindings.length === 0) {
logOk('No hardcoded English string literals in App <script> region');
} else {
logWarn(`Hardcoded English string literal(s) in App <script> region (${jsEnglishFindings.length}) — wrap in I18n.t():`);
jsEnglishFindings.forEach((f) => console.log(` - index.html:${f.line}: [${f.kind}] "${f.text}"`));
}
// Summary
console.log(`\n${'='.repeat(50)}`);
if (violations === 0) {
console.log(`${GREEN}✓ All i18n checks passed!${RESET}`);
} else {
console.log(`${RED}✗ Found ${violations} violation(s)${RESET}`);
}
console.log(`${'='.repeat(50)}\n`);
process.exit(exitCode);