Files

176 lines
6.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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 的防回归闸门。
*
* 为什么必须有:P1 是一边翻译一边还在开发新功能。没有这道闸门,
* 翻译永远收不了口(翻完一片,另一片又进来新的中文串)。
*
* 为什么用 Node 而不是 .ps1:这个脚本要进 CI,也要能被任何人在任何 shell 里跑;
* 而且本机的 PowerShell 5.1 对 UTF-8 无 BOM 的 .ps1 会按 GBK 读,中文注释一乱码
* 就是语法错误(见 pitfalls 9)。Node 读文件永远按 UTF-8。
*
* 只报**代码里的**中文:注释里的中文有 1700 多行,是项目的正常风格,全报出来
* 就没人看了。所以先剥注释,再在剩下的部分里找。
*
* 用法:
* node scripts/check-i18n.mjs # 只看未迁移的文件清单(摘要)
* node scripts/check-i18n.mjs --all # 逐行列出
* node scripts/check-i18n.mjs --strict # 白名单外只要有一处就退出码 1(CI 用)
*/
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join, relative, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import ts from 'typescript'
const SRC_ROOT = fileURLToPath(new URL('../src', import.meta.url))
const CLIENT_ROOT = fileURLToPath(new URL('..', import.meta.url))
const CJK = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3000-\u303f\uff01-\uff5e]/
/**
* 允许含中文的路径(前缀匹配,相对 client/)。
*
* 加白名单前先问一句:这里的中文是**给用户看的文案**,还是**被处理的数据**?
* 是文案就该进 locale 文件,不该进这个列表。
*/
const ALLOWLIST = [
// locale 文件本身就是中文的家
'src/i18n/locales/',
]
/**
* 测试整体不扫。
*
* 测试里的中文分两种,两种都不该由这个脚本管:中文文本处理的**被测数据**
* (textPostProcess / textReplacement / textSegmenter)翻掉等于废掉测试;
* 中文的 describe/it 标题是项目既有风格,也不是用户可见文案。
* 真正要处理的是「断言了用户可见文案」的那几个测试 —— 它们由 i18n-todo.md 的
* P1「每片都要遵守的三条」逐片改成断言 key,不靠这里扫。
*/
const SKIP_DIRS = ['__tests__']
const args = new Set(process.argv.slice(2))
const showAll = args.has('--all')
const strict = args.has('--strict')
/**
* 少量中文是算法输入、错误匹配词或 Prompt,不是界面文案。它们必须就地标明边界:
* // i18n-allow-start: 原因
* ...
* // i18n-allow-end
* 或在单行末尾写 `// i18n-allow: 原因`。
*
* 不使用整文件白名单:同一个文件以后新增 UI 文案时,闸门仍然必须能拦住。
*/
function collectAllowedLines(file, source) {
const allowed = new Set()
let inAllowedBlock = false
source.split('\n').forEach((line, index) => {
const lineNumber = index + 1
if (line.includes('i18n-allow-start')) {
if (inAllowedBlock) throw new Error(`${file}:${lineNumber}: nested i18n allow block`)
inAllowedBlock = true
}
if (inAllowedBlock || line.includes('i18n-allow:')) allowed.add(lineNumber)
if (line.includes('i18n-allow-end')) {
if (!inAllowedBlock) throw new Error(`${file}:${lineNumber}: unmatched i18n-allow-end`)
inAllowedBlock = false
}
})
if (inAllowedBlock) throw new Error(`${file}: unclosed i18n allow block`)
return allowed
}
function isTextBearingNode(node) {
return ts.isStringLiteralLike(node)
|| ts.isRegularExpressionLiteral(node)
|| ts.isJsxText(node)
|| node.kind === ts.SyntaxKind.TemplateHead
|| node.kind === ts.SyntaxKind.TemplateMiddle
|| node.kind === ts.SyntaxKind.TemplateTail
}
/** 用 TypeScript AST 找字符串、模板、正则与 JSX 文本;注释天然不会进入结果。 */
function findChinese(file, source) {
const scriptKind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, scriptKind)
const sourceLines = source.split('\n')
const allowedLines = collectAllowedLines(file, source)
const results = new Map()
const visit = (node) => {
if (isTextBearingNode(node)) {
const nodeText = node.getText(sourceFile)
const isJsxComment = ts.isJsxText(node) && /^\s*\{\/\*/.test(nodeText)
if (!isJsxComment && CJK.test(nodeText)) {
const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line
nodeText.split('\n').forEach((part, offset) => {
const line = start + offset
const lineNumber = line + 1
if (!allowedLines.has(lineNumber) && CJK.test(part)) {
results.set(lineNumber, { line: lineNumber, text: (sourceLines[line] ?? '').trim() })
}
})
}
}
if (!ts.isJsxText(node)) {
ts.forEachChild(node, visit)
}
}
visit(sourceFile)
return [...results.values()]
}
function walk(dir, files = []) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry)
if (statSync(full).isDirectory()) {
if (!SKIP_DIRS.includes(entry)) walk(full, files)
} else if (/\.(ts|tsx)$/.test(entry)) {
files.push(full)
}
}
return files
}
function isAllowed(relPath) {
const posix = relPath.split(sep).join('/')
return ALLOWLIST.some((prefix) => posix.startsWith(prefix))
}
const findings = []
for (const file of walk(SRC_ROOT)) {
const relPath = relative(CLIENT_ROOT, file)
if (isAllowed(relPath)) continue
const source = readFileSync(file, 'utf8')
for (const item of findChinese(file, source)) {
findings.push({ file: relPath.split(sep).join('/'), ...item })
}
}
if (findings.length === 0) {
console.log('check-i18n: no hardcoded Chinese found outside the allowlist.')
process.exit(0)
}
const byFile = new Map()
for (const item of findings) {
byFile.set(item.file, (byFile.get(item.file) ?? 0) + 1)
}
if (showAll) {
for (const item of findings) {
console.log(`${item.file}:${item.line}: ${item.text}`)
}
} else {
for (const [file, count] of [...byFile].sort((a, b) => b[1] - a[1])) {
console.log(`${String(count).padStart(4)} ${file}`)
}
}
console.log(`\ncheck-i18n: ${findings.length} line(s) in ${byFile.size} file(s) still hold Chinese in code.`)
if (strict) {
console.error('check-i18n: --strict is on, failing.')
process.exit(1)
}
process.exit(0)