@zachhandley/ez-i18n 0.1.2 → 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.
@@ -1,33 +1,191 @@
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
+ getNamespaceFromPath,
12
+ generateNamespaceWrapperCode,
13
+ } from './utils/translations';
14
+ import * as path from 'node:path';
3
15
 
4
16
  const VIRTUAL_CONFIG = 'ez-i18n:config';
5
17
  const VIRTUAL_RUNTIME = 'ez-i18n:runtime';
6
18
  const VIRTUAL_TRANSLATIONS = 'ez-i18n:translations';
7
19
  const RESOLVED_PREFIX = '\0';
8
20
 
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
- };
21
+ interface TranslationInfo {
22
+ locale: string;
23
+ files: string[];
24
+ /** Glob pattern for dev mode HMR (if applicable) */
25
+ globPattern?: string;
26
+ /** Base directory for this locale (used for namespace calculation) */
27
+ localeBaseDir?: string;
19
28
  }
20
29
 
21
30
  /**
22
31
  * Vite plugin that provides virtual modules for ez-i18n
23
32
  */
24
33
  export function vitePlugin(config: EzI18nConfig): Plugin {
25
- const resolved = resolveConfig(config);
34
+ let viteConfig: ResolvedConfig;
35
+ let isDev = false;
36
+ let resolved: ResolvedEzI18nConfig;
37
+ let translationInfo: Map<string, TranslationInfo> = new Map();
26
38
 
27
39
  return {
28
40
  name: 'ez-i18n-vite',
29
41
  enforce: 'pre',
30
42
 
43
+ configResolved(resolvedConfig) {
44
+ viteConfig = resolvedConfig;
45
+ isDev = resolvedConfig.command === 'serve';
46
+ },
47
+
48
+ async buildStart() {
49
+ const projectRoot = viteConfig.root;
50
+
51
+ // Determine if path-based namespacing should be enabled
52
+ // Default to true when using folder-based or auto-discovery config
53
+ const isAutoDiscovery = !config.translations || typeof config.translations === 'string';
54
+ const pathBasedNamespacing = config.pathBasedNamespacing ?? isAutoDiscovery;
55
+
56
+ // Calculate base translation directory
57
+ const translationsBaseDir = typeof config.translations === 'string'
58
+ ? path.resolve(projectRoot, config.translations.replace(/\/$/, ''))
59
+ : path.resolve(projectRoot, './public/i18n');
60
+
61
+ // Try to use cache in production builds
62
+ let useCache = false;
63
+ if (!isDev) {
64
+ const cache = loadCache(projectRoot);
65
+ if (cache && isCacheValid(cache, projectRoot)) {
66
+ // Build locale base dirs
67
+ const localeBaseDirs: Record<string, string> = {};
68
+ for (const locale of Object.keys(cache.discovered)) {
69
+ localeBaseDirs[locale] = path.join(translationsBaseDir, locale);
70
+ }
71
+
72
+ // Use cached discovery
73
+ resolved = {
74
+ locales: config.locales || Object.keys(cache.discovered),
75
+ defaultLocale: config.defaultLocale,
76
+ cookieName: config.cookieName ?? 'ez-locale',
77
+ translations: cache.discovered,
78
+ pathBasedNamespacing,
79
+ localeBaseDirs,
80
+ };
81
+ useCache = true;
82
+
83
+ // Populate translationInfo from cache
84
+ for (const [locale, files] of Object.entries(cache.discovered)) {
85
+ translationInfo.set(locale, {
86
+ locale,
87
+ files,
88
+ localeBaseDir: localeBaseDirs[locale],
89
+ });
90
+ }
91
+ }
92
+ }
93
+
94
+ if (!useCache) {
95
+ // Resolve translations config
96
+ const { locales, translations } = await resolveTranslationsConfig(
97
+ config.translations,
98
+ projectRoot,
99
+ config.locales
100
+ );
101
+
102
+ // Merge with configured locales (config takes precedence if specified)
103
+ const finalLocales = config.locales && config.locales.length > 0
104
+ ? config.locales
105
+ : locales;
106
+
107
+ // Build locale base dirs
108
+ const localeBaseDirs: Record<string, string> = {};
109
+ for (const locale of finalLocales) {
110
+ if (isAutoDiscovery) {
111
+ // For auto-discovery, locale folder is under the base dir
112
+ localeBaseDirs[locale] = path.join(translationsBaseDir, locale);
113
+ } else if (typeof config.translations === 'object' && config.translations[locale]) {
114
+ // For explicit config, determine base from the config value
115
+ const localeConfig = config.translations[locale];
116
+ if (typeof localeConfig === 'string') {
117
+ const pathType = detectPathType(localeConfig);
118
+ if (pathType === 'folder' || pathType === 'file') {
119
+ // Use the folder itself or parent of file
120
+ const resolved = path.resolve(projectRoot, localeConfig.replace(/\/$/, ''));
121
+ localeBaseDirs[locale] = pathType === 'folder' ? resolved : path.dirname(resolved);
122
+ } else {
123
+ // Glob - use the non-glob prefix
124
+ const baseDir = localeConfig.split('*')[0].replace(/\/$/, '');
125
+ localeBaseDirs[locale] = path.resolve(projectRoot, baseDir);
126
+ }
127
+ } else {
128
+ // Array - use common parent directory
129
+ localeBaseDirs[locale] = translationsBaseDir;
130
+ }
131
+ } else {
132
+ localeBaseDirs[locale] = translationsBaseDir;
133
+ }
134
+ }
135
+
136
+ resolved = {
137
+ locales: finalLocales,
138
+ defaultLocale: config.defaultLocale,
139
+ cookieName: config.cookieName ?? 'ez-locale',
140
+ translations,
141
+ pathBasedNamespacing,
142
+ localeBaseDirs,
143
+ };
144
+
145
+ // Build translation info for each locale
146
+ for (const locale of finalLocales) {
147
+ const files = translations[locale] || [];
148
+ const info: TranslationInfo = {
149
+ locale,
150
+ files,
151
+ localeBaseDir: localeBaseDirs[locale],
152
+ };
153
+
154
+ // For dev mode, determine if we can use import.meta.glob
155
+ if (isDev && config.translations) {
156
+ const localeConfig = typeof config.translations === 'string'
157
+ ? path.join(config.translations, locale)
158
+ : config.translations[locale];
159
+
160
+ if (localeConfig && typeof localeConfig === 'string') {
161
+ const pathType = detectPathType(localeConfig);
162
+ if (pathType === 'folder' || pathType === 'glob') {
163
+ // Can use import.meta.glob for HMR
164
+ const basePath = pathType === 'glob'
165
+ ? localeConfig
166
+ : toGlobPattern(path.resolve(projectRoot, localeConfig), projectRoot);
167
+ info.globPattern = basePath;
168
+ }
169
+ }
170
+ }
171
+
172
+ translationInfo.set(locale, info);
173
+ }
174
+
175
+ // Save cache for future builds
176
+ if (!isDev && Object.keys(translations).length > 0) {
177
+ saveCache(projectRoot, translations);
178
+ }
179
+ }
180
+
181
+ // Validate defaultLocale
182
+ if (!resolved.locales.includes(resolved.defaultLocale)) {
183
+ console.warn(
184
+ `[ez-i18n] defaultLocale "${resolved.defaultLocale}" not found in locales: [${resolved.locales.join(', ')}]`
185
+ );
186
+ }
187
+ },
188
+
31
189
  resolveId(id) {
32
190
  if (id === VIRTUAL_CONFIG || id === VIRTUAL_RUNTIME || id === VIRTUAL_TRANSLATIONS) {
33
191
  return RESOLVED_PREFIX + id;
@@ -99,25 +257,187 @@ export function t(key, params) {
99
257
 
100
258
  // ez-i18n:translations - Translation loaders
101
259
  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');
260
+ return isDev
261
+ ? generateDevTranslationsModule(translationInfo, viteConfig.root, resolved.pathBasedNamespacing)
262
+ : generateBuildTranslationsModule(translationInfo, viteConfig.root, resolved.pathBasedNamespacing);
263
+ }
264
+
265
+ return null;
266
+ },
267
+
268
+ // HMR support for dev mode
269
+ handleHotUpdate({ file, server }) {
270
+ if (!isDev) return;
271
+
272
+ // Check if the changed file is a translation file
273
+ for (const info of translationInfo.values()) {
274
+ if (info.files.includes(file)) {
275
+ // Invalidate the virtual translations module
276
+ const mod = server.moduleGraph.getModuleById(RESOLVED_PREFIX + VIRTUAL_TRANSLATIONS);
277
+ if (mod) {
278
+ server.moduleGraph.invalidateModule(mod);
279
+ // Full reload to ensure translations are updated everywhere
280
+ server.ws.send({
281
+ type: 'full-reload',
282
+ path: '*',
283
+ });
284
+ }
285
+ break;
286
+ }
287
+ }
288
+ },
289
+
290
+ configureServer(server: ViteDevServer) {
291
+ // Watch translation directories for new/deleted files
292
+ const watchedDirs = new Set<string>();
293
+
294
+ if (config.translations) {
295
+ if (typeof config.translations === 'string') {
296
+ // Base directory
297
+ watchedDirs.add(path.resolve(viteConfig.root, config.translations));
298
+ } else {
299
+ // Per-locale config
300
+ for (const localePath of Object.values(config.translations)) {
301
+ if (typeof localePath === 'string') {
302
+ const pathType = detectPathType(localePath);
303
+ if (pathType === 'folder') {
304
+ watchedDirs.add(path.resolve(viteConfig.root, localePath));
305
+ } else if (pathType === 'glob') {
306
+ // Extract base directory from glob
307
+ const baseDir = localePath.split('*')[0].replace(/\/$/, '');
308
+ if (baseDir) {
309
+ watchedDirs.add(path.resolve(viteConfig.root, baseDir));
310
+ }
311
+ }
312
+ } else if (Array.isArray(localePath)) {
313
+ // Array of files - watch parent directories
314
+ for (const file of localePath) {
315
+ const dir = path.dirname(path.resolve(viteConfig.root, file));
316
+ watchedDirs.add(dir);
317
+ }
318
+ }
319
+ }
320
+ }
321
+ } else {
322
+ // Default directory
323
+ watchedDirs.add(path.resolve(viteConfig.root, './public/i18n'));
324
+ }
325
+
326
+ // Add directories to watcher
327
+ for (const dir of watchedDirs) {
328
+ server.watcher.add(dir);
329
+ }
330
+ },
331
+ };
332
+ }
106
333
 
107
- return `
108
334
  /**
109
- * Translation loaders for ez-i18n
110
- * Auto-generated from config
335
+ * Generate the translations virtual module for dev mode.
336
+ * Uses import.meta.glob where possible for HMR support.
111
337
  */
338
+ function generateDevTranslationsModule(
339
+ translationInfo: Map<string, TranslationInfo>,
340
+ projectRoot: string,
341
+ pathBasedNamespacing: boolean
342
+ ): string {
343
+ const imports: string[] = [];
344
+ const loaderEntries: string[] = [];
345
+
346
+ // Add deepMerge inline for runtime merging
347
+ imports.push(getDeepMergeCode());
348
+
349
+ // Add namespace wrapper if needed
350
+ if (pathBasedNamespacing) {
351
+ imports.push(generateNamespaceWrapperCode());
352
+ }
353
+
354
+ for (const [locale, info] of translationInfo) {
355
+ if (info.files.length === 0) {
356
+ // No files - return empty object
357
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
358
+ } else if (info.globPattern && pathBasedNamespacing && info.localeBaseDir) {
359
+ // Use import.meta.glob with namespace wrapping
360
+ const varName = `__${locale}Modules`;
361
+ const localeBaseDir = info.localeBaseDir.replace(/\\/g, '/');
362
+ imports.push(
363
+ `const ${varName} = import.meta.glob(${JSON.stringify(info.globPattern)}, { eager: true, import: 'default' });`
364
+ );
365
+
366
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
367
+ const entries = Object.entries(${varName});
368
+ if (entries.length === 0) return {};
369
+ const localeBaseDir = ${JSON.stringify(localeBaseDir)};
370
+ const wrapped = entries.map(([filePath, content]) => {
371
+ // Extract relative path from locale base dir
372
+ const relativePath = filePath.replace(localeBaseDir + '/', '').replace(/\\.json$/i, '');
373
+ const namespace = relativePath.replace(/[\\/]/g, '.').replace(/\\.index$/, '');
374
+ return __wrapWithNamespace(namespace, content);
375
+ });
376
+ return __deepMerge({}, ...wrapped);
377
+ }`);
378
+ } else if (info.globPattern) {
379
+ // Use import.meta.glob without namespace wrapping
380
+ const varName = `__${locale}Modules`;
381
+ imports.push(
382
+ `const ${varName} = import.meta.glob(${JSON.stringify(info.globPattern)}, { eager: true, import: 'default' });`
383
+ );
384
+
385
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
386
+ const modules = Object.values(${varName});
387
+ if (modules.length === 0) return {};
388
+ if (modules.length === 1) return modules[0];
389
+ return __deepMerge({}, ...modules);
390
+ }`);
391
+ } else if (info.files.length === 1) {
392
+ // Single file
393
+ const relativePath = toRelativeImport(info.files[0], projectRoot);
394
+ if (pathBasedNamespacing && info.localeBaseDir) {
395
+ const namespace = getNamespaceFromPath(info.files[0], info.localeBaseDir);
396
+ loaderEntries.push(
397
+ ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => __wrapWithNamespace(${JSON.stringify(namespace)}, m.default ?? m))`
398
+ );
399
+ } else {
400
+ loaderEntries.push(
401
+ ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
402
+ );
403
+ }
404
+ } else {
405
+ // Multiple explicit files - import all and merge
406
+ if (pathBasedNamespacing && info.localeBaseDir) {
407
+ const fileEntries = info.files.map(f => {
408
+ const relativePath = toRelativeImport(f, projectRoot);
409
+ const namespace = getNamespaceFromPath(f, info.localeBaseDir!);
410
+ return `{ path: ${JSON.stringify(relativePath)}, namespace: ${JSON.stringify(namespace)} }`;
411
+ });
412
+
413
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
414
+ const fileInfos = [${fileEntries.join(', ')}];
415
+ const modules = await Promise.all(fileInfos.map(f => import(f.path)));
416
+ const wrapped = modules.map((m, i) => __wrapWithNamespace(fileInfos[i].namespace, m.default ?? m));
417
+ return __deepMerge({}, ...wrapped);
418
+ }`);
419
+ } else {
420
+ const fileImports = info.files
421
+ .map(f => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`)
422
+ .join(', ');
423
+
424
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
425
+ const modules = await Promise.all([${fileImports}]);
426
+ const contents = modules.map(m => m.default ?? m);
427
+ if (contents.length === 1) return contents[0];
428
+ return __deepMerge({}, ...contents);
429
+ }`);
430
+ }
431
+ }
432
+ }
433
+
434
+ return `
435
+ ${imports.join('\n')}
436
+
112
437
  export const translationLoaders = {
113
- ${loaderEntries}
438
+ ${loaderEntries.join(',\n')}
114
439
  };
115
440
 
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
441
  export async function loadTranslations(locale) {
122
442
  const loader = translationLoaders[locale];
123
443
  if (!loader) {
@@ -128,8 +448,7 @@ export async function loadTranslations(locale) {
128
448
  }
129
449
 
130
450
  try {
131
- const mod = await loader();
132
- return mod.default ?? mod;
451
+ return await loader();
133
452
  } catch (error) {
134
453
  if (import.meta.env.DEV) {
135
454
  console.error('[ez-i18n] Failed to load translations for locale:', locale, error);
@@ -138,9 +457,132 @@ export async function loadTranslations(locale) {
138
457
  }
139
458
  }
140
459
  `;
460
+ }
461
+
462
+ /**
463
+ * Generate the translations virtual module for production builds.
464
+ * Pre-resolves all imports for optimal bundling.
465
+ */
466
+ function generateBuildTranslationsModule(
467
+ translationInfo: Map<string, TranslationInfo>,
468
+ projectRoot: string,
469
+ pathBasedNamespacing: boolean
470
+ ): string {
471
+ const loaderEntries: string[] = [];
472
+ let needsDeepMerge = false;
473
+ let needsNamespaceWrapper = false;
474
+
475
+ for (const [locale, info] of translationInfo) {
476
+ if (info.files.length === 0) {
477
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
478
+ } else if (info.files.length === 1) {
479
+ // Single file
480
+ const relativePath = toRelativeImport(info.files[0], projectRoot);
481
+ if (pathBasedNamespacing && info.localeBaseDir) {
482
+ needsNamespaceWrapper = true;
483
+ const namespace = getNamespaceFromPath(info.files[0], info.localeBaseDir);
484
+ loaderEntries.push(
485
+ ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => __wrapWithNamespace(${JSON.stringify(namespace)}, m.default ?? m))`
486
+ );
487
+ } else {
488
+ loaderEntries.push(
489
+ ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
490
+ );
141
491
  }
492
+ } else {
493
+ // Multiple files - import and merge
494
+ needsDeepMerge = true;
142
495
 
143
- return null;
144
- },
496
+ if (pathBasedNamespacing && info.localeBaseDir) {
497
+ needsNamespaceWrapper = true;
498
+ const fileEntries = info.files.map(f => {
499
+ const relativePath = toRelativeImport(f, projectRoot);
500
+ const namespace = getNamespaceFromPath(f, info.localeBaseDir!);
501
+ return `{ path: ${JSON.stringify(relativePath)}, namespace: ${JSON.stringify(namespace)} }`;
502
+ });
503
+
504
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
505
+ const fileInfos = [${fileEntries.join(', ')}];
506
+ const modules = await Promise.all(fileInfos.map(f => import(f.path)));
507
+ const wrapped = modules.map((m, i) => __wrapWithNamespace(fileInfos[i].namespace, m.default ?? m));
508
+ return __deepMerge({}, ...wrapped);
509
+ }`);
510
+ } else {
511
+ const fileImports = info.files
512
+ .map(f => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`)
513
+ .join(', ');
514
+
515
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
516
+ const modules = await Promise.all([${fileImports}]);
517
+ const contents = modules.map(m => m.default ?? m);
518
+ return __deepMerge({}, ...contents);
519
+ }`);
520
+ }
521
+ }
522
+ }
523
+
524
+ const helperCode = [
525
+ needsDeepMerge ? getDeepMergeCode() : '',
526
+ needsNamespaceWrapper ? generateNamespaceWrapperCode() : '',
527
+ ].filter(Boolean).join('\n');
528
+
529
+ return `
530
+ ${helperCode}
531
+
532
+ export const translationLoaders = {
533
+ ${loaderEntries.join(',\n')}
534
+ };
535
+
536
+ export async function loadTranslations(locale) {
537
+ const loader = translationLoaders[locale];
538
+ if (!loader) {
539
+ return {};
540
+ }
541
+
542
+ try {
543
+ return await loader();
544
+ } catch (error) {
545
+ console.error('[ez-i18n] Failed to load translations:', locale, error);
546
+ return {};
547
+ }
548
+ }
549
+ `;
550
+ }
551
+
552
+ /**
553
+ * Inline deepMerge function for the virtual module
554
+ */
555
+ function getDeepMergeCode(): string {
556
+ return `
557
+ function __deepMerge(target, ...sources) {
558
+ const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);
559
+ const result = { ...target };
560
+ for (const source of sources) {
561
+ if (!source || typeof source !== 'object') continue;
562
+ for (const key of Object.keys(source)) {
563
+ if (FORBIDDEN.has(key)) continue;
564
+ const tv = result[key], sv = source[key];
565
+ if (sv && typeof sv === 'object' && !Array.isArray(sv) && tv && typeof tv === 'object' && !Array.isArray(tv)) {
566
+ result[key] = __deepMerge(tv, sv);
567
+ } else {
568
+ result[key] = sv;
569
+ }
570
+ }
571
+ }
572
+ return result;
573
+ }`;
574
+ }
575
+
576
+ // Re-export resolveConfig for backwards compatibility
577
+ export function resolveConfig(config: EzI18nConfig): ResolvedEzI18nConfig {
578
+ // This is now a simplified version - full resolution happens in buildStart
579
+ const isAutoDiscovery = !config.translations || typeof config.translations === 'string';
580
+ return {
581
+ locales: config.locales || [],
582
+ defaultLocale: config.defaultLocale,
583
+ cookieName: config.cookieName ?? 'ez-locale',
584
+ translations: {},
585
+ pathBasedNamespacing: config.pathBasedNamespacing ?? isAutoDiscovery,
586
+ localeBaseDirs: {},
145
587
  };
146
588
  }
@@ -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 };