@flightdev/i18n 0.1.5

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,127 @@
1
+ /**
2
+ * Type Generation for Type-Safe Translation Keys
3
+ *
4
+ * Generates TypeScript declaration files from JSON translation files,
5
+ * enabling full autocompletion and compile-time validation of translation keys.
6
+ *
7
+ * @example CLI Usage
8
+ * ```bash
9
+ * # Generate types from translation directory
10
+ * npx flight i18n:typegen --input ./locales/en --output ./src/i18n.d.ts
11
+ *
12
+ * # Generate nested types
13
+ * npx flight i18n:typegen --input ./locales/en --output ./src/i18n.d.ts --style nested
14
+ * ```
15
+ *
16
+ * @example Programmatic Usage
17
+ * ```typescript
18
+ * import { generateTypesAndWrite } from '@flightdev/i18n/typegen';
19
+ *
20
+ * await generateTypesAndWrite({
21
+ * input: './locales/en',
22
+ * output: './src/i18n.d.ts',
23
+ * style: 'flat',
24
+ * });
25
+ * ```
26
+ *
27
+ * @example Using Generated Types
28
+ * ```typescript
29
+ * import type { TranslationKey } from './i18n.d.ts';
30
+ * import { createTypedI18n } from '@flightdev/i18n';
31
+ *
32
+ * const i18n = createTypedI18n<TranslationKey>(adapter);
33
+ *
34
+ * i18n.t('nav.home'); // Valid - autocomplete works
35
+ * i18n.t('nav.nonexistent'); // TypeScript error
36
+ * ```
37
+ *
38
+ * @module @flightdev/i18n/typegen
39
+ */
40
+ /**
41
+ * Options for type generation
42
+ */
43
+ interface TypeGenOptions {
44
+ /** Input directory or JSON file containing translations */
45
+ input: string;
46
+ /** Output path for generated .d.ts file */
47
+ output: string;
48
+ /**
49
+ * Type style:
50
+ * - 'flat': Union of string literal types ("nav.home" | "nav.about")
51
+ * - 'nested': Nested object types matching JSON structure
52
+ * @default 'flat'
53
+ */
54
+ style?: 'flat' | 'nested';
55
+ /**
56
+ * Custom header comment for generated file
57
+ * @default Auto-generated header with timestamp
58
+ */
59
+ header?: string;
60
+ /**
61
+ * Module name if generating a module declaration
62
+ * If provided, wraps types in `declare module`
63
+ */
64
+ moduleName?: string;
65
+ }
66
+ /**
67
+ * Result of type generation
68
+ */
69
+ interface TypeGenResult {
70
+ /** Generated TypeScript code */
71
+ content: string;
72
+ /** Number of translation keys found */
73
+ keyCount: number;
74
+ /** List of all keys in flat format */
75
+ keys: string[];
76
+ }
77
+ /** Recursive record type for nested translations */
78
+ type NestedRecord = {
79
+ [key: string]: string | NestedRecord;
80
+ };
81
+ /**
82
+ * Extract all translation keys from a nested object in dot notation
83
+ *
84
+ * @param obj - Translation object
85
+ * @param prefix - Current key prefix
86
+ * @returns Array of dot-notation keys
87
+ *
88
+ * @example
89
+ * ```typescript
90
+ * extractKeysFromObject({ nav: { home: 'Home', about: 'About' } });
91
+ * // ['nav.home', 'nav.about']
92
+ * ```
93
+ */
94
+ declare function extractKeysFromObject(obj: NestedRecord, prefix?: string): string[];
95
+ /**
96
+ * Load translation data from file or directory
97
+ *
98
+ * @param inputPath - Path to JSON file or directory containing JSON files
99
+ * @returns Merged translation object
100
+ */
101
+ declare function loadTranslations(inputPath: string): NestedRecord;
102
+ /**
103
+ * Generate TypeScript types from translation files
104
+ *
105
+ * @param options - Generation options
106
+ * @returns Generated TypeScript content and metadata
107
+ */
108
+ declare function generateTypes(options: TypeGenOptions): TypeGenResult;
109
+ /**
110
+ * Generate types and write to output file
111
+ *
112
+ * @param options - Generation options
113
+ */
114
+ declare function generateTypesAndWrite(options: TypeGenOptions): Promise<TypeGenResult>;
115
+ interface WatchOptions extends TypeGenOptions {
116
+ /** Debounce delay in milliseconds */
117
+ debounce?: number;
118
+ }
119
+ /**
120
+ * Watch translation files and regenerate types on changes
121
+ *
122
+ * @param options - Watch options
123
+ * @returns Cleanup function to stop watching
124
+ */
125
+ declare function watchAndGenerate(options: WatchOptions): () => void;
126
+
127
+ export { type TypeGenOptions, type TypeGenResult, type WatchOptions, extractKeysFromObject, generateTypes, generateTypesAndWrite, loadTranslations, watchAndGenerate };
@@ -0,0 +1,214 @@
1
+ // src/typegen.ts
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ function extractKeysFromObject(obj, prefix = "") {
5
+ const keys = [];
6
+ for (const [key, value] of Object.entries(obj)) {
7
+ const safeKey = key.replace(/[^a-zA-Z0-9_]/g, "_");
8
+ const fullKey = prefix ? `${prefix}.${safeKey}` : safeKey;
9
+ if (typeof value === "string") {
10
+ keys.push(fullKey);
11
+ } else if (typeof value === "object" && value !== null) {
12
+ keys.push(...extractKeysFromObject(value, fullKey));
13
+ }
14
+ }
15
+ return keys.sort();
16
+ }
17
+ function loadTranslations(inputPath) {
18
+ const absolutePath = path.resolve(inputPath);
19
+ if (!fs.existsSync(absolutePath)) {
20
+ throw new Error(`Translation path does not exist: ${absolutePath}`);
21
+ }
22
+ const stat = fs.statSync(absolutePath);
23
+ if (stat.isFile()) {
24
+ if (!absolutePath.endsWith(".json")) {
25
+ throw new Error(`Input file must be a JSON file: ${absolutePath}`);
26
+ }
27
+ const content = fs.readFileSync(absolutePath, "utf-8");
28
+ return JSON.parse(content);
29
+ }
30
+ if (stat.isDirectory()) {
31
+ const merged = {};
32
+ const files = fs.readdirSync(absolutePath);
33
+ for (const file of files) {
34
+ if (!file.endsWith(".json")) continue;
35
+ const namespace = file.replace(".json", "");
36
+ const filePath = path.join(absolutePath, file);
37
+ const content = fs.readFileSync(filePath, "utf-8");
38
+ try {
39
+ merged[namespace] = JSON.parse(content);
40
+ } catch (error) {
41
+ throw new Error(`Failed to parse ${filePath}: ${error.message}`);
42
+ }
43
+ }
44
+ if (Object.keys(merged).length === 0) {
45
+ throw new Error(`No JSON files found in directory: ${absolutePath}`);
46
+ }
47
+ return merged;
48
+ }
49
+ throw new Error(`Input path is neither a file nor directory: ${absolutePath}`);
50
+ }
51
+ function generateFlatTypes(keys, options) {
52
+ const header = options.header ?? generateDefaultHeader();
53
+ const keyLiterals = keys.map((k) => ` | '${k}'`).join("\n");
54
+ let content = `${header}
55
+
56
+ /**
57
+ * All available translation keys (union type)
58
+ * Use with createTypedI18n for full type safety
59
+ */
60
+ export type TranslationKey =
61
+ ${keyLiterals};
62
+
63
+ /**
64
+ * Translation key to value mapping
65
+ */
66
+ export type TranslationKeys = {
67
+ [K in TranslationKey]: string;
68
+ };
69
+
70
+ /**
71
+ * Helper type to validate translation keys
72
+ */
73
+ export type ValidTranslationKey<T extends string> = T extends TranslationKey ? T : never;
74
+ `;
75
+ if (options.moduleName) {
76
+ content = `declare module '${options.moduleName}' {
77
+ ${content}
78
+ }`;
79
+ }
80
+ return content;
81
+ }
82
+ function generateNestedInterface(obj, indent = 1) {
83
+ const spaces = " ".repeat(indent);
84
+ const lines = [];
85
+ for (const [key, value] of Object.entries(obj)) {
86
+ const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : `'${key}'`;
87
+ if (typeof value === "string") {
88
+ lines.push(`${spaces}${safeKey}: string;`);
89
+ } else if (typeof value === "object" && value !== null) {
90
+ lines.push(`${spaces}${safeKey}: {`);
91
+ lines.push(generateNestedInterface(value, indent + 1));
92
+ lines.push(`${spaces}};`);
93
+ }
94
+ }
95
+ return lines.join("\n");
96
+ }
97
+ function generateNestedTypes(obj, options) {
98
+ const header = options.header ?? generateDefaultHeader();
99
+ const interfaceContent = generateNestedInterface(obj);
100
+ let content = `${header}
101
+
102
+ /**
103
+ * Nested translation structure matching JSON files
104
+ */
105
+ export interface TranslationTree {
106
+ ${interfaceContent}
107
+ }
108
+
109
+ /**
110
+ * Utility type to flatten nested keys to dot notation
111
+ */
112
+ type FlattenKeys<T, Prefix extends string = ''> = T extends string
113
+ ? Prefix
114
+ : {
115
+ [K in keyof T]: K extends string
116
+ ? FlattenKeys<T[K], Prefix extends '' ? K : \`\${Prefix}.\${K}\`>
117
+ : never;
118
+ }[keyof T];
119
+
120
+ /**
121
+ * All available translation keys (flattened from nested structure)
122
+ */
123
+ export type TranslationKey = FlattenKeys<TranslationTree>;
124
+
125
+ /**
126
+ * Access nested value by dot-notation key
127
+ */
128
+ type GetNested<T, K extends string> = K extends \`\${infer A}.\${infer B}\`
129
+ ? A extends keyof T
130
+ ? GetNested<T[A], B>
131
+ : never
132
+ : K extends keyof T
133
+ ? T[K]
134
+ : never;
135
+
136
+ /**
137
+ * Get the value type for a specific key
138
+ */
139
+ export type TranslationValue<K extends TranslationKey> = GetNested<TranslationTree, K>;
140
+ `;
141
+ if (options.moduleName) {
142
+ content = `declare module '${options.moduleName}' {
143
+ ${content}
144
+ }`;
145
+ }
146
+ return content;
147
+ }
148
+ function generateDefaultHeader() {
149
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
150
+ return `/**
151
+ * Auto-generated translation types
152
+ * DO NOT EDIT - Generated by @flightdev/i18n
153
+ * Generated at: ${timestamp}
154
+ *
155
+ * To regenerate, run:
156
+ * npx flight i18n:typegen --input <translations-path> --output <output-path>
157
+ */`;
158
+ }
159
+ function generateTypes(options) {
160
+ const { input, style = "flat" } = options;
161
+ const translations = loadTranslations(input);
162
+ const keys = extractKeysFromObject(translations);
163
+ const content = style === "nested" ? generateNestedTypes(translations, options) : generateFlatTypes(keys, options);
164
+ return {
165
+ content,
166
+ keyCount: keys.length,
167
+ keys
168
+ };
169
+ }
170
+ async function generateTypesAndWrite(options) {
171
+ const result = generateTypes(options);
172
+ const outputPath = path.resolve(options.output);
173
+ const outputDir = path.dirname(outputPath);
174
+ if (!fs.existsSync(outputDir)) {
175
+ fs.mkdirSync(outputDir, { recursive: true });
176
+ }
177
+ fs.writeFileSync(outputPath, result.content, "utf-8");
178
+ console.log(`Generated ${result.keyCount} translation keys at ${outputPath}`);
179
+ return result;
180
+ }
181
+ function watchAndGenerate(options) {
182
+ const { input, debounce = 100 } = options;
183
+ const absolutePath = path.resolve(input);
184
+ let timeout = null;
185
+ const regenerate = () => {
186
+ try {
187
+ generateTypesAndWrite(options);
188
+ } catch (error) {
189
+ console.error("Type generation failed:", error.message);
190
+ }
191
+ };
192
+ const debouncedRegenerate = () => {
193
+ if (timeout) clearTimeout(timeout);
194
+ timeout = setTimeout(regenerate, debounce);
195
+ };
196
+ regenerate();
197
+ const watcher = fs.watch(absolutePath, { recursive: true }, (eventType, filename) => {
198
+ if (filename?.endsWith(".json")) {
199
+ console.log(`Translation file changed: ${filename}`);
200
+ debouncedRegenerate();
201
+ }
202
+ });
203
+ return () => {
204
+ watcher.close();
205
+ if (timeout) clearTimeout(timeout);
206
+ };
207
+ }
208
+ export {
209
+ extractKeysFromObject,
210
+ generateTypes,
211
+ generateTypesAndWrite,
212
+ loadTranslations,
213
+ watchAndGenerate
214
+ };
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@flightdev/i18n",
3
+ "version": "0.1.5",
4
+ "description": "Agnostic internationalization for Flight Framework. Choose your engine: i18next, Paraglide, FormatJS, Lingui, or custom.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "import": "./dist/index.js"
10
+ },
11
+ "./i18next": {
12
+ "types": "./dist/adapters/i18next.d.ts",
13
+ "import": "./dist/adapters/i18next.js"
14
+ },
15
+ "./paraglide": {
16
+ "types": "./dist/adapters/paraglide.d.ts",
17
+ "import": "./dist/adapters/paraglide.js"
18
+ },
19
+ "./formatjs": {
20
+ "types": "./dist/adapters/formatjs.d.ts",
21
+ "import": "./dist/adapters/formatjs.js"
22
+ },
23
+ "./lingui": {
24
+ "types": "./dist/adapters/lingui.d.ts",
25
+ "import": "./dist/adapters/lingui.js"
26
+ },
27
+ "./routing": {
28
+ "types": "./dist/routing.d.ts",
29
+ "import": "./dist/routing.js"
30
+ },
31
+ "./middleware": {
32
+ "types": "./dist/middleware.d.ts",
33
+ "import": "./dist/middleware.js"
34
+ },
35
+ "./typegen": {
36
+ "types": "./dist/typegen.d.ts",
37
+ "import": "./dist/typegen.js"
38
+ }
39
+ },
40
+ "files": [
41
+ "dist"
42
+ ],
43
+ "peerDependencies": {
44
+ "@formatjs/intl": ">=2.0.0",
45
+ "@lingui/core": ">=5.0.0",
46
+ "i18next": ">=23.0.0"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "@formatjs/intl": {
50
+ "optional": true
51
+ },
52
+ "@lingui/core": {
53
+ "optional": true
54
+ },
55
+ "i18next": {
56
+ "optional": true
57
+ }
58
+ },
59
+ "devDependencies": {
60
+ "@types/node": "^22.0.0",
61
+ "tsup": "^8.0.0",
62
+ "typescript": "^5.7.0",
63
+ "vitest": "^2.0.0"
64
+ },
65
+ "license": "MIT",
66
+ "scripts": {
67
+ "build": "tsup",
68
+ "dev": "tsup --watch",
69
+ "test": "vitest run",
70
+ "typecheck": "tsc --noEmit"
71
+ }
72
+ }