@ultimat3/i18n 29.3.2 → 30.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
@@ -18,6 +18,7 @@ Imported by every package that renders a string.
18
18
  | `registration.ts` | are the catalogs an app SHIPS in the registry it READS? No file access. |
19
19
  | `extract.ts` | static scan + audit for `x verify`. |
20
20
  | `catalogs/en.json` | data, not code. |
21
+ | `recording-translator.ts` | a translator that records what it could not answer: `TranslationMiss` |
21
22
 
22
23
  ## Rules
23
24
 
@@ -123,6 +124,9 @@ Imported by every package that renders a string.
123
124
  - A name is a **leaf or a branch, never both** — `parseNestedCatalog` refuses a dot inside a key,
124
125
  so `admin.detail.not-found` and `admin.detail.not-found.fix` cannot coexist. Both keys shipped
125
126
  and neither was in the catalog; the leaf is now `…not-found.cause`.
127
+ - `extract.ts` names no framework API. A new key-bearing shape goes in `cli/src/i18n-app-key-shapes.ts` as a callee or a `properties` entry, never in `DEFAULT_CALLEES`: the framework's own scan (`scripts/lib/i18n-scan.ts`) shares this function and its `mail.*` keys live outside `catalogs/en.json`.
128
+ - **`selectMessageKey` (`interpolate.ts`) is the one key selection.** `t()` and `recordingTranslator` both call it; a new selecting var goes there, or a mail refuses a key `t()` can answer. Every key `has()` answers true for renders: a missing family var still reaches `_select_other`/`_ordinal_other`.
129
+ - `auditCatalogs`'s `required` is `used` plus the `reference` locale's stems. `used` stays what source names; `unused` still compares against `used` alone.
126
130
 
127
131
  ## Commands
128
132
 
package/README.md CHANGED
@@ -5,6 +5,8 @@ Every string goes through `t()`. A key with no translation renders `⟦key⟧`
5
5
  never the English fallback, never silence. Numbers, dates and money are **not** i18n
6
6
  strings: format those with `Intl` via [@ultimat3/money](../money) and [@ultimat3/time](../time).
7
7
 
8
+ `bun add @ultimat3/i18n` · guide: [I18n](https://github.com/developerz-ai/ultimate/wiki/I18n)
9
+
8
10
  | Concern | Owner | Rule |
9
11
  |---|---|---|
10
12
  | Registration | `define-catalogs.ts` | `defineCatalogs()` — one call, at boot, per app |
@@ -126,13 +128,20 @@ The one reader the CLI (`loadApp`'s `locales`, the pwa manifest and service work
126
128
 
127
129
  ## Enforcement
128
130
 
129
- `extractKeys()` scans source for `t('...')` calls; `auditCatalogs()` reports keys used,
130
- keys missing per locale, and keys defined but never used; `assertCatalogsComplete()` is
131
+ `recordingTranslator(t)` wraps a translator and records each `TranslationMiss` (`{ key, slot? }`) for
132
+ a surface that refuses rather than shows `⟦key⟧`; `selectMessageKey()` is the one key selection it
133
+ and `t()` share.
134
+
135
+ `extractKeys()` scans source for `t('...')` calls, plus any other `callees` and key-bearing
136
+ `properties` (`{ name, whenFileContains? }`: a literal value is a usage, anything else `dynamic`)
137
+ the caller declares; this package names no API but its own. `auditCatalogs()` reports keys used,
138
+ keys missing per locale (`reference` makes each key that locale defines owed by the rest, by
139
+ plural stem, in `required`), and keys defined but never used; `assertCatalogsComplete()` is
131
140
  the `x verify` gate.
132
141
 
133
142
  ```
134
143
  X_CATALOG_MISSING_KEYS: catalog is incomplete
135
- cause: packages/i18n/catalogs/es.json is missing 2 key(s) used in source: admin.nav.jobs, common.save
144
+ cause: packages/i18n/catalogs/es.json is missing 2 key(s) source names or the reference locale defines: admin.nav.jobs, common.save
136
145
  fix: x i18n sync es
137
146
  ```
138
147
 
package/package.json CHANGED
@@ -1,7 +1,17 @@
1
1
  {
2
2
  "name": "@ultimat3/i18n",
3
- "version": "29.3.2",
3
+ "version": "30.0.0",
4
4
  "description": "Dependency-free translator, catalog flattening, locale negotiation and loud missing-key rendering",
5
+ "keywords": [
6
+ "ultimate",
7
+ "bun",
8
+ "framework",
9
+ "i18n",
10
+ "l10n",
11
+ "translation",
12
+ "intl"
13
+ ],
14
+ "homepage": "https://github.com/developerz-ai/ultimate/wiki/I18n",
5
15
  "license": "MIT",
6
16
  "type": "module",
7
17
  "sideEffects": [
@@ -38,6 +48,6 @@
38
48
  "test": "bun test"
39
49
  },
40
50
  "dependencies": {
41
- "@ultimat3/core": "29.3.2"
51
+ "@ultimat3/core": "30.0.0"
42
52
  }
43
53
  }
@@ -173,7 +173,16 @@
173
173
  },
174
174
  "audit": {
175
175
  "title": "Audit trail",
176
- "empty": "Nothing has been done to this row yet.",
176
+ "empty": "No entry in the trail matches.",
177
+ "empty-row": "Nothing has been done to this row yet.",
178
+ "filter": {
179
+ "entity": "Entity",
180
+ "actor": "Actor",
181
+ "outcome": "Outcome",
182
+ "since": "From",
183
+ "until": "Before",
184
+ "changes": "Changes and refusals only"
185
+ },
177
186
  "outcome": {
178
187
  "allowed": "Allowed",
179
188
  "denied": "Denied",
@@ -490,6 +499,38 @@
490
499
  "step": "Step"
491
500
  }
492
501
  }
502
+ },
503
+ "errors": {
504
+ "title": "Errors",
505
+ "detail": {
506
+ "title": "Error group"
507
+ },
508
+ "status": {
509
+ "open": "Open",
510
+ "resolved": "Resolved",
511
+ "ignored": "Ignored"
512
+ },
513
+ "field": {
514
+ "code": "Code",
515
+ "source": "Source",
516
+ "operation": "Operation",
517
+ "status": "Status",
518
+ "count": "Occurrences",
519
+ "first-seen": "First seen",
520
+ "last-seen": "Last seen",
521
+ "release": "Release",
522
+ "environment": "Environment",
523
+ "request": "Request",
524
+ "trace": "Trace"
525
+ },
526
+ "filter": {
527
+ "status": "Filter errors by status"
528
+ },
529
+ "empty": "No error groups match this filter.",
530
+ "count": "{count} occurrence",
531
+ "count_plural": "{count} occurrences",
532
+ "missing": "This error group no longer exists.",
533
+ "back": "Back to errors"
493
534
  }
494
535
  },
495
536
  "dev": {
@@ -599,10 +640,18 @@
599
640
  "ui": {
600
641
  "breadcrumb": "Breadcrumb",
601
642
  "cancel": "Cancel",
643
+ "chart": {
644
+ "none": "No data"
645
+ },
602
646
  "close": "Close",
603
647
  "confirm": "Confirm",
604
648
  "copied": "Copied",
605
649
  "copy": "Copy",
650
+ "date": {
651
+ "open": "Choose a date",
652
+ "previousMonth": "Previous month",
653
+ "nextMonth": "Next month"
654
+ },
606
655
  "dismiss": "Dismiss",
607
656
  "empty": "Nothing here yet.",
608
657
  "error": {
@@ -611,7 +660,17 @@
611
660
  "fix": "How to fix it",
612
661
  "title": "Something went wrong"
613
662
  },
663
+ "first": "First",
614
664
  "language": "Language",
665
+ "list": {
666
+ "toEnd": "Back to the end"
667
+ },
668
+ "live": {
669
+ "live": "Live",
670
+ "reconnecting": "Reconnecting",
671
+ "paused": "Paused",
672
+ "offline": "Offline"
673
+ },
615
674
  "load": {
616
675
  "more": "Load more",
617
676
  "end": "That's everything."
@@ -628,18 +687,73 @@
628
687
  "results": {
629
688
  "empty": "No matches"
630
689
  },
690
+ "range": {
691
+ "open": "Choose dates",
692
+ "from": "From",
693
+ "to": "To",
694
+ "apply": "Apply",
695
+ "custom": "Custom range",
696
+ "invalid": "That range is not valid, so the default is shown."
697
+ },
698
+ "refresh": {
699
+ "pull": "Pull to refresh",
700
+ "release": "Release to refresh",
701
+ "busy": "Refreshing…"
702
+ },
631
703
  "retry": "Try again",
704
+ "select": {
705
+ "none": "Nothing chosen",
706
+ "more": "Showing {shown} of {total} — type to narrow",
707
+ "remove": "Remove {label}",
708
+ "clear": "Clear"
709
+ },
710
+ "shortcuts": "Keyboard shortcuts",
632
711
  "skip": "Skip to content",
633
712
  "sort": {
634
713
  "ascending": "Sort ascending",
635
- "descending": "Sort descending"
636
- },
714
+ "descending": "Sort descending",
715
+ "handle": "Move",
716
+ "target": "Move to",
717
+ "lift": "Lifted, position {position} of {total} in {group}. Arrow keys move it, Space drops it, Escape puts it back.",
718
+ "moved": "Position {position} of {total} in {group}.",
719
+ "dropped": "Dropped at position {position} of {total} in {group}.",
720
+ "cancelled": "Not moved: back at position {position} of {total} in {group}.",
721
+ "up": "Move up",
722
+ "down": "Move down"
723
+ },
724
+ "step": "Step {current} of {total}: {label}",
637
725
  "suggestions": "{count} suggestions",
638
726
  "theme": {
639
727
  "light": "Light",
640
728
  "dark": "Dark",
641
729
  "system": "System",
642
730
  "label": "Theme"
731
+ },
732
+ "tree": {
733
+ "loading": "Loading children…"
734
+ },
735
+ "pane": {
736
+ "resize": "Resize panes"
737
+ },
738
+ "code": {
739
+ "region": "Code",
740
+ "wrap": "Wrap lines",
741
+ "terminal": "Terminal output"
742
+ },
743
+ "diff": {
744
+ "region": "Changes",
745
+ "added": "Added",
746
+ "removed": "Removed",
747
+ "next": "Next change",
748
+ "folded": "{count} more lines not shown"
749
+ },
750
+ "log": {
751
+ "region": "Log",
752
+ "filter": "Filter lines",
753
+ "level": "Level",
754
+ "all": "Every level",
755
+ "follow": "Follow the end",
756
+ "capped": "Showing the last {count} lines"
643
757
  }
644
758
  }
645
759
  }
package/src/errors.ts CHANGED
@@ -10,6 +10,7 @@ export const I18N_ERROR_CODES = [
10
10
  'X_CATALOG_MISSING_KEYS',
11
11
  'X_CATALOG_INVALID',
12
12
  'X_CATALOG_UNREGISTERED',
13
+ 'X_I18N_FAMILY_INCOMPLETE',
13
14
  ] as const;
14
15
 
15
16
  export type I18nErrorCode = (typeof I18N_ERROR_CODES)[number];
@@ -19,6 +20,7 @@ export const I18N_ERROR_TITLES: Readonly<Record<I18nErrorCode, string>> = {
19
20
  X_CATALOG_MISSING_KEYS: 'a catalog is missing keys used in source',
20
21
  X_CATALOG_INVALID: 'a catalog entry is malformed',
21
22
  X_CATALOG_UNREGISTERED: 'a shipped catalog never reached the runtime registry',
23
+ X_I18N_FAMILY_INCOMPLETE: 'a select or ordinal family has no _other',
22
24
  };
23
25
 
24
26
  // Titles must be registered for format() to render the contract's first line. Every code above is
@@ -55,11 +57,20 @@ export function catalogMissingKeys(locale: string, keys: readonly string[]): I18
55
57
  const suffix = keys.length > shown.length ? `, +${keys.length - shown.length} more` : '';
56
58
  return new I18nError({
57
59
  code: 'X_CATALOG_MISSING_KEYS',
58
- cause: `packages/i18n/catalogs/${locale}.json is missing ${keys.length} key(s) used in source: ${shown.join(', ')}${suffix}`,
60
+ cause: `packages/i18n/catalogs/${locale}.json is missing ${keys.length} key(s) source names or the reference locale defines: ${shown.join(', ')}${suffix}`,
59
61
  fix: `x i18n sync ${locale} # writes each key above as ⟦key⟧ — replace every one with the real string`,
60
62
  });
61
63
  }
62
64
 
65
+ /** A select or ordinal family with no `_other`: a value it does not name renders `⟦key⟧`. */
66
+ export function familyIncomplete(locale: string, owed: readonly string[]): I18nError {
67
+ return new I18nError({
68
+ code: 'X_I18N_FAMILY_INCOMPLETE',
69
+ cause: `packages/i18n/catalogs/${locale}.json defines a select or ordinal family with no _other, so a value it does not name renders the loud miss: ${shownKeys(owed)}`,
70
+ fix: `x i18n check --json # then add each key named here (${owed[0] ?? '<key>_select_other'}) to packages/i18n/catalogs/${locale}.json`,
71
+ });
72
+ }
73
+
63
74
  /**
64
75
  * The keys, capped and comma-joined. Shared by both factories below because a `fix:` an agent can
65
76
  * act on has to name keys, and a 300-key catalog rendered whole is a cause nobody reads.
package/src/extract.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { stripComments } from '@ultimat3/core';
8
8
  import { type Catalog, catalogKeys } from './catalog';
9
- import { catalogMissingKeys } from './errors';
9
+ import { catalogMissingKeys, familyIncomplete } from './errors';
10
10
  import { PLURAL_CATEGORIES } from './interpolate';
11
11
  import type { Locale } from './locales';
12
12
 
@@ -34,24 +34,42 @@ export interface LocaleAudit {
34
34
  locale: Locale;
35
35
  missing: string[];
36
36
  unused: string[];
37
+ /** Select and ordinal families with no `_other`: `<family>_other` is what each one owes. */
38
+ incomplete: string[];
37
39
  }
38
40
 
39
41
  export interface ExtractReport {
40
42
  /** Distinct static keys, sorted. */
41
43
  used: string[];
44
+ /** What every locale owes: `used` plus each stem the reference locale defines, sorted. */
45
+ required: string[];
42
46
  usages: KeyUsage[];
43
47
  dynamic: DynamicUsage[];
44
48
  locales: LocaleAudit[];
45
49
  ok: boolean;
46
50
  }
47
51
 
52
+ /** A property whose value is a catalog key, for an API that takes keys rather than strings. */
53
+ export interface KeyProperty {
54
+ name: string;
55
+ /** Only in a file holding this text: a bare `subject:` means nothing on its own. */
56
+ whenFileContains?: string;
57
+ }
58
+
48
59
  export interface ExtractOptions {
49
60
  /** Call names treated as translator calls. */
50
61
  callees?: readonly string[];
62
+ /** Properties whose literal value is a usage and whose any other value is `dynamic`. */
63
+ properties?: readonly KeyProperty[];
51
64
  }
52
65
 
53
66
  const DEFAULT_CALLEES: readonly string[] = ['t', '$t', 'translate'];
54
- const STRING_LITERAL = /^(['"`])((?:[^\\]|\\.)*?)\1$/s;
67
+ // The closing quote may not recur inside: `'a' + 'b'` is two literals, not a key spelled `a' + 'b`.
68
+ const STRING_LITERAL = /^(['"`])((?:(?!\1)[^\\]|\\.)*)\1$/s;
69
+ // A value runs to the first comma, closer or line break outside a string. Strings are matched whole
70
+ // so a comma in one does not end it. The trailing `;` is captured because it marks a type member
71
+ // (`titleKey: string;`): an object literal never ends a property with one.
72
+ const PROPERTY_VALUE = String.raw`((?:'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"|\`(?:[^\`\\]|\\.)*\`|[^,;})\n'"\`])*)(;?)`;
55
73
 
56
74
  /**
57
75
  * Scan one source file. Deliberately a lexer-free scan: a translator call is a
@@ -72,26 +90,43 @@ export function extractKeys(
72
90
 
73
91
  const usages: KeyUsage[] = [];
74
92
  const dynamic: DynamicUsage[] = [];
75
-
76
- // Over the COMMENT-STRIPPED text, never the raw file: a `// … t('…', { … })` in a comment matched
77
- // the shape above and produced a phantom key that `x i18n sync` would have written into every
78
- // catalog as `"…": "⟦…⟧"` (measured 2026-09-05). The mask keeps every newline and every string
79
- // literal in place, so an index into it is an index into `source` and the positions below hold.
80
- for (const match of stripComments(source).matchAll(pattern)) {
81
- const argument = (match[1] ?? '').trim();
82
- const index = match.index ?? 0;
93
+ const record = (rawArgument: string | undefined, index: number): void => {
94
+ const argument = (rawArgument ?? '').trim();
83
95
  const position = positionOf(source, index);
84
- if (argument === '') continue;
96
+ if (argument === '') return;
85
97
  const literal = STRING_LITERAL.exec(argument);
86
98
  const key = literal?.[2];
87
99
  if (key === undefined || key.includes('${')) {
88
100
  dynamic.push({ expression: argument, file, ...position });
89
- continue;
101
+ return;
90
102
  }
91
103
  usages.push({ key, file, ...position });
104
+ };
105
+
106
+ // Over the COMMENT-STRIPPED text, never the raw file: a `// … t('…', { … })` in a comment matched
107
+ // the shape above and produced a phantom key that `x i18n sync` would have written into every
108
+ // catalog as `"…": "⟦…⟧"` (measured 2026-09-05). The mask keeps every newline and every string
109
+ // literal in place, so an index into it is an index into `source` and the positions below hold.
110
+ const stripped = stripComments(source);
111
+ const properties = options.properties ?? [];
112
+ for (const match of stripped.matchAll(pattern)) record(match[1], match.index ?? 0);
113
+
114
+ // Properties are read in CODE only: `stripComments` keeps strings, and a fix line or a docs
115
+ // example quoting `{ titleKey: 'x' }` is text. The value is still read off `stripped`.
116
+ const code = properties.length === 0 ? stripped : blankStrings(stripped);
117
+ for (const property of properties) {
118
+ const marker = property.whenFileContains;
119
+ if (marker !== undefined && !code.includes(marker)) continue;
120
+ const name = escapeRegExp(property.name);
121
+ // Not after an identifier char, a `.` or a quote: `subtitleKey`, `a.titleKey`, `'x.titleKey'`.
122
+ const shape = new RegExp(String.raw`(?<![\w$.'"-])${name}\s*:\s*${PROPERTY_VALUE}`, 'g');
123
+ for (const match of stripped.matchAll(shape)) {
124
+ const index = match.index ?? 0;
125
+ if (match[2] !== ';' && code[index] === stripped[index]) record(match[1], index);
126
+ }
92
127
  }
93
128
 
94
- return { usages, dynamic };
129
+ return { usages: usages.sort(bySourceOrder), dynamic: dynamic.sort(bySourceOrder) };
95
130
  }
96
131
 
97
132
  export interface AuditInput {
@@ -100,35 +135,53 @@ export interface AuditInput {
100
135
  catalogs: Readonly<Record<Locale, Catalog>>;
101
136
  /** Keys resolved at runtime (e.g. `time.cron.*`) — never reported unused. */
102
137
  ignoreUnused?: readonly string[];
138
+ /** Every locale also owes each key this one defines, compared by plural stem. */
139
+ reference?: Locale;
103
140
  }
104
141
 
105
142
  /**
106
143
  * A used key counts as present when the locale defines it *or* any of its plural
107
144
  * variants: `pl` legitimately defines `items_one`/`items_few`/`items_many` and no bare
108
- * `items`, and that must not read as a missing key.
145
+ * `items`, and that must not read as a missing key. The same comparison serves `reference`, so
146
+ * `ja`'s lone `items_other` answers `en`'s `items_one`/`items_other`.
109
147
  */
110
148
  export function auditCatalogs(input: AuditInput): ExtractReport {
111
149
  const used = [...new Set(input.extraction.usages.map((usage) => usage.key))].sort();
112
150
  const ignore = input.ignoreUnused ?? [];
151
+ // By family stem too: a locale with no grammatical gender owes `invite`, and its lone
152
+ // `invite_select_other` answers it — never every value the reference names.
153
+ const referenced = catalogKeys(input.catalogs[input.reference ?? ''] ?? {}).map((key) =>
154
+ familyStemOf(stemOf(key)),
155
+ );
156
+ const required = [...new Set([...used, ...referenced])].sort();
113
157
 
114
158
  const locales: LocaleAudit[] = Object.keys(input.catalogs)
115
159
  .sort()
116
160
  .map((locale) => {
117
161
  const catalog = input.catalogs[locale] ?? {};
118
- const missing = used.filter((key) => !definesKey(catalog, key));
162
+ const missing = required.filter((key) => !definesKey(catalog, key));
119
163
  const usedWithVariants = new Set(used.flatMap(variantsOf));
164
+ const usedSet = new Set(used);
165
+ // A family member is used when its stem is: `t('invite', { select })` reads every one.
120
166
  const unused = catalogKeys(catalog).filter(
121
- (key) => !usedWithVariants.has(key) && !isIgnored(key, ignore),
167
+ (key) =>
168
+ !usedWithVariants.has(key) && !usedSet.has(familyStemOf(key)) && !isIgnored(key, ignore),
122
169
  );
123
- return { locale, missing, unused };
170
+ return {
171
+ locale,
172
+ missing,
173
+ unused,
174
+ incomplete: incompleteFamilies(catalogKeys(catalog), usedSet),
175
+ };
124
176
  });
125
177
 
126
178
  return {
127
179
  used,
180
+ required,
128
181
  usages: input.extraction.usages,
129
182
  dynamic: input.extraction.dynamic,
130
183
  locales,
131
- ok: locales.every((audit) => audit.missing.length === 0),
184
+ ok: locales.every((audit) => audit.missing.length === 0 && audit.incomplete.length === 0),
132
185
  };
133
186
  }
134
187
 
@@ -136,6 +189,7 @@ export function auditCatalogs(input: AuditInput): ExtractReport {
136
189
  export function assertCatalogsComplete(report: ExtractReport): void {
137
190
  for (const audit of report.locales) {
138
191
  if (audit.missing.length > 0) throw catalogMissingKeys(audit.locale, audit.missing);
192
+ if (audit.incomplete.length > 0) throw familyIncomplete(audit.locale, audit.incomplete);
139
193
  }
140
194
  }
141
195
 
@@ -159,19 +213,114 @@ export async function extractFromFiles(
159
213
  }
160
214
 
161
215
  function definesKey(catalog: Catalog, key: string): boolean {
162
- return variantsOf(key).some((candidate) => Object.hasOwn(catalog, candidate));
216
+ return (
217
+ variantsOf(key).some((candidate) => Object.hasOwn(catalog, candidate)) ||
218
+ catalogKeys(catalog).some((defined) => familyStemOf(defined) === key)
219
+ );
220
+ }
221
+
222
+ const FAMILY = /_(select|ordinal)_/;
223
+
224
+ /** `invite_select_female_one` → `invite`; a key in no family is its own. */
225
+ function familyStemOf(key: string): string {
226
+ const match = FAMILY.exec(key);
227
+ return match === null || match.index === 0 ? key : key.slice(0, match.index);
228
+ }
229
+
230
+ /**
231
+ * Every family prefix (`invite_select`, `place_ordinal`, `invite_select_female_ordinal`) whose
232
+ * `_other` — bare or plural-suffixed — the catalog does not define: a value the family does not
233
+ * name would render the loud miss. Answered as the key owed, `<prefix>_other`. A key source names
234
+ * itself (`t('theme_select_label')`) is an ordinary key that happens to spell `_select_`, never a
235
+ * family member: it owes no `theme_select_other`.
236
+ */
237
+ function incompleteFamilies(keys: readonly string[], named: ReadonlySet<string>): string[] {
238
+ const defined = new Set(keys);
239
+ const owed = new Set<string>();
240
+ for (const key of keys) {
241
+ if (named.has(stemOf(key))) continue;
242
+ for (const match of key.matchAll(/_(select|ordinal)_/g)) {
243
+ if (match.index === 0) continue;
244
+ const prefix = key.slice(0, match.index + match[0].length - 1);
245
+ const other = `${prefix}_other`;
246
+ const present =
247
+ variantsOf(other).some((candidate) => defined.has(candidate)) ||
248
+ keys.some((candidate) => candidate.startsWith(`${other}_`));
249
+ if (!present) owed.add(other);
250
+ }
251
+ }
252
+ return [...owed].sort();
163
253
  }
164
254
 
165
255
  function variantsOf(key: string): string[] {
166
256
  return [key, `${key}_plural`, ...PLURAL_CATEGORIES.map((category) => `${key}_${category}`)];
167
257
  }
168
258
 
259
+ /** `items_few` → `items`. Anything else is its own stem: `sort_order` is no plural. */
260
+ function stemOf(key: string): string {
261
+ const cut = key.lastIndexOf('_');
262
+ if (cut <= 0) return key;
263
+ const suffix = key.slice(cut + 1);
264
+ const plural = suffix === 'plural' || PLURAL_CATEGORIES.some((category) => category === suffix);
265
+ return plural ? key.slice(0, cut) : key;
266
+ }
267
+
169
268
  function isIgnored(key: string, patterns: readonly string[]): boolean {
170
269
  return patterns.some((pattern) =>
171
270
  pattern.endsWith('*') ? key.startsWith(pattern.slice(0, -1)) : key === pattern,
172
271
  );
173
272
  }
174
273
 
274
+ const BLANK = '\u0000';
275
+
276
+ /**
277
+ * `text` with every string literal's contents replaced by `BLANK`, same length, so an index into it is
278
+ * an index into `text`. A template's `${…}` is code and stays: a key can be written there.
279
+ */
280
+ function blankStrings(text: string): string {
281
+ let out = '';
282
+ // One entry per open template: the brace depth of its `${…}`, or -1 while in its text.
283
+ const templates: number[] = [];
284
+ let quote: string | undefined;
285
+ for (let i = 0; i < text.length; i += 1) {
286
+ const char = text[i] ?? '';
287
+ const top = templates.length - 1;
288
+ const inTemplateText = quote === undefined && templates[top] === -1;
289
+ if (quote !== undefined || inTemplateText) {
290
+ if (char === '\\') {
291
+ out += BLANK + (text[i + 1] === undefined ? '' : BLANK);
292
+ i += 1;
293
+ } else if (quote !== undefined && (char === quote || char === '\n')) {
294
+ quote = undefined;
295
+ out += char;
296
+ } else if (inTemplateText && char === '`') {
297
+ templates.pop();
298
+ out += char;
299
+ } else if (inTemplateText && char === '$' && text[i + 1] === '{') {
300
+ templates[top] = 0;
301
+ out += '${';
302
+ i += 1;
303
+ } else {
304
+ out += char === '\n' ? char : BLANK;
305
+ }
306
+ continue;
307
+ }
308
+ if (char === "'" || char === '"') quote = char;
309
+ else if (char === '`') templates.push(-1);
310
+ else if (top >= 0 && char === '{') templates[top] = (templates[top] ?? 0) + 1;
311
+ else if (top >= 0 && char === '}') {
312
+ const depth = templates[top] ?? 0;
313
+ templates[top] = depth === 0 ? -1 : depth - 1;
314
+ }
315
+ out += char;
316
+ }
317
+ return out;
318
+ }
319
+
320
+ function bySourceOrder(a: DynamicUsage | KeyUsage, b: DynamicUsage | KeyUsage): number {
321
+ return a.line - b.line || a.column - b.column;
322
+ }
323
+
175
324
  function positionOf(source: string, index: number): { line: number; column: number } {
176
325
  let line = 1;
177
326
  let lineStart = 0;
package/src/index.ts CHANGED
@@ -55,6 +55,7 @@ export {
55
55
  catalogMissingKeys,
56
56
  catalogsNeverRegistered,
57
57
  catalogUnregistered,
58
+ familyIncomplete,
58
59
  I18N_ERROR_CODES,
59
60
  I18N_ERROR_TITLES,
60
61
  I18nError,
@@ -77,6 +78,7 @@ export {
77
78
  } from './extract';
78
79
  export { FRAMEWORK_CATALOG } from './framework';
79
80
  export {
81
+ familyStems,
80
82
  type InterpolationValue,
81
83
  type InterpolationVars,
82
84
  interpolate,
@@ -85,6 +87,7 @@ export {
85
87
  pluralCategory,
86
88
  pluralKeyCandidates,
87
89
  pluralVariantsOf,
90
+ selectMessageKey,
88
91
  selectPluralKey,
89
92
  } from './interpolate';
90
93
  export {
@@ -104,6 +107,11 @@ export {
104
107
  parseAcceptLanguage,
105
108
  SUPPORTED_LOCALES,
106
109
  } from './locales';
110
+ export {
111
+ type RecordingTranslator,
112
+ recordingTranslator,
113
+ type TranslationMiss,
114
+ } from './recording-translator';
107
115
  export {
108
116
  assertCatalogsRegistered,
109
117
  type CatalogRegistrationGap,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `{var}` substitution and CLDR plural-form selection.
2
+ * `{var}` substitution and key selection: CLDR plurals, ordinals and `select` families.
3
3
  * Pure string work: it never reads a catalog and never resolves a locale.
4
4
  */
5
5
 
@@ -103,12 +103,85 @@ export function selectPluralKey(
103
103
  return key;
104
104
  }
105
105
 
106
+ /** A `select` value that can be part of a key: anything else selects nothing, never a stray key. */
107
+ const SELECT_VALUE = /^[A-Za-z0-9-]{1,32}$/;
108
+
109
+ /**
110
+ * The family stems `t(key, { select, ordinal })` reads, most specific first: `select` picks
111
+ * `<key>_select_<value>` then `<key>_select_other`; `ordinal` picks `<key>_ordinal_<category>` (CLDR's
112
+ * ORDINAL rules: `en` 1 one, 2 two, 3 few, 4 other, 11 other) then `<key>_ordinal_other`. Both
113
+ * compose, select outermost: `invite_select_female_ordinal_one`. Empty when neither var selects.
114
+ */
115
+ export function familyStems(
116
+ key: string,
117
+ vars: InterpolationVars | undefined,
118
+ locale: string,
119
+ ): string[] {
120
+ const select = vars?.['select'];
121
+ const ordinal = vars?.['ordinal'];
122
+ const selects = typeof select === 'string' && SELECT_VALUE.test(select);
123
+ const ordinals = typeof ordinal === 'number' && Number.isFinite(ordinal);
124
+ if (!selects && !ordinals) return [];
125
+ let stems = [key];
126
+ if (selects) stems = [...new Set([`${key}_select_${select}`, `${key}_select_other`])];
127
+ if (ordinals) {
128
+ const category = ordinalRulesFor(locale).select(ordinal);
129
+ stems = stems.flatMap((stem) => [
130
+ ...new Set([`${stem}_ordinal_${category}`, `${stem}_ordinal_other`]),
131
+ ]);
132
+ }
133
+ return stems;
134
+ }
135
+
136
+ /**
137
+ * The catalog entry one `t(key, vars)` reads. The ONE selection: `t()` and `recordingTranslator`
138
+ * both call it, so a new selecting var is added here and neither can answer differently. A family
139
+ * (`select`, `ordinal`) is asked first, each stem with `count`'s plural chain when there is one; a
140
+ * family the catalog does not define falls through to the key's own plural, as before.
141
+ */
142
+ export function selectMessageKey(
143
+ key: string,
144
+ vars: InterpolationVars | undefined,
145
+ locale: string,
146
+ has: (candidate: string) => boolean,
147
+ ): string {
148
+ const count = vars?.['count'];
149
+ const counted = typeof count === 'number' && Number.isFinite(count);
150
+ const first = (stems: readonly string[]): string | undefined => {
151
+ for (const stem of stems) {
152
+ const found = (counted ? pluralKeyCandidates(stem, count, locale) : [stem]).find(has);
153
+ if (found !== undefined) return found;
154
+ }
155
+ return undefined;
156
+ };
157
+ const family = first(familyStems(key, vars, locale));
158
+ if (family !== undefined) return family;
159
+ const own = counted ? selectPluralKey(key, count, locale, has) : key;
160
+ if (has(own)) return own;
161
+ // `Translator.has()` is true on `_select_other`/`_ordinal_other` alone, so a guarded `t(key)`
162
+ // without (or with an unusable) family var reaches them last — never a ⟦key⟧ after the guard.
163
+ return first([`${key}_select_other`, `${key}_ordinal_other`]) ?? own;
164
+ }
165
+
106
166
  /** All suffixed variants of `key` a locale could legitimately define. */
107
167
  export function pluralVariantsOf(key: string): string[] {
108
168
  return [`${key}_plural`, ...PLURAL_CATEGORIES.map((category) => `${key}_${category}`)];
109
169
  }
110
170
 
111
171
  const rulesCache = new Map<string, Intl.PluralRules>();
172
+ const ordinalCache = new Map<string, Intl.PluralRules>();
173
+
174
+ /** `pluralRulesFor`'s bound and canonical key, for CLDR's ordinal rules: "1st", "2nd", "3rd". */
175
+ function ordinalRulesFor(locale: string): Intl.PluralRules {
176
+ const tag = canonicalLocale(locale) ?? 'en';
177
+ return cachedFormatter(ordinalCache, tag, () => {
178
+ try {
179
+ return new Intl.PluralRules(tag, { type: 'ordinal' });
180
+ } catch {
181
+ return new Intl.PluralRules('en', { type: 'ordinal' });
182
+ }
183
+ });
184
+ }
112
185
 
113
186
  /**
114
187
  * Bounded and canonically keyed, because `locale` reaches here from an `Accept-Language` header
@@ -0,0 +1,61 @@
1
+ /**
2
+ * A translator that remembers what it could not answer, for a surface that refuses rather than
3
+ * shows `⟦key⟧`: a mail cannot be taken back. It reads the render's own facts, never the bytes,
4
+ * because user data may hold a `⟦`.
5
+ */
6
+
7
+ import { placeholdersOf, selectMessageKey } from './interpolate';
8
+ import { isMiss, type TranslateVars, type Translator } from './translator';
9
+
10
+ /** One thing a render asked for and could not fill. A `slot` names a `{var}` of a key that exists. */
11
+ export interface TranslationMiss {
12
+ readonly key: string;
13
+ readonly slot?: string | undefined;
14
+ }
15
+
16
+ export interface RecordingTranslator {
17
+ readonly t: Translator;
18
+ /** Distinct misses so far, sorted: a key may be rendered more than once. */
19
+ misses(): readonly TranslationMiss[];
20
+ }
21
+
22
+ /**
23
+ * Picks the template through `selectMessageKey`, the selector `t()` itself uses, because `has()` is
24
+ * too coarse to decide on: it says yes for a plural family read without a `count`, where `t()`
25
+ * renders `⟦key⟧`. A value that is itself a marker is a miss too: it is what `x i18n sync` seeds, so
26
+ * following a refusal's own fix must not be what delivers the brackets.
27
+ */
28
+ export function recordingTranslator(base: Translator): RecordingTranslator {
29
+ const misses = new Map<string, TranslationMiss>();
30
+ const note = (key: string, slot?: string): void => {
31
+ misses.set(slot === undefined ? key : `${key}{${slot}}`, { key, slot });
32
+ };
33
+ const exists = (candidate: string): boolean => base.raw(candidate) !== undefined;
34
+
35
+ const inspect = (key: string, vars: TranslateVars | undefined): void => {
36
+ const template = base.raw(selectMessageKey(key, vars, base.locale, exists));
37
+ if (template === undefined || isMiss(template)) {
38
+ note(key);
39
+ return;
40
+ }
41
+ for (const slot of placeholdersOf(template)) {
42
+ if (vars === undefined || !Object.hasOwn(vars, slot) || vars[slot] === undefined) {
43
+ note(key, slot);
44
+ }
45
+ }
46
+ };
47
+
48
+ const t: Translator = Object.assign(
49
+ (key: string, vars?: TranslateVars): string => {
50
+ inspect(key, vars);
51
+ return base(key, vars);
52
+ },
53
+ { has: base.has, raw: base.raw, keys: base.keys, locale: base.locale },
54
+ );
55
+
56
+ return {
57
+ t,
58
+ misses: () =>
59
+ [...misses.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).map(([, miss]) => miss),
60
+ };
61
+ }
package/src/translator.ts CHANGED
@@ -6,12 +6,16 @@
6
6
 
7
7
  import { DEFAULT_LOCALE } from '@ultimat3/core';
8
8
  import type { Catalog } from './catalog';
9
- import { type InterpolationVars, interpolate, selectPluralKey } from './interpolate';
9
+ import { type InterpolationVars, interpolate, selectMessageKey } from './interpolate';
10
10
  import type { Locale } from './locales';
11
11
 
12
12
  export interface TranslateVars extends InterpolationVars {
13
13
  /** Present ⇒ plural selection runs against the locale's CLDR categories. */
14
14
  count?: number;
15
+ /** Present ⇒ `<key>_ordinal_<category>` by CLDR's ordinal rules, then `<key>_ordinal_other`. */
16
+ ordinal?: number;
17
+ /** Present ⇒ `<key>_select_<value>`, then `<key>_select_other`: gender, role, any closed set. */
18
+ select?: string;
15
19
  }
16
20
 
17
21
  /** Suffixes `selectPluralKey` probes: the CLDR categories plus the two-form shortcut. */
@@ -22,6 +26,13 @@ type PluralStem<TKey extends string> = TKey extends `${infer TStem}_${PluralSuff
22
26
  ? TStem
23
27
  : never;
24
28
 
29
+ /** `invite_select_female_one` → `invite`; `place_ordinal_two` → `place`. */
30
+ type FamilyStem<TKey extends string> = TKey extends `${infer TStem}_select_${string}`
31
+ ? TStem
32
+ : TKey extends `${infer TStem}_ordinal_${string}`
33
+ ? TStem
34
+ : never;
35
+
25
36
  /** Recursion budget — an unresolved generic catalog would otherwise never bottom out. */
26
37
  type Depth = [never, 0, 1, 2, 3, 4, 5, 6, 7];
27
38
 
@@ -30,7 +41,7 @@ type CatalogPaths<TCatalog, TDepth extends number = 8> = [Depth[TDepth]] extends
30
41
  ? never
31
42
  : {
32
43
  [TKey in keyof TCatalog & string]: TCatalog[TKey] extends string
33
- ? TKey | PluralStem<TKey>
44
+ ? TKey | PluralStem<TKey> | FamilyStem<TKey>
34
45
  : `${TKey}.${CatalogPaths<TCatalog[TKey], Depth[TDepth]> & string}`;
35
46
  }[keyof TCatalog & string];
36
47
 
@@ -56,21 +67,13 @@ export interface Translator<TCatalog = Catalog> {
56
67
  export function catalogTranslator(catalog: Catalog, locale: Locale = DEFAULT_LOCALE): Translator {
57
68
  const hasExact = (key: string): boolean => Object.hasOwn(catalog, key);
58
69
 
59
- const resolveKey = (key: string, vars?: TranslateVars): string => {
60
- const count = vars?.count;
61
- if (typeof count === 'number' && Number.isFinite(count)) {
62
- return selectPluralKey(key, count, locale, hasExact);
63
- }
64
- return key;
65
- };
66
-
67
70
  const translate = (key: string, vars?: TranslateVars): string => {
68
71
  // Through `hasExact`, never a raw index: a key travels as data (`t(row.labelKey)`), and on a
69
72
  // `{}`-prototyped catalog `catalog['valueOf']` resolves to the INHERITED function instead of
70
73
  // reading as absent — so `interpolate` threw on a non-string, `t('constructor')` returned a
71
74
  // function through a signature typed `string`, and `isMiss(t('__proto__'))` threw on an object.
72
75
  // Catalogs are null-prototyped now; this guard is what makes that true of any catalog.
73
- const resolved = resolveKey(key, vars);
76
+ const resolved = selectMessageKey(key, vars, locale, hasExact);
74
77
  const template = hasExact(resolved) ? catalog[resolved] : undefined;
75
78
  if (template === undefined) return `⟦${key}⟧`;
76
79
  // Always, never only when `vars` was passed: skipping it made `t('a')` and `t('a', {})` two
@@ -85,7 +88,9 @@ export function catalogTranslator(catalog: Catalog, locale: Locale = DEFAULT_LOC
85
88
  hasExact(key) ||
86
89
  hasExact(`${key}_other`) ||
87
90
  hasExact(`${key}_plural`) ||
88
- hasExact(`${key}_one`),
91
+ hasExact(`${key}_one`) ||
92
+ hasExact(`${key}_select_other`) ||
93
+ hasExact(`${key}_ordinal_other`),
89
94
  raw: (key: string): string | undefined => (hasExact(key) ? catalog[key] : undefined),
90
95
  keys: (): string[] => Object.keys(catalog).sort(),
91
96
  locale,