diff --git a/plugin-designs/my-upg-100-list/poster-wall.vue b/plugin-designs/my-upg-100-list/poster-wall.vue index 2879c81e7..92e20aa25 100644 --- a/plugin-designs/my-upg-100-list/poster-wall.vue +++ b/plugin-designs/my-upg-100-list/poster-wall.vue @@ -192,7 +192,46 @@ async function fetchListRecords(list) { // Product call taken by dx-shepherd (UPGHK, 2026-09-27) under the Driver's standing goal that the // production maps be localized. const t = useOptionalT('') -const intro = computed(() => t('messages.upg100WallIntro')) + +// ★ THE MARKUP IS COMPOSED HERE, so the CATALOGUE holds plain text (dx-shepherd UPGHTML, 2026-09-27). +// vue-i18n warns "[intlify] Detected HTML in '…'" twice per tab pass, and it is right to: a translated +// string carrying is the only one in this tree that does — I measured every en catalogue area +// and upg100WallIntro is 1 of 1, so this was not a convention, it was my outlier. +// +// ⚠ WHY THE OBVIOUS FIXES DO NOT APPLY HERE. The shepherd's suggestion — markup in the template, or +// interpolation — assumes this profile renders the sentence. It does not: `intro` is a STRING +// PROP on the library PosterWall, rendered there with v-html (PosterWall.vue:955), and that component +// has no slots at all. So the markup has to arrive inside the string, and the only question is who +// builds the string. Building it HERE keeps the catalogue clean without a library change. +// +// ★ AND IT IS STRICTLY SAFER THAN WHAT IT REPLACES, which is the part worth keeping. v-html on a whole +// catalogue string TRUSTS THE CATALOGUE: any tag a translator (or a bad merge) puts in any of the 11 +// locale files renders as markup. Composing here escapes each part and emits exactly one tag I control, +// so the trusted surface shrinks from "every translation of this key" to "this line". +// +// THREE PARTS, NOT A WRAPPER AROUND ONE, because the emphasis is not in the same place in every +// language. Measured across all 11 locales: each has exactly one and no other tags, and de, hi +// and zh carry real text AFTER the emphasised run (" beten.", " प्रार्थना करते हुए।", "為他們禱告。") +// where the others end on a full stop. A head+emphasis wrapper would have silently dropped those tails. +const escHtml = (s) => String(s) + .replace(/&/g, '&').replace(//g, '>') +const intro = computed(() => { + // The three plain-text keys studio adds (rule 5B — the catalogue is theirs). Until they land, the + // single legacy key is still the source of truth, so this renders identically before AND after: no + // flag day, and my landing does not have to be ordered against theirs. + const a = t('messages.upg100WallIntroA') + const b = t('messages.upg100WallIntroEmph') + const c = t('messages.upg100WallIntroB') + const missing = (v, key) => !v || v === key + if (missing(a, 'messages.upg100WallIntroA') || missing(b, 'messages.upg100WallIntroEmph')) { + // ⚠ THE LEGACY KEY STILL CONTAINS THE MARKUP, so it is passed through unescaped exactly as before. + // Escaping it here would print a literal "" to the reader — a visible regression traded for + // a console warning, which is the wrong way round. + return t('messages.upg100WallIntro') + } + const tail = missing(c, 'messages.upg100WallIntroB') ? '' : escHtml(c) + return `${escHtml(a)}${escHtml(b)}${tail}` +}) const heading = computed(() => t('messages.upg100WallHeading')) const wallTag = computed(() => t('messages.upg100WallTag'))