dootask/scripts/check-language.mjs

1235 lines
51 KiB
JavaScript
Raw 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
/**
* 前端国际化质量门禁与迁移报告。
*
* 阻断字面量登记、Vue 模板硬编码、占位符、翻译数据、前端生成文件。
* 报告:动态翻译键、(**) 迁移、可参数化文案、疑似未使用文案。
*/
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, extname, join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const babelParser = require("@babel/parser");
const babelTraverse = require("@babel/traverse").default;
const vueTemplateCompiler = require("vue-template-compiler");
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const FRONTEND_DIR = join(ROOT, "resources", "assets", "js");
const ORIGINAL_WEB_FILE = join(ROOT, "language", "original-web.txt");
const TRANSLATE_FILE = join(ROOT, "language", "translate.json");
const PUBLIC_WEB_DIR = join(ROOT, "public", "language", "web");
const LANGUAGE_FIELDS = ["key", "zh", "zh-CHT", "en", "ko", "ja", "de", "fr", "id", "ru"];
const EXCLUDED_TEMPLATE_FILES = new Set([
"resources/assets/js/pages/manage/components/MCPHelper.vue",
]);
const ACTUAL_SOURCE_ROOTS = [
"app",
"bootstrap/app.php",
"bootstrap/providers.php",
"config",
"database",
"electron",
"resources/assets/js",
"resources/assets/sass",
"resources/mobile/src",
"resources/mobile/scripts",
"resources/views",
"routes",
"bin",
];
const ACTUAL_SOURCE_EXTENSIONS = new Set([
".cjs", ".css", ".htm", ".html", ".js", ".json", ".jsx", ".less", ".mjs",
".php", ".sass", ".scss", ".sh", ".ts", ".tsx", ".vue", ".xml", ".yaml", ".yml",
]);
const EXCLUDED_SOURCE_DIRECTORIES = new Set([
".git", "node_modules", "vendor", "ai-kb", "coverage", "dist", "build", "platforms", "plugins",
]);
const BABEL_PLUGINS = [
"classProperties",
"classPrivateProperties",
"classPrivateMethods",
"decorators-legacy",
"dynamicImport",
"jsx",
"nullishCoalescingOperator",
"objectRestSpread",
"optionalChaining",
"topLevelAwait",
];
const PARAMETER_RE = /\(%[TM]\d+\)/g;
const RAW_PARAMETER_RE = /\(\*{1,2}\)/g;
const PARAMETER_TEST_RE = /\(%[TM]\d+\)/;
const RAW_PARAMETER_TEST_RE = /\(\*{1,2}\)/;
const NAMED_PARAMETER_RE = /\{[A-Za-z_][A-Za-z0-9_.-]*\}/g;
const CHINESE_RE = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/;
function normalizePath(file) {
return file.split(sep).join("/");
}
function lineAt(content, offset) {
return content.slice(0, offset).split("\n").length;
}
function shortenText(text, maxLength = 120) {
const compact = text.replace(/\s+/g, " ").trim();
return compact.length <= maxLength ? compact : `${compact.slice(0, maxLength - 1)}`;
}
function collectFiles(path, predicate, out = []) {
if (!existsSync(path)) return out;
const stat = statSync(path);
if (stat.isFile()) {
if (predicate(path)) out.push(path);
return out;
}
for (const entry of readdirSync(path, { withFileTypes: true })) {
if (entry.isDirectory() && EXCLUDED_SOURCE_DIRECTORIES.has(entry.name)) continue;
const full = join(path, entry.name);
if (entry.isDirectory()) {
collectFiles(full, predicate, out);
} else if (entry.isFile() && predicate(full)) {
out.push(full);
}
}
return out;
}
function readOriginalLines(file) {
return readFileSync(file, "utf8")
.split(/\r?\n/)
.map((text, index) => ({ text: text.trim(), line: index + 1 }))
.filter(item => item.text !== "");
}
function normalizeParameterKey(key) {
return key.replace(/\(%T\d+\)/g, "(*)").replace(/\(%M\d+\)/g, "(**)");
}
function placeholders(value) {
return [...value.matchAll(PARAMETER_RE)].map(match => match[0]);
}
function samePlaceholderSet(expected, actual) {
return [...expected].sort().join("\u0000") === [...actual].sort().join("\u0000");
}
function addMapLocation(map, key, location) {
if (!map.has(key)) map.set(key, []);
const locations = map.get(key);
if (!locations.includes(location)) locations.push(location);
}
function uniqueNamedParameters(items) {
const seen = new Set();
return items.filter(item => {
const key = `${item.location}\u0000${item.named}\u0000${item.text}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function isTranslationCall(node) {
const callee = node.callee;
if (callee?.type === "Identifier" && callee.name === "$L") return true;
if (callee?.type !== "MemberExpression" && callee?.type !== "OptionalMemberExpression") return false;
const propertyName = callee.computed ? callee.property?.value : callee.property?.name;
if (propertyName === "$L") return true;
return propertyName === "L" && callee.object?.type === "Identifier" && callee.object.name === "$A";
}
function staticStringValue(node) {
if (node?.type === "StringLiteral") return node.value.trim();
if (node?.type === "TemplateLiteral" && node.expressions.length === 0) {
return (node.quasis[0]?.value?.cooked ?? node.quasis[0]?.value?.raw ?? "").trim();
}
return null;
}
function translationLiteralValue(node) {
return node?.type === "StringLiteral" ? node.value.trim() : null;
}
function containsStaticString(node) {
if (!node) return false;
if (node.type === "StringLiteral") return true;
if (node.type === "TemplateLiteral") return node.quasis.some(part => (part.value.cooked ?? part.value.raw ?? "") !== "");
if (node.type === "BinaryExpression" && node.operator === "+") {
return containsStaticString(node.left) || containsStaticString(node.right);
}
return false;
}
function classifyDynamicArgument(node) {
if (node?.type === "TemplateLiteral") return "模板字符串";
if (node?.type === "BinaryExpression" && node.operator === "+" && containsStaticString(node)) return "字符串拼接";
return null;
}
function appendStringShapes(left, right) {
const values = [];
for (const leftValue of left) {
for (const rightValue of right) {
values.push(leftValue + rightValue);
if (values.length >= 32) return values;
}
}
return values;
}
function normalizeShapePunctuation(value) {
return value
.replace(/"\(\*\)"/g, "(*)")
.replace(/"\(\*\)/g, "(*)")
.replace(/\(\*\)"/g, "(*)");
}
function normalizeParameterizedShape(value) {
return normalizeShapePunctuation(compactParameterized(value));
}
function parameterizedFixedPartInfo(value) {
const normalized = normalizeParameterizedShape(value);
if (!normalized.includes("(*)") && !normalized.includes("(**)")) return false;
const parts = normalized.split(/\(\*{1,2}\)/);
if (parts.length < 2) return false;
const fixedParts = parts.map(part => part.trim()).filter(Boolean);
const meaningfulLength = [...fixedParts.join("").matchAll(/[A-Za-z0-9\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/g)].length;
return { parts, fixedParts, meaningfulLength };
}
function isCredibleParameterizedShape(shape) {
const info = parameterizedFixedPartInfo(shape);
if (!info || info.fixedParts.length === 0) return false;
if (info.fixedParts.length >= 2) {
return info.meaningfulLength >= 4 || (info.parts.length > 2 && info.fixedParts.length >= 3 && info.meaningfulLength >= 3);
}
return info.meaningfulLength >= 4;
}
function recordParameterizedShape(result, shape, location) {
if (!isCredibleParameterizedShape(shape)) return;
result.parameterizedShapes.push({ shape, location });
}
function expressionStringShapes(node, scope, seen = new Set(), allowUnknown = false) {
if (!node) return allowUnknown ? ["(*)"] : [];
if (node.type === "StringLiteral") return [node.value.trim()];
if (node.type === "NumericLiteral" || node.type === "BooleanLiteral") return [String(node.value)];
if (node.type === "TemplateLiteral") {
let values = [""];
for (let index = 0; index < node.quasis.length; index++) {
values = values.map(value => value + (node.quasis[index].value.cooked ?? node.quasis[index].value.raw ?? ""));
if (index >= node.expressions.length) continue;
const expressionValues = expressionStringShapes(node.expressions[index], scope, seen, true);
values = appendStringShapes(values, expressionValues);
}
return values.map(value => value.trim());
}
if (node.type === "BinaryExpression" && node.operator === "+") {
return appendStringShapes(
expressionStringShapes(node.left, scope, seen, true),
expressionStringShapes(node.right, scope, seen, true),
);
}
if (node.type === "ConditionalExpression") {
return [
...expressionStringShapes(node.consequent, scope, seen, allowUnknown),
...expressionStringShapes(node.alternate, scope, seen, allowUnknown),
].slice(0, 32);
}
if (node.type === "LogicalExpression") {
return [
...expressionStringShapes(node.left, scope, seen, allowUnknown),
...expressionStringShapes(node.right, scope, seen, allowUnknown),
].slice(0, 32);
}
if (node.type === "Identifier") {
const binding = scope?.getBinding(node.name);
const bindingNode = binding?.path?.node;
const init = bindingNode?.type === "VariableDeclarator" ? bindingNode.init : null;
if (init && !seen.has(bindingNode)) {
const nextSeen = new Set(seen);
nextSeen.add(bindingNode);
const values = expressionStringShapes(init, binding.path.scope, nextSeen, allowUnknown);
for (const violation of binding.constantViolations || []) {
const right = violation.node?.type === "AssignmentExpression" ? violation.node.right : null;
if (right) values.push(...expressionStringShapes(right, violation.scope, nextSeen, allowUnknown));
}
const unique = [...new Set(values)];
if (unique.length) return unique.slice(0, 32);
return allowUnknown ? ["(*)"] : [];
}
}
return allowUnknown ? ["(*)"] : [];
}
function templateShape(node) {
if (node?.type !== "TemplateLiteral" || node.expressions.length === 0) return null;
let result = "";
for (let index = 0; index < node.quasis.length; index++) {
result += node.quasis[index].value.cooked ?? node.quasis[index].value.raw ?? "";
if (index < node.expressions.length) result += "(*)";
}
return result.trim();
}
function parseJavaScript(content, rel, baseLine, result) {
let ast;
try {
ast = babelParser.parse(content, {
sourceType: "unambiguous",
allowReturnOutsideFunction: true,
plugins: BABEL_PLUGINS,
});
} catch (error) {
result.parserWarnings.push(`${rel}:${baseLine} JavaScript 解析失败,已跳过 AST 报告:${error.message}`);
return;
}
const locationOf = node => `${rel}:${baseLine + (node.loc?.start?.line || 1) - 1}`;
babelTraverse(ast, {
CallExpression(path) {
const node = path.node;
if (!isTranslationCall(node) || node.arguments.length === 0) return;
const argument = node.arguments[0];
const location = locationOf(node);
const literal = translationLiteralValue(argument);
if (literal) {
addMapLocation(result.literalUsages, literal, location);
for (const named of literal.match(NAMED_PARAMETER_RE) || []) {
result.namedParameters.push({ text: literal, named, location });
}
}
const staticValue = staticStringValue(argument);
if (staticValue) {
for (const named of staticValue.match(NAMED_PARAMETER_RE) || []) {
result.namedParameters.push({ text: staticValue, named, location });
}
}
if (literal) {
if (literal.includes("(*)") || literal.includes("(**)")) {
let rendered = [literal];
for (const argumentValue of node.arguments.slice(1)) {
const values = expressionStringShapes(argumentValue, path.scope);
rendered = rendered.flatMap(value => values.map(item => {
const marker = value.includes("(**)") ? "(**)" : "(*)";
return value.replace(marker, item);
})).slice(0, 32);
}
for (const shapeValue of rendered) {
recordParameterizedShape(result, shapeValue, location);
}
}
return;
}
const category = classifyDynamicArgument(argument);
if (category) {
result.dynamicCalls.push({ category, location, expression: shortenText(content.slice(argument.start, argument.end)) });
}
},
BinaryExpression(path) {
if (path.node.operator !== "+" || !containsStaticString(path.node)) return;
for (const shape of expressionStringShapes(path.node, path.scope)) {
recordParameterizedShape(result, shape, locationOf(path.node));
}
},
StringLiteral(path) {
const value = path.node.value.trim();
if (value) addMapLocation(result.sourceStrings, value, locationOf(path.node));
},
TemplateLiteral(path) {
const literal = staticStringValue(path.node);
if (literal) addMapLocation(result.sourceStrings, literal, locationOf(path.node));
const shape = templateShape(path.node);
if (shape) result.templateShapes.push({ shape, location: locationOf(path.node) });
if (path.node.expressions.length) {
for (const value of expressionStringShapes(path.node, path.scope)) {
recordParameterizedShape(result, value, locationOf(path.node));
}
}
},
});
}
function skipQuoted(content, start, quote) {
let index = start + 1;
while (index < content.length) {
if (content[index] === "\\") {
index += 2;
} else if (content[index] === quote) {
return index + 1;
} else {
index++;
}
}
return content.length;
}
function firstArgumentSource(content, openParen) {
let index = openParen + 1;
while (/\s/.test(content[index] || "")) index++;
const start = index;
const stack = [];
while (index < content.length) {
const char = content[index];
if (char === "'" || char === '"' || char === "`") {
index = skipQuoted(content, index, char);
continue;
}
if (char === "/" && content[index + 1] === "/") {
const newline = content.indexOf("\n", index + 2);
index = newline === -1 ? content.length : newline + 1;
continue;
}
if (char === "/" && content[index + 1] === "*") {
const close = content.indexOf("*/", index + 2);
index = close === -1 ? content.length : close + 2;
continue;
}
if (char === "(" || char === "[" || char === "{") {
stack.push(char);
} else if (char === ")") {
if (stack.length === 0) return content.slice(start, index).trim();
stack.pop();
} else if (char === "]" || char === "}") {
stack.pop();
} else if (char === "," && stack.length === 0) {
return content.slice(start, index).trim();
}
index++;
}
return null;
}
function scanVueTemplateCalls(blockContent, rel, baseLine, result) {
const content = blockContent.replace(/<!--[\s\S]*?-->/g, match => match.replace(/[^\n]/g, " "));
const callRe = /(?<![\w$])\$L\s*\(|\$A\.L\s*\(/g;
let match;
while ((match = callRe.exec(content)) !== null) {
const openParen = content.indexOf("(", match.index);
const source = firstArgumentSource(content, openParen);
if (!source) continue;
let argument;
try {
argument = babelParser.parseExpression(source, { plugins: BABEL_PLUGINS });
} catch {
continue;
}
const location = `${rel}:${baseLine + lineAt(content, match.index) - 1}`;
const literal = translationLiteralValue(argument);
if (literal) {
addMapLocation(result.literalUsages, literal, location);
addMapLocation(result.sourceStrings, literal, location);
for (const named of literal.match(NAMED_PARAMETER_RE) || []) {
result.namedParameters.push({ text: literal, named, location });
}
}
const staticValue = staticStringValue(argument);
if (staticValue) {
addMapLocation(result.sourceStrings, staticValue, location);
for (const named of staticValue.match(NAMED_PARAMETER_RE) || []) {
result.namedParameters.push({ text: staticValue, named, location });
}
}
if (literal) {
continue;
}
const category = classifyDynamicArgument(argument);
if (category) result.dynamicCalls.push({ category, location, expression: shortenText(source) });
const shape = templateShape(argument);
if (shape) result.templateShapes.push({ shape, location });
if (argument.type === "TemplateLiteral" || argument.type === "BinaryExpression" || argument.type === "ConditionalExpression") {
for (const value of expressionStringShapes(argument, null)) {
recordParameterizedShape(result, value, location);
}
}
}
}
function scanPhpStrings(content, rel, result) {
let index = 0;
const readQuoted = (start, quote) => {
let cursor = start + 1;
let value = "";
while (cursor < content.length) {
if (content[cursor] === "\\") {
if (cursor + 1 < content.length) {
value += content[cursor + 1];
cursor += 2;
} else {
cursor++;
}
continue;
}
if (content[cursor] === quote) return { end: cursor + 1, value };
value += content[cursor++];
}
return { end: content.length, value };
};
const skipSpaceAndComments = start => {
let cursor = start;
while (cursor < content.length) {
if (/\s/.test(content[cursor])) {
cursor++;
continue;
}
if (content.startsWith("//", cursor) || content[cursor] === "#") {
const newline = content.indexOf("\n", cursor + 1);
return newline === -1 ? content.length : newline + 1;
}
if (content.startsWith("/*", cursor)) {
const close = content.indexOf("*/", cursor + 2);
cursor = close === -1 ? content.length : close + 2;
continue;
}
break;
}
return cursor;
};
const skipOperand = start => {
let cursor = start;
const stack = [];
while (cursor < content.length) {
if (content[cursor] === "'" || content[cursor] === '"') {
cursor = readQuoted(cursor, content[cursor]).end;
continue;
}
if (content.startsWith("//", cursor) || content[cursor] === "#") {
const newline = content.indexOf("\n", cursor + 1);
cursor = newline === -1 ? content.length : newline + 1;
continue;
}
if (content.startsWith("/*", cursor)) {
const close = content.indexOf("*/", cursor + 2);
cursor = close === -1 ? content.length : close + 2;
continue;
}
if (content[cursor] === "(" || content[cursor] === "[" || content[cursor] === "{") {
stack.push(content[cursor]);
} else if (content[cursor] === ")" || content[cursor] === "]" || content[cursor] === "}") {
if (stack.length === 0) return cursor;
stack.pop();
} else if (stack.length === 0 && (content[cursor] === "." || content[cursor] === ";" || content[cursor] === ",")) {
return cursor;
}
cursor++;
}
return cursor;
};
while (index < content.length) {
if (content.startsWith("//", index) || content[index] === "#") {
const newline = content.indexOf("\n", index + 1);
index = newline === -1 ? content.length : newline + 1;
continue;
}
if (content.startsWith("/*", index)) {
const close = content.indexOf("*/", index + 2);
index = close === -1 ? content.length : close + 2;
continue;
}
if (content[index] !== "'" && content[index] !== '"') {
index++;
continue;
}
const start = index;
const quote = content[index];
const token = readQuoted(index, quote);
index = token.end;
const location = `${rel}:${lineAt(content, start)}`;
const interpolation = quote === '"' && /(?:\{\$[^}]+\}|\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*)/.test(token.value);
if (!interpolation) {
const value = token.value.trim();
if (value) addMapLocation(result.sourceStrings, value, location);
}
let shape = interpolation
? token.value.replace(/\$[A-Za-z_][A-Za-z0-9_]*|\$\{[^}]+\}|\{\$[^}]+\}/g, "(*)").trim()
: token.value.trim();
let cursor = skipSpaceAndComments(index);
let hasConcat = false;
while (content[cursor] === ".") {
hasConcat = true;
cursor = skipSpaceAndComments(cursor + 1);
if (content[cursor] === "'" || content[cursor] === '"') {
const next = readQuoted(cursor, content[cursor]);
const nextInterpolation = content[cursor] === '"' && /(?:\{\$[^}]+\}|\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*)/.test(next.value);
shape += nextInterpolation
? next.value.replace(/\$[A-Za-z_][A-Za-z0-9_]*|\$\{[^}]+\}|\{\$[^}]+\}/g, "(*)").trim()
: next.value.trim();
cursor = skipSpaceAndComments(next.end);
} else {
shape += "(*)";
cursor = skipSpaceAndComments(skipOperand(cursor));
}
}
if (shape.includes("(*)") && (interpolation || hasConcat)) {
recordParameterizedShape(result, shape, location);
}
}
}
function extractTemplateTexts(content) {
let block;
try {
block = vueTemplateCompiler.parseComponent(content, { deindent: false }).template;
} catch {
return [];
}
if (!block) return [];
let ast;
try {
ast = vueTemplateCompiler.compile(block.content, { outputSourceRange: true }).ast;
} catch {
return [];
}
if (!ast) return [];
const results = [];
const visibleTextInfo = (raw) => {
let text = "";
let firstChineseIndex = -1;
let index = 0;
while (index < raw.length) {
if (raw.startsWith("{{", index)) {
const close = raw.indexOf("}}", index + 2);
if (close === -1) break;
index = close + 2;
continue;
}
const char = raw[index];
text += char;
if (firstChineseIndex === -1 && CHINESE_RE.test(char)) firstChineseIndex = index;
index++;
}
text = text.replace(/\s+/g, " ").trim();
return text && firstChineseIndex !== -1 ? { text, firstChineseIndex } : null;
};
const visited = new Set();
const visit = (node) => {
if (!node || visited.has(node)) return;
visited.add(node);
if (node.type === 2 || node.type === 3) {
const info = visibleTextInfo(node.text || "");
if (info) {
const nodeStart = typeof node.start === "number" ? node.start : 0;
results.push({ text: shortenText(info.text), offset: (block.start || 0) + nodeStart + info.firstChineseIndex });
}
}
for (const child of node.children || []) visit(child);
for (const condition of node.ifConditions || []) visit(condition.block);
for (const slot of Object.values(node.scopedSlots || {})) visit(slot);
};
visit(ast);
return results;
}
function scanFrontendSources() {
const files = collectFiles(FRONTEND_DIR, file => /\.(js|vue)$/.test(file));
const result = {
files,
literalUsages: new Map(),
sourceStrings: new Map(),
namedParameters: [],
dynamicCalls: [],
templateShapes: [],
parameterizedShapes: [],
templateTexts: [],
parserWarnings: [],
};
for (const file of files) {
const rel = normalizePath(relative(ROOT, file));
const content = readFileSync(file, "utf8");
if (file.endsWith(".js")) {
parseJavaScript(content, rel, 1, result);
continue;
}
let descriptor;
try {
descriptor = vueTemplateCompiler.parseComponent(content, { deindent: false });
} catch (error) {
result.parserWarnings.push(`${rel}:1 Vue SFC 解析失败:${error.message}`);
continue;
}
if (descriptor.script) {
const scriptOffset = content.indexOf(descriptor.script.content);
parseJavaScript(descriptor.script.content, rel, lineAt(content, Math.max(scriptOffset, 0)), result);
}
if (descriptor.template) {
const templateOffset = content.indexOf(descriptor.template.content);
scanVueTemplateCalls(
descriptor.template.content,
rel,
lineAt(content, Math.max(templateOffset, 0)),
result,
);
}
if (!EXCLUDED_TEMPLATE_FILES.has(rel)) {
for (const item of extractTemplateTexts(content)) {
result.templateTexts.push({ rel, line: lineAt(content, item.offset), text: item.text });
}
}
}
const backendFiles = ACTUAL_SOURCE_ROOTS.flatMap(root => collectFiles(
join(ROOT, root), file => extname(file).toLowerCase() === ".php",
));
for (const file of backendFiles) {
const rel = normalizePath(relative(ROOT, file));
scanPhpStrings(readFileSync(file, "utf8"), rel, result);
}
return result;
}
function stripSourceComments(content, file) {
const extension = extname(file).toLowerCase();
const supportsHashComments = extension === ".php" || extension === ".sh" || extension === ".yaml" || extension === ".yml";
let result = "";
let index = 0;
let quote = null;
const blank = text => text.replace(/[^\r\n]/g, " ");
while (index < content.length) {
const char = content[index];
if (quote) {
result += char;
if (char === "\\") {
result += content[index + 1] || "";
index += 2;
continue;
}
if (char === quote) quote = null;
index++;
continue;
}
if (char === "'" || char === "\"" || char === "`") {
quote = char;
result += char;
index++;
continue;
}
if (content.startsWith("<!--", index)) {
const close = content.indexOf("-->", index + 4);
const end = close === -1 ? content.length : close + 3;
result += blank(content.slice(index, end));
index = end;
continue;
}
if (content.startsWith("//", index)) {
const newline = content.slice(index + 2).search(/\r?\n/);
const end = newline === -1 ? content.length : index + 2 + newline;
result += blank(content.slice(index, end));
index = end;
continue;
}
if (content.startsWith("/*", index)) {
const close = content.indexOf("*/", index + 2);
const end = close === -1 ? content.length : close + 2;
result += blank(content.slice(index, end));
index = end;
continue;
}
if (supportsHashComments && char === "#") {
const newline = content.slice(index + 1).search(/\r?\n/);
const end = newline === -1 ? content.length : index + 1 + newline;
result += blank(content.slice(index, end));
index = end;
continue;
}
result += char;
index++;
}
return result;
}
function buildPatternMatcher(patterns, resolveIndex = index => index) {
const nodes = [{ next: new Map(), fail: 0, outputs: [] }];
patterns.forEach((pattern, patternIndex) => {
let nodeIndex = 0;
for (const char of pattern) {
if (!nodes[nodeIndex].next.has(char)) {
nodes[nodeIndex].next.set(char, nodes.length);
nodes.push({ next: new Map(), fail: 0, outputs: [] });
}
nodeIndex = nodes[nodeIndex].next.get(char);
}
nodes[nodeIndex].outputs.push(resolveIndex(patternIndex));
});
const queue = [];
for (const child of nodes[0].next.values()) queue.push(child);
for (let cursor = 0; cursor < queue.length; cursor++) {
const current = queue[cursor];
for (const [char, child] of nodes[current].next) {
queue.push(child);
let fallback = nodes[current].fail;
while (fallback !== 0 && !nodes[fallback].next.has(char)) fallback = nodes[fallback].fail;
if (nodes[fallback].next.has(char)) fallback = nodes[fallback].next.get(char);
nodes[child].fail = fallback;
nodes[child].outputs.push(...nodes[fallback].outputs);
}
}
return (content, found) => {
let nodeIndex = 0;
for (const char of content) {
while (nodeIndex !== 0 && !nodes[nodeIndex].next.has(char)) nodeIndex = nodes[nodeIndex].fail;
if (nodes[nodeIndex].next.has(char)) nodeIndex = nodes[nodeIndex].next.get(char);
for (const patternIndex of nodes[nodeIndex].outputs) found.add(patternIndex);
}
};
}
function parameterizedShapeMatches(key, shape) {
if (!isCredibleParameterizedShape(shape)) return false;
const normalizedKey = normalizeParameterizedShape(key);
const normalizedShape = normalizeParameterizedShape(shape);
const keyParts = normalizedKey.split(/\(\*{1,2}\)/);
const shapeParts = normalizedShape.split(/\(\*{1,2}\)/);
if (keyParts.length === shapeParts.length && keyParts.every((part, index) => part === shapeParts[index])) return true;
const boundedCaptures = match => match && match.slice(1).every(item => item.length > 0 && item.length <= 200);
if (keyParts.length === shapeParts.length) {
const match = normalizedShape.match(parameterizedRegExp(key));
if (boundedCaptures(match)) return true;
}
if (!/[0-9]/.test(key) || shapeParts.filter(part => part.trim()).length < 2) return false;
const numericMatch = normalizedKey.match(parameterizedRegExp(shape));
return boundedCaptures(numericMatch) && numericMatch.slice(1).some(item => /[0-9]/.test(item));
}
function findUnusedKeys(keys, decodedSourceStrings, parameterizedShapes = []) {
const sourceFiles = ACTUAL_SOURCE_ROOTS.flatMap(root => collectFiles(
join(ROOT, root),
file => ACTUAL_SOURCE_EXTENSIONS.has(extname(file).toLowerCase()),
));
const found = new Set();
const keyIndexes = new Map(keys.map((key, index) => [key, index]));
const parameterizedKeys = keys.filter(key => key.includes("(*)") || key.includes("(**)"));
const parameterizedKeySet = new Set(parameterizedKeys);
const plainKeys = keys.filter(key => !parameterizedKeySet.has(key));
const plainMatcher = buildPatternMatcher(plainKeys, index => keyIndexes.get(plainKeys[index]));
const parameterizedMatcher = buildPatternMatcher(parameterizedKeys, index => keyIndexes.get(parameterizedKeys[index]));
for (const file of sourceFiles) {
const content = readFileSync(file, "utf8");
plainMatcher(content, found);
parameterizedMatcher(stripSourceComments(content, file), found);
}
const parameterizedUsages = new Map();
const recordParameterizedUsage = (key, locations) => {
if (!parameterizedUsages.has(key)) parameterizedUsages.set(key, []);
for (const location of locations || []) {
const entries = parameterizedUsages.get(key);
if (!entries.includes(location)) entries.push(location);
}
};
for (const value of decodedSourceStrings.keys()) {
const index = keyIndexes.get(value);
if (index !== undefined) {
found.add(index);
if (parameterizedKeySet.has(value)) recordParameterizedUsage(value, decodedSourceStrings.get(value));
}
}
for (const key of parameterizedKeys) {
const keyIndex = keyIndexes.get(key);
if (found.has(keyIndex)) continue;
if (isCredibleParameterizedShape(key)) {
const regex = parameterizedRegExp(key);
for (const [value, locations] of decodedSourceStrings) {
if (RAW_PARAMETER_TEST_RE.test(value)) continue;
const match = compactParameterized(value).match(regex);
if (match && match.slice(1).every(item => item.length > 0 && item.length <= 200)) {
found.add(keyIndex);
recordParameterizedUsage(key, locations);
break;
}
}
if (found.has(keyIndex)) continue;
}
for (const item of parameterizedShapes) {
if (!parameterizedShapeMatches(key, item.shape)) continue;
found.add(keyIndex);
recordParameterizedUsage(key, [item.location]);
}
}
return {
sourceFileCount: new Set(sourceFiles).size,
unused: keys.filter((key, index) => !found.has(index)),
parameterizedUsages,
};
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function compactParameterized(value) {
return value.replace(/\s+/g, "");
}
function parameterizedRegExp(key) {
const parts = compactParameterized(key).split(/\(\*{1,2}\)/);
return new RegExp(`^${parts.map(escapeRegExp).join("(.+?)")}$`);
}
function findDoubleStarTodos(originalKeys, templateShapes) {
return originalKeys.filter(key => key.includes("(**)")).map(key => {
const regex = parameterizedRegExp(key);
const sources = templateShapes
.filter(item => regex.test(compactParameterized(item.shape)))
.map(item => item.location);
return { key, sources: [...new Set(sources)] };
});
}
function findSpecificCandidates(originalKeys, sourceStrings) {
const genericKeys = originalKeys.filter(key => key.includes("(*)") && !key.includes("(**)"));
const candidates = [];
for (const concrete of originalKeys) {
if (concrete.includes("(*)") || concrete.includes("(**)")) continue;
const sources = sourceStrings.get(concrete);
if (!sources?.length) continue;
const matches = genericKeys.filter(generic => {
const fixedLength = [...generic.replaceAll("(*)", "")].length;
if (fixedLength < 5) return false;
const match = compactParameterized(concrete).match(parameterizedRegExp(generic));
return match && match.slice(1).every(value => value.length <= 20);
});
if (matches.length === 1) candidates.push({ concrete, generic: matches[0], sources });
}
return candidates;
}
function parseGeneratedWebFile(field) {
const rel = `public/language/web/${field}.js`;
const file = join(PUBLIC_WEB_DIR, `${field}.js`);
if (!existsSync(file)) return { rel, error: "文件不存在" };
const content = readFileSync(file, "utf8");
const match = content.match(/window\.LANGUAGE_DATA\["([^"]+)"\]=(\[[\s\S]*\])\s*;?\s*$/);
if (!match || match[1] !== field) return { rel, error: "文件格式无法解析或字段名不匹配" };
try {
const data = JSON.parse(match[2]);
return Array.isArray(data) ? { rel, data } : { rel, error: "数据不是数组" };
} catch (error) {
return { rel, error: `JSON 解析失败:${error.message}` };
}
}
function validateGeneratedFiles(originalKeys, translationsByNormalizedKey, translationRows, options = {}) {
const errors = [];
const parsed = new Map();
for (const field of LANGUAGE_FIELDS) {
const result = parseGeneratedWebFile(field);
if (result.error) {
errors.push(`${result.rel}${result.error}`);
} else {
parsed.set(field, result);
}
}
if (parsed.size !== LANGUAGE_FIELDS.length) return { errors, rawPublicKeys: [] };
const expectedRows = originalKeys
.map(key => translationsByNormalizedKey.get(key))
.filter(Boolean)
.sort((left, right) => {
const weight = item => Buffer.byteLength(item.key, "utf8") + (PARAMETER_TEST_RE.test(item.key) ? 0 : 10_000_000_000);
const difference = weight(right) - weight(left);
return difference || Buffer.compare(Buffer.from(left.key), Buffer.from(right.key));
});
const expectedLength = expectedRows.length;
const lengthGroups = new Map();
for (const [field, result] of parsed) {
if (result.data.length !== expectedLength) {
const key = result.data.length;
if (!lengthGroups.has(key)) lengthGroups.set(key, []);
lengthGroups.get(key).push(field);
}
}
for (const [actualLength, fields] of lengthGroups) {
const scope = fields.length === LANGUAGE_FIELDS.length
? `public/language/web${fields.length} 个文件`
: fields.map(field => `public/language/web/${field}.js`).join("、");
errors.push(`${scope} 数组长度 ${actualLength},预期 ${expectedLength}`);
}
const publicKeys = parsed.get("key").data;
const duplicateKeys = new Map();
publicKeys.forEach((key, index) => {
if (!duplicateKeys.has(key)) duplicateKeys.set(key, []);
duplicateKeys.get(key).push(index);
});
for (const [key, indexes] of duplicateKeys) {
if (indexes.length > 1) errors.push(`public/language/web/key.js重复 key「${key}」,索引 ${indexes.join(", ")}`);
}
if (options.skipIndexComparison) {
errors.push("public/language/web上游 original-web.txt 或 translate.json 已存在缺失/重复,已跳过逐索引内容比较以避免级联噪声");
return {
errors,
rawPublicKeys: publicKeys
.map((key, index) => ({ key, index }))
.filter(item => RAW_PARAMETER_TEST_RE.test(item.key)),
};
}
const comparisonLength = Math.min(publicKeys.length, expectedRows.length);
for (let index = 0; index < comparisonLength; index++) {
const expected = expectedRows[index];
if (publicKeys[index] !== expected.key) {
errors.push(`public/language/web/key.js[${index}]key 为「${publicKeys[index]}」,预期「${expected.key}`);
continue;
}
for (const field of LANGUAGE_FIELDS) {
const actual = parsed.get(field).data[index];
if (actual !== expected[field]) {
errors.push(`public/language/web/${field}.js[${index}]:内容与 translate.json 的「${expected.key}」不一致`);
}
}
}
const expectedKeySet = new Set(expectedRows.map(item => item.key));
for (const [index, key] of publicKeys.entries()) {
if (!translationRows.some(item => item.key === key)) {
errors.push(`public/language/web/key.js[${index}]key「${key}」不在 translate.json`);
} else if (!expectedKeySet.has(key)) {
errors.push(`public/language/web/key.js[${index}]key「${key}」不属于前端 original-web.txt`);
}
}
return {
errors,
rawPublicKeys: publicKeys
.map((key, index) => ({ key, index }))
.filter(item => RAW_PARAMETER_TEST_RE.test(item.key)),
};
}
const originalLineItems = readOriginalLines(ORIGINAL_WEB_FILE);
const originalKeys = originalLineItems.map(item => item.text);
const originalLocations = new Map();
for (const item of originalLineItems) {
if (!originalLocations.has(item.text)) originalLocations.set(item.text, []);
originalLocations.get(item.text).push(item.line);
}
const originalDuplicates = [...originalLocations]
.filter(([, lines]) => lines.length > 1)
.map(([key, lines]) => ({ key, lines }));
const originalKeySet = new Set(originalKeys);
let translationRows = [];
const translationFormatErrors = [];
try {
const parsed = JSON.parse(readFileSync(TRANSLATE_FILE, "utf8"));
if (!Array.isArray(parsed)) {
translationFormatErrors.push("language/translate.json根数据必须是数组");
} else {
translationRows = parsed;
}
} catch (error) {
translationFormatErrors.push(`language/translate.jsonJSON 解析失败:${error.message}`);
}
const rawTranslateKeys = [];
const translationParameterErrors = [];
const normalizedTranslations = new Map();
for (const [index, row] of translationRows.entries()) {
const location = `language/translate.json[index ${index}]`;
if (!row || typeof row !== "object" || Array.isArray(row)) {
translationFormatErrors.push(`${location}:条目必须是对象`);
continue;
}
for (const field of LANGUAGE_FIELDS) {
if (typeof row[field] !== "string") translationFormatErrors.push(`${location}.${field}:必须是字符串`);
}
if (typeof row.key !== "string") continue;
if (RAW_PARAMETER_TEST_RE.test(row.key)) rawTranslateKeys.push({ key: row.key, location });
const keyParameters = placeholders(row.key);
if (row.key.replace(PARAMETER_RE, "").includes("(%")) {
translationParameterErrors.push(`${location}.key存在非法参数占位符「${row.key}`);
}
PARAMETER_RE.lastIndex = 0;
const numbers = keyParameters.map(parameter => Number(parameter.match(/\d+/)[0]));
if (numbers.some((number, parameterIndex) => number !== parameterIndex + 1)) {
translationParameterErrors.push(`${location}.key参数编号必须按出现顺序从 1 连续递增「${row.key}`);
}
for (const field of LANGUAGE_FIELDS.slice(1)) {
if (typeof row[field] !== "string" || row[field] === "") continue;
const fieldParameters = placeholders(row[field]);
if (!samePlaceholderSet(keyParameters, fieldParameters)) {
translationParameterErrors.push(
`${location}.${field}:参数占位符与 key 不一致key=[${keyParameters.join(", ")}]value=[${fieldParameters.join(", ")}]`,
);
}
}
const normalized = normalizeParameterKey(row.key);
if (!normalizedTranslations.has(normalized)) normalizedTranslations.set(normalized, []);
normalizedTranslations.get(normalized).push({ index, row });
}
const normalizedDuplicates = [...normalizedTranslations]
.filter(([, rows]) => rows.length > 1)
.map(([key, rows]) => ({ key, rows }));
const translationsByNormalizedKey = new Map(
[...normalizedTranslations].map(([key, rows]) => [key, rows[0].row]),
);
const frontend = scanFrontendSources();
const missingLiterals = [...frontend.literalUsages]
.filter(([key]) => !originalKeySet.has(key))
.map(([key, locations]) => ({ key, locations }));
const namedOriginalKeys = originalLineItems.flatMap(item =>
(item.text.match(NAMED_PARAMETER_RE) || []).map(named => ({
text: item.text,
named,
location: `language/original-web.txt:${item.line}`,
})),
);
const namedParameters = uniqueNamedParameters([...frontend.namedParameters, ...namedOriginalKeys]);
const missingFrontendTranslations = originalKeys
.filter(key => !translationsByNormalizedKey.has(key))
.map(key => ({ key, line: originalLocations.get(key)[0] }));
const skipGeneratedIndexComparison = originalDuplicates.length > 0
|| normalizedDuplicates.length > 0
|| missingFrontendTranslations.length > 0
|| translationFormatErrors.length > 0
|| translationParameterErrors.length > 0;
const generated = validateGeneratedFiles(originalKeys, translationsByNormalizedKey, translationRows, {
skipIndexComparison: skipGeneratedIndexComparison,
});
const unusedReport = findUnusedKeys(originalKeys, frontend.sourceStrings, frontend.parameterizedShapes);
const doubleStarTodos = findDoubleStarTodos(originalKeys, frontend.templateShapes);
const specificCandidates = findSpecificCandidates(originalKeys, frontend.sourceStrings);
const blockerCounts = {
"代码字面量未登记": missingLiterals.length,
"Vue 模板中文硬编码": frontend.templateTexts.length,
"命名变量占位符": namedParameters.length,
"original-web.txt 重复键": originalDuplicates.length,
"translate.json raw (*)/(**) key": rawTranslateKeys.length,
"translate.json 结构错误": translationFormatErrors.length,
"translate.json 参数错误": translationParameterErrors.length,
"translate.json 规范化重复": normalizedDuplicates.length,
"前端键缺少 translate.json 翻译": missingFrontendTranslations.length,
"public/language/web 结构或内容错误": generated.errors.length,
"public key raw (*)/(**)": generated.rawPublicKeys.length,
};
console.log("== 阻断错误 ==");
for (const [label, count] of Object.entries(blockerCounts)) console.log(` ${label}: ${count}`);
if (missingLiterals.length) {
console.log("\n-- 代码字面量未登记 --");
for (const item of missingLiterals) {
console.log(`${item.key}`);
for (const location of item.locations) console.log(` ${location}`);
}
}
if (frontend.templateTexts.length) {
console.log("\n-- Vue 模板中文硬编码 --");
for (const item of frontend.templateTexts) console.log(` ${item.rel}:${item.line}${item.text}`);
}
if (namedParameters.length) {
console.log("\n-- 命名变量占位符(请改用 (*)--");
for (const item of namedParameters) console.log(` ${item.location} ${item.named}${item.text}`);
}
if (originalDuplicates.length) {
console.log("\n-- original-web.txt 重复键 --");
for (const item of originalDuplicates) console.log(`${item.lines.join(", ")}${item.key}`);
}
if (rawTranslateKeys.length) {
console.log("\n-- translate.json raw key请改用 (%Tn)/(%Mn)--");
for (const item of rawTranslateKeys) console.log(` ${item.location}${item.key}`);
}
for (const section of [translationFormatErrors, translationParameterErrors]) {
if (section.length) {
console.log("\n-- translate.json 数据错误 --");
for (const error of section) console.log(` ${error}`);
}
}
if (normalizedDuplicates.length) {
console.log("\n-- translate.json 规范化重复 --");
for (const item of normalizedDuplicates) {
console.log(`${item.key}」:${item.rows.map(row => `index ${row.index}${row.row.key}`).join("")}`);
}
}
if (missingFrontendTranslations.length) {
console.log("\n-- 前端键缺少 translate.json 翻译 --");
for (const item of missingFrontendTranslations) console.log(` language/original-web.txt:${item.line}${item.key}`);
}
if (generated.errors.length) {
console.log("\n-- public/language/web 结构或内容错误 --");
for (const error of generated.errors) console.log(` ${error}`);
}
if (generated.rawPublicKeys.length) {
console.log("\n-- public key raw (*)/(**) --");
for (const item of generated.rawPublicKeys) console.log(` public/language/web/key.js[index ${item.index}] 「${item.key}`);
}
console.log("\n== 迁移待办(仅报告,不阻断)==");
const dynamicCounts = new Map([["模板字符串", 0], ["字符串拼接", 0]]);
for (const item of frontend.dynamicCalls) dynamicCounts.set(item.category, dynamicCounts.get(item.category) + 1);
console.log(` 动态翻译键/模板字符串: ${dynamicCounts.get("模板字符串")}`);
console.log(` 动态翻译键/字符串拼接: ${dynamicCounts.get("字符串拼接")}`);
console.log(` 仍使用 (**) 的前端键: ${doubleStarTodos.length}`);
console.log(` 可明显改用现有 (*) 键: ${specificCandidates.length}`);
if (frontend.dynamicCalls.length) {
console.log("\n-- 动态翻译键 --");
for (const category of ["模板字符串", "字符串拼接"]) {
const items = frontend.dynamicCalls.filter(item => item.category === category);
if (!items.length) continue;
console.log(` [${category}]`);
for (const item of items) console.log(` ${item.location} ${item.expression}`);
}
}
if (doubleStarTodos.length) {
console.log("\n-- (**) 前端键 --");
for (const item of doubleStarTodos) {
console.log(`${item.key}`);
if (item.sources.length) {
for (const location of item.sources) console.log(` ${location}`);
} else {
console.log(" 来源:未在直接模板字符串中定位(可能经变量间接传入)");
}
}
}
if (specificCandidates.length) {
console.log("\n-- 可明显参数化的具体文案 --");
for (const item of specificCandidates) {
console.log(`${item.concrete}」 => 「${item.generic}`);
for (const location of item.sources) console.log(` ${location}`);
}
}
console.log("\n== 疑似未使用(仅报告,不阻断)==");
console.log(` 实际源码扫描文件数: ${unusedReport.sourceFileCount}`);
console.log(` 参数化形状确认使用数: ${unusedReport.parameterizedUsages.size}`);
console.log(` 疑似未使用数: ${unusedReport.unused.length}`);
for (const key of unusedReport.unused.slice(0, 20)) console.log(`${key}`);
if (unusedReport.unused.length > 20) console.log(` ...(共 ${unusedReport.unused.length} 条)`);
if (unusedReport.parameterizedUsages.size) {
console.log("\n-- 参数化键使用证据(固定片段/参数槽位完整匹配) --");
for (const [key, locations] of unusedReport.parameterizedUsages) {
console.log(`${key}`);
for (const location of locations.slice(0, 8)) console.log(` ${location}`);
if (locations.length > 8) console.log(` ...(共 ${locations.length} 个来源)`);
}
}
if (frontend.parserWarnings.length) {
console.log("\n== 检测器提示(不阻断)==");
for (const warning of frontend.parserWarnings) console.log(` ${warning}`);
}
console.log("\n== 汇总 ==");
console.log(` 前端扫描文件数: ${frontend.files.length}`);
console.log(` 前端字面量键数(去重): ${frontend.literalUsages.size}`);
console.log(` 阻断错误总数: ${Object.values(blockerCounts).reduce((sum, count) => sum + count, 0)}`);
console.log(` 迁移待办总数: ${frontend.dynamicCalls.length + doubleStarTodos.length + specificCandidates.length}`);
console.log(` 疑似未使用数: ${unusedReport.unused.length}`);
if (Object.values(blockerCounts).some(count => count > 0)) {
console.log("\n校验失败存在阻断错误。");
process.exit(1);
}
console.log("\n校验通过阻断错误为 0迁移待办与疑似未使用仅作报告。");