@ultimat3/i18n 1.1.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 +64 -0
- package/package.json +3 -2
- package/src/catalogs/en.json +214 -61
- package/src/context.ts +27 -42
- package/src/framework.ts +7 -1
- package/src/index.ts +0 -2
- package/src/interpolate.ts +26 -6
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": "
|
|
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": "
|
|
34
|
+
"@ultimat3/core": "2.0.0"
|
|
34
35
|
}
|
|
35
36
|
}
|
package/src/catalogs/en.json
CHANGED
|
@@ -124,71 +124,180 @@
|
|
|
124
124
|
"noMore": "That's everything."
|
|
125
125
|
},
|
|
126
126
|
"admin": {
|
|
127
|
-
"
|
|
128
|
-
"
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
"
|
|
132
|
-
"
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
"
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
"
|
|
149
|
-
"
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
"
|
|
156
|
-
"
|
|
157
|
-
"
|
|
158
|
-
"
|
|
159
|
-
"
|
|
160
|
-
"
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
"
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
"
|
|
169
|
-
"
|
|
170
|
-
"
|
|
171
|
-
"
|
|
172
|
-
"
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
"
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
"
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
"
|
|
182
|
-
"
|
|
183
|
-
"
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
"
|
|
189
|
-
"
|
|
190
|
-
"
|
|
191
|
-
"
|
|
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 {
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
116
|
-
return
|
|
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
|
-
/**
|
|
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,
|
package/src/interpolate.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
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`. */
|