Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fae84f7
feat: add script to verify custom terminology in i18n files
henrikmv Sep 3, 2026
c467348
fix: update suggestions for plural custom labels in terminology verif…
henrikmv Sep 3, 2026
92404d8
Merge branch 'hv/feat/DHIS2-21878_plural-custom-terminology-note-rela…
henrikmv Sep 3, 2026
d68ff83
fix: sonar qube
henrikmv Sep 3, 2026
6d3fff4
feat: add i18n verification step to CI and pre push
henrikmv Sep 3, 2026
1b7db8d
Merge branch 'hv/feat/DHIS2-21878_plural-custom-terminology-note-rela…
henrikmv Sep 3, 2026
5f01004
fix: sonar qube
henrikmv Sep 3, 2026
11c1a89
fix: (review) devin comments
henrikmv Sep 4, 2026
7a64066
Merge branch 'hv/feat/DHIS2-21878_plural-custom-terminology-note-rela…
henrikmv Sep 4, 2026
e412d62
Merge branch 'hv/feat/DHIS2-21878_plural-custom-terminology-note-rela…
henrikmv Sep 4, 2026
6b8a7f0
Merge branch 'hv/feat/DHIS2-21878_plural-custom-terminology-note-rela…
henrikmv Sep 4, 2026
fe44d3a
Merge remote-tracking branch 'origin/hv/feat/DHIS2-21635_display-cust…
henrikmv Sep 5, 2026
ea12524
Merge remote-tracking branch 'origin/hv/feat/DHIS2-21635_display-cust…
henrikmv Sep 7, 2026
bba852b
feat: update custom terminology checks for tracked entity attributes …
henrikmv Sep 7, 2026
ccbb92e
Merge branch 'hv/feat/DHIS2-21635_display-custom-teminology' into hv/…
henrikmv Sep 10, 2026
0ba1d13
Merge remote-tracking branch 'origin/hv/feat/DHIS2-21635_display-cust…
henrikmv Sep 10, 2026
3e6d486
fix: update allow list
henrikmv Sep 10, 2026
7dd5dbf
fix: simplify error message in terminology verification
henrikmv Sep 10, 2026
f54aa00
fix: improve msgid extraction logic in terminology verification
henrikmv Sep 16, 2026
2efdd28
fix: (review) remove sorting in terminology verification
henrikmv Sep 16, 2026
58d9cf9
fix: (SonarQube) use modern syntax for accessing last entry in msgid …
henrikmv Sep 16, 2026
c63e67b
Merge branch 'hv/feat/DHIS2-21635_display-custom-teminology' into hv/…
henrikmv Sep 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .github/workflows/verify-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]')"
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .husky/pre-push
Original file line number Diff line number Diff line change
@@ -1 +1 @@
yarn linter:check && yarn tsc:check
yarn linter:check && yarn tsc:check && yarn i18n:verify
4 changes: 2 additions & 2 deletions i18n/en.pot
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
131 changes: 131 additions & 0 deletions scripts/verifyCustomTerminology.mjs
Original file line number Diff line number Diff line change
@@ -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+\])?) "(.*)"$/;
Comment thread
simonadomnisoru marked this conversation as resolved.
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();
Loading