@zachhandley/ez-i18n 0.1.9 → 0.2.0

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);
@@ -419,19 +442,43 @@ function generateDevTranslationsModule(translationInfo, projectRoot, pathBasedNa
419
442
  for (const [locale, info] of translationInfo) {
420
443
  if (info.files.length === 0) {
421
444
  loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
445
+ } else if (info.isPublic) {
446
+ if (pathBasedNamespacing && info.localeBaseDir) {
447
+ const localeBaseDirForNs = getLocaleBaseDirForNamespace(info.localeBaseDir, projectRoot);
448
+ const fileEntries = info.files.map((f) => {
449
+ const url = toPublicUrl(f, projectRoot);
450
+ const namespace = getNamespaceFromPath(f, info.localeBaseDir);
451
+ return `{ url: ${JSON.stringify(url)}, namespace: ${JSON.stringify(namespace)} }`;
452
+ });
453
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
454
+ const fileInfos = [${fileEntries.join(", ")}];
455
+ const responses = await Promise.all(fileInfos.map(f => fetch(f.url).then(r => r.json())));
456
+ const wrapped = responses.map((content, i) => __wrapWithNamespace(fileInfos[i].namespace, content));
457
+ return __deepMerge({}, ...wrapped);
458
+ }`);
459
+ } else {
460
+ const urls = info.files.map((f) => toPublicUrl(f, projectRoot));
461
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
462
+ const urls = ${JSON.stringify(urls)};
463
+ const responses = await Promise.all(urls.map(url => fetch(url).then(r => r.json())));
464
+ if (responses.length === 1) return responses[0];
465
+ return __deepMerge({}, ...responses);
466
+ }`);
467
+ }
422
468
  } else if (info.globPattern && pathBasedNamespacing && info.localeBaseDir) {
423
469
  const varName = `__${locale}Modules`;
424
- const localeBaseDir = info.localeBaseDir.replace(/\\/g, "/");
470
+ const localeBaseDirForNs = getLocaleBaseDirForNamespace(info.localeBaseDir, projectRoot);
425
471
  imports.push(
426
472
  `const ${varName} = import.meta.glob(${JSON.stringify(info.globPattern)}, { eager: true, import: 'default' });`
427
473
  );
428
474
  loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
429
475
  const entries = Object.entries(${varName});
430
476
  if (entries.length === 0) return {};
431
- const localeBaseDir = ${JSON.stringify(localeBaseDir)};
477
+ const localeBaseDir = ${JSON.stringify(localeBaseDirForNs)};
432
478
  const wrapped = entries.map(([filePath, content]) => {
433
- // Extract relative path from locale base dir
434
- const relativePath = filePath.replace(localeBaseDir + '/', '').replace(/\\.json$/i, '');
479
+ // Extract relative path from locale base dir - filePath starts with /
480
+ const normalizedPath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
481
+ const relativePath = normalizedPath.replace(localeBaseDir + '/', '').replace(/\\.json$/i, '');
435
482
  const namespace = relativePath.replace(/[\\/]/g, '.').replace(/\\.index$/, '');
436
483
  return __wrapWithNamespace(namespace, content);
437
484
  });
@@ -518,6 +565,33 @@ function generateBuildTranslationsModule(translationInfo, projectRoot, pathBased
518
565
  for (const [locale, info] of translationInfo) {
519
566
  if (info.files.length === 0) {
520
567
  loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
568
+ } else if (info.isPublic) {
569
+ needsDeepMerge = info.files.length > 1;
570
+ if (pathBasedNamespacing && info.localeBaseDir) {
571
+ needsNamespaceWrapper = true;
572
+ const fileEntries = info.files.map((f) => {
573
+ const url = toPublicUrl(f, projectRoot);
574
+ const namespace = getNamespaceFromPath(f, info.localeBaseDir);
575
+ return `{ url: ${JSON.stringify(url)}, namespace: ${JSON.stringify(namespace)} }`;
576
+ });
577
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
578
+ const fileInfos = [${fileEntries.join(", ")}];
579
+ const responses = await Promise.all(fileInfos.map(f => fetch(f.url).then(r => r.json())));
580
+ const wrapped = responses.map((content, i) => __wrapWithNamespace(fileInfos[i].namespace, content));
581
+ return __deepMerge({}, ...wrapped);
582
+ }`);
583
+ } else {
584
+ const urls = info.files.map((f) => toPublicUrl(f, projectRoot));
585
+ if (urls.length === 1) {
586
+ loaderEntries.push(` ${JSON.stringify(locale)}: () => fetch(${JSON.stringify(urls[0])}).then(r => r.json())`);
587
+ } else {
588
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
589
+ const urls = ${JSON.stringify(urls)};
590
+ const responses = await Promise.all(urls.map(url => fetch(url).then(r => r.json())));
591
+ return __deepMerge({}, ...responses);
592
+ }`);
593
+ }
594
+ }
521
595
  } else if (info.files.length === 1) {
522
596
  const relativePath = toRelativeImport(info.files[0], projectRoot);
523
597
  if (pathBasedNamespacing && info.localeBaseDir) {
@@ -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.0",
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.
340
350
  */
341
351
  function generateDevTranslationsModule(
342
352
  translationInfo: Map<string, TranslationInfo>,
@@ -358,10 +368,35 @@ function generateDevTranslationsModule(
358
368
  if (info.files.length === 0) {
359
369
  // No files - return empty object
360
370
  loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
371
+ } else if (info.isPublic) {
372
+ // Public directory files - use fetch() instead of import
373
+ if (pathBasedNamespacing && info.localeBaseDir) {
374
+ const localeBaseDirForNs = getLocaleBaseDirForNamespace(info.localeBaseDir, projectRoot);
375
+ const fileEntries = info.files.map(f => {
376
+ const url = toPublicUrl(f, projectRoot);
377
+ const namespace = getNamespaceFromPath(f, info.localeBaseDir!);
378
+ return `{ url: ${JSON.stringify(url)}, namespace: ${JSON.stringify(namespace)} }`;
379
+ });
380
+
381
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
382
+ const fileInfos = [${fileEntries.join(', ')}];
383
+ const responses = await Promise.all(fileInfos.map(f => fetch(f.url).then(r => r.json())));
384
+ const wrapped = responses.map((content, i) => __wrapWithNamespace(fileInfos[i].namespace, content));
385
+ return __deepMerge({}, ...wrapped);
386
+ }`);
387
+ } else {
388
+ const urls = info.files.map(f => toPublicUrl(f, projectRoot));
389
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
390
+ const urls = ${JSON.stringify(urls)};
391
+ const responses = await Promise.all(urls.map(url => fetch(url).then(r => r.json())));
392
+ if (responses.length === 1) return responses[0];
393
+ return __deepMerge({}, ...responses);
394
+ }`);
395
+ }
361
396
  } else if (info.globPattern && pathBasedNamespacing && info.localeBaseDir) {
362
397
  // Use import.meta.glob with namespace wrapping
363
398
  const varName = `__${locale}Modules`;
364
- const localeBaseDir = info.localeBaseDir.replace(/\\/g, '/');
399
+ const localeBaseDirForNs = getLocaleBaseDirForNamespace(info.localeBaseDir, projectRoot);
365
400
  imports.push(
366
401
  `const ${varName} = import.meta.glob(${JSON.stringify(info.globPattern)}, { eager: true, import: 'default' });`
367
402
  );
@@ -369,10 +404,11 @@ function generateDevTranslationsModule(
369
404
  loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
370
405
  const entries = Object.entries(${varName});
371
406
  if (entries.length === 0) return {};
372
- const localeBaseDir = ${JSON.stringify(localeBaseDir)};
407
+ const localeBaseDir = ${JSON.stringify(localeBaseDirForNs)};
373
408
  const wrapped = entries.map(([filePath, content]) => {
374
- // Extract relative path from locale base dir
375
- const relativePath = filePath.replace(localeBaseDir + '/', '').replace(/\\.json$/i, '');
409
+ // Extract relative path from locale base dir - filePath starts with /
410
+ const normalizedPath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
411
+ const relativePath = normalizedPath.replace(localeBaseDir + '/', '').replace(/\\.json$/i, '');
376
412
  const namespace = relativePath.replace(/[\\/]/g, '.').replace(/\\.index$/, '');
377
413
  return __wrapWithNamespace(namespace, content);
378
414
  });
@@ -392,7 +428,7 @@ function generateDevTranslationsModule(
392
428
  return __deepMerge({}, ...modules);
393
429
  }`);
394
430
  } else if (info.files.length === 1) {
395
- // Single file
431
+ // Single file - use import
396
432
  const relativePath = toRelativeImport(info.files[0], projectRoot);
397
433
  if (pathBasedNamespacing && info.localeBaseDir) {
398
434
  const namespace = getNamespaceFromPath(info.files[0], info.localeBaseDir);
@@ -465,6 +501,7 @@ export async function loadTranslations(locale) {
465
501
  /**
466
502
  * Generate the translations virtual module for production builds.
467
503
  * Pre-resolves all imports for optimal bundling.
504
+ * Uses fetch() for files in public/ directory.
468
505
  */
469
506
  function generateBuildTranslationsModule(
470
507
  translationInfo: Map<string, TranslationInfo>,
@@ -478,8 +515,37 @@ function generateBuildTranslationsModule(
478
515
  for (const [locale, info] of translationInfo) {
479
516
  if (info.files.length === 0) {
480
517
  loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
518
+ } else if (info.isPublic) {
519
+ // Public directory files - use fetch() instead of import
520
+ needsDeepMerge = info.files.length > 1;
521
+ if (pathBasedNamespacing && info.localeBaseDir) {
522
+ needsNamespaceWrapper = true;
523
+ const fileEntries = info.files.map(f => {
524
+ const url = toPublicUrl(f, projectRoot);
525
+ const namespace = getNamespaceFromPath(f, info.localeBaseDir!);
526
+ return `{ url: ${JSON.stringify(url)}, namespace: ${JSON.stringify(namespace)} }`;
527
+ });
528
+
529
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
530
+ const fileInfos = [${fileEntries.join(', ')}];
531
+ const responses = await Promise.all(fileInfos.map(f => fetch(f.url).then(r => r.json())));
532
+ const wrapped = responses.map((content, i) => __wrapWithNamespace(fileInfos[i].namespace, content));
533
+ return __deepMerge({}, ...wrapped);
534
+ }`);
535
+ } else {
536
+ const urls = info.files.map(f => toPublicUrl(f, projectRoot));
537
+ if (urls.length === 1) {
538
+ loaderEntries.push(` ${JSON.stringify(locale)}: () => fetch(${JSON.stringify(urls[0])}).then(r => r.json())`);
539
+ } else {
540
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
541
+ const urls = ${JSON.stringify(urls)};
542
+ const responses = await Promise.all(urls.map(url => fetch(url).then(r => r.json())));
543
+ return __deepMerge({}, ...responses);
544
+ }`);
545
+ }
546
+ }
481
547
  } else if (info.files.length === 1) {
482
- // Single file
548
+ // Single file - use import
483
549
  const relativePath = toRelativeImport(info.files[0], projectRoot);
484
550
  if (pathBasedNamespacing && info.localeBaseDir) {
485
551
  needsNamespaceWrapper = true;