@xndrjs/i18n 0.2.1 → 0.3.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.
@@ -0,0 +1,134 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { auditDictionaries, reportHasGaps } from "./audit-dictionaries.js";
3
+
4
+ describe("auditDictionaries", () => {
5
+ it("reports missingDirect for fallback-only locales while missingEffective stays empty when en covers", () => {
6
+ const report = auditDictionaries({
7
+ namespaces: {
8
+ default: {
9
+ login_button: { en: "Login", it: "Accedi" },
10
+ },
11
+ },
12
+ config: {
13
+ localeFallback: {
14
+ en: null,
15
+ "de-DE": "en",
16
+ "de-CH": "de-DE",
17
+ it: "en",
18
+ },
19
+ },
20
+ });
21
+
22
+ expect(report.requiredLocales).toEqual(["de-CH", "de-DE", "en", "it"]);
23
+ expect(report.missingDirectByLocale.default?.["de-CH"]).toEqual(["login_button"]);
24
+ expect(report.missingEffectiveByLocale.default?.["de-CH"]).toEqual([]);
25
+ expect(report.missingDirectByLocale.default?.en).toEqual([]);
26
+ });
27
+
28
+ it("treats empty strings as missing by default", () => {
29
+ const report = auditDictionaries({
30
+ namespaces: {
31
+ default: {
32
+ empty_label: { en: "" },
33
+ },
34
+ },
35
+ config: {},
36
+ });
37
+
38
+ expect(report.missingDirectByLocale.default?.en).toEqual(["empty_label"]);
39
+ expect(report.missingEffectiveByLocale.default?.en).toEqual(["empty_label"]);
40
+ });
41
+
42
+ it("allows empty strings when treatEmptyAsMissing is false", () => {
43
+ const report = auditDictionaries({
44
+ namespaces: {
45
+ default: {
46
+ empty_label: { en: "" },
47
+ },
48
+ },
49
+ config: {},
50
+ treatEmptyAsMissing: false,
51
+ });
52
+
53
+ expect(report.missingDirectByLocale.default?.en).toEqual([]);
54
+ expect(report.missingEffectiveByLocale.default?.en).toEqual([]);
55
+ });
56
+
57
+ it("uses enriched locale fallback for effective gaps", () => {
58
+ const report = auditDictionaries({
59
+ namespaces: {
60
+ default: {
61
+ welcome: { en: "Welcome {name}!" },
62
+ },
63
+ },
64
+ config: {
65
+ localeFallback: {
66
+ en: null,
67
+ it: "en",
68
+ },
69
+ },
70
+ });
71
+
72
+ expect(report.localeFallback).toEqual({
73
+ en: null,
74
+ it: "en",
75
+ });
76
+ expect(report.missingEffectiveByLocale.default?.it).toEqual([]);
77
+ });
78
+
79
+ it("matches direct and effective when localeFallback is absent", () => {
80
+ const report = auditDictionaries({
81
+ namespaces: {
82
+ default: {
83
+ login_button: { en: "Login" },
84
+ },
85
+ },
86
+ config: {},
87
+ });
88
+
89
+ expect(report.localeFallback).toBeUndefined();
90
+ expect(report.requiredLocales).toEqual(["en"]);
91
+ expect(report.missingDirectByLocale.default?.en).toEqual([]);
92
+ expect(report.missingEffectiveByLocale.default?.en).toEqual([]);
93
+ });
94
+
95
+ it("reports effective gaps when fallback chain cannot resolve", () => {
96
+ const report = auditDictionaries({
97
+ namespaces: {
98
+ default: {
99
+ login_button: { it: "Accedi" },
100
+ },
101
+ },
102
+ config: {
103
+ localeFallback: {
104
+ en: null,
105
+ it: "en",
106
+ },
107
+ },
108
+ });
109
+
110
+ expect(report.missingEffectiveByLocale.default?.en).toEqual(["login_button"]);
111
+ });
112
+ });
113
+
114
+ describe("reportHasGaps", () => {
115
+ const report = auditDictionaries({
116
+ namespaces: {
117
+ default: {
118
+ login_button: { en: "Login", it: "Accedi" },
119
+ },
120
+ },
121
+ config: {
122
+ localeFallback: {
123
+ en: null,
124
+ "de-CH": "en",
125
+ },
126
+ },
127
+ });
128
+
129
+ it("detects direct gaps", () => {
130
+ expect(reportHasGaps(report, "direct")).toBe(true);
131
+ expect(reportHasGaps(report, "effective")).toBe(false);
132
+ expect(reportHasGaps(report, "any")).toBe(true);
133
+ });
134
+ });
@@ -0,0 +1,172 @@
1
+ import path from "node:path";
2
+ import type { CodegenConfig, DictionaryJson } from "../codegen/types.js";
3
+ import { buildRequiredLocales, enrichLocaleFallback } from "../codegen/locale-policy.js";
4
+ import { resolveLocaleTemplate } from "../resolve-locale.js";
5
+
6
+ export type FailOnCriterion = "effective" | "direct" | "any";
7
+
8
+ export interface I18nAuditReport {
9
+ requiredLocales: string[];
10
+ localeFallback?: Record<string, string | null>;
11
+ summary: Record<string, Record<string, { missingDirect: number; missingEffective: number }>>;
12
+ missingDirectByLocale: Record<string, Record<string, string[]>>;
13
+ missingEffectiveByLocale: Record<string, Record<string, string[]>>;
14
+ }
15
+
16
+ export interface AuditDictionariesOptions {
17
+ namespaces: Record<string, DictionaryJson>;
18
+ config: Pick<CodegenConfig, "localeFallback" | "defaultNamespace">;
19
+ treatEmptyAsMissing?: boolean;
20
+ }
21
+
22
+ function isDirectlyMissing(
23
+ localesByKey: Record<string, string>,
24
+ locale: string,
25
+ treatEmptyAsMissing: boolean
26
+ ): boolean {
27
+ const template = localesByKey[locale];
28
+ if (template === undefined) {
29
+ return true;
30
+ }
31
+ return treatEmptyAsMissing && template === "";
32
+ }
33
+
34
+ function isEffectivelyMissing(
35
+ localesByKey: Record<string, string>,
36
+ locale: string,
37
+ localeFallback: Record<string, string | null> | undefined,
38
+ treatEmptyAsMissing: boolean
39
+ ): boolean {
40
+ const resolved = resolveLocaleTemplate(localesByKey, locale, localeFallback);
41
+ if (resolved === undefined) {
42
+ return true;
43
+ }
44
+ return treatEmptyAsMissing && resolved.template === "";
45
+ }
46
+
47
+ function collectDictionaryLocales(namespaces: Record<string, DictionaryJson>): Set<string> {
48
+ const locales = new Set<string>();
49
+
50
+ for (const dictionary of Object.values(namespaces)) {
51
+ for (const localesByKey of Object.values(dictionary)) {
52
+ for (const locale of Object.keys(localesByKey)) {
53
+ locales.add(locale);
54
+ }
55
+ }
56
+ }
57
+
58
+ return locales;
59
+ }
60
+
61
+ export function auditDictionaries(options: AuditDictionariesOptions): I18nAuditReport {
62
+ const { namespaces, config, treatEmptyAsMissing = true } = options;
63
+ const dictionaryLocales = collectDictionaryLocales(namespaces);
64
+ const requiredLocales = buildRequiredLocales(dictionaryLocales, config.localeFallback);
65
+ const localeFallbackForAudit = config.localeFallback
66
+ ? enrichLocaleFallback(dictionaryLocales, config.localeFallback)
67
+ : undefined;
68
+
69
+ const summary: I18nAuditReport["summary"] = {};
70
+ const missingDirectByLocale: I18nAuditReport["missingDirectByLocale"] = {};
71
+ const missingEffectiveByLocale: I18nAuditReport["missingEffectiveByLocale"] = {};
72
+
73
+ for (const [namespace, dictionary] of Object.entries(namespaces)) {
74
+ summary[namespace] = {};
75
+ missingDirectByLocale[namespace] = {};
76
+ missingEffectiveByLocale[namespace] = {};
77
+
78
+ for (const locale of requiredLocales) {
79
+ summary[namespace]![locale] = { missingDirect: 0, missingEffective: 0 };
80
+ missingDirectByLocale[namespace]![locale] = [];
81
+ missingEffectiveByLocale[namespace]![locale] = [];
82
+ }
83
+
84
+ for (const [key, localesByKey] of Object.entries(dictionary)) {
85
+ for (const locale of requiredLocales) {
86
+ if (isDirectlyMissing(localesByKey, locale, treatEmptyAsMissing)) {
87
+ summary[namespace]![locale]!.missingDirect += 1;
88
+ missingDirectByLocale[namespace]![locale]!.push(key);
89
+ }
90
+
91
+ if (
92
+ isEffectivelyMissing(localesByKey, locale, localeFallbackForAudit, treatEmptyAsMissing)
93
+ ) {
94
+ summary[namespace]![locale]!.missingEffective += 1;
95
+ missingEffectiveByLocale[namespace]![locale]!.push(key);
96
+ }
97
+ }
98
+ }
99
+
100
+ for (const locale of requiredLocales) {
101
+ missingDirectByLocale[namespace]![locale]!.sort();
102
+ missingEffectiveByLocale[namespace]![locale]!.sort();
103
+ }
104
+ }
105
+
106
+ return {
107
+ requiredLocales,
108
+ localeFallback: localeFallbackForAudit,
109
+ summary,
110
+ missingDirectByLocale,
111
+ missingEffectiveByLocale,
112
+ };
113
+ }
114
+
115
+ export function reportHasGaps(report: I18nAuditReport, criterion: FailOnCriterion): boolean {
116
+ for (const namespace of Object.keys(report.missingDirectByLocale)) {
117
+ for (const locale of report.requiredLocales) {
118
+ const direct = report.missingDirectByLocale[namespace]?.[locale] ?? [];
119
+ const effective = report.missingEffectiveByLocale[namespace]?.[locale] ?? [];
120
+
121
+ if (criterion === "direct" && direct.length > 0) {
122
+ return true;
123
+ }
124
+ if (criterion === "effective" && effective.length > 0) {
125
+ return true;
126
+ }
127
+ if (criterion === "any" && (direct.length > 0 || effective.length > 0)) {
128
+ return true;
129
+ }
130
+ }
131
+ }
132
+
133
+ return false;
134
+ }
135
+
136
+ export interface RunAuditOptions {
137
+ projectRoot: string;
138
+ config: CodegenConfig;
139
+ configPath: string;
140
+ treatEmptyAsMissing?: boolean;
141
+ failOn?: FailOnCriterion;
142
+ }
143
+
144
+ export interface RunAuditResult {
145
+ report: I18nAuditReport;
146
+ exitCode: 0 | 1;
147
+ }
148
+
149
+ export async function runAuditFromConfig(options: RunAuditOptions): Promise<RunAuditResult> {
150
+ const { resolveNamespaces } = await import("../codegen/config.js");
151
+ const { readDictionaryFile } = await import("../codegen/read-dictionary.js");
152
+
153
+ const config = options.config;
154
+ const entries = resolveNamespaces(config);
155
+ const namespaces: Record<string, DictionaryJson> = {};
156
+
157
+ for (const entry of entries) {
158
+ const absolutePath = path.resolve(options.projectRoot, entry.filePath);
159
+ namespaces[entry.namespace] = readDictionaryFile(absolutePath);
160
+ }
161
+
162
+ const report = auditDictionaries({
163
+ namespaces,
164
+ config,
165
+ treatEmptyAsMissing: options.treatEmptyAsMissing,
166
+ });
167
+
168
+ const exitCode =
169
+ options.failOn && reportHasGaps(report, options.failOn) ? (1 as const) : (0 as const);
170
+
171
+ return { report, exitCode };
172
+ }
@@ -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,8 @@ export interface InstanceFileOptions {
11
12
  localeFallbackConstName: string;
12
13
  factoryName: string;
13
14
  hasLocaleFallback: boolean;
15
+ hasLocaleType: boolean;
16
+ importExtension: ImportExtension;
14
17
  }
15
18
 
16
19
  export function formatInstanceFile(options: InstanceFileOptions): string {
@@ -25,36 +28,51 @@ export function formatInstanceFile(options: InstanceFileOptions): string {
25
28
  localeFallbackConstName,
26
29
  factoryName,
27
30
  hasLocaleFallback,
31
+ hasLocaleType,
32
+ importExtension,
28
33
  } = options;
29
34
 
30
35
  const providerClass = isSingle ? "IcuTranslationProviderSingle" : "IcuTranslationProviderMulti";
31
36
  const typesModule = toModuleBasename(typesOutputPath);
32
37
  const dictionaryModule = toModuleBasename(dictionaryOutputPath);
38
+ const typesImport = toRelativeModuleImport(typesModule, importExtension);
39
+ const dictionaryImport = toRelativeModuleImport(dictionaryModule, importExtension);
33
40
  const initialDictionaryType = hasLazy ? "InitialSchema" : schemaTypeName;
34
41
  const initialDictionaryImport = hasLazy
35
- ? `import type { ${paramsTypeName}, ${schemaTypeName}, InitialSchema } from './${typesModule}.js';\n`
36
- : `import type { ${paramsTypeName}, ${schemaTypeName} } from './${typesModule}.js';\n`;
42
+ ? `import type { ${paramsTypeName}, ${schemaTypeName}, InitialSchema } from '${typesImport}';\n`
43
+ : `import type { ${paramsTypeName}, ${schemaTypeName} } from '${typesImport}';\n`;
37
44
  const providerTypeArgs = hasLocaleFallback
38
45
  ? `${schemaTypeName}, ${paramsTypeName}, ${localeTypeName}, typeof ${localeFallbackConstName}`
39
46
  : `${schemaTypeName}, ${paramsTypeName}`;
40
47
  const providerOptions = hasLocaleFallback
41
48
  ? `, {\n localeFallback: ${localeFallbackConstName},\n }`
42
49
  : "";
43
- const fallbackImport = hasLocaleFallback
44
- ? `import { ${localeFallbackConstName}, type ${localeTypeName} } from './${typesModule}.js';\n`
50
+ const typesImportLine = hasLocaleType
51
+ ? hasLocaleFallback
52
+ ? `import { ${localeFallbackConstName}, type ${localeTypeName} } from '${typesImport}';\n`
53
+ : `import type { ${localeTypeName} } from '${typesImport}';\n`
45
54
  : "";
55
+ const localesParamType = hasLocaleType ? `readonly ${localeTypeName}[]` : "readonly string[]";
56
+ const projectLocalesFallbackArg = hasLocaleFallback ? `, ${localeFallbackConstName}` : "";
46
57
 
47
58
  return (
48
59
  `${GENERATED_FILE_BANNER}` +
49
- `import { ${providerClass} } from '@xndrjs/i18n';\n` +
50
- `import { dictionary } from './${dictionaryModule}.js';\n` +
60
+ `import { ${providerClass}, projectLocales as projectLocalesCore, type KeyDictionary } from '@xndrjs/i18n';\n` +
61
+ `import { dictionary } from '${dictionaryImport}';\n` +
51
62
  initialDictionaryImport +
52
- fallbackImport +
63
+ typesImportLine +
53
64
  `\n` +
54
65
  `export function ${factoryName}(\n` +
55
66
  ` initialDictionary: ${initialDictionaryType} = dictionary,\n` +
56
67
  `) {\n` +
57
68
  ` return new ${providerClass}<${providerTypeArgs}>(initialDictionary${providerOptions});\n` +
69
+ `}\n` +
70
+ `\n` +
71
+ `export function projectLocales(\n` +
72
+ ` dictionary: KeyDictionary,\n` +
73
+ ` locales: ${localesParamType},\n` +
74
+ `): KeyDictionary {\n` +
75
+ ` return projectLocalesCore(dictionary, locales${projectLocalesFallbackArg});\n` +
58
76
  `}\n`
59
77
  );
60
78
  }