build: reject duplicate generated translations
This commit is contained in:
@@ -22,9 +22,11 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 127.0.0.1 --port 5173",
|
"dev": "vite --host 127.0.0.1 --port 5173",
|
||||||
|
"prebuild": "npm run audit:i18n-structural",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview --host 127.0.0.1 --port 4173",
|
"preview": "vite preview --host 127.0.0.1 --port 4173",
|
||||||
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
|
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
|
||||||
|
"test:i18n-catalog": "node --test tests/i18n-catalog-validation.test.mjs",
|
||||||
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
|
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
|
||||||
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js",
|
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js",
|
||||||
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
|
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import vm from "node:vm";
|
import vm from "node:vm";
|
||||||
import ts from "typescript";
|
import ts from "typescript";
|
||||||
|
import { findDuplicateTranslationKeys } from "./i18n-catalog-validation.mjs";
|
||||||
|
|
||||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const webuiDir = path.resolve(scriptDir, "..");
|
const webuiDir = path.resolve(scriptDir, "..");
|
||||||
@@ -74,6 +75,8 @@ function scanStructuralSourcePositions(roots) {
|
|||||||
function scanGeneratedCatalogs(files) {
|
function scanGeneratedCatalogs(files) {
|
||||||
const findings = [];
|
const findings = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
|
const source = fs.readFileSync(file, "utf8");
|
||||||
|
findings.push(...findDuplicateTranslationKeys(source, file));
|
||||||
const translations = loadGeneratedTranslations(file);
|
const translations = loadGeneratedTranslations(file);
|
||||||
for (const [language, dictionary] of Object.entries(translations)) {
|
for (const [language, dictionary] of Object.entries(translations)) {
|
||||||
for (const [key, value] of Object.entries(dictionary)) {
|
for (const [key, value] of Object.entries(dictionary)) {
|
||||||
|
|||||||
69
webui/scripts/i18n-catalog-validation.mjs
Normal file
69
webui/scripts/i18n-catalog-validation.mjs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import ts from "typescript";
|
||||||
|
|
||||||
|
export function findDuplicateTranslationKeys(sourceText, fileName = "generatedTranslations.ts") {
|
||||||
|
const source = ts.createSourceFile(
|
||||||
|
fileName,
|
||||||
|
sourceText,
|
||||||
|
ts.ScriptTarget.Latest,
|
||||||
|
true,
|
||||||
|
ts.ScriptKind.TS
|
||||||
|
);
|
||||||
|
const findings = [];
|
||||||
|
|
||||||
|
function visit(node) {
|
||||||
|
if (
|
||||||
|
ts.isVariableDeclaration(node) &&
|
||||||
|
ts.isIdentifier(node.name) &&
|
||||||
|
node.name.text === "generatedTranslations" &&
|
||||||
|
node.initializer &&
|
||||||
|
ts.isObjectLiteralExpression(node.initializer)
|
||||||
|
) {
|
||||||
|
scanCatalog(node.initializer);
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanCatalog(catalog) {
|
||||||
|
for (const localeProperty of catalog.properties) {
|
||||||
|
if (!ts.isPropertyAssignment(localeProperty) || !ts.isObjectLiteralExpression(localeProperty.initializer)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const locale = propertyName(localeProperty.name);
|
||||||
|
const firstDeclarations = new Map();
|
||||||
|
for (const translationProperty of localeProperty.initializer.properties) {
|
||||||
|
if (!ts.isPropertyAssignment(translationProperty)) continue;
|
||||||
|
const key = propertyName(translationProperty.name);
|
||||||
|
if (!key) continue;
|
||||||
|
const first = firstDeclarations.get(key);
|
||||||
|
if (!first) {
|
||||||
|
firstDeclarations.set(key, translationProperty.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const duplicatePosition = source.getLineAndCharacterOfPosition(
|
||||||
|
translationProperty.name.getStart(source)
|
||||||
|
);
|
||||||
|
const firstPosition = source.getLineAndCharacterOfPosition(first.getStart(source));
|
||||||
|
findings.push(
|
||||||
|
`${fileName}:${duplicatePosition.line + 1}:${duplicatePosition.character + 1} ` +
|
||||||
|
`duplicate generated translation key ${JSON.stringify(key)} in locale ` +
|
||||||
|
`${JSON.stringify(locale)}; first declared at line ${firstPosition.line + 1}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visit(source);
|
||||||
|
return findings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function propertyName(name) {
|
||||||
|
if (
|
||||||
|
ts.isIdentifier(name) ||
|
||||||
|
ts.isStringLiteral(name) ||
|
||||||
|
ts.isNumericLiteral(name) ||
|
||||||
|
ts.isNoSubstitutionTemplateLiteral(name)
|
||||||
|
) {
|
||||||
|
return name.text;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
38
webui/tests/i18n-catalog-validation.test.mjs
Normal file
38
webui/tests/i18n-catalog-validation.test.mjs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { findDuplicateTranslationKeys } from "../scripts/i18n-catalog-validation.mjs";
|
||||||
|
|
||||||
|
test("allows the same translation key once in each locale", () => {
|
||||||
|
const source = `
|
||||||
|
export const generatedTranslations = {
|
||||||
|
en: { "i18n:example.share.12345678": "Share" },
|
||||||
|
de: { "i18n:example.share.12345678": "Freigeben" }
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
assert.deepEqual(findDuplicateTranslationKeys(source), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects duplicate keys within a generated locale catalog", () => {
|
||||||
|
const source = `
|
||||||
|
export const generatedTranslations = {
|
||||||
|
en: {
|
||||||
|
"i18n:example.share.12345678": "Share",
|
||||||
|
"i18n:example.share.12345678": "Share"
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
"i18n:example.share.12345678": "Freigabe",
|
||||||
|
"i18n:example.share.12345678": "Freigeben"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const findings = findDuplicateTranslationKeys(source, "fixture.ts");
|
||||||
|
|
||||||
|
assert.equal(findings.length, 2);
|
||||||
|
assert.match(findings[0], /fixture\.ts:\d+:\d+ duplicate generated translation key/);
|
||||||
|
assert.match(findings[0], /locale "en"/);
|
||||||
|
assert.match(findings[1], /locale "de"/);
|
||||||
|
assert.ok(findings.every((finding) => finding.includes("first declared at line")));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user