489 lines
15 KiB
JavaScript
489 lines
15 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_FUNCTION_NAMES = new Set(['t', 'translate', 'translateMain'])
|
|
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
|
|
const LOCALES_RELATIVE_DIR = path.join('src', 'renderer', 'src', 'i18n', 'locales')
|
|
const SOURCE_RELATIVE_ROOTS = [path.join('src', 'renderer', 'src'), path.join('src', 'main')]
|
|
|
|
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 flattenCatalogKeys(value, prefix = '') {
|
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
return prefix ? [prefix] : []
|
|
}
|
|
return Object.entries(value).flatMap(([key, child]) =>
|
|
flattenCatalogKeys(child, prefix ? `${prefix}.${key}` : key)
|
|
)
|
|
}
|
|
|
|
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 reportAt(root, filePath, sourceFile, node, key, fallback) {
|
|
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
|
|
return {
|
|
filePath: normalizePath(root, filePath),
|
|
line: position.line + 1,
|
|
column: position.character + 1,
|
|
key,
|
|
fallback
|
|
}
|
|
}
|
|
|
|
export function collectLocalizationKeyReferences(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 references = []
|
|
|
|
function visit(node) {
|
|
if (ts.isCallExpression(node)) {
|
|
const name = expressionNameText(node.expression)
|
|
const functionName = name?.split('.').at(-1)
|
|
const firstArg = node.arguments[0]
|
|
if (
|
|
functionName &&
|
|
LOCALIZATION_FUNCTION_NAMES.has(functionName) &&
|
|
firstArg &&
|
|
ts.isStringLiteralLike(firstArg)
|
|
) {
|
|
const secondArg = node.arguments[1]
|
|
references.push(
|
|
reportAt(
|
|
root,
|
|
filePath,
|
|
sourceFile,
|
|
firstArg,
|
|
firstArg.text,
|
|
secondArg && ts.isStringLiteralLike(secondArg) ? secondArg.text : undefined
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
ts.forEachChild(node, visit)
|
|
}
|
|
|
|
visit(sourceFile)
|
|
return references
|
|
}
|
|
|
|
function formatMissingReferences(missing) {
|
|
return missing
|
|
.map(
|
|
(reference) => `${reference.filePath}:${reference.line}:${reference.column} ${reference.key}`
|
|
)
|
|
.join('\n')
|
|
}
|
|
|
|
function formatMissingKeys(label, keys) {
|
|
return keys.map((key) => `${label}: ${key}`).join('\n')
|
|
}
|
|
|
|
function normalizeInterpolationVariables(value) {
|
|
return collectInterpolationVariables(value)
|
|
.map((variable) => variable.slice(2, -2))
|
|
.join('|')
|
|
}
|
|
|
|
function formatInconsistentFallbackVariables(inconsistentFallbackVariables) {
|
|
return inconsistentFallbackVariables
|
|
.map(({ key, references }) => {
|
|
const locations = references
|
|
.map(
|
|
(reference) =>
|
|
` ${reference.filePath}:${reference.line}:${reference.column} ${JSON.stringify(reference.fallback)}`
|
|
)
|
|
.join('\n')
|
|
return `${key}\n${locations}`
|
|
})
|
|
.join('\n\n')
|
|
}
|
|
|
|
function collectInconsistentFallbackVariables(references) {
|
|
const byKey = new Map()
|
|
|
|
for (const reference of references) {
|
|
if (typeof reference.fallback !== 'string') {
|
|
continue
|
|
}
|
|
const existing = byKey.get(reference.key) ?? []
|
|
existing.push(reference)
|
|
byKey.set(reference.key, existing)
|
|
}
|
|
|
|
return [...byKey.entries()]
|
|
.map(([key, keyReferences]) => {
|
|
const uniqueFallbackVariables = new Set(
|
|
keyReferences.map((reference) => normalizeInterpolationVariables(reference.fallback))
|
|
)
|
|
return {
|
|
key,
|
|
references: keyReferences,
|
|
uniqueFallbackVariableCount: uniqueFallbackVariables.size
|
|
}
|
|
})
|
|
.filter(({ uniqueFallbackVariableCount }) => uniqueFallbackVariableCount > 1)
|
|
}
|
|
|
|
function collectInterpolationVariables(value) {
|
|
if (typeof value === 'string') {
|
|
const matches = value.match(PLACEHOLDER_RE) ?? []
|
|
return [...matches].sort()
|
|
}
|
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
return []
|
|
}
|
|
return Object.values(value).flatMap((child) => collectInterpolationVariables(child))
|
|
}
|
|
|
|
function flattenCatalogEntries(value, prefix = '', entries = new Map()) {
|
|
if (typeof value === 'string') {
|
|
entries.set(prefix, value)
|
|
return entries
|
|
}
|
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
return entries
|
|
}
|
|
for (const [key, child] of Object.entries(value)) {
|
|
flattenCatalogEntries(child, prefix ? `${prefix}.${key}` : key, entries)
|
|
}
|
|
return entries
|
|
}
|
|
|
|
function getCatalogEntry(catalog, key) {
|
|
return key.split('.').reduce((cursor, part) => cursor?.[part], catalog)
|
|
}
|
|
|
|
function setCatalogEntry(catalog, key, value) {
|
|
const parts = key.split('.')
|
|
let cursor = catalog
|
|
for (const part of parts.slice(0, -1)) {
|
|
if (typeof cursor[part] !== 'object' || cursor[part] === null || Array.isArray(cursor[part])) {
|
|
cursor[part] = {}
|
|
}
|
|
cursor = cursor[part]
|
|
}
|
|
cursor[parts.at(-1)] = value
|
|
}
|
|
|
|
function deleteCatalogEntry(catalog, key) {
|
|
const parts = key.split('.')
|
|
const stack = []
|
|
let cursor = catalog
|
|
|
|
for (const part of parts.slice(0, -1)) {
|
|
if (
|
|
typeof cursor?.[part] !== 'object' ||
|
|
cursor[part] === null ||
|
|
Array.isArray(cursor[part])
|
|
) {
|
|
return false
|
|
}
|
|
stack.push([cursor, part])
|
|
cursor = cursor[part]
|
|
}
|
|
|
|
const leafKey = parts.at(-1)
|
|
if (!Object.hasOwn(cursor, leafKey)) {
|
|
return false
|
|
}
|
|
|
|
delete cursor[leafKey]
|
|
for (let index = stack.length - 1; index >= 0; index -= 1) {
|
|
const [parent, part] = stack[index]
|
|
const child = parent[part]
|
|
if (
|
|
typeof child === 'object' &&
|
|
child !== null &&
|
|
!Array.isArray(child) &&
|
|
Object.keys(child).length === 0
|
|
) {
|
|
delete parent[part]
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
function collectLocaleParityIssues(enCatalog, localeCatalog) {
|
|
const enEntries = flattenCatalogEntries(enCatalog)
|
|
const localeEntries = flattenCatalogEntries(localeCatalog)
|
|
const missingInLocale = [...enEntries.keys()].filter((key) => !localeEntries.has(key))
|
|
const extraInLocale = [...localeEntries.keys()].filter((key) => !enEntries.has(key))
|
|
const interpolationMismatches = []
|
|
|
|
for (const key of enEntries.keys()) {
|
|
if (!localeEntries.has(key)) {
|
|
continue
|
|
}
|
|
const enVariables = collectInterpolationVariables(enEntries.get(key))
|
|
const localeVariables = collectInterpolationVariables(localeEntries.get(key))
|
|
if (enVariables.join('|') !== localeVariables.join('|')) {
|
|
interpolationMismatches.push(key)
|
|
}
|
|
}
|
|
|
|
return { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches }
|
|
}
|
|
|
|
function repairLocaleParity(enCatalog, localeCatalog) {
|
|
const { enEntries, missingInLocale, extraInLocale, interpolationMismatches } =
|
|
collectLocaleParityIssues(enCatalog, localeCatalog)
|
|
let changed = 0
|
|
|
|
for (const key of missingInLocale) {
|
|
setCatalogEntry(localeCatalog, key, enEntries.get(key))
|
|
changed += 1
|
|
}
|
|
|
|
for (const key of extraInLocale) {
|
|
if (deleteCatalogEntry(localeCatalog, key)) {
|
|
changed += 1
|
|
}
|
|
}
|
|
|
|
for (const key of interpolationMismatches) {
|
|
setCatalogEntry(localeCatalog, key, enEntries.get(key))
|
|
changed += 1
|
|
}
|
|
|
|
return changed
|
|
}
|
|
|
|
function referencesMissingFallbacks(missing) {
|
|
return missing.filter((reference) => typeof reference.fallback !== 'string')
|
|
}
|
|
|
|
function collectMissingCatalogEntries(missing) {
|
|
const entries = new Map()
|
|
|
|
for (const reference of missing) {
|
|
if (typeof reference.fallback !== 'string') {
|
|
continue
|
|
}
|
|
if (!entries.has(reference.key)) {
|
|
entries.set(reference.key, reference.fallback)
|
|
}
|
|
}
|
|
|
|
return entries
|
|
}
|
|
|
|
function applyMissingEnglishEntries(catalog, missing) {
|
|
const entries = collectMissingCatalogEntries(missing)
|
|
let changed = 0
|
|
|
|
for (const [key, fallback] of entries) {
|
|
if (getCatalogEntry(catalog, key) !== undefined) {
|
|
continue
|
|
}
|
|
setCatalogEntry(catalog, key, fallback)
|
|
changed += 1
|
|
}
|
|
|
|
return changed
|
|
}
|
|
|
|
function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
|
|
const { localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
|
|
collectLocaleParityIssues(enCatalog, localeCatalog)
|
|
|
|
if (
|
|
missingInLocale.length > 0 ||
|
|
extraInLocale.length > 0 ||
|
|
interpolationMismatches.length > 0
|
|
) {
|
|
console.error(`Locale catalog parity failed for ${localeName}.json.`)
|
|
if (missingInLocale.length > 0) {
|
|
console.error('')
|
|
console.error(formatMissingKeys('missing', missingInLocale.slice(0, 20)))
|
|
if (missingInLocale.length > 20) {
|
|
console.error(`...and ${missingInLocale.length - 20} more missing keys`)
|
|
}
|
|
}
|
|
if (extraInLocale.length > 0) {
|
|
console.error('')
|
|
console.error(formatMissingKeys('extra', extraInLocale.slice(0, 20)))
|
|
if (extraInLocale.length > 20) {
|
|
console.error(`...and ${extraInLocale.length - 20} more extra keys`)
|
|
}
|
|
}
|
|
if (interpolationMismatches.length > 0) {
|
|
console.error('')
|
|
console.error(
|
|
formatMissingKeys('interpolation mismatch', interpolationMismatches.slice(0, 20))
|
|
)
|
|
if (interpolationMismatches.length > 20) {
|
|
console.error(`...and ${interpolationMismatches.length - 20} more interpolation mismatches`)
|
|
}
|
|
}
|
|
return 1
|
|
}
|
|
|
|
console.log(`Verified locale parity for ${localeName}.json (${localeEntries.size} keys).`)
|
|
return 0
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
return {
|
|
fix: argv.includes('--fix')
|
|
}
|
|
}
|
|
|
|
export async function main(root = process.cwd(), options = parseArgs(process.argv.slice(2))) {
|
|
const localesDir = path.join(root, LOCALES_RELATIVE_DIR)
|
|
const catalogPath = path.join(localesDir, 'en.json')
|
|
const catalog = JSON.parse(await fs.readFile(catalogPath, 'utf8'))
|
|
let catalogKeys = new Set(flattenCatalogKeys(catalog))
|
|
const sourceRoots = SOURCE_RELATIVE_ROOTS.map((sourceRoot) => path.join(root, sourceRoot))
|
|
const references = []
|
|
|
|
for (const sourceRoot of sourceRoots) {
|
|
const files = await collectSourceFiles(root, sourceRoot)
|
|
for (const filePath of files) {
|
|
references.push(
|
|
...collectLocalizationKeyReferences(filePath, await fs.readFile(filePath, 'utf8'), root)
|
|
)
|
|
}
|
|
}
|
|
|
|
const missing = references.filter((reference) => !catalogKeys.has(reference.key))
|
|
if (missing.length > 0) {
|
|
const missingFallbacks = referencesMissingFallbacks(missing)
|
|
if (options.fix && missingFallbacks.length === 0) {
|
|
const added = applyMissingEnglishEntries(catalog, missing)
|
|
await fs.writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`, 'utf8')
|
|
catalogKeys = new Set(flattenCatalogKeys(catalog))
|
|
console.log(`Added ${added} missing localization key(s) to en.json.`)
|
|
} else {
|
|
if (options.fix && missingFallbacks.length > 0) {
|
|
console.error('Some missing localization keys do not have string fallbacks to bootstrap.')
|
|
console.error('')
|
|
console.error(formatMissingReferences(missingFallbacks))
|
|
return 1
|
|
}
|
|
console.error('Localization keys are missing from src/renderer/src/i18n/locales/en.json.')
|
|
console.error('')
|
|
console.error(formatMissingReferences(missing))
|
|
console.error('')
|
|
console.error('Run `pnpm run sync:localization-catalog` to add keys with string fallbacks.')
|
|
return 1
|
|
}
|
|
}
|
|
|
|
const remainingMissing = references.filter((reference) => !catalogKeys.has(reference.key))
|
|
if (remainingMissing.length > 0) {
|
|
console.error('Localization keys are missing from src/renderer/src/i18n/locales/en.json.')
|
|
console.error('')
|
|
console.error(formatMissingReferences(remainingMissing))
|
|
return 1
|
|
}
|
|
|
|
const inconsistentFallbackVariables = collectInconsistentFallbackVariables(references)
|
|
if (inconsistentFallbackVariables.length > 0) {
|
|
console.error('Localization keys are used with inconsistent interpolation placeholders.')
|
|
console.error('')
|
|
console.error(formatInconsistentFallbackVariables(inconsistentFallbackVariables))
|
|
return 1
|
|
}
|
|
|
|
console.log(`Verified ${references.length} localization key references against en.json.`)
|
|
|
|
const localeFiles = (await fs.readdir(localesDir))
|
|
.filter(
|
|
(fileName) =>
|
|
fileName.endsWith('.json') &&
|
|
fileName !== 'en.json' &&
|
|
!fileName.startsWith('.') &&
|
|
!fileName.includes('-catalog-cache')
|
|
)
|
|
.sort()
|
|
|
|
for (const fileName of localeFiles) {
|
|
const localeName = fileName.replace(/\.json$/, '')
|
|
const localeCatalogPath = path.join(localesDir, fileName)
|
|
const localeCatalog = JSON.parse(await fs.readFile(localeCatalogPath, 'utf8'))
|
|
if (options.fix) {
|
|
const repaired = repairLocaleParity(catalog, localeCatalog)
|
|
if (repaired > 0) {
|
|
await fs.writeFile(localeCatalogPath, `${JSON.stringify(localeCatalog, null, 2)}\n`, 'utf8')
|
|
console.log(`Repaired ${fileName} parity (${repaired} key update(s)).`)
|
|
}
|
|
}
|
|
const exitCode = verifyLocaleParity(catalog, localeName, localeCatalog)
|
|
if (exitCode !== 0) {
|
|
if (!options.fix) {
|
|
console.error('')
|
|
console.error('Run `pnpm run sync:localization-catalog` to repair locale parity.')
|
|
}
|
|
return exitCode
|
|
}
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
process.exit(await main())
|
|
}
|