@xndrjs/i18n 0.8.0 → 0.8.2-alpha.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 (94) hide show
  1. package/README.md +1 -2
  2. package/dist/cli/audit.js +739 -0
  3. package/dist/cli/audit.js.map +1 -0
  4. package/dist/cli/codegen.js +1471 -0
  5. package/dist/cli/codegen.js.map +1 -0
  6. package/dist/cli/setup.js +148 -0
  7. package/dist/cli/setup.js.map +1 -0
  8. package/dist/codegen/index.js +43 -40
  9. package/dist/codegen/index.js.map +1 -1
  10. package/dist/index.d.ts +20 -1
  11. package/dist/index.js +56 -10
  12. package/dist/index.js.map +1 -1
  13. package/package.json +6 -9
  14. package/bin/audit.mjs +0 -14
  15. package/bin/codegen.mjs +0 -17
  16. package/bin/setup.mjs +0 -14
  17. package/src/IcuTranslationProviderMulti.test.ts +0 -215
  18. package/src/IcuTranslationProviderMulti.ts +0 -176
  19. package/src/audit/audit-dictionaries.test.ts +0 -134
  20. package/src/audit/audit-dictionaries.ts +0 -178
  21. package/src/audit/run-audit.test.ts +0 -169
  22. package/src/audit/run-audit.ts +0 -94
  23. package/src/builder-load-registry.ts +0 -39
  24. package/src/builder-loaders.ts +0 -15
  25. package/src/builder-types.test.ts +0 -49
  26. package/src/builder-types.ts +0 -24
  27. package/src/builder.test.ts +0 -260
  28. package/src/builder.ts +0 -10
  29. package/src/codegen/codegen-config-schema.ts +0 -156
  30. package/src/codegen/config.test.ts +0 -231
  31. package/src/codegen/config.ts +0 -42
  32. package/src/codegen/constants.ts +0 -9
  33. package/src/codegen/delivery-artifacts.test.ts +0 -167
  34. package/src/codegen/delivery-artifacts.ts +0 -154
  35. package/src/codegen/dictionary-spec-contract.test.ts +0 -28
  36. package/src/codegen/dictionary-spec-contract.ts +0 -138
  37. package/src/codegen/emit/dictionary-schema-file.ts +0 -113
  38. package/src/codegen/emit/instance-file.test.ts +0 -71
  39. package/src/codegen/emit/instance-file.ts +0 -257
  40. package/src/codegen/emit/namespace-loaders-file.test.ts +0 -251
  41. package/src/codegen/emit/namespace-loaders-file.ts +0 -229
  42. package/src/codegen/emit/types-file.test.ts +0 -96
  43. package/src/codegen/emit/types-file.ts +0 -142
  44. package/src/codegen/fixtures/billing-namespace.json +0 -6
  45. package/src/codegen/generate-i18n-types.test.ts +0 -1137
  46. package/src/codegen/generate-i18n-types.ts +0 -20
  47. package/src/codegen/icu-analysis.ts +0 -110
  48. package/src/codegen/locale-fallback.ts +0 -73
  49. package/src/codegen/locale-policy.test.ts +0 -35
  50. package/src/codegen/locale-policy.ts +0 -30
  51. package/src/codegen/paths.ts +0 -44
  52. package/src/codegen/project-locales-set-namespace.test.ts +0 -134
  53. package/src/codegen/read-dictionary.test.ts +0 -594
  54. package/src/codegen/read-dictionary.ts +0 -260
  55. package/src/codegen/regenerate-namespaces.ts +0 -180
  56. package/src/codegen/run-codegen.test.ts +0 -230
  57. package/src/codegen/run-codegen.ts +0 -252
  58. package/src/codegen/types.ts +0 -10
  59. package/src/codegen/write-file-if-changed.test.ts +0 -42
  60. package/src/codegen/write-file-if-changed.ts +0 -20
  61. package/src/codegen-config/build-config.ts +0 -15
  62. package/src/codegen-config/codegen-config.test.ts +0 -36
  63. package/src/codegen-config/index.ts +0 -37
  64. package/src/codegen-config/type-names.ts +0 -20
  65. package/src/codegen-config/write-config.ts +0 -8
  66. package/src/engine.ts +0 -47
  67. package/src/fetch-artifact.ts +0 -16
  68. package/src/format-core.ts +0 -91
  69. package/src/i18n-handle.ts +0 -202
  70. package/src/icu/extract-variables.test.ts +0 -199
  71. package/src/icu/extract-variables.ts +0 -227
  72. package/src/icu/parse-template.ts +0 -31
  73. package/src/index.ts +0 -58
  74. package/src/project-locales.test.ts +0 -280
  75. package/src/project-locales.ts +0 -211
  76. package/src/resolve-locale.test.ts +0 -91
  77. package/src/resolve-locale.ts +0 -88
  78. package/src/scope-multi.ts +0 -105
  79. package/src/scope-types.ts +0 -35
  80. package/src/scope.test.ts +0 -172
  81. package/src/serialized-state.test.ts +0 -114
  82. package/src/serialized-state.ts +0 -28
  83. package/src/setup/setup-i18n.test.ts +0 -85
  84. package/src/setup/setup-i18n.ts +0 -156
  85. package/src/types.ts +0 -65
  86. package/src/validation/create-args-schema.ts +0 -76
  87. package/src/validation/create-normalized-schema.ts +0 -52
  88. package/src/validation/errors.ts +0 -43
  89. package/src/validation/index.ts +0 -202
  90. package/src/validation/normalize.ts +0 -299
  91. package/src/validation/to-dictionary.ts +0 -67
  92. package/src/validation/types.ts +0 -82
  93. package/src/validation/validate-normalized.ts +0 -133
  94. package/src/validation/validation.test.ts +0 -562
package/src/index.ts DELETED
@@ -1,58 +0,0 @@
1
- export type {
2
- DeliveryArtifactsMap,
3
- LocalesForDeliveryArea,
4
- LocalesForDeliveryAreaOrAll,
5
- } from "./builder-types.js";
6
-
7
- export type { I18nEngineMulti, I18nEngineMultiImpl } from "./engine.js";
8
-
9
- export type {
10
- I18nHandle,
11
- I18nHandleOptions,
12
- LoadNamespacesInput,
13
- NamespaceLoader,
14
- PartitionForLocale,
15
- PartitionedLoader,
16
- ScopeForLocale,
17
- } from "./builder.js";
18
- export { createI18nHandle, invokeNamespaceLoader } from "./builder.js";
19
-
20
- export type { BuilderResourceEntry } from "./builder-load-registry.js";
21
- export type { I18nCreateInput, I18nSerializedState } from "./serialized-state.js";
22
- export { normalizeI18nCreateInput } from "./serialized-state.js";
23
-
24
- export { IcuTranslationProviderMulti } from "./IcuTranslationProviderMulti.js";
25
-
26
- export type { I18nScopeMulti, I18nScopeMultiForLocale } from "./scope-multi.js";
27
-
28
- export type {
29
- MultiParams,
30
- NamespaceBoundTranslateFn,
31
- ParamsForNamespaces,
32
- SchemaForNamespaces,
33
- } from "./scope-types.js";
34
-
35
- export type {
36
- IcuTranslationProviderOptions,
37
- KeyDictionary,
38
- LocaleDictionary,
39
- LocaleFallbackMap,
40
- LocaleOfKeys,
41
- LocaleOfMulti,
42
- MissingTranslationContext,
43
- MultiDictionary,
44
- PartialKeyDictionary,
45
- PartialMultiDictionary,
46
- OnMissingTranslation,
47
- } from "./types.js";
48
-
49
- export {
50
- formatLocaleFallbackChain,
51
- resolveLocaleTemplate,
52
- validateLocaleFallback,
53
- } from "./resolve-locale.js";
54
- export type { ResolvedLocaleTemplate } from "./resolve-locale.js";
55
-
56
- export { mergeDictionaryLocalesCore, mergeNamespaceLocalesCore } from "./project-locales.js";
57
-
58
- export type { FetchArtifact, DeliveryResourceId } from "./fetch-artifact.js";
@@ -1,280 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- mergeDictionaryLocalesCore,
4
- mergeNamespaceLocalesCore,
5
- projectDictionaryForDeliveryAreaCore,
6
- projectDictionaryLocalesCore,
7
- projectNamespaceForDeliveryAreaCore,
8
- projectNamespaceLocalesCore,
9
- } from "./project-locales.js";
10
-
11
- describe("projectNamespaceLocalesCore", () => {
12
- const dictionary = {
13
- login_button: { en: "Login", it: "Accedi" },
14
- welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
15
- empty_label: { en: "" },
16
- };
17
-
18
- const fallback = {
19
- en: null,
20
- "de-DE": "en",
21
- "de-CH": "de-DE",
22
- it: "en",
23
- } as const;
24
-
25
- it("keeps only the requested locales with direct templates", () => {
26
- expect(projectNamespaceLocalesCore(dictionary, ["en", "it"])).toEqual({
27
- login_button: { en: "Login", it: "Accedi" },
28
- welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
29
- empty_label: { en: "" },
30
- });
31
- });
32
-
33
- it("drops locales not listed in the projection", () => {
34
- expect(projectNamespaceLocalesCore(dictionary, ["en"])).toEqual({
35
- login_button: { en: "Login" },
36
- welcome: { en: "Welcome {name}!" },
37
- empty_label: { en: "" },
38
- });
39
- });
40
-
41
- it("resolves fallback templates for missing direct locale entries", () => {
42
- expect(projectNamespaceLocalesCore(dictionary, ["de-CH"], fallback)).toEqual({
43
- login_button: { "de-CH": "Login" },
44
- welcome: { "de-CH": "Welcome {name}!" },
45
- empty_label: { "de-CH": "" },
46
- });
47
- });
48
-
49
- it("projects multiple locales with mixed direct and fallback resolution", () => {
50
- expect(projectNamespaceLocalesCore(dictionary, ["it", "de-CH"], fallback)).toEqual({
51
- login_button: { it: "Accedi", "de-CH": "Login" },
52
- welcome: { it: "Benvenuto {name}!", "de-CH": "Welcome {name}!" },
53
- empty_label: { it: "", "de-CH": "" },
54
- });
55
- });
56
-
57
- it("omits a target locale when fallback cannot resolve a template", () => {
58
- const partial = {
59
- login_button: { it: "Accedi" },
60
- };
61
-
62
- expect(projectNamespaceLocalesCore(partial, ["en", "it"], fallback)).toEqual({
63
- login_button: { it: "Accedi" },
64
- });
65
- });
66
-
67
- it("deduplicates locales in the projection list", () => {
68
- expect(projectNamespaceLocalesCore(dictionary, ["en", "en"])).toEqual({
69
- login_button: { en: "Login" },
70
- welcome: { en: "Welcome {name}!" },
71
- empty_label: { en: "" },
72
- });
73
- });
74
- });
75
-
76
- describe("projectNamespaceForDeliveryAreaCore", () => {
77
- const namespace = {
78
- some_key: { it: "Ciao", "it-CH": "Ciao CH", fr: "Hallo", "en-US": "Hello" },
79
- some_other_key: { "en-US": "Computer", fr: "Ordinateur" },
80
- };
81
-
82
- const localeFallback = {
83
- "en-US": null,
84
- it: "en-US",
85
- "it-CH": "it",
86
- fr: null,
87
- } as const;
88
-
89
- it("projects eu area with fallback resolution for full locales", () => {
90
- expect(
91
- projectNamespaceForDeliveryAreaCore(namespace, ["it", "fr", "it-CH"], localeFallback)
92
- ).toEqual({
93
- some_key: { it: "Ciao", fr: "Hallo", "it-CH": "Ciao CH" },
94
- // it-CH is preserve (fallback it is in the area): copied only when present in the
95
- // canonical dict; "it" is full (fallback en-US is outside the area) and resolves here.
96
- some_other_key: { it: "Computer", fr: "Ordinateur" },
97
- });
98
- });
99
-
100
- it("projects us area with single full locale", () => {
101
- expect(projectNamespaceForDeliveryAreaCore(namespace, ["en-US"], localeFallback)).toEqual({
102
- some_key: { "en-US": "Hello" },
103
- some_other_key: { "en-US": "Computer" },
104
- });
105
- });
106
-
107
- it("preserves canonical entries for in-area fallback locales without resolving", () => {
108
- const withInAreaFallback = {
109
- some_key: { it: "Ciao", "it-CH": "Ciao CH", fr: "Hallo", "en-US": "Hello" },
110
- some_other_key: {
111
- it: "Italiano",
112
- "it-CH": "Svizzero",
113
- "en-US": "Computer",
114
- fr: "Ordinateur",
115
- },
116
- };
117
-
118
- expect(
119
- projectNamespaceForDeliveryAreaCore(
120
- withInAreaFallback,
121
- ["it", "it-CH", "en-US", "fr"],
122
- localeFallback
123
- )
124
- ).toEqual({
125
- some_key: { it: "Ciao", "it-CH": "Ciao CH", "en-US": "Hello", fr: "Hallo" },
126
- some_other_key: {
127
- it: "Italiano",
128
- "it-CH": "Svizzero",
129
- "en-US": "Computer",
130
- fr: "Ordinateur",
131
- },
132
- });
133
- });
134
- });
135
-
136
- describe("projectDictionaryForDeliveryAreaCore", () => {
137
- const localeFallback = {
138
- "en-US": null,
139
- it: "en-US",
140
- "it-CH": "it",
141
- fr: null,
142
- } as const;
143
-
144
- it("projects each namespace independently for a multi-namespace dictionary", () => {
145
- const dictionary = {
146
- default: {
147
- some_key: { it: "Ciao", "it-CH": "Ciao CH", fr: "Hallo", "en-US": "Hello" },
148
- some_other_key: { "en-US": "Computer", fr: "Ordinateur" },
149
- },
150
- billing: {
151
- invoice_summary: {
152
- it: "{count} fatture",
153
- "en-US": "{count} invoices",
154
- fr: "{count} factures",
155
- },
156
- },
157
- };
158
-
159
- expect(
160
- projectDictionaryForDeliveryAreaCore(dictionary, ["it", "fr", "it-CH"], localeFallback)
161
- ).toEqual({
162
- default: {
163
- some_key: { it: "Ciao", fr: "Hallo", "it-CH": "Ciao CH" },
164
- some_other_key: { it: "Computer", fr: "Ordinateur" },
165
- },
166
- billing: {
167
- invoice_summary: { it: "{count} fatture", fr: "{count} factures" },
168
- },
169
- });
170
- });
171
-
172
- it("matches projectNamespaceForDeliveryAreaCore applied per namespace", () => {
173
- const dictionary = {
174
- default: {
175
- some_key: { it: "Ciao", "en-US": "Hello" },
176
- },
177
- billing: {
178
- invoice_summary: { it: "{count} fatture", "en-US": "{count} invoices" },
179
- },
180
- };
181
- const areaLocales = ["en-US"] as const;
182
-
183
- expect(projectDictionaryForDeliveryAreaCore(dictionary, areaLocales, localeFallback)).toEqual({
184
- default: projectNamespaceForDeliveryAreaCore(dictionary.default, areaLocales, localeFallback),
185
- billing: projectNamespaceForDeliveryAreaCore(dictionary.billing, areaLocales, localeFallback),
186
- });
187
- });
188
- });
189
-
190
- describe("projectDictionaryLocalesCore", () => {
191
- it("projects each namespace independently", () => {
192
- const dictionary = {
193
- default: {
194
- login_button: { en: "Login", it: "Accedi" },
195
- },
196
- billing: {
197
- invoice_summary: { en: "{count} invoices", it: "{count} fatture" },
198
- },
199
- };
200
-
201
- expect(projectDictionaryLocalesCore(dictionary, ["en"])).toEqual({
202
- default: { login_button: { en: "Login" } },
203
- billing: { invoice_summary: { en: "{count} invoices" } },
204
- });
205
- });
206
- });
207
-
208
- describe("mergeNamespaceLocalesCore", () => {
209
- type TestDictionary = {
210
- welcome: Partial<Record<"en" | "it", string>>;
211
- login_button: Partial<Record<"en" | "it", string>>;
212
- };
213
-
214
- it("merges locale maps per key without dropping existing locales", () => {
215
- const existing: TestDictionary = {
216
- welcome: { en: "Welcome {name}!" },
217
- login_button: { en: "Login", it: "Accedi" },
218
- };
219
- const incoming = {
220
- welcome: { it: "Benvenuto {name}!" },
221
- };
222
-
223
- expect(mergeNamespaceLocalesCore(existing, incoming)).toEqual({
224
- welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
225
- login_button: { en: "Login", it: "Accedi" },
226
- });
227
- });
228
-
229
- it("overwrites the same locale when incoming provides a new template", () => {
230
- const existing = {
231
- welcome: { en: "Welcome {name}!" },
232
- };
233
- const incoming = {
234
- welcome: { en: "Hello {name}!" },
235
- };
236
-
237
- expect(mergeNamespaceLocalesCore(existing, incoming)).toEqual({
238
- welcome: { en: "Hello {name}!" },
239
- });
240
- });
241
- });
242
-
243
- describe("mergeDictionaryLocalesCore", () => {
244
- type TestMultiDictionary = {
245
- default: {
246
- welcome: Partial<Record<"en" | "it", string>>;
247
- };
248
- billing: {
249
- invoice_summary: Partial<Record<"en" | "it", string>>;
250
- };
251
- };
252
-
253
- it("merges each namespace without dropping existing locales", () => {
254
- const existing: TestMultiDictionary = {
255
- default: {
256
- welcome: { en: "Welcome {name}!" },
257
- },
258
- billing: {
259
- invoice_summary: { en: "{count} invoices" },
260
- },
261
- };
262
- const incoming = {
263
- default: {
264
- welcome: { it: "Benvenuto {name}!" },
265
- },
266
- billing: {
267
- invoice_summary: { it: "{count} fatture" },
268
- },
269
- };
270
-
271
- expect(mergeDictionaryLocalesCore(existing, incoming)).toEqual({
272
- default: {
273
- welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
274
- },
275
- billing: {
276
- invoice_summary: { en: "{count} invoices", it: "{count} fatture" },
277
- },
278
- });
279
- });
280
- });
@@ -1,211 +0,0 @@
1
- import { resolveLocaleTemplate } from "./resolve-locale.js";
2
- import type {
3
- KeyDictionary,
4
- LocaleFallbackMap,
5
- LocaleOfMulti,
6
- LocaleOfKeys,
7
- MultiDictionary,
8
- PartialKeyDictionary,
9
- PartialMultiDictionary,
10
- } from "./types.js";
11
-
12
- function classifyAreaLocales(
13
- areaLocales: readonly string[],
14
- localeFallback?: LocaleFallbackMap
15
- ): { full: string[]; preserve: string[] } {
16
- const areaSet = new Set(areaLocales);
17
- const full: string[] = [];
18
- const preserve: string[] = [];
19
-
20
- for (const locale of areaLocales) {
21
- const fallback = localeFallback?.[locale];
22
- if (fallback !== null && fallback !== undefined && areaSet.has(fallback)) {
23
- preserve.push(locale);
24
- } else {
25
- full.push(locale);
26
- }
27
- }
28
-
29
- return { full, preserve };
30
- }
31
-
32
- function projectKeyDictionary<T extends KeyDictionary>(
33
- dictionary: T,
34
- locales: readonly string[],
35
- localeFallback?: LocaleFallbackMap
36
- ): T {
37
- const targetLocales = [...new Set(locales)];
38
- const result = {} as T;
39
-
40
- for (const [key, localesByKey] of Object.entries(dictionary)) {
41
- const projected: Record<string, string> = {};
42
-
43
- for (const locale of targetLocales) {
44
- const resolved = resolveLocaleTemplate(localesByKey, locale, localeFallback);
45
- if (resolved !== undefined) {
46
- projected[locale] = resolved.template;
47
- }
48
- }
49
-
50
- if (Object.keys(projected).length > 0) {
51
- result[key as keyof T] = projected as T[keyof T];
52
- }
53
- }
54
-
55
- return result;
56
- }
57
-
58
- /**
59
- * Builds a namespace dictionary that contains only the requested locales.
60
- * For each key and target locale, copies the direct template when present;
61
- * otherwise resolves through `localeFallback` (same rules as `.get()` at runtime).
62
- */
63
- export function projectNamespaceLocalesCore<T extends KeyDictionary>(
64
- dictionary: T,
65
- locales: readonly string[],
66
- localeFallback?: LocaleFallbackMap
67
- ): T {
68
- return projectKeyDictionary(dictionary, locales, localeFallback);
69
- }
70
-
71
- /**
72
- * Projects a single namespace for a custom delivery area.
73
- * Full locales resolve through `localeFallback` (e.g. external chains like `it → en-US`);
74
- * preserve locales copy only their direct canonical entry when present.
75
- */
76
- export function projectNamespaceForDeliveryAreaCore<T extends KeyDictionary>(
77
- dictionary: T,
78
- areaLocales: readonly string[],
79
- localeFallback?: LocaleFallbackMap
80
- ): T {
81
- const { full, preserve } = classifyAreaLocales(areaLocales, localeFallback);
82
- const fullProjection =
83
- full.length > 0 ? projectKeyDictionary(dictionary, full, localeFallback) : undefined;
84
- const result = {} as T;
85
-
86
- for (const [key, localesByKey] of Object.entries(dictionary)) {
87
- const projected: Record<string, string> = {};
88
-
89
- if (fullProjection !== undefined) {
90
- const fullEntry = fullProjection[key as keyof T];
91
- if (fullEntry !== undefined) {
92
- Object.assign(projected, fullEntry);
93
- }
94
- }
95
-
96
- for (const locale of preserve) {
97
- const template = localesByKey[locale];
98
- if (template !== undefined) {
99
- projected[locale] = template;
100
- }
101
- }
102
-
103
- if (Object.keys(projected).length > 0) {
104
- result[key as keyof T] = projected as T[keyof T];
105
- }
106
- }
107
-
108
- return result;
109
- }
110
-
111
- /**
112
- * Projects a multi-namespace dictionary for a custom delivery area.
113
- * Each namespace uses the same delivery-area rules as `projectNamespaceForDeliveryAreaCore`.
114
- */
115
- export function projectDictionaryForDeliveryAreaCore<T extends MultiDictionary>(
116
- dictionary: T,
117
- areaLocales: readonly string[],
118
- localeFallback?: LocaleFallbackMap
119
- ): T {
120
- const result = {} as T;
121
-
122
- for (const namespace of Object.keys(dictionary) as (keyof T & string)[]) {
123
- const namespaceDictionary = dictionary[namespace];
124
- if (namespaceDictionary === undefined) {
125
- continue;
126
- }
127
-
128
- result[namespace] = projectNamespaceForDeliveryAreaCore(
129
- namespaceDictionary,
130
- areaLocales,
131
- localeFallback
132
- ) as T[typeof namespace];
133
- }
134
-
135
- return result;
136
- }
137
-
138
- /**
139
- * Projects every namespace in a multi-namespace dictionary.
140
- */
141
- export function projectDictionaryLocalesCore<T extends MultiDictionary>(
142
- dictionary: T,
143
- locales: readonly string[],
144
- localeFallback?: LocaleFallbackMap
145
- ): T {
146
- const result = {} as T;
147
-
148
- for (const namespace of Object.keys(dictionary) as (keyof T & string)[]) {
149
- const namespaceDictionary = dictionary[namespace];
150
- if (namespaceDictionary === undefined) {
151
- continue;
152
- }
153
-
154
- result[namespace] = projectKeyDictionary(
155
- namespaceDictionary,
156
- locales,
157
- localeFallback
158
- ) as T[typeof namespace];
159
- }
160
-
161
- return result;
162
- }
163
-
164
- /**
165
- * Merges locale entries per translation key without dropping keys or locales
166
- * already present in `existing`. Incoming locales overwrite the same locale on conflict.
167
- */
168
- export function mergeNamespaceLocalesCore<
169
- T extends KeyDictionary,
170
- Locales extends string = LocaleOfKeys<T>,
171
- >(existing: T, incoming: PartialKeyDictionary<T, Locales>): T {
172
- const merged = structuredClone(existing);
173
-
174
- for (const [key, incomingLocales] of Object.entries(incoming)) {
175
- if (incomingLocales === undefined || typeof incomingLocales !== "object") {
176
- continue;
177
- }
178
-
179
- merged[key as keyof T] = {
180
- ...(merged[key as keyof T] ?? {}),
181
- ...incomingLocales,
182
- } as T[keyof T];
183
- }
184
-
185
- return merged;
186
- }
187
-
188
- /**
189
- * Merges locale entries per key in every namespace present in `incoming`.
190
- * Namespaces and keys not in `incoming` are preserved from `existing`.
191
- */
192
- export function mergeDictionaryLocalesCore<
193
- T extends MultiDictionary,
194
- Locales extends string = LocaleOfMulti<T>,
195
- >(existing: T, incoming: PartialMultiDictionary<T, Locales>): T {
196
- const merged = structuredClone(existing) as T;
197
-
198
- for (const namespace of Object.keys(incoming) as (keyof T & string)[]) {
199
- const incomingNamespace = incoming[namespace];
200
- if (incomingNamespace === undefined) {
201
- continue;
202
- }
203
-
204
- merged[namespace] = mergeNamespaceLocalesCore(
205
- (merged[namespace] ?? {}) as T[typeof namespace],
206
- incomingNamespace as T[typeof namespace]
207
- ) as T[typeof namespace];
208
- }
209
-
210
- return merged;
211
- }
@@ -1,91 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- formatLocaleFallbackChain,
4
- resolveLocaleTemplate,
5
- validateLocaleFallback,
6
- } from "./resolve-locale.js";
7
-
8
- const fallbackMap = {
9
- en: null,
10
- "de-DE": "en",
11
- "de-CH": "de-DE",
12
- it: "en",
13
- } as const;
14
-
15
- const localeByKey = {
16
- en: "Hello",
17
- it: "Ciao",
18
- };
19
-
20
- describe("resolveLocaleTemplate", () => {
21
- it("returns the template for the requested locale when present", () => {
22
- expect(resolveLocaleTemplate(localeByKey, "en")).toEqual({
23
- template: "Hello",
24
- resolvedLocale: "en",
25
- fallbackChain: ["en"],
26
- });
27
- });
28
-
29
- it("follows the fallback chain until a template is found", () => {
30
- expect(resolveLocaleTemplate(localeByKey, "de-CH", fallbackMap)).toEqual({
31
- template: "Hello",
32
- resolvedLocale: "en",
33
- fallbackChain: ["de-CH", "de-DE", "en"],
34
- });
35
- });
36
-
37
- it("uses an intermediate locale when it has a template", () => {
38
- const withGerman = { ...localeByKey, "de-DE": "Hallo" };
39
- expect(resolveLocaleTemplate(withGerman, "de-CH", fallbackMap)).toEqual({
40
- template: "Hallo",
41
- resolvedLocale: "de-DE",
42
- fallbackChain: ["de-CH", "de-DE"],
43
- });
44
- });
45
-
46
- it("treats an empty string template as present", () => {
47
- expect(resolveLocaleTemplate({ en: "" }, "en")).toEqual({
48
- template: "",
49
- resolvedLocale: "en",
50
- fallbackChain: ["en"],
51
- });
52
- });
53
-
54
- it("returns undefined when the chain ends with null fallback", () => {
55
- expect(resolveLocaleTemplate(localeByKey, "de-CH", { "de-CH": null })).toBeUndefined();
56
- });
57
-
58
- it("returns undefined when the locale is unknown and has no fallback map entry", () => {
59
- expect(resolveLocaleTemplate(localeByKey, "fr", fallbackMap)).toBeUndefined();
60
- });
61
-
62
- it("throws on circular fallback chains", () => {
63
- expect(() =>
64
- resolveLocaleTemplate(localeByKey, "de-CH", {
65
- "de-CH": "de-DE",
66
- "de-DE": "de-CH",
67
- })
68
- ).toThrow("[i18n] Circular locale fallback detected");
69
- });
70
- });
71
-
72
- describe("validateLocaleFallback", () => {
73
- it("accepts acyclic fallback maps", () => {
74
- expect(() => validateLocaleFallback(fallbackMap)).not.toThrow();
75
- });
76
-
77
- it("rejects circular fallback maps at construction time", () => {
78
- expect(() =>
79
- validateLocaleFallback({
80
- a: "b",
81
- b: "a",
82
- })
83
- ).toThrow("[i18n] Circular locale fallback detected");
84
- });
85
- });
86
-
87
- describe("formatLocaleFallbackChain", () => {
88
- it("formats the fallback chain for error messages", () => {
89
- expect(formatLocaleFallbackChain("de-CH", fallbackMap)).toBe("de-CH → de-DE → en");
90
- });
91
- });