@zachhandley/ez-i18n 0.1.1 → 0.1.3
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 +125 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +385 -24
- package/dist/runtime/react-plugin.d.ts +29 -0
- package/dist/runtime/react-plugin.js +85 -0
- package/dist/runtime/vue-plugin.d.ts +1 -1
- package/dist/types-CHyDGt_C.d.ts +86 -0
- package/dist/utils/index.d.ts +59 -0
- package/dist/utils/index.js +190 -0
- package/package.json +112 -89
- package/src/components/EzI18nHead.astro +1 -1
- package/src/runtime/react-plugin.ts +78 -0
- package/src/types.ts +57 -12
- package/src/utils/index.ts +13 -0
- package/src/utils/translations.ts +311 -0
- package/src/vite-plugin.ts +329 -29
- package/dist/types-DwCG8sp8.d.ts +0 -48
package/src/vite-plugin.ts
CHANGED
|
@@ -1,33 +1,130 @@
|
|
|
1
|
-
import type { Plugin } from 'vite';
|
|
1
|
+
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';
|
|
2
2
|
import type { EzI18nConfig, ResolvedEzI18nConfig } from './types';
|
|
3
|
+
import {
|
|
4
|
+
resolveTranslationsConfig,
|
|
5
|
+
toRelativeImport,
|
|
6
|
+
toGlobPattern,
|
|
7
|
+
loadCache,
|
|
8
|
+
saveCache,
|
|
9
|
+
isCacheValid,
|
|
10
|
+
detectPathType,
|
|
11
|
+
} from './utils/translations';
|
|
12
|
+
import * as path from 'node:path';
|
|
3
13
|
|
|
4
14
|
const VIRTUAL_CONFIG = 'ez-i18n:config';
|
|
5
15
|
const VIRTUAL_RUNTIME = 'ez-i18n:runtime';
|
|
6
16
|
const VIRTUAL_TRANSLATIONS = 'ez-i18n:translations';
|
|
7
17
|
const RESOLVED_PREFIX = '\0';
|
|
8
18
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
locales: config.locales,
|
|
15
|
-
defaultLocale: config.defaultLocale,
|
|
16
|
-
cookieName: config.cookieName ?? 'ez-locale',
|
|
17
|
-
translations: config.translations ?? {},
|
|
18
|
-
};
|
|
19
|
+
interface TranslationInfo {
|
|
20
|
+
locale: string;
|
|
21
|
+
files: string[];
|
|
22
|
+
/** Glob pattern for dev mode HMR (if applicable) */
|
|
23
|
+
globPattern?: string;
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
/**
|
|
22
27
|
* Vite plugin that provides virtual modules for ez-i18n
|
|
23
28
|
*/
|
|
24
29
|
export function vitePlugin(config: EzI18nConfig): Plugin {
|
|
25
|
-
|
|
30
|
+
let viteConfig: ResolvedConfig;
|
|
31
|
+
let isDev = false;
|
|
32
|
+
let resolved: ResolvedEzI18nConfig;
|
|
33
|
+
let translationInfo: Map<string, TranslationInfo> = new Map();
|
|
26
34
|
|
|
27
35
|
return {
|
|
28
36
|
name: 'ez-i18n-vite',
|
|
29
37
|
enforce: 'pre',
|
|
30
38
|
|
|
39
|
+
configResolved(resolvedConfig) {
|
|
40
|
+
viteConfig = resolvedConfig;
|
|
41
|
+
isDev = resolvedConfig.command === 'serve';
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
async buildStart() {
|
|
45
|
+
const projectRoot = viteConfig.root;
|
|
46
|
+
|
|
47
|
+
// Try to use cache in production builds
|
|
48
|
+
let useCache = false;
|
|
49
|
+
if (!isDev) {
|
|
50
|
+
const cache = loadCache(projectRoot);
|
|
51
|
+
if (cache && isCacheValid(cache, projectRoot)) {
|
|
52
|
+
// Use cached discovery
|
|
53
|
+
resolved = {
|
|
54
|
+
locales: config.locales || Object.keys(cache.discovered),
|
|
55
|
+
defaultLocale: config.defaultLocale,
|
|
56
|
+
cookieName: config.cookieName ?? 'ez-locale',
|
|
57
|
+
translations: cache.discovered,
|
|
58
|
+
};
|
|
59
|
+
useCache = true;
|
|
60
|
+
|
|
61
|
+
// Populate translationInfo from cache
|
|
62
|
+
for (const [locale, files] of Object.entries(cache.discovered)) {
|
|
63
|
+
translationInfo.set(locale, { locale, files });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!useCache) {
|
|
69
|
+
// Resolve translations config
|
|
70
|
+
const { locales, translations } = await resolveTranslationsConfig(
|
|
71
|
+
config.translations,
|
|
72
|
+
projectRoot,
|
|
73
|
+
config.locales
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// Merge with configured locales (config takes precedence if specified)
|
|
77
|
+
const finalLocales = config.locales && config.locales.length > 0
|
|
78
|
+
? config.locales
|
|
79
|
+
: locales;
|
|
80
|
+
|
|
81
|
+
resolved = {
|
|
82
|
+
locales: finalLocales,
|
|
83
|
+
defaultLocale: config.defaultLocale,
|
|
84
|
+
cookieName: config.cookieName ?? 'ez-locale',
|
|
85
|
+
translations,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Build translation info for each locale
|
|
89
|
+
for (const locale of finalLocales) {
|
|
90
|
+
const files = translations[locale] || [];
|
|
91
|
+
const info: TranslationInfo = { locale, files };
|
|
92
|
+
|
|
93
|
+
// For dev mode, determine if we can use import.meta.glob
|
|
94
|
+
if (isDev && config.translations) {
|
|
95
|
+
const localeConfig = typeof config.translations === 'string'
|
|
96
|
+
? path.join(config.translations, locale)
|
|
97
|
+
: config.translations[locale];
|
|
98
|
+
|
|
99
|
+
if (localeConfig && typeof localeConfig === 'string') {
|
|
100
|
+
const pathType = detectPathType(localeConfig);
|
|
101
|
+
if (pathType === 'folder' || pathType === 'glob') {
|
|
102
|
+
// Can use import.meta.glob for HMR
|
|
103
|
+
const basePath = pathType === 'glob'
|
|
104
|
+
? localeConfig
|
|
105
|
+
: toGlobPattern(path.resolve(projectRoot, localeConfig), projectRoot);
|
|
106
|
+
info.globPattern = basePath;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
translationInfo.set(locale, info);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Save cache for future builds
|
|
115
|
+
if (!isDev && Object.keys(translations).length > 0) {
|
|
116
|
+
saveCache(projectRoot, translations);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Validate defaultLocale
|
|
121
|
+
if (!resolved.locales.includes(resolved.defaultLocale)) {
|
|
122
|
+
console.warn(
|
|
123
|
+
`[ez-i18n] defaultLocale "${resolved.defaultLocale}" not found in locales: [${resolved.locales.join(', ')}]`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
|
|
31
128
|
resolveId(id) {
|
|
32
129
|
if (id === VIRTUAL_CONFIG || id === VIRTUAL_RUNTIME || id === VIRTUAL_TRANSLATIONS) {
|
|
33
130
|
return RESOLVED_PREFIX + id;
|
|
@@ -99,25 +196,139 @@ export function t(key, params) {
|
|
|
99
196
|
|
|
100
197
|
// ez-i18n:translations - Translation loaders
|
|
101
198
|
if (id === RESOLVED_PREFIX + VIRTUAL_TRANSLATIONS) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
199
|
+
return isDev
|
|
200
|
+
? generateDevTranslationsModule(translationInfo, viteConfig.root)
|
|
201
|
+
: generateBuildTranslationsModule(translationInfo, viteConfig.root);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return null;
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
// HMR support for dev mode
|
|
208
|
+
handleHotUpdate({ file, server }) {
|
|
209
|
+
if (!isDev) return;
|
|
210
|
+
|
|
211
|
+
// Check if the changed file is a translation file
|
|
212
|
+
for (const info of translationInfo.values()) {
|
|
213
|
+
if (info.files.includes(file)) {
|
|
214
|
+
// Invalidate the virtual translations module
|
|
215
|
+
const mod = server.moduleGraph.getModuleById(RESOLVED_PREFIX + VIRTUAL_TRANSLATIONS);
|
|
216
|
+
if (mod) {
|
|
217
|
+
server.moduleGraph.invalidateModule(mod);
|
|
218
|
+
// Full reload to ensure translations are updated everywhere
|
|
219
|
+
server.ws.send({
|
|
220
|
+
type: 'full-reload',
|
|
221
|
+
path: '*',
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
configureServer(server: ViteDevServer) {
|
|
230
|
+
// Watch translation directories for new/deleted files
|
|
231
|
+
const watchedDirs = new Set<string>();
|
|
232
|
+
|
|
233
|
+
if (config.translations) {
|
|
234
|
+
if (typeof config.translations === 'string') {
|
|
235
|
+
// Base directory
|
|
236
|
+
watchedDirs.add(path.resolve(viteConfig.root, config.translations));
|
|
237
|
+
} else {
|
|
238
|
+
// Per-locale config
|
|
239
|
+
for (const localePath of Object.values(config.translations)) {
|
|
240
|
+
if (typeof localePath === 'string') {
|
|
241
|
+
const pathType = detectPathType(localePath);
|
|
242
|
+
if (pathType === 'folder') {
|
|
243
|
+
watchedDirs.add(path.resolve(viteConfig.root, localePath));
|
|
244
|
+
} else if (pathType === 'glob') {
|
|
245
|
+
// Extract base directory from glob
|
|
246
|
+
const baseDir = localePath.split('*')[0].replace(/\/$/, '');
|
|
247
|
+
if (baseDir) {
|
|
248
|
+
watchedDirs.add(path.resolve(viteConfig.root, baseDir));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} else if (Array.isArray(localePath)) {
|
|
252
|
+
// Array of files - watch parent directories
|
|
253
|
+
for (const file of localePath) {
|
|
254
|
+
const dir = path.dirname(path.resolve(viteConfig.root, file));
|
|
255
|
+
watchedDirs.add(dir);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
// Default directory
|
|
262
|
+
watchedDirs.add(path.resolve(viteConfig.root, './public/i18n'));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Add directories to watcher
|
|
266
|
+
for (const dir of watchedDirs) {
|
|
267
|
+
server.watcher.add(dir);
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
106
272
|
|
|
107
|
-
return `
|
|
108
273
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
274
|
+
* Generate the translations virtual module for dev mode.
|
|
275
|
+
* Uses import.meta.glob where possible for HMR support.
|
|
111
276
|
*/
|
|
277
|
+
function generateDevTranslationsModule(
|
|
278
|
+
translationInfo: Map<string, TranslationInfo>,
|
|
279
|
+
projectRoot: string
|
|
280
|
+
): string {
|
|
281
|
+
const imports: string[] = [];
|
|
282
|
+
const loaderEntries: string[] = [];
|
|
283
|
+
|
|
284
|
+
// Add deepMerge inline for runtime merging
|
|
285
|
+
imports.push(getDeepMergeCode());
|
|
286
|
+
|
|
287
|
+
for (const [locale, info] of translationInfo) {
|
|
288
|
+
if (info.files.length === 0) {
|
|
289
|
+
// No files - return empty object
|
|
290
|
+
loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
|
|
291
|
+
} else if (info.globPattern) {
|
|
292
|
+
// Use import.meta.glob for HMR support
|
|
293
|
+
const varName = `__${locale}Modules`;
|
|
294
|
+
imports.push(
|
|
295
|
+
`const ${varName} = import.meta.glob(${JSON.stringify(info.globPattern)}, { eager: true, import: 'default' });`
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
|
|
299
|
+
const modules = Object.values(${varName});
|
|
300
|
+
if (modules.length === 0) return {};
|
|
301
|
+
if (modules.length === 1) return modules[0];
|
|
302
|
+
return __deepMerge({}, ...modules);
|
|
303
|
+
}`);
|
|
304
|
+
} else if (info.files.length === 1) {
|
|
305
|
+
// Single file - simple dynamic import
|
|
306
|
+
const relativePath = toRelativeImport(info.files[0], projectRoot);
|
|
307
|
+
loaderEntries.push(
|
|
308
|
+
` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
|
|
309
|
+
);
|
|
310
|
+
} else {
|
|
311
|
+
// Multiple explicit files - import all and merge
|
|
312
|
+
const fileImports = info.files
|
|
313
|
+
.map(f => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`)
|
|
314
|
+
.join(', ');
|
|
315
|
+
|
|
316
|
+
loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
|
|
317
|
+
const modules = await Promise.all([${fileImports}]);
|
|
318
|
+
const contents = modules.map(m => m.default ?? m);
|
|
319
|
+
if (contents.length === 1) return contents[0];
|
|
320
|
+
return __deepMerge({}, ...contents);
|
|
321
|
+
}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return `
|
|
326
|
+
${imports.join('\n')}
|
|
327
|
+
|
|
112
328
|
export const translationLoaders = {
|
|
113
|
-
${loaderEntries}
|
|
329
|
+
${loaderEntries.join(',\n')}
|
|
114
330
|
};
|
|
115
331
|
|
|
116
|
-
/**
|
|
117
|
-
* Load translations for a specific locale
|
|
118
|
-
* @param locale - Locale code to load translations for
|
|
119
|
-
* @returns Translations object or empty object if not found
|
|
120
|
-
*/
|
|
121
332
|
export async function loadTranslations(locale) {
|
|
122
333
|
const loader = translationLoaders[locale];
|
|
123
334
|
if (!loader) {
|
|
@@ -128,8 +339,7 @@ export async function loadTranslations(locale) {
|
|
|
128
339
|
}
|
|
129
340
|
|
|
130
341
|
try {
|
|
131
|
-
|
|
132
|
-
return mod.default ?? mod;
|
|
342
|
+
return await loader();
|
|
133
343
|
} catch (error) {
|
|
134
344
|
if (import.meta.env.DEV) {
|
|
135
345
|
console.error('[ez-i18n] Failed to load translations for locale:', locale, error);
|
|
@@ -138,9 +348,99 @@ export async function loadTranslations(locale) {
|
|
|
138
348
|
}
|
|
139
349
|
}
|
|
140
350
|
`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Generate the translations virtual module for production builds.
|
|
355
|
+
* Pre-resolves all imports for optimal bundling.
|
|
356
|
+
*/
|
|
357
|
+
function generateBuildTranslationsModule(
|
|
358
|
+
translationInfo: Map<string, TranslationInfo>,
|
|
359
|
+
projectRoot: string
|
|
360
|
+
): string {
|
|
361
|
+
const loaderEntries: string[] = [];
|
|
362
|
+
let needsDeepMerge = false;
|
|
363
|
+
|
|
364
|
+
for (const [locale, info] of translationInfo) {
|
|
365
|
+
if (info.files.length === 0) {
|
|
366
|
+
loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
|
|
367
|
+
} else if (info.files.length === 1) {
|
|
368
|
+
// Single file - simple dynamic import
|
|
369
|
+
const relativePath = toRelativeImport(info.files[0], projectRoot);
|
|
370
|
+
loaderEntries.push(
|
|
371
|
+
` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
|
|
372
|
+
);
|
|
373
|
+
} else {
|
|
374
|
+
// Multiple files - import and merge
|
|
375
|
+
needsDeepMerge = true;
|
|
376
|
+
const fileImports = info.files
|
|
377
|
+
.map(f => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`)
|
|
378
|
+
.join(', ');
|
|
379
|
+
|
|
380
|
+
loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
|
|
381
|
+
const modules = await Promise.all([${fileImports}]);
|
|
382
|
+
const contents = modules.map(m => m.default ?? m);
|
|
383
|
+
return __deepMerge({}, ...contents);
|
|
384
|
+
}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const deepMergeCode = needsDeepMerge ? getDeepMergeCode() : '';
|
|
389
|
+
|
|
390
|
+
return `
|
|
391
|
+
${deepMergeCode}
|
|
392
|
+
|
|
393
|
+
export const translationLoaders = {
|
|
394
|
+
${loaderEntries.join(',\n')}
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
export async function loadTranslations(locale) {
|
|
398
|
+
const loader = translationLoaders[locale];
|
|
399
|
+
if (!loader) {
|
|
400
|
+
return {};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
return await loader();
|
|
405
|
+
} catch (error) {
|
|
406
|
+
console.error('[ez-i18n] Failed to load translations:', locale, error);
|
|
407
|
+
return {};
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Inline deepMerge function for the virtual module
|
|
415
|
+
*/
|
|
416
|
+
function getDeepMergeCode(): string {
|
|
417
|
+
return `
|
|
418
|
+
function __deepMerge(target, ...sources) {
|
|
419
|
+
const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);
|
|
420
|
+
const result = { ...target };
|
|
421
|
+
for (const source of sources) {
|
|
422
|
+
if (!source || typeof source !== 'object') continue;
|
|
423
|
+
for (const key of Object.keys(source)) {
|
|
424
|
+
if (FORBIDDEN.has(key)) continue;
|
|
425
|
+
const tv = result[key], sv = source[key];
|
|
426
|
+
if (sv && typeof sv === 'object' && !Array.isArray(sv) && tv && typeof tv === 'object' && !Array.isArray(tv)) {
|
|
427
|
+
result[key] = __deepMerge(tv, sv);
|
|
428
|
+
} else {
|
|
429
|
+
result[key] = sv;
|
|
141
430
|
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return result;
|
|
434
|
+
}`;
|
|
435
|
+
}
|
|
142
436
|
|
|
143
|
-
|
|
144
|
-
|
|
437
|
+
// Re-export resolveConfig for backwards compatibility
|
|
438
|
+
export function resolveConfig(config: EzI18nConfig): ResolvedEzI18nConfig {
|
|
439
|
+
// This is now a simplified version - full resolution happens in buildStart
|
|
440
|
+
return {
|
|
441
|
+
locales: config.locales || [],
|
|
442
|
+
defaultLocale: config.defaultLocale,
|
|
443
|
+
cookieName: config.cookieName ?? 'ez-locale',
|
|
444
|
+
translations: {},
|
|
145
445
|
};
|
|
146
446
|
}
|
package/dist/types-DwCG8sp8.d.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Configuration for ez-i18n Astro integration
|
|
3
|
-
*/
|
|
4
|
-
interface EzI18nConfig {
|
|
5
|
-
/**
|
|
6
|
-
* List of supported locale codes (e.g., ['en', 'es', 'fr'])
|
|
7
|
-
*/
|
|
8
|
-
locales: string[];
|
|
9
|
-
/**
|
|
10
|
-
* Default locale to use when no preference is detected
|
|
11
|
-
*/
|
|
12
|
-
defaultLocale: string;
|
|
13
|
-
/**
|
|
14
|
-
* Cookie name for storing locale preference
|
|
15
|
-
* @default 'ez-locale'
|
|
16
|
-
*/
|
|
17
|
-
cookieName?: string;
|
|
18
|
-
/**
|
|
19
|
-
* Translation file paths for each locale.
|
|
20
|
-
* Paths are relative to your project root.
|
|
21
|
-
* Dynamic imports will be generated for code splitting.
|
|
22
|
-
* @example
|
|
23
|
-
* translations: {
|
|
24
|
-
* en: './src/i18n/en.json',
|
|
25
|
-
* es: './src/i18n/es.json',
|
|
26
|
-
* }
|
|
27
|
-
*/
|
|
28
|
-
translations?: Record<string, string>;
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Translation function type
|
|
32
|
-
*/
|
|
33
|
-
type TranslateFunction = (key: string, params?: Record<string, string | number>) => string;
|
|
34
|
-
/**
|
|
35
|
-
* Augment Astro's locals type
|
|
36
|
-
*/
|
|
37
|
-
declare global {
|
|
38
|
-
namespace App {
|
|
39
|
-
interface Locals {
|
|
40
|
-
/** Current locale code */
|
|
41
|
-
locale: string;
|
|
42
|
-
/** Loaded translations for the current locale */
|
|
43
|
-
translations: Record<string, unknown>;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export type { EzI18nConfig as E, TranslateFunction as T };
|