crush-level-web/scripts/extract-copy.cjs

334 lines
10 KiB
JavaScript
Raw Permalink Normal View History

2025-11-13 08:38:25 +00:00
/*
CommonJS runtime for extracting user-facing copy into Excel.
*/
2025-11-28 06:31:36 +00:00
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')
2025-11-13 08:38:25 +00:00
2025-11-28 06:31:36 +00:00
const WORKDIR = process.cwd()
const SRC_DIR = path.join(WORKDIR, 'src')
const APP_DIR = path.join(SRC_DIR, 'app')
2025-11-13 08:38:25 +00:00
function ensureExcelDir() {
2025-11-28 06:31:36 +00:00
const docsDir = path.join(WORKDIR, 'docs')
if (!fs.existsSync(docsDir)) fs.mkdirSync(docsDir, { recursive: true })
2025-11-13 08:38:25 +00:00
}
function isMeaningfulText(value) {
2025-11-28 06:31:36 +00:00
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
2025-11-13 08:38:25 +00:00
}
function getRouteForFile(absFilePath) {
2025-11-28 06:31:36 +00:00
if (!absFilePath.startsWith(APP_DIR)) return 'shared'
let dir = path.dirname(absFilePath)
2025-11-13 08:38:25 +00:00
while (dir.startsWith(APP_DIR)) {
2025-11-28 06:31:36 +00:00
const pageTsx = path.join(dir, 'page.tsx')
const pageTs = path.join(dir, 'page.ts')
2025-11-13 08:38:25 +00:00
if (fs.existsSync(pageTsx) || fs.existsSync(pageTs)) {
2025-11-28 06:31:36 +00:00
const rel = path.relative(APP_DIR, dir)
return rel || '/'
2025-11-13 08:38:25 +00:00
}
2025-11-28 06:31:36 +00:00
const parent = path.dirname(dir)
if (parent === dir) break
dir = parent
2025-11-13 08:38:25 +00:00
}
2025-11-28 06:31:36 +00:00
const relToApp = path.relative(APP_DIR, absFilePath)
const parts = relToApp.split(path.sep)
return parts.length > 0 ? parts[0] : 'shared'
2025-11-13 08:38:25 +00:00
}
function getComponentOrFnName(node) {
2025-11-28 06:31:36 +00:00
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())
2025-11-13 08:38:25 +00:00
}
function getNodeLine(node) {
2025-11-28 06:31:36 +00:00
const pos = node.getStartLineNumber && node.getStartLineNumber()
return pos || 1
2025-11-13 08:38:25 +00:00
}
function getAttrName(attr) {
2025-11-28 06:31:36 +00:00
return attr.getNameNode().getText()
2025-11-13 08:38:25 +00:00
}
function getStringFromInitializer(attr) {
2025-11-28 06:31:36 +00:00
const init = attr.getInitializer()
if (!init) return undefined
if (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))
return init.getLiteralText()
2025-11-13 08:38:25 +00:00
if (Node.isJsxExpression(init)) {
2025-11-28 06:31:36 +00:00
const expr = init.getExpression()
if (!expr) return undefined
if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))
return expr.getLiteralText()
2025-11-13 08:38:25 +00:00
}
2025-11-28 06:31:36 +00:00
return undefined
2025-11-13 08:38:25 +00:00
}
async function collectFiles() {
2025-11-28 06:31:36 +00:00
const patterns = ['src/**/*.{ts,tsx}']
const ignore = [
'**/node_modules/**',
'**/.next/**',
'**/__tests__/**',
'**/mocks/**',
'**/mock/**',
'**/*.d.ts',
]
return await globby(patterns, { gitignore: true, ignore })
2025-11-13 08:38:25 +00:00
}
function pushItem(items, item) {
2025-11-28 06:31:36 +00:00
if (!isMeaningfulText(item.text)) return
items.push(item)
2025-11-13 08:38:25 +00:00
}
function extractFromSourceFile(abs, items, project) {
2025-11-28 06:31:36 +00:00
const sf = project.addSourceFileAtPath(abs)
2025-11-13 08:38:25 +00:00
sf.forEachDescendant((node) => {
// JSX text nodes
if (Node.isJsxElement(node)) {
2025-11-28 06:31:36 +00:00
const opening = node.getOpeningElement()
const componentOrFn = getComponentOrFnName(node)
const route = getRouteForFile(abs)
const tagName = opening.getTagNameNode().getText()
2025-11-13 08:38:25 +00:00
// 递归抓取所有子层级文本节点
2025-11-28 06:31:36 +00:00
const textNodes = node.getDescendantsOfKind(SyntaxKind.JsxText)
2025-11-13 08:38:25 +00:00
textNodes.forEach((t) => {
2025-11-28 06:31:36 +00:00
const text = t.getText()
const cleaned = text.replace(/\s+/g, ' ').trim()
2025-11-13 08:38:25 +00:00
if (isMeaningfulText(cleaned)) {
pushItem(items, {
route,
file: path.relative(WORKDIR, abs),
componentOrFn,
kind: 'text',
keyOrLocator: tagName,
text: cleaned,
line: getNodeLine(t),
2025-11-28 06:31:36 +00:00
})
2025-11-13 08:38:25 +00:00
}
2025-11-28 06:31:36 +00:00
})
2025-11-13 08:38:25 +00:00
// 抓取 {'...'} 这类表达式中的字符串字面量
2025-11-28 06:31:36 +00:00
const exprs = node.getDescendantsOfKind(SyntaxKind.JsxExpression)
2025-11-13 08:38:25 +00:00
exprs.forEach((expr) => {
2025-11-28 06:31:36 +00:00
const inner = expr.getExpression()
2025-11-13 08:38:25 +00:00
if (inner && (Node.isStringLiteral(inner) || Node.isNoSubstitutionTemplateLiteral(inner))) {
2025-11-28 06:31:36 +00:00
const cleaned = inner.getLiteralText().replace(/\s+/g, ' ').trim()
2025-11-13 08:38:25 +00:00
if (isMeaningfulText(cleaned)) {
pushItem(items, {
route,
file: path.relative(WORKDIR, abs),
componentOrFn,
kind: 'text',
keyOrLocator: tagName,
text: cleaned,
line: getNodeLine(expr),
2025-11-28 06:31:36 +00:00
})
2025-11-13 08:38:25 +00:00
}
}
2025-11-28 06:31:36 +00:00
})
2025-11-13 08:38:25 +00:00
}
// JSX attributes
if (Node.isJsxOpeningElement(node) || Node.isJsxSelfClosingElement(node)) {
2025-11-28 06:31:36 +00:00
const route = getRouteForFile(abs)
const componentOrFn = getComponentOrFnName(node)
const tag = node.getTagNameNode().getText()
const attrs = node.getAttributes().filter(Node.isJsxAttribute)
2025-11-13 08:38:25 +00:00
attrs.forEach((attr) => {
2025-11-28 06:31:36 +00:00
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'
2025-11-13 08:38:25 +00:00
if (kind) {
pushItem(items, {
route,
file: path.relative(WORKDIR, abs),
componentOrFn,
kind,
keyOrLocator: `${tag}.${name}`,
text: value,
line: getNodeLine(attr),
2025-11-28 06:31:36 +00:00
})
2025-11-13 08:38:25 +00:00
}
2025-11-28 06:31:36 +00:00
})
2025-11-13 08:38:25 +00:00
}
// Interaction messages
if (Node.isCallExpression(node)) {
2025-11-28 06:31:36 +00:00
const route = getRouteForFile(abs)
const componentOrFn = getComponentOrFnName(node)
const expr = node.getExpression()
let kind = null
let keyOrLocator = ''
2025-11-13 08:38:25 +00:00
if (Node.isPropertyAccessExpression(expr)) {
2025-11-28 06:31:36 +00:00
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}`
}
2025-11-13 08:38:25 +00:00
} else if (Node.isIdentifier(expr)) {
2025-11-28 06:31:36 +00:00
const id = expr.getText()
if (id === 'alert' || id === 'confirm') {
kind = 'dialog'
keyOrLocator = id
}
2025-11-13 08:38:25 +00:00
}
if (kind) {
2025-11-28 06:31:36 +00:00
const arg0 = node.getArguments()[0]
2025-11-13 08:38:25 +00:00
if (arg0 && (Node.isStringLiteral(arg0) || Node.isNoSubstitutionTemplateLiteral(arg0))) {
2025-11-28 06:31:36 +00:00
const text = arg0.getLiteralText()
pushItem(items, {
route,
file: path.relative(WORKDIR, abs),
componentOrFn,
kind,
keyOrLocator,
text,
line: getNodeLine(node),
})
2025-11-13 08:38:25 +00:00
}
}
// form.setError("field", { message: "..." })
if (Node.isPropertyAccessExpression(expr) && expr.getName() === 'setError') {
2025-11-28 06:31:36 +00:00
const args = node.getArguments()
2025-11-13 08:38:25 +00:00
if (args.length >= 2) {
2025-11-28 06:31:36 +00:00
const second = args[1]
2025-11-13 08:38:25 +00:00
if (Node.isObjectLiteralExpression(second)) {
2025-11-28 06:31:36 +00:00
const msgProp = second.getProperty('message')
2025-11-13 08:38:25 +00:00
if (msgProp && Node.isPropertyAssignment(msgProp)) {
2025-11-28 06:31:36 +00:00
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),
})
2025-11-13 08:38:25 +00:00
}
}
}
}
}
// Generic validation object { message: "..." }
2025-11-28 06:31:36 +00:00
const args = node.getArguments()
2025-11-13 08:38:25 +00:00
for (const a of args) {
if (Node.isObjectLiteralExpression(a)) {
2025-11-28 06:31:36 +00:00
const prop = a.getProperty('message')
2025-11-13 08:38:25 +00:00
if (prop && Node.isPropertyAssignment(prop)) {
2025-11-28 06:31:36 +00:00
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),
})
2025-11-13 08:38:25 +00:00
}
}
}
}
}
2025-11-28 06:31:36 +00:00
})
2025-11-13 08:38:25 +00:00
}
function aggregate(items) {
2025-11-28 06:31:36 +00:00
const map = new Map()
2025-11-13 08:38:25 +00:00
for (const it of items) {
2025-11-28 06:31:36 +00:00
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
2025-11-13 08:38:25 +00:00
}
2025-11-28 06:31:36 +00:00
const result = []
2025-11-13 08:38:25 +00:00
for (const { item, count } of map.values()) {
2025-11-28 06:31:36 +00:00
result.push({ ...item, count })
2025-11-13 08:38:25 +00:00
}
2025-11-28 06:31:36 +00:00
return result
2025-11-13 08:38:25 +00:00
}
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 || '',
2025-11-28 06:31:36 +00:00
}))
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
2025-11-13 08:38:25 +00:00
}
async function main() {
2025-11-28 06:31:36 +00:00
ensureExcelDir()
const files = await collectFiles()
const project = new Project({
tsConfigFilePath: path.join(WORKDIR, 'tsconfig.json'),
skipAddingFilesFromTsConfig: true,
})
const items = []
2025-11-13 08:38:25 +00:00
for (const rel of files) {
2025-11-28 06:31:36 +00:00
const abs = path.join(WORKDIR, rel)
2025-11-13 08:38:25 +00:00
try {
2025-11-28 06:31:36 +00:00
extractFromSourceFile(abs, items, project)
2025-11-13 08:38:25 +00:00
} catch (e) {
// continue on parse errors
}
}
2025-11-28 06:31:36 +00:00
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}`)
2025-11-13 08:38:25 +00:00
}
main().catch((err) => {
2025-11-28 06:31:36 +00:00
console.error(err)
process.exitCode = 1
})