@ultimat3/i18n 23.0.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": "23.0.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",
@@ -36,6 +36,6 @@
36
36
  "test": "bun test"
37
37
  },
38
38
  "dependencies": {
39
- "@ultimat3/core": "23.0.0"
39
+ "@ultimat3/core": "24.0.0"
40
40
  }
41
41
  }
@@ -205,7 +205,8 @@
205
205
  "secret-required": "A value is required: this column is sealed and has no default.",
206
206
  "action-not-applicable": "This action does not apply to the row as it is now.",
207
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."
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."
209
210
  },
210
211
  "filter": {
211
212
  "any": "Any",
@@ -326,7 +327,8 @@
326
327
  "skipped": {
327
328
  "forbidden": "this account may not search that entity",
328
329
  "no-text-fields": "that entity has no text field to search",
329
- "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"
330
332
  }
331
333
  },
332
334
  "value": {
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, {