@zachhandley/ez-i18n 0.3.4 → 0.3.6

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