@zachhandley/ez-i18n 0.1.9 → 0.2.1

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/dist/index.js CHANGED
@@ -152,8 +152,29 @@ function toRelativeImport(absolutePath, projectRoot) {
152
152
  }
153
153
  function toGlobPattern(baseDir, projectRoot) {
154
154
  const relativePath = path.relative(projectRoot, baseDir).replace(/\\/g, "/");
155
+ if (relativePath.startsWith("public/") || relativePath === "public") {
156
+ return null;
157
+ }
155
158
  return `/${relativePath}/**/*.json`;
156
159
  }
160
+ function isInPublicDir(filePath, projectRoot) {
161
+ const relativePath = path.relative(projectRoot, filePath).replace(/\\/g, "/");
162
+ return relativePath.startsWith("public/") || relativePath === "public";
163
+ }
164
+ function toPublicUrl(filePath, projectRoot) {
165
+ const relativePath = path.relative(projectRoot, filePath).replace(/\\/g, "/");
166
+ if (relativePath.startsWith("public/")) {
167
+ return "/" + relativePath.slice("public/".length);
168
+ }
169
+ return "/" + relativePath;
170
+ }
171
+ function getLocaleBaseDirForNamespace(localeBaseDir, projectRoot) {
172
+ const relativePath = path.relative(projectRoot, localeBaseDir).replace(/\\/g, "/");
173
+ if (relativePath.startsWith("public/")) {
174
+ return relativePath.slice("public/".length);
175
+ }
176
+ return relativePath;
177
+ }
157
178
  function getNamespaceFromPath(filePath, localeDir) {
158
179
  const relative2 = path.relative(localeDir, filePath);
159
180
  const withoutExt = relative2.replace(/\.json$/i, "");
@@ -261,12 +282,14 @@ function vitePlugin(config) {
261
282
  };
262
283
  for (const locale of finalLocales) {
263
284
  const files = translations[locale] || [];
285
+ const filesInPublic = files.length > 0 && isInPublicDir(files[0], projectRoot);
264
286
  const info = {
265
287
  locale,
266
288
  files,
267
- localeBaseDir: localeBaseDirs[locale]
289
+ localeBaseDir: localeBaseDirs[locale],
290
+ isPublic: filesInPublic
268
291
  };
269
- if (isDev && config.translations) {
292
+ if (isDev && config.translations && !filesInPublic) {
270
293
  const localeConfig = typeof config.translations === "string" ? path2.join(config.translations, locale) + "/" : config.translations[locale];
271
294
  if (localeConfig && typeof localeConfig === "string") {
272
295
  const pathType = detectPathType(localeConfig);
@@ -412,6 +435,7 @@ export function t(key, params) {
412
435
  function generateDevTranslationsModule(translationInfo, projectRoot, pathBasedNamespacing) {
413
436
  const imports = [];
414
437
  const loaderEntries = [];
438
+ let needsPublicLoader = false;
415
439
  imports.push(getDeepMergeCode());
416
440
  if (pathBasedNamespacing) {
417
441
  imports.push(generateNamespaceWrapperCode());
@@ -419,19 +443,48 @@ function generateDevTranslationsModule(translationInfo, projectRoot, pathBasedNa
419
443
  for (const [locale, info] of translationInfo) {
420
444
  if (info.files.length === 0) {
421
445
  loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
446
+ } else if (info.isPublic) {
447
+ needsPublicLoader = true;
448
+ if (pathBasedNamespacing && info.localeBaseDir) {
449
+ const fileEntries = info.files.map((f) => {
450
+ const url = toPublicUrl(f, projectRoot);
451
+ const absolutePath = f.replace(/\\/g, "/");
452
+ const namespace = getNamespaceFromPath(f, info.localeBaseDir);
453
+ return `{ url: ${JSON.stringify(url)}, path: ${JSON.stringify(absolutePath)}, namespace: ${JSON.stringify(namespace)} }`;
454
+ });
455
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
456
+ const fileInfos = [${fileEntries.join(", ")}];
457
+ const responses = await Promise.all(fileInfos.map(f => __loadPublicJson(f.url, f.path)));
458
+ const wrapped = responses.map((content, i) => __wrapWithNamespace(fileInfos[i].namespace, content));
459
+ return __deepMerge({}, ...wrapped);
460
+ }`);
461
+ } else {
462
+ const fileEntries = info.files.map((f) => {
463
+ const url = toPublicUrl(f, projectRoot);
464
+ const absolutePath = f.replace(/\\/g, "/");
465
+ return `{ url: ${JSON.stringify(url)}, path: ${JSON.stringify(absolutePath)} }`;
466
+ });
467
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
468
+ const files = [${fileEntries.join(", ")}];
469
+ const responses = await Promise.all(files.map(f => __loadPublicJson(f.url, f.path)));
470
+ if (responses.length === 1) return responses[0];
471
+ return __deepMerge({}, ...responses);
472
+ }`);
473
+ }
422
474
  } else if (info.globPattern && pathBasedNamespacing && info.localeBaseDir) {
423
475
  const varName = `__${locale}Modules`;
424
- const localeBaseDir = info.localeBaseDir.replace(/\\/g, "/");
476
+ const localeBaseDirForNs = getLocaleBaseDirForNamespace(info.localeBaseDir, projectRoot);
425
477
  imports.push(
426
478
  `const ${varName} = import.meta.glob(${JSON.stringify(info.globPattern)}, { eager: true, import: 'default' });`
427
479
  );
428
480
  loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
429
481
  const entries = Object.entries(${varName});
430
482
  if (entries.length === 0) return {};
431
- const localeBaseDir = ${JSON.stringify(localeBaseDir)};
483
+ const localeBaseDir = ${JSON.stringify(localeBaseDirForNs)};
432
484
  const wrapped = entries.map(([filePath, content]) => {
433
- // Extract relative path from locale base dir
434
- const relativePath = filePath.replace(localeBaseDir + '/', '').replace(/\\.json$/i, '');
485
+ // Extract relative path from locale base dir - filePath starts with /
486
+ const normalizedPath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
487
+ const relativePath = normalizedPath.replace(localeBaseDir + '/', '').replace(/\\.json$/i, '');
435
488
  const namespace = relativePath.replace(/[\\/]/g, '.').replace(/\\.index$/, '');
436
489
  return __wrapWithNamespace(namespace, content);
437
490
  });
@@ -484,6 +537,9 @@ function generateDevTranslationsModule(translationInfo, projectRoot, pathBasedNa
484
537
  }
485
538
  }
486
539
  }
540
+ if (needsPublicLoader) {
541
+ imports.push(getPublicLoaderCode());
542
+ }
487
543
  return `
488
544
  ${imports.join("\n")}
489
545
 
@@ -515,9 +571,46 @@ function generateBuildTranslationsModule(translationInfo, projectRoot, pathBased
515
571
  const loaderEntries = [];
516
572
  let needsDeepMerge = false;
517
573
  let needsNamespaceWrapper = false;
574
+ let needsPublicLoader = false;
518
575
  for (const [locale, info] of translationInfo) {
519
576
  if (info.files.length === 0) {
520
577
  loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
578
+ } else if (info.isPublic) {
579
+ needsPublicLoader = true;
580
+ needsDeepMerge = info.files.length > 1;
581
+ if (pathBasedNamespacing && info.localeBaseDir) {
582
+ needsNamespaceWrapper = true;
583
+ const fileEntries = info.files.map((f) => {
584
+ const url = toPublicUrl(f, projectRoot);
585
+ const absolutePath = f.replace(/\\/g, "/");
586
+ const namespace = getNamespaceFromPath(f, info.localeBaseDir);
587
+ return `{ url: ${JSON.stringify(url)}, path: ${JSON.stringify(absolutePath)}, namespace: ${JSON.stringify(namespace)} }`;
588
+ });
589
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
590
+ const fileInfos = [${fileEntries.join(", ")}];
591
+ const responses = await Promise.all(fileInfos.map(f => __loadPublicJson(f.url, f.path)));
592
+ const wrapped = responses.map((content, i) => __wrapWithNamespace(fileInfos[i].namespace, content));
593
+ return __deepMerge({}, ...wrapped);
594
+ }`);
595
+ } else {
596
+ const fileEntries = info.files.map((f) => {
597
+ const url = toPublicUrl(f, projectRoot);
598
+ const absolutePath = f.replace(/\\/g, "/");
599
+ return `{ url: ${JSON.stringify(url)}, path: ${JSON.stringify(absolutePath)} }`;
600
+ });
601
+ if (fileEntries.length === 1) {
602
+ const f = info.files[0];
603
+ const url = toPublicUrl(f, projectRoot);
604
+ const absolutePath = f.replace(/\\/g, "/");
605
+ loaderEntries.push(` ${JSON.stringify(locale)}: () => __loadPublicJson(${JSON.stringify(url)}, ${JSON.stringify(absolutePath)})`);
606
+ } else {
607
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
608
+ const files = [${fileEntries.join(", ")}];
609
+ const responses = await Promise.all(files.map(f => __loadPublicJson(f.url, f.path)));
610
+ return __deepMerge({}, ...responses);
611
+ }`);
612
+ }
613
+ }
521
614
  } else if (info.files.length === 1) {
522
615
  const relativePath = toRelativeImport(info.files[0], projectRoot);
523
616
  if (pathBasedNamespacing && info.localeBaseDir) {
@@ -558,7 +651,8 @@ function generateBuildTranslationsModule(translationInfo, projectRoot, pathBased
558
651
  }
559
652
  const helperCode = [
560
653
  needsDeepMerge ? getDeepMergeCode() : "",
561
- needsNamespaceWrapper ? generateNamespaceWrapperCode() : ""
654
+ needsNamespaceWrapper ? generateNamespaceWrapperCode() : "",
655
+ needsPublicLoader ? getPublicLoaderCode() : ""
562
656
  ].filter(Boolean).join("\n");
563
657
  return `
564
658
  ${helperCode}
@@ -602,6 +696,20 @@ function __deepMerge(target, ...sources) {
602
696
  return result;
603
697
  }`;
604
698
  }
699
+ function getPublicLoaderCode() {
700
+ return `
701
+ async function __loadPublicJson(url, absolutePath) {
702
+ if (typeof window !== 'undefined') {
703
+ // Browser - use fetch with relative URL
704
+ return fetch(url).then(r => r.json());
705
+ } else {
706
+ // SSR/Node - read from filesystem
707
+ const fs = await import('node:fs');
708
+ const content = fs.readFileSync(absolutePath, 'utf-8');
709
+ return JSON.parse(content);
710
+ }
711
+ }`;
712
+ }
605
713
  function resolveConfig(config) {
606
714
  const isAutoDiscovery = !config.translations || typeof config.translations === "string";
607
715
  return {
@@ -54,8 +54,9 @@ declare function toRelativeImport(absolutePath: string, projectRoot: string): st
54
54
  /**
55
55
  * Generate a glob pattern for import.meta.glob from a base directory.
56
56
  * In virtual modules, globs must start with '/' (project root relative).
57
+ * Returns null if the path is in public/ (can't use import.meta.glob for public files).
57
58
  */
58
- declare function toGlobPattern(baseDir: string, projectRoot: string): string;
59
+ declare function toGlobPattern(baseDir: string, projectRoot: string): string | null;
59
60
  /**
60
61
  * Get namespace from file path relative to locale base directory.
61
62
  *
@@ -173,6 +173,9 @@ function toRelativeImport(absolutePath, projectRoot) {
173
173
  }
174
174
  function toGlobPattern(baseDir, projectRoot) {
175
175
  const relativePath = path.relative(projectRoot, baseDir).replace(/\\/g, "/");
176
+ if (relativePath.startsWith("public/") || relativePath === "public") {
177
+ return null;
178
+ }
176
179
  return `/${relativePath}/**/*.json`;
177
180
  }
178
181
  function getNamespaceFromPath(filePath, localeDir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zachhandley/ez-i18n",
3
- "version": "0.1.9",
3
+ "version": "0.2.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -304,13 +304,52 @@ export function toRelativeImport(absolutePath: string, projectRoot: string): str
304
304
  /**
305
305
  * Generate a glob pattern for import.meta.glob from a base directory.
306
306
  * In virtual modules, globs must start with '/' (project root relative).
307
+ * Returns null if the path is in public/ (can't use import.meta.glob for public files).
307
308
  */
308
- export function toGlobPattern(baseDir: string, projectRoot: string): string {
309
+ export function toGlobPattern(baseDir: string, projectRoot: string): string | null {
309
310
  const relativePath = path.relative(projectRoot, baseDir).replace(/\\/g, '/');
311
+ // Can't use import.meta.glob for public directory files
312
+ if (relativePath.startsWith('public/') || relativePath === 'public') {
313
+ return null;
314
+ }
310
315
  // Virtual modules require globs to start with '/' (project root relative)
311
316
  return `/${relativePath}/**/*.json`;
312
317
  }
313
318
 
319
+ /**
320
+ * Check if an absolute path is inside the public directory
321
+ */
322
+ export function isInPublicDir(filePath: string, projectRoot: string): boolean {
323
+ const relativePath = path.relative(projectRoot, filePath).replace(/\\/g, '/');
324
+ return relativePath.startsWith('public/') || relativePath === 'public';
325
+ }
326
+
327
+ /**
328
+ * Convert a public directory path to its served URL.
329
+ * public/i18n/en/common.json → /i18n/en/common.json
330
+ */
331
+ export function toPublicUrl(filePath: string, projectRoot: string): string {
332
+ const relativePath = path.relative(projectRoot, filePath).replace(/\\/g, '/');
333
+ // Remove 'public/' prefix - files in public/ are served at root
334
+ if (relativePath.startsWith('public/')) {
335
+ return '/' + relativePath.slice('public/'.length);
336
+ }
337
+ return '/' + relativePath;
338
+ }
339
+
340
+ /**
341
+ * Get the locale base directory for namespace calculation.
342
+ * For public paths, strips the 'public/' prefix.
343
+ */
344
+ export function getLocaleBaseDirForNamespace(localeBaseDir: string, projectRoot: string): string {
345
+ const relativePath = path.relative(projectRoot, localeBaseDir).replace(/\\/g, '/');
346
+ // For namespace calculation, we want the path without 'public/' prefix
347
+ if (relativePath.startsWith('public/')) {
348
+ return relativePath.slice('public/'.length);
349
+ }
350
+ return relativePath;
351
+ }
352
+
314
353
  /**
315
354
  * Get namespace from file path relative to locale base directory.
316
355
  *
@@ -10,6 +10,9 @@ import {
10
10
  detectPathType,
11
11
  getNamespaceFromPath,
12
12
  generateNamespaceWrapperCode,
13
+ isInPublicDir,
14
+ toPublicUrl,
15
+ getLocaleBaseDirForNamespace,
13
16
  } from './utils/translations';
14
17
  import * as path from 'node:path';
15
18
 
@@ -21,10 +24,12 @@ const RESOLVED_PREFIX = '\0';
21
24
  interface TranslationInfo {
22
25
  locale: string;
23
26
  files: string[];
24
- /** Glob pattern for dev mode HMR (if applicable) */
25
- globPattern?: string;
27
+ /** Glob pattern for dev mode HMR (if applicable, null for public files) */
28
+ globPattern?: string | null;
26
29
  /** Base directory for this locale (used for namespace calculation) */
27
30
  localeBaseDir?: string;
31
+ /** Whether files are in the public directory (use fetch instead of import) */
32
+ isPublic?: boolean;
28
33
  }
29
34
 
30
35
  /**
@@ -145,14 +150,18 @@ export function vitePlugin(config: EzI18nConfig): Plugin {
145
150
  // Build translation info for each locale
146
151
  for (const locale of finalLocales) {
147
152
  const files = translations[locale] || [];
153
+ // Check if files are in public directory
154
+ const filesInPublic = files.length > 0 && isInPublicDir(files[0], projectRoot);
155
+
148
156
  const info: TranslationInfo = {
149
157
  locale,
150
158
  files,
151
159
  localeBaseDir: localeBaseDirs[locale],
160
+ isPublic: filesInPublic,
152
161
  };
153
162
 
154
- // For dev mode, determine if we can use import.meta.glob
155
- if (isDev && config.translations) {
163
+ // For dev mode, determine if we can use import.meta.glob (not for public files)
164
+ if (isDev && config.translations && !filesInPublic) {
156
165
  const localeConfig = typeof config.translations === 'string'
157
166
  ? path.join(config.translations, locale) + '/' // Trailing slash ensures detectPathType returns 'folder'
158
167
  : config.translations[locale];
@@ -160,7 +169,7 @@ export function vitePlugin(config: EzI18nConfig): Plugin {
160
169
  if (localeConfig && typeof localeConfig === 'string') {
161
170
  const pathType = detectPathType(localeConfig);
162
171
  if (pathType === 'folder' || pathType === 'glob') {
163
- // Can use import.meta.glob for HMR
172
+ // Can use import.meta.glob for HMR (returns null for public files)
164
173
  const basePath = pathType === 'glob'
165
174
  ? localeConfig
166
175
  : toGlobPattern(path.resolve(projectRoot, localeConfig), projectRoot);
@@ -337,6 +346,7 @@ export function t(key, params) {
337
346
  /**
338
347
  * Generate the translations virtual module for dev mode.
339
348
  * Uses import.meta.glob where possible for HMR support.
349
+ * Uses fetch() for files in public/ directory (with fs fallback for SSR).
340
350
  */
341
351
  function generateDevTranslationsModule(
342
352
  translationInfo: Map<string, TranslationInfo>,
@@ -345,6 +355,7 @@ function generateDevTranslationsModule(
345
355
  ): string {
346
356
  const imports: string[] = [];
347
357
  const loaderEntries: string[] = [];
358
+ let needsPublicLoader = false;
348
359
 
349
360
  // Add deepMerge inline for runtime merging
350
361
  imports.push(getDeepMergeCode());
@@ -358,10 +369,40 @@ function generateDevTranslationsModule(
358
369
  if (info.files.length === 0) {
359
370
  // No files - return empty object
360
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
+ }
361
402
  } else if (info.globPattern && pathBasedNamespacing && info.localeBaseDir) {
362
403
  // Use import.meta.glob with namespace wrapping
363
404
  const varName = `__${locale}Modules`;
364
- const localeBaseDir = info.localeBaseDir.replace(/\\/g, '/');
405
+ const localeBaseDirForNs = getLocaleBaseDirForNamespace(info.localeBaseDir, projectRoot);
365
406
  imports.push(
366
407
  `const ${varName} = import.meta.glob(${JSON.stringify(info.globPattern)}, { eager: true, import: 'default' });`
367
408
  );
@@ -369,10 +410,11 @@ function generateDevTranslationsModule(
369
410
  loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
370
411
  const entries = Object.entries(${varName});
371
412
  if (entries.length === 0) return {};
372
- const localeBaseDir = ${JSON.stringify(localeBaseDir)};
413
+ const localeBaseDir = ${JSON.stringify(localeBaseDirForNs)};
373
414
  const wrapped = entries.map(([filePath, content]) => {
374
- // Extract relative path from locale base dir
375
- const relativePath = filePath.replace(localeBaseDir + '/', '').replace(/\\.json$/i, '');
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, '');
376
418
  const namespace = relativePath.replace(/[\\/]/g, '.').replace(/\\.index$/, '');
377
419
  return __wrapWithNamespace(namespace, content);
378
420
  });
@@ -392,7 +434,7 @@ function generateDevTranslationsModule(
392
434
  return __deepMerge({}, ...modules);
393
435
  }`);
394
436
  } else if (info.files.length === 1) {
395
- // Single file
437
+ // Single file - use import
396
438
  const relativePath = toRelativeImport(info.files[0], projectRoot);
397
439
  if (pathBasedNamespacing && info.localeBaseDir) {
398
440
  const namespace = getNamespaceFromPath(info.files[0], info.localeBaseDir);
@@ -434,6 +476,11 @@ function generateDevTranslationsModule(
434
476
  }
435
477
  }
436
478
 
479
+ // Add public loader helper if needed
480
+ if (needsPublicLoader) {
481
+ imports.push(getPublicLoaderCode());
482
+ }
483
+
437
484
  return `
438
485
  ${imports.join('\n')}
439
486
 
@@ -465,6 +512,7 @@ export async function loadTranslations(locale) {
465
512
  /**
466
513
  * Generate the translations virtual module for production builds.
467
514
  * Pre-resolves all imports for optimal bundling.
515
+ * Uses fetch() for files in public/ directory (with fs fallback for SSR).
468
516
  */
469
517
  function generateBuildTranslationsModule(
470
518
  translationInfo: Map<string, TranslationInfo>,
@@ -474,12 +522,51 @@ function generateBuildTranslationsModule(
474
522
  const loaderEntries: string[] = [];
475
523
  let needsDeepMerge = false;
476
524
  let needsNamespaceWrapper = false;
525
+ let needsPublicLoader = false;
477
526
 
478
527
  for (const [locale, info] of translationInfo) {
479
528
  if (info.files.length === 0) {
480
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
+ }
481
568
  } else if (info.files.length === 1) {
482
- // Single file
569
+ // Single file - use import
483
570
  const relativePath = toRelativeImport(info.files[0], projectRoot);
484
571
  if (pathBasedNamespacing && info.localeBaseDir) {
485
572
  needsNamespaceWrapper = true;
@@ -527,6 +614,7 @@ function generateBuildTranslationsModule(
527
614
  const helperCode = [
528
615
  needsDeepMerge ? getDeepMergeCode() : '',
529
616
  needsNamespaceWrapper ? generateNamespaceWrapperCode() : '',
617
+ needsPublicLoader ? getPublicLoaderCode() : '',
530
618
  ].filter(Boolean).join('\n');
531
619
 
532
620
  return `
@@ -576,6 +664,25 @@ function __deepMerge(target, ...sources) {
576
664
  }`;
577
665
  }
578
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
+
579
686
  // Re-export resolveConfig for backwards compatibility
580
687
  export function resolveConfig(config: EzI18nConfig): ResolvedEzI18nConfig {
581
688
  // This is now a simplified version - full resolution happens in buildStart