@xndrjs/i18n 0.8.1 → 0.8.2-alpha.1
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/README.md +1 -2
- package/dist/cli/audit.js +739 -0
- package/dist/cli/audit.js.map +1 -0
- package/dist/cli/codegen.js +1471 -0
- package/dist/cli/codegen.js.map +1 -0
- package/dist/cli/setup.js +148 -0
- package/dist/cli/setup.js.map +1 -0
- package/dist/codegen/index.js +43 -40
- package/dist/codegen/index.js.map +1 -1
- package/package.json +6 -9
- package/bin/audit.mjs +0 -14
- package/bin/codegen.mjs +0 -17
- package/bin/setup.mjs +0 -14
- package/src/IcuTranslationProviderMulti.test.ts +0 -215
- package/src/IcuTranslationProviderMulti.ts +0 -188
- package/src/audit/audit-dictionaries.test.ts +0 -134
- package/src/audit/audit-dictionaries.ts +0 -178
- package/src/audit/run-audit.test.ts +0 -169
- package/src/audit/run-audit.ts +0 -94
- package/src/builder-load-registry.test.ts +0 -44
- package/src/builder-load-registry.ts +0 -78
- package/src/builder-loaders.ts +0 -15
- package/src/builder-types.test.ts +0 -57
- package/src/builder-types.ts +0 -24
- package/src/builder.test.ts +0 -303
- package/src/builder.ts +0 -15
- package/src/codegen/codegen-config-schema.ts +0 -156
- package/src/codegen/config.test.ts +0 -231
- package/src/codegen/config.ts +0 -42
- package/src/codegen/constants.ts +0 -9
- package/src/codegen/delivery-artifacts.test.ts +0 -167
- package/src/codegen/delivery-artifacts.ts +0 -154
- package/src/codegen/dictionary-spec-contract.test.ts +0 -28
- package/src/codegen/dictionary-spec-contract.ts +0 -138
- package/src/codegen/emit/dictionary-schema-file.ts +0 -113
- package/src/codegen/emit/instance-file.test.ts +0 -71
- package/src/codegen/emit/instance-file.ts +0 -257
- package/src/codegen/emit/namespace-loaders-file.test.ts +0 -251
- package/src/codegen/emit/namespace-loaders-file.ts +0 -229
- package/src/codegen/emit/types-file.test.ts +0 -96
- package/src/codegen/emit/types-file.ts +0 -142
- package/src/codegen/fixtures/billing-namespace.json +0 -6
- package/src/codegen/generate-i18n-types.test.ts +0 -1137
- package/src/codegen/generate-i18n-types.ts +0 -20
- package/src/codegen/icu-analysis.ts +0 -110
- package/src/codegen/locale-fallback.ts +0 -73
- package/src/codegen/locale-policy.test.ts +0 -35
- package/src/codegen/locale-policy.ts +0 -30
- package/src/codegen/paths.ts +0 -44
- package/src/codegen/project-locales-set-namespace.test.ts +0 -134
- package/src/codegen/read-dictionary.test.ts +0 -594
- package/src/codegen/read-dictionary.ts +0 -260
- package/src/codegen/regenerate-namespaces.ts +0 -180
- package/src/codegen/run-codegen.test.ts +0 -230
- package/src/codegen/run-codegen.ts +0 -252
- package/src/codegen/types.ts +0 -10
- package/src/codegen/write-file-if-changed.test.ts +0 -42
- package/src/codegen/write-file-if-changed.ts +0 -20
- package/src/codegen-config/build-config.ts +0 -15
- package/src/codegen-config/codegen-config.test.ts +0 -36
- package/src/codegen-config/index.ts +0 -37
- package/src/codegen-config/type-names.ts +0 -20
- package/src/codegen-config/write-config.ts +0 -8
- package/src/engine.ts +0 -47
- package/src/fetch-artifact.ts +0 -16
- package/src/format-core.ts +0 -91
- package/src/i18n-handle.ts +0 -216
- package/src/icu/extract-variables.test.ts +0 -199
- package/src/icu/extract-variables.ts +0 -227
- package/src/icu/parse-template.ts +0 -31
- package/src/index.ts +0 -63
- package/src/load-state.ts +0 -15
- package/src/project-locales.test.ts +0 -280
- package/src/project-locales.ts +0 -211
- package/src/resolve-locale.test.ts +0 -91
- package/src/resolve-locale.ts +0 -88
- package/src/scope-multi.ts +0 -105
- package/src/scope-types.ts +0 -35
- package/src/scope.test.ts +0 -172
- package/src/serialized-state.test.ts +0 -114
- package/src/serialized-state.ts +0 -28
- package/src/setup/setup-i18n.test.ts +0 -85
- package/src/setup/setup-i18n.ts +0 -156
- package/src/types.ts +0 -65
- package/src/validation/create-args-schema.ts +0 -76
- package/src/validation/create-normalized-schema.ts +0 -52
- package/src/validation/errors.ts +0 -43
- package/src/validation/index.ts +0 -202
- package/src/validation/normalize.ts +0 -299
- package/src/validation/to-dictionary.ts +0 -67
- package/src/validation/types.ts +0 -82
- package/src/validation/validate-normalized.ts +0 -133
- 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
|
-
});
|
package/src/audit/run-audit.ts
DELETED
|
@@ -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
|
-
}
|
package/src/builder-loaders.ts
DELETED
|
@@ -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
|
-
});
|
package/src/builder-types.ts
DELETED
|
@@ -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;
|