agent-Specialization/scripts/i18n_audit.mjs
JOJO 7c7201a574 feat(i18n): 前端多语言基建(vue-i18n 文案 token 体系 + 审计栏杆)与第一波迁移
- 基建:static/src/locales/ 11 个命名空间×中英双语,zh 为源语言;
  en-US 聚合器 DeepString 强校验 key 奇偶;installI18n/useLocale 复刻 theme.ts 模式
- 语言切换:个人空间→外观→界面语言(默认 zh-CN,localStorage 持久化)
- 防回退栏杆:scripts/i18n_audit.mjs(字符级注释剥离状态机)+ baseline 豁免清单,
  挂入 npm run lint 链首与独立 lint:text;规范见 doc/frontend/i18n_spec.md(本地)
- 第一波迁移 13 文件约 1400 条:PersonalizationDrawer/toolRenderers/chatDisplay/
  InputComposer/admin 全部 6 应用/tutorial/MonitorDirector/WorkflowEditorView
- common 公共词归并:刷新中/保存中/暂无数据/未运行 单一来源
- AGENTS.md 新增 §5.8 文案规范
2026-08-28 12:07:03 +08:00

337 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* i18n 文案栏杆(对标 .stylelintrc.cjs 的颜色 token 栏杆,规范见 doc/frontend/i18n_spec.md
*
* 作用:防止「绕过文案包直接写中文界面文字」的写法回退。
* - 已迁移文件(不在 baseline 中script/模板里的中文字符串 → 报错(必须用 t()/$t())。
* - 存量文件(在 scripts/i18n_baseline.txt 中):暂时豁免,迁移一个、从 baseline 移除一个。
* - 中文注释、console/debugLog 日志行不作为违规(见规范 §3.3)。
* - static/src/locales/** 永久豁免(文案定义源,对标 _tokens.scss
*
* 用法:
* node scripts/i18n_audit.mjs # 检查
* node scripts/i18n_audit.mjs --write-baseline # 重新生成 baseline仅限立规初始化禁止用于掩盖新增违规
*/
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
const ROOT = process.cwd();
const SRC_DIR = join(ROOT, 'static', 'src');
const LOCALES_DIR = join(SRC_DIR, 'locales');
const BASELINE_PATH = join(ROOT, 'scripts', 'i18n_baseline.txt');
const CJK_RE = /[一-龥]/;
/** 递归收集 .vue/.ts 文件(排除 .d.ts 与 locales/ */
function collectSourceFiles(dir, out = []) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (full.startsWith(LOCALES_DIR)) continue;
const stat = statSync(full);
if (stat.isDirectory()) {
if (entry === 'node_modules' || entry === 'dist') continue;
collectSourceFiles(full, out);
} else if (/\.(vue|ts)$/.test(entry) && !entry.endsWith('.d.ts')) {
out.push(full);
}
}
return out;
}
/**
* 尽力而为的注释剥离(字符级状态机):
* 保留字符串内容('...' "..." `...` 及正则字面量),剥离 // 行注释、/* */ 块注释、<!-- --> HTML 注释。
*
* 已知近似(作为防回退栏杆足够,不作为解析器):
* - 正则起点用「前一个有效字符是否为运算符/括号类」启发式判断;
* - prevOutChar 跟踪的是「最近一次追加到输出的非空白字符」(注释视为空白),
* 不能用 code 下标回查输出串(两者长度不等会错位——已踩过坑)。
*/
export function stripComments(code) {
let out = '';
let i = 0;
const n = code.length;
// 状态normal | line | block | html | sq | dq | tpl | regex | regexClass
let state = 'normal';
const tplBraceDepth = []; // 模板字符串内 ${} 的嵌套深度栈
let prevOutChar = ''; // 最近追加到 out 的非空白字符(正则起点启发式用)
const append = (text) => {
out += text;
for (let k = text.length - 1; k >= 0; k--) {
const c = text[k];
if (c !== ' ' && c !== '\t' && c !== '\n' && c !== '\r') {
prevOutChar = c;
break;
}
}
};
while (i < n) {
const ch = code[i];
const next = i + 1 < n ? code[i + 1] : '';
if (state === 'line') {
if (ch === '\n') {
state = 'normal';
out += ch;
}
i++;
continue;
}
if (state === 'block') {
if (ch === '*' && next === '/') {
state = 'normal';
i += 2;
} else {
if (ch === '\n') out += ch; // 保留换行,行号不漂移
i++;
}
continue;
}
if (state === 'html') {
if (ch === '-' && next === '-' && code[i + 2] === '>') {
state = 'normal';
i += 3;
} else {
if (ch === '\n') out += ch;
i++;
}
continue;
}
if (state === 'regex') {
if (ch === '\\') {
append(ch + next);
i += 2;
continue;
}
if (ch === '[') {
state = 'regexClass';
append(ch);
i++;
continue;
}
if (ch === '/') {
state = 'normal';
append(ch);
i++;
continue;
}
if (ch === '\n') {
// 正则跨行 = 之前误判(实为除法),回退为普通代码
state = 'normal';
out += ch;
i++;
continue;
}
append(ch);
i++;
continue;
}
if (state === 'regexClass') {
if (ch === '\\') {
append(ch + next);
i += 2;
continue;
}
if (ch === ']') state = 'regex';
append(ch);
i++;
continue;
}
if (state === 'sq' || state === 'dq' || state === 'tpl') {
if (ch === '\\') {
append(ch + next);
i += 2;
continue;
}
if (state === 'sq' && ch === "'") {
state = 'normal';
append(ch);
i++;
continue;
}
if (state === 'dq' && ch === '"') {
state = 'normal';
append(ch);
i++;
continue;
}
if (state === 'tpl') {
if (ch === '`') {
state = 'normal';
append(ch);
i++;
continue;
}
if (ch === '$' && next === '{') {
tplBraceDepth.push(1);
append('${');
i += 2;
state = 'normal';
continue;
}
}
append(ch);
i++;
continue;
}
// normal 状态
if (ch === '/' && next === '/') {
state = 'line';
i += 2;
continue;
}
if (ch === '/' && next === '*') {
state = 'block';
i += 2;
continue;
}
// 正则字面量(启发式):/ 的前一个有效字符是运算符/括号类时视为正则起点,
// 否则视为除法。不修这个会把 /"/g 这类正则里的引号误判为字符串起点,导致后续状态全错。
if (ch === '/' && next !== '/' && next !== '*' && next !== '=') {
const regexStarters = '(,=:[!&|?{};+-*%^~<>';
if (prevOutChar === '' || regexStarters.includes(prevOutChar)) {
state = 'regex';
append(ch);
i++;
continue;
}
}
if (ch === '<' && next === '!' && code[i + 2] === '-' && code[i + 3] === '-') {
state = 'html';
i += 4;
continue;
}
if (ch === "'") {
state = 'sq';
append(ch);
i++;
continue;
}
if (ch === '"') {
state = 'dq';
append(ch);
i++;
continue;
}
if (ch === '`') {
state = 'tpl';
append(ch);
i++;
continue;
}
if (ch === '{' && tplBraceDepth.length > 0) {
tplBraceDepth[tplBraceDepth.length - 1]++;
append(ch);
i++;
continue;
}
if (ch === '}' && tplBraceDepth.length > 0) {
tplBraceDepth[tplBraceDepth.length - 1]--;
if (tplBraceDepth[tplBraceDepth.length - 1] === 0) {
tplBraceDepth.pop();
append(ch);
i++;
state = 'tpl';
continue;
}
append(ch);
i++;
continue;
}
append(ch);
i++;
}
return out;
}
/** console / debugLog 日志行不作为违规(日志可保留中文,见规范 §3.3 */
const LOG_LINE_RE =
/\bconsole\s*\.\s*(log|warn|error|info|debug|trace|table|group|groupEnd|groupCollapsed)\s*\(|\bdebugLog\s*\(|\.\s*debugLog\s*\(/;
/** 返回文件中剥离注释后仍含中文的行号列表 */
function findCjkLines(filePath) {
const stripped = stripComments(readFileSync(filePath, 'utf8'));
const lines = stripped.split('\n');
const hits = [];
for (let idx = 0; idx < lines.length; idx++) {
if (CJK_RE.test(lines[idx]) && !LOG_LINE_RE.test(lines[idx])) hits.push(idx + 1);
}
return hits;
}
function readBaseline() {
try {
return new Set(
readFileSync(BASELINE_PATH, 'utf8')
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'))
);
} catch {
return new Set();
}
}
function main() {
// 入口守卫:被 import 时不执行(供其他脚本复用 stripComments
const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].split('/').pop());
if (!isMain) return;
const writeBaseline = process.argv.includes('--write-baseline');
const files = collectSourceFiles(SRC_DIR);
const violations = new Map(); // relPath -> lineNumbers
for (const file of files) {
const hits = findCjkLines(file);
if (hits.length > 0) {
violations.set(relative(ROOT, file), hits);
}
}
if (writeBaseline) {
const content =
'# i18n baseline —— 立规前已存在中文文案的存量文件(迁移一个、移除一个)\n' +
'# 重新生成node scripts/i18n_audit.mjs --write-baseline\n' +
[...violations.keys()].sort().join('\n') +
'\n';
writeFileSync(BASELINE_PATH, content);
console.log(`[i18n-audit] baseline 已写入 ${violations.size} 个文件 → scripts/i18n_baseline.txt`);
return;
}
const baseline = readBaseline();
const newViolations = [];
const cleanNow = [];
for (const [rel, hits] of violations) {
if (baseline.has(rel)) {
// 存量豁免中
} else {
newViolations.push([rel, hits]);
}
}
for (const rel of baseline) {
if (!violations.has(rel)) cleanNow.push(rel);
}
if (newViolations.length > 0) {
console.error('[i18n-audit] 以下文件绕过了文案包,直接写入了中文(规范见 doc/frontend/i18n_spec.md');
for (const [rel, hits] of newViolations) {
console.error(` ${rel} (行: ${hits.slice(0, 10).join(', ')}${hits.length > 10 ? '…' : ''})`);
}
console.error('[i18n-audit] 请改用 t()/$t() 取词;若为存量文件迁移中,请先完成迁移再加入 baseline 移除流程。');
process.exit(1);
}
if (cleanNow.length > 0) {
console.log('[i18n-audit] 以下 baseline 文件已不含中文文案,可从 scripts/i18n_baseline.txt 移除:');
for (const rel of cleanNow) console.log(` ${rel}`);
}
console.log(`[i18n-audit] 通过(豁免中 ${baseline.size - cleanNow.length} 个存量文件,无新增违规)。`);
}
main();