@ultimat3/i18n 1.2.0 → 2.0.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.
package/CLAUDE.md ADDED
@@ -0,0 +1,64 @@
1
+ # @ultimat3/i18n — agent notes
2
+
3
+ **Tier 1.** May import `@ultimat3/core`, `@ultimat3/schema`. Nothing else, no external deps.
4
+ Imported by every package that renders a string.
5
+
6
+ ## Boundary
7
+
8
+ | File | Single responsibility |
9
+ |---|---|
10
+ | `translator.ts` | lookup + delegate interpolation, and `TranslationKey`. No locale logic. |
11
+ | `catalog.ts` | nested → flat, merge, validate. No lookup. |
12
+ | `define-catalogs.ts` | the app's one registration call. Boot only, composes the four below. |
13
+ | `interpolate.ts` | `{var}` + CLDR plural selection. No catalog access (takes a `has` predicate). |
14
+ | `locales.ts` | supported set, normalize, negotiate, RTL. No catalogs. |
15
+ | `context.ts` | request locale via ALS, the registry, the ambient `t` and `useI18n`. |
16
+ | `extract.ts` | static scan + audit for `x verify`. |
17
+ | `catalogs/en.json` | data, not code. |
18
+
19
+ ## Rules
20
+
21
+ - An app registers catalogs with `defineCatalogs({ default, locales })`. One call, at boot.
22
+ `registerCatalog` / `configureLocales` are its internals — an app calling them is a second path.
23
+ - Reading: `t('ns.key')` for one string, `useI18n<AppCatalog>()` where the keys must be typed.
24
+ There is no `currentTranslator`; `useI18n` replaced it.
25
+ - A miss renders `⟦key⟧`. Never add a fallback locale chain — it hides gaps.
26
+ - Only an **own** property of `vars` is a variable — `interpolate` guards with `Object.hasOwn`.
27
+ A plain object inherits `constructor`, `toString`, `valueOf` and `__proto__`, so a bare
28
+ `vars[name]` rendered a function's source into the page for a template nobody wrote a variable
29
+ for. Same reach `catalog.ts` shuts off by nesting into null-prototype nodes; never reintroduce
30
+ either. The `interpolate` fast path must test **both** braces: `}}` un-escapes with no `{` in
31
+ sight, and a `{`-only check gave one escape two meanings.
32
+ - Plural selection is `Intl.PluralRules`. Never `count === 1`. Variants are underscore suffixes on
33
+ the leaf — a CLDR category (`_zero _one _two _few _many _other`), or `n` / `n_plural` as the
34
+ two-form shortcut; pair `n_one` with `n_other`, never with `n_plural`. Never a nested
35
+ `{ one, other }` branch — the runtime probes the variant leaf (`n_other`), and `TranslationKey`
36
+ admits the stem `n` on exactly that basis.
37
+ - `Translator<TCatalog = Catalog>` must keep defaulting to `string` keys: `@ultimat3/ui` and
38
+ `@ultimat3/mail` take a bare `Translator`. `has()` and `raw()` stay `string` — they are probes.
39
+ - Nothing here formats a number, date or money. That is `@ultimat3/money` / `@ultimat3/time`.
40
+ - **The ambient locale IS `Ctx.locale`**, core's own declared field — this package writes no context
41
+ field of its own and publishes no writer. `createContext({ locale })` and
42
+ `withChildContext({ locale })` are the only ways in, `currentLocale()` the only way out.
43
+ `attachLocale`/`localeOf` existed until 1.3.0 with zero callers; `@ultimat3/time` had the same
44
+ pair over a field name (`ctx['timeZone']`) that disagreed with core's `tz`, which is what made
45
+ every server-rendered date UTC. Never reintroduce either half.
46
+ - Adding a framework string: `catalogs/en.json`, feature-namespaced, then use `t('ns.key')`.
47
+ - **`catalogs/en.json` is gated in both directions**, on `x verify`'s `boundaries` step via
48
+ `scripts/i18n-catalog.ts`. A `t('literal')` in `packages/*/src` with no entry is
49
+ `X_CATALOG_MISSING_KEYS`; an entry nothing in framework source names, in a namespace the
50
+ framework already renders (`admin.*`, `dev.*`, `ui.*`), is `X_CATALOG_KEY_UNREACHABLE`. The
51
+ namespaces the framework only *ships* for an app (`common.*`, `auth.*`, `errors.*`,
52
+ `pagination.*`, `validation.*`, `time.*`) are exempt from the second half and derived, not
53
+ listed. `ui.*` is pinned exactly by `packages/ui/src/i18n-keys.test.ts`, because every ui string
54
+ resolves as `ui.t(UI_KEYS.x)` and no static scan can follow that.
55
+ - A name is a **leaf or a branch, never both** — `parseNestedCatalog` refuses a dot inside a key,
56
+ so `admin.detail.not-found` and `admin.detail.not-found.fix` cannot coexist. Both keys shipped
57
+ and neither was in the catalog; the leaf is now `…not-found.cause`.
58
+
59
+ ## Commands
60
+
61
+ ```
62
+ bun test packages/i18n
63
+ bun run --filter @ultimat3/i18n typecheck
64
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/i18n",
3
- "version": "1.2.0",
3
+ "version": "2.0.0",
4
4
  "description": "Dependency-free translator, catalog flattening, locale negotiation and loud missing-key rendering",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "CLAUDE.md",
22
23
  "README.md",
23
24
  "LICENSE"
24
25
  ],
@@ -30,6 +31,6 @@
30
31
  "test": "bun test"
31
32
  },
32
33
  "dependencies": {
33
- "@ultimat3/core": "1.2.0"
34
+ "@ultimat3/core": "2.0.0"
34
35
  }
35
36
  }
@@ -124,71 +124,180 @@
124
124
  "noMore": "That's everything."
125
125
  },
126
126
  "admin": {
127
- "nav": {
128
- "dashboard": "Dashboard",
129
- "entities": "Data",
130
- "actions": "Actions",
131
- "jobs": "Jobs",
132
- "tasks": "Schedules",
133
- "queries": "Queries",
134
- "policies": "Policies",
135
- "users": "Users",
136
- "settings": "Settings",
137
- "logs": "Logs",
138
- "traces": "Traces",
139
- "migrations": "Migrations"
127
+ "a11y": {
128
+ "skip-to-content": "Skip to content"
129
+ },
130
+ "actions": {
131
+ "label": "Actions",
132
+ "confirm": {
133
+ "title": "Confirm before this runs",
134
+ "body": "Type {token} to confirm. Nothing happens until it matches.",
135
+ "label": "Confirmation",
136
+ "submit": "Run it"
137
+ }
138
+ },
139
+ "ai": {
140
+ "disabled": "AI panes are switched off for this admin.",
141
+ "no-runner": "No AI runner is wired, so this pane has nothing to ask.",
142
+ "anomaly": {
143
+ "title": "Anomalies",
144
+ "description": "Rows that look unlike the rest of the table."
145
+ },
146
+ "nl-query": {
147
+ "title": "Ask the data",
148
+ "description": "A question in plain language, answered as a read-only query."
149
+ },
150
+ "forecast": {
151
+ "title": "Forecast",
152
+ "description": "Where this series is heading, from the rows already stored."
153
+ }
154
+ },
155
+ "audit": {
156
+ "title": "Audit trail",
157
+ "empty": "Nothing has been done to this row yet.",
158
+ "outcome": {
159
+ "allowed": "Allowed",
160
+ "denied": "Denied",
161
+ "failed": "Failed"
162
+ }
163
+ },
164
+ "brand": {
165
+ "name": "Admin"
140
166
  },
141
167
  "dashboard": {
142
- "title": "Dashboard",
143
- "health": "Health",
144
- "throughput": "Requests per minute",
145
- "errorRate": "Error rate",
146
- "latency": "p95 latency",
147
- "queueDepth": "Queue depth",
148
- "activeUsers": "Active users",
149
- "since": "since {when}"
168
+ "title": "Dashboard"
169
+ },
170
+ "denied": {
171
+ "body": "This account is missing {permission} ({reason})."
172
+ },
173
+ "detail": {
174
+ "edit": "Edit",
175
+ "loading": "Loading…",
176
+ "not-found": {
177
+ "cause": "No {entity} with that id.",
178
+ "fix": "Go back to the list and open a row that still exists."
179
+ }
180
+ },
181
+ "error": {
182
+ "action-failed": "The action threw before it finished.",
183
+ "confirmation-required": "This is destructive: echo the confirmation token first.",
184
+ "invalid-input": "Some fields did not pass the entity's own schema."
185
+ },
186
+ "form": {
187
+ "cancel": "Cancel",
188
+ "create": "New {entity}",
189
+ "edit": "Edit {entity}",
190
+ "issues": "Check the highlighted fields",
191
+ "save": "Save",
192
+ "saving": "Saving…"
193
+ },
194
+ "group": {
195
+ "data": "Data"
150
196
  },
151
197
  "jobs": {
152
- "title": "Jobs",
153
- "queued": "Queued",
154
- "running": "Running",
155
- "succeeded": "Succeeded",
156
- "failed": "Failed",
157
- "retrying": "Retrying",
158
- "cancelled": "Cancelled",
159
- "attempt": "Attempt {attempt} of {attempts}",
160
- "nextRun": "Next run {when}",
161
- "retryNow": "Retry now",
162
- "cancel": "Cancel job",
163
- "step": "Step {name}",
164
- "pending": "{count} job waiting",
165
- "pending_plural": "{count} jobs waiting"
166
- },
167
- "tasks": {
168
- "title": "Schedules",
169
- "cron": "Cron",
170
- "timezone": "Timezone",
171
- "lastRun": "Last run",
172
- "nextRun": "Next run",
173
- "runNow": "Run now",
174
- "pause": "Pause",
175
- "resume": "Resume"
176
- },
177
- "entities": {
178
- "title": "Data",
179
- "rows": "{count} row",
180
- "rows_plural": "{count} rows",
181
- "newRecord": "New {entity}",
182
- "deleteRecord": "Delete this {entity}?",
183
- "noResults": "No {entity} matched that filter."
184
- },
185
- "table": {
186
- "sortAscending": "Sort ascending",
187
- "sortDescending": "Sort descending",
188
- "columns": "Columns",
189
- "selected": "{count} selected",
190
- "selectAll": "Select all",
191
- "clearSelection": "Clear selection"
198
+ "title": "Jobs"
199
+ },
200
+ "list": {
201
+ "empty": "Nothing here yet.",
202
+ "loading": "Loading…",
203
+ "next": "Next",
204
+ "open": "Open",
205
+ "pagination": "Pagination",
206
+ "previous": "Previous"
207
+ },
208
+ "nav": {
209
+ "label": "Sections"
210
+ },
211
+ "operation": {
212
+ "list": "listed",
213
+ "detail": "read",
214
+ "search": "searched",
215
+ "create": "created",
216
+ "update": "updated",
217
+ "delete": "deleted",
218
+ "page": "opened a page"
219
+ },
220
+ "permission": {
221
+ "read": "Read every admin screen",
222
+ "write": "Create and update rows",
223
+ "destroy": "Delete rows",
224
+ "impersonate": "Act as another account"
225
+ },
226
+ "policy": {
227
+ "all-granted": "every required permission is granted",
228
+ "evaluated": "the policy decided this",
229
+ "granted": "the grant list contains this permission",
230
+ "missing": "no policy is registered for this permission",
231
+ "not-granted": "the grant list does not contain this permission"
232
+ },
233
+ "search": {
234
+ "title": "Search",
235
+ "label": "Search",
236
+ "placeholder": "Search every entity",
237
+ "skipped": {
238
+ "forbidden": "this account may not search that entity",
239
+ "no-text-fields": "that entity has no text field to search",
240
+ "no-repo": "that entity has no repo bound"
241
+ }
242
+ },
243
+ "value": {
244
+ "empty": "—",
245
+ "true": "Yes",
246
+ "false": "No"
247
+ }
248
+ },
249
+ "dev": {
250
+ "panel": {
251
+ "routes": {
252
+ "title": "Routes",
253
+ "question": "which handler serves this?"
254
+ },
255
+ "timeline": {
256
+ "title": "Timeline",
257
+ "question": "where did the time go in this request?"
258
+ },
259
+ "live": {
260
+ "title": "Live",
261
+ "question": "what does each subscriber receive, and why?"
262
+ },
263
+ "jobs": {
264
+ "title": "Jobs",
265
+ "question": "is the queue moving, and which step failed?"
266
+ },
267
+ "db": {
268
+ "title": "DB",
269
+ "question": "what is in the table, and does the schema match the migrations?"
270
+ },
271
+ "mail": {
272
+ "title": "Mail",
273
+ "question": "what did that email look like, in that locale?"
274
+ },
275
+ "cache": {
276
+ "title": "Cache",
277
+ "question": "what invalidated what — and why is this still stale?"
278
+ },
279
+ "policy": {
280
+ "title": "Policy",
281
+ "question": "can this actor do that, and why?"
282
+ },
283
+ "manifest": {
284
+ "title": "Manifest",
285
+ "question": "is the committed x.manifest.json current?"
286
+ },
287
+ "services": {
288
+ "title": "Services",
289
+ "question": "which services is this process talking to, and did anything fail to load?"
290
+ },
291
+ "boundaries": {
292
+ "title": "Boundaries",
293
+ "question": "does any file import across a boundary the build will reject?"
294
+ }
295
+ },
296
+ "live": {
297
+ "no-sync-node": "no sync node is attached — subscriber facts need a running x dev process"
298
+ },
299
+ "cache": {
300
+ "no-invalidations-yet": "no invalidation has been logged yet — the graph above is still real"
192
301
  }
193
302
  },
194
303
  "time": {
@@ -208,5 +317,49 @@
208
317
  "relative": {
209
318
  "now": "just now"
210
319
  }
320
+ },
321
+ "ui": {
322
+ "breadcrumb": "Breadcrumb",
323
+ "cancel": "Cancel",
324
+ "close": "Close",
325
+ "confirm": "Confirm",
326
+ "dismiss": "Dismiss",
327
+ "empty": "Nothing here yet.",
328
+ "error": {
329
+ "code": "Error code",
330
+ "cause": "What went wrong",
331
+ "fix": "How to fix it",
332
+ "title": "Something went wrong"
333
+ },
334
+ "language": "Language",
335
+ "load": {
336
+ "more": "Load more",
337
+ "end": "That's everything."
338
+ },
339
+ "loading": "Loading…",
340
+ "menu": "Menu",
341
+ "more": "More",
342
+ "navigation": "Navigation",
343
+ "next": "Next",
344
+ "optional": "Optional",
345
+ "page": "Page",
346
+ "previous": "Previous",
347
+ "required": "Required",
348
+ "results": {
349
+ "empty": "No matches"
350
+ },
351
+ "retry": "Try again",
352
+ "skip": "Skip to content",
353
+ "sort": {
354
+ "ascending": "Sort ascending",
355
+ "descending": "Sort descending"
356
+ },
357
+ "suggestions": "{count} suggestions",
358
+ "theme": {
359
+ "light": "Light",
360
+ "dark": "Dark",
361
+ "system": "System",
362
+ "label": "Theme"
363
+ }
211
364
  }
212
365
  }
package/src/context.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * No call site passes a locale by hand — `t()` here is the ambient translator.
4
4
  */
5
5
 
6
- import { type Ctx, useContext } from '@ultimat3/core';
6
+ import { tryUseContext } from '@ultimat3/core';
7
7
  import { type Catalog, mergeCatalogs } from './catalog';
8
8
  import {
9
9
  DEFAULT_LOCALE,
@@ -19,9 +19,6 @@ import { createTranslator, type TranslateVars, type Translator } from './transla
19
19
  /** The cookie an explicit language switcher writes. */
20
20
  export const LOCALE_COOKIE = 'x_locale';
21
21
 
22
- /** Field name on the ALS context. Core owns `Ctx`; it stays a plain string there. */
23
- const CTX_LOCALE = 'locale';
24
-
25
22
  export interface LocaleSources {
26
23
  /** Raw `Accept-Language` header value. */
27
24
  header?: string | null;
@@ -49,7 +46,13 @@ export interface LocaleConfig {
49
46
  order: readonly LocaleSourceName[];
50
47
  }
51
48
 
52
- const DEFAULT_ORDER: readonly LocaleSourceName[] = ['header', 'cookie', 'user', 'query'];
49
+ /**
50
+ * Explicit before inferred, always. `Accept-Language` is what the browser was installed as; the
51
+ * query, the cookie and the user row are what a person *chose*. Ranking the header first meant a
52
+ * language switcher wrote a cookie that never won again — and `@ultimat3/http`'s negotiator, which
53
+ * takes the explicit value ahead of the header, disagreed with this one about the same request.
54
+ */
55
+ const DEFAULT_ORDER: readonly LocaleSourceName[] = ['query', 'cookie', 'user', 'header'];
53
56
 
54
57
  let config: LocaleConfig = {
55
58
  supported: SUPPORTED_LOCALES,
@@ -68,7 +71,7 @@ export function localeConfig(): LocaleConfig {
68
71
  }
69
72
 
70
73
  /**
71
- * Header → cookie → user record → query → default, per `config.order`.
74
+ * Query → cookie → user record → header → default, per `config.order`.
72
75
  * An unsupported tag is skipped rather than thrown: a stale cookie must not 500 a page.
73
76
  */
74
77
  export function resolveLocale(
@@ -95,25 +98,29 @@ export function localeCookieOf(cookieHeader?: string | null): string | undefined
95
98
  const index = pair.indexOf('=');
96
99
  if (index === -1) continue;
97
100
  if (pair.slice(0, index).trim() !== LOCALE_COOKIE) continue;
98
- return decodeURIComponent(pair.slice(index + 1).trim());
101
+ const raw = pair.slice(index + 1).trim();
102
+ try {
103
+ return decodeURIComponent(raw);
104
+ } catch {
105
+ // A cookie is client-authored: `x_locale=%` is a `URIError` out of a per-request path, and
106
+ // a 500 for a malformed locale is worse than the raw value, which `resolveLocale` then
107
+ // fails to normalise and skips. Same guard as `@ultimat3/auth`'s `decodeCookieValue`.
108
+ return raw;
109
+ }
99
110
  }
100
111
  return undefined;
101
112
  }
102
113
 
103
- /** The HTTP layer calls this once per request, before any render. */
104
- export function attachLocale(ctx: Ctx, locale: Locale): Locale {
105
- writeContextField(ctx, CTX_LOCALE, locale);
106
- return locale;
107
- }
108
-
109
- export function localeOf(ctx: Ctx): Locale {
110
- return readField(ctx, CTX_LOCALE) ?? config.fallback;
111
- }
112
-
113
- /** Ambient locale for the in-flight request; the configured fallback outside one. */
114
+ /**
115
+ * Ambient locale for the in-flight request; the configured fallback outside one.
116
+ *
117
+ * The store is **`Ctx.locale`**, core's own declared field, so `createContext({ locale })` and
118
+ * `withChildContext({ locale })` are the only writers and this package publishes no second one.
119
+ * `@ultimat3/time`'s `currentTimeZone()` is the same shape over `Ctx.tz`, deliberately.
120
+ */
114
121
  export function currentLocale(): Locale {
115
- const ctx = tryContext();
116
- return (ctx === undefined ? undefined : readField(ctx, CTX_LOCALE)) ?? config.fallback;
122
+ const locale = tryUseContext()?.locale;
123
+ return locale === undefined || locale === '' ? config.fallback : locale;
117
124
  }
118
125
 
119
126
  export function currentDirection(): Direction {
@@ -171,25 +178,3 @@ export function resetCatalogs(): void {
171
178
  registry.clear();
172
179
  translators.clear();
173
180
  }
174
-
175
- function tryContext(): Ctx | undefined {
176
- try {
177
- return useContext();
178
- } catch {
179
- // Outside a request scope (boot, a CLI command, a worker tick) — no ambient locale.
180
- return undefined;
181
- }
182
- }
183
-
184
- /**
185
- * `Ctx` is owned by core (tier 0) and cannot reference i18n's `Locale`, so the field is
186
- * read structurally. One place, one cast, validated on the way out.
187
- */
188
- function readField(ctx: Ctx, field: string): string | undefined {
189
- const value = (ctx as unknown as Record<string, unknown>)[field];
190
- return typeof value === 'string' && value !== '' ? value : undefined;
191
- }
192
-
193
- function writeContextField(ctx: Ctx, field: string, value: string): void {
194
- (ctx as unknown as Record<string, unknown>)[field] = value;
195
- }
package/src/framework.ts CHANGED
@@ -8,7 +8,13 @@ import en from './catalogs/en.json';
8
8
  import { registerCatalog } from './context';
9
9
  import { DEFAULT_LOCALE, type Locale } from './locales';
10
10
 
11
- /** `errors.*`, `auth.*`, `pagination.*`, `admin.*`, `validation.*`, `common.*`, `time.*`. */
11
+ /**
12
+ * `errors.*`, `auth.*`, `pagination.*`, `admin.*`, `validation.*`, `common.*`, `time.*`, `ui.*`.
13
+ *
14
+ * `ui.*` is `@ultimat3/ui`'s `UI_KEYS` — the strings the design system needs and cannot receive as
15
+ * a prop. They live here rather than in that package because a catalog is one flat file per locale
16
+ * and a second one would be a second place a translator has to find.
17
+ */
12
18
  export const FRAMEWORK_CATALOG: Catalog = loadCatalog(en);
13
19
 
14
20
  export const FRAMEWORK_CATALOG_LOCALE: Locale = DEFAULT_LOCALE;
package/src/index.ts CHANGED
@@ -12,7 +12,6 @@ export {
12
12
  parseNestedCatalog,
13
13
  } from './catalog';
14
14
  export {
15
- attachLocale,
16
15
  catalogFor,
17
16
  configureLocales,
18
17
  currentDirection,
@@ -24,7 +23,6 @@ export {
24
23
  type LocaleSources,
25
24
  localeConfig,
26
25
  localeCookieOf,
27
- localeOf,
28
26
  registerCatalog,
29
27
  registeredLocales,
30
28
  resetCatalogs,
@@ -22,14 +22,22 @@ const PLACEHOLDER = /\{\{|\}\}|\{([^{}\s]+)\}/g;
22
22
  * Replace `{name}` with `vars.name`. `{{` and `}}` escape a literal brace.
23
23
  * An unknown variable renders loudly as `⟦name⟧` for the same reason a missing key
24
24
  * does: a silently blank slot ships to production, a bracketed one does not.
25
+ *
26
+ * Only an OWN property of `vars` is a variable. A plain object inherits `constructor`,
27
+ * `toString` and the rest, so `{constructor}` would have rendered a function's source into a
28
+ * page — the same prototype walk `catalog.ts` avoids by nesting into null-prototype nodes.
29
+ *
30
+ * The fast path tests both braces because `}}` un-escapes without a `{` in sight: skipping on
31
+ * `{` alone left `'a}}b'` unchanged while `'{{a}}b'` collapsed, which is one escape with two
32
+ * meanings.
25
33
  */
26
34
  export function interpolate(template: string, vars?: InterpolationVars): string {
27
- if (!template.includes('{')) return template;
35
+ if (!template.includes('{') && !template.includes('}')) return template;
28
36
  return template.replace(PLACEHOLDER, (match, name: string | undefined) => {
29
37
  if (match === '{{') return '{';
30
38
  if (match === '}}') return '}';
31
39
  if (name === undefined) return match;
32
- const value = vars?.[name];
40
+ const value = vars !== undefined && Object.hasOwn(vars, name) ? vars[name] : undefined;
33
41
  if (value === undefined) return `⟦${name}⟧`;
34
42
  return String(value);
35
43
  });
@@ -57,15 +65,27 @@ export function pluralCategory(count: number, locale: string): Intl.LDMLPluralRu
57
65
  /**
58
66
  * Candidate keys for a plural lookup, most specific first.
59
67
  *
60
- * `items` + count 3 in `pl` → `items_many`, `items_plural`, `items_other`, `items`.
61
- * `items` + count 1 in `pl` → `items_one`, `items`.
68
+ * `items` + count 5 in `pl` → `items_many`, `items_plural`, `items_other`, `items`, `items_one`.
69
+ * `items` + count 1 in `pl` → `items_one`, `items`, `items_plural`, `items_other`.
62
70
  * `_plural` is the two-form authoring shortcut from the house style; the `_<category>`
63
71
  * suffixes are what a 3+ form locale needs.
72
+ *
73
+ * Both chains END on the same four candidates — `key`, `_one`, `_plural`, `_other` — because those
74
+ * are exactly what `Translator.has()` answers true for, and `has()` is what a caller GUARDS a
75
+ * render with (`packages/mail/src/render.ts:48`). A chain narrower than `has()` puts ⟦key⟧ into
76
+ * copy a guard had just promised was renderable. Only the ORDER differs by category, and that is
77
+ * the point: for `one` the bare key is the singular half of the two-form shortcut, so it outranks
78
+ * the plural forms; everywhere else it is the last-resort singular.
64
79
  */
65
80
  export function pluralKeyCandidates(key: string, count: number, locale: string): string[] {
66
81
  const category = pluralCategory(count, locale);
67
- if (category === 'one') return [`${key}_one`, key];
68
- return [`${key}_${category}`, `${key}_plural`, `${key}_other`, key];
82
+ const chain =
83
+ category === 'one'
84
+ ? [`${key}_one`, key, `${key}_plural`, `${key}_other`]
85
+ : [`${key}_${category}`, `${key}_plural`, `${key}_other`, key, `${key}_one`];
86
+ // `other` names itself twice without this — the duplicate is harmless and was the tell that the
87
+ // list had never been exercised.
88
+ return [...new Set(chain)];
69
89
  }
70
90
 
71
91
  /** First candidate that exists, per the caller's `has` predicate; falls back to `key`. */