/* 使用 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 { 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(); 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 { const translation: Record = {}; 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), byKind: aggregated.reduce((acc, item) => { acc[item.kind] = (acc[item.kind] || 0) + 1; return acc; }, {} as Record), 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; });