@ultimat3/i18n 23.0.0 → 25.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
@@ -14,6 +14,7 @@ Imported by every package that renders a string.
14
14
  | `locales.ts` | supported set, normalize, negotiate, RTL. No catalogs. |
15
15
  | `context.ts` | request locale via ALS, the registry + the base layer under it, the ambient `t` and `useI18n`. |
16
16
  | `framework.ts` | the framework's own strings, installed as the base layer at module scope. |
17
+ | `app-catalogs.ts` | an app's declared locales, read by importing its catalog module — `loadAppCatalogs` (`undefined` when undeclared) and `appLocaleSet` (`UNDECLARED_LOCALES` then). The ONE reader: a CLI step never reads `localeConfig()`/`routedLocales()` after `loadApp`; it reads `LoadedApp.locales`. Tooling only: the `./app-catalogs` entry, never `index.ts` (it is in every island). |
17
18
  | `registration.ts` | are the catalogs an app SHIPS in the registry it READS? No file access. |
18
19
  | `extract.ts` | static scan + audit for `x verify`. |
19
20
  | `catalogs/en.json` | data, not code. |
@@ -35,7 +36,7 @@ Imported by every package that renders a string.
35
36
  puts it back. It was `registerFrameworkCatalog()`, whose ONE caller was `defineCatalogs`, so an
36
37
  app whose catalog module nothing imported served `⟦errors.notFound.title⟧` on its 404 page and
37
38
  `⟦ui.*⟧` in every design-system control, with `x verify` green (issue #249). Under
38
- `FRAMEWORK_CATALOG_LOCALE` (`en`) **only**: it ran per locale until 2026-08, so an `es`-only app
39
+ core's `DEFAULT_LOCALE` (`en`) **only**: it ran per locale until 2026-08, so an `es`-only app
39
40
  served `Page not found` with `isMiss` reading FALSE — the fallback chain the line above refuses,
40
41
  wearing registration as a disguise. A non-`en` app translates the framework keys it renders into
41
42
  its own catalog.
@@ -53,6 +54,16 @@ Imported by every package that renders a string.
53
54
  and `x verify`'s `i18n` step is what asks it of an app. An app reads strings through its OWN
54
55
  module (`useT()` from `packages/i18n/src/index.ts`), never `t` from `@ultimat3/i18n`, so that
55
56
  registration is a consequence of rendering rather than something a boot has to remember.
57
+ - **`t()` ALWAYS interpolates, `As of 2026-10`.** It skipped `interpolate` when `vars` was omitted,
58
+ so `t('a')` on `Hello {name}` put the raw placeholder on the page where `t('a', {})` put the loud
59
+ `⟦name⟧`, and `{{` stayed escaped. One message, one render. A caller that wants the TEMPLATE
60
+ (plural forms for a client island) calls `t.raw(key)`. **Breaking.**
61
+ - **`defineCatalogs` runs `assertLocale` over every tag BEFORE the register loop.**
62
+ `configureLocales` validated them after it, so `X_LOCALE_INVALID` arrived with the bad tag
63
+ already in `registeredLocales()`. That code is `@ultimat3/core`'s, raised by its `assertLocale` —
64
+ never add it to `I18N_ERROR_CODES` (a second claim is `X_ERROR_CODE_DUPLICATE`).
65
+ - **A `q` that is not a plain decimal is 0**, so the range is dropped. A digits-only capture meant
66
+ `en;q=abc` never matched and kept the default quality of 1.
56
67
  - Only an **own** property of `vars` is a variable — `interpolate` guards with `Object.hasOwn`.
57
68
  A plain object inherits `constructor`, `toString`, `valueOf` and `__proto__`, so a bare
58
69
  `vars[name]` rendered a function's source into the page for a template nobody wrote a variable
@@ -95,7 +106,7 @@ Imported by every package that renders a string.
95
106
  `@ultimat3/mail` take a bare `Translator`. `has()` and `raw()` stay `string` — they are probes.
96
107
  - Nothing here formats a number, date or money. That is `@ultimat3/money` / `@ultimat3/time`.
97
108
  - **The ambient locale IS `Ctx.locale`**, core's own declared field — this package writes no context
98
- field of its own and publishes no writer. `createContext({ locale })` and
109
+ field of its own and publishes no writer. `ctxOf({ locale })` and
99
110
  `withChildContext({ locale })` are the only ways in, `currentLocale()` the only way out.
100
111
  `attachLocale`/`localeOf` existed until 1.3.0 with zero callers; `@ultimat3/time` had the same
101
112
  pair over a field name (`ctx['timeZone']`) that disagreed with core's `tz`, which is what made
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 |
@@ -97,6 +99,22 @@ catalog is a fallback chain, it reads as `isMiss === false`, and `assertCatalogs
97
99
  see it because `CatalogSet.catalogs` carries app strings only. Translate the framework keys your
98
100
  app renders into your own catalog — that is the one path, and it is the same merge an override is.
99
101
 
102
+ ## Reading an app's declared locales (tooling)
103
+
104
+ `@ultimat3/i18n/app-catalogs` — server-only, its own entry so no island bundle carries it:
105
+
106
+ | Export | What |
107
+ |---|---|
108
+ | `APP_CATALOGS_PATH` | `'packages/i18n/src/index.ts'`, where `x new` writes the app's `defineCatalogs()` |
109
+ | `loadAppCatalogs(root)` | imports that module and answers `{ locales, defaultLocale }` (default first), or `undefined` when no `defineCatalogs()` was declared — never the ambient `localeConfig()` read blind. An exported set answers for itself, whatever the locale config says by then. A module that throws rejects with its own error |
110
+ | `appLocaleSet(root)` | the same answer, with `UNDECLARED_LOCALES` for an app that declared nothing — what every consumer that needs *a* locale asks |
111
+ | `UNDECLARED_LOCALES` | `{ locales: ['en'], defaultLocale: 'en' }`, frozen: the one answer for an app with no catalogs, so the PWA manifest, the service worker, the prerender and every `x` command agree |
112
+ | `AppLocaleSet` | the type of both answers |
113
+
114
+ The one reader the CLI (`loadApp`'s `locales`, the pwa manifest and service worker, the static export, `x shot`, `x g`, `x i18n`) and `@ultimat3/testing`'s e2e preload share. `@ultimat3/testing` asks `loadAppCatalogs` deliberately: an app that declared nothing pins no browser language rather than a guessed one.
115
+
116
+ `DEFAULT_LOCALE`, `directionOf` and `isRtl` are `@ultimat3/core`'s since 25.0.0 — import them from there. The barrel still re-exports the three until `@ultimat3/mail` and `@ultimat3/ui` move (marked `PENDING` in `src/index.ts`); `Direction` stays a type re-export, because `LocaleResolution.direction` names it.
117
+
100
118
  ## Enforcement
101
119
 
102
120
  `extractKeys()` scans source for `t('...')` calls; `auditCatalogs()` reports keys used,
@@ -114,6 +132,7 @@ X_CATALOG_MISSING_KEYS: catalog is incomplete
114
132
  | Code | When |
115
133
  |---|---|
116
134
  | `X_LOCALE_UNSUPPORTED` | a tag outside the supported set was asserted, or a `defineCatalogs` default that is not one of its locales |
135
+ | `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
136
  | `X_CATALOG_MISSING_KEYS` | a shipped locale lacks a key the source uses |
118
137
  | `X_CATALOG_INVALID` | non-string leaf, bad key segment, or a dotted/nested collision |
119
138
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/i18n",
3
- "version": "23.0.0",
3
+ "version": "25.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",
@@ -18,7 +18,8 @@
18
18
  "provenance": true
19
19
  },
20
20
  "exports": {
21
- ".": "./src/index.ts"
21
+ ".": "./src/index.ts",
22
+ "./app-catalogs": "./src/app-catalogs.ts"
22
23
  },
23
24
  "files": [
24
25
  "src",
@@ -29,13 +30,13 @@
29
30
  "LICENSE"
30
31
  ],
31
32
  "engines": {
32
- "bun": ">=1.4.0"
33
+ "bun": ">=1.4.2"
33
34
  },
34
35
  "scripts": {
35
36
  "typecheck": "tsc --noEmit -p tsconfig.json",
36
37
  "test": "bun test"
37
38
  },
38
39
  "dependencies": {
39
- "@ultimat3/core": "23.0.0"
40
+ "@ultimat3/core": "25.0.0"
40
41
  }
41
42
  }
@@ -0,0 +1,77 @@
1
+ // The ONE reader of an app's declared locales: its catalog module's `defineCatalogs()`, imported
2
+ // and read back. The CLI (manifest, worker, prerender, `x shot`, `x g`, `x i18n`) and the e2e
3
+ // preload each kept a copy, and the copies disagreed. Server-only, so its own entry
4
+ // (`@ultimat3/i18n/app-catalogs`): `index.ts` is in every island.
5
+
6
+ // why: Bun exposes no path-join primitive; the catalog module is joined to the app root.
7
+ import { join } from 'node:path';
8
+ import { DEFAULT_LOCALE } from '@ultimat3/core';
9
+ import { localeConfig, routedLocales } from './context';
10
+ import type { CatalogSet, CatalogSources } from './define-catalogs';
11
+ import { catalogDeclarationCount, isDeclaredCatalogSet } from './define-catalogs';
12
+
13
+ /** Where an app declares its catalogs — `x new` writes it, and its `defineCatalogs()` runs there. */
14
+ export const APP_CATALOGS_PATH = 'packages/i18n/src/index.ts';
15
+
16
+ export interface AppLocaleSet {
17
+ /** Every locale the app routes, its default first. */
18
+ readonly locales: readonly string[];
19
+ readonly defaultLocale: string;
20
+ }
21
+
22
+ /**
23
+ * `root`'s declared locales, or `undefined` when no declaration ran: no catalog module, or one
24
+ * that calls no `defineCatalogs()`. Never `localeConfig()` read blind — it ALWAYS answers a
25
+ * fallback, so a module declaring nothing would inherit whatever an earlier import declared, and a
26
+ * caller would pin a language no visitor sees. Declared means this import ran the call, or the
27
+ * module exports the set one returned (a cached module evaluates once: the boot scan imported it).
28
+ *
29
+ * A module that will not import throws its own error: each caller decides whether that is a
30
+ * refusal or "nothing declared".
31
+ */
32
+ export async function loadAppCatalogs(root: string): Promise<AppLocaleSet | undefined> {
33
+ const path = join(root, APP_CATALOGS_PATH);
34
+ if (!(await Bun.file(path).exists())) return undefined;
35
+ const before = catalogDeclarationCount();
36
+ const module = (await import(path)) as Readonly<Record<string, unknown>>;
37
+ const ran = catalogDeclarationCount() !== before;
38
+ // The exported set FIRST: it is the declaration itself, where the locale config is shared,
39
+ // process-wide state a later `configureLocales()` or a reset may have moved since the module ran
40
+ // — and a cached module never runs its `defineCatalogs()` again to put it back.
41
+ const exported = Object.values(module).find(isDeclaredCatalogSet) as DeclaredSet | undefined;
42
+ if (exported !== undefined) return setOf(exported.default, exported.locales);
43
+ if (!ran) return undefined;
44
+ // Declared by this very import and not exported: the config is what that call just wrote.
45
+ const defaultLocale = localeConfig().fallback;
46
+ if (defaultLocale === '') return undefined;
47
+ return { locales: [...routedLocales()], defaultLocale };
48
+ }
49
+
50
+ /** What `isDeclaredCatalogSet` vouches for: a `CatalogSet` some `defineCatalogs()` returned. */
51
+ type DeclaredSet = Pick<CatalogSet<CatalogSources>, 'default' | 'locales'>;
52
+
53
+ /** Default first, as `routedLocales()` orders them — every consumer enumerates in this order. */
54
+ const setOf = (defaultLocale: string, locales: readonly string[]): AppLocaleSet => ({
55
+ locales: [defaultLocale, ...locales.filter((locale) => locale !== defaultLocale)],
56
+ defaultLocale,
57
+ });
58
+
59
+ /**
60
+ * The answer for an app that declares no catalogs: the framework default, alone. ONE value, so the
61
+ * PWA manifest, the service worker, the prerender and every `x` command name the same language for
62
+ * such an app — before it, the manifest said `en` off its own literal while the worker and the
63
+ * prerender read `localeConfig()` blind and could inherit an earlier import's declaration.
64
+ */
65
+ export const UNDECLARED_LOCALES: AppLocaleSet = Object.freeze({
66
+ locales: Object.freeze([DEFAULT_LOCALE]),
67
+ defaultLocale: DEFAULT_LOCALE,
68
+ });
69
+
70
+ /**
71
+ * `root`'s locales whether it declared any or not: `loadAppCatalogs`, else `UNDECLARED_LOCALES`.
72
+ * The caller that must tell the two apart (`x g`, which writes no catalog for an app without one)
73
+ * asks `loadAppCatalogs`; every other caller asks this. A module that will not import still throws.
74
+ */
75
+ export async function appLocaleSet(root: string): Promise<AppLocaleSet> {
76
+ return (await loadAppCatalogs(root)) ?? UNDECLARED_LOCALES;
77
+ }
@@ -65,7 +65,9 @@
65
65
  "action": "Try again"
66
66
  },
67
67
  "page": {
68
- "builtWith": "Built with Ultimate"
68
+ "builtWith": "Built with Ultimate",
69
+ "code": "code",
70
+ "request": "request"
69
71
  }
70
72
  },
71
73
  "validation": {
@@ -183,7 +185,18 @@
183
185
  "name": "Admin"
184
186
  },
185
187
  "dashboard": {
186
- "title": "Dashboard"
188
+ "title": "Dashboard",
189
+ "records": {
190
+ "title": "Records",
191
+ "label": "Records per resource",
192
+ "resource": "Resource",
193
+ "total": "records in all"
194
+ },
195
+ "access": {
196
+ "title": "Access",
197
+ "allowed": "{allowed} of {total} allowed",
198
+ "open": "Open the list"
199
+ }
187
200
  },
188
201
  "denied": {
189
202
  "body": "This account is missing {permission} ({reason})."
@@ -205,7 +218,8 @@
205
218
  "secret-required": "A value is required: this column is sealed and has no default.",
206
219
  "action-not-applicable": "This action does not apply to the row as it is now.",
207
220
  "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."
221
+ "batch-not-offered": "This action does not run over a selection of rows.",
222
+ "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
223
  },
210
224
  "filter": {
211
225
  "any": "Any",
@@ -326,7 +340,8 @@
326
340
  "skipped": {
327
341
  "forbidden": "this account may not search that entity",
328
342
  "no-text-fields": "that entity has no text field to search",
329
- "no-repo": "that entity has no repo bound"
343
+ "no-repo": "that entity has no repo bound",
344
+ "failed": "that entity's repo failed while searching"
330
345
  }
331
346
  },
332
347
  "value": {
@@ -485,7 +500,27 @@
485
500
  },
486
501
  "timeline": {
487
502
  "title": "Timeline",
488
- "question": "where did the time go in this request?"
503
+ "question": "where did the time go in this request?",
504
+ "requests": "Recent requests",
505
+ "spans": "Spans, on the request's own clock",
506
+ "byKind": "Time by kind",
507
+ "ms": "{ms} ms",
508
+ "empty": "No request has been traced yet — open a page of the app, then reload this tab.",
509
+ "kind": {
510
+ "http": "http",
511
+ "render": "render",
512
+ "action": "action",
513
+ "policy": "policy",
514
+ "sql": "sql",
515
+ "cache": "cache",
516
+ "job": "job"
517
+ },
518
+ "nPlusOne": {
519
+ "title": "Repeated statements",
520
+ "fix": "fix:",
521
+ "clean": "no statement repeated past the detector's threshold in this request",
522
+ "unwired": "no N+1 detector is attached to this process — x dev wires one"
523
+ }
489
524
  },
490
525
  "live": {
491
526
  "title": "Live",
@@ -493,7 +528,19 @@
493
528
  },
494
529
  "jobs": {
495
530
  "title": "Jobs",
496
- "question": "is the queue moving, and which step failed?"
531
+ "question": "is the queue moving, and which step failed?",
532
+ "chart": {
533
+ "label": "Recent runs by state",
534
+ "total": "recent runs",
535
+ "state": "State",
536
+ "runs": "Runs"
537
+ },
538
+ "state": {
539
+ "ok": "done",
540
+ "running": "running",
541
+ "failed": "failed",
542
+ "dead": "dead"
543
+ }
497
544
  },
498
545
  "db": {
499
546
  "title": "DB",
package/src/context.ts CHANGED
@@ -3,18 +3,19 @@
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';
7
- import { type Catalog, mergeCatalogs } from './catalog';
8
6
  import {
7
+ assertLocale,
8
+ cachedFormatter,
9
+ canonicalLocale,
9
10
  DEFAULT_LOCALE,
10
11
  type Direction,
11
12
  directionOf,
12
- type Locale,
13
- negotiateLocale,
14
- normalizeLocale,
15
- SUPPORTED_LOCALES,
16
- } from './locales';
17
- import { createTranslator, type TranslateVars, type Translator } from './translator';
13
+ readCookie,
14
+ tryUseContext,
15
+ } from '@ultimat3/core';
16
+ import { type Catalog, mergeCatalogs } from './catalog';
17
+ import { type Locale, negotiateLocale, normalizeLocale, SUPPORTED_LOCALES } from './locales';
18
+ import { catalogTranslator, type TranslateVars, type Translator } from './translator';
18
19
 
19
20
  /** The cookie an explicit language switcher writes. */
20
21
  export const LOCALE_COOKIE = 'x_locale';
@@ -145,30 +146,18 @@ export function resolveLocale(
145
146
  return { locale: fallback, direction: directionOf(fallback), source: 'default' };
146
147
  }
147
148
 
148
- /** Read `x_locale` out of a raw `Cookie` header. */
149
+ /**
150
+ * Read `x_locale` out of a raw `Cookie` header — through core's `readCookie`, so a malformed
151
+ * escape (`x_locale=%`) is the raw value, which `resolveLocale` then fails to normalise and skips.
152
+ */
149
153
  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;
154
+ return readCookie(cookieHeader, LOCALE_COOKIE) ?? undefined;
166
155
  }
167
156
 
168
157
  /**
169
158
  * Ambient locale for the in-flight request; the configured fallback outside one.
170
159
  *
171
- * The store is **`Ctx.locale`**, core's own declared field, so `createContext({ locale })` and
160
+ * The store is **`Ctx.locale`**, core's own declared field, so `ctxOf({ locale })` and
172
161
  * `withChildContext({ locale })` are the only writers and this package publishes no second one.
173
162
  * `@ultimat3/time`'s `currentTimeZone()` is the same shape over `Ctx.tz`, deliberately.
174
163
  */
@@ -304,10 +293,10 @@ export function catalogFor(locale: Locale): Catalog {
304
293
  export function translatorFor<TCatalog = Catalog>(locale: Locale): Translator<TCatalog> {
305
294
  const key = translatorKey(locale);
306
295
  // Resolved BEFORE the memo, so what lands under `key` is the catalog that key stands for and
307
- // not whichever spelling asked first. The caller's own tag reaches `createTranslator` either
296
+ // not whichever spelling asked first. The caller's own tag reaches `catalogTranslator` either
308
297
  // way — the catalog is chosen by locale, the formatting by what the caller actually sent.
309
298
  const translator = cachedFormatter(translators, key, () =>
310
- createTranslator(catalogFor(registeredUnder(locale, key) ?? locale), locale),
299
+ catalogTranslator(catalogFor(registeredUnder(locale, key) ?? locale), locale),
311
300
  );
312
301
  // One cast, one place. `TCatalog` narrows the key parameter and nothing else, so the memoized
313
302
  // object already is the right value — the registry cannot be keyed by an app's catalog type.
@@ -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';
@@ -37,11 +38,27 @@ let declarations = 0;
37
38
  */
38
39
  export const catalogDeclarationCount = (): number => declarations;
39
40
 
41
+ /**
42
+ * Every set `defineCatalogs()` returned in this process — held weakly, so a set nothing references
43
+ * is never kept alive by being asked about. `app-catalogs.ts` asks it of a module's exports: a
44
+ * module evaluates once, so on its second `import()` the count above has not moved, and the set it
45
+ * exports is the one fact left that it declared.
46
+ */
47
+ const declaredSets = new WeakSet<object>();
48
+
49
+ /** Whether `value` is a set some `defineCatalogs()` call returned — never a lookalike object. */
50
+ export const isDeclaredCatalogSet = (value: unknown): boolean =>
51
+ typeof value === 'object' && value !== null && declaredSets.has(value);
52
+
40
53
  export function defineCatalogs<TLocales extends CatalogSources>(
41
54
  input: DefineCatalogsInput<TLocales>,
42
55
  ): CatalogSet<TLocales> {
43
56
  const locales = Object.keys(input.locales) as (keyof TLocales & string)[];
44
57
  if (!locales.includes(input.default)) throw localeUnsupported(input.default, locales);
58
+ // Every tag, before anything is registered. `configureLocales` below runs the same screen, but
59
+ // after the register loop — so its `X_LOCALE_INVALID` arrived with the malformed tag already in
60
+ // `registeredLocales()` and the well-formed locales' strings already live.
61
+ for (const locale of locales) assertLocale(locale);
45
62
 
46
63
  // Load everything before registering anything: a malformed catalog must fail the boot
47
64
  // whole, not leave half the locales live and the other half missing.
@@ -57,10 +74,12 @@ export function defineCatalogs<TLocales extends CatalogSources>(
57
74
  declarations += 1;
58
75
 
59
76
  const catalogs = Object.fromEntries(loaded) as Readonly<Record<keyof TLocales & string, Catalog>>;
60
- return {
77
+ const set: CatalogSet<TLocales> = {
61
78
  default: input.default,
62
79
  locales,
63
80
  catalogs,
64
81
  keys: () => catalogKeys(mergeCatalogs(...loaded.map(([, catalog]) => catalog))),
65
82
  };
83
+ declaredSets.add(set);
84
+ return set;
66
85
  }
package/src/framework.ts CHANGED
@@ -3,10 +3,10 @@
3
3
  * Under every app catalog, so an app overrides any framework key and forgets none.
4
4
  */
5
5
 
6
+ import { DEFAULT_LOCALE } from '@ultimat3/core';
6
7
  import { type Catalog, loadCatalog } from './catalog';
7
8
  import en from './catalogs/en.json';
8
9
  import { registerBaseCatalog } from './context';
9
- import { DEFAULT_LOCALE, type Locale } from './locales';
10
10
 
11
11
  /**
12
12
  * `errors.*`, `auth.*`, `pagination.*`, `admin.*`, `validation.*`, `common.*`, `time.*`, `ui.*`.
@@ -17,20 +17,10 @@ import { DEFAULT_LOCALE, type Locale } from './locales';
17
17
  */
18
18
  export const FRAMEWORK_CATALOG: Catalog = loadCatalog(en);
19
19
 
20
- /**
21
- * The ONE locale this catalog is written in.
22
- *
23
- * Registering it under every locale was a fallback chain with no name: an app shipping only `es`
24
- * served `Page not found` and English `ui.*` chrome with `isMiss` reading FALSE, so nothing
25
- * downstream could see the gap — the one thing `⟦key⟧` exists to make impossible. A non-`en` app
26
- * translates the framework keys it renders into its own catalog.
27
- */
28
- export const FRAMEWORK_CATALOG_LOCALE: Locale = DEFAULT_LOCALE;
29
-
30
20
  // At module scope, and that is the whole point: `index.ts` re-exports this file, so anything that
31
21
  // can call `t()` has already imported the module that installs these strings. It was a function
32
22
  // (`registerFrameworkCatalog`) that only `defineCatalogs` called until 5.1.0 — so an app whose
33
23
  // catalog module nothing imported rendered `⟦errors.notFound.title⟧` on its 404 page, `⟦ui.*⟧` in
34
24
  // every design-system control, and had no way to notice (issue #249). Registration that an app
35
25
  // can forget is not registration; the framework's half of it is now unforgettable by construction.
36
- registerBaseCatalog(FRAMEWORK_CATALOG_LOCALE, FRAMEWORK_CATALOG);
26
+ registerBaseCatalog(DEFAULT_LOCALE, FRAMEWORK_CATALOG);
package/src/index.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  /** Public surface of @ultimat3/i18n. Explicit exports only. */
2
2
 
3
- // Anchored on purpose, and NOT by the `sideEffects` array: Bun reads any array as `false` and drops
4
- // the module regardless (oven-sh/bun#40650). `registerBaseCatalog` here fills the catalog `t()`
3
+ // Anchored on purpose, and not by the `sideEffects` array alone: Bun before 1.4.1 read any array as
4
+ // `false` and dropped the module regardless (oven-sh/bun#40650), and a bare import holds on every
5
+ // bundler. The array still lists it, because a bundler that honours the array drops a bare import
6
+ // of a module it does not list. `registerBaseCatalog` here fills the catalog `t()`
5
7
  // falls back to, and `t()` never imports this module — so without the bare import every framework
6
8
  // string renders as its ⟦key⟧ placeholder. `SIDE_EFFECTS_ANCHORS` carries the argument and
7
9
  // `bun run side-effects` enforces it.
@@ -73,7 +75,7 @@ export {
73
75
  type LocaleAudit,
74
76
  mergeExtractions,
75
77
  } from './extract';
76
- export { FRAMEWORK_CATALOG, FRAMEWORK_CATALOG_LOCALE } from './framework';
78
+ export { FRAMEWORK_CATALOG } from './framework';
77
79
  export {
78
80
  type InterpolationValue,
79
81
  type InterpolationVars,
@@ -87,17 +89,13 @@ export {
87
89
  } from './interpolate';
88
90
  export {
89
91
  type LocalePrefix,
90
- localeSegment,
91
92
  localizedPath,
92
93
  splitLocalePrefix,
93
94
  unlocalizedPath,
94
95
  } from './locale-path';
95
96
  export {
96
97
  assertSupportedLocale,
97
- DEFAULT_LOCALE,
98
98
  type Direction,
99
- directionOf,
100
- isRtl,
101
99
  isSupportedLocale,
102
100
  type LanguageRange,
103
101
  type Locale,
@@ -112,7 +110,7 @@ export {
112
110
  catalogRegistrationGaps,
113
111
  } from './registration';
114
112
  export {
115
- createTranslator,
113
+ catalogTranslator,
116
114
  isMiss,
117
115
  type TranslateVars,
118
116
  type TranslationKey,
@@ -6,11 +6,10 @@
6
6
  * `meta` and the prerender writes files from one answer.
7
7
  */
8
8
 
9
- import { type LocalePathSplit, localeSegment, localizePath, splitLocalePath } from '@ultimat3/core';
9
+ import { type LocalePathSplit, localizePath, splitLocalePath } from '@ultimat3/core';
10
10
  import { localeConfig, routedLocales } from './context';
11
11
  import type { Locale } from './locales';
12
12
 
13
- export { localeSegment };
14
13
  export type LocalePrefix = LocalePathSplit;
15
14
 
16
15
  /** `/en/precios` → `{ locale: 'en', path: '/precios', isDefault: false }`, or `undefined`. */
package/src/locales.ts CHANGED
@@ -3,13 +3,12 @@
3
3
  * HTTP `Accept-Language` negotiation. Knows nothing about catalogs or interpolation.
4
4
  */
5
5
 
6
+ import { DEFAULT_LOCALE } from '@ultimat3/core';
6
7
  import { localeUnsupported } from './errors';
7
8
 
8
9
  /** A normalized BCP-47 tag — primary subtag, lowercase (`pt`), or `lang-script` (`zh-hant`). */
9
10
  export type Locale = string;
10
11
 
11
- export const DEFAULT_LOCALE: Locale = 'en';
12
-
13
12
  /** Locales the framework's own catalogs may ship for. Apps narrow or extend this set. */
14
13
  export const SUPPORTED_LOCALES: readonly Locale[] = [
15
14
  'en',
@@ -44,10 +43,11 @@ export const SUPPORTED_LOCALES: readonly Locale[] = [
44
43
  'zh',
45
44
  ];
46
45
 
47
- // Direction moved to `@ultimat3/core` (`locale-direction.ts`) so a browser chunk that needs only
48
- // `directionOf` does not reach this barrel and the framework catalog it installs (issue #490).
49
- // Re-exported under the same names: every caller of this package is unchanged.
50
- export { type Direction, directionOf, isRtl } from '@ultimat3/core';
46
+ // Direction is `@ultimat3/core`'s (`locale-direction.ts`), so a browser chunk that needs only
47
+ // `directionOf` does not reach this barrel and the framework catalog it installs (issue #490). The
48
+ // TYPE is re-exported because `LocaleResolution.direction` names it; the functions are not — their
49
+ // one import path is `@ultimat3/core`.
50
+ export type { Direction } from '@ultimat3/core';
51
51
 
52
52
  /**
53
53
  * The registered spelling of `lower`, compared case-insensitively. A registered `pt-BR` used to
@@ -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
@@ -4,9 +4,10 @@
4
4
  * visible in dev and in review screenshots instead of invisible in production.
5
5
  */
6
6
 
7
+ import { DEFAULT_LOCALE } from '@ultimat3/core';
7
8
  import type { Catalog } from './catalog';
8
9
  import { type InterpolationVars, interpolate, selectPluralKey } from './interpolate';
9
- import { DEFAULT_LOCALE, type Locale } from './locales';
10
+ import type { Locale } from './locales';
10
11
 
11
12
  export interface TranslateVars extends InterpolationVars {
12
13
  /** Present ⇒ plural selection runs against the locale's CLDR categories. */
@@ -52,7 +53,7 @@ export interface Translator<TCatalog = Catalog> {
52
53
  readonly locale: Locale;
53
54
  }
54
55
 
55
- export function createTranslator(catalog: Catalog, locale: Locale = DEFAULT_LOCALE): Translator {
56
+ export function catalogTranslator(catalog: Catalog, locale: Locale = DEFAULT_LOCALE): Translator {
56
57
  const hasExact = (key: string): boolean => Object.hasOwn(catalog, key);
57
58
 
58
59
  const resolveKey = (key: string, vars?: TranslateVars): string => {
@@ -72,7 +73,11 @@ export function createTranslator(catalog: Catalog, locale: Locale = DEFAULT_LOCA
72
73
  const resolved = resolveKey(key, vars);
73
74
  const template = hasExact(resolved) ? catalog[resolved] : undefined;
74
75
  if (template === undefined) return `⟦${key}⟧`;
75
- return vars === undefined ? template : interpolate(template, vars);
76
+ // Always, never only when `vars` was passed: skipping it made `t('a')` and `t('a', {})` two
77
+ // renders of one message — the first put the raw `Hello {name}` on the page where the second
78
+ // put the loud `⟦name⟧`, and left `{{` escaped. `interpolate` returns a brace-free template
79
+ // untouched, so the common case pays one `includes`.
80
+ return interpolate(template, vars);
76
81
  };
77
82
 
78
83
  return Object.assign(translate, {