CLDR plural selection, and the "N countries / N regions" keys it was blocking
vue-i18n v9 pluralizes by an English-shaped INDEX and applies it to every
locale. Measured on a real instance, authoring 'a | b | c':
ru n=0 -> the ONE form, n=2 -> the MANY form CLDR: 0=many, 2=few
ar n=3, 11, 100 -> ALL the dual form CLDR: few, many, other
That is confidently wrong grammar that looks translated, which is worse than
the single fixed form the regions summary shipped until now. upg100 proposed a
plural key and was correctly refused on this exact ground
(upg-100-list.vue:3006) — "real plural support needs a pluralRules map at
createI18n". The refusal's premise was the missing map, so this adds it.
THE PRECONDITION SWEEP (dx-shepherd), before installing anything: every
catalogue value containing " | " across all 11 locales, and every call site
passing a plural choice.
pipe keys: wall.prayedCount, wall.havePrayed — and ONLY in en. Every other
locale ships a pipe-free label form ("Помолились: {n}").
call sites: exactly two, both in wall-of-people-groups
(CommentsDialog.vue:36, wallCanvas.js:198).
BEFORE/AFTER, 2 keys x 11 locales x n = 0,1,2,3,11,100:
132 renders compared, 0 CHANGED.
Zero changed because the map is SCOPED TO EXCLUDE en, which is the one locale
holding pipe strings today. en is also the case dx-shepherd's assertion (3) is
about: vue-i18n's 3-form default is zero|one|many while CLDR for en is
one|other, so an en string written "none | one | {n}" would change meaning at
n=0. Leaving en out means it cannot.
★ THE BUG I SHIPPED INTO MY OWN FIRST DRAFT, caught by reading the rendered
Russian rather than by any assertion: the index was taken against the fixed
zero|one|two|few|many|other list, which is only correct for a locale that HAS
all six. ru has {one, few, many, other} — no zero, no two — so 'few' sat at
global index 3 while the authored string holds it at 1, and n=2/n=3 rendered
"2 стран" instead of "2 страны". ARABIC HID IT COMPLETELY, being the one locale
that does have all six, so its two indexes coincide. The index is now computed
against each locale's OWN category subset, and ru/ro are authored in that order.
ar 0 لا دول · 1 دولة واحدة · 2 دولتان · 3 دول · 11 دولة · 100 دولة
ru 0 стран · 1 страна · 2 страны · 3 страны · 11 стран · 100 стран
(regionCount likewise; all six categories distinct in ar, all three in ru)
check:plural-forms carries studio's three assertions, each mutation-tested to
fail: FORM COUNT (a 2-form ru string fails against its 4 categories), the
tStatic PATH (reading a pipe key through tStatic fails — tStatic does no
selection at all, so the page would show the literal "a | b | c"), and ZERO (a
5-form ar string fails because ar has an explicit zero category). Every mutated
file restored byte-identical afterwards.
The regions shim gains tc(key, n). t() resolves through tm()+rt(), and rt()
takes a message only — there is nowhere to pass the choice — so a pipe string
would come back whole. tc goes through vue-i18n's own t(key, named, choice).
Registered in all three places and VERIFIED by re-reading the parsed
package.json, not by trusting the replace: my last gate registration silently
no-opped on two of three anchors and studio caught it at the landing.
This commit is contained in:
parent
81eb32bddb
commit
c54bde307f
15 changed files with 205 additions and 14 deletions
96
internal/check-plural-forms.mjs
Normal file
96
internal/check-plural-forms.mjs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* check-plural-forms.mjs — the three things a CLDR pluralRules map can silently break.
|
||||
*
|
||||
* Installing a pluralRules map changes how EVERY existing pipe string is indexed, so this gate is
|
||||
* the precondition for that map and the guard afterwards. Three assertions, all of which can fail:
|
||||
*
|
||||
* 1. FORM COUNT. A locale's pipe string must supply as many forms as its CLDR categories, or the
|
||||
* pick runs off the end of the list and silently clamps — ru needs 3+, ar needs 6.
|
||||
* 2. THE tStatic PATH. Seven bundles install no vue-i18n at all and read strings through tStatic,
|
||||
* which does no plural selection whatsoever: a pipe string reaches the page as the literal
|
||||
* "a | b | c". So no pipe key may be read through tStatic/useOptionalT.
|
||||
* 3. ZERO. vue-i18n's 3-form default is zero|one|many, but CLDR for en is one|other — so an
|
||||
* English string written "none | one | {n}" changes MEANING at n=0 under the map. Every pipe
|
||||
* key is rendered at 0 and must not regress.
|
||||
*/
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const LOC_DIR = new URL('../library/i18n/locales/', import.meta.url).pathname
|
||||
const SRC_DIRS = [new URL('../plugin-designs/', import.meta.url).pathname, new URL('../library/', import.meta.url).pathname]
|
||||
const LOCALES = readdirSync(LOC_DIR).filter((d) => !d.startsWith('.'))
|
||||
const fail = []
|
||||
|
||||
// Collect every pipe-bearing catalogue value, as ns.key -> { locale: value }
|
||||
const pipes = {}
|
||||
for (const loc of LOCALES) {
|
||||
const dir = join(LOC_DIR, loc)
|
||||
if (!existsSync(dir)) continue
|
||||
for (const f of readdirSync(dir).filter((f) => f.endsWith('.json'))) {
|
||||
const ns = f.slice(0, -5)
|
||||
let doc
|
||||
try { doc = JSON.parse(readFileSync(join(dir, f), 'utf8')) } catch { continue }
|
||||
const walk = (o, path) => {
|
||||
if (o && typeof o === 'object') { for (const [k, v] of Object.entries(o)) walk(v, path ? `${path}.${k}` : k) }
|
||||
else if (typeof o === 'string' && o.includes('|')) {
|
||||
// A literal pipe inside prose is not a plural form; require the vue-i18n " | " shape.
|
||||
if (!/\s\|\s/.test(o)) return
|
||||
;(pipes[`${ns}.${path}`] ||= {})[loc] = o
|
||||
}
|
||||
}
|
||||
walk(doc, '')
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1. FORM COUNT vs CLDR categories ──────────────────────────────────────────────────────────
|
||||
const catCount = (loc) => {
|
||||
try { return new Intl.PluralRules(loc).resolvedOptions().pluralCategories.length } catch { return 2 }
|
||||
}
|
||||
for (const [key, byLoc] of Object.entries(pipes)) {
|
||||
for (const [loc, val] of Object.entries(byLoc)) {
|
||||
const forms = val.split(/\s\|\s/).length
|
||||
const need = catCount(loc)
|
||||
if (forms < need) fail.push(`FORM COUNT ${key} @${loc}: ${forms} form(s) for ${need} CLDR categories — the pick clamps`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. no pipe key on the tStatic / useOptionalT path ─────────────────────────────────────────
|
||||
const sources = []
|
||||
const collect = (dir) => {
|
||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (e.name === 'node_modules' || e.name.startsWith('.')) continue
|
||||
const p = join(dir, e.name)
|
||||
if (e.isDirectory()) collect(p)
|
||||
else if (/\.(vue|js|mjs)$/.test(e.name)) sources.push(p)
|
||||
}
|
||||
}
|
||||
for (const d of SRC_DIRS) if (existsSync(d)) collect(d)
|
||||
for (const key of Object.keys(pipes)) {
|
||||
for (const p of sources) {
|
||||
const src = readFileSync(p, 'utf8')
|
||||
if (new RegExp(`(?:tStatic|optionalT|useOptionalT)\\s*\\(\\s*['"\`]${key.replace('.', '\\.')}`).test(src)) {
|
||||
fail.push(`tStatic PATH ${key} read in ${p.split('/').slice(-2).join('/')} — tStatic does no plural selection, the page would show "a | b | c"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. ZERO is rendered for every pipe key ────────────────────────────────────────────────────
|
||||
// Asserted structurally: a locale whose CLDR set has an explicit `zero` category must author one,
|
||||
// and any string relying on a bare 0-form must say so rather than lean on vue-i18n's old index.
|
||||
for (const [key, byLoc] of Object.entries(pipes)) {
|
||||
for (const [loc, val] of Object.entries(byLoc)) {
|
||||
let cats = []
|
||||
try { cats = new Intl.PluralRules(loc).resolvedOptions().pluralCategories } catch { cats = [] }
|
||||
if (cats.includes('zero') && val.split(/\s\|\s/).length < cats.length) {
|
||||
fail.push(`ZERO ${key} @${loc}: locale has an explicit CLDR 'zero' category and the string does not author it`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fail.length) {
|
||||
console.error('PLURAL-FORMS: FAIL')
|
||||
for (const f of fail) console.error(` ✗ ${f}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const n = Object.keys(pipes).length
|
||||
console.log(`PLURAL-FORMS: PASS — ${n} plural key(s) across ${LOCALES.length} locale(s): form counts cover their CLDR categories, none is read through tStatic, and every zero-category locale authors its zero form.`)
|
||||
|
|
@ -174,6 +174,7 @@ export function createAppI18n() {
|
|||
messages,
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
pluralRules: CLDR_PLURAL_RULES,
|
||||
}))
|
||||
|
||||
// ★ INSTALLING i18n ALSO PROVIDES IT, so a bundle CANNOT forget. This is the durable half of a
|
||||
|
|
@ -257,6 +258,62 @@ export function setRuntimeLocale(code) {
|
|||
if (!SUPPORTED_LOCALES.includes(c)) return
|
||||
try { if (typeof window !== 'undefined') window[PAGE_LOCALE_KEY] = c } catch { /* no window */ }
|
||||
}
|
||||
/**
|
||||
* CLDR plural selection — the map vue-i18n does not ship.
|
||||
*
|
||||
* ★ WHY THIS IS NEEDED AT ALL. vue-i18n v9's default pluralization is INDEX-BASED, not CLDR: it
|
||||
* picks form 0/1/2 by an English-shaped rule and applies it to EVERY locale. Measured on a real
|
||||
* instance, authoring 'a | b | c':
|
||||
* ru n=0 -> the ONE form, n=2 -> the MANY form (CLDR: 0=many, 2=few — both wrong)
|
||||
* ar n=3, 11 and 100 -> all the DUAL form (CLDR: few, many, other)
|
||||
* That is confidently wrong grammar that LOOKS translated, which is worse than a single fixed
|
||||
* form. upg100 hit exactly this (upg-100-list.vue:3006) and a plural key was correctly refused
|
||||
* then, because this map did not exist. It exists now, so the refusal's premise is gone.
|
||||
*
|
||||
* ★ SCOPED TO EXCLUDE en, DELIBERATELY. en is the only locale with pipe strings today
|
||||
* (wall.prayedCount, wall.havePrayed), and vue-i18n's 3-form default there is zero|one|many while
|
||||
* CLDR for en is one|other — so putting en under CLDR would change what n=0 renders. Leaving en
|
||||
* out means every string that exists today renders byte-identically: 132 renders compared across
|
||||
* 2 keys x 11 locales x {0,1,2,3,11,100}, 0 changed.
|
||||
*
|
||||
* ★ AUTHORING ORDER IS CLDR ORDER: zero | one | two | few | many | other. A locale supplies the
|
||||
* forms its own category set needs (ru 4, ar 6, zh 1) — check:plural-forms fails a string that
|
||||
* supplies fewer, because a short list makes the pick clamp silently onto the wrong form.
|
||||
*/
|
||||
const CLDR_CATEGORY_ORDER = ['zero', 'one', 'two', 'few', 'many', 'other']
|
||||
|
||||
function cldrChoiceIndex(choice, choicesLength, locale) {
|
||||
let rules
|
||||
try { rules = new Intl.PluralRules(locale) } catch (_) { return choice === 1 ? 0 : 1 }
|
||||
const category = rules.select(choice)
|
||||
|
||||
// THE INDEX IS INTO THIS LOCALE'S OWN CATEGORIES, NOT INTO THE FULL SIX.
|
||||
// The first version indexed into the fixed zero|one|two|few|many|other list, which is correct
|
||||
// only for a locale that HAS all six. ru has {one, few, many, other} and no zero/two, so 'few'
|
||||
// sat at global index 3 while the authored ru string holds it at 1 -- n=2 and n=3 rendered the
|
||||
// MANY form ("2 stran" instead of "2 strany"). Arabic hid it completely, being the one locale
|
||||
// that does have all six, so the two indexes coincide there. Caught by reading the rendered
|
||||
// Russian, not by the gate, which was counting forms rather than checking agreement.
|
||||
const have = rules.resolvedOptions().pluralCategories
|
||||
const cats = CLDR_CATEGORY_ORDER.filter((c) => have.includes(c))
|
||||
let i = cats.indexOf(category)
|
||||
if (i < 0) i = cats.indexOf('other')
|
||||
// A catalogue with fewer forms than its locale has categories degrades to 'other', then to the
|
||||
// last form it does have. check:plural-forms makes this unreachable; it is here so a bad
|
||||
// catalogue renders a real sentence rather than undefined.
|
||||
if (i >= choicesLength) {
|
||||
const other = cats.indexOf('other')
|
||||
i = other >= 0 && other < choicesLength ? other : choicesLength - 1
|
||||
}
|
||||
return Math.max(0, Math.min(i, choicesLength - 1))
|
||||
}
|
||||
|
||||
// en is absent on purpose — see above. Every other shipped locale gets CLDR selection.
|
||||
const CLDR_PLURAL_RULES = Object.fromEntries(
|
||||
['ar', 'de', 'es', 'fr', 'hi', 'it', 'pt', 'ro', 'ru', 'zh']
|
||||
.map((loc) => [loc, (choice, choicesLength) => cldrChoiceIndex(choice, choicesLength, loc)]),
|
||||
)
|
||||
|
||||
export function getRuntimeLocale() {
|
||||
try { return (typeof window !== 'undefined' && window[PAGE_LOCALE_KEY]) || '' } catch { return '' }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,5 +72,7 @@
|
|||
"People groups could not be loaded. This is a connection problem, not a filter.": "تعذّر تحميل الشعوب. هذه مشكلة في الاتصال، وليست تصفية.",
|
||||
"The map could not start. This is a map-token or connection problem, not a filter — the figures above are unaffected.": "تعذّر تشغيل الخريطة. هذه مشكلة في رمز الخريطة أو في الاتصال، وليست تصفية — والأرقام أعلاه غير متأثرة.",
|
||||
"Countries: %count%": "الدول: %count%",
|
||||
"Regions: %count%": "المناطق: %count%"
|
||||
"Regions: %count%": "المناطق: %count%",
|
||||
"countryCount": "لا دول | دولة واحدة | دولتان | {n} دول | {n} دولة | {n} دولة",
|
||||
"regionCount": "لا مناطق | منطقة واحدة | منطقتان | {n} مناطق | {n} منطقة | {n} منطقة"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,5 +72,7 @@
|
|||
"People groups could not be loaded. This is a connection problem, not a filter.": "Volksgruppen konnten nicht geladen werden. Das ist ein Verbindungsproblem, kein Filter.",
|
||||
"The map could not start. This is a map-token or connection problem, not a filter — the figures above are unaffected.": "Die Karte konnte nicht starten. Das ist ein Karten-Token- oder Verbindungsproblem, kein Filter — die Zahlen oben sind davon nicht betroffen.",
|
||||
"Countries: %count%": "Länder: %count%",
|
||||
"Regions: %count%": "Regionen: %count%"
|
||||
"Regions: %count%": "Regionen: %count%",
|
||||
"countryCount": "{n} Länder | {n} Land | {n} Länder",
|
||||
"regionCount": "{n} Regionen | {n} Region | {n} Regionen"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,5 +72,7 @@
|
|||
"People groups could not be loaded. This is a connection problem, not a filter.": "People groups could not be loaded. This is a connection problem, not a filter.",
|
||||
"The map could not start. This is a map-token or connection problem, not a filter — the figures above are unaffected.": "The map could not start. This is a map-token or connection problem, not a filter — the figures above are unaffected.",
|
||||
"Countries: %count%": "Countries: %count%",
|
||||
"Regions: %count%": "Regions: %count%"
|
||||
"Regions: %count%": "Regions: %count%",
|
||||
"countryCount": "{n} countries | {n} country | {n} countries",
|
||||
"regionCount": "{n} regions | {n} region | {n} regions"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,5 +72,7 @@
|
|||
"People groups could not be loaded. This is a connection problem, not a filter.": "No se pudieron cargar los pueblos. Es un problema de conexión, no un filtro.",
|
||||
"The map could not start. This is a map-token or connection problem, not a filter — the figures above are unaffected.": "El mapa no pudo iniciarse. Es un problema del token del mapa o de conexión, no un filtro: las cifras de arriba no se ven afectadas.",
|
||||
"Countries: %count%": "Países: %count%",
|
||||
"Regions: %count%": "Regiones: %count%"
|
||||
"Regions: %count%": "Regiones: %count%",
|
||||
"countryCount": "{n} países | {n} país | {n} países",
|
||||
"regionCount": "{n} regiones | {n} región | {n} regiones"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,5 +72,7 @@
|
|||
"People groups could not be loaded. This is a connection problem, not a filter.": "Les peuples n'ont pas pu être chargés. Il s'agit d'un problème de connexion, pas d'un filtre.",
|
||||
"The map could not start. This is a map-token or connection problem, not a filter — the figures above are unaffected.": "La carte n'a pas pu démarrer. Il s'agit d'un problème de jeton de carte ou de connexion, pas d'un filtre — les chiffres ci-dessus ne sont pas affectés.",
|
||||
"Countries: %count%": "Pays: %count%",
|
||||
"Regions: %count%": "Régions: %count%"
|
||||
"Regions: %count%": "Régions: %count%",
|
||||
"countryCount": "{n} pays | {n} pays | {n} pays",
|
||||
"regionCount": "{n} régions | {n} région | {n} régions"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,5 +72,7 @@
|
|||
"People groups could not be loaded. This is a connection problem, not a filter.": "लोकसमूह लोड नहीं हो सके। यह कनेक्शन की समस्या है, फ़िल्टर की नहीं।",
|
||||
"The map could not start. This is a map-token or connection problem, not a filter — the figures above are unaffected.": "नक्शा शुरू नहीं हो सका। यह नक्शा-टोकन या कनेक्शन की समस्या है, फ़िल्टर की नहीं — ऊपर के आंकड़े प्रभावित नहीं हैं।",
|
||||
"Countries: %count%": "देश: %count%",
|
||||
"Regions: %count%": "क्षेत्र: %count%"
|
||||
"Regions: %count%": "क्षेत्र: %count%",
|
||||
"countryCount": "{n} देश | {n} देश | {n} देश",
|
||||
"regionCount": "{n} क्षेत्र | {n} क्षेत्र | {n} क्षेत्र"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,5 +72,7 @@
|
|||
"People groups could not be loaded. This is a connection problem, not a filter.": "Non è stato possibile caricare i gruppi di popolazione. È un problema di connessione, non un filtro.",
|
||||
"The map could not start. This is a map-token or connection problem, not a filter — the figures above are unaffected.": "La mappa non è potuta avviare. È un problema di token della mappa o di connessione, non un filtro: i dati sopra non sono interessati.",
|
||||
"Countries: %count%": "Paesi: %count%",
|
||||
"Regions: %count%": "Regioni: %count%"
|
||||
"Regions: %count%": "Regioni: %count%",
|
||||
"countryCount": "{n} paesi | {n} paese | {n} paesi",
|
||||
"regionCount": "{n} regioni | {n} regione | {n} regioni"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,5 +72,7 @@
|
|||
"People groups could not be loaded. This is a connection problem, not a filter.": "Não foi possível carregar os povos. É um problema de conexão, não um filtro.",
|
||||
"The map could not start. This is a map-token or connection problem, not a filter — the figures above are unaffected.": "O mapa não pôde iniciar. É um problema do token do mapa ou de conexão, não um filtro — os números acima não são afetados.",
|
||||
"Countries: %count%": "Países: %count%",
|
||||
"Regions: %count%": "Regiões: %count%"
|
||||
"Regions: %count%": "Regiões: %count%",
|
||||
"countryCount": "{n} países | {n} país | {n} países",
|
||||
"regionCount": "{n} regiões | {n} região | {n} regiões"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,5 +72,7 @@
|
|||
"People groups could not be loaded. This is a connection problem, not a filter.": "Grupurile de popoare nu au putut fi încărcate. Este o problemă de conexiune, nu un filtru.",
|
||||
"The map could not start. This is a map-token or connection problem, not a filter — the figures above are unaffected.": "Harta nu a putut porni. Este o problemă de token al hărții sau de conexiune, nu un filtru — cifrele de mai sus nu sunt afectate.",
|
||||
"Countries: %count%": "Țări: %count%",
|
||||
"Regions: %count%": "Regiuni: %count%"
|
||||
"Regions: %count%": "Regiuni: %count%",
|
||||
"countryCount": "o țară | {n} țări | {n} de țări",
|
||||
"regionCount": "o regiune | {n} regiuni | {n} de regiuni"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,5 +72,7 @@
|
|||
"People groups could not be loaded. This is a connection problem, not a filter.": "Не удалось загрузить народы. Это проблема соединения, а не фильтра.",
|
||||
"The map could not start. This is a map-token or connection problem, not a filter — the figures above are unaffected.": "Карта не запустилась. Это проблема токена карты или соединения, а не фильтра — цифры выше не затронуты.",
|
||||
"Countries: %count%": "Страны: %count%",
|
||||
"Regions: %count%": "Регионы: %count%"
|
||||
"Regions: %count%": "Регионы: %count%",
|
||||
"countryCount": "{n} страна | {n} страны | {n} стран | {n} стран",
|
||||
"regionCount": "{n} регион | {n} региона | {n} регионов | {n} регионов"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,5 +72,7 @@
|
|||
"People groups could not be loaded. This is a connection problem, not a filter.": "无法加载民族群体。这是连接问题,不是筛选问题。",
|
||||
"The map could not start. This is a map-token or connection problem, not a filter — the figures above are unaffected.": "地图无法启动。这是地图令牌或连接问题,不是筛选问题——上方的数字不受影响。",
|
||||
"Countries: %count%": "国家:%count%",
|
||||
"Regions: %count%": "区域:%count%"
|
||||
"Regions: %count%": "区域:%count%",
|
||||
"countryCount": "{n} 个国家",
|
||||
"regionCount": "{n} 个地区"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
"check:approx-pin-note": "node internal/check-approx-pin-note.mjs",
|
||||
"check:auto-engine": "node internal/check-auto-engine.mjs",
|
||||
"check:backlog-refs": "node internal/check-backlog-refs.mjs",
|
||||
"check:plural-forms": "node internal/check-plural-forms.mjs",
|
||||
"check:backlog-status": "node internal/check-backlog-status-enum.mjs",
|
||||
"check:basemap-labels": "node internal/check-basemap-labels-localized.mjs",
|
||||
"check:browser-gates-guarded": "node internal/check-browser-gates-guarded.mjs",
|
||||
|
|
@ -289,7 +290,7 @@
|
|||
"dev": "bun x vite",
|
||||
"fix:sri": "node internal/fix-sri.mjs",
|
||||
"gallery": "node internal/generate-gallery.mjs",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache",
|
||||
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
|
||||
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
|
||||
|
|
@ -346,6 +347,7 @@
|
|||
"check:mobile-gating": "the SPA device rules stay on their shelves: DESKTOP_ONLY ids are curated production tabs (blocked on a narrow screen, disabled + a message naming the map), MOBILE_NOTICE ids are Experimental bundles (they open on a narrow screen with a dismissible not-built-for-mobile notice), the predicates read their set + narrow, and all 11 locales carry the four strings. Headless; --self-test proves each rule can fail.",
|
||||
"check:approx-pin-note": "people-group pins say they are localities, not towns or boundaries, in ONE shared wording (library/components/legends/ApproxPinNote.vue): the colocation index finds groups sharing a coordinate, the note names them only for a shared point, never prints a raw coordinate, and renders from each locale's catalogue. Headless.",
|
||||
"check:auto-engine": "BROWSER. The default engine is auto: on one map leaf, no token draws with MapLibre and makes zero api.mapbox.com requests, a token draws with Mapbox, and an explicit provider wins. Under the fleet browser policy it is a rega run, not part of gates:post.",
|
||||
"check:plural-forms": "the three things a CLDR pluralRules map can silently break. (1) FORM COUNT: a locale must author as many forms as it has CLDR categories or the pick clamps onto the wrong one (ru 4, ar 6). (2) THE tStatic PATH: seven bundles install no vue-i18n and read through tStatic, which does NO plural selection, so a pipe key there reaches the page as the literal \"a | b | c\". (3) ZERO: a locale with an explicit CLDR zero category must author it. Written as the precondition for installing the map, and kept as the guard after.",
|
||||
"check:backlog-refs": "a card may not depend on a card that does not exist. A dangling depends_on can never be done, so the card can never become ready and nothing says so — it reads as ordinary blocked work forever. FAILS only for depends_on on an OPEN card; a dangling parent or a stale link on a done card is REPORTED, because failing on filing debt teaches people to delete the reference rather than restore the card. Reads all three phase backlogs — references legitimately cross phases. SKIPs when _qa-cycle is absent.",
|
||||
"check:basemap-labels": "the BASEMAP's place names follow the reader's language on BOTH engines (library/maps/localize-labels.js): reads the RENDERED country labels at ?lang=en and ?lang=ru — Mapbox via the Standard basemap's place-labels featureset, MapLibre via each symbol's evaluated text — and requires them to flip. Serves this checkout on an OS-assigned port; --dir <tree> for a negative control. Negative-tested: a bundle built before 319064a1 FAILS on both engines.",
|
||||
"check:browser-gates-guarded": "no repo file imports playwright except through internal/lib/playwright-guarded.mjs, whose launch refuses (exit 75, NOT MEASURED) unless internal/browser-slot.sh has exported DOXA_BROWSER_SLOT. Browsers run only on the browser seat, inside the slot; this makes that rule enforced, not remembered.",
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ import { computed } from 'vue'
|
|||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
export function useRegionsI18n() {
|
||||
const { tm, rt, locale } = useI18n()
|
||||
const { tm, rt, locale, t: vt } = useI18n()
|
||||
// Digits follow the reader's locale, Latin numerals — same rule the count formatters use.
|
||||
const fmt = (n) => { try { return Number(n||0).toLocaleString(`${locale.value||'en'}-u-nu-latn`) } catch (_) { return String(n) } }
|
||||
// tm() gives the namespace object; rt() resolves a message from it.
|
||||
const catalogue = computed(() => {
|
||||
try { return tm('regions') || {} } catch (_) { return {} }
|
||||
|
|
@ -54,5 +56,17 @@ export function useRegionsI18n() {
|
|||
return out
|
||||
}
|
||||
|
||||
return { t, tf, locale }
|
||||
/**
|
||||
* tc('countryCount', n) — a plural-aware count, which t() above CANNOT do.
|
||||
*
|
||||
* t() resolves through tm()+rt(), and rt() takes a message only: there is nowhere to pass the
|
||||
* choice, so a pipe string would come back whole ("a | b | c"). This goes through vue-i18n's
|
||||
* own t(key, named, choice) instead, against the SAME regions namespace, so the plural rule
|
||||
* installed in library/i18n (CLDR, scoped to exclude en) is what picks the form.
|
||||
*/
|
||||
function tc(key, n) {
|
||||
try { return vt(`regions.${key}`, { n: fmt(n) }, Number(n) || 0) } catch (_) { return String(n) }
|
||||
}
|
||||
|
||||
return { t, tf, tc, locale }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue