@ultimat3/i18n 22.15.0 → 24.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 CHANGED
@@ -53,6 +53,16 @@ Imported by every package that renders a string.
53
53
  and `x verify`'s `i18n` step is what asks it of an app. An app reads strings through its OWN
54
54
  module (`useT()` from `packages/i18n/src/index.ts`), never `t` from `@ultimat3/i18n`, so that
55
55
  registration is a consequence of rendering rather than something a boot has to remember.
56
+ - **`t()` ALWAYS interpolates, `As of 2026-10`.** It skipped `interpolate` when `vars` was omitted,
57
+ so `t('a')` on `Hello {name}` put the raw placeholder on the page where `t('a', {})` put the loud
58
+ `⟦name⟧`, and `{{` stayed escaped. One message, one render. A caller that wants the TEMPLATE
59
+ (plural forms for a client island) calls `t.raw(key)`. **Breaking.**
60
+ - **`defineCatalogs` runs `assertLocale` over every tag BEFORE the register loop.**
61
+ `configureLocales` validated them after it, so `X_LOCALE_INVALID` arrived with the bad tag
62
+ already in `registeredLocales()`. That code is `@ultimat3/core`'s, raised by its `assertLocale` —
63
+ never add it to `I18N_ERROR_CODES` (a second claim is `X_ERROR_CODE_DUPLICATE`).
64
+ - **A `q` that is not a plain decimal is 0**, so the range is dropped. A digits-only capture meant
65
+ `en;q=abc` never matched and kept the default quality of 1.
56
66
  - Only an **own** property of `vars` is a variable — `interpolate` guards with `Object.hasOwn`.
57
67
  A plain object inherits `constructor`, `toString`, `valueOf` and `__proto__`, so a bare
58
68
  `vars[name]` rendered a function's source into the page for a template nobody wrote a variable
package/README.md CHANGED
@@ -54,6 +54,8 @@ t('pagination.showing', { from: 1, to: 20, total: 137 });
54
54
  t('pagination.result', { count: 1 }); // "1 result"
55
55
  t('pagination.result', { count: 9 }); // "9 results"
56
56
  t('nav.settings'); // "⟦nav.settings⟧" — fix it or ship it broken, visibly
57
+ t('pagination.result'); // "⟦count⟧ result" — vars or none, the template is always
58
+ // interpolated; t.raw(key) is the template itself
57
59
  ```
58
60
 
59
61
  | Call | Returns | Use for |
@@ -114,6 +116,7 @@ X_CATALOG_MISSING_KEYS: catalog is incomplete
114
116
  | Code | When |
115
117
  |---|---|
116
118
  | `X_LOCALE_UNSUPPORTED` | a tag outside the supported set was asserted, or a `defineCatalogs` default that is not one of its locales |
119
+ | `X_LOCALE_INVALID` | `defineCatalogs` or `configureLocales` was given a tag that is not well-formed BCP 47 (`en_US`). **`@ultimat3/core`'s code**, raised by its `assertLocale` — not an `I18nError` and not in `I18N_ERROR_CODES` |
117
120
  | `X_CATALOG_MISSING_KEYS` | a shipped locale lacks a key the source uses |
118
121
  | `X_CATALOG_INVALID` | non-string leaf, bad key segment, or a dotted/nested collision |
119
122
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/i18n",
3
- "version": "22.15.0",
3
+ "version": "24.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",
@@ -23,6 +23,7 @@
23
23
  "files": [
24
24
  "src",
25
25
  "!src/**/*.test.ts",
26
+ "!src/**/*-fixture.ts",
26
27
  "CLAUDE.md",
27
28
  "README.md",
28
29
  "LICENSE"
@@ -35,6 +36,6 @@
35
36
  "test": "bun test"
36
37
  },
37
38
  "dependencies": {
38
- "@ultimat3/core": "22.15.0"
39
+ "@ultimat3/core": "24.0.0"
39
40
  }
40
41
  }
@@ -143,10 +143,14 @@
143
143
  "actions": {
144
144
  "label": "Actions",
145
145
  "confirm": {
146
- "title": "Confirm before this runs",
147
- "body": "Type {token} to confirm. Nothing happens until it matches.",
148
- "label": "Confirmation",
149
- "submit": "Run it"
146
+ "body": "Type {token} to confirm. Nothing happens until it matches."
147
+ },
148
+ "none": "No resource is available to this account.",
149
+ "unknown": "That action is not one this dashboard declares.",
150
+ "back": "Back to the row",
151
+ "subject": {
152
+ "row": "Runs on {id}.",
153
+ "app": "Runs once, for the whole app."
150
154
  }
151
155
  },
152
156
  "ai": {
@@ -172,7 +176,8 @@
172
176
  "allowed": "Allowed",
173
177
  "denied": "Denied",
174
178
  "failed": "Failed"
175
- }
179
+ },
180
+ "older": "Older entries"
176
181
  },
177
182
  "brand": {
178
183
  "name": "Admin"
@@ -185,17 +190,31 @@
185
190
  },
186
191
  "detail": {
187
192
  "edit": "Edit",
188
- "loading": "Loading…",
189
193
  "not-found": {
190
194
  "cause": "No {entity} with that id.",
191
195
  "fix": "Go back to the list and open a row that still exists."
192
- }
196
+ },
197
+ "delete": "Delete"
193
198
  },
194
199
  "error": {
195
200
  "action-failed": "The action threw before it finished.",
196
201
  "confirmation-required": "This is destructive: echo the confirmation token first.",
197
202
  "invalid-input": "Some fields did not pass the entity's own schema.",
198
- "operation-not-offered": "This resource does not offer that operation."
203
+ "operation-not-offered": "This resource does not offer that operation.",
204
+ "row-missing": "No row has that id.",
205
+ "secret-required": "A value is required: this column is sealed and has no default.",
206
+ "action-not-applicable": "This action does not apply to the row as it is now.",
207
+ "row-out-of-scope": "The row this write would leave is outside the rows this account may see.",
208
+ "batch-not-offered": "This action does not run over a selection of rows.",
209
+ "row-changed": "Someone changed this row after you opened it. Your values are kept over the row as it is now: review them and save again."
210
+ },
211
+ "filter": {
212
+ "any": "Any",
213
+ "apply": "Filter",
214
+ "clear": "Clear filters",
215
+ "find": "Find…",
216
+ "from": "{field}, from",
217
+ "to": "{field}, to"
199
218
  },
200
219
  "form": {
201
220
  "cancel": "Cancel",
@@ -203,21 +222,75 @@
203
222
  "edit": "Edit {entity}",
204
223
  "issues": "Check the highlighted fields",
205
224
  "save": "Save",
206
- "saving": "Saving…"
225
+ "secret-unchanged": "Leave empty to keep the stored value."
207
226
  },
208
227
  "group": {
209
- "data": "Data"
228
+ "data": "Data",
229
+ "jobs": "Jobs"
210
230
  },
211
231
  "jobs": {
212
- "title": "Jobs"
232
+ "title": "Jobs",
233
+ "scoped": "Queues, workers and throughput are every organisation's, so they are a platform operator's view. Your organisation's jobs:",
234
+ "ms": "{ms} ms",
235
+ "seconds": "{seconds} s",
236
+ "tile": {
237
+ "ready": "Ready",
238
+ "running": "Running",
239
+ "delayed": "Delayed",
240
+ "suspended": "Suspended",
241
+ "dead": "Dead",
242
+ "workers": "Workers",
243
+ "oldestReady": "Oldest ready"
244
+ },
245
+ "range": {
246
+ "label": "Range",
247
+ "1h": "Last hour",
248
+ "24h": "Last 24 hours",
249
+ "7d": "Last 7 days",
250
+ "30d": "Last 30 days"
251
+ },
252
+ "chart": {
253
+ "title": "Done and failed",
254
+ "label": "Runs done and failed, {range}",
255
+ "done": "done",
256
+ "failed": "failed"
257
+ },
258
+ "byName": {
259
+ "caption": "By job name",
260
+ "name": "Job",
261
+ "volume": "Runs",
262
+ "failureRate": "Failure rate",
263
+ "meanMs": "Mean duration",
264
+ "empty": "No job ended in this range."
265
+ },
266
+ "scope": {
267
+ "all": "All",
268
+ "ready": "Ready",
269
+ "delayed": "Delayed",
270
+ "running": "Running",
271
+ "suspended": "Suspended",
272
+ "done": "Done",
273
+ "failed": "Failed",
274
+ "dead": "Dead",
275
+ "cancelled": "Cancelled"
276
+ },
277
+ "section": {
278
+ "run": "Run",
279
+ "failure": "Failure",
280
+ "work": "Input, progress and steps",
281
+ "trace": "Tenant and trace"
282
+ },
283
+ "hint": {
284
+ "id": "Paste the first characters of a job id."
285
+ }
213
286
  },
214
287
  "list": {
215
288
  "empty": "Nothing here yet.",
216
- "loading": "Loading…",
217
- "next": "Next",
218
- "open": "Open",
219
- "pagination": "Pagination",
220
- "previous": "Previous"
289
+ "open": "Open"
290
+ },
291
+ "lookup": {
292
+ "title": "Find {entity}",
293
+ "placeholder": "Search by name"
221
294
  },
222
295
  "nav": {
223
296
  "label": "Sections"
@@ -243,6 +316,10 @@
243
316
  "missing": "no policy is registered for this permission",
244
317
  "not-granted": "the grant list does not contain this permission"
245
318
  },
319
+ "scope": {
320
+ "label": "Scopes",
321
+ "all": "All"
322
+ },
246
323
  "search": {
247
324
  "title": "Search",
248
325
  "label": "Search",
@@ -250,13 +327,156 @@
250
327
  "skipped": {
251
328
  "forbidden": "this account may not search that entity",
252
329
  "no-text-fields": "that entity has no text field to search",
253
- "no-repo": "that entity has no repo bound"
330
+ "no-repo": "that entity has no repo bound",
331
+ "failed": "that entity's repo failed while searching"
254
332
  }
255
333
  },
256
334
  "value": {
257
335
  "empty": "—",
258
336
  "true": "Yes",
259
- "false": "No"
337
+ "false": "No",
338
+ "sealed": "Sealed",
339
+ "currency": "Currency"
340
+ },
341
+ "actor": {
342
+ "anonymous": "not signed in",
343
+ "signedIn": "{id} — roles: {roles}"
344
+ },
345
+ "backToApp": "← Back to the app",
346
+ "matrix": {
347
+ "operation": "Operation",
348
+ "permissions": "Permissions required",
349
+ "verdict": "Verdict",
350
+ "allowed": "allowed",
351
+ "deniedBy": "denied —"
352
+ },
353
+ "section": {
354
+ "other": "Other"
355
+ },
356
+ "related": {
357
+ "all": "See all"
358
+ },
359
+ "batch": {
360
+ "action": "Action",
361
+ "over": "Run over",
362
+ "checked": "The checked rows",
363
+ "all": "Every row this list matches",
364
+ "run": "Run",
365
+ "select": "Select",
366
+ "select-row": "Select {id}",
367
+ "done": "Done",
368
+ "refused": "Refused",
369
+ "failed": "Failed",
370
+ "queued": "Queued",
371
+ "remaining": "Not reached yet",
372
+ "continue": "Run on the next {count}",
373
+ "back": "Back to the list",
374
+ "subject": {
375
+ "checked": "Runs on {count} checked rows.",
376
+ "all": "Runs on every row this list matches.",
377
+ "count": "Runs on {count} rows."
378
+ },
379
+ "matching": "Ran once over every matching row"
380
+ },
381
+ "x_jobs": {
382
+ "title": "Job runs",
383
+ "field": {
384
+ "id": "Id",
385
+ "name": "Job",
386
+ "queue": "Queue",
387
+ "state": "State",
388
+ "attempt": "Attempt",
389
+ "maxAttempts": "Max attempts",
390
+ "runAt": "Runs at",
391
+ "createdAt": "Queued",
392
+ "updatedAt": "Updated",
393
+ "tenantId": "Tenant",
394
+ "lastError": "Last error",
395
+ "lastErrorStack": "Stack",
396
+ "progress": "Progress",
397
+ "input": "Input",
398
+ "steps": "Steps",
399
+ "traceparent": "Trace",
400
+ "enqueuedBy": "Queued by",
401
+ "claimedBy": "Claimed by"
402
+ },
403
+ "column": {
404
+ "completed": "Progress",
405
+ "error": "Last error"
406
+ }
407
+ },
408
+ "x_job_queues": {
409
+ "title": "Queues",
410
+ "field": {
411
+ "name": "Queue",
412
+ "ready": "Ready",
413
+ "delayed": "Delayed",
414
+ "running": "Running",
415
+ "suspended": "Suspended",
416
+ "failed": "Failed",
417
+ "dead": "Dead",
418
+ "oldestReadyMs": "Oldest ready (ms)",
419
+ "paused": "Paused",
420
+ "pausedAt": "Paused at"
421
+ },
422
+ "column": {
423
+ "oldest": "Oldest ready",
424
+ "jobs": "Jobs"
425
+ }
426
+ },
427
+ "x_job_tasks": {
428
+ "title": "Scheduled tasks",
429
+ "field": {
430
+ "name": "Task",
431
+ "cron": "Schedule",
432
+ "tz": "Time zone",
433
+ "catchUp": "Catch-up",
434
+ "enqueues": "Queues",
435
+ "lastFireAt": "Last fire",
436
+ "nextFireAt": "Next fire",
437
+ "paused": "Paused"
438
+ }
439
+ },
440
+ "x_job_workers": {
441
+ "title": "Workers",
442
+ "field": {
443
+ "id": "Worker",
444
+ "host": "Host",
445
+ "queues": "Queues",
446
+ "concurrency": "Slots",
447
+ "inFlight": "In flight",
448
+ "startedAt": "Started",
449
+ "heartbeatAt": "Heartbeat"
450
+ },
451
+ "column": {
452
+ "heartbeat": "Last heartbeat",
453
+ "running": "Running jobs"
454
+ }
455
+ },
456
+ "action": {
457
+ "job": {
458
+ "retry": "Retry",
459
+ "retry-from-step": "Retry from a step",
460
+ "run-now": "Run now",
461
+ "cancel": "Cancel",
462
+ "remove": "Remove",
463
+ "queue": {
464
+ "pause": "Pause queue",
465
+ "resume": "Resume queue"
466
+ },
467
+ "task": {
468
+ "pause": "Pause task",
469
+ "resume": "Resume task",
470
+ "run-now": "Run now"
471
+ }
472
+ }
473
+ },
474
+ "input": {
475
+ "job": {
476
+ "retry-from-step": {
477
+ "step": "Step"
478
+ }
479
+ }
260
480
  }
261
481
  },
262
482
  "dev": {
package/src/context.ts CHANGED
@@ -3,7 +3,13 @@
3
3
  * No call site passes a locale by hand — `t()` here is the ambient translator.
4
4
  */
5
5
 
6
- import { assertLocale, cachedFormatter, canonicalLocale, tryUseContext } from '@ultimat3/core';
6
+ import {
7
+ assertLocale,
8
+ cachedFormatter,
9
+ canonicalLocale,
10
+ readCookie,
11
+ tryUseContext,
12
+ } from '@ultimat3/core';
7
13
  import { type Catalog, mergeCatalogs } from './catalog';
8
14
  import {
9
15
  DEFAULT_LOCALE,
@@ -145,24 +151,12 @@ export function resolveLocale(
145
151
  return { locale: fallback, direction: directionOf(fallback), source: 'default' };
146
152
  }
147
153
 
148
- /** Read `x_locale` out of a raw `Cookie` header. */
154
+ /**
155
+ * Read `x_locale` out of a raw `Cookie` header — through core's `readCookie`, so a malformed
156
+ * escape (`x_locale=%`) is the raw value, which `resolveLocale` then fails to normalise and skips.
157
+ */
149
158
  export function localeCookieOf(cookieHeader?: string | null): string | undefined {
150
- if (!cookieHeader) return undefined;
151
- for (const pair of cookieHeader.split(';')) {
152
- const index = pair.indexOf('=');
153
- if (index === -1) continue;
154
- if (pair.slice(0, index).trim() !== LOCALE_COOKIE) continue;
155
- const raw = pair.slice(index + 1).trim();
156
- try {
157
- return decodeURIComponent(raw);
158
- } catch {
159
- // A cookie is client-authored: `x_locale=%` is a `URIError` out of a per-request path, and
160
- // a 500 for a malformed locale is worse than the raw value, which `resolveLocale` then
161
- // fails to normalise and skips. Same guard as `@ultimat3/auth`'s `decodeCookieValue`.
162
- return raw;
163
- }
164
- }
165
- return undefined;
159
+ return readCookie(cookieHeader, LOCALE_COOKIE) ?? undefined;
166
160
  }
167
161
 
168
162
  /**
@@ -4,6 +4,7 @@
4
4
  * Boot-time only — nothing here runs per request.
5
5
  */
6
6
 
7
+ import { assertLocale } from '@ultimat3/core';
7
8
  import { type Catalog, catalogKeys, loadCatalog, mergeCatalogs } from './catalog';
8
9
  import { configureLocales, registerCatalog } from './context';
9
10
  import { localeUnsupported } from './errors';
@@ -42,6 +43,10 @@ export function defineCatalogs<TLocales extends CatalogSources>(
42
43
  ): CatalogSet<TLocales> {
43
44
  const locales = Object.keys(input.locales) as (keyof TLocales & string)[];
44
45
  if (!locales.includes(input.default)) throw localeUnsupported(input.default, locales);
46
+ // Every tag, before anything is registered. `configureLocales` below runs the same screen, but
47
+ // after the register loop — so its `X_LOCALE_INVALID` arrived with the malformed tag already in
48
+ // `registeredLocales()` and the well-formed locales' strings already live.
49
+ for (const locale of locales) assertLocale(locale);
45
50
 
46
51
  // Load everything before registering anything: a malformed catalog must fail the boot
47
52
  // whole, not leave half the locales live and the other half missing.
package/src/locales.ts CHANGED
@@ -107,6 +107,9 @@ export interface LanguageRange {
107
107
  quality: number;
108
108
  }
109
109
 
110
+ /** A plain non-negative decimal — `0.8`, `1`, `.5`. No sign, no exponent, one point. */
111
+ const Q_VALUE = /^(?:\d+(?:\.\d*)?|\.\d+)$/;
112
+
110
113
  /**
111
114
  * Parse `Accept-Language: en-GB,en;q=0.9,fr-CH;q=0.8,*;q=0.5` into ranges sorted by
112
115
  * quality descending, ties resolved by header order (stable sort).
@@ -120,10 +123,13 @@ export function parseAcceptLanguage(header?: string | null): LanguageRange[] {
120
123
  if (!tag) continue;
121
124
  let quality = 1;
122
125
  for (const param of params) {
123
- const match = /^\s*q\s*=\s*([0-9.]+)\s*$/i.exec(param);
126
+ // The value is captured WHATEVER it is and judged after. Capturing digits only meant
127
+ // `q=abc` did not match at all, so the default of 1 stood: a malformed range outranked
128
+ // every well-formed one. A `q` that is not a plain decimal is a range nobody can rank — 0.
129
+ const match = /^\s*q\s*=(.*)$/i.exec(param);
124
130
  if (match?.[1] !== undefined) {
125
- const parsed = Number.parseFloat(match[1]);
126
- quality = Number.isFinite(parsed) ? Math.min(Math.max(parsed, 0), 1) : 0;
131
+ const value = match[1].trim();
132
+ quality = Q_VALUE.test(value) ? Math.min(Number.parseFloat(value), 1) : 0;
127
133
  }
128
134
  }
129
135
  if (quality > 0) ranges.push({ tag: tag.toLowerCase(), quality });
package/src/translator.ts CHANGED
@@ -72,7 +72,11 @@ export function createTranslator(catalog: Catalog, locale: Locale = DEFAULT_LOCA
72
72
  const resolved = resolveKey(key, vars);
73
73
  const template = hasExact(resolved) ? catalog[resolved] : undefined;
74
74
  if (template === undefined) return `⟦${key}⟧`;
75
- return vars === undefined ? template : interpolate(template, vars);
75
+ // Always, never only when `vars` was passed: skipping it made `t('a')` and `t('a', {})` two
76
+ // renders of one message — the first put the raw `Hello {name}` on the page where the second
77
+ // put the loud `⟦name⟧`, and left `{{` escaped. `interpolate` returns a brace-free template
78
+ // untouched, so the common case pays one `includes`.
79
+ return interpolate(template, vars);
76
80
  };
77
81
 
78
82
  return Object.assign(translate, {