@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 +10 -0
- package/README.md +3 -0
- package/package.json +2 -2
- package/src/catalogs/en.json +4 -2
- package/src/context.ts +12 -18
- package/src/define-catalogs.ts +5 -0
- package/src/locales.ts +9 -3
- package/src/translator.ts +5 -1
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": "
|
|
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": "
|
|
39
|
+
"@ultimat3/core": "24.0.0"
|
|
40
40
|
}
|
|
41
41
|
}
|
package/src/catalogs/en.json
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
/**
|
package/src/define-catalogs.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
126
|
-
quality =
|
|
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
|
-
|
|
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, {
|