@zachhandley/ez-i18n 0.3.3 → 0.3.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,698 +0,0 @@
1
- import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';
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
- isInPublicDir,
14
- toPublicUrl,
15
- getLocaleBaseDirForNamespace,
16
- } from './utils/translations';
17
- import * as path from 'node:path';
18
-
19
- const VIRTUAL_CONFIG = 'ez-i18n:config';
20
- const VIRTUAL_RUNTIME = 'ez-i18n:runtime';
21
- const VIRTUAL_TRANSLATIONS = 'ez-i18n:translations';
22
- const RESOLVED_PREFIX = '\0';
23
-
24
- interface TranslationInfo {
25
- locale: string;
26
- files: string[];
27
- /** Glob pattern for dev mode HMR (if applicable, null for public files) */
28
- globPattern?: string | null;
29
- /** Base directory for this locale (used for namespace calculation) */
30
- localeBaseDir?: string;
31
- /** Whether files are in the public directory (use fetch instead of import) */
32
- isPublic?: boolean;
33
- }
34
-
35
- /**
36
- * Vite plugin that provides virtual modules for ez-i18n
37
- */
38
- export function vitePlugin(config: EzI18nConfig): Plugin {
39
- let viteConfig: ResolvedConfig;
40
- let isDev = false;
41
- let resolved: ResolvedEzI18nConfig;
42
- let translationInfo: Map<string, TranslationInfo> = new Map();
43
-
44
- return {
45
- name: 'ez-i18n-vite',
46
- enforce: 'pre',
47
-
48
- configResolved(resolvedConfig) {
49
- viteConfig = resolvedConfig;
50
- isDev = resolvedConfig.command === 'serve';
51
- },
52
-
53
- async buildStart() {
54
- const projectRoot = viteConfig.root;
55
-
56
- // Determine if path-based namespacing should be enabled
57
- // Default to true when using folder-based or auto-discovery config
58
- const isAutoDiscovery = !config.translations || typeof config.translations === 'string';
59
- const pathBasedNamespacing = config.pathBasedNamespacing ?? isAutoDiscovery;
60
-
61
- // Calculate base translation directory
62
- const translationsBaseDir = typeof config.translations === 'string'
63
- ? path.resolve(projectRoot, config.translations.replace(/\/$/, ''))
64
- : path.resolve(projectRoot, './public/i18n');
65
-
66
- // Try to use cache in production builds
67
- let useCache = false;
68
- if (!isDev) {
69
- const cache = loadCache(projectRoot);
70
- if (cache && isCacheValid(cache, projectRoot)) {
71
- // Build locale base dirs
72
- const localeBaseDirs: Record<string, string> = {};
73
- for (const locale of Object.keys(cache.discovered)) {
74
- localeBaseDirs[locale] = path.join(translationsBaseDir, locale);
75
- }
76
-
77
- // Use cached discovery
78
- resolved = {
79
- locales: config.locales || Object.keys(cache.discovered),
80
- defaultLocale: config.defaultLocale,
81
- cookieName: config.cookieName ?? 'ez-locale',
82
- translations: cache.discovered,
83
- pathBasedNamespacing,
84
- localeBaseDirs,
85
- };
86
- useCache = true;
87
-
88
- // Populate translationInfo from cache
89
- for (const [locale, files] of Object.entries(cache.discovered)) {
90
- translationInfo.set(locale, {
91
- locale,
92
- files,
93
- localeBaseDir: localeBaseDirs[locale],
94
- });
95
- }
96
- }
97
- }
98
-
99
- if (!useCache) {
100
- // Resolve translations config
101
- const { locales, translations } = await resolveTranslationsConfig(
102
- config.translations,
103
- projectRoot,
104
- config.locales
105
- );
106
-
107
- // Merge with configured locales (config takes precedence if specified)
108
- const finalLocales = config.locales && config.locales.length > 0
109
- ? config.locales
110
- : locales;
111
-
112
- // Build locale base dirs
113
- const localeBaseDirs: Record<string, string> = {};
114
- for (const locale of finalLocales) {
115
- if (isAutoDiscovery) {
116
- // For auto-discovery, locale folder is under the base dir
117
- localeBaseDirs[locale] = path.join(translationsBaseDir, locale);
118
- } else if (typeof config.translations === 'object' && config.translations[locale]) {
119
- // For explicit config, determine base from the config value
120
- const localeConfig = config.translations[locale];
121
- if (typeof localeConfig === 'string') {
122
- const pathType = detectPathType(localeConfig);
123
- if (pathType === 'folder' || pathType === 'file') {
124
- // Use the folder itself or parent of file
125
- const resolved = path.resolve(projectRoot, localeConfig.replace(/\/$/, ''));
126
- localeBaseDirs[locale] = pathType === 'folder' ? resolved : path.dirname(resolved);
127
- } else {
128
- // Glob - use the non-glob prefix
129
- const baseDir = localeConfig.split('*')[0].replace(/\/$/, '');
130
- localeBaseDirs[locale] = path.resolve(projectRoot, baseDir);
131
- }
132
- } else {
133
- // Array - use common parent directory
134
- localeBaseDirs[locale] = translationsBaseDir;
135
- }
136
- } else {
137
- localeBaseDirs[locale] = translationsBaseDir;
138
- }
139
- }
140
-
141
- resolved = {
142
- locales: finalLocales,
143
- defaultLocale: config.defaultLocale,
144
- cookieName: config.cookieName ?? 'ez-locale',
145
- translations,
146
- pathBasedNamespacing,
147
- localeBaseDirs,
148
- };
149
-
150
- // Build translation info for each locale
151
- for (const locale of finalLocales) {
152
- const files = translations[locale] || [];
153
- // Check if files are in public directory
154
- const filesInPublic = files.length > 0 && isInPublicDir(files[0], projectRoot);
155
-
156
- const info: TranslationInfo = {
157
- locale,
158
- files,
159
- localeBaseDir: localeBaseDirs[locale],
160
- isPublic: filesInPublic,
161
- };
162
-
163
- // For dev mode, determine if we can use import.meta.glob (not for public files)
164
- if (isDev && config.translations && !filesInPublic) {
165
- const localeConfig = typeof config.translations === 'string'
166
- ? path.join(config.translations, locale) + '/' // Trailing slash ensures detectPathType returns 'folder'
167
- : config.translations[locale];
168
-
169
- if (localeConfig && typeof localeConfig === 'string') {
170
- const pathType = detectPathType(localeConfig);
171
- if (pathType === 'folder' || pathType === 'glob') {
172
- // Can use import.meta.glob for HMR (returns null for public files)
173
- const basePath = pathType === 'glob'
174
- ? localeConfig
175
- : toGlobPattern(path.resolve(projectRoot, localeConfig), projectRoot);
176
- info.globPattern = basePath;
177
- }
178
- }
179
- }
180
-
181
- translationInfo.set(locale, info);
182
- }
183
-
184
- // Save cache for future builds
185
- if (!isDev && Object.keys(translations).length > 0) {
186
- saveCache(projectRoot, translations);
187
- }
188
- }
189
-
190
- // Validate defaultLocale
191
- if (!resolved.locales.includes(resolved.defaultLocale)) {
192
- console.warn(
193
- `[ez-i18n] defaultLocale "${resolved.defaultLocale}" not found in locales: [${resolved.locales.join(', ')}]`
194
- );
195
- }
196
- },
197
-
198
- resolveId(id) {
199
- if (id === VIRTUAL_CONFIG || id === VIRTUAL_RUNTIME || id === VIRTUAL_TRANSLATIONS) {
200
- return RESOLVED_PREFIX + id;
201
- }
202
- return null;
203
- },
204
-
205
- load(id) {
206
- // ez-i18n:config - Static config values
207
- if (id === RESOLVED_PREFIX + VIRTUAL_CONFIG) {
208
- return `
209
- export const locales = ${JSON.stringify(resolved.locales)};
210
- export const defaultLocale = ${JSON.stringify(resolved.defaultLocale)};
211
- export const cookieName = ${JSON.stringify(resolved.cookieName)};
212
- `;
213
- }
214
-
215
- // ez-i18n:runtime - Runtime exports for Astro files
216
- if (id === RESOLVED_PREFIX + VIRTUAL_RUNTIME) {
217
- return `
218
- import { effectiveLocale, translations, setLocale, initLocale } from '@zachhandley/ez-i18n/runtime';
219
-
220
- export { setLocale, initLocale };
221
- export { effectiveLocale as locale };
222
-
223
- /**
224
- * Get nested value from object using dot notation
225
- */
226
- function getNestedValue(obj, path) {
227
- const keys = path.split('.');
228
- let value = obj;
229
- for (const key of keys) {
230
- if (value == null || typeof value !== 'object') return undefined;
231
- value = value[key];
232
- }
233
- return value;
234
- }
235
-
236
- /**
237
- * Interpolate params into string
238
- */
239
- function interpolate(str, params) {
240
- if (!params) return str;
241
- return str.replace(/\\{(\\w+)\\}/g, (match, key) => {
242
- return key in params ? String(params[key]) : match;
243
- });
244
- }
245
-
246
- /**
247
- * Translate a key to the current locale
248
- * @param key - Dot-notation key (e.g., 'common.welcome')
249
- * @param params - Optional interpolation params
250
- */
251
- export function t(key, params) {
252
- const trans = translations.get();
253
- const value = getNestedValue(trans, key);
254
-
255
- if (typeof value !== 'string') {
256
- if (import.meta.env.DEV) {
257
- console.warn('[ez-i18n] Missing translation:', key);
258
- }
259
- return key;
260
- }
261
-
262
- return interpolate(value, params);
263
- }
264
- `;
265
- }
266
-
267
- // ez-i18n:translations - Translation loaders
268
- if (id === RESOLVED_PREFIX + VIRTUAL_TRANSLATIONS) {
269
- return isDev
270
- ? generateDevTranslationsModule(translationInfo, viteConfig.root, resolved.pathBasedNamespacing)
271
- : generateBuildTranslationsModule(translationInfo, viteConfig.root, resolved.pathBasedNamespacing);
272
- }
273
-
274
- return null;
275
- },
276
-
277
- // HMR support for dev mode
278
- handleHotUpdate({ file, server }) {
279
- if (!isDev) return;
280
-
281
- // Only process JSON files
282
- if (!file.endsWith('.json')) return;
283
-
284
- // Check if the changed file is a translation file
285
- for (const info of translationInfo.values()) {
286
- if (info.files.includes(file)) {
287
- // Invalidate the virtual translations module
288
- const mod = server.moduleGraph.getModuleById(RESOLVED_PREFIX + VIRTUAL_TRANSLATIONS);
289
- if (mod) {
290
- server.moduleGraph.invalidateModule(mod);
291
- // Full reload to ensure translations are updated everywhere
292
- server.ws.send({
293
- type: 'full-reload',
294
- path: '*',
295
- });
296
- }
297
- break;
298
- }
299
- }
300
- },
301
-
302
- configureServer(server: ViteDevServer) {
303
- // Watch translation directories for new/deleted files
304
- const watchedDirs = new Set<string>();
305
-
306
- if (config.translations) {
307
- if (typeof config.translations === 'string') {
308
- // Base directory
309
- watchedDirs.add(path.resolve(viteConfig.root, config.translations));
310
- } else {
311
- // Per-locale config
312
- for (const localePath of Object.values(config.translations)) {
313
- if (typeof localePath === 'string') {
314
- const pathType = detectPathType(localePath);
315
- if (pathType === 'folder') {
316
- watchedDirs.add(path.resolve(viteConfig.root, localePath));
317
- } else if (pathType === 'glob') {
318
- // Extract base directory from glob
319
- const baseDir = localePath.split('*')[0].replace(/\/$/, '');
320
- if (baseDir) {
321
- watchedDirs.add(path.resolve(viteConfig.root, baseDir));
322
- }
323
- }
324
- } else if (Array.isArray(localePath)) {
325
- // Array of files - watch parent directories
326
- for (const file of localePath) {
327
- const dir = path.dirname(path.resolve(viteConfig.root, file));
328
- watchedDirs.add(dir);
329
- }
330
- }
331
- }
332
- }
333
- } else {
334
- // Default directory
335
- watchedDirs.add(path.resolve(viteConfig.root, './public/i18n'));
336
- }
337
-
338
- // Add directories to watcher
339
- for (const dir of watchedDirs) {
340
- server.watcher.add(dir);
341
- }
342
- },
343
- };
344
- }
345
-
346
- /**
347
- * Generate the translations virtual module for dev mode.
348
- * Uses import.meta.glob where possible for HMR support.
349
- * Uses fetch() for files in public/ directory (with fs fallback for SSR).
350
- */
351
- function generateDevTranslationsModule(
352
- translationInfo: Map<string, TranslationInfo>,
353
- projectRoot: string,
354
- pathBasedNamespacing: boolean
355
- ): string {
356
- const imports: string[] = [];
357
- const loaderEntries: string[] = [];
358
- let needsPublicLoader = false;
359
-
360
- // Add deepMerge inline for runtime merging
361
- imports.push(getDeepMergeCode());
362
-
363
- // Add namespace wrapper if needed
364
- if (pathBasedNamespacing) {
365
- imports.push(generateNamespaceWrapperCode());
366
- }
367
-
368
- for (const [locale, info] of translationInfo) {
369
- if (info.files.length === 0) {
370
- // No files - return empty object
371
- loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
372
- } else if (info.isPublic) {
373
- // Public directory files - use fetch in browser, fs in SSR
374
- needsPublicLoader = true;
375
- if (pathBasedNamespacing && info.localeBaseDir) {
376
- const fileEntries = info.files.map(f => {
377
- const url = toPublicUrl(f, projectRoot);
378
- const absolutePath = f.replace(/\\/g, '/');
379
- const namespace = getNamespaceFromPath(f, info.localeBaseDir!);
380
- return `{ url: ${JSON.stringify(url)}, path: ${JSON.stringify(absolutePath)}, namespace: ${JSON.stringify(namespace)} }`;
381
- });
382
-
383
- loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
384
- const fileInfos = [${fileEntries.join(', ')}];
385
- const responses = await Promise.all(fileInfos.map(f => __loadPublicJson(f.url, f.path)));
386
- const wrapped = responses.map((content, i) => __wrapWithNamespace(fileInfos[i].namespace, content));
387
- return __deepMerge({}, ...wrapped);
388
- }`);
389
- } else {
390
- const fileEntries = info.files.map(f => {
391
- const url = toPublicUrl(f, projectRoot);
392
- const absolutePath = f.replace(/\\/g, '/');
393
- return `{ url: ${JSON.stringify(url)}, path: ${JSON.stringify(absolutePath)} }`;
394
- });
395
- loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
396
- const files = [${fileEntries.join(', ')}];
397
- const responses = await Promise.all(files.map(f => __loadPublicJson(f.url, f.path)));
398
- if (responses.length === 1) return responses[0];
399
- return __deepMerge({}, ...responses);
400
- }`);
401
- }
402
- } else if (info.globPattern && pathBasedNamespacing && info.localeBaseDir) {
403
- // Use import.meta.glob with namespace wrapping
404
- const varName = `__${locale}Modules`;
405
- const localeBaseDirForNs = getLocaleBaseDirForNamespace(info.localeBaseDir, projectRoot);
406
- imports.push(
407
- `const ${varName} = import.meta.glob(${JSON.stringify(info.globPattern)}, { eager: true, import: 'default' });`
408
- );
409
-
410
- loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
411
- const entries = Object.entries(${varName});
412
- if (entries.length === 0) return {};
413
- const localeBaseDir = ${JSON.stringify(localeBaseDirForNs)};
414
- const wrapped = entries.map(([filePath, content]) => {
415
- // Extract relative path from locale base dir - filePath starts with /
416
- const normalizedPath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
417
- const relativePath = normalizedPath.replace(localeBaseDir + '/', '').replace(/\\.json$/i, '');
418
- const namespace = relativePath.replace(/[\\/]/g, '.').replace(/\\.index$/, '');
419
- return __wrapWithNamespace(namespace, content);
420
- });
421
- return __deepMerge({}, ...wrapped);
422
- }`);
423
- } else if (info.globPattern) {
424
- // Use import.meta.glob without namespace wrapping
425
- const varName = `__${locale}Modules`;
426
- imports.push(
427
- `const ${varName} = import.meta.glob(${JSON.stringify(info.globPattern)}, { eager: true, import: 'default' });`
428
- );
429
-
430
- loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
431
- const modules = Object.values(${varName});
432
- if (modules.length === 0) return {};
433
- if (modules.length === 1) return modules[0];
434
- return __deepMerge({}, ...modules);
435
- }`);
436
- } else if (info.files.length === 1) {
437
- // Single file - use import
438
- const relativePath = toRelativeImport(info.files[0], projectRoot);
439
- if (pathBasedNamespacing && info.localeBaseDir) {
440
- const namespace = getNamespaceFromPath(info.files[0], info.localeBaseDir);
441
- loaderEntries.push(
442
- ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => __wrapWithNamespace(${JSON.stringify(namespace)}, m.default ?? m))`
443
- );
444
- } else {
445
- loaderEntries.push(
446
- ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
447
- );
448
- }
449
- } else {
450
- // Multiple explicit files - import all and merge
451
- if (pathBasedNamespacing && info.localeBaseDir) {
452
- const fileEntries = info.files.map(f => {
453
- const relativePath = toRelativeImport(f, projectRoot);
454
- const namespace = getNamespaceFromPath(f, info.localeBaseDir!);
455
- return `{ path: ${JSON.stringify(relativePath)}, namespace: ${JSON.stringify(namespace)} }`;
456
- });
457
-
458
- loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
459
- const fileInfos = [${fileEntries.join(', ')}];
460
- const modules = await Promise.all(fileInfos.map(f => import(/* @vite-ignore */ f.path)));
461
- const wrapped = modules.map((m, i) => __wrapWithNamespace(fileInfos[i].namespace, m.default ?? m));
462
- return __deepMerge({}, ...wrapped);
463
- }`);
464
- } else {
465
- const fileImports = info.files
466
- .map(f => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`)
467
- .join(', ');
468
-
469
- loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
470
- const modules = await Promise.all([${fileImports}]);
471
- const contents = modules.map(m => m.default ?? m);
472
- if (contents.length === 1) return contents[0];
473
- return __deepMerge({}, ...contents);
474
- }`);
475
- }
476
- }
477
- }
478
-
479
- // Add public loader helper if needed
480
- if (needsPublicLoader) {
481
- imports.push(getPublicLoaderCode());
482
- }
483
-
484
- return `
485
- ${imports.join('\n')}
486
-
487
- export const translationLoaders = {
488
- ${loaderEntries.join(',\n')}
489
- };
490
-
491
- export async function loadTranslations(locale) {
492
- const loader = translationLoaders[locale];
493
- if (!loader) {
494
- if (import.meta.env.DEV) {
495
- console.warn('[ez-i18n] No translations configured for locale:', locale);
496
- }
497
- return {};
498
- }
499
-
500
- try {
501
- return await loader();
502
- } catch (error) {
503
- if (import.meta.env.DEV) {
504
- console.error('[ez-i18n] Failed to load translations for locale:', locale, error);
505
- }
506
- return {};
507
- }
508
- }
509
- `;
510
- }
511
-
512
- /**
513
- * Generate the translations virtual module for production builds.
514
- * Pre-resolves all imports for optimal bundling.
515
- * Uses fetch() for files in public/ directory (with fs fallback for SSR).
516
- */
517
- function generateBuildTranslationsModule(
518
- translationInfo: Map<string, TranslationInfo>,
519
- projectRoot: string,
520
- pathBasedNamespacing: boolean
521
- ): string {
522
- const loaderEntries: string[] = [];
523
- let needsDeepMerge = false;
524
- let needsNamespaceWrapper = false;
525
- let needsPublicLoader = false;
526
-
527
- for (const [locale, info] of translationInfo) {
528
- if (info.files.length === 0) {
529
- loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
530
- } else if (info.isPublic) {
531
- // Public directory files - use fetch in browser, fs in SSR
532
- needsPublicLoader = true;
533
- needsDeepMerge = info.files.length > 1;
534
- if (pathBasedNamespacing && info.localeBaseDir) {
535
- needsNamespaceWrapper = true;
536
- const fileEntries = info.files.map(f => {
537
- const url = toPublicUrl(f, projectRoot);
538
- const absolutePath = f.replace(/\\/g, '/');
539
- const namespace = getNamespaceFromPath(f, info.localeBaseDir!);
540
- return `{ url: ${JSON.stringify(url)}, path: ${JSON.stringify(absolutePath)}, namespace: ${JSON.stringify(namespace)} }`;
541
- });
542
-
543
- loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
544
- const fileInfos = [${fileEntries.join(', ')}];
545
- const responses = await Promise.all(fileInfos.map(f => __loadPublicJson(f.url, f.path)));
546
- const wrapped = responses.map((content, i) => __wrapWithNamespace(fileInfos[i].namespace, content));
547
- return __deepMerge({}, ...wrapped);
548
- }`);
549
- } else {
550
- const fileEntries = info.files.map(f => {
551
- const url = toPublicUrl(f, projectRoot);
552
- const absolutePath = f.replace(/\\/g, '/');
553
- return `{ url: ${JSON.stringify(url)}, path: ${JSON.stringify(absolutePath)} }`;
554
- });
555
- if (fileEntries.length === 1) {
556
- const f = info.files[0];
557
- const url = toPublicUrl(f, projectRoot);
558
- const absolutePath = f.replace(/\\/g, '/');
559
- loaderEntries.push(` ${JSON.stringify(locale)}: () => __loadPublicJson(${JSON.stringify(url)}, ${JSON.stringify(absolutePath)})`);
560
- } else {
561
- loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
562
- const files = [${fileEntries.join(', ')}];
563
- const responses = await Promise.all(files.map(f => __loadPublicJson(f.url, f.path)));
564
- return __deepMerge({}, ...responses);
565
- }`);
566
- }
567
- }
568
- } else if (info.files.length === 1) {
569
- // Single file - use import
570
- const relativePath = toRelativeImport(info.files[0], projectRoot);
571
- if (pathBasedNamespacing && info.localeBaseDir) {
572
- needsNamespaceWrapper = true;
573
- const namespace = getNamespaceFromPath(info.files[0], info.localeBaseDir);
574
- loaderEntries.push(
575
- ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => __wrapWithNamespace(${JSON.stringify(namespace)}, m.default ?? m))`
576
- );
577
- } else {
578
- loaderEntries.push(
579
- ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
580
- );
581
- }
582
- } else {
583
- // Multiple files - import and merge
584
- needsDeepMerge = true;
585
-
586
- if (pathBasedNamespacing && info.localeBaseDir) {
587
- needsNamespaceWrapper = true;
588
- const fileEntries = info.files.map(f => {
589
- const relativePath = toRelativeImport(f, projectRoot);
590
- const namespace = getNamespaceFromPath(f, info.localeBaseDir!);
591
- return `{ path: ${JSON.stringify(relativePath)}, namespace: ${JSON.stringify(namespace)} }`;
592
- });
593
-
594
- loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
595
- const fileInfos = [${fileEntries.join(', ')}];
596
- const modules = await Promise.all(fileInfos.map(f => import(/* @vite-ignore */ f.path)));
597
- const wrapped = modules.map((m, i) => __wrapWithNamespace(fileInfos[i].namespace, m.default ?? m));
598
- return __deepMerge({}, ...wrapped);
599
- }`);
600
- } else {
601
- const fileImports = info.files
602
- .map(f => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`)
603
- .join(', ');
604
-
605
- loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
606
- const modules = await Promise.all([${fileImports}]);
607
- const contents = modules.map(m => m.default ?? m);
608
- return __deepMerge({}, ...contents);
609
- }`);
610
- }
611
- }
612
- }
613
-
614
- const helperCode = [
615
- needsDeepMerge ? getDeepMergeCode() : '',
616
- needsNamespaceWrapper ? generateNamespaceWrapperCode() : '',
617
- needsPublicLoader ? getPublicLoaderCode() : '',
618
- ].filter(Boolean).join('\n');
619
-
620
- return `
621
- ${helperCode}
622
-
623
- export const translationLoaders = {
624
- ${loaderEntries.join(',\n')}
625
- };
626
-
627
- export async function loadTranslations(locale) {
628
- const loader = translationLoaders[locale];
629
- if (!loader) {
630
- return {};
631
- }
632
-
633
- try {
634
- return await loader();
635
- } catch (error) {
636
- console.error('[ez-i18n] Failed to load translations:', locale, error);
637
- return {};
638
- }
639
- }
640
- `;
641
- }
642
-
643
- /**
644
- * Inline deepMerge function for the virtual module
645
- */
646
- function getDeepMergeCode(): string {
647
- return `
648
- function __deepMerge(target, ...sources) {
649
- const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);
650
- const result = { ...target };
651
- for (const source of sources) {
652
- if (!source || typeof source !== 'object') continue;
653
- for (const key of Object.keys(source)) {
654
- if (FORBIDDEN.has(key)) continue;
655
- const tv = result[key], sv = source[key];
656
- if (sv && typeof sv === 'object' && !Array.isArray(sv) && tv && typeof tv === 'object' && !Array.isArray(tv)) {
657
- result[key] = __deepMerge(tv, sv);
658
- } else {
659
- result[key] = sv;
660
- }
661
- }
662
- }
663
- return result;
664
- }`;
665
- }
666
-
667
- /**
668
- * Inline public JSON loader for the virtual module.
669
- * Uses fetch in browser, fs.readFileSync in SSR/Node.
670
- */
671
- function getPublicLoaderCode(): string {
672
- return `
673
- async function __loadPublicJson(url, absolutePath) {
674
- if (typeof window !== 'undefined') {
675
- // Browser - use fetch with relative URL
676
- return fetch(url).then(r => r.json());
677
- } else {
678
- // SSR/Node - read from filesystem
679
- const fs = await import('node:fs');
680
- const content = fs.readFileSync(absolutePath, 'utf-8');
681
- return JSON.parse(content);
682
- }
683
- }`;
684
- }
685
-
686
- // Re-export resolveConfig for backwards compatibility
687
- export function resolveConfig(config: EzI18nConfig): ResolvedEzI18nConfig {
688
- // This is now a simplified version - full resolution happens in buildStart
689
- const isAutoDiscovery = !config.translations || typeof config.translations === 'string';
690
- return {
691
- locales: config.locales || [],
692
- defaultLocale: config.defaultLocale,
693
- cookieName: config.cookieName ?? 'ez-locale',
694
- translations: {},
695
- pathBasedNamespacing: config.pathBasedNamespacing ?? isAutoDiscovery,
696
- localeBaseDirs: {},
697
- };
698
- }