@xndrjs/i18n 0.2.1 → 0.3.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.
@@ -0,0 +1,136 @@
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 } 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/translations.json"), JSON.stringify(dictionary));
51
+ writeFileSync(
52
+ join(tempDir, "i18n/i18n.codegen.json"),
53
+ JSON.stringify({
54
+ dictionary: "translations/translations.json",
55
+ typesOutput: "generated/i18n-types.generated.ts",
56
+ dictionaryOutput: "generated/dictionary.generated.ts",
57
+ instanceOutput: "generated/instance.generated.ts",
58
+ paramsTypeName: "AppParams",
59
+ schemaTypeName: "AppSchema",
60
+ localeFallback: {
61
+ en: null,
62
+ it: "en",
63
+ },
64
+ })
65
+ );
66
+ }
67
+
68
+ it("exits 0 without --fail-on even when effective gaps exist", async () => {
69
+ writeFixture({
70
+ welcome: { en: "Welcome" },
71
+ });
72
+
73
+ const previousCwd = process.cwd();
74
+ process.chdir(tempDir);
75
+ try {
76
+ const exitCode = await runAuditCli(["--config", "i18n/i18n.codegen.json"]);
77
+ expect(exitCode).toBe(0);
78
+ } finally {
79
+ process.chdir(previousCwd);
80
+ }
81
+ });
82
+
83
+ it("exits 1 with --fail-on effective when effective gaps exist", async () => {
84
+ writeFixture({
85
+ login_button: { it: "Accedi" },
86
+ });
87
+
88
+ const previousCwd = process.cwd();
89
+ process.chdir(tempDir);
90
+ try {
91
+ const exitCode = await runAuditCli([
92
+ "--config",
93
+ "i18n/i18n.codegen.json",
94
+ "--fail-on",
95
+ "effective",
96
+ ]);
97
+ expect(exitCode).toBe(1);
98
+ } finally {
99
+ process.chdir(previousCwd);
100
+ }
101
+ });
102
+
103
+ it("writes JSON report to --out", async () => {
104
+ writeFixture({
105
+ login_button: { en: "Login", it: "Accedi" },
106
+ });
107
+
108
+ const outPath = join(tempDir, "audit.json");
109
+ const previousCwd = process.cwd();
110
+ process.chdir(tempDir);
111
+ try {
112
+ const exitCode = await runAuditCli(["--config", "i18n/i18n.codegen.json", "--out", outPath]);
113
+ expect(exitCode).toBe(0);
114
+ const report = JSON.parse(readFileSync(outPath, "utf8"));
115
+ expect(report.requiredLocales).toEqual(["en", "it"]);
116
+ expect(report.missingEffectiveByLocale.default.en).toEqual([]);
117
+ } finally {
118
+ process.chdir(previousCwd);
119
+ }
120
+ });
121
+
122
+ it("runs through the bin wrapper", () => {
123
+ writeFixture({
124
+ login_button: { en: "Login" },
125
+ });
126
+
127
+ const result = spawnSync("node", [auditBin, "--config", "i18n/i18n.codegen.json"], {
128
+ cwd: tempDir,
129
+ encoding: "utf8",
130
+ });
131
+
132
+ expect(result.status).toBe(0);
133
+ const report = JSON.parse(result.stdout);
134
+ expect(report.requiredLocales).toEqual(["en", "it"]);
135
+ });
136
+ });
@@ -0,0 +1,81 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { loadConfig } from "../codegen/config.js";
4
+ import { fail } from "../codegen/paths.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
+ fail(`[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 { configPath, outPath, failOn, treatEmptyAsMissing };
41
+ }
42
+
43
+ export async function runAuditCli(argv = process.argv.slice(2)): Promise<number> {
44
+ const options = parseAuditArgs(argv);
45
+
46
+ if (!fs.existsSync(options.configPath)) {
47
+ console.error(`[Audit Error] Config file not found: ${options.configPath}`);
48
+ return 2;
49
+ }
50
+
51
+ const projectRoot = path.dirname(options.configPath);
52
+ const config = loadConfig(options.configPath);
53
+
54
+ const { report, exitCode } = await runAuditFromConfig({
55
+ projectRoot,
56
+ config,
57
+ configPath: options.configPath,
58
+ treatEmptyAsMissing: options.treatEmptyAsMissing,
59
+ failOn: options.failOn,
60
+ });
61
+
62
+ const json = `${JSON.stringify(report, null, 2)}\n`;
63
+
64
+ if (options.outPath) {
65
+ fs.mkdirSync(path.dirname(options.outPath), { recursive: true });
66
+ fs.writeFileSync(options.outPath, json);
67
+ } else {
68
+ process.stdout.write(json);
69
+ }
70
+
71
+ return exitCode;
72
+ }
73
+
74
+ async function main(): Promise<void> {
75
+ const exitCode = await runAuditCli();
76
+ process.exit(exitCode);
77
+ }
78
+
79
+ if (process.argv[1]?.endsWith("run-audit.ts")) {
80
+ void main();
81
+ }
@@ -4,8 +4,9 @@ import {
4
4
  toImportIdentifier,
5
5
  toImportPath,
6
6
  toModuleBasename,
7
+ toRelativeModuleImport,
7
8
  } from "../paths.js";
8
- import type { NamespaceEntry } from "../types.js";
9
+ import type { ImportExtension, NamespaceEntry } from "../types.js";
9
10
 
10
11
  export interface DictionaryFileOptions {
11
12
  isSingle: boolean;
@@ -16,6 +17,7 @@ export interface DictionaryFileOptions {
16
17
  dictionaryOutputPath: string;
17
18
  typesOutputPath: string;
18
19
  schemaTypeName: string;
20
+ importExtension: ImportExtension;
19
21
  }
20
22
 
21
23
  export function formatDictionaryFile(options: DictionaryFileOptions): string {
@@ -28,9 +30,11 @@ export function formatDictionaryFile(options: DictionaryFileOptions): string {
28
30
  dictionaryOutputPath,
29
31
  typesOutputPath,
30
32
  schemaTypeName,
33
+ importExtension,
31
34
  } = options;
32
35
 
33
36
  const typesModule = toModuleBasename(typesOutputPath);
37
+ const typesImport = toRelativeModuleImport(typesModule, importExtension);
34
38
 
35
39
  if (isSingle) {
36
40
  const entry = entries[0]!;
@@ -43,7 +47,7 @@ export function formatDictionaryFile(options: DictionaryFileOptions): string {
43
47
  return (
44
48
  `${GENERATED_FILE_BANNER}` +
45
49
  `import ${importId} from '${importPath}.json';\n` +
46
- `import type { ${schemaTypeName} } from './${typesModule}.js';\n\n` +
50
+ `import type { ${schemaTypeName} } from '${typesImport}';\n\n` +
47
51
  `export const dictionary: ${schemaTypeName} = ${importId};\n`
48
52
  );
49
53
  }
@@ -67,7 +71,7 @@ export function formatDictionaryFile(options: DictionaryFileOptions): string {
67
71
  return (
68
72
  `${GENERATED_FILE_BANNER}` +
69
73
  `${imports}\n` +
70
- `import type { ${dictionaryTypeName} } from './${typesModule}.js';\n\n` +
74
+ `import type { ${dictionaryTypeName} } from '${typesImport}';\n\n` +
71
75
  `export const dictionary: ${dictionaryTypeName} = {\n${objectEntries}\n};\n`
72
76
  );
73
77
  }
@@ -1,6 +1,6 @@
1
1
  import type { VariableSpec } from "../../icu/extract-variables.js";
2
- import { GENERATED_FILE_BANNER } from "../paths.js";
3
- import type { NamespaceEntry } from "../types.js";
2
+ import { GENERATED_FILE_BANNER, toRelativeModuleImport } from "../paths.js";
3
+ import type { ImportExtension, NamespaceEntry } from "../types.js";
4
4
 
5
5
  function formatVariableSpecObject(spec: VariableSpec): string {
6
6
  const entries = Object.entries(spec);
@@ -70,8 +70,10 @@ export function formatDictionarySchemaFile(
70
70
  schemaTypeName: string,
71
71
  typesModule: string,
72
72
  isSingle: boolean,
73
- dictionarySpecBlock: string
73
+ dictionarySpecBlock: string,
74
+ importExtension: ImportExtension
74
75
  ): string {
76
+ const typesImport = toRelativeModuleImport(typesModule, importExtension);
75
77
  const namespaceImport = isSingle
76
78
  ? ""
77
79
  : ` validateExternalNamespace as validateExternalNamespaceImpl,\n`;
@@ -99,7 +101,7 @@ export function formatDictionarySchemaFile(
99
101
  ` type NormalizedDictionary,\n` +
100
102
  ` type ValidationResult,\n` +
101
103
  `} from '@xndrjs/i18n/validation';\n` +
102
- `import type { ${schemaTypeName} } from './${typesModule}.js';\n\n` +
104
+ `import type { ${schemaTypeName} } from '${typesImport}';\n\n` +
103
105
  `${dictionarySpecBlock}\n` +
104
106
  `export function normalizeExternalDictionary(\n` +
105
107
  ` input: unknown,\n` +
@@ -1,4 +1,5 @@
1
- import { GENERATED_FILE_BANNER, toModuleBasename } from "../paths.js";
1
+ import { GENERATED_FILE_BANNER, toModuleBasename, toRelativeModuleImport } from "../paths.js";
2
+ import type { ImportExtension } from "../types.js";
2
3
 
3
4
  export interface InstanceFileOptions {
4
5
  isSingle: boolean;
@@ -11,6 +12,7 @@ export interface InstanceFileOptions {
11
12
  localeFallbackConstName: string;
12
13
  factoryName: string;
13
14
  hasLocaleFallback: boolean;
15
+ importExtension: ImportExtension;
14
16
  }
15
17
 
16
18
  export function formatInstanceFile(options: InstanceFileOptions): string {
@@ -25,15 +27,18 @@ export function formatInstanceFile(options: InstanceFileOptions): string {
25
27
  localeFallbackConstName,
26
28
  factoryName,
27
29
  hasLocaleFallback,
30
+ importExtension,
28
31
  } = options;
29
32
 
30
33
  const providerClass = isSingle ? "IcuTranslationProviderSingle" : "IcuTranslationProviderMulti";
31
34
  const typesModule = toModuleBasename(typesOutputPath);
32
35
  const dictionaryModule = toModuleBasename(dictionaryOutputPath);
36
+ const typesImport = toRelativeModuleImport(typesModule, importExtension);
37
+ const dictionaryImport = toRelativeModuleImport(dictionaryModule, importExtension);
33
38
  const initialDictionaryType = hasLazy ? "InitialSchema" : schemaTypeName;
34
39
  const initialDictionaryImport = hasLazy
35
- ? `import type { ${paramsTypeName}, ${schemaTypeName}, InitialSchema } from './${typesModule}.js';\n`
36
- : `import type { ${paramsTypeName}, ${schemaTypeName} } from './${typesModule}.js';\n`;
40
+ ? `import type { ${paramsTypeName}, ${schemaTypeName}, InitialSchema } from '${typesImport}';\n`
41
+ : `import type { ${paramsTypeName}, ${schemaTypeName} } from '${typesImport}';\n`;
37
42
  const providerTypeArgs = hasLocaleFallback
38
43
  ? `${schemaTypeName}, ${paramsTypeName}, ${localeTypeName}, typeof ${localeFallbackConstName}`
39
44
  : `${schemaTypeName}, ${paramsTypeName}`;
@@ -41,13 +46,13 @@ export function formatInstanceFile(options: InstanceFileOptions): string {
41
46
  ? `, {\n localeFallback: ${localeFallbackConstName},\n }`
42
47
  : "";
43
48
  const fallbackImport = hasLocaleFallback
44
- ? `import { ${localeFallbackConstName}, type ${localeTypeName} } from './${typesModule}.js';\n`
49
+ ? `import { ${localeFallbackConstName}, type ${localeTypeName} } from '${typesImport}';\n`
45
50
  : "";
46
51
 
47
52
  return (
48
53
  `${GENERATED_FILE_BANNER}` +
49
54
  `import { ${providerClass} } from '@xndrjs/i18n';\n` +
50
- `import { dictionary } from './${dictionaryModule}.js';\n` +
55
+ `import { dictionary } from '${dictionaryImport}';\n` +
51
56
  initialDictionaryImport +
52
57
  fallbackImport +
53
58
  `\n` +
@@ -1,5 +1,5 @@
1
- import { GENERATED_FILE_BANNER, toImportPath } from "../paths.js";
2
- import type { NamespaceEntry } from "../types.js";
1
+ import { GENERATED_FILE_BANNER, toImportPath, toRelativeModuleImport } from "../paths.js";
2
+ import type { ImportExtension, NamespaceEntry } from "../types.js";
3
3
 
4
4
  export function formatNamespaceLoadersFile(
5
5
  loadersOutputPath: string,
@@ -8,8 +8,12 @@ export function formatNamespaceLoadersFile(
8
8
  typesModule: string,
9
9
  dictionarySchemaModule: string,
10
10
  instanceModule: string,
11
- factoryName: string
11
+ factoryName: string,
12
+ importExtension: ImportExtension
12
13
  ): string {
14
+ const dictionarySchemaImport = toRelativeModuleImport(dictionarySchemaModule, importExtension);
15
+ const typesImport = toRelativeModuleImport(typesModule, importExtension);
16
+ const instanceImport = toRelativeModuleImport(instanceModule, importExtension);
13
17
  const loaderEntries = lazyEntries
14
18
  .map((entry) => {
15
19
  const importPath = toImportPath(loadersOutputPath, entry.absolutePath);
@@ -20,9 +24,9 @@ export function formatNamespaceLoadersFile(
20
24
  return (
21
25
  `${GENERATED_FILE_BANNER}` +
22
26
  `import { ensureNamespacesLoadedImpl } from '@xndrjs/i18n';\n` +
23
- `import { validateExternalNamespace } from './${dictionarySchemaModule}.js';\n` +
24
- `import type { ${schemaTypeName}, LazyNamespace } from './${typesModule}.js';\n` +
25
- `import type { ${factoryName} } from './${instanceModule}.js';\n\n` +
27
+ `import { validateExternalNamespace } from '${dictionarySchemaImport}';\n` +
28
+ `import type { ${schemaTypeName}, LazyNamespace } from '${typesImport}';\n` +
29
+ `import type { ${factoryName} } from '${instanceImport}';\n\n` +
26
30
  `export const namespaceLoaders: Record<\n` +
27
31
  ` LazyNamespace,\n` +
28
32
  ` () => Promise<${schemaTypeName}[LazyNamespace]>\n` +