@xndrjs/i18n 0.7.0 → 0.8.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.
Files changed (69) hide show
  1. package/README.md +67 -872
  2. package/dist/codegen/index.d.ts +117 -51
  3. package/dist/codegen/index.js +1585 -93
  4. package/dist/codegen/index.js.map +1 -1
  5. package/dist/index.d.ts +100 -234
  6. package/dist/index.js +74 -638
  7. package/dist/index.js.map +1 -1
  8. package/dist/validation/index.d.ts +5 -0
  9. package/dist/validation/index.js.map +1 -1
  10. package/package.json +2 -2
  11. package/src/IcuTranslationProviderMulti.test.ts +18 -40
  12. package/src/IcuTranslationProviderMulti.ts +27 -37
  13. package/src/audit/audit-dictionaries.ts +1 -1
  14. package/src/audit/run-audit.test.ts +6 -7
  15. package/src/builder-load-registry.ts +26 -5
  16. package/src/builder-loaders.ts +8 -11
  17. package/src/builder-types.test.ts +11 -42
  18. package/src/builder.test.ts +82 -243
  19. package/src/builder.ts +8 -110
  20. package/src/codegen/codegen-config-schema.ts +82 -77
  21. package/src/codegen/config.test.ts +55 -143
  22. package/src/codegen/config.ts +10 -67
  23. package/src/codegen/dictionary-spec-contract.test.ts +28 -0
  24. package/src/codegen/dictionary-spec-contract.ts +138 -0
  25. package/src/codegen/emit/dictionary-schema-file.ts +7 -43
  26. package/src/codegen/emit/instance-file.test.ts +35 -52
  27. package/src/codegen/emit/instance-file.ts +170 -268
  28. package/src/codegen/emit/namespace-loaders-file.test.ts +72 -74
  29. package/src/codegen/emit/namespace-loaders-file.ts +147 -90
  30. package/src/codegen/emit/types-file.test.ts +20 -41
  31. package/src/codegen/emit/types-file.ts +48 -74
  32. package/src/codegen/generate-i18n-types.test.ts +163 -492
  33. package/src/codegen/generate-i18n-types.ts +7 -269
  34. package/src/codegen/paths.ts +0 -14
  35. package/src/codegen/project-locales-set-namespace.test.ts +44 -86
  36. package/src/codegen/read-dictionary.test.ts +27 -9
  37. package/src/codegen/read-dictionary.ts +18 -40
  38. package/src/codegen/regenerate-namespaces.ts +180 -0
  39. package/src/codegen/run-codegen.test.ts +230 -0
  40. package/src/codegen/run-codegen.ts +252 -0
  41. package/src/codegen/types.ts +0 -6
  42. package/src/codegen-config/build-config.ts +4 -23
  43. package/src/codegen-config/codegen-config.test.ts +10 -6
  44. package/src/codegen-config/index.ts +18 -3
  45. package/src/engine.ts +3 -27
  46. package/src/fetch-artifact.ts +16 -0
  47. package/src/i18n-handle.ts +202 -0
  48. package/src/index.ts +15 -34
  49. package/src/project-locales.ts +2 -2
  50. package/src/scope-multi.ts +1 -20
  51. package/src/scope-types.ts +14 -6
  52. package/src/scope.test.ts +3 -312
  53. package/src/serialized-state.test.ts +114 -0
  54. package/src/serialized-state.ts +28 -0
  55. package/src/setup/setup-i18n.test.ts +22 -33
  56. package/src/setup/setup-i18n.ts +22 -27
  57. package/src/types.ts +3 -3
  58. package/src/validation/index.ts +4 -0
  59. package/src/IcuTranslationProviderSingle.test.ts +0 -177
  60. package/src/IcuTranslationProviderSingle.ts +0 -114
  61. package/src/builder-multi.ts +0 -481
  62. package/src/codegen/emit/dictionary-file.test.ts +0 -215
  63. package/src/codegen/emit/dictionary-file.ts +0 -244
  64. package/src/deep-freeze.test.ts +0 -40
  65. package/src/deep-freeze.ts +0 -22
  66. package/src/patch-key.test.ts +0 -186
  67. package/src/patch-key.ts +0 -140
  68. package/src/scope-single.ts +0 -85
  69. package/src/single-builder.ts +0 -153
package/src/types.ts CHANGED
@@ -4,7 +4,8 @@ export type LocaleDictionary = Record<string, string>;
4
4
  export type KeyDictionary = Record<string, LocaleDictionary>;
5
5
  export type MultiDictionary = Record<string, KeyDictionary>;
6
6
 
7
- export type LocaleOfSingle<Schema extends KeyDictionary> = {
7
+ /** Locales present on any key of a flat (per-namespace) key dictionary. */
8
+ export type LocaleOfKeys<Schema extends KeyDictionary> = {
8
9
  [K in keyof Schema]: keyof Schema[K];
9
10
  }[keyof Schema] &
10
11
  string;
@@ -19,7 +20,7 @@ export type LocaleOfMulti<Schema extends MultiDictionary> = {
19
20
  /** Subset of schema keys with any subset of project locales per key — valid input for merge operations. */
20
21
  export type PartialKeyDictionary<
21
22
  T extends KeyDictionary,
22
- Locales extends string = LocaleOfSingle<T>,
23
+ Locales extends string = LocaleOfKeys<T>,
23
24
  > = {
24
25
  [K in keyof T]?: Partial<Record<Locales, string>>;
25
26
  };
@@ -61,5 +62,4 @@ export type IcuTranslationProviderOptions<
61
62
  };
62
63
 
63
64
  export type LocaleCache = Record<string, IntlMessageFormat>;
64
- export type SingleCompiledCache = Record<string, LocaleCache>;
65
65
  export type MultiCompiledCache = Record<string, Record<string, LocaleCache>>;
@@ -1,3 +1,7 @@
1
+ /**
2
+ * External payload validation for server-side codegen/ingest (CMS → authoring files).
3
+ * Not a runtime patch API — validate before writing authoring, then `regenerateNamespaces` / `load()`.
4
+ */
1
5
  import { normalizeDictionaryPartial, normalizeKeyDictionaryPartial } from "./normalize.js";
2
6
  import { toDictionary, toNamespaceDictionary } from "./to-dictionary.js";
3
7
  import type {
@@ -1,177 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { IcuTranslationProviderSingle } from "./IcuTranslationProviderSingle.js";
3
-
4
- type TestSchema = {
5
- login_button: { en: string; it: string };
6
- welcome: { en: string; it: string };
7
- empty_label: { en: string };
8
- broken: { en: string };
9
- invoice_count: { en: string; it: string };
10
- item_count_zero: { en: string };
11
- item_count_exact: { en: string };
12
- dashboard_status: { en: string; it: string };
13
- inbox_owner: { en: string; it: string };
14
- ranking_position: { en: string; it: string };
15
- account_balance: { en: string; it: string };
16
- appointment_summary: { en: string; it: string };
17
- invoice_due_long: { en: string; it: string };
18
- discount_rate: { en: string; it: string };
19
- };
20
-
21
- type TestParams = {
22
- login_button: never;
23
- welcome: { name: string };
24
- empty_label: never;
25
- broken: { name: string };
26
- invoice_count: { count: number };
27
- item_count_zero: { count: number };
28
- item_count_exact: { count: number };
29
- dashboard_status: { msgCount: number; chatCount: number };
30
- inbox_owner: { gender: string; name: string };
31
- ranking_position: { position: number };
32
- account_balance: { amount: number };
33
- appointment_summary: { dueDate: Date | number; startTime: Date | number };
34
- invoice_due_long: { dueDate: Date | number };
35
- discount_rate: { rate: number };
36
- };
37
-
38
- const dictionary: TestSchema = {
39
- login_button: { en: "Login", it: "Accedi" },
40
- welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
41
- empty_label: { en: "" },
42
- broken: { en: "Hi {name" },
43
- invoice_count: {
44
- en: "You have {count, plural, one {1 invoice} other {{count} invoices}}",
45
- it: "Hai {count, plural, one {1 fattura} other {{count} fatture}}",
46
- },
47
- item_count_zero: {
48
- en: "{count, plural, zero {no items} one {1 item} other {{count} items}}",
49
- },
50
- item_count_exact: {
51
- en: "{count, plural, =5 {five items} one {1 item} other {{count} items}}",
52
- },
53
- dashboard_status: {
54
- en: "You have {msgCount, plural, one {1 message} other {{msgCount} messages}} in {chatCount, plural, one {one chat} other {{chatCount} chats}}",
55
- it: "Hai {msgCount, plural, one {1 messaggio} other {{msgCount} messaggi}} in {chatCount, plural, one {una chat} other {{chatCount} chat}}",
56
- },
57
- inbox_owner: {
58
- en: "{gender, select, female {{name} owns her inbox} male {{name} owns his inbox} other {{name} owns their inbox}}",
59
- it: "{gender, select, other {{name} gestisce la propria casella}}",
60
- },
61
- ranking_position: {
62
- en: "You finished {position, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}",
63
- it: "Hai concluso al {position, selectordinal, one {#°} other {#°}} posto",
64
- },
65
- account_balance: {
66
- en: "Balance: {amount, number, ::currency/EUR}",
67
- it: "Saldo: {amount, number, ::currency/EUR}",
68
- },
69
- appointment_summary: {
70
- en: "Due {dueDate, date, short} at {startTime, time, short}",
71
- it: "Scade il {dueDate, date, short} alle {startTime, time, short}",
72
- },
73
- invoice_due_long: {
74
- en: "Payment due on {dueDate, date, ::yMMMMd}",
75
- it: "Pagamento entro il {dueDate, date, ::yMMMMd}",
76
- },
77
- discount_rate: {
78
- en: "Save {rate, number, ::percent} today",
79
- it: "Risparmia il {rate, number, ::percent} oggi",
80
- },
81
- };
82
-
83
- describe("IcuTranslationProviderSingle", () => {
84
- const engine = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary);
85
-
86
- it("patches a preloaded key and invalidates cache", () => {
87
- const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary);
88
- expect(local.toScope().t("login_button", "en")).toBe("Login");
89
-
90
- local.patchKey("login_button", "en", "Sign in");
91
-
92
- expect(local.toScope().t("login_button", "en")).toBe("Sign in");
93
- expect(local.toScope().t("login_button", "it")).toBe("Accedi");
94
- });
95
-
96
- it("merges locales per key with applyLoadMergeSingle without dropping existing locales", () => {
97
- const local = new IcuTranslationProviderSingle<TestSchema, TestParams>({
98
- ...dictionary,
99
- // @ts-expect-error missing locale
100
- welcome: {
101
- en: "Welcome {name}!",
102
- },
103
- });
104
-
105
- local.applyLoadMergeSingle({
106
- welcome: {
107
- // @ts-expect-error adding locale via merge
108
- it: "Benvenuto {name}!",
109
- },
110
- });
111
-
112
- const view = local.toScope();
113
- expect(view.t("welcome", "en", { name: "Ada" })).toBe("Welcome Ada!");
114
- expect(view.t("welcome", "it", { name: "Ada" })).toBe("Benvenuto Ada!");
115
- expect(view.t("login_button", "en")).toBe("Login");
116
- });
117
-
118
- it("throws when patching a key that was not preloaded", () => {
119
- const local = new IcuTranslationProviderSingle<TestSchema, TestParams>({
120
- login_button: { en: "Login" },
121
- } as TestSchema);
122
-
123
- expect(() => local.patchKey("login_button", "it", "Accedi")).toThrow(
124
- "[i18n] Key not preloaded: login_button (it)"
125
- );
126
- });
127
-
128
- it("returns a deep-frozen snapshot from getAll", () => {
129
- expect(engine.getAll()).toEqual(dictionary);
130
- expect(engine.getAll()).not.toBe(dictionary);
131
- expect(Object.isFrozen(engine.getAll())).toBe(true);
132
- });
133
-
134
- it("does not mutate the engine when getAll snapshot is modified", () => {
135
- const snapshot = engine.getAll();
136
- expect(() => {
137
- snapshot.welcome.en = "Hacked";
138
- }).toThrow(TypeError);
139
- expect(engine.toScope().t("welcome", "en", { name: "Ada" })).toBe("Welcome Ada!");
140
- });
141
-
142
- it("does not reflect external dictionary mutations after construction", () => {
143
- const external = {
144
- login_button: { en: "Login", it: "Accedi" },
145
- welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
146
- empty_label: { en: "" },
147
- broken: { en: "Hi {name" },
148
- invoice_count: {
149
- en: "You have {count, plural, one {1 invoice} other {{count} invoices}}",
150
- it: "Hai {count, plural, one {1 fattura} other {{count} fatture}}",
151
- },
152
- item_count_zero: dictionary.item_count_zero,
153
- item_count_exact: dictionary.item_count_exact,
154
- dashboard_status: dictionary.dashboard_status,
155
- inbox_owner: dictionary.inbox_owner,
156
- ranking_position: dictionary.ranking_position,
157
- account_balance: dictionary.account_balance,
158
- appointment_summary: dictionary.appointment_summary,
159
- invoice_due_long: dictionary.invoice_due_long,
160
- discount_rate: dictionary.discount_rate,
161
- };
162
- const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(external);
163
- external.login_button.en = "Sign in";
164
- expect(local.toScope().t("login_button", "en")).toBe("Login");
165
- });
166
-
167
- describe("locale fallback", () => {
168
- it("rejects circular fallback maps at construction", () => {
169
- expect(
170
- () =>
171
- new IcuTranslationProviderSingle(dictionary, {
172
- localeFallback: { a: "b", b: "a" },
173
- })
174
- ).toThrow("[i18n] Circular locale fallback detected");
175
- });
176
- });
177
- });
@@ -1,114 +0,0 @@
1
- import { cloneAndFreeze } from "./deep-freeze.js";
2
- import { BuilderLoadRegistry, SINGLE_BUILDER_RESOURCE } from "./builder-load-registry.js";
3
- import type { I18nEngineSingle } from "./engine.js";
4
- import { resolveAndFormat } from "./format-core.js";
5
- import {
6
- assertPatchKeySingle,
7
- recordPreloadedKeysSingle,
8
- seedPreloadedKeysSingle,
9
- } from "./patch-key.js";
10
- import { mergeNamespaceLocalesCore } from "./project-locales.js";
11
- import { validateLocaleFallback } from "./resolve-locale.js";
12
- import type {
13
- IcuTranslationProviderOptions,
14
- KeyDictionary,
15
- LocaleFallbackMap,
16
- LocaleOfSingle,
17
- OnMissingTranslation,
18
- SingleCompiledCache,
19
- PartialKeyDictionary,
20
- } from "./types.js";
21
- import { I18nScopeSingleForLocaleImpl, I18nScopeSingleImpl } from "./scope-single.js";
22
-
23
- export class IcuTranslationProviderSingle<
24
- Schema extends KeyDictionary,
25
- Params extends { [K in keyof Schema]: unknown },
26
- RequestLocales extends string = LocaleOfSingle<Schema>,
27
- Fallback extends LocaleFallbackMap | undefined = undefined,
28
- > implements I18nEngineSingle<Schema, Params, RequestLocales> {
29
- readonly __i18nEngineMode = "single" as const;
30
-
31
- private dictionary: Schema;
32
- private compiledCache: SingleCompiledCache = {};
33
- private readonly preloadedKeys = new Set<string>();
34
- private readonly builderLoadRegistry = new BuilderLoadRegistry();
35
- private readonly localeFallback?: Fallback;
36
- private readonly onMissing: OnMissingTranslation;
37
-
38
- constructor(dictionary: Schema, options?: IcuTranslationProviderOptions<Fallback>) {
39
- this.dictionary = structuredClone(dictionary);
40
- seedPreloadedKeysSingle(this.dictionary, this.preloadedKeys);
41
- if (options?.localeFallback) {
42
- validateLocaleFallback(options.localeFallback);
43
- this.localeFallback = options.localeFallback;
44
- }
45
- this.onMissing = options?.onMissing ?? "throw";
46
- }
47
-
48
- getWithLocale(key: string, locale: string, params?: Record<string, unknown>): string {
49
- return resolveAndFormat({
50
- localeByKey: this.dictionary[key],
51
- locale,
52
- params,
53
- getCache: (resolvedLocale) => {
54
- if (!this.compiledCache[resolvedLocale]) {
55
- this.compiledCache[resolvedLocale] = {};
56
- }
57
- return this.compiledCache[resolvedLocale]!;
58
- },
59
- context: {
60
- key,
61
- locale,
62
- localeFallback: this.localeFallback,
63
- onMissing: this.onMissing,
64
- },
65
- });
66
- }
67
-
68
- toScope(): I18nScopeSingleImpl<Schema, Params, RequestLocales, Fallback>;
69
- toScope<Locale extends RequestLocales>(options: {
70
- locale: Locale;
71
- }): I18nScopeSingleForLocaleImpl<Schema, Params, RequestLocales, Locale, Fallback>;
72
- toScope<Locale extends RequestLocales>(options?: { locale?: Locale }) {
73
- if (options?.locale !== undefined) {
74
- return new I18nScopeSingleForLocaleImpl(this, options.locale);
75
- }
76
- return new I18nScopeSingleImpl(this);
77
- }
78
-
79
- getAll(): Schema {
80
- return cloneAndFreeze(this.dictionary);
81
- }
82
-
83
- hasBuilderResourceLoaded(partition: string | undefined): boolean {
84
- return this.builderLoadRegistry.has(SINGLE_BUILDER_RESOURCE, partition);
85
- }
86
-
87
- markBuilderResourceLoaded(partition: string | undefined): void {
88
- this.builderLoadRegistry.mark(SINGLE_BUILDER_RESOURCE, partition);
89
- }
90
-
91
- applyLoadMergeSingle(values: PartialKeyDictionary<Schema, RequestLocales>): void {
92
- this.dictionary = mergeNamespaceLocalesCore(this.dictionary, values);
93
- recordPreloadedKeysSingle(values, this.preloadedKeys);
94
-
95
- for (const [key, incomingLocales] of Object.entries(values)) {
96
- if (incomingLocales === undefined || typeof incomingLocales !== "object") {
97
- continue;
98
- }
99
-
100
- for (const locale of Object.keys(incomingLocales)) {
101
- delete this.compiledCache[locale]?.[key];
102
- }
103
- }
104
- }
105
-
106
- patchKey(key: string, locale: string, template: string): void {
107
- assertPatchKeySingle(key, locale, template, this.preloadedKeys, this.dictionary[key]);
108
-
109
- this.dictionary = mergeNamespaceLocalesCore(this.dictionary, {
110
- [key]: { [locale]: template },
111
- } as PartialKeyDictionary<Schema, RequestLocales>);
112
- delete this.compiledCache[locale]?.[key];
113
- }
114
- }