285 lines
9.9 KiB
JavaScript
285 lines
9.9 KiB
JavaScript
|
|
/*
|
||
|
|
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;
|
||
|
|
});
|
||
|
|
|
||
|
|
|