@xndrjs/i18n 0.8.1 → 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 (93) 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/package.json +6 -9
  11. package/bin/audit.mjs +0 -14
  12. package/bin/codegen.mjs +0 -17
  13. package/bin/setup.mjs +0 -14
  14. package/src/IcuTranslationProviderMulti.test.ts +0 -215
  15. package/src/IcuTranslationProviderMulti.ts +0 -188
  16. package/src/audit/audit-dictionaries.test.ts +0 -134
  17. package/src/audit/audit-dictionaries.ts +0 -178
  18. package/src/audit/run-audit.test.ts +0 -169
  19. package/src/audit/run-audit.ts +0 -94
  20. package/src/builder-load-registry.test.ts +0 -44
  21. package/src/builder-load-registry.ts +0 -78
  22. package/src/builder-loaders.ts +0 -15
  23. package/src/builder-types.test.ts +0 -57
  24. package/src/builder-types.ts +0 -24
  25. package/src/builder.test.ts +0 -303
  26. package/src/builder.ts +0 -15
  27. package/src/codegen/codegen-config-schema.ts +0 -156
  28. package/src/codegen/config.test.ts +0 -231
  29. package/src/codegen/config.ts +0 -42
  30. package/src/codegen/constants.ts +0 -9
  31. package/src/codegen/delivery-artifacts.test.ts +0 -167
  32. package/src/codegen/delivery-artifacts.ts +0 -154
  33. package/src/codegen/dictionary-spec-contract.test.ts +0 -28
  34. package/src/codegen/dictionary-spec-contract.ts +0 -138
  35. package/src/codegen/emit/dictionary-schema-file.ts +0 -113
  36. package/src/codegen/emit/instance-file.test.ts +0 -71
  37. package/src/codegen/emit/instance-file.ts +0 -257
  38. package/src/codegen/emit/namespace-loaders-file.test.ts +0 -251
  39. package/src/codegen/emit/namespace-loaders-file.ts +0 -229
  40. package/src/codegen/emit/types-file.test.ts +0 -96
  41. package/src/codegen/emit/types-file.ts +0 -142
  42. package/src/codegen/fixtures/billing-namespace.json +0 -6
  43. package/src/codegen/generate-i18n-types.test.ts +0 -1137
  44. package/src/codegen/generate-i18n-types.ts +0 -20
  45. package/src/codegen/icu-analysis.ts +0 -110
  46. package/src/codegen/locale-fallback.ts +0 -73
  47. package/src/codegen/locale-policy.test.ts +0 -35
  48. package/src/codegen/locale-policy.ts +0 -30
  49. package/src/codegen/paths.ts +0 -44
  50. package/src/codegen/project-locales-set-namespace.test.ts +0 -134
  51. package/src/codegen/read-dictionary.test.ts +0 -594
  52. package/src/codegen/read-dictionary.ts +0 -260
  53. package/src/codegen/regenerate-namespaces.ts +0 -180
  54. package/src/codegen/run-codegen.test.ts +0 -230
  55. package/src/codegen/run-codegen.ts +0 -252
  56. package/src/codegen/types.ts +0 -10
  57. package/src/codegen/write-file-if-changed.test.ts +0 -42
  58. package/src/codegen/write-file-if-changed.ts +0 -20
  59. package/src/codegen-config/build-config.ts +0 -15
  60. package/src/codegen-config/codegen-config.test.ts +0 -36
  61. package/src/codegen-config/index.ts +0 -37
  62. package/src/codegen-config/type-names.ts +0 -20
  63. package/src/codegen-config/write-config.ts +0 -8
  64. package/src/engine.ts +0 -47
  65. package/src/fetch-artifact.ts +0 -16
  66. package/src/format-core.ts +0 -91
  67. package/src/i18n-handle.ts +0 -216
  68. package/src/icu/extract-variables.test.ts +0 -199
  69. package/src/icu/extract-variables.ts +0 -227
  70. package/src/icu/parse-template.ts +0 -31
  71. package/src/index.ts +0 -63
  72. package/src/load-state.ts +0 -15
  73. package/src/project-locales.test.ts +0 -280
  74. package/src/project-locales.ts +0 -211
  75. package/src/resolve-locale.test.ts +0 -91
  76. package/src/resolve-locale.ts +0 -88
  77. package/src/scope-multi.ts +0 -105
  78. package/src/scope-types.ts +0 -35
  79. package/src/scope.test.ts +0 -172
  80. package/src/serialized-state.test.ts +0 -114
  81. package/src/serialized-state.ts +0 -28
  82. package/src/setup/setup-i18n.test.ts +0 -85
  83. package/src/setup/setup-i18n.ts +0 -156
  84. package/src/types.ts +0 -65
  85. package/src/validation/create-args-schema.ts +0 -76
  86. package/src/validation/create-normalized-schema.ts +0 -52
  87. package/src/validation/errors.ts +0 -43
  88. package/src/validation/index.ts +0 -202
  89. package/src/validation/normalize.ts +0 -299
  90. package/src/validation/to-dictionary.ts +0 -67
  91. package/src/validation/types.ts +0 -82
  92. package/src/validation/validate-normalized.ts +0 -133
  93. package/src/validation/validation.test.ts +0 -562
@@ -1,178 +0,0 @@
1
- import path from "node:path";
2
- import type { DictionaryJson } from "../codegen/types.js";
3
- import { buildRequiredLocales, enrichLocaleFallback } from "../codegen/locale-policy.js";
4
- import { resolveLocaleTemplate } from "../resolve-locale.js";
5
- import { CodegenConfig } from "../codegen/codegen-config-schema.js";
6
-
7
- export type FailOnCriterion = "effective" | "direct" | "any";
8
-
9
- export interface I18nAuditReport {
10
- requiredLocales: string[];
11
- localeFallback?: Record<string, string | null>;
12
- summary: Record<string, Record<string, { missingDirect: number; missingEffective: number }>>;
13
- missingDirectByLocale: Record<string, Record<string, string[]>>;
14
- missingEffectiveByLocale: Record<string, Record<string, string[]>>;
15
- }
16
-
17
- export interface AuditDictionariesOptions {
18
- namespaces: Record<string, DictionaryJson>;
19
- config: Pick<CodegenConfig, "localeFallback">;
20
- treatEmptyAsMissing?: boolean;
21
- }
22
-
23
- function isDirectlyMissing(
24
- localesByKey: Record<string, string>,
25
- locale: string,
26
- treatEmptyAsMissing: boolean
27
- ): boolean {
28
- const template = localesByKey[locale];
29
- if (template === undefined) {
30
- return true;
31
- }
32
- return treatEmptyAsMissing && template === "";
33
- }
34
-
35
- function isEffectivelyMissing(
36
- localesByKey: Record<string, string>,
37
- locale: string,
38
- localeFallback: Record<string, string | null> | undefined,
39
- treatEmptyAsMissing: boolean
40
- ): boolean {
41
- const resolved = resolveLocaleTemplate(localesByKey, locale, localeFallback);
42
- if (resolved === undefined) {
43
- return true;
44
- }
45
- return treatEmptyAsMissing && resolved.template === "";
46
- }
47
-
48
- function collectDictionaryLocales(namespaces: Record<string, DictionaryJson>): Set<string> {
49
- const locales = new Set<string>();
50
-
51
- for (const dictionary of Object.values(namespaces)) {
52
- for (const localesByKey of Object.values(dictionary)) {
53
- for (const locale of Object.keys(localesByKey)) {
54
- locales.add(locale);
55
- }
56
- }
57
- }
58
-
59
- return locales;
60
- }
61
-
62
- /**
63
- * Audit pipeline: reports missing translations per namespace/locale (direct and
64
- * effective via fallback), using the same locale policy as codegen.
65
- */
66
- export function auditDictionaries(options: AuditDictionariesOptions): I18nAuditReport {
67
- const { namespaces, config, treatEmptyAsMissing = true } = options;
68
- const dictionaryLocales = collectDictionaryLocales(namespaces);
69
- const requiredLocales = buildRequiredLocales(dictionaryLocales, config.localeFallback);
70
- const localeFallbackForAudit = config.localeFallback
71
- ? enrichLocaleFallback(dictionaryLocales, config.localeFallback)
72
- : undefined;
73
-
74
- const summary: I18nAuditReport["summary"] = {};
75
- const missingDirectByLocale: I18nAuditReport["missingDirectByLocale"] = {};
76
- const missingEffectiveByLocale: I18nAuditReport["missingEffectiveByLocale"] = {};
77
-
78
- for (const [namespace, dictionary] of Object.entries(namespaces)) {
79
- summary[namespace] = {};
80
- missingDirectByLocale[namespace] = {};
81
- missingEffectiveByLocale[namespace] = {};
82
-
83
- for (const locale of requiredLocales) {
84
- summary[namespace]![locale] = { missingDirect: 0, missingEffective: 0 };
85
- missingDirectByLocale[namespace]![locale] = [];
86
- missingEffectiveByLocale[namespace]![locale] = [];
87
- }
88
-
89
- for (const [key, localesByKey] of Object.entries(dictionary)) {
90
- for (const locale of requiredLocales) {
91
- if (isDirectlyMissing(localesByKey, locale, treatEmptyAsMissing)) {
92
- summary[namespace]![locale]!.missingDirect += 1;
93
- missingDirectByLocale[namespace]![locale]!.push(key);
94
- }
95
-
96
- if (
97
- isEffectivelyMissing(localesByKey, locale, localeFallbackForAudit, treatEmptyAsMissing)
98
- ) {
99
- summary[namespace]![locale]!.missingEffective += 1;
100
- missingEffectiveByLocale[namespace]![locale]!.push(key);
101
- }
102
- }
103
- }
104
-
105
- for (const locale of requiredLocales) {
106
- missingDirectByLocale[namespace]![locale]!.sort();
107
- missingEffectiveByLocale[namespace]![locale]!.sort();
108
- }
109
- }
110
-
111
- return {
112
- requiredLocales,
113
- ...(localeFallbackForAudit !== undefined ? { localeFallback: localeFallbackForAudit } : {}),
114
- summary,
115
- missingDirectByLocale,
116
- missingEffectiveByLocale,
117
- };
118
- }
119
-
120
- export function reportHasGaps(report: I18nAuditReport, criterion: FailOnCriterion): boolean {
121
- for (const namespace of Object.keys(report.missingDirectByLocale)) {
122
- for (const locale of report.requiredLocales) {
123
- const direct = report.missingDirectByLocale[namespace]?.[locale] ?? [];
124
- const effective = report.missingEffectiveByLocale[namespace]?.[locale] ?? [];
125
-
126
- if (criterion === "direct" && direct.length > 0) {
127
- return true;
128
- }
129
- if (criterion === "effective" && effective.length > 0) {
130
- return true;
131
- }
132
- if (criterion === "any" && (direct.length > 0 || effective.length > 0)) {
133
- return true;
134
- }
135
- }
136
- }
137
-
138
- return false;
139
- }
140
-
141
- export interface RunAuditOptions {
142
- projectRoot: string;
143
- config: CodegenConfig;
144
- treatEmptyAsMissing?: boolean;
145
- failOn?: FailOnCriterion;
146
- }
147
-
148
- export interface RunAuditResult {
149
- report: I18nAuditReport;
150
- exitCode: 0 | 1;
151
- }
152
-
153
- export async function runAuditFromConfig(options: RunAuditOptions): Promise<RunAuditResult> {
154
- const { resolveNamespaces } = await import("../codegen/config.js");
155
- const { readDictionaryFile } = await import("../codegen/read-dictionary.js");
156
-
157
- const config = options.config;
158
- const entries = resolveNamespaces(config);
159
- const namespaces: Record<string, DictionaryJson> = {};
160
-
161
- for (const entry of entries) {
162
- const absolutePath = path.resolve(options.projectRoot, entry.filePath);
163
- namespaces[entry.namespace] = readDictionaryFile(absolutePath);
164
- }
165
-
166
- const report = auditDictionaries({
167
- namespaces,
168
- config,
169
- ...(options.treatEmptyAsMissing !== undefined
170
- ? { treatEmptyAsMissing: options.treatEmptyAsMissing }
171
- : {}),
172
- });
173
-
174
- const exitCode =
175
- options.failOn && reportHasGaps(report, options.failOn) ? (1 as const) : (0 as const);
176
-
177
- return { report, exitCode };
178
- }
@@ -1,169 +0,0 @@
1
- import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import { spawnSync } from "node:child_process";
5
- import { fileURLToPath } from "node:url";
6
- import { dirname } from "node:path";
7
- import { afterEach, describe, expect, it, vi } from "vitest";
8
- import { parseAuditArgs, runAuditCli } from "./run-audit.js";
9
-
10
- const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
11
- const auditBin = join(packageRoot, "bin/audit.mjs");
12
-
13
- describe("parseAuditArgs", () => {
14
- it("defaults to report-only mode without --fail-on", () => {
15
- expect(parseAuditArgs([])).toEqual({
16
- configPath: join(process.cwd(), "i18n/i18n.codegen.json"),
17
- outPath: undefined,
18
- failOn: undefined,
19
- treatEmptyAsMissing: true,
20
- });
21
- });
22
-
23
- it("parses fail-on and allow-empty", () => {
24
- const parsed = parseAuditArgs([
25
- "--config",
26
- "cfg.json",
27
- "--fail-on",
28
- "effective",
29
- "--allow-empty",
30
- ]);
31
- expect(parsed.failOn).toBe("effective");
32
- expect(parsed.treatEmptyAsMissing).toBe(false);
33
- expect(parsed.configPath).toBe(join(process.cwd(), "cfg.json"));
34
- });
35
- });
36
-
37
- describe("runAuditCli", () => {
38
- let tempDir = "";
39
-
40
- afterEach(() => {
41
- if (tempDir) {
42
- rmSync(tempDir, { recursive: true, force: true });
43
- tempDir = "";
44
- }
45
- });
46
-
47
- function writeFixture(dictionary: Record<string, Record<string, string>>) {
48
- tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-audit-"));
49
- mkdirSync(join(tempDir, "i18n/translations"), { recursive: true });
50
- writeFileSync(join(tempDir, "i18n/translations/default.json"), JSON.stringify(dictionary));
51
- writeFileSync(
52
- join(tempDir, "i18n/i18n.codegen.json"),
53
- JSON.stringify({
54
- projectName: "App",
55
- namespaces: {
56
- default: "translations/default.json",
57
- },
58
- codegenPath: "generated",
59
- localeFallback: {
60
- en: null,
61
- it: "en",
62
- },
63
- })
64
- );
65
- }
66
-
67
- it("exits 0 without --fail-on even when effective gaps exist", async () => {
68
- writeFixture({
69
- welcome: { en: "Welcome" },
70
- });
71
-
72
- const previousCwd = process.cwd();
73
- process.chdir(tempDir);
74
- try {
75
- const exitCode = await runAuditCli(["--config", "i18n/i18n.codegen.json"]);
76
- expect(exitCode).toBe(0);
77
- } finally {
78
- process.chdir(previousCwd);
79
- }
80
- });
81
-
82
- it("exits 1 with --fail-on effective when effective gaps exist", async () => {
83
- writeFixture({
84
- login_button: { it: "Accedi" },
85
- });
86
-
87
- const previousCwd = process.cwd();
88
- process.chdir(tempDir);
89
- try {
90
- const exitCode = await runAuditCli([
91
- "--config",
92
- "i18n/i18n.codegen.json",
93
- "--fail-on",
94
- "effective",
95
- ]);
96
- expect(exitCode).toBe(1);
97
- } finally {
98
- process.chdir(previousCwd);
99
- }
100
- });
101
-
102
- it("writes JSON report to --out", async () => {
103
- writeFixture({
104
- login_button: { en: "Login", it: "Accedi" },
105
- });
106
-
107
- const outPath = join(tempDir, "audit.json");
108
- const previousCwd = process.cwd();
109
- process.chdir(tempDir);
110
- try {
111
- const exitCode = await runAuditCli(["--config", "i18n/i18n.codegen.json", "--out", outPath]);
112
- expect(exitCode).toBe(0);
113
- const report = JSON.parse(readFileSync(outPath, "utf8"));
114
- expect(report.requiredLocales).toEqual(["en", "it"]);
115
- expect(report.missingEffectiveByLocale.default.en).toEqual([]);
116
- } finally {
117
- process.chdir(previousCwd);
118
- }
119
- });
120
-
121
- it("exits 2 with an error message when the config is invalid", async () => {
122
- tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-audit-"));
123
- mkdirSync(join(tempDir, "i18n"), { recursive: true });
124
- writeFileSync(
125
- join(tempDir, "i18n/i18n.codegen.json"),
126
- JSON.stringify({ namespaces: { default: "translations/default.json" } })
127
- );
128
-
129
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => void 0);
130
- const previousCwd = process.cwd();
131
- process.chdir(tempDir);
132
- try {
133
- const exitCode = await runAuditCli(["--config", "i18n/i18n.codegen.json"]);
134
- expect(exitCode).toBe(2);
135
- expect(String(errorSpy.mock.calls[0]?.[0])).toContain("Invalid i18n.codegen.json");
136
- } finally {
137
- process.chdir(previousCwd);
138
- errorSpy.mockRestore();
139
- }
140
- });
141
-
142
- it("exits 2 with an error message when --fail-on has an invalid value", async () => {
143
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => void 0);
144
- try {
145
- const exitCode = await runAuditCli(["--fail-on", "bogus"]);
146
- expect(exitCode).toBe(2);
147
- expect(String(errorSpy.mock.calls[0]?.[0])).toContain(
148
- "[Audit Error] --fail-on must be one of: effective, direct, any"
149
- );
150
- } finally {
151
- errorSpy.mockRestore();
152
- }
153
- });
154
-
155
- it("runs through the bin wrapper", () => {
156
- writeFixture({
157
- login_button: { en: "Login" },
158
- });
159
-
160
- const result = spawnSync("node", [auditBin, "--config", "i18n/i18n.codegen.json"], {
161
- cwd: tempDir,
162
- encoding: "utf8",
163
- });
164
-
165
- expect(result.status).toBe(0);
166
- const report = JSON.parse(result.stdout);
167
- expect(report.requiredLocales).toEqual(["en", "it"]);
168
- });
169
- });
@@ -1,94 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import type { CodegenConfig } from "../codegen/codegen-config-schema.js";
4
- import { loadConfig } from "../codegen/config.js";
5
- import { type FailOnCriterion, runAuditFromConfig } from "./audit-dictionaries.js";
6
-
7
- const FAIL_ON_VALUES = new Set<FailOnCriterion>(["effective", "direct", "any"]);
8
-
9
- export interface AuditCliOptions {
10
- configPath: string;
11
- outPath?: string;
12
- failOn?: FailOnCriterion;
13
- treatEmptyAsMissing: boolean;
14
- }
15
-
16
- export function parseAuditArgs(argv: string[]): AuditCliOptions {
17
- const configArgIndex = argv.indexOf("--config");
18
- const outArgIndex = argv.indexOf("--out");
19
- const failOnArgIndex = argv.indexOf("--fail-on");
20
-
21
- const configPath = path.resolve(
22
- process.cwd(),
23
- configArgIndex >= 0 ? argv[configArgIndex + 1]! : "i18n/i18n.codegen.json"
24
- );
25
-
26
- const outPath =
27
- outArgIndex >= 0 ? path.resolve(process.cwd(), argv[outArgIndex + 1]!) : undefined;
28
-
29
- let failOn: FailOnCriterion | undefined;
30
- if (failOnArgIndex >= 0) {
31
- const value = argv[failOnArgIndex + 1];
32
- if (!value || !FAIL_ON_VALUES.has(value as FailOnCriterion)) {
33
- throw new Error(`[Audit Error] --fail-on must be one of: effective, direct, any`);
34
- }
35
- failOn = value as FailOnCriterion;
36
- }
37
-
38
- const treatEmptyAsMissing = !argv.includes("--allow-empty");
39
-
40
- return {
41
- configPath,
42
- treatEmptyAsMissing,
43
- ...(outPath !== undefined ? { outPath } : {}),
44
- ...(failOn !== undefined ? { failOn } : {}),
45
- };
46
- }
47
-
48
- export async function runAuditCli(argv = process.argv.slice(2)): Promise<number> {
49
- let options: AuditCliOptions;
50
- let config: CodegenConfig;
51
-
52
- try {
53
- options = parseAuditArgs(argv);
54
-
55
- if (!fs.existsSync(options.configPath)) {
56
- console.error(`[Audit Error] Config file not found: ${options.configPath}`);
57
- return 2;
58
- }
59
-
60
- config = loadConfig(options.configPath);
61
- } catch (error) {
62
- console.error(error instanceof Error ? error.message : String(error));
63
- return 2;
64
- }
65
-
66
- const projectRoot = path.dirname(options.configPath);
67
-
68
- const { report, exitCode } = await runAuditFromConfig({
69
- projectRoot,
70
- config,
71
- treatEmptyAsMissing: options.treatEmptyAsMissing,
72
- ...(options.failOn !== undefined ? { failOn: options.failOn } : {}),
73
- });
74
-
75
- const json = `${JSON.stringify(report, null, 2)}\n`;
76
-
77
- if (options.outPath) {
78
- fs.mkdirSync(path.dirname(options.outPath), { recursive: true });
79
- fs.writeFileSync(options.outPath, json);
80
- } else {
81
- process.stdout.write(json);
82
- }
83
-
84
- return exitCode;
85
- }
86
-
87
- async function main(): Promise<void> {
88
- const exitCode = await runAuditCli();
89
- process.exit(exitCode);
90
- }
91
-
92
- if (process.argv[1]?.endsWith("run-audit.ts")) {
93
- void main();
94
- }
@@ -1,44 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { BuilderLoadRegistry } from "./builder-load-registry.js";
3
-
4
- describe("BuilderLoadRegistry", () => {
5
- it("tracks pending, loaded, and error states", () => {
6
- const registry = new BuilderLoadRegistry();
7
- const failure = new Error("boom");
8
-
9
- registry.markPending("billing", "it");
10
- expect(registry.getLoadState()).toEqual({
11
- resources: [{ namespace: "billing", partition: "it", status: "pending" }],
12
- });
13
-
14
- registry.markLoaded("billing", "it");
15
- expect(registry.has("billing", "it")).toBe(true);
16
- expect(registry.getLoadState()).toEqual({
17
- resources: [{ namespace: "billing", partition: "it", status: "loaded" }],
18
- });
19
- expect(registry.entries()).toEqual([["billing", "it"]]);
20
-
21
- registry.markError("default", "en", failure);
22
- expect(registry.getLoadState()).toEqual({
23
- resources: [
24
- { namespace: "billing", partition: "it", status: "loaded" },
25
- { namespace: "default", partition: "en", status: "error", error: failure },
26
- ],
27
- });
28
- });
29
-
30
- it("seeds hydrated resources as loaded", () => {
31
- const registry = new BuilderLoadRegistry();
32
- registry.seed([
33
- ["default", "it"],
34
- ["billing", "it"],
35
- ]);
36
-
37
- expect(registry.getLoadState()).toEqual({
38
- resources: [
39
- { namespace: "billing", partition: "it", status: "loaded" },
40
- { namespace: "default", partition: "it", status: "loaded" },
41
- ],
42
- });
43
- });
44
- });
@@ -1,78 +0,0 @@
1
- import type { I18nLoadState, I18nResourceLoadRecord } from "./load-state.js";
2
-
3
- /** Stable key for a lazy-loaded builder resource (namespace + locale or delivery area). */
4
- export function formatBuilderResourceKey(namespace: string, partition: string): string {
5
- return `${namespace}\0${partition}`;
6
- }
7
-
8
- /** Serialized builder resource: namespace + partition (locale / delivery area). */
9
- export type BuilderResourceEntry = readonly [namespace: string, partition: string];
10
-
11
- /**
12
- * Dedup cache for lazy-loaded builder resources (`namespace` × partition).
13
- * Tracks pending / loaded / error for {@link I18nLoadState}.
14
- */
15
- export class BuilderLoadRegistry {
16
- private readonly states = new Map<string, I18nResourceLoadRecord>();
17
-
18
- has(namespace: string, partition: string): boolean {
19
- return this.states.get(formatBuilderResourceKey(namespace, partition))?.status === "loaded";
20
- }
21
-
22
- markPending(namespace: string, partition: string): void {
23
- this.setState(namespace, partition, { status: "pending" });
24
- }
25
-
26
- mark(namespace: string, partition: string): void {
27
- this.markLoaded(namespace, partition);
28
- }
29
-
30
- markLoaded(namespace: string, partition: string): void {
31
- this.setState(namespace, partition, { status: "loaded" });
32
- }
33
-
34
- markError(namespace: string, partition: string, error: unknown): void {
35
- this.setState(namespace, partition, { status: "error", error });
36
- }
37
-
38
- /** Snapshot of loaded resources as `[namespace, partition]` tuples. */
39
- entries(): BuilderResourceEntry[] {
40
- const out: BuilderResourceEntry[] = [];
41
- for (const record of this.states.values()) {
42
- if (record.status === "loaded") {
43
- out.push([record.namespace, record.partition]);
44
- }
45
- }
46
- return out;
47
- }
48
-
49
- seed(resources: readonly BuilderResourceEntry[]): void {
50
- for (const [namespace, partition] of resources) {
51
- this.markLoaded(namespace, partition);
52
- }
53
- }
54
-
55
- getLoadState(): I18nLoadState {
56
- const resources = [...this.states.values()].sort((a, b) => {
57
- const byNamespace = a.namespace.localeCompare(b.namespace);
58
- return byNamespace !== 0 ? byNamespace : a.partition.localeCompare(b.partition);
59
- });
60
- return { resources };
61
- }
62
-
63
- private setState(
64
- namespace: string,
65
- partition: string,
66
- patch: Pick<I18nResourceLoadRecord, "status"> & Partial<Pick<I18nResourceLoadRecord, "error">>
67
- ): void {
68
- const record: I18nResourceLoadRecord = {
69
- namespace,
70
- partition,
71
- status: patch.status,
72
- };
73
- if (patch.error !== undefined) {
74
- record.error = patch.error;
75
- }
76
- this.states.set(formatBuilderResourceKey(namespace, partition), record);
77
- }
78
- }
@@ -1,15 +0,0 @@
1
- /** Split-by-locale or custom-area loader — partition key is locale or area name. */
2
- export type PartitionedLoader<T> = (
3
- partition: string,
4
- context: { locale: string }
5
- ) => Promise<T> | T;
6
-
7
- export type NamespaceLoader<T> = PartitionedLoader<T>;
8
-
9
- export async function invokeNamespaceLoader<T>(
10
- loader: NamespaceLoader<T>,
11
- partition: string,
12
- context: { locale: string }
13
- ): Promise<T> {
14
- return await loader(partition, context);
15
- }
@@ -1,57 +0,0 @@
1
- import { describe, expectTypeOf, it } from "vitest";
2
- import { createI18nHandle } from "./builder.js";
3
- import type { I18nHandle } from "./i18n-handle.js";
4
- import { IcuTranslationProviderMulti } from "./IcuTranslationProviderMulti.js";
5
- import type { LocalesForDeliveryArea, LocalesForDeliveryAreaOrAll } from "./builder-types.js";
6
- import type { PartialMultiDictionary } from "./types.js";
7
-
8
- type TypestateSchema = {
9
- billing: { invoice_summary: { en: string; it: string } };
10
- };
11
-
12
- type TypestateParams = {
13
- billing: { invoice_summary: { count: number } };
14
- };
15
-
16
- describe("builder-types", () => {
17
- type ProjectLocale = "en" | "en-US" | "it" | "fr";
18
- type DeliveryArea = "eu" | "amer";
19
- type Artifacts = {
20
- eu: readonly ["en", "it", "fr"];
21
- amer: readonly ["en-US"];
22
- };
23
-
24
- it("LocalesForDeliveryArea indexes artifact locales", () => {
25
- expectTypeOf<LocalesForDeliveryArea<Artifacts, "eu">>().toEqualTypeOf<"en" | "it" | "fr">();
26
- expectTypeOf<LocalesForDeliveryArea<Artifacts, "amer">>().toEqualTypeOf<"en-US">();
27
- });
28
-
29
- it("LocalesForDeliveryAreaOrAll narrows only when a delivery map is configured", () => {
30
- expectTypeOf<
31
- LocalesForDeliveryAreaOrAll<ProjectLocale, DeliveryArea, Artifacts, "eu">
32
- >().toEqualTypeOf<"en" | "it" | "fr">();
33
- expectTypeOf<
34
- LocalesForDeliveryAreaOrAll<ProjectLocale, never, Record<string, never>, "eu">
35
- >().toEqualTypeOf<ProjectLocale>();
36
- });
37
- });
38
-
39
- describe("I18nHandle typestate", () => {
40
- it("exposes load / peek / serialize / getLoadState", () => {
41
- const engine = new IcuTranslationProviderMulti<TypestateSchema, TypestateParams>({});
42
- const handle = createI18nHandle(engine);
43
- expectTypeOf(handle).toMatchTypeOf<I18nHandle<TypestateSchema, TypestateParams>>();
44
- expectTypeOf(handle.serialize).returns.toEqualTypeOf<{
45
- dictionary: PartialMultiDictionary<TypestateSchema>;
46
- resources: readonly (readonly [string, string])[];
47
- }>();
48
- expectTypeOf(handle.getLoadState).returns.toEqualTypeOf<{
49
- resources: readonly {
50
- namespace: string;
51
- partition: string;
52
- status: "pending" | "loaded" | "error";
53
- error?: unknown;
54
- }[];
55
- }>();
56
- });
57
- });
@@ -1,24 +0,0 @@
1
- /** Locales served by a delivery area, from a codegen `DELIVERY_ARTIFACTS`-shaped map. */
2
- export type LocalesForDeliveryArea<
3
- Artifacts extends Record<string, readonly string[]>,
4
- Area extends keyof Artifacts & string,
5
- > = Artifacts[Area][number];
6
-
7
- /** Map from delivery area to locale lists; empty when custom delivery is not configured. */
8
- export type DeliveryArtifactsMap<RequestLocales extends string, DeliveryArea extends string> = [
9
- DeliveryArea,
10
- ] extends [never]
11
- ? Record<string, never>
12
- : Record<DeliveryArea, readonly RequestLocales[]>;
13
-
14
- /** When a delivery-area map is configured, resolve partition locales; otherwise keep the full pool. */
15
- export type LocalesForDeliveryAreaOrAll<
16
- RequestLocales extends string,
17
- DeliveryArea extends string,
18
- DeliveryArtifacts extends DeliveryArtifactsMap<RequestLocales, DeliveryArea>,
19
- Area extends string,
20
- > = [DeliveryArea] extends [never]
21
- ? RequestLocales
22
- : Area extends DeliveryArea
23
- ? DeliveryArtifacts[Area][number]
24
- : never;