/* CommonJS runtime for extracting user-facing copy into Excel. */ const path = require('node:path'); const fs = require('node:fs'); const { globby } = require('globby'); const { Project, SyntaxKind, Node } = require('ts-morph'); const XLSX = require('xlsx'); 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) { if (!value) return false; const trimmed = String(value).replace(/\s+/g, ' ').trim(); if (!trimmed) return false; if (/^[A-Za-z0-9_.$-]+$/.test(trimmed) && trimmed.length > 24) return false; return true; } function getRouteForFile(absFilePath) { if (!absFilePath.startsWith(APP_DIR)) return 'shared'; let dir = path.dirname(absFilePath); 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; } const relToApp = path.relative(APP_DIR, absFilePath); const parts = relToApp.split(path.sep); return parts.length > 0 ? parts[0] : 'shared'; } function getComponentOrFnName(node) { const fn = node.getFirstAncestorByKind(SyntaxKind.FunctionDeclaration); if (fn && fn.getName && fn.getName()) return fn.getName(); const varDecl = node.getFirstAncestorByKind(SyntaxKind.VariableDeclaration); if (varDecl && varDecl.getName) return varDecl.getName(); const cls = node.getFirstAncestorByKind(SyntaxKind.ClassDeclaration); if (cls && cls.getName && cls.getName()) return cls.getName(); const sf = node.getSourceFile(); return path.basename(sf.getFilePath()); } function getNodeLine(node) { const pos = node.getStartLineNumber && node.getStartLineNumber(); return pos || 1; } function getAttrName(attr) { return attr.getNameNode().getText(); } function getStringFromInitializer(attr) { const init = attr.getInitializer(); if (!init) return undefined; if (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init)) return init.getLiteralText(); if (Node.isJsxExpression(init)) { const expr = init.getExpression(); if (!expr) return undefined; if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr)) return expr.getLiteralText(); } return undefined; } async function collectFiles() { const patterns = ['src/**/*.{ts,tsx}']; const ignore = ['**/node_modules/**','**/.next/**','**/__tests__/**','**/mocks/**','**/mock/**','**/*.d.ts']; return await globby(patterns, { gitignore: true, ignore }); } function pushItem(items, item) { if (!isMeaningfulText(item.text)) return; items.push(item); } function extractFromSourceFile(abs, items, project) { const sf = project.addSourceFileAtPath(abs); sf.forEachDescendant((node) => { // JSX text nodes if (Node.isJsxElement(node)) { const opening = node.getOpeningElement(); const componentOrFn = getComponentOrFnName(node); const route = getRouteForFile(abs); 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.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 = 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 if (Node.isCallExpression(node)) { const route = getRouteForFile(abs); const componentOrFn = getComponentOrFnName(node); const expr = node.getExpression(); let kind = 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.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 object { message: "..." } 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) { 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 = []; for (const { item, count } of map.values()) { result.push({ ...item, count }); } return result; } function toWorkbook(items) { 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.count || 1, 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, 'copy'); return wb; } async function main() { ensureExcelDir(); const files = await collectFiles(); const project = new Project({ tsConfigFilePath: path.join(WORKDIR, 'tsconfig.json'), skipAddingFilesFromTsConfig: true }); const items = []; for (const rel of files) { const abs = path.join(WORKDIR, rel); try { extractFromSourceFile(abs, items, project); } catch (e) { // continue on parse errors } } const aggregated = aggregate(items); const wb = toWorkbook(aggregated); const out = path.join(WORKDIR, 'docs', 'copy-audit.xlsx'); XLSX.writeFile(wb, out); console.log(`Wrote ${aggregated.length} rows to ${out}`); } main().catch((err) => { console.error(err); process.exitCode = 1; });