@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
@@ -0,0 +1,138 @@
1
+ import fs from "node:fs";
2
+ import type { VariableSpec } from "../icu/extract-variables.js";
3
+ import type { DictionarySpec } from "../validation/types.js";
4
+ import type { NamespaceEntry } from "./types.js";
5
+
6
+ /** Builds a multi-mode {@link DictionarySpec} from ICU analysis for the given entries. */
7
+ export function buildDictionarySpecFromAnalysis(
8
+ entries: NamespaceEntry[],
9
+ argsSpecByNamespace: Record<string, Record<string, VariableSpec>>
10
+ ): Extract<DictionarySpec, { mode: "multi" }> {
11
+ const requiredKeys: Record<string, readonly string[]> = {};
12
+ const argsByKey: Record<string, Record<string, VariableSpec>> = {};
13
+
14
+ for (const entry of entries) {
15
+ const argsByKeyForNs = argsSpecByNamespace[entry.namespace] ?? {};
16
+ requiredKeys[entry.namespace] = Object.keys(argsByKeyForNs).sort();
17
+ argsByKey[entry.namespace] = argsByKeyForNs;
18
+ }
19
+
20
+ return {
21
+ mode: "multi",
22
+ requiredKeys,
23
+ argsByKey,
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Loads `DICTIONARY_SPEC` from a previously generated `dictionary-schema.generated.ts`.
29
+ * Sync parse of the emitter format from {@link formatDictionarySpecBlock} (no TS runtime import).
30
+ */
31
+ export function loadDictionarySpecFromSchemaFile(
32
+ absoluteSchemaPath: string
33
+ ): Extract<DictionarySpec, { mode: "multi" }> {
34
+ if (!fs.existsSync(absoluteSchemaPath)) {
35
+ throw new Error(
36
+ `[Codegen Error] Missing dictionary schema at ${absoluteSchemaPath}. Run runCodegen first.`
37
+ );
38
+ }
39
+
40
+ const source = fs.readFileSync(absoluteSchemaPath, "utf8");
41
+ const match = source.match(
42
+ /export const DICTIONARY_SPEC = (\{[\s\S]*?\n\}) satisfies DictionarySpec;/
43
+ );
44
+ if (!match?.[1]) {
45
+ throw new Error(
46
+ `[Codegen Error] Could not parse DICTIONARY_SPEC from ${absoluteSchemaPath}. Re-run runCodegen.`
47
+ );
48
+ }
49
+
50
+ const cleaned = match[1].replace(/ as const/g, "");
51
+ let parsed: unknown;
52
+ try {
53
+ parsed = new Function(`"use strict"; return (${cleaned});`)();
54
+ } catch (error) {
55
+ const message = error instanceof Error ? error.message : String(error);
56
+ throw new Error(
57
+ `[Codegen Error] Failed to evaluate DICTIONARY_SPEC from ${absoluteSchemaPath}: ${message}`
58
+ );
59
+ }
60
+
61
+ if (
62
+ !parsed ||
63
+ typeof parsed !== "object" ||
64
+ (parsed as DictionarySpec).mode !== "multi" ||
65
+ !("requiredKeys" in parsed) ||
66
+ !("argsByKey" in parsed)
67
+ ) {
68
+ throw new Error(
69
+ `[Codegen Error] DICTIONARY_SPEC in ${absoluteSchemaPath} is not a multi-mode DictionarySpec.`
70
+ );
71
+ }
72
+
73
+ return parsed as Extract<DictionarySpec, { mode: "multi" }>;
74
+ }
75
+
76
+ function sortedKeys(keys: readonly string[]): string[] {
77
+ return [...keys].sort();
78
+ }
79
+
80
+ function variableSpecsEqual(a: VariableSpec, b: VariableSpec): boolean {
81
+ const aKeys = Object.keys(a).sort();
82
+ const bKeys = Object.keys(b).sort();
83
+ if (aKeys.length !== bKeys.length) {
84
+ return false;
85
+ }
86
+ for (let i = 0; i < aKeys.length; i++) {
87
+ const key = aKeys[i]!;
88
+ if (key !== bKeys[i] || a[key] !== b[key]) {
89
+ return false;
90
+ }
91
+ }
92
+ return true;
93
+ }
94
+
95
+ function namespaceArgsEqual(
96
+ a: Readonly<Record<string, VariableSpec>> | undefined,
97
+ b: Readonly<Record<string, VariableSpec>> | undefined
98
+ ): boolean {
99
+ const aKeys = Object.keys(a ?? {}).sort();
100
+ const bKeys = Object.keys(b ?? {}).sort();
101
+ if (aKeys.length !== bKeys.length) {
102
+ return false;
103
+ }
104
+ for (const key of aKeys) {
105
+ if (!bKeys.includes(key)) {
106
+ return false;
107
+ }
108
+ if (!variableSpecsEqual(a![key]!, b![key]!)) {
109
+ return false;
110
+ }
111
+ }
112
+ return true;
113
+ }
114
+
115
+ /**
116
+ * Returns true when `current` matches `established` for every namespace in `namespaces`
117
+ * (same keys + same ICU VariableSpec per key).
118
+ */
119
+ export function namespaceContractsMatch(
120
+ namespaces: readonly string[],
121
+ current: Extract<DictionarySpec, { mode: "multi" }>,
122
+ established: Extract<DictionarySpec, { mode: "multi" }>
123
+ ): boolean {
124
+ for (const namespace of namespaces) {
125
+ const currentKeys = sortedKeys(current.requiredKeys[namespace] ?? []);
126
+ const establishedKeys = sortedKeys(established.requiredKeys[namespace] ?? []);
127
+ if (
128
+ currentKeys.length !== establishedKeys.length ||
129
+ currentKeys.some((key, index) => key !== establishedKeys[index])
130
+ ) {
131
+ return false;
132
+ }
133
+ if (!namespaceArgsEqual(current.argsByKey[namespace], established.argsByKey[namespace])) {
134
+ return false;
135
+ }
136
+ }
137
+ return true;
138
+ }
@@ -16,29 +16,9 @@ function formatVariableSpecObject(spec: VariableSpec): string {
16
16
 
17
17
  /** Builds the `DICTIONARY_SPEC` constant from ICU analysis output (keys + variable args). */
18
18
  export function formatDictionarySpecBlock(
19
- isSingle: boolean,
20
19
  entries: NamespaceEntry[],
21
20
  argsSpecByNamespace: Record<string, Record<string, VariableSpec>>
22
21
  ): string {
23
- if (isSingle) {
24
- const onlyNamespace = entries[0]!.namespace;
25
- const argsByKey = argsSpecByNamespace[onlyNamespace] ?? {};
26
- const requiredKeys = Object.keys(argsByKey)
27
- .map((key) => JSON.stringify(key))
28
- .join(", ");
29
- const argsLines = Object.entries(argsByKey)
30
- .map(([key, spec]) => ` ${JSON.stringify(key)}: ${formatVariableSpecObject(spec)},`)
31
- .join("\n");
32
-
33
- return (
34
- `export const DICTIONARY_SPEC = {\n` +
35
- ` mode: 'single' as const,\n` +
36
- ` requiredKeys: [${requiredKeys}] as const,\n` +
37
- ` argsByKey: {\n${argsLines}\n },\n` +
38
- `} satisfies DictionarySpec;\n`
39
- );
40
- }
41
-
42
22
  const requiredKeysLines = entries
43
23
  .map((entry) => {
44
24
  const keys = Object.keys(argsSpecByNamespace[entry.namespace] ?? {})
@@ -69,34 +49,20 @@ export function formatDictionarySpecBlock(
69
49
 
70
50
  /**
71
51
  * Emits the optional dictionary-schema module: `DICTIONARY_SPEC` plus thin wrappers
72
- * around `@xndrjs/i18n/validation` for external dictionary ingestion.
52
+ * around `@xndrjs/i18n/validation` for validating external payloads before
53
+ * updating authoring / `regenerateNamespaces` (e.g. CMS → authoring JSON), not for runtime key patches.
73
54
  */
74
55
  export function formatDictionarySchemaFile(
75
56
  schemaTypeName: string,
76
57
  typesModule: string,
77
- isSingle: boolean,
78
58
  dictionarySpecBlock: string,
79
59
  importExtension: ImportExtension
80
60
  ): string {
81
61
  const typesImport = toRelativeModuleImport(typesModule, importExtension);
82
- const partialImport = isSingle
83
- ? ` validateExternalDictionaryPartial as validateExternalDictionaryPartialCore,\n` +
84
- ` validateExternalKey as validateExternalKeyCore,\n`
85
- : ` validateExternalDictionaryPartial as validateExternalDictionaryPartialCore,\n` +
86
- ` validateExternalNamespacePartial as validateExternalNamespacePartialCore,\n` +
87
- ` validateExternalKey as validateExternalKeyCore,\n`;
88
-
89
- const singleKeyValidator =
90
- `export function validateExternalKey<K extends keyof ${schemaTypeName}>(\n` +
91
- ` key: K,\n` +
92
- ` input: unknown,\n` +
93
- `): ValidationResult<Pick<${schemaTypeName}, K>> {\n` +
94
- ` return validateExternalKeyCore<Pick<${schemaTypeName}, K>>(\n` +
95
- ` key as string,\n` +
96
- ` input,\n` +
97
- ` DICTIONARY_SPEC,\n` +
98
- ` );\n` +
99
- `}\n\n`;
62
+ const partialImport =
63
+ ` validateExternalDictionaryPartial as validateExternalDictionaryPartialCore,\n` +
64
+ ` validateExternalNamespacePartial as validateExternalNamespacePartialCore,\n` +
65
+ ` validateExternalKey as validateExternalKeyCore,\n`;
100
66
 
101
67
  const multiValidators =
102
68
  `export function validateExternalNamespacePartial<NS extends keyof ${schemaTypeName}>(\n` +
@@ -125,8 +91,6 @@ export function formatDictionarySchemaFile(
125
91
  ` );\n` +
126
92
  `}\n\n`;
127
93
 
128
- const keyValidator = isSingle ? singleKeyValidator : multiValidators;
129
-
130
94
  return (
131
95
  `${GENERATED_FILE_BANNER}` +
132
96
  `import {\n` +
@@ -144,6 +108,6 @@ export function formatDictionarySchemaFile(
144
108
  ` DICTIONARY_SPEC,\n` +
145
109
  ` );\n` +
146
110
  `}\n\n` +
147
- keyValidator
111
+ multiValidators
148
112
  );
149
113
  }
@@ -3,9 +3,8 @@ import { formatInstanceFile, type InstanceFileOptions } from "./instance-file.js
3
3
 
4
4
  function baseOptions(overrides: Partial<InstanceFileOptions> = {}): InstanceFileOptions {
5
5
  return {
6
- isSingle: false,
7
- hasLazy: false,
8
6
  typesOutputPath: "src/i18n/i18n-types.generated.ts",
7
+ namespaceLoadersOutputPath: "src/i18n/namespace-loaders.generated.ts",
9
8
  paramsTypeName: "AppParams",
10
9
  schemaTypeName: "AppSchema",
11
10
  localeTypeName: "AppLocale",
@@ -13,9 +12,8 @@ function baseOptions(overrides: Partial<InstanceFileOptions> = {}): InstanceFile
13
12
  factoryName: "createI18n",
14
13
  hasLocaleFallback: true,
15
14
  hasLocaleType: true,
16
- namespaceNames: ["default", "billing"],
17
15
  importExtension: "none",
18
- delivery: "canonical",
16
+ delivery: "split-by-locale",
19
17
  ...overrides,
20
18
  };
21
19
  }
@@ -26,63 +24,48 @@ function packageImportBlock(output: string): string {
26
24
  }
27
25
 
28
26
  describe("formatInstanceFile package imports", () => {
29
- it("emits only single-scope symbols for single eager projects", () => {
30
- const imports = packageImportBlock(formatInstanceFile(baseOptions({ isSingle: true })));
31
-
32
- expect(imports).toContain("IcuTranslationProviderSingle");
33
- expect(imports).toContain("projectNamespaceLocalesCore");
34
- expect(imports).toContain("type I18nScopeSingle");
35
- expect(imports).toContain("type OnMissingTranslation");
36
- expect(imports).not.toContain("createI18nBuilder");
37
- expect(imports).not.toContain("createI18nMultiBuilder");
38
- expect(imports).not.toContain("I18nBuilderMultiInitial");
39
- expect(imports).not.toContain("I18nBuilderMultiOptions");
40
- expect(imports).not.toContain("I18nBuilderMultiPartitioned");
41
- expect(imports).not.toContain("I18nScopeMulti");
42
- expect(imports).not.toContain("projectDictionaryLocalesCore");
43
- });
44
-
45
- it("emits multi eager scope symbols without builder imports", () => {
46
- const imports = packageImportBlock(formatInstanceFile(baseOptions()));
27
+ it("emits lazy multi handle symbols", () => {
28
+ const output = formatInstanceFile(baseOptions());
29
+ const imports = packageImportBlock(output);
47
30
 
48
31
  expect(imports).toContain("IcuTranslationProviderMulti");
49
- expect(imports).toContain("projectDictionaryLocalesCore");
50
- expect(imports).toContain("type I18nScopeMulti");
51
- expect(imports).not.toContain("createI18nMultiBuilder");
52
- expect(imports).not.toContain("I18nBuilderMultiInitial");
53
- expect(imports).not.toContain("I18nScopeSingle");
32
+ expect(imports).toContain("createI18nHandle");
33
+ expect(imports).toContain("type I18nHandle");
34
+ expect(imports).toContain("type I18nHandleOptions");
35
+ expect(output).toContain("partitionForLocale: (locale) => locale");
36
+ expect(output).toContain("I18nHandle<AppSchema, AppParams, AppLocale>");
37
+ expect(output).toContain(
38
+ "options?: { state?: { dictionary: InitialSchema; resources?: readonly (readonly [string, string])[] }; onMissing?: OnMissingTranslation }"
39
+ );
40
+ expect(output).toContain("const { state, ...providerOptions } = options ?? {}");
54
41
  });
55
42
 
56
- it("emits lazy multi builder symbols instead of I18nScopeMulti", () => {
57
- const imports = packageImportBlock(
58
- formatInstanceFile(
59
- baseOptions({
60
- hasLazy: true,
61
- namespaceLoadersOutputPath: "src/i18n/namespace-loaders.generated.ts",
62
- })
63
- )
43
+ it("injects LOCALE_DELIVERY_AREA for custom delivery", () => {
44
+ const output = formatInstanceFile(
45
+ baseOptions({
46
+ delivery: "custom",
47
+ localeDeliveryAreaConstName: "LOCALE_DELIVERY_AREA",
48
+ })
64
49
  );
65
50
 
66
- expect(imports).toContain("createI18nMultiBuilder");
67
- expect(imports).toContain("type I18nBuilderMultiInitial");
68
- expect(imports).toContain("type I18nBuilderMultiOptions");
69
- expect(imports).not.toContain("type I18nScopeMulti");
70
- expect(imports).not.toContain("createI18nBuilder");
71
- expect(imports).not.toContain("I18nBuilderMultiPartitioned");
51
+ expect(output).toContain("LOCALE_DELIVERY_AREA");
52
+ expect(output).toContain("partitionForLocale: (locale) => LOCALE_DELIVERY_AREA[locale]");
72
53
  });
73
54
 
74
- it("includes delivery-area projection helpers when delivery is custom", () => {
75
- const imports = packageImportBlock(
76
- formatInstanceFile(
77
- baseOptions({
78
- delivery: "custom",
79
- deliveryAreaTypeName: "AppDeliveryArea",
80
- deliveryArtifactsTypeName: "AppDeliveryArtifacts",
81
- })
82
- )
55
+ it("wires createNamespaceLoaders + required fetchImpl in options bag", () => {
56
+ const output = formatInstanceFile(
57
+ baseOptions({
58
+ loaderStrategy: "fetch",
59
+ })
83
60
  );
84
61
 
85
- expect(imports).toContain("projectNamespaceForDeliveryAreaCore");
86
- expect(imports).toContain("projectDictionaryForDeliveryAreaCore");
62
+ expect(output).toContain("type FetchArtifact");
63
+ expect(output).toContain("import { createNamespaceLoaders }");
64
+ expect(output).toContain(
65
+ "options: { fetchImpl: FetchArtifact; state?: { dictionary: InitialSchema; resources?: readonly (readonly [string, string])[] }; onMissing?: OnMissingTranslation }"
66
+ );
67
+ expect(output).toContain("const { fetchImpl, state, ...providerOptions } = options;");
68
+ expect(output).toContain("createNamespaceLoaders(fetchImpl)");
69
+ expect(output).not.toContain("import { namespaceLoaders }");
87
70
  });
88
71
  });