diff --git a/.github/workflows/verify-app.yml b/.github/workflows/verify-app.yml index 9e3b485fc9..925405b9eb 100644 --- a/.github/workflows/verify-app.yml +++ b/.github/workflows/verify-app.yml @@ -42,6 +42,21 @@ jobs: - name: Lint run: yarn linter:check + i18n: + runs-on: ubuntu-latest + needs: install + if: "!contains(github.event.head_commit.message, '[skip ci]')" + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + cache: '' + + - uses: dhis2/action-safe-yarn-install-with-cache@v1 + + - name: Verify custom terminology in i18n/en.pot + run: yarn i18n:verify + typescript: runs-on: ubuntu-latest if: "!contains(github.event.head_commit.message, '[skip ci]')" @@ -160,7 +175,7 @@ jobs: build: runs-on: ubuntu-latest - needs: [lint, typescript, unit-tests] + needs: [lint, typescript, unit-tests, i18n] if: "!contains(github.event.head_commit.message, '[skip ci]')" steps: - uses: actions/checkout@v6 diff --git a/.husky/pre-push b/.husky/pre-push index 2333da579d..0769f91379 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1 +1 @@ -yarn linter:check && yarn tsc:check +yarn linter:check && yarn tsc:check && yarn i18n:verify diff --git a/i18n/en.pot b/i18n/en.pot index db44c9dd8d..9ee346033a 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-17T14:05:20.397Z\n" -"PO-Revision-Date: 2026-09-17T14:05:20.397Z\n" +"POT-Creation-Date: 2026-09-17T14:08:27.014Z\n" +"PO-Revision-Date: 2026-09-17T14:08:27.014Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/package.json b/package.json index 5248f5d4e8..ddc2984748 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "verifyCacheVersion": "node scripts/verifyCacheVersion.js", "postinstall": "husky && patch-package && node scripts/createSymlinkToInternalPackages.mjs", "i18n:add": "d2-app-scripts i18n extract && git add ./i18n/", + "i18n:verify": "d2-app-scripts i18n extract && node scripts/verifyCustomTerminology.mjs", "clean": "./scripts/clean.sh && yarn workspaces run clean" }, "devDependencies": { diff --git a/scripts/verifyCustomTerminology.mjs b/scripts/verifyCustomTerminology.mjs new file mode 100644 index 0000000000..f8dae484cd --- /dev/null +++ b/scripts/verifyCustomTerminology.mjs @@ -0,0 +1,131 @@ +/* + * Verifies that i18n/en.pot contains no msgid where custom terminology + * appears outside a {{...}}-template placeholder. + * + * Custom terms (enrollment, event, program stage, note, relationship, attribute, + * organisation unit, follow-up) needs to be inside {{...}}-template placeholder + * to be overridden per program at runtime. + + * Runs with `yarn i18n:verify` + */ +/* eslint-disable no-console */ + +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const POT = fileURLToPath(new URL('../i18n/en.pot', import.meta.url)); + +const CUSTOM_TERMS = [ + { words: ['enrollment', 'enrolment'], suggestion: '{{enrollmentLabel}}' }, + { words: ['enrollments', 'enrolments'], suggestion: '{{enrollmentsLabel}}' }, + { words: ['event'], suggestion: '{{eventLabel}}' }, + { words: ['events'], suggestion: '{{eventsLabel}}' }, + { words: ['program stage'], suggestion: '{{programStageLabel}}' }, + { words: ['program stages'], suggestion: '{{programStagesLabel}}' }, + { words: ['note'], suggestion: '{{noteLabel}}' }, + { words: ['notes'], suggestion: '{{notesLabel}}' }, + { words: ['relationship'], suggestion: '{{relationshipLabel}}' }, + { words: ['relationships'], suggestion: '{{relationshipsLabel}}' }, + { words: ['attribute'], suggestion: '{{attributeLabel}}' }, + { words: ['attributes'], suggestion: '{{attributesLabel}}' }, + { words: ['organisation unit', 'org unit', 'organization unit', 'registering unit'], suggestion: '{{orgUnitLabel}}' }, + { words: ['follow-up', 'followup', 'follow up'], suggestion: '{{followUpLabel}}' }, +]; + +const FALLBACKS = new Set([ + 'enrollment', 'enrollments', + 'event', 'events', + 'program stage', 'program stages', + 'note', 'notes', + 'relationship', 'relationships', + 'attribute', 'attributes', + 'organisation unit', 'follow-up', +]); + +const ALLOWLIST = new Set([ + // "event program" = DHIS2 programType, not user's event terminology + 'This is not an event program or the metadata is corrupt. See log for details.', + // "event program" = DHIS2 programType, not user's event terminology + '{{programName}} is an event program and does not have {{enrollmentsLabel}}.', + // NoSelectionsInfoBox — only renders when no program is selected, so no program-specific label to use + 'Choose a program and organisation unit to see existing data and create new records.', + // ProgramList — program picker shows generic label rather than any single program's custom label + 'Some programs are being filtered by the chosen organisation unit', +]); + +function extractStrings(potContents) { + const header = /^(msgid|msgstr(?:\[\d+\])?) "(.*)"$/; + const continuation = /^"(.*)"$/; + + const entries = []; + potContents.split('\n').forEach((line, idx) => { + const h = line.match(header); + if (h) { + entries.push({ kind: h[1], value: h[2], line: idx + 1 }); + return; + } + const c = line.match(continuation); + if (c && entries.length) entries.at(-1).value += c[1]; + }); + + let currentMsgid = null; + return entries.flatMap(({ kind, value, line }) => { + if (kind === 'msgid') currentMsgid = value; + return value && currentMsgid ? [{ value, line, msgid: currentMsgid, kind }] : []; + }); +} + +function findViolations(msgid) { + const stripped = msgid.replace(/\{\{[^{}]*\}\}/g, ''); + const hits = []; + for (const { words, suggestion } of CUSTOM_TERMS) { + for (const word of words) { + const re = new RegExp(String.raw`\b${word}\b`, 'i'); + if (re.test(stripped)) { + hits.push({ word, suggestion }); + break; + } + } + } + return hits; +} + +const DIVIDER = '━'.repeat(72); + +function reportViolations(violations) { + const relPot = path.relative(process.cwd(), POT); + console.error(`\n${DIVIDER}\n`); + for (const v of violations) { + console.error(` ${relPot}:${v.line}`); + console.error(` msgid: "${v.msgid}"`); + if (v.value !== v.msgid) console.error(` ${v.kind.padEnd(11)} "${v.value}"`); + for (const h of v.hits) { + console.error(` ✗ "${h.word}" → use ${h.suggestion}`); + } + console.error(''); + } + console.error(`\x1b[1;31m${violations.length} custom-terminology violation(s) in en.pot.\x1b[0m\n`); + console.error('Fix by wrapping the offending word in a custom-terminology template.\n'); + console.error('If a hit is a genuine exception, add the exact msgid to the ALLOWLIST'); + console.error('in scripts/verifyCustomTerminology.mjs.'); + console.error(`\n${DIVIDER}\n`); +} + +function main() { + const strings = extractStrings(readFileSync(POT, 'utf8')); + const violations = strings + .filter(({ msgid, value }) => !ALLOWLIST.has(msgid) && !FALLBACKS.has(value)) + .map(({ value, line, msgid, kind }) => ({ msgid, value, line, kind, hits: findViolations(value) })) + .filter(({ hits }) => hits.length > 0); + + if (violations.length === 0) { + console.log('i18n:verify — no custom-terminology violations in en.pot'); + return; + } + + reportViolations(violations); + process.exit(1); +} + +main();