@zachhandley/ez-i18n 0.1.2 → 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.
@@ -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
- * Resolve config with defaults
11
- */
12
- export function resolveConfig(config: EzI18nConfig): ResolvedEzI18nConfig {
13
- return {
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
- const resolved = resolveConfig(config);
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
- // Generate dynamic import statements for each locale
103
- const loaderEntries = Object.entries(resolved.translations)
104
- .map(([locale, path]) => ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(path)})`)
105
- .join(',\n');
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
- * Translation loaders for ez-i18n
110
- * Auto-generated from config
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
- const mod = await loader();
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
- return null;
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
  }
@@ -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 };