diff --git a/webui/package.json b/webui/package.json index 31b41a3..de1f37d 100644 --- a/webui/package.json +++ b/webui/package.json @@ -22,9 +22,11 @@ }, "scripts": { "dev": "vite --host 127.0.0.1 --port 5173", + "prebuild": "npm run audit:i18n-structural", "build": "tsc && vite build", "preview": "vite preview --host 127.0.0.1 --port 4173", "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: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", diff --git a/webui/scripts/audit-i18n-structural.mjs b/webui/scripts/audit-i18n-structural.mjs index d4e7f14..c0e7cd7 100644 --- a/webui/scripts/audit-i18n-structural.mjs +++ b/webui/scripts/audit-i18n-structural.mjs @@ -4,6 +4,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import vm from "node:vm"; import ts from "typescript"; +import { findDuplicateTranslationKeys } from "./i18n-catalog-validation.mjs"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const webuiDir = path.resolve(scriptDir, ".."); @@ -74,6 +75,8 @@ function scanStructuralSourcePositions(roots) { function scanGeneratedCatalogs(files) { const findings = []; for (const file of files) { + const source = fs.readFileSync(file, "utf8"); + findings.push(...findDuplicateTranslationKeys(source, file)); const translations = loadGeneratedTranslations(file); for (const [language, dictionary] of Object.entries(translations)) { for (const [key, value] of Object.entries(dictionary)) { diff --git a/webui/scripts/i18n-catalog-validation.mjs b/webui/scripts/i18n-catalog-validation.mjs new file mode 100644 index 0000000..d531387 --- /dev/null +++ b/webui/scripts/i18n-catalog-validation.mjs @@ -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 ""; +} diff --git a/webui/tests/i18n-catalog-validation.test.mjs b/webui/tests/i18n-catalog-validation.test.mjs new file mode 100644 index 0000000..59f66de --- /dev/null +++ b/webui/tests/i18n-catalog-validation.test.mjs @@ -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"))); +});