Files
qiming/qiming-mobile/scripts/i18n-audit.mjs

284 lines
8.1 KiB
JavaScript

#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const rootDir = process.cwd();
const includeDirs = ["pages", "subpackages", "components", "utils", "servers"];
const includeFiles = ["App.uvue"];
const includeExt = new Set([".uvue", ".vue", ".uts"]);
const excludeFiles = new Set([
"components/pane-tabs/example.uvue",
"utils/mockApiService.uts",
"utils/pinyin.uts",
]);
const zhLocalePath = "constants/i18n-locales/zh-CN.uts";
const enLocalePath = "constants/i18n-locales/en-US.uts";
const hasChinese = /[\u4e00-\u9fff]/;
const visibleLine = new RegExp(
[
"showToast",
"showModal",
"showLoading",
"showError\\(",
"setNavigationBarTitle",
"throw\\s+new\\s+Error",
"reject\\s*\\(\\s*new\\s+Error",
"confirmText",
"cancelText",
"placeholder=",
"title=",
"alt=",
"label\\s*:",
"content\\s*:",
"description\\s*:",
"steps\\s*:",
"error\\s*:\\s*['\"`]",
"message\\s*:\\s*['\"`]",
"<text",
"<button",
"<input",
"<textarea",
].join("|"),
);
const hardcodedVisibleLiteral = new RegExp(
[
"title\\s*:\\s*['\"](?!Mobile\\.)[^'\"\\n]+['\"]",
"content\\s*:\\s*['\"](?!Mobile\\.)[^'\"\\n]+['\"]",
"text\\s*:\\s*['\"](?!Mobile\\.)[^'\"\\n]+['\"]",
"showError\\s*\\(\\s*['\"][^'\"\\n]+['\"]\\s*\\)",
"showError\\s*\\(\\s*`[^`\\n]+`\\s*\\)",
"new\\s+Error\\s*\\(\\s*['\"][^'\"\\n]+['\"]\\s*\\)",
"new\\s+Error\\s*\\(\\s*`[^`\\n]+`\\s*\\)",
"confirmText\\s*:\\s*['\"](?!Mobile\\.)[^'\"\\n]+['\"]",
"cancelText\\s*:\\s*['\"](?!Mobile\\.)[^'\"\\n]+['\"]",
"(^|\\s)placeholder\\s*=\\s*['\"][^'\"\\n]+['\"]",
"(^|\\s)alt\\s*=\\s*['\"][^'\"\\n]+['\"]",
"(^|\\s):placeholder\\s*=\\s*['\"]\\s*['\"`][^'\"`\\n]+['\"`]\\s*['\"]",
"(^|\\s):alt\\s*=\\s*['\"]\\s*['\"`][^'\"`\\n]+['\"`]\\s*['\"]",
"(^|\\s):title\\s*=\\s*['\"]\\s*['\"`][^'\"`\\n]+['\"`]\\s*['\"]",
"(^|\\s):content\\s*=\\s*['\"]\\s*['\"`][^'\"`\\n]+['\"`]\\s*['\"]",
"(^|\\s):confirmText\\s*=\\s*['\"]\\s*['\"`][^'\"`\\n]+['\"`]\\s*['\"]",
"(^|\\s):cancelText\\s*=\\s*['\"]\\s*['\"`][^'\"`\\n]+['\"`]\\s*['\"]",
].join("|"),
);
const stripComments = (content) => {
return content
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/\/\*[\s\S]*?\*\//g, "");
};
const walkFiles = (dir) => {
const absDir = path.join(rootDir, dir);
if (!fs.existsSync(absDir)) return [];
const results = [];
const stack = [absDir];
while (stack.length > 0) {
const current = stack.pop();
const entries = fs.readdirSync(current, { withFileTypes: true });
entries.forEach((entry) => {
const absPath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(absPath);
return;
}
const relPath = path.relative(rootDir, absPath).replace(/\\/g, "/");
if (excludeFiles.has(relPath)) return;
if (!includeExt.has(path.extname(relPath))) return;
results.push(relPath);
});
}
return results;
};
const collectLocaleKeyLines = (relPath) => {
const absPath = path.join(rootDir, relPath);
if (!fs.existsSync(absPath)) return new Map();
const content = fs.readFileSync(absPath, "utf8");
const keyLineMap = new Map();
const keyRegex = /"((?:Mobile|System)\.[A-Za-z0-9_.]+)"\s*:/g;
let match;
while ((match = keyRegex.exec(content)) !== null) {
const key = match[1];
if (!keyLineMap.has(key)) {
keyLineMap.set(key, content.slice(0, match.index).split(/\r?\n/).length);
}
}
return keyLineMap;
};
const findings = [];
const usedKeyLocations = new Map();
const files = Array.from(
new Set([
...includeDirs.flatMap((dir) => walkFiles(dir)),
...includeFiles.filter((file) => {
const relPath = file.replace(/\\/g, "/");
return (
fs.existsSync(path.join(rootDir, relPath)) &&
includeExt.has(path.extname(relPath))
);
}),
]),
);
files.forEach((relPath) => {
const absPath = path.join(rootDir, relPath);
const raw = fs.readFileSync(absPath, "utf8");
const content = stripComments(raw);
const lines = content.split(/\r?\n/);
const keyRegex = /["'`](Mobile\.[A-Za-z0-9_.]+)["'`]/g;
let keyMatch;
while ((keyMatch = keyRegex.exec(content)) !== null) {
const key = keyMatch[1];
if (!usedKeyLocations.has(key)) {
usedKeyLocations.set(key, {
file: relPath,
line: content.slice(0, keyMatch.index).split(/\r?\n/).length,
});
}
}
const legacyKeyRegex = /["'`](System\.[A-Za-z0-9_.]+)["'`]/g;
let legacyMatch;
while ((legacyMatch = legacyKeyRegex.exec(content)) !== null) {
findings.push({
file: relPath,
line: content.slice(0, legacyMatch.index).split(/\r?\n/).length,
text: `legacy i18n key prefix is forbidden: ${legacyMatch[1]}`,
});
}
lines.forEach((line, index) => {
const code = line.replace(/\/\/.*$/, "");
const trimmed = code.trim();
if (!trimmed || trimmed.startsWith("//")) return;
if (hardcodedVisibleLiteral.test(code)) {
findings.push({
file: relPath,
line: index + 1,
text: code.trim(),
});
return;
}
if (!hasChinese.test(code)) return;
if (!visibleLine.test(code)) return;
findings.push({
file: relPath,
line: index + 1,
text: code.trim(),
});
});
const imageTagRegex = /<image\b[\s\S]*?>/g;
let tagMatch;
while ((tagMatch = imageTagRegex.exec(content)) !== null) {
const imageTag = tagMatch[0];
if (!/(^|\s)(:alt|alt)\s*=/.test(imageTag)) {
findings.push({
file: relPath,
line: content.slice(0, tagMatch.index).split(/\r?\n/).length,
text: imageTag.replace(/\s+/g, " ").trim(),
});
}
}
const contentWithoutSelfClosedTextNode = content.replace(
/<(text|button)\b(?:[^>"']|"[^"]*"|'[^']*')*\/>/g,
(match) => match.replace(/[^\n]/g, " "),
);
const textNodeRegex =
/<(text|button)\b(?:[^>"']|"[^"]*"|'[^']*')*>([\s\S]*?)<\/\1>/g;
let textNodeMatch;
while ((textNodeMatch = textNodeRegex.exec(contentWithoutSelfClosedTextNode)) !== null) {
const rawInnerText = textNodeMatch[2] || "";
const cleanedInnerText = rawInnerText
.replace(/\{\{[\s\S]*?\}\}/g, "")
.replace(/<[^>]+>/g, "")
.trim();
if (!cleanedInnerText) continue;
findings.push({
file: relPath,
line: contentWithoutSelfClosedTextNode
.slice(0, textNodeMatch.index)
.split(/\r?\n/).length,
text: `${textNodeMatch[1]} node contains hardcoded text: ${cleanedInnerText}`,
});
}
});
const zhKeyLineMap = collectLocaleKeyLines(zhLocalePath);
const enKeyLineMap = collectLocaleKeyLines(enLocalePath);
const zhKeys = new Set(
[...zhKeyLineMap.keys()].filter((key) => key.startsWith("Mobile.")),
);
const enKeys = new Set(
[...enKeyLineMap.keys()].filter((key) => key.startsWith("Mobile.")),
);
const legacyLocaleKeyList = [
...[...zhKeyLineMap.keys()].filter((key) => key.startsWith("System.")),
...[...enKeyLineMap.keys()].filter((key) => key.startsWith("System.")),
];
legacyLocaleKeyList.forEach((key) => {
findings.push({
file: key.startsWith("System.")
? zhKeyLineMap.has(key)
? zhLocalePath
: enLocalePath
: zhLocalePath,
line: zhKeyLineMap.get(key) || enKeyLineMap.get(key) || 1,
text: `legacy locale key prefix is forbidden: ${key}`,
});
});
[...zhKeys]
.filter((key) => !enKeys.has(key))
.forEach((key) => {
findings.push({
file: enLocalePath,
line: 1,
text: `missing key in en-us locale: ${key}`,
});
});
[...enKeys]
.filter((key) => !zhKeys.has(key))
.forEach((key) => {
findings.push({
file: zhLocalePath,
line: 1,
text: `missing key in zh-cn locale: ${key}`,
});
});
usedKeyLocations.forEach((location, key) => {
if (!zhKeys.has(key) || !enKeys.has(key)) {
findings.push({
file: location.file,
line: location.line,
text: `missing locale definition for key: ${key}`,
});
}
});
if (findings.length > 0) {
console.error(
`[i18n audit] failed: found ${findings.length} i18n coverage issue(s).`,
);
findings.forEach((item) => {
console.error(`${item.file}:${item.line}: ${item.text}`);
});
process.exit(1);
}
console.log("[i18n audit] passed: 0 i18n coverage issues.");