From 4fb42582451b6220418c7e38f6c96a0f3a24f4a3 Mon Sep 17 00:00:00 2001 From: Leonardo Mendoza Date: Wed, 29 Jul 2026 11:27:15 -0600 Subject: [PATCH] PD-5781 Fix peer review i18n in print view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The peer review heading is the only print-view string with placeholders, and it rendered truncated in every locale ("Revisión por pares ($") with both counts missing and no build diagnostic. normalize-xlf parses $localize calls out of fetch-orcid.js, since the file is a plain asset that ng extract-i18n never sees. Its placeholder converter only matched UPPERCASE names, but the source uses camelCase, so the raw `${reviewsCount}:reviewCount:` text was written verbatim into the XLF. Fixing the name pattern alone is not enough: Xliff1TranslationParser only recognises real elements, so the `{$NAME}` text form that converter aimed for parses as literal content, leaves the message with zero placeholders and truncates the translation at the first `$`. Emit proper XLIFF placeholder elements instead, carried through the xml2js Builder as sentinels because it escapes markup found in a text node. printView.* units are now always regenerated from fetch-orcid.js rather than only added when absent, so re-parsing a unit that already holds cannot degrade it, and add a build-time check that fails on leftover legacy syntax and warns when a target drops a placeholder. Locale targets are converted in place, preserving the reordering translators applied in tr and zh-TW. --- scripts/normalize-xlf.prebuild.ts | 124 ++++++++++++++++++++++++++++-- src/locale/messages.ar.xlf | 4 +- src/locale/messages.ca.xlf | 2 +- src/locale/messages.cs.xlf | 4 +- src/locale/messages.de.xlf | 4 +- src/locale/messages.es.xlf | 4 +- src/locale/messages.fr.xlf | 4 +- src/locale/messages.it.xlf | 4 +- src/locale/messages.ja.xlf | 4 +- src/locale/messages.ko.xlf | 4 +- src/locale/messages.lr.xlf | 2 +- src/locale/messages.pl_PL.xlf | 4 +- src/locale/messages.pt.xlf | 4 +- src/locale/messages.rl.xlf | 2 +- src/locale/messages.ru.xlf | 4 +- src/locale/messages.tr_TR.xlf | 4 +- src/locale/messages.uk.xlf | 2 +- src/locale/messages.xlf | 2 +- src/locale/messages.xx.xlf | 2 +- src/locale/messages.zh_CN.xlf | 4 +- src/locale/messages.zh_TW.xlf | 4 +- 21 files changed, 153 insertions(+), 39 deletions(-) diff --git a/scripts/normalize-xlf.prebuild.ts b/scripts/normalize-xlf.prebuild.ts index 470c03c7d..c9d8b964f 100644 --- a/scripts/normalize-xlf.prebuild.ts +++ b/scripts/normalize-xlf.prebuild.ts @@ -15,11 +15,35 @@ const TEST_LANGUAGES = [ // hardcoded list to drift out of sync. const PRINT_VIEW_SOURCE_FILE = './src/assets/print-view/fetch-orcid.js' +// Sentinels used to carry XLIFF placeholder elements through the xml2js +// Builder, which escapes any markup it finds in a text node. We emit the +// sentinels as plain text and swap them for real elements in formatXmlOutput(). +const PH_START = '@@XPH_START@@' +const PH_MID = '@@XPH_MID@@' +const PH_END = '@@XPH_END@@' + +// Matches a $localize template placeholder: `${expression}:NAME:`. +// NAME deliberately accepts any case: fetch-orcid.js uses camelCase names, and +// $localize matches placeholder names verbatim. +const LOCALIZE_PLACEHOLDER_RE = /\$\{([^}]*)\}:([A-Za-z0-9_]+):/g + +// Non-global twin of the above, for stateless `.test()` checks (a shared /g +// regex carries lastIndex between calls). +const HAS_LOCALIZE_PLACEHOLDER_RE = new RegExp(LOCALIZE_PLACEHOLDER_RE.source) + interface PrintViewUnit { id: string source: string } +function escapeXmlAttr(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + function parsePrintViewUnits(path: string): PrintViewUnit[] { const js = fs.readFileSync(path, 'utf8') // Matches $localize tagged templates of the form `:@@printView.someId:Source text` @@ -33,11 +57,18 @@ function parsePrintViewUnits(path: string): PrintViewUnit[] { continue } seen.add(id) - // Convert $localize template placeholders `${expression}:NAME:` into the - // canonical $localize message form `{$NAME}` so the XLF source matches the - // runtime message and stays stable regardless of the JS expression. + // Convert $localize template placeholders `${expression}:NAME:` into XLIFF + // elements (emitted as sentinels here, see formatXmlOutput). + // Plain-text forms such as `{$NAME}` do NOT work: Xliff1TranslationParser + // only recognises real elements, so a text placeholder is parsed as + // literal content and the message ends up with zero placeholders — the + // translation is then silently truncated at the first `$`. const source = normalizeText( - rawSource.replace(/\$\{[^}]*\}:([A-Z0-9_]+):/g, '{$$$1}') + rawSource.replace( + LOCALIZE_PLACEHOLDER_RE, + (_match, expression: string, name: string) => + `${PH_START}${name}${PH_MID}\${${expression}}${PH_END}` + ) ) units.push({ id, source }) } @@ -54,6 +85,13 @@ function normalizeText(input: string): string { function formatXmlOutput(xml: string): string { let out = xml + // Restore XLIFF placeholder elements that were emitted as sentinels so the + // xml2js Builder would not escape them into plain text. + out = out.replace( + new RegExp(`${PH_START}(.*?)${PH_MID}(.*?)${PH_END}`, 'g'), + (_match, name: string, equivText: string) => + `` + ) // Match tx pull header formatting (xml decl + on same line) out = out.replace( /]*>/, @@ -97,6 +135,69 @@ function generateTestingLanguages(baseData: any) { }) } +/** Extracts the ordered names inside an XLF element body. */ +function placeholderIds(fragment: string): string[] { + return Array.from(fragment.matchAll(/ m[1]) +} + +/** + * Warns when a translated printView.* target has lost one of the placeholders + * its source declares. + * + * Such a target still inlines cleanly — @angular/localize reports no error — but + * renders with the value silently dropped, e.g. a peer review count vanishing + * from the heading. That silence is what let this class of bug ship before, so + * surface it at build time. Read-only: translations are never rewritten here. + */ +function checkLocalePlaceholders(localeDir: string) { + const files = fs + .readdirSync(localeDir) + .filter((name) => name.startsWith('messages.') && name.endsWith('.xlf')) + // The generated test languages replace every target with a marker, so their + // placeholders are legitimately absent. + .filter( + (name) => + !TEST_LANGUAGES.some(({ code }) => name === `messages.${code}.xlf`) + ) + + files.forEach((name) => { + const xlf = fs.readFileSync(`${localeDir}/${name}`, 'utf8') + + // Transifex holds the form of these strings, so a pull should never + // bring back the legacy `${expression}:name:` text. If it does, the source + // there has gone stale and every locale would render the heading truncated + // at the first `$` with no other symptom — fail rather than ship that. + if (HAS_LOCALIZE_PLACEHOLDER_RE.test(xlf)) { + throw new Error( + `[normalize-xlf] ${name} contains legacy raw placeholder syntax ` + + `(\${expression}:name:). Push the current messages.xlf to Transifex ` + + `so translations come back with placeholders.` + ) + } + + Array.from( + xlf.matchAll(//g) + ).forEach(([unit, id]) => { + const target = unit.match(/]*>([\s\S]*?)<\/target>/) + if (!target) { + return + } + const expected = placeholderIds( + unit.match(/]*>([\s\S]*?)<\/source>/)?.[1] ?? '' + ) + const actual = placeholderIds(target[1]) + const missing = expected.filter((p) => !actual.includes(p)) + if (missing.length) { + console.warn( + `[normalize-xlf] ${name}: "${id}" target is missing placeholder(s) ${missing.join( + ', ' + )} — their values will be dropped from the rendered string.` + ) + } + }) + }) +} + function normalizeXlf12Sources(path: string) { const xml = fs.readFileSync(path, 'utf8') parseString(xml, (error, data) => { @@ -111,11 +212,24 @@ function normalizeXlf12Sources(path: string) { throw new Error('No trans-units found in XLF 1.2 file') } + const printViewUnits = parsePrintViewUnits(PRINT_VIEW_SOURCE_FILE) + const printViewById = new Map(printViewUnits.map((u) => [u.id, u])) + transUnits.forEach((tu) => { // Provide a stable key for Transifex (some parsers rely on resname over id/source) if (tu?.$?.id) { tu.$.resname = tu.$.id } + const generated = tu?.$?.id ? printViewById.get(tu.$.id) : undefined + if (generated) { + // fetch-orcid.js is the source of truth for printView.* units, so + // always regenerate the source rather than keeping what is on disk. + // Re-parsing a unit that already contains elements yields mixed + // content, which the Builder cannot round-trip; replacing the whole + // node with a freshly built string keeps placeholders correct. + tu.source = [generated.source] + return + } const src = tu?.source?.[0] if (typeof src === 'string') { tu.source[0] = normalizeText(src) @@ -127,7 +241,6 @@ function normalizeXlf12Sources(path: string) { // will also stamp X / LR / RL for these strings, and so Transifex can // discover them for real locales. We use a stable @@-prefixed id so // $localize can match by explicit id. - const printViewUnits = parsePrintViewUnits(PRINT_VIEW_SOURCE_FILE) const existingIds = new Set(transUnits.map((tu: any) => tu?.$?.id)) const printViewBody = fileNode?.body?.[0] printViewUnits.forEach(({ id, source }) => { @@ -145,3 +258,4 @@ function normalizeXlf12Sources(path: string) { } normalizeXlf12Sources('./src/locale/messages.xlf') +checkLocalePlaceholders('./src/locale') diff --git a/src/locale/messages.ar.xlf b/src/locale/messages.ar.xlf index 550370635..aa1bba234 100644 --- a/src/locale/messages.ar.xlf +++ b/src/locale/messages.ar.xlf @@ -21237,8 +21237,8 @@ ممول من - Peer review (${reviewsCount}:reviewCount: reviews for ${publicationsCount}:publicationCount: publications/grants) - مراجعة الأقران (${reviewsCount}:reviewCount: مراجعة لـ ${publicationsCount}:publicationCount: منشور/منحة) + Peer review ( reviews for publications/grants) + مراجعة الأقران ( مراجعة لـ منشور/منحة) Afghanistan diff --git a/src/locale/messages.ca.xlf b/src/locale/messages.ca.xlf index 4598ebb59..bc6e4e1b3 100644 --- a/src/locale/messages.ca.xlf +++ b/src/locale/messages.ca.xlf @@ -21057,7 +21057,7 @@ Funded by - Peer review (${reviewsCount}:reviewCount: reviews for ${publicationsCount}:publicationCount: publications/grants) + Peer review ( reviews for publications/grants) Afghanistan diff --git a/src/locale/messages.cs.xlf b/src/locale/messages.cs.xlf index e376f92a5..e8c8a50b1 100644 --- a/src/locale/messages.cs.xlf +++ b/src/locale/messages.cs.xlf @@ -21238,8 +21238,8 @@ Financováno - Peer review (${reviewsCount}:reviewCount: reviews for ${publicationsCount}:publicationCount: publications/grants) - Posudek oponenta (${reviewsCount}:reviewCount: posudek na ${publicationsCount}:publicationCount: publikace/granty) + Peer review ( reviews for publications/grants) + Posudek oponenta ( posudek na publikace/granty) Afghanistan diff --git a/src/locale/messages.de.xlf b/src/locale/messages.de.xlf index e83495549..fc17fc697 100644 --- a/src/locale/messages.de.xlf +++ b/src/locale/messages.de.xlf @@ -21238,8 +21238,8 @@ Finanziert durch - Peer review (${reviewsCount}:reviewCount: reviews for ${publicationsCount}:publicationCount: publications/grants) - Peer-Review (${reviewsCount}:reviewCount: Reviews für ${publicationsCount}:publicationCount: Veröffentlichungen/Förderungen) + Peer review ( reviews for publications/grants) + Peer-Review ( Reviews für Veröffentlichungen/Förderungen) Afghanistan diff --git a/src/locale/messages.es.xlf b/src/locale/messages.es.xlf index 9a6a67026..dda4737a0 100644 --- a/src/locale/messages.es.xlf +++ b/src/locale/messages.es.xlf @@ -21238,8 +21238,8 @@ Financiado por - Peer review (${reviewsCount}:reviewCount: reviews for ${publicationsCount}:publicationCount: publications/grants) - Revisión por pares (${reviewsCount}:reviewCount: revisiones de ${publicationsCount}:publicationCount: publicaciones/concesiones) + Peer review ( reviews for publications/grants) + Revisión por pares ( revisiones de publicaciones/concesiones) Afghanistan diff --git a/src/locale/messages.fr.xlf b/src/locale/messages.fr.xlf index 99706b9ed..de67999e1 100644 --- a/src/locale/messages.fr.xlf +++ b/src/locale/messages.fr.xlf @@ -21238,8 +21238,8 @@ Financé par - Peer review (${reviewsCount}:reviewCount: reviews for ${publicationsCount}:publicationCount: publications/grants) - Évaluation par les pairs (${reviewsCount}:reviewCount: évaluations pour ${publicationsCount}:publicationCount: publications/subventions) + Peer review ( reviews for publications/grants) + Évaluation par les pairs ( évaluations pour  publications/subventions) Afghanistan diff --git a/src/locale/messages.it.xlf b/src/locale/messages.it.xlf index 7f9b4460b..b292264ef 100644 --- a/src/locale/messages.it.xlf +++ b/src/locale/messages.it.xlf @@ -21238,8 +21238,8 @@ Finanziato da - Peer review (${reviewsCount}:reviewCount: reviews for ${publicationsCount}:publicationCount: publications/grants) - Revisione paritaria (${reviewsCount}:reviewCount: revisioni per ${publicationsCount}:publicationCount: pubblicazioni/borse) + Peer review ( reviews for publications/grants) + Revisione paritaria ( revisioni per pubblicazioni/borse) Afghanistan diff --git a/src/locale/messages.ja.xlf b/src/locale/messages.ja.xlf index 9e40eed6a..ff1d1dbda 100644 --- a/src/locale/messages.ja.xlf +++ b/src/locale/messages.ja.xlf @@ -21238,8 +21238,8 @@ 資金の提供: - Peer review (${reviewsCount}:reviewCount: reviews for ${publicationsCount}:publicationCount: publications/grants) - ピアレビュー (レビュー ${reviewsCount}:reviewCount: 件、出版/助成 ${publicationsCount}:publicationCount: 件) + Peer review ( reviews for publications/grants) + ピアレビュー (レビュー 件、出版/助成 件) Afghanistan diff --git a/src/locale/messages.ko.xlf b/src/locale/messages.ko.xlf index 57ab46784..3f859cf8d 100644 --- a/src/locale/messages.ko.xlf +++ b/src/locale/messages.ko.xlf @@ -21238,8 +21238,8 @@ 자금 제공 주체 - Peer review (${reviewsCount}:reviewCount: reviews for ${publicationsCount}:publicationCount: publications/grants) - 동료 검토 (${reviewsCount}:reviewCount:건의 검토 - ${publicationsCount}:publicationCount:건의 발행물/보조금) + Peer review ( reviews for publications/grants) + 동료 검토 (건의 검토 - 건의 발행물/보조금) Afghanistan diff --git a/src/locale/messages.lr.xlf b/src/locale/messages.lr.xlf index 1e783fff9..9205d4d1e 100644 --- a/src/locale/messages.lr.xlf +++ b/src/locale/messages.lr.xlf @@ -21181,7 +21181,7 @@ LR - Peer review (${reviewsCount}:reviewCount: reviews for ${publicationsCount}:publicationCount: publications/grants) + Peer review ( reviews for publications/grants) LR diff --git a/src/locale/messages.pl_PL.xlf b/src/locale/messages.pl_PL.xlf index aa14f7b52..1d182fafc 100644 --- a/src/locale/messages.pl_PL.xlf +++ b/src/locale/messages.pl_PL.xlf @@ -21238,8 +21238,8 @@ Finansowane przez - Peer review (${reviewsCount}:reviewCount: reviews for ${publicationsCount}:publicationCount: publications/grants) - Recenzja naukowa (recenzje (${reviewsCount}:reviewCount:) dla następującej liczby publikacji/grantów: ${publicationsCount}:publicationCount:) + Peer review ( reviews for publications/grants) + Recenzja naukowa (recenzje () dla następującej liczby publikacji/grantów: ) Afghanistan diff --git a/src/locale/messages.pt.xlf b/src/locale/messages.pt.xlf index d77a27685..a5127f6c8 100644 --- a/src/locale/messages.pt.xlf +++ b/src/locale/messages.pt.xlf @@ -21238,8 +21238,8 @@ Financiado por - Peer review (${reviewsCount}:reviewCount: reviews for ${publicationsCount}:publicationCount: publications/grants) - Revisão por pares (${reviewsCount}:reviewCount: revisões para ${publicationsCount}:publicationCount: publicações/bolsas) + Peer review ( reviews for publications/grants) + Revisão por pares ( revisões para publicações/bolsas) Afghanistan diff --git a/src/locale/messages.rl.xlf b/src/locale/messages.rl.xlf index 1056912de..82655d229 100644 --- a/src/locale/messages.rl.xlf +++ b/src/locale/messages.rl.xlf @@ -21181,7 +21181,7 @@ RL - Peer review (${reviewsCount}:reviewCount: reviews for ${publicationsCount}:publicationCount: publications/grants) + Peer review ( reviews for publications/grants) RL diff --git a/src/locale/messages.ru.xlf b/src/locale/messages.ru.xlf index ea1584d03..5544bc905 100644 --- a/src/locale/messages.ru.xlf +++ b/src/locale/messages.ru.xlf @@ -21238,8 +21238,8 @@ Источник финансирования - Peer review (${reviewsCount}:reviewCount: reviews for ${publicationsCount}:publicationCount: publications/grants) - Рецензии (${reviewsCount}:reviewCount: для стольких публикаций/грантов: ${publicationsCount}:publicationCount:) + Peer review ( reviews for publications/grants) + Рецензии ( для стольких публикаций/грантов: ) Afghanistan diff --git a/src/locale/messages.tr_TR.xlf b/src/locale/messages.tr_TR.xlf index 6fd8eb110..a7f138751 100644 --- a/src/locale/messages.tr_TR.xlf +++ b/src/locale/messages.tr_TR.xlf @@ -21238,8 +21238,8 @@ Finanse eden: - Peer review (${reviewsCount}:reviewCount: reviews for ${publicationsCount}:publicationCount: publications/grants) - Meslektaş değerlendirmesi (${publicationsCount}:publicationCount: yayın/hibe için ${reviewsCount}:reviewCount: değerlendirme) + Peer review ( reviews for publications/grants) + Meslektaş değerlendirmesi ( yayın/hibe için değerlendirme) Afghanistan diff --git a/src/locale/messages.uk.xlf b/src/locale/messages.uk.xlf index 3e23e32f8..3f539de51 100644 --- a/src/locale/messages.uk.xlf +++ b/src/locale/messages.uk.xlf @@ -21057,7 +21057,7 @@ Funded by - Peer review (${reviewsCount}:reviewCount: reviews for ${publicationsCount}:publicationCount: publications/grants) + Peer review ( reviews for publications/grants) Afghanistan diff --git a/src/locale/messages.xlf b/src/locale/messages.xlf index c52de5427..25705ba45 100644 --- a/src/locale/messages.xlf +++ b/src/locale/messages.xlf @@ -18915,7 +18915,7 @@ Funded by - Peer review (${reviewsCount}:reviewCount: reviews for ${publicationsCount}:publicationCount: publications/grants) + Peer review ( reviews for publications/grants) Afghanistan diff --git a/src/locale/messages.xx.xlf b/src/locale/messages.xx.xlf index 160040f5c..824686bb6 100644 --- a/src/locale/messages.xx.xlf +++ b/src/locale/messages.xx.xlf @@ -21181,7 +21181,7 @@ X - Peer review (${reviewsCount}:reviewCount: reviews for ${publicationsCount}:publicationCount: publications/grants) + Peer review ( reviews for publications/grants) X diff --git a/src/locale/messages.zh_CN.xlf b/src/locale/messages.zh_CN.xlf index 1b728c6e5..70793eefd 100644 --- a/src/locale/messages.zh_CN.xlf +++ b/src/locale/messages.zh_CN.xlf @@ -21238,8 +21238,8 @@ 资助方 - Peer review (${reviewsCount}:reviewCount: reviews for ${publicationsCount}:publicationCount: publications/grants) - 同级审查(${reviewsCount}:reviewCount: 份出版物/补助金的${publicationsCount}:publicationCount: 个审查) + Peer review ( reviews for publications/grants) + 同级审查( 份出版物/补助金的 个审查) Afghanistan diff --git a/src/locale/messages.zh_TW.xlf b/src/locale/messages.zh_TW.xlf index c6e9127bf..697c19137 100644 --- a/src/locale/messages.zh_TW.xlf +++ b/src/locale/messages.zh_TW.xlf @@ -21238,8 +21238,8 @@ 資助人 - Peer review (${reviewsCount}:reviewCount: reviews for ${publicationsCount}:publicationCount: publications/grants) - 同儕審查 (${publicationsCount}:publicationCount: 份出版物/補助金的 ${reviewsCount}:reviewCount: 個審查) + Peer review ( reviews for publications/grants) + 同儕審查 ( 份出版物/補助金的 個審查) Afghanistan