@ultimat3/i18n 29.4.0 → 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 +4 -0
- package/README.md +12 -3
- package/package.json +12 -2
- package/src/catalogs/en.json +117 -3
- package/src/errors.ts +12 -1
- package/src/extract.ts +168 -19
- package/src/index.ts +8 -0
- package/src/interpolate.ts +74 -1
- package/src/recording-translator.ts +61 -0
- package/src/translator.ts +17 -12
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
|
-
`
|
|
130
|
-
|
|
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)
|
|
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": "
|
|
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": "
|
|
51
|
+
"@ultimat3/core": "30.0.0"
|
|
42
52
|
}
|
|
43
53
|
}
|
package/src/catalogs/en.json
CHANGED
|
@@ -173,7 +173,16 @@
|
|
|
173
173
|
},
|
|
174
174
|
"audit": {
|
|
175
175
|
"title": "Audit trail",
|
|
176
|
-
"empty": "
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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 === '')
|
|
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
|
-
|
|
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 =
|
|
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) =>
|
|
167
|
+
(key) =>
|
|
168
|
+
!usedWithVariants.has(key) && !usedSet.has(familyStemOf(key)) && !isIgnored(key, ignore),
|
|
122
169
|
);
|
|
123
|
-
return {
|
|
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
|
|
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,
|
package/src/interpolate.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `{var}` substitution and CLDR
|
|
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,
|
|
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 =
|
|
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,
|