crush-level-web/scripts/i18n-scan.ts

431 lines
14 KiB
TypeScript

/*
使用 i18next-scanner 格式扫描项目中未翻译的文本
基于现有的 extract-copy.ts 工具,生成 i18next 格式的扫描报告
*/
import path from "node:path";
import fs from "node:fs";
import { globby } from "globby";
import { Project, SyntaxKind, Node, JsxAttribute, StringLiteral, NoSubstitutionTemplateLiteral } from "ts-morph";
import * as XLSX from "xlsx";
type CopyKind =
| "text"
| "placeholder"
| "title"
| "alt"
| "aria"
| "label"
| "toast"
| "dialog"
| "error"
| "validation";
interface CopyItem {
route: string;
file: string;
componentOrFn: string;
kind: CopyKind;
keyOrLocator: string;
text: string;
line: number;
notes?: string;
}
interface I18nKey {
key: string;
value: string;
context?: string;
file: string;
line: number;
}
const WORKDIR = process.cwd();
const SRC_DIR = path.join(WORKDIR, "src");
const APP_DIR = path.join(SRC_DIR, "app");
function ensureExcelDir() {
const docsDir = path.join(WORKDIR, "docs");
if (!fs.existsSync(docsDir)) fs.mkdirSync(docsDir, { recursive: true });
}
function isMeaningfulText(value: string | undefined | null): value is string {
if (!value) return false;
const trimmed = value.replace(/\s+/g, " ").trim();
if (!trimmed) return false;
// Filter obvious code-like tokens
if (/^[A-Za-z0-9_.$-]+$/.test(trimmed) && trimmed.length > 24) return false;
return true;
}
function getRouteForFile(absFilePath: string): string {
if (!absFilePath.startsWith(APP_DIR)) return "shared";
let dir = path.dirname(absFilePath);
// Walk up to find nearest folder that contains a page.tsx (or page.ts)
while (dir.startsWith(APP_DIR)) {
const pageTsx = path.join(dir, "page.tsx");
const pageTs = path.join(dir, "page.ts");
if (fs.existsSync(pageTsx) || fs.existsSync(pageTs)) {
const rel = path.relative(APP_DIR, dir);
return rel || "/";
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
// Fallback: route is the first app subfolder segment
const relToApp = path.relative(APP_DIR, absFilePath);
const parts = relToApp.split(path.sep);
return parts.length > 0 ? parts[0] : "shared";
}
function getComponentOrFnName(node: Node): string {
const fn = node.getFirstAncestorByKind(SyntaxKind.FunctionDeclaration);
if (fn?.getName()) return fn.getName()!;
const varDecl = node.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
if (varDecl?.getName()) return varDecl.getName();
const cls = node.getFirstAncestorByKind(SyntaxKind.ClassDeclaration);
if (cls?.getName()) return cls.getName()!;
const sf = node.getSourceFile();
return path.basename(sf.getFilePath());
}
function getNodeLine(node: Node): number {
const pos = node.getStartLineNumber();
return pos ?? 1;
}
function getAttrName(attr: JsxAttribute): string {
return attr.getNameNode().getText();
}
function getStringFromInitializer(attr: JsxAttribute): string | undefined {
const init = attr.getInitializer();
if (!init) return undefined;
if (Node.isStringLiteral(init)) return init.getLiteralText();
if (Node.isNoSubstitutionTemplateLiteral(init)) return init.getLiteralText();
if (Node.isJsxExpression(init)) {
const expr = init.getExpression();
if (!expr) return undefined;
if (Node.isStringLiteral(expr)) return expr.getLiteralText();
if (Node.isNoSubstitutionTemplateLiteral(expr)) return expr.getLiteralText();
}
return undefined;
}
function pushItem(items: CopyItem[], item: CopyItem) {
if (!isMeaningfulText(item.text)) return;
items.push(item);
}
function generateI18nKey(item: CopyItem): string {
// 生成 i18next 格式的键名
const route = item.route === "shared" ? "common" : item.route.replace(/[^a-zA-Z0-9]/g, "_");
const component = item.componentOrFn.replace(/[^a-zA-Z0-9]/g, "_");
const kind = item.kind;
const locator = item.keyOrLocator.replace(/[^a-zA-Z0-9]/g, "_");
return `${route}.${component}.${kind}.${locator}`.toLowerCase();
}
async function collectFiles(): Promise<string[]> {
const patterns = [
"src/**/*.{ts,tsx}",
];
const ignore = [
"**/node_modules/**",
"**/.next/**",
"**/__tests__/**",
"**/mocks/**",
"**/mock/**",
"**/*.d.ts",
];
return await globby(patterns, { gitignore: true, ignore });
}
function extractFromSourceFile(abs: string, items: CopyItem[], project: Project) {
const sf = project.addSourceFileAtPath(abs);
// JSX text nodes
sf.forEachDescendant((node) => {
if (Node.isJsxElement(node)) {
const opening = node.getOpeningElement();
const componentOrFn = getComponentOrFnName(node);
const route = getRouteForFile(abs);
// 递归提取所有 JsxText 与 {'...'} 字面量
const tagName = opening.getTagNameNode().getText();
const textNodes = node.getDescendantsOfKind(SyntaxKind.JsxText);
textNodes.forEach((t) => {
const text = t.getText();
const cleaned = text.replace(/\s+/g, " ").trim();
if (isMeaningfulText(cleaned)) {
pushItem(items, {
route,
file: path.relative(WORKDIR, abs),
componentOrFn,
kind: "text",
keyOrLocator: tagName,
text: cleaned,
line: getNodeLine(t),
});
}
});
const exprs = node.getDescendantsOfKind(SyntaxKind.JsxExpression);
exprs.forEach((expr) => {
const inner = expr.getExpression();
if (inner && (Node.isStringLiteral(inner) || Node.isNoSubstitutionTemplateLiteral(inner))) {
const cleaned = inner.getLiteralText().replace(/\s+/g, " ").trim();
if (isMeaningfulText(cleaned)) {
pushItem(items, {
route,
file: path.relative(WORKDIR, abs),
componentOrFn,
kind: "text",
keyOrLocator: tagName,
text: cleaned,
line: getNodeLine(expr),
});
}
}
});
}
// JSX attributes
if (Node.isJsxOpeningElement(node) || Node.isJsxSelfClosingElement(node)) {
const route = getRouteForFile(abs);
const componentOrFn = getComponentOrFnName(node);
const tag = Node.isJsxOpeningElement(node)
? node.getTagNameNode().getText()
: node.getTagNameNode().getText();
const attrs = node.getAttributes().filter(Node.isJsxAttribute);
attrs.forEach((attr) => {
const name = getAttrName(attr);
const lower = name.toLowerCase();
const value = getStringFromInitializer(attr);
if (!value) return;
let kind: CopyKind | null = null;
if (lower === "placeholder") kind = "placeholder";
else if (lower === "title") kind = "title";
else if (lower === "alt") kind = "alt";
else if (lower.startsWith("aria-")) kind = "aria";
else if (lower === "label") kind = "label";
if (kind) {
pushItem(items, {
route,
file: path.relative(WORKDIR, abs),
componentOrFn,
kind,
keyOrLocator: `${tag}.${name}`,
text: value,
line: getNodeLine(attr),
});
}
});
}
// Interaction messages: toast.*, alert, confirm, message.*
if (Node.isCallExpression(node)) {
const route = getRouteForFile(abs);
const componentOrFn = getComponentOrFnName(node);
const expr = node.getExpression();
let kind: CopyKind | null = null;
let keyOrLocator = "";
if (Node.isPropertyAccessExpression(expr)) {
const left = expr.getExpression().getText();
const name = expr.getName();
if (left === "toast" || left === "message") {
kind = "toast";
keyOrLocator = `${left}.${name}`;
}
if (left.toLowerCase().includes("dialog")) {
kind = "dialog";
keyOrLocator = `${left}.${name}`;
}
} else if (Node.isIdentifier(expr)) {
const id = expr.getText();
if (id === "alert" || id === "confirm") {
kind = "dialog";
keyOrLocator = id;
}
}
if (kind) {
const arg0 = node.getArguments()[0];
if (arg0 && (Node.isStringLiteral(arg0) || Node.isNoSubstitutionTemplateLiteral(arg0))) {
const text = (arg0 as StringLiteral | NoSubstitutionTemplateLiteral).getLiteralText();
pushItem(items, {
route,
file: path.relative(WORKDIR, abs),
componentOrFn,
kind,
keyOrLocator,
text,
line: getNodeLine(node),
});
}
}
// form.setError("field", { message: "..." })
if (Node.isPropertyAccessExpression(expr) && expr.getName() === "setError") {
const args = node.getArguments();
if (args.length >= 2) {
const second = args[1];
if (Node.isObjectLiteralExpression(second)) {
const msgProp = second.getProperty("message");
if (msgProp && Node.isPropertyAssignment(msgProp)) {
const init = msgProp.getInitializer();
if (init && (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))) {
const text = init.getLiteralText();
pushItem(items, {
route,
file: path.relative(WORKDIR, abs),
componentOrFn,
kind: "error",
keyOrLocator: "form.setError",
text,
line: getNodeLine(msgProp),
});
}
}
}
}
}
// Generic validation: any object literal { message: "..." } inside chained calls
const args = node.getArguments();
for (const a of args) {
if (Node.isObjectLiteralExpression(a)) {
const prop = a.getProperty("message");
if (prop && Node.isPropertyAssignment(prop)) {
const init = prop.getInitializer();
if (init && (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))) {
const text = init.getLiteralText();
pushItem(items, {
route,
file: path.relative(WORKDIR, abs),
componentOrFn,
kind: "validation",
keyOrLocator: "message",
text,
line: getNodeLine(prop),
});
}
}
}
}
}
});
}
function aggregate(items: CopyItem[]): CopyItem[] {
// Deduplicate by route+kind+text+keyOrLocator to keep first occurrence, count separately
const map = new Map<string, { item: CopyItem; count: number }>();
for (const it of items) {
const key = `${it.route}__${it.kind}__${it.keyOrLocator}__${it.text}`;
if (!map.has(key)) {
map.set(key, { item: it, count: 1 });
} else {
map.get(key)!.count += 1;
}
}
const result: CopyItem[] = [];
for (const { item, count } of map.values()) {
(item as any).count = count;
result.push(item);
}
return result;
}
function generateI18nTranslation(items: CopyItem[]): Record<string, string> {
const translation: Record<string, string> = {};
items.forEach((item) => {
const key = generateI18nKey(item);
translation[key] = item.text;
});
return translation;
}
function toWorkbook(items: CopyItem[]): XLSX.WorkBook {
const rows = items.map((it) => ({
route: it.route,
file: it.file,
componentOrFn: it.componentOrFn,
kind: it.kind,
keyOrLocator: it.keyOrLocator,
text: it.text,
line: it.line,
count: (it as any).count ?? 1,
i18nKey: generateI18nKey(it),
notes: it.notes ?? "",
}));
const ws = XLSX.utils.json_to_sheet(rows, { skipHeader: false });
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "i18n-scan");
return wb;
}
async function main() {
ensureExcelDir();
const files = await collectFiles();
const project = new Project({
tsConfigFilePath: path.join(WORKDIR, "tsconfig.json"),
skipAddingFilesFromTsConfig: true,
});
const items: CopyItem[] = [];
for (const rel of files) {
const abs = path.join(WORKDIR, rel);
try {
extractFromSourceFile(abs, items, project);
} catch (e) {
// swallow parse errors but continue
}
}
const aggregated = aggregate(items);
// 生成 i18next 格式的翻译文件
const translation = generateI18nTranslation(aggregated);
const localesDir = path.join(WORKDIR, "public", "locales", "en");
if (!fs.existsSync(localesDir)) {
fs.mkdirSync(localesDir, { recursive: true });
}
const translationFile = path.join(localesDir, "translation.json");
fs.writeFileSync(translationFile, JSON.stringify(translation, null, 2));
// 生成 Excel 报告
const wb = toWorkbook(aggregated);
const out = path.join(WORKDIR, "docs", "i18n-scan-report.xlsx");
XLSX.writeFile(wb, out);
// 生成扫描报告
const report = {
totalItems: aggregated.length,
uniqueTexts: new Set(aggregated.map(item => item.text)).size,
byRoute: aggregated.reduce((acc, item) => {
acc[item.route] = (acc[item.route] || 0) + 1;
return acc;
}, {} as Record<string, number>),
byKind: aggregated.reduce((acc, item) => {
acc[item.kind] = (acc[item.kind] || 0) + 1;
return acc;
}, {} as Record<string, number>),
translationKeys: Object.keys(translation).length
};
const reportFile = path.join(WORKDIR, "docs", "i18n-scan-report.json");
fs.writeFileSync(reportFile, JSON.stringify(report, null, 2));
// eslint-disable-next-line no-console
console.log(`✅ i18next 扫描完成!`);
console.log(`📊 总扫描条目: ${aggregated.length}`);
console.log(`🔑 生成翻译键: ${Object.keys(translation).length}`);
console.log(`📁 翻译文件: ${translationFile}`);
console.log(`📋 Excel 报告: ${out}`);
console.log(`📄 JSON 报告: ${reportFile}`);
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
process.exitCode = 1;
});