620 lines
16 KiB
JavaScript
620 lines
16 KiB
JavaScript
import fs from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
import process from 'node:process'
|
|
|
|
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
|
|
import ts from 'typescript-api'
|
|
|
|
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
|
|
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
|
|
const LOCALIZATION_CALL_NAMES = new Set(['t', 'translate'])
|
|
const USER_VISIBLE_JSX_ATTRIBUTES = new Set([
|
|
'ariaLabel',
|
|
'aria-label',
|
|
'aria-description',
|
|
'alt',
|
|
'description',
|
|
'emptyText',
|
|
'helperText',
|
|
'keywords',
|
|
'label',
|
|
'message',
|
|
'placeholder',
|
|
'subtitle',
|
|
'text',
|
|
'title',
|
|
'toggleDescription',
|
|
'tooltip'
|
|
])
|
|
const USER_VISIBLE_OBJECT_KEYS = new Set([
|
|
'ariaLabel',
|
|
'badge',
|
|
'description',
|
|
'emptyText',
|
|
'error',
|
|
'helperText',
|
|
'keywords',
|
|
'label',
|
|
'message',
|
|
'placeholder',
|
|
'subtitle',
|
|
'title',
|
|
'toggleDescription',
|
|
'tooltip'
|
|
])
|
|
const USER_VISIBLE_FUNCTION_NAMES = new Set([
|
|
'alert',
|
|
'confirm',
|
|
'prompt',
|
|
'showError',
|
|
'showToast'
|
|
])
|
|
const USER_VISIBLE_OBJECT_METHODS = new Set([
|
|
'error',
|
|
'info',
|
|
'loading',
|
|
'message',
|
|
'promise',
|
|
'success',
|
|
'warning'
|
|
])
|
|
const USER_VISIBLE_OBJECT_NAMES = new Set(['toast'])
|
|
|
|
function normalizePath(root, filePath) {
|
|
return path.relative(root, filePath).split(path.sep).join('/')
|
|
}
|
|
|
|
function isSkippedFile(root, filePath) {
|
|
const relative = normalizePath(root, filePath)
|
|
if (
|
|
relative.endsWith('.d.ts') ||
|
|
relative.includes('.test.') ||
|
|
relative.includes('.spec.') ||
|
|
relative.includes('/__tests__/')
|
|
) {
|
|
return true
|
|
}
|
|
return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part))
|
|
}
|
|
|
|
async function collectSourceFiles(root, dir) {
|
|
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
const files = []
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dir, entry.name)
|
|
if (entry.isDirectory()) {
|
|
if (!SKIP_PATH_PARTS.has(entry.name)) {
|
|
files.push(...(await collectSourceFiles(root, fullPath)))
|
|
}
|
|
continue
|
|
}
|
|
if (
|
|
entry.isFile() &&
|
|
SOURCE_EXTENSIONS.has(path.extname(entry.name)) &&
|
|
!isSkippedFile(root, fullPath)
|
|
) {
|
|
files.push(fullPath)
|
|
}
|
|
}
|
|
|
|
return files
|
|
}
|
|
|
|
function hasHumanLanguageText(text) {
|
|
const trimmed = text.replace(/\s+/g, ' ').trim()
|
|
if (trimmed.length < 2) {
|
|
return false
|
|
}
|
|
if (/^[\d\s!-/:-@[-`{-~]+$/.test(trimmed)) {
|
|
return false
|
|
}
|
|
return /[A-Za-z\u00C0-\u024F\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(trimmed)
|
|
}
|
|
|
|
function compactText(text) {
|
|
return text.replace(/\s+/g, ' ').trim()
|
|
}
|
|
|
|
function lineAndColumn(sourceFile, node) {
|
|
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
|
|
return { line: position.line + 1, column: position.character + 1 }
|
|
}
|
|
|
|
function propertyNameText(name) {
|
|
if (ts.isIdentifier(name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name)) {
|
|
return name.text
|
|
}
|
|
if (ts.isComputedPropertyName(name) && ts.isStringLiteralLike(name.expression)) {
|
|
return name.expression.text
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function expressionNameText(node) {
|
|
if (ts.isIdentifier(node)) {
|
|
return node.text
|
|
}
|
|
if (ts.isPropertyAccessExpression(node)) {
|
|
return `${expressionNameText(node.expression) ?? ''}.${node.name.text}`.replace(/^\./, '')
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function stringParts(node) {
|
|
if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
return [{ text: node.text, dynamic: false }]
|
|
}
|
|
if (!ts.isTemplateExpression(node)) {
|
|
return []
|
|
}
|
|
return [
|
|
{ text: node.head.text, dynamic: true },
|
|
...node.templateSpans.map((span) => ({ text: span.literal.text, dynamic: true }))
|
|
]
|
|
}
|
|
|
|
function isInsideLocalizationCall(node) {
|
|
let current = node.parent
|
|
while (current) {
|
|
if (ts.isCallExpression(current)) {
|
|
const name = expressionNameText(current.expression)
|
|
if (name && LOCALIZATION_CALL_NAMES.has(name.split('.').at(-1) ?? name)) {
|
|
return true
|
|
}
|
|
}
|
|
current = current.parent
|
|
}
|
|
return false
|
|
}
|
|
|
|
function isJsxAttributeValue(node) {
|
|
const parent = node.parent
|
|
if (!parent) {
|
|
return undefined
|
|
}
|
|
if (ts.isJsxAttribute(parent)) {
|
|
return propertyNameText(parent.name)
|
|
}
|
|
if (parent && ts.isJsxExpression(parent) && parent.parent && ts.isJsxAttribute(parent.parent)) {
|
|
return propertyNameText(parent.parent.name)
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function ancestorJsxAttributeName(node) {
|
|
let current = node.parent
|
|
while (current) {
|
|
if (ts.isJsxAttribute(current)) {
|
|
return propertyNameText(current.name)
|
|
}
|
|
if (
|
|
ts.isJsxExpression(current) ||
|
|
ts.isConditionalExpression(current) ||
|
|
ts.isParenthesizedExpression(current) ||
|
|
ts.isBinaryExpression(current)
|
|
) {
|
|
current = current.parent
|
|
continue
|
|
}
|
|
return undefined
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function isRenderedJsxExpression(node) {
|
|
let current = node.parent
|
|
while (current) {
|
|
if (ts.isJsxExpression(current)) {
|
|
return (
|
|
ts.isJsxElement(current.parent) ||
|
|
ts.isJsxFragment(current.parent) ||
|
|
ts.isJsxSelfClosingElement(current.parent)
|
|
)
|
|
}
|
|
if (
|
|
ts.isConditionalExpression(current) ||
|
|
ts.isParenthesizedExpression(current) ||
|
|
ts.isTemplateExpression(current) ||
|
|
ts.isNoSubstitutionTemplateLiteral(current)
|
|
) {
|
|
if (ts.isConditionalExpression(current) && current.condition === node) {
|
|
return false
|
|
}
|
|
current = current.parent
|
|
continue
|
|
}
|
|
if (ts.isBinaryExpression(current)) {
|
|
if (current.operatorToken.kind !== ts.SyntaxKind.PlusToken) {
|
|
return false
|
|
}
|
|
current = current.parent
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return false
|
|
}
|
|
|
|
function nearestObjectPropertyName(node) {
|
|
let current = node.parent
|
|
while (current) {
|
|
if (ts.isPropertyAssignment(current) || ts.isShorthandPropertyAssignment(current)) {
|
|
return propertyNameText(current.name)
|
|
}
|
|
if (ts.isObjectLiteralExpression(current) || ts.isArrayLiteralExpression(current)) {
|
|
current = current.parent
|
|
continue
|
|
}
|
|
return undefined
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function hasAncestorObjectPropertyName(node, names) {
|
|
let current = node.parent
|
|
while (current) {
|
|
if (
|
|
(ts.isPropertyAssignment(current) || ts.isShorthandPropertyAssignment(current)) &&
|
|
names.has(propertyNameText(current.name) ?? '')
|
|
) {
|
|
return true
|
|
}
|
|
current = current.parent
|
|
}
|
|
return false
|
|
}
|
|
|
|
function nearestAncestorObjectPropertyName(node) {
|
|
let current = node.parent
|
|
while (current) {
|
|
if (ts.isPropertyAssignment(current) || ts.isShorthandPropertyAssignment(current)) {
|
|
return propertyNameText(current.name)
|
|
}
|
|
current = current.parent
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function findAncestor(node, predicate) {
|
|
let current = node.parent
|
|
while (current) {
|
|
if (predicate(current)) {
|
|
return current
|
|
}
|
|
current = current.parent
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function isUserVisibleCallArgument(node) {
|
|
const call = findAncestor(node, ts.isCallExpression)
|
|
if (!call) {
|
|
return false
|
|
}
|
|
const expressionName = expressionNameText(call.expression)
|
|
if (!expressionName) {
|
|
return false
|
|
}
|
|
const parts = expressionName.split('.')
|
|
const methodName = parts.at(-1)
|
|
const objectName = parts.at(-2)
|
|
return (
|
|
USER_VISIBLE_FUNCTION_NAMES.has(expressionName) ||
|
|
USER_VISIBLE_FUNCTION_NAMES.has(methodName ?? '') ||
|
|
(objectName !== undefined &&
|
|
USER_VISIBLE_OBJECT_NAMES.has(objectName) &&
|
|
USER_VISIBLE_OBJECT_METHODS.has(methodName ?? ''))
|
|
)
|
|
}
|
|
|
|
function classifyStringNode(node) {
|
|
if (hasAncestorObjectPropertyName(node, new Set(['className', 'classNames']))) {
|
|
return undefined
|
|
}
|
|
|
|
if (
|
|
findAncestor(
|
|
node,
|
|
(ancestor) =>
|
|
ts.isBinaryExpression(ancestor) && ancestor.operatorToken.kind !== ts.SyntaxKind.PlusToken
|
|
)
|
|
) {
|
|
return undefined
|
|
}
|
|
|
|
const jsxAttributeName = isJsxAttributeValue(node)
|
|
if (jsxAttributeName) {
|
|
return USER_VISIBLE_JSX_ATTRIBUTES.has(jsxAttributeName)
|
|
? `jsx-attribute:${jsxAttributeName}`
|
|
: undefined
|
|
}
|
|
|
|
const ancestorAttributeName = ancestorJsxAttributeName(node)
|
|
if (ancestorAttributeName) {
|
|
return USER_VISIBLE_JSX_ATTRIBUTES.has(ancestorAttributeName)
|
|
? `jsx-attribute:${ancestorAttributeName}`
|
|
: undefined
|
|
}
|
|
|
|
if (ts.isJsxText(node)) {
|
|
return 'jsx-text'
|
|
}
|
|
|
|
const objectPropertyName = nearestObjectPropertyName(node)
|
|
if (objectPropertyName && !USER_VISIBLE_OBJECT_KEYS.has(objectPropertyName)) {
|
|
return undefined
|
|
}
|
|
|
|
const ancestorObjectPropertyName = nearestAncestorObjectPropertyName(node)
|
|
if (ancestorObjectPropertyName && !USER_VISIBLE_OBJECT_KEYS.has(ancestorObjectPropertyName)) {
|
|
return undefined
|
|
}
|
|
|
|
if (isRenderedJsxExpression(node)) {
|
|
return 'jsx-expression'
|
|
}
|
|
|
|
if (isUserVisibleCallArgument(node)) {
|
|
return 'user-visible-call'
|
|
}
|
|
|
|
if (objectPropertyName) {
|
|
return `object-property:${objectPropertyName}`
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
function areaForFile(relativePath) {
|
|
const rendererPrefix = 'src/renderer/src/'
|
|
if (!relativePath.startsWith(rendererPrefix)) {
|
|
return relativePath.split('/').slice(0, 2).join('/')
|
|
}
|
|
|
|
const withoutPrefix = relativePath.slice(rendererPrefix.length)
|
|
const parts = withoutPrefix.split('/')
|
|
if (parts[0] === 'components' && parts[1]) {
|
|
return `renderer/${parts[1]}`
|
|
}
|
|
return `renderer/${parts[0] ?? 'root'}`
|
|
}
|
|
|
|
export function collectLocalizationCandidates(filePath, sourceText, root = process.cwd()) {
|
|
const sourceKind =
|
|
filePath.endsWith('.tsx') || filePath.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
|
const sourceFile = ts.createSourceFile(
|
|
filePath,
|
|
sourceText,
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
sourceKind
|
|
)
|
|
const reports = []
|
|
const relativePath = normalizePath(root, filePath)
|
|
|
|
function pushReport(node, kind, text, dynamic = false) {
|
|
const value = compactText(text)
|
|
if (!hasHumanLanguageText(value) || isInsideLocalizationCall(node)) {
|
|
return
|
|
}
|
|
const position = lineAndColumn(sourceFile, node)
|
|
reports.push({
|
|
area: areaForFile(relativePath),
|
|
filePath: relativePath,
|
|
start: node.getStart(sourceFile),
|
|
end: node.getEnd(),
|
|
line: position.line,
|
|
column: position.column,
|
|
kind,
|
|
text: value,
|
|
dynamic
|
|
})
|
|
}
|
|
|
|
function visit(node) {
|
|
if (ts.isJsxText(node)) {
|
|
pushReport(node, 'jsx-text', node.text)
|
|
return
|
|
}
|
|
|
|
const kind = classifyStringNode(node)
|
|
if (kind) {
|
|
for (const part of stringParts(node)) {
|
|
pushReport(node, kind, part.text, part.dynamic)
|
|
}
|
|
}
|
|
|
|
ts.forEachChild(node, visit)
|
|
}
|
|
|
|
visit(sourceFile)
|
|
return reports
|
|
}
|
|
|
|
function groupByArea(reports) {
|
|
const groups = new Map()
|
|
for (const report of reports) {
|
|
const group = groups.get(report.area) ?? { area: report.area, count: 0, files: new Map() }
|
|
group.count += 1
|
|
group.files.set(report.filePath, (group.files.get(report.filePath) ?? 0) + 1)
|
|
groups.set(report.area, group)
|
|
}
|
|
return [...groups.values()].sort((left, right) => right.count - left.count)
|
|
}
|
|
|
|
function formatReports(_root, reports) {
|
|
return reports
|
|
.map(
|
|
(report) =>
|
|
`${report.filePath}:${report.line}:${report.column} ${report.kind}: ${JSON.stringify(report.text)}`
|
|
)
|
|
.join('\n')
|
|
}
|
|
|
|
function formatMarkdownReport(reports) {
|
|
const groups = groupByArea(reports)
|
|
const lines = [
|
|
'# Localization Candidate Inventory',
|
|
'',
|
|
`Generated: ${new Date().toISOString()}`,
|
|
'',
|
|
`Total candidates: ${reports.length}`,
|
|
'',
|
|
'## Area Summary',
|
|
''
|
|
]
|
|
|
|
for (const group of groups) {
|
|
lines.push(`- ${group.area}: ${group.count} candidates across ${group.files.size} files`)
|
|
}
|
|
|
|
lines.push('', '## Candidates', '')
|
|
for (const group of groups) {
|
|
lines.push(`### ${group.area}`, '')
|
|
for (const report of reports.filter((entry) => entry.area === group.area)) {
|
|
lines.push(
|
|
`- \`${report.filePath}:${report.line}:${report.column}\` ${report.kind}: ${JSON.stringify(report.text)}`
|
|
)
|
|
}
|
|
lines.push('')
|
|
}
|
|
|
|
return lines.join('\n')
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const options = {
|
|
allowlistPath: path.join('config', 'localization-coverage-allowlist.json'),
|
|
check: false,
|
|
format: 'summary',
|
|
outputPath: null,
|
|
sourceRoot: path.join('src', 'renderer', 'src')
|
|
}
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index]
|
|
if (arg === '--json') {
|
|
options.format = 'json'
|
|
} else if (arg === '--markdown') {
|
|
options.format = 'markdown'
|
|
} else if (arg === '--check') {
|
|
options.check = true
|
|
} else if (arg === '--allowlist') {
|
|
options.allowlistPath = argv[index + 1] ?? options.allowlistPath
|
|
index += 1
|
|
} else if (arg === '--output') {
|
|
options.outputPath = argv[index + 1] ?? null
|
|
index += 1
|
|
} else if (arg === '--source-root') {
|
|
options.sourceRoot = argv[index + 1] ?? options.sourceRoot
|
|
index += 1
|
|
}
|
|
}
|
|
|
|
return options
|
|
}
|
|
|
|
function candidateSignature(candidate) {
|
|
return JSON.stringify({
|
|
filePath: candidate.filePath,
|
|
kind: candidate.kind,
|
|
text: candidate.text,
|
|
dynamic: candidate.dynamic
|
|
})
|
|
}
|
|
|
|
function countBySignature(reports) {
|
|
const counts = new Map()
|
|
for (const report of reports) {
|
|
const signature = candidateSignature(report)
|
|
counts.set(signature, (counts.get(signature) ?? 0) + 1)
|
|
}
|
|
return counts
|
|
}
|
|
|
|
async function readAllowlist(root, allowlistPath) {
|
|
const absolutePath = path.resolve(root, allowlistPath)
|
|
const raw = await fs.readFile(absolutePath, 'utf8')
|
|
return JSON.parse(raw)
|
|
}
|
|
|
|
function findNewCandidates(reports, allowlist) {
|
|
const allowedCounts = new Map(
|
|
allowlist.map((entry) => [
|
|
JSON.stringify({
|
|
filePath: entry.filePath,
|
|
kind: entry.kind,
|
|
text: entry.text,
|
|
dynamic: entry.dynamic
|
|
}),
|
|
entry.count
|
|
])
|
|
)
|
|
const seenCounts = countBySignature(reports)
|
|
const newCandidates = []
|
|
|
|
for (const report of reports) {
|
|
const signature = candidateSignature(report)
|
|
const seenCount = seenCounts.get(signature) ?? 0
|
|
const allowedCount = allowedCounts.get(signature) ?? 0
|
|
if (seenCount > allowedCount) {
|
|
newCandidates.push(report)
|
|
seenCounts.set(signature, seenCount - 1)
|
|
}
|
|
}
|
|
|
|
return newCandidates
|
|
}
|
|
|
|
export async function main(root = process.cwd(), argv = process.argv.slice(2)) {
|
|
const options = parseArgs(argv)
|
|
const absoluteSourceRoot = path.resolve(root, options.sourceRoot)
|
|
const files = await collectSourceFiles(root, absoluteSourceRoot)
|
|
const reports = []
|
|
|
|
for (const filePath of files) {
|
|
const sourceText = await fs.readFile(filePath, 'utf8')
|
|
reports.push(...collectLocalizationCandidates(filePath, sourceText, root))
|
|
}
|
|
|
|
if (options.check) {
|
|
const allowlist = await readAllowlist(root, options.allowlistPath)
|
|
const newCandidates = findNewCandidates(reports, allowlist)
|
|
if (newCandidates.length > 0) {
|
|
console.error('New unlocalized renderer strings were found.')
|
|
console.error('Localize them or add a reviewed exclusion to the localization allowlist.')
|
|
console.error('')
|
|
console.error(formatReports(root, newCandidates))
|
|
return 1
|
|
}
|
|
console.log(`Localization coverage check passed with ${reports.length} allowlisted candidates.`)
|
|
return 0
|
|
}
|
|
|
|
const output =
|
|
options.format === 'json'
|
|
? `${JSON.stringify(reports, null, 2)}\n`
|
|
: options.format === 'markdown'
|
|
? `${formatMarkdownReport(reports)}\n`
|
|
: `${reports.length} localization candidates in ${files.length} files.\n${groupByArea(
|
|
reports
|
|
)
|
|
.map((group) => `${group.area}: ${group.count}`)
|
|
.join('\n')}\n`
|
|
|
|
if (options.outputPath) {
|
|
await fs.mkdir(path.dirname(path.resolve(root, options.outputPath)), { recursive: true })
|
|
await fs.writeFile(path.resolve(root, options.outputPath), output)
|
|
} else {
|
|
process.stdout.write(output)
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
process.exit(await main())
|
|
}
|