doxa-maps/internal/check-plural-forms.mjs
Doxa Maps c54bde307f 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.
2026-09-28 23:23:56 +00:00

96 lines
4.9 KiB
JavaScript

/**
* 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.`)