@ultimat3/i18n 1.2.0 → 3.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,72 @@
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
+ - **A catalog is read through `Object.hasOwn`, never a raw index, and every catalog this package
33
+ builds is `Object.create(null)`** — `flattenCatalog`, `mergeCatalogs` and `nestCatalog` all are.
34
+ On a `{}` catalog `catalog['valueOf']` resolved to the INHERITED function, so `t('valueOf')`
35
+ threw inside `interpolate`, `t('constructor')` returned a function through a signature typed
36
+ `string`, and `isMiss(t('__proto__'))` threw on an object — all three reachable wherever a key
37
+ travels as data (`t(row.labelKey)`). Both halves are load-bearing: the guard in `translator.ts`
38
+ makes it true of a catalog this package did not build, the null prototype makes `__proto__` an
39
+ ordinary key instead of one the setter silently swallows. Never reintroduce either.
40
+ - Plural selection is `Intl.PluralRules`. Never `count === 1`. Variants are underscore suffixes on
41
+ the leaf — a CLDR category (`_zero _one _two _few _many _other`), or `n` / `n_plural` as the
42
+ two-form shortcut; pair `n_one` with `n_other`, never with `n_plural`. Never a nested
43
+ `{ one, other }` branch — the runtime probes the variant leaf (`n_other`), and `TranslationKey`
44
+ admits the stem `n` on exactly that basis.
45
+ - `Translator<TCatalog = Catalog>` must keep defaulting to `string` keys: `@ultimat3/ui` and
46
+ `@ultimat3/mail` take a bare `Translator`. `has()` and `raw()` stay `string` — they are probes.
47
+ - Nothing here formats a number, date or money. That is `@ultimat3/money` / `@ultimat3/time`.
48
+ - **The ambient locale IS `Ctx.locale`**, core's own declared field — this package writes no context
49
+ field of its own and publishes no writer. `createContext({ locale })` and
50
+ `withChildContext({ locale })` are the only ways in, `currentLocale()` the only way out.
51
+ `attachLocale`/`localeOf` existed until 1.3.0 with zero callers; `@ultimat3/time` had the same
52
+ pair over a field name (`ctx['timeZone']`) that disagreed with core's `tz`, which is what made
53
+ every server-rendered date UTC. Never reintroduce either half.
54
+ - Adding a framework string: `catalogs/en.json`, feature-namespaced, then use `t('ns.key')`.
55
+ - **`catalogs/en.json` is gated in both directions**, on `x verify`'s `boundaries` step via
56
+ `scripts/i18n-catalog.ts`. A `t('literal')` in `packages/*/src` with no entry is
57
+ `X_CATALOG_MISSING_KEYS`; an entry nothing in framework source names, in a namespace the
58
+ framework already renders (`admin.*`, `dev.*`, `ui.*`), is `X_CATALOG_KEY_UNREACHABLE`. The
59
+ namespaces the framework only *ships* for an app (`common.*`, `auth.*`, `errors.*`,
60
+ `pagination.*`, `validation.*`, `time.*`) are exempt from the second half and derived, not
61
+ listed. `ui.*` is pinned exactly by `packages/ui/src/i18n-keys.test.ts`, because every ui string
62
+ resolves as `ui.t(UI_KEYS.x)` and no static scan can follow that.
63
+ - A name is a **leaf or a branch, never both** — `parseNestedCatalog` refuses a dot inside a key,
64
+ so `admin.detail.not-found` and `admin.detail.not-found.fix` cannot coexist. Both keys shipped
65
+ and neither was in the catalog; the leaf is now `…not-found.cause`.
66
+
67
+ ## Commands
68
+
69
+ ```
70
+ bun test packages/i18n
71
+ bun run --filter @ultimat3/i18n typecheck
72
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/i18n",
3
- "version": "1.2.0",
3
+ "version": "3.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": "3.0.0"
34
35
  }
35
36
  }
package/src/catalog.ts CHANGED
@@ -16,9 +16,14 @@ const KEY_SEGMENT = /^[A-Za-z0-9_-]+$/;
16
16
  /**
17
17
  * Depth-first flatten. Throws `X_CATALOG_INVALID` on a non-string leaf (arrays and
18
18
  * numbers are the two mistakes translators actually make) or a duplicate flat key.
19
+ *
20
+ * `Object.create(null)`, for the reason `nestCatalog` states below: a catalog is untrusted input
21
+ * read off disk. On a `{}` literal `out['__proto__'] = 'Hello'` hits the `Object.prototype` SETTER
22
+ * and the key vanishes silently, and every other member of `Object.prototype` reads back as
23
+ * present when it is not.
19
24
  */
20
25
  export function flattenCatalog(source: NestedCatalog, prefix = ''): Catalog {
21
- const flat: Record<string, string> = {};
26
+ const flat = Object.create(null) as Record<string, string>;
22
27
  walk(source, prefix, flat);
23
28
  return flat;
24
29
  }
@@ -89,7 +94,9 @@ export function nestCatalog(catalog: Catalog): NestedCatalog {
89
94
  * app can override `errors.notFound.title` without forking the framework catalog.
90
95
  */
91
96
  export function mergeCatalogs(...catalogs: readonly Catalog[]): Catalog {
92
- const merged: Record<string, string> = {};
97
+ // Null-prototyped like every catalog this package builds — a merge must not hand back an
98
+ // object on which `catalog['toString']` reads as a function.
99
+ const merged = Object.create(null) as Record<string, string>;
93
100
  for (const catalog of catalogs) {
94
101
  for (const key of Object.keys(catalog)) {
95
102
  const value = catalog[key];
@@ -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`. */
package/src/translator.ts CHANGED
@@ -64,7 +64,13 @@ export function createTranslator(catalog: Catalog, locale: Locale = DEFAULT_LOCA
64
64
  };
65
65
 
66
66
  const translate = (key: string, vars?: TranslateVars): string => {
67
- const template = catalog[resolveKey(key, vars)];
67
+ // Through `hasExact`, never a raw index: a key travels as data (`t(row.labelKey)`), and on a
68
+ // `{}`-prototyped catalog `catalog['valueOf']` resolves to the INHERITED function instead of
69
+ // reading as absent — so `interpolate` threw on a non-string, `t('constructor')` returned a
70
+ // function through a signature typed `string`, and `isMiss(t('__proto__'))` threw on an object.
71
+ // Catalogs are null-prototyped now; this guard is what makes that true of any catalog.
72
+ const resolved = resolveKey(key, vars);
73
+ const template = hasExact(resolved) ? catalog[resolved] : undefined;
68
74
  if (template === undefined) return `⟦${key}⟧`;
69
75
  return vars === undefined ? template : interpolate(template, vars);
70
76
  };
@@ -75,7 +81,7 @@ export function createTranslator(catalog: Catalog, locale: Locale = DEFAULT_LOCA
75
81
  hasExact(`${key}_other`) ||
76
82
  hasExact(`${key}_plural`) ||
77
83
  hasExact(`${key}_one`),
78
- raw: (key: string): string | undefined => catalog[key],
84
+ raw: (key: string): string | undefined => (hasExact(key) ? catalog[key] : undefined),
79
85
  keys: (): string[] => Object.keys(catalog).sort(),
80
86
  locale,
81
87
  });