@cat-factory/app 0.217.1 → 0.218.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { allowedFormCounts, pluralRules, type OverriddenLocale } from './plural-rules'
3
+
4
+ // The slot order each overridden locale's CLDR categories fill, matching `plural-rules.ts`.
5
+ // Naming them as CLDR category strings is what lets the tests below compare against
6
+ // `Intl.PluralRules`, the platform's own copy of the same CLDR data: a hand-written rule that
7
+ // drifts from ICU (because CLDR revised the locale, or because the rule was wrong to begin
8
+ // with) fails here instead of quietly rendering the wrong form.
9
+ const CATEGORY_ORDER: Record<OverriddenLocale, readonly Intl.LDMLPluralRule[]> = {
10
+ pl: ['one', 'few', 'many'],
11
+ uk: ['one', 'few', 'many'],
12
+ he: ['one', 'two', 'other'],
13
+ }
14
+
15
+ const LOCALES = Object.keys(CATEGORY_ORDER) as OverriddenLocale[]
16
+
17
+ /** The CLDR category a selector resolves `n` to, for an entry carrying only the CLDR forms. */
18
+ function categoryFor(locale: OverriddenLocale, n: number): Intl.LDMLPluralRule {
19
+ const order = CATEGORY_ORDER[locale]
20
+ return order[pluralRules[locale](n, order.length)]!
21
+ }
22
+
23
+ describe('plural selectors agree with Intl.PluralRules', () => {
24
+ // Every whole count a UI badge can plausibly show. The Slavic rules key off n % 100, and
25
+ // Hebrew's off n itself, so 0..1000 exercises both far past their period.
26
+ it.each(LOCALES)('%s over whole counts 0..1000', (locale) => {
27
+ const icu = new Intl.PluralRules(locale)
28
+ const disagreements = Array.from({ length: 1001 }, (_, n) => n)
29
+ .map((n) => ({ n, ours: categoryFor(locale, n), icu: icu.select(n) }))
30
+ .filter(({ ours, icu: theirs }) => ours !== theirs)
31
+ expect(disagreements).toEqual([])
32
+ })
33
+
34
+ // Hebrew is exact over fractions too. The Slavic locales are deliberately not: CLDR routes
35
+ // their fractions to an `other` category the 3-form catalogs carry no slot for, so the rule
36
+ // folds those onto `many` (documented in `plural-rules.ts`) and cannot be compared here.
37
+ it('he over fractional counts', () => {
38
+ const icu = new Intl.PluralRules('he')
39
+ const fractions = [0.1, 0.5, 0.9, 1.1, 1.5, 2.5, 3.5, 10.5, 20.5, 100.5]
40
+ for (const n of fractions) expect(categoryFor('he', n)).toBe(icu.select(n))
41
+ })
42
+
43
+ it('treats a negative count as its magnitude', () => {
44
+ for (const locale of LOCALES) {
45
+ for (const n of [1, 2, 3, 5, 22]) {
46
+ expect(pluralRules[locale](-n, 3)).toBe(pluralRules[locale](n, 3))
47
+ }
48
+ }
49
+ })
50
+ })
51
+
52
+ describe('the leading zero form', () => {
53
+ // One optional slot may precede the CLDR forms: a copy nicety ("no participants"), NOT a
54
+ // CLDR category. An entry that carries it shifts every other slot by one, which is why the
55
+ // form count is part of the contract rather than an authoring detail.
56
+ it('is selected only for 0, and only when the entry carries it', () => {
57
+ for (const locale of LOCALES) {
58
+ const [cldrOnly, withZero] = allowedFormCounts(locale) as [number, number]
59
+ expect(pluralRules[locale](0, withZero)).toBe(0)
60
+ // Without a zero slot, 0 falls to whatever category the locale puts it in, never to
61
+ // the `one` form.
62
+ expect(pluralRules[locale](0, cldrOnly)).not.toBe(0)
63
+ for (const n of [1, 2, 3, 5, 11, 22]) {
64
+ expect(pluralRules[locale](n, withZero)).toBe(pluralRules[locale](n, cldrOnly) + 1)
65
+ }
66
+ }
67
+ })
68
+ })
69
+
70
+ describe('an entry with too few forms', () => {
71
+ // A short entry is a CI failure (`scripts/i18n-plural-forms.mjs`). At RUNTIME the selector
72
+ // still has to answer with an in-range index: vue-i18n indexes the form array raw and throws
73
+ // out of `t()` on an out-of-range answer, which blanks the whole surface rendering it rather
74
+ // than degrading to an approximate form.
75
+ it('clamps to the last form instead of running off the end', () => {
76
+ for (const locale of LOCALES) {
77
+ for (const forms of [1, 2]) {
78
+ for (let n = 0; n <= 200; n++) {
79
+ const index = pluralRules[locale](n, forms)
80
+ expect(index).toBeGreaterThanOrEqual(0)
81
+ expect(index).toBeLessThan(forms)
82
+ }
83
+ }
84
+ }
85
+ })
86
+ })
87
+
88
+ describe('hebrew', () => {
89
+ // The behaviour this module exists to add: 2 is its own form, where the default 2-form
90
+ // selector lumped it in with the plural.
91
+ it('gives 2 a form of its own', () => {
92
+ const forms = ['one', 'two', 'other']
93
+ expect(forms[pluralRules.he(1, 3)]).toBe('one')
94
+ expect(forms[pluralRules.he(2, 3)]).toBe('two')
95
+ expect(forms[pluralRules.he(3, 3)]).toBe('other')
96
+ expect(forms[pluralRules.he(20, 3)]).toBe('other')
97
+ })
98
+ })
@@ -0,0 +1,123 @@
1
+ // Per-locale plural SELECTORS for vue-i18n: given a count and how many forms a catalog entry
2
+ // carries, return the index of the form to render. Wired onto `pluralRules` in `i18n.config.ts`;
3
+ // this module is deliberately free of Nuxt/vue-i18n imports so it unit-tests as pure logic
4
+ // (`plural-rules.spec.ts`).
5
+ //
6
+ // vue-i18n's BUILT-IN selector implements neither Slavic nor Semitic agreement: for a 2-form
7
+ // entry it picks index 0 when n === 1 and index 1 otherwise, and for a 3-form entry it picks
8
+ // 0/1/2 for n === 0 / n === 1 / n > 1. That is right for `en`/`es`/`fr`/`de`/`it`/`ja`/`tr`
9
+ // (which are therefore NOT listed here) and wrong everywhere below.
10
+ //
11
+ // ## The slot contract a catalog entry declares by its FORM COUNT
12
+ //
13
+ // A locale's CLDR categories fill the trailing slots, in the order named by `CLDR_CATEGORIES`
14
+ // below. One optional slot may precede them: a ZERO form, which is a COPY nicety rather than a
15
+ // CLDR category ("no participants" reads better than "0 participants") and which `en` already
16
+ // uses. So for a locale with 3 CLDR categories:
17
+ //
18
+ // 3 forms -> <cat0> | <cat1> | <cat2>
19
+ // 4 forms -> zero | <cat0> | <cat1> | <cat2>
20
+ //
21
+ // The count is therefore load-bearing: dropping a form does not degrade the message, it
22
+ // RE-POINTS every remaining slot onto a different count. `scripts/i18n-plural-forms.mjs` fails
23
+ // CI on an entry whose form count is not one of the two shapes, because nothing else can catch
24
+ // it (a short entry renders confidently and wrongly, and vue-i18n throws outright when a
25
+ // selector returns an index past the end).
26
+
27
+ /** A vue-i18n plural selector: `(count, formsInThisEntry) => index of the form to render`. */
28
+ export type PluralSelector = (choice: number, choicesLength: number) => number
29
+
30
+ // Polish and Ukrainian share the `few` bucket but NOT the `one` bucket, and one rule serving
31
+ // both is what this pair of functions replaced: Polish reserves `one` for exactly 1, while
32
+ // Ukrainian gives it to every count ending in 1 except the teens, so 21/31/…/101 were rendering
33
+ // the `many` form ("21 репозиторіїв" for "21 репозиторій") on 89 of the first 1000 counts.
34
+ const slavicFew = (mod10: number, mod100: number): boolean =>
35
+ mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)
36
+
37
+ /**
38
+ * Polish one/few/many, e.g. "decyzja | decyzje | decyzji".
39
+ *
40
+ * CLDR also gives Polish an `other` category, reached only by fractional counts. The catalogs
41
+ * carry no slot for it and a count here is always a whole number of things, so a fraction
42
+ * collapses onto `many`, which is the form Polish uses for a decimal anyway ("2,5 decyzji").
43
+ * The same holds for Ukrainian below.
44
+ */
45
+ const polishCategory = (n: number): number => {
46
+ if (n === 1) return 0 // one
47
+ if (slavicFew(n % 10, n % 100)) return 1 // few
48
+ return 2 // many (incl. 0, 5-21, ...)
49
+ }
50
+
51
+ /** Ukrainian one/few/many, e.g. "рішення | рішення | рішень". */
52
+ const ukrainianCategory = (n: number): number => {
53
+ const mod10 = n % 10
54
+ const mod100 = n % 100
55
+ if (mod10 === 1 && mod100 !== 11) return 0 // one (1, 21, 31, ..., 101, ...)
56
+ if (slavicFew(mod10, mod100)) return 1 // few
57
+ return 2 // many (incl. 0, 5-20, ...)
58
+ }
59
+
60
+ /**
61
+ * Hebrew one/two/other (CLDR `he`): a distinct DUAL, which is why running `he` on the default
62
+ * 2-form selector made every count message an approximation. n === 2 takes its own form, both
63
+ * for the lexical duals ("יומיים" for two days, "פעמיים" for twice) and for the spelled-out
64
+ * numeral ordinary prose wants ("שתי משימות" rather than "2 משימות").
65
+ *
66
+ * Fractions follow CLDR too: a count below 1 is `one` (0.5 -> "one"), a fractional count at or
67
+ * above 1 is `other`. `plural-rules.spec.ts` asserts the whole domain against `Intl.PluralRules`,
68
+ * so an ICU/CLDR revision to Hebrew fails a test rather than silently disagreeing with the
69
+ * platform's own formatter.
70
+ *
71
+ * Note the CLDR rule has THREE categories, not the four (one/two/many/other) it carried before
72
+ * the `many` bucket for round tens was retired: modern Hebrew does not inflect for it, so asking
73
+ * a translator to author that form would only produce a duplicate of `other`.
74
+ */
75
+ const hebrewCategory = (n: number): number => {
76
+ const integerPart = Math.floor(n)
77
+ if (n !== integerPart) return integerPart === 0 ? 0 : 2 // one below 1, otherwise other
78
+ if (integerPart === 1) return 0 // one
79
+ if (integerPart === 2) return 1 // two
80
+ return 2 // other (incl. 0)
81
+ }
82
+
83
+ /** How many CLDR categories each overridden locale's rule resolves, in slot order. */
84
+ const CLDR_CATEGORIES = {
85
+ pl: { count: 3, category: polishCategory },
86
+ uk: { count: 3, category: ukrainianCategory },
87
+ he: { count: 3, category: hebrewCategory },
88
+ } as const
89
+
90
+ /** The locales whose plural selector is overridden, i.e. the ones the form-count guard covers. */
91
+ export type OverriddenLocale = keyof typeof CLDR_CATEGORIES
92
+
93
+ /**
94
+ * The form counts a catalog entry may carry in `locale`: the CLDR categories alone, or those
95
+ * preceded by the optional zero form. Exported so the CI guard and this module agree on the
96
+ * contract by construction instead of by two copies of the same numbers.
97
+ */
98
+ export function allowedFormCounts(locale: OverriddenLocale): readonly number[] {
99
+ const { count } = CLDR_CATEGORIES[locale]
100
+ return [count, count + 1]
101
+ }
102
+
103
+ function selectorFor(locale: OverriddenLocale): PluralSelector {
104
+ const { count: cldrForms, category } = CLDR_CATEGORIES[locale]
105
+ return (choice, choicesLength) => {
106
+ const n = Math.abs(choice)
107
+ // More forms than the locale has categories means the entry leads with a zero form.
108
+ const hasZeroForm = choicesLength > cldrForms
109
+ if (hasZeroForm && n === 0) return 0
110
+ const index = (hasZeroForm ? 1 : 0) + category(n)
111
+ // An entry with too FEW forms is a CI failure, not a runtime one: clamping renders the
112
+ // nearest form instead of handing vue-i18n an out-of-range index, which it rejects by
113
+ // throwing out of `t()` and blanking whatever was rendering the message.
114
+ return Math.min(index, choicesLength - 1)
115
+ }
116
+ }
117
+
118
+ /** vue-i18n's `pluralRules` map: only the locales the built-in selector gets wrong. */
119
+ export const pluralRules: Record<OverriddenLocale, PluralSelector> = {
120
+ pl: selectorFor('pl'),
121
+ uk: selectorFor('uk'),
122
+ he: selectorFor('he'),
123
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.217.1",
3
+ "version": "0.218.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,7 +40,7 @@
40
40
  "valibot": "^1.4.2",
41
41
  "vue": "3.5.40",
42
42
  "wretch": "^3.0.9",
43
- "@cat-factory/contracts": "0.230.1"
43
+ "@cat-factory/contracts": "0.231.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",