kboss/f/web-kboss/scripts/i18n-extract.js
2026-07-03 14:30:24 +08:00

192 lines
5.6 KiB
JavaScript

/* eslint-disable no-console */
const fs = require('fs')
const path = require('path')
const projectRoot = process.cwd()
const args = process.argv.slice(2)
const getArg = (name, defaultValue = '') => {
const full = `--${name}`
const hit = args.find((item) => item.startsWith(`${full}=`))
if (hit) return hit.slice(full.length + 1)
const idx = args.indexOf(full)
if (idx !== -1 && args[idx + 1]) return args[idx + 1]
return defaultValue
}
const hasFlag = (flag) => args.includes(`--${flag}`)
const targetDirArg = getArg('dir', 'src/views/homePage')
const replaceMode = hasFlag('replace')
const targetDir = path.resolve(projectRoot, targetDirArg)
const zhAutoPath = path.resolve(projectRoot, 'src/i18n/lang/zh-CN.auto.json')
const enAutoPath = path.resolve(projectRoot, 'src/i18n/lang/en-US.auto.json')
const chinesePattern = /[\u4e00-\u9fa5]/
const ignorePattern = /^(\s*|[-:,.(){}\[\]/\\]+)$/
const readJsonSafe = (filePath) => {
if (!fs.existsSync(filePath)) return {}
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
} catch (error) {
console.warn(`[warn] JSON parse failed: ${filePath}`)
return {}
}
}
const writeJson = (filePath, value) => {
const content = JSON.stringify(value, null, 2) + '\n'
fs.writeFileSync(filePath, content, 'utf8')
}
const walkFiles = (dir, bucket) => {
const entries = fs.readdirSync(dir, { withFileTypes: true })
entries.forEach((entry) => {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
if (['node_modules', '.git', 'dist'].includes(entry.name)) return
walkFiles(fullPath, bucket)
return
}
if (!/\.(vue|js)$/.test(entry.name)) return
bucket.push(fullPath)
})
}
const normalizeText = (text) =>
text
.replace(/\s+/g, ' ')
.replace(/ /g, ' ')
.trim()
const keyFromText = (text) => {
let hash = 0
for (let i = 0; i < text.length; i += 1) {
hash = (hash * 131 + text.charCodeAt(i)) >>> 0
}
return `auto.k_${hash.toString(16)}`
}
const zhMessages = readJsonSafe(zhAutoPath)
const enMessages = readJsonSafe(enAutoPath)
const reverseMap = new Map()
Object.keys(zhMessages).forEach((key) => {
reverseMap.set(zhMessages[key], key)
})
const ensureKey = (rawText) => {
const text = normalizeText(rawText)
if (!text || !chinesePattern.test(text) || ignorePattern.test(text)) return null
if (reverseMap.has(text)) return reverseMap.get(text)
let key = keyFromText(text)
while (zhMessages[key] && zhMessages[key] !== text) {
key = `${key}_${Math.floor(Math.random() * 10000)}`
}
zhMessages[key] = text
if (!Object.prototype.hasOwnProperty.call(enMessages, key)) {
enMessages[key] = ''
}
reverseMap.set(text, key)
return key
}
const transformTemplate = (template) => {
let replacedCount = 0
const textNodeRegex = />([^<>{}\n]*[\u4e00-\u9fa5][^<>{}\n]*)</g
let updated = template.replace(textNodeRegex, (full, inner) => {
const key = ensureKey(inner)
if (!key || !replaceMode) return full
replacedCount += 1
return `>{{ $t('${key}') }}<`
})
const attrRegex = /\s([a-zA-Z_][\w-]*)=(["'])([^"']*[\u4e00-\u9fa5][^"']*)\2/g
updated = updated.replace(attrRegex, (full, attr, quote, value) => {
const key = ensureKey(value)
if (!key || !replaceMode) return full
replacedCount += 1
return ` :${attr}="$t('${key}')"`
})
return { updated, replacedCount }
}
const processVueFile = (filePath) => {
const raw = fs.readFileSync(filePath, 'utf8')
const templateMatch = raw.match(/<template>([\s\S]*?)<\/template>/)
if (!templateMatch) return { changed: false, replacedCount: 0 }
const templateBlock = templateMatch[0]
const templateInner = templateMatch[1]
const beforeCount = Object.keys(zhMessages).length
const { updated, replacedCount } = transformTemplate(templateInner)
const afterCount = Object.keys(zhMessages).length
const extractedCount = afterCount - beforeCount
if (!replaceMode || replacedCount === 0) {
return { changed: false, replacedCount: 0, extractedCount }
}
const replacedBlock = `<template>${updated}</template>`
const next = raw.replace(templateBlock, replacedBlock)
if (next !== raw) {
fs.writeFileSync(filePath, next, 'utf8')
return { changed: true, replacedCount, extractedCount }
}
return { changed: false, replacedCount: 0, extractedCount }
}
const processJsLiterals = (filePath) => {
const raw = fs.readFileSync(filePath, 'utf8')
const stringRegex = /(['"`])([^'"`\n]*[\u4e00-\u9fa5][^'"`\n]*)\1/g
let match = stringRegex.exec(raw)
while (match) {
ensureKey(match[2])
match = stringRegex.exec(raw)
}
}
if (!fs.existsSync(targetDir)) {
console.error(`[error] Directory does not exist: ${targetDirArg}`)
process.exit(1)
}
const files = []
walkFiles(targetDir, files)
let changedFiles = 0
let replacedEntries = 0
let extractedEntries = 0
files.forEach((filePath) => {
if (filePath.endsWith('.vue')) {
const result = processVueFile(filePath)
if (result.changed) changedFiles += 1
replacedEntries += result.replacedCount || 0
extractedEntries += result.extractedCount || 0
return
}
processJsLiterals(filePath)
})
writeJson(zhAutoPath, zhMessages)
writeJson(enAutoPath, enMessages)
console.log(`[i18n] target: ${targetDirArg}`)
console.log(`[i18n] files scanned: ${files.length}`)
console.log(`[i18n] new keys extracted: ${extractedEntries}`)
console.log(`[i18n] replace mode: ${replaceMode ? 'on' : 'off'}`)
console.log(`[i18n] files changed: ${changedFiles}`)
console.log(`[i18n] nodes replaced: ${replacedEntries}`)
console.log(`[i18n] zh map: src/i18n/lang/zh-CN.auto.json`)
console.log(`[i18n] en map: src/i18n/lang/en-US.auto.json`)