Files
tolaria/scripts/validate-locales.mjs
2026-05-03 17:25:31 +02:00

150 lines
3.9 KiB
JavaScript

import {
closeSync, fstatSync, openSync, opendirSync, readFileSync,
} from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const localesDir = path.join(root, 'src/lib/locales')
const sourcePath = path.join(localesDir, 'en.json')
function readCatalog(filePath) {
return JSON.parse(readUtf8File(filePath))
}
function readUtf8File(filePath) {
const fd = openSync(filePath, 'r')
try {
return readFileSync(fd, 'utf8')
} finally {
closeSync(fd)
}
}
function directoryFiles(dirPath) {
const dir = opendirSync(dirPath)
try {
const files = []
let entry = dir.readSync()
while (entry) {
if (entry.isFile()) files.push(entry.name)
entry = dir.readSync()
}
return files
} finally {
dir.closeSync()
}
}
function ensureDirectory(dirPath) {
const fd = openSync(dirPath, 'r')
try {
if (!fstatSync(fd).isDirectory()) {
throw new Error(`${dirPath} is not a directory`)
}
} finally {
closeSync(fd)
}
}
function isFlatObject(value) {
if (!value) return false
if (typeof value !== 'object') return false
return !Array.isArray(value)
}
function assertFlatStringCatalog(locale, catalog) {
if (!isFlatObject(catalog)) {
throw new Error(`${locale}: expected a flat object of translation keys`)
}
for (const [key, value] of Object.entries(catalog)) {
if (typeof value !== 'string') {
throw new Error(`${locale}: key "${key}" must map to a string`)
}
}
}
function missingKeys(sourceKeys, localeKeys) {
const localeKeySet = new Set(localeKeys)
return sourceKeys.filter((key) => !localeKeySet.has(key))
}
function extraKeys(sourceKeys, localeKeys) {
const sourceKeySet = new Set(sourceKeys)
return localeKeys.filter((key) => !sourceKeySet.has(key))
}
function placeholders(value) {
return Array.from(value.matchAll(/\{(\w+)\}/g), (match) => match[1]).sort()
}
function sameValues(left, right) {
if (left.length !== right.length) return false
return left.every((value, index) => value === right[index])
}
function formatValues(values) {
return values.length === 0 ? 'none' : values.join(', ')
}
function placeholderIssues(locale, sourceCatalog, catalog) {
const issues = []
for (const [key, sourceValue] of Object.entries(sourceCatalog)) {
if (!(key in catalog)) continue
const sourcePlaceholders = placeholders(sourceValue)
const localePlaceholders = placeholders(catalog[key])
if (sameValues(sourcePlaceholders, localePlaceholders)) continue
issues.push(
`${locale}: key "${key}" placeholders differ ` +
`(expected ${formatValues(sourcePlaceholders)}, found ${formatValues(localePlaceholders)})`,
)
}
return issues
}
const sourceCatalog = readCatalog(sourcePath)
assertFlatStringCatalog('en', sourceCatalog)
const sourceKeys = Object.keys(sourceCatalog).sort()
ensureDirectory(localesDir)
const localeFiles = directoryFiles(localesDir).filter((file) => file.endsWith('.json'))
const issues = []
for (const file of localeFiles) {
const locale = file.replace(/\.json$/, '')
const filePath = path.join(localesDir, file)
const catalog = readCatalog(filePath)
assertFlatStringCatalog(locale, catalog)
if (locale === 'en') continue
const keys = Object.keys(catalog).sort()
const missing = missingKeys(sourceKeys, keys)
const extra = extraKeys(sourceKeys, keys)
if (missing.length > 0) {
issues.push(`${locale}: missing ${missing.length} key(s)`)
}
if (extra.length > 0) {
issues.push(`${locale}: extra ${extra.length} key(s)`)
}
issues.push(...placeholderIssues(locale, sourceCatalog, catalog))
}
if (issues.length > 0) {
console.error('Locale validation failed:')
for (const issue of issues) {
console.error(`- ${issue}`)
}
process.exit(1)
}
console.log(`Validated ${localeFiles.length} locale catalog(s) against ${sourceKeys.length} English keys.`)