Files
api/web/scripts/extract-translations.mjs

575 lines
17 KiB
JavaScript

#!/usr/bin/env node
import { readdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { parse } from '@vue/compiler-sfc';
import { glob } from 'glob';
import ts from 'typescript';
async function loadExtractor() {
const module = await import('vue-i18n-extract');
if (typeof module.createI18NReport === 'function') {
return module.createI18NReport;
}
if (module.default && typeof module.default.createI18NReport === 'function') {
return module.default.createI18NReport;
}
throw new Error('createI18NReport export not found');
}
async function readJson(filePath) {
const raw = await readFile(filePath, 'utf8');
return raw.trim() ? JSON.parse(raw) : {};
}
async function writeJson(filePath, data) {
const json = JSON.stringify(data, null, 2) + '\n';
await writeFile(filePath, json, 'utf8');
}
function expandJsonFormsKey(key) {
const expanded = new Set();
// Preserve explicit keys for shared error translations
if (key.startsWith('jsonforms.errors')) {
expanded.add(key);
return expanded;
}
// Keep any explicitly referenced error keys as-is
if (key.includes('.error.')) {
expanded.add(key);
return expanded;
}
// Don't add .label to keys that already have specific suffixes
if (key.endsWith('.title') || key.endsWith('.description')) {
expanded.add(key);
return expanded;
}
const baseKey = key.endsWith('.label') ? key.slice(0, -'.label'.length) : key;
expanded.add(`${baseKey}.label`);
expanded.add(`${baseKey}.error.custom`);
return expanded;
}
function stripAsExpressions(node) {
let current = node;
while (current && (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current))) {
current = current.expression;
}
return current;
}
function getPropertyName(node) {
if (!node) return undefined;
if (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) {
return node.text;
}
return undefined;
}
function objectLiteralToObject(node) {
const result = {};
for (const prop of node.properties) {
if (!ts.isPropertyAssignment(prop)) {
continue;
}
const name = getPropertyName(prop.name);
if (!name) {
continue;
}
const value = literalToValue(prop.initializer);
if (value !== undefined) {
result[name] = value;
}
}
return result;
}
function literalToValue(node) {
const stripped = stripAsExpressions(node);
if (!stripped) return undefined;
if (ts.isStringLiteralLike(stripped)) {
return stripped.text;
}
if (ts.isObjectLiteralExpression(stripped)) {
return objectLiteralToObject(stripped);
}
return undefined;
}
function resolvePropertyAccess(constantMap, expression) {
const segments = [];
let current = expression;
while (ts.isPropertyAccessExpression(current)) {
segments.unshift(current.name.text);
current = current.expression;
}
if (!ts.isIdentifier(current)) {
return undefined;
}
const root = current.text;
let value = constantMap.get(root);
if (value === undefined) {
return undefined;
}
for (const segment of segments) {
if (value && typeof value === 'object' && segment in value) {
value = value[segment];
} else {
return undefined;
}
}
return typeof value === 'string' ? value : undefined;
}
function resolveI18nString(constantMap, expression) {
const stripped = stripAsExpressions(expression);
if (!stripped) return undefined;
if (ts.isStringLiteralLike(stripped)) {
return stripped.text;
}
if (ts.isPropertyAccessExpression(stripped)) {
return resolvePropertyAccess(constantMap, stripped);
}
return undefined;
}
const translationFunctionNames = new Set(['t', 'tc']);
function createSourceFileFromContent(fileName, content, scriptKind = ts.ScriptKind.TSX) {
return ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true, scriptKind);
}
function collectTranslationKeysFromSource(sourceFile, keys) {
const visit = (node) => {
if (ts.isCallExpression(node) && node.arguments.length > 0) {
let functionName;
const expression = node.expression;
if (ts.isIdentifier(expression)) {
functionName = expression.text;
} else if (ts.isPropertyAccessExpression(expression)) {
functionName = expression.name.text;
}
if (functionName && translationFunctionNames.has(functionName)) {
const firstArg = stripAsExpressions(node.arguments[0]);
if (firstArg && ts.isStringLiteralLike(firstArg)) {
keys.add(firstArg.text);
}
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
}
function detectScriptKind(filePath) {
if (filePath.endsWith('.tsx')) return ts.ScriptKind.TSX;
if (filePath.endsWith('.ts')) return ts.ScriptKind.TS;
if (filePath.endsWith('.jsx')) return ts.ScriptKind.JSX;
return ts.ScriptKind.JS;
}
async function collectTsTranslationKeys() {
const sourceRoot = path.resolve(process.cwd(), 'src');
const ignorePatterns = [
'**/__tests__/**',
'**/__test__/**',
'**/*.spec.ts',
'**/*.spec.tsx',
'**/*.spec.js',
'**/*.spec.jsx',
'**/*.test.ts',
'**/*.test.tsx',
'**/*.test.js',
'**/*.test.jsx',
];
let scriptFiles = [];
try {
scriptFiles = await glob('**/*.{ts,tsx,js,jsx}', {
cwd: sourceRoot,
ignore: ignorePatterns,
absolute: true,
});
} catch (error) {
console.warn('[i18n] Failed to enumerate TS source files for translation keys.', error);
return new Set();
}
let vueFiles = [];
try {
vueFiles = await glob('**/*.vue', {
cwd: sourceRoot,
ignore: ignorePatterns,
absolute: true,
});
} catch (error) {
console.warn('[i18n] Failed to enumerate Vue files for translation keys.', error);
}
const keys = new Set();
await Promise.all(
scriptFiles.map(async (file) => {
try {
const content = await readFile(file, 'utf8');
const kind = detectScriptKind(file);
const sourceFile = createSourceFileFromContent(file, content, kind);
collectTranslationKeysFromSource(sourceFile, keys);
} catch (error) {
console.warn(`[i18n] Failed to process ${file} for translation keys.`, error);
}
})
);
await Promise.all(
vueFiles.map(async (file) => {
try {
const content = await readFile(file, 'utf8');
const { descriptor } = parse(content, { filename: file });
if (descriptor.script) {
const lang = descriptor.script.lang || 'ts';
const kind = detectScriptKind(
`file.${lang === 'tsx' ? 'tsx' : lang === 'ts' ? 'ts' : lang === 'jsx' ? 'jsx' : 'js'}`
);
const sourceFile = createSourceFileFromContent(file, descriptor.script.content, kind);
collectTranslationKeysFromSource(sourceFile, keys);
}
if (descriptor.scriptSetup) {
const lang = descriptor.scriptSetup.lang || 'ts';
const kind = detectScriptKind(
`file.${lang === 'tsx' ? 'tsx' : lang === 'ts' ? 'ts' : lang === 'jsx' ? 'jsx' : 'js'}`
);
const sourceFile = createSourceFileFromContent(
`${file}?setup`,
descriptor.scriptSetup.content,
kind
);
collectTranslationKeysFromSource(sourceFile, keys);
}
} catch (error) {
console.warn(`[i18n] Failed to process ${file} for Vue translation keys.`, error);
}
})
);
return keys;
}
async function collectJsonFormsKeys() {
const apiSourceRoot = path.resolve(process.cwd(), '../api/src');
const ignorePatterns = [
'**/__tests__/**',
'**/__test__/**',
'**/*.spec.ts',
'**/*.spec.js',
'**/*.test.ts',
'**/*.test.js',
];
let files = [];
try {
files = await glob('**/*.ts', {
cwd: apiSourceRoot,
ignore: ignorePatterns,
absolute: true,
});
} catch (error) {
console.warn('[i18n] Failed to enumerate API source files for jsonforms keys.', error);
return { keys: new Set(), descriptions: new Map() };
}
const keys = new Set();
const descriptionValues = new Map();
const labelValues = new Map();
await Promise.all(
files.map(async (file) => {
try {
const content = await readFile(file, 'utf8');
const sourceFile = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true);
const constantMap = new Map();
const recordConstants = (node) => {
if (ts.isVariableStatement(node)) {
for (const declaration of node.declarationList.declarations) {
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) {
continue;
}
const stripped = stripAsExpressions(declaration.initializer);
if (!stripped) {
continue;
}
if (ts.isObjectLiteralExpression(stripped)) {
const obj = objectLiteralToObject(stripped);
if (obj && Object.keys(obj).length > 0) {
constantMap.set(declaration.name.text, obj);
}
}
}
}
ts.forEachChild(node, recordConstants);
};
recordConstants(sourceFile);
const visit = (node) => {
if (ts.isPropertyAssignment(node) && getPropertyName(node.name) === 'i18n') {
const key = resolveI18nString(constantMap, node.initializer);
if (key && key.startsWith('jsonforms.')) {
expandJsonFormsKey(key).forEach((expandedKey) => keys.add(expandedKey));
const parent = node.parent;
if (ts.isObjectLiteralExpression(parent)) {
let labelCandidate;
let titleCandidate;
let descriptionCandidate;
const allowDescriptionExtraction = !key.endsWith('.description');
for (const prop of parent.properties) {
if (!ts.isPropertyAssignment(prop)) {
continue;
}
const propName = getPropertyName(prop.name);
if (propName === 'description' && allowDescriptionExtraction) {
const descriptionValue = resolveI18nString(constantMap, prop.initializer);
if (typeof descriptionValue === 'string' && descriptionValue.length > 0) {
descriptionCandidate = descriptionValue;
}
continue;
}
if (propName === 'title') {
const titleValue = resolveI18nString(constantMap, prop.initializer);
if (typeof titleValue === 'string' && titleValue.length > 0) {
titleCandidate = titleValue;
}
continue;
}
if (!labelCandidate && (propName === 'label' || propName === 'text')) {
const resolved = resolveI18nString(constantMap, prop.initializer);
if (typeof resolved === 'string' && resolved.length > 0) {
labelCandidate = resolved;
}
}
}
// Add title key if we found a title value
if (typeof titleCandidate === 'string' && titleCandidate.length > 0) {
const titleKey = `${key}.title`;
keys.add(titleKey);
labelValues.set(titleKey, titleCandidate);
}
// Add description key if we found a description value
if (typeof descriptionCandidate === 'string' && descriptionCandidate.length > 0) {
const descriptionKey = `${key}.description`;
keys.add(descriptionKey);
descriptionValues.set(descriptionKey, descriptionCandidate);
}
// Add label key if we found a label value
if (typeof labelCandidate === 'string' && labelCandidate.length > 0) {
const labelKey = key.endsWith('.label') ? key : `${key}.label`;
keys.add(labelKey);
labelValues.set(labelKey, labelCandidate);
}
}
}
} else if (ts.isStringLiteralLike(node)) {
const text = node.text;
if (text.startsWith('jsonforms.')) {
expandJsonFormsKey(text).forEach((key) => keys.add(key));
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
} catch (error) {
console.warn(`[i18n] Failed to process ${file} for jsonforms keys.`, error);
}
})
);
return { keys, descriptions: descriptionValues, labels: labelValues };
}
async function main() {
const createI18NReport = await loadExtractor();
const root = process.cwd();
const localesDir = path.resolve(root, 'src/locales');
const localeFiles = (await readdir(localesDir)).filter((file) => file.endsWith('.json'));
if (localeFiles.length === 0) {
console.log('[i18n] No locale files found.');
return;
}
const englishLocale = 'en_US';
const englishFileName = 'en.json';
const localeDescriptors = localeFiles.map((file) => {
const rawLocale = file.replace(/\.json$/, '');
const locale = rawLocale === 'en' ? englishLocale : rawLocale;
return {
locale,
file,
absPath: path.resolve(localesDir, file),
};
});
const missingByLocale = new Map(localeDescriptors.map(({ locale }) => [locale, new Set()]));
let report;
const originalLog = console.log;
const originalTable = console.table;
const originalInfo = console.info;
const originalWarn = console.warn;
try {
console.log = () => {};
console.table = () => {};
console.info = () => {};
console.warn = () => {};
report = await createI18NReport({
vueFiles: 'src/**/*.{vue,ts,js}',
languageFiles: 'src/locales/*.json',
});
} finally {
console.log = originalLog;
console.table = originalTable;
console.info = originalInfo;
console.warn = originalWarn;
}
for (const entry of report.missingKeys ?? []) {
const rawLocale = path.basename(entry.language, '.json');
const normalizedLocale = rawLocale === 'en' ? englishLocale : rawLocale;
const target = missingByLocale.get(normalizedLocale);
if (target) {
target.add(entry.path);
}
}
const englishDescriptor = localeDescriptors.find((descriptor) => descriptor.file === englishFileName);
if (!englishDescriptor) {
throw new Error(`Source locale file ${englishFileName} not found in ${localesDir}`);
}
const englishData = await readJson(englishDescriptor.absPath);
const englishMissing = missingByLocale.get(englishLocale) ?? new Set();
const {
keys: jsonFormsKeys,
descriptions: jsonFormsDescriptions,
labels: jsonFormsLabels,
} = await collectJsonFormsKeys();
jsonFormsKeys.forEach((key) => englishMissing.add(key));
const tsTranslationKeys = await collectTsTranslationKeys();
tsTranslationKeys.forEach((key) => englishMissing.add(key));
const missingValuePlaceholder = null;
let englishUpdated = false;
let addedEnglish = 0;
for (const key of englishMissing) {
if (!(key in englishData)) {
let value = missingValuePlaceholder;
if (key.endsWith('.label')) {
const baseKey = key.slice(0, -'.label'.length);
const baseValue = englishData[baseKey];
if (typeof baseValue === 'string' && baseValue.length > 0) {
value = baseValue;
} else if (jsonFormsLabels.has(key)) {
value = jsonFormsLabels.get(key);
}
} else if (jsonFormsDescriptions.has(key)) {
value = jsonFormsDescriptions.get(key);
}
englishData[key] = value;
addedEnglish += 1;
}
}
if (addedEnglish > 0) {
englishUpdated = true;
}
const protectedKeys = new Set([
...jsonFormsKeys,
...jsonFormsDescriptions.keys(),
...jsonFormsLabels.keys(),
...tsTranslationKeys,
]);
const maybeDynamicKeys = new Set((report?.maybeDynamicKeys ?? []).map((entry) => entry.path));
const englishLanguageKey = 'en';
const englishUnusedKeys = new Set(
(report?.unusedKeys ?? [])
.filter((entry) => entry.language === englishLanguageKey)
.map((entry) => entry.path)
);
let removedEnglish = 0;
if (englishUnusedKeys.size > 0) {
for (const key of Object.keys(englishData)) {
if (!englishUnusedKeys.has(key)) {
continue;
}
if (protectedKeys.has(key)) {
continue;
}
if (maybeDynamicKeys.has(key)) {
continue;
}
delete englishData[key];
removedEnglish += 1;
}
}
if (removedEnglish > 0) {
englishUpdated = true;
}
if (englishUpdated) {
await writeJson(englishDescriptor.absPath, englishData);
}
if (addedEnglish === 0 && removedEnglish === 0) {
console.log('[i18n] No translation updates required for English locale.');
return;
}
if (addedEnglish > 0) {
console.log(`[i18n] Added ${addedEnglish} key(s) to ${englishFileName}.`);
}
if (removedEnglish > 0) {
console.log(`[i18n] Removed ${removedEnglish} unused key(s) from ${englishFileName}.`);
}
}
main().catch((error) => {
console.error('[i18n] Failed to extract translations.', error);
process.exitCode = 1;
});