2025-11-13 08:38:25 +00:00
|
|
|
|
/*
|
|
|
|
|
|
使用 i18next-scanner 格式扫描项目中未翻译的文本
|
|
|
|
|
|
基于现有的 extract-copy.ts 工具,生成 i18next 格式的扫描报告
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2025-11-28 06:31:36 +00:00
|
|
|
|
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'
|
2025-11-13 08:38:25 +00:00
|
|
|
|
|
|
|
|
|
|
type CopyKind =
|
2025-11-28 06:31:36 +00:00
|
|
|
|
| 'text'
|
|
|
|
|
|
| 'placeholder'
|
|
|
|
|
|
| 'title'
|
|
|
|
|
|
| 'alt'
|
|
|
|
|
|
| 'aria'
|
|
|
|
|
|
| 'label'
|
|
|
|
|
|
| 'toast'
|
|
|
|
|
|
| 'dialog'
|
|
|
|
|
|
| 'error'
|
|
|
|
|
|
| 'validation'
|
2025-11-13 08:38:25 +00:00
|
|
|
|
|
|
|
|
|
|
interface CopyItem {
|
2025-11-28 06:31:36 +00:00
|
|
|
|
route: string
|
|
|
|
|
|
file: string
|
|
|
|
|
|
componentOrFn: string
|
|
|
|
|
|
kind: CopyKind
|
|
|
|
|
|
keyOrLocator: string
|
|
|
|
|
|
text: string
|
|
|
|
|
|
line: number
|
|
|
|
|
|
notes?: string
|
2025-11-13 08:38:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface I18nKey {
|
2025-11-28 06:31:36 +00:00
|
|
|
|
key: string
|
|
|
|
|
|
value: string
|
|
|
|
|
|
context?: string
|
|
|
|
|
|
file: string
|
|
|
|
|
|
line: number
|
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: string | undefined | null): value is string {
|
2025-11-28 06:31:36 +00:00
|
|
|
|
if (!value) return false
|
|
|
|
|
|
const trimmed = value.replace(/\s+/g, ' ').trim()
|
|
|
|
|
|
if (!trimmed) return false
|
2025-11-13 08:38:25 +00:00
|
|
|
|
// Filter obvious code-like tokens
|
2025-11-28 06:31:36 +00:00
|
|
|
|
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: string): string {
|
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
|
|
|
|
// Walk up to find nearest folder that contains a page.tsx (or page.ts)
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
// Fallback: route is the first app subfolder segment
|
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: Node): string {
|
2025-11-28 06:31:36 +00:00
|
|
|
|
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())
|
2025-11-13 08:38:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getNodeLine(node: Node): number {
|
2025-11-28 06:31:36 +00:00
|
|
|
|
const pos = node.getStartLineNumber()
|
|
|
|
|
|
return pos ?? 1
|
2025-11-13 08:38:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getAttrName(attr: JsxAttribute): string {
|
2025-11-28 06:31:36 +00:00
|
|
|
|
return attr.getNameNode().getText()
|
2025-11-13 08:38:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getStringFromInitializer(attr: JsxAttribute): string | undefined {
|
2025-11-28 06:31:36 +00:00
|
|
|
|
const init = attr.getInitializer()
|
|
|
|
|
|
if (!init) return undefined
|
|
|
|
|
|
if (Node.isStringLiteral(init)) return init.getLiteralText()
|
|
|
|
|
|
if (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)) return expr.getLiteralText()
|
|
|
|
|
|
if (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
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function pushItem(items: CopyItem[], item: CopyItem) {
|
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 generateI18nKey(item: CopyItem): string {
|
|
|
|
|
|
// 生成 i18next 格式的键名
|
2025-11-28 06:31:36 +00:00
|
|
|
|
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()
|
2025-11-13 08:38:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function collectFiles(): Promise<string[]> {
|
2025-11-28 06:31:36 +00:00
|
|
|
|
const patterns = ['src/**/*.{ts,tsx}']
|
2025-11-13 08:38:25 +00:00
|
|
|
|
const ignore = [
|
2025-11-28 06:31:36 +00:00
|
|
|
|
'**/node_modules/**',
|
|
|
|
|
|
'**/.next/**',
|
|
|
|
|
|
'**/__tests__/**',
|
|
|
|
|
|
'**/mocks/**',
|
|
|
|
|
|
'**/mock/**',
|
|
|
|
|
|
'**/*.d.ts',
|
|
|
|
|
|
]
|
|
|
|
|
|
return await globby(patterns, { gitignore: true, ignore })
|
2025-11-13 08:38:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function extractFromSourceFile(abs: string, items: CopyItem[], project: Project) {
|
2025-11-28 06:31:36 +00:00
|
|
|
|
const sf = project.addSourceFileAtPath(abs)
|
2025-11-13 08:38:25 +00:00
|
|
|
|
// JSX text nodes
|
|
|
|
|
|
sf.forEachDescendant((node) => {
|
|
|
|
|
|
if (Node.isJsxElement(node)) {
|
2025-11-28 06:31:36 +00:00
|
|
|
|
const opening = node.getOpeningElement()
|
|
|
|
|
|
const componentOrFn = getComponentOrFnName(node)
|
|
|
|
|
|
const route = getRouteForFile(abs)
|
2025-11-13 08:38:25 +00:00
|
|
|
|
// 递归提取所有 JsxText 与 {'...'} 字面量
|
2025-11-28 06:31:36 +00:00
|
|
|
|
const tagName = opening.getTagNameNode().getText()
|
|
|
|
|
|
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,
|
2025-11-28 06:31:36 +00:00
|
|
|
|
kind: 'text',
|
2025-11-13 08:38:25 +00:00
|
|
|
|
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
|
|
|
|
})
|
|
|
|
|
|
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,
|
2025-11-28 06:31:36 +00:00
|
|
|
|
kind: 'text',
|
2025-11-13 08:38:25 +00:00
|
|
|
|
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)
|
2025-11-13 08:38:25 +00:00
|
|
|
|
const tag = Node.isJsxOpeningElement(node)
|
|
|
|
|
|
? node.getTagNameNode().getText()
|
2025-11-28 06:31:36 +00:00
|
|
|
|
: 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: 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'
|
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: toast.*, alert, confirm, message.*
|
|
|
|
|
|
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: CopyKind | null = 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}`
|
2025-11-13 08:38:25 +00:00
|
|
|
|
}
|
2025-11-28 06:31:36 +00:00
|
|
|
|
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 as StringLiteral | NoSubstitutionTemplateLiteral).getLiteralText()
|
2025-11-13 08:38:25 +00:00
|
|
|
|
pushItem(items, {
|
|
|
|
|
|
route,
|
|
|
|
|
|
file: path.relative(WORKDIR, abs),
|
|
|
|
|
|
componentOrFn,
|
|
|
|
|
|
kind,
|
|
|
|
|
|
keyOrLocator,
|
|
|
|
|
|
text,
|
|
|
|
|
|
line: getNodeLine(node),
|
2025-11-28 06:31:36 +00:00
|
|
|
|
})
|
2025-11-13 08:38:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// form.setError("field", { message: "..." })
|
2025-11-28 06:31:36 +00:00
|
|
|
|
if (Node.isPropertyAccessExpression(expr) && expr.getName() === 'setError') {
|
|
|
|
|
|
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()
|
2025-11-13 08:38:25 +00:00
|
|
|
|
pushItem(items, {
|
|
|
|
|
|
route,
|
|
|
|
|
|
file: path.relative(WORKDIR, abs),
|
|
|
|
|
|
componentOrFn,
|
2025-11-28 06:31:36 +00:00
|
|
|
|
kind: 'error',
|
|
|
|
|
|
keyOrLocator: 'form.setError',
|
2025-11-13 08:38:25 +00:00
|
|
|
|
text,
|
|
|
|
|
|
line: getNodeLine(msgProp),
|
2025-11-28 06:31:36 +00:00
|
|
|
|
})
|
2025-11-13 08:38:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Generic validation: any object literal { message: "..." } inside chained calls
|
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()
|
2025-11-13 08:38:25 +00:00
|
|
|
|
pushItem(items, {
|
|
|
|
|
|
route,
|
|
|
|
|
|
file: path.relative(WORKDIR, abs),
|
|
|
|
|
|
componentOrFn,
|
2025-11-28 06:31:36 +00:00
|
|
|
|
kind: 'validation',
|
|
|
|
|
|
keyOrLocator: 'message',
|
2025-11-13 08:38:25 +00:00
|
|
|
|
text,
|
|
|
|
|
|
line: getNodeLine(prop),
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function aggregate(items: CopyItem[]): CopyItem[] {
|
|
|
|
|
|
// Deduplicate by route+kind+text+keyOrLocator to keep first occurrence, count separately
|
2025-11-28 06:31:36 +00:00
|
|
|
|
const map = new Map<string, { item: CopyItem; count: number }>()
|
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}`
|
2025-11-13 08:38:25 +00:00
|
|
|
|
if (!map.has(key)) {
|
2025-11-28 06:31:36 +00:00
|
|
|
|
map.set(key, { item: it, count: 1 })
|
2025-11-13 08:38:25 +00:00
|
|
|
|
} else {
|
2025-11-28 06:31:36 +00:00
|
|
|
|
map.get(key)!.count += 1
|
2025-11-13 08:38:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-11-28 06:31:36 +00:00
|
|
|
|
const result: CopyItem[] = []
|
2025-11-13 08:38:25 +00:00
|
|
|
|
for (const { item, count } of map.values()) {
|
2025-11-28 06:31:36 +00:00
|
|
|
|
;(item as any).count = count
|
|
|
|
|
|
result.push(item)
|
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 generateI18nTranslation(items: CopyItem[]): Record<string, string> {
|
2025-11-28 06:31:36 +00:00
|
|
|
|
const translation: Record<string, string> = {}
|
|
|
|
|
|
|
2025-11-13 08:38:25 +00:00
|
|
|
|
items.forEach((item) => {
|
2025-11-28 06:31:36 +00:00
|
|
|
|
const key = generateI18nKey(item)
|
|
|
|
|
|
translation[key] = item.text
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return translation
|
2025-11-13 08:38:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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),
|
2025-11-28 06:31:36 +00:00
|
|
|
|
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
|
2025-11-13 08:38:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function main() {
|
2025-11-28 06:31:36 +00:00
|
|
|
|
ensureExcelDir()
|
|
|
|
|
|
const files = await collectFiles()
|
2025-11-13 08:38:25 +00:00
|
|
|
|
const project = new Project({
|
2025-11-28 06:31:36 +00:00
|
|
|
|
tsConfigFilePath: path.join(WORKDIR, 'tsconfig.json'),
|
2025-11-13 08:38:25 +00:00
|
|
|
|
skipAddingFilesFromTsConfig: true,
|
2025-11-28 06:31:36 +00:00
|
|
|
|
})
|
|
|
|
|
|
const items: CopyItem[] = []
|
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) {
|
|
|
|
|
|
// swallow parse errors but continue
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-11-28 06:31:36 +00:00
|
|
|
|
const aggregated = aggregate(items)
|
|
|
|
|
|
|
2025-11-13 08:38:25 +00:00
|
|
|
|
// 生成 i18next 格式的翻译文件
|
2025-11-28 06:31:36 +00:00
|
|
|
|
const translation = generateI18nTranslation(aggregated)
|
|
|
|
|
|
const localesDir = path.join(WORKDIR, 'public', 'locales', 'en')
|
2025-11-13 08:38:25 +00:00
|
|
|
|
if (!fs.existsSync(localesDir)) {
|
2025-11-28 06:31:36 +00:00
|
|
|
|
fs.mkdirSync(localesDir, { recursive: true })
|
2025-11-13 08:38:25 +00:00
|
|
|
|
}
|
2025-11-28 06:31:36 +00:00
|
|
|
|
const translationFile = path.join(localesDir, 'translation.json')
|
|
|
|
|
|
fs.writeFileSync(translationFile, JSON.stringify(translation, null, 2))
|
|
|
|
|
|
|
2025-11-13 08:38:25 +00:00
|
|
|
|
// 生成 Excel 报告
|
2025-11-28 06:31:36 +00:00
|
|
|
|
const wb = toWorkbook(aggregated)
|
|
|
|
|
|
const out = path.join(WORKDIR, 'docs', 'i18n-scan-report.xlsx')
|
|
|
|
|
|
XLSX.writeFile(wb, out)
|
|
|
|
|
|
|
2025-11-13 08:38:25 +00:00
|
|
|
|
// 生成扫描报告
|
|
|
|
|
|
const report = {
|
|
|
|
|
|
totalItems: aggregated.length,
|
2025-11-28 06:31:36 +00:00
|
|
|
|
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))
|
|
|
|
|
|
|
2025-11-13 08:38:25 +00:00
|
|
|
|
// eslint-disable-next-line no-console
|
2025-11-28 06:31:36 +00:00
|
|
|
|
console.log(`✅ i18next 扫描完成!`)
|
|
|
|
|
|
console.log(`📊 总扫描条目: ${aggregated.length}`)
|
|
|
|
|
|
console.log(`🔑 生成翻译键: ${Object.keys(translation).length}`)
|
|
|
|
|
|
console.log(`📁 翻译文件: ${translationFile}`)
|
|
|
|
|
|
console.log(`📋 Excel 报告: ${out}`)
|
|
|
|
|
|
console.log(`📄 JSON 报告: ${reportFile}`)
|
2025-11-13 08:38:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
main().catch((err) => {
|
|
|
|
|
|
// eslint-disable-next-line no-console
|
2025-11-28 06:31:36 +00:00
|
|
|
|
console.error(err)
|
|
|
|
|
|
process.exitCode = 1
|
|
|
|
|
|
})
|