@zachhandley/ez-i18n 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -125,6 +125,44 @@ en/
125
125
  99-overrides.json # Loaded last, highest priority
126
126
  ```
127
127
 
128
+ #### Path-Based Namespacing
129
+
130
+ When using folder-based translation organization, ez-i18n automatically creates namespaces from your file paths. This is **enabled by default** when using folder-based config.
131
+
132
+ **Example:**
133
+
134
+ ```
135
+ public/i18n/
136
+ en/
137
+ auth/
138
+ login.json # { "title": "Sign In", "button": "Log In" }
139
+ signup.json # { "title": "Create Account" }
140
+ common.json # { "welcome": "Welcome" }
141
+ ```
142
+
143
+ Access translations using dot notation that mirrors the folder structure:
144
+
145
+ ```typescript
146
+ $t('auth.login.title') // "Sign In"
147
+ $t('auth.login.button') // "Log In"
148
+ $t('auth.signup.title') // "Create Account"
149
+ $t('common.welcome') // "Welcome"
150
+ ```
151
+
152
+ **Disable path-based namespacing:**
153
+
154
+ If you prefer to manage namespaces manually within your JSON files, you can disable this feature:
155
+
156
+ ```typescript
157
+ ezI18n({
158
+ defaultLocale: 'en',
159
+ translations: './src/i18n/',
160
+ pathBasedNamespacing: false, // Disable automatic path namespacing
161
+ })
162
+ ```
163
+
164
+ With `pathBasedNamespacing: false`, the file structure is ignored and keys are used directly from each JSON file.
165
+
128
166
  #### Cache File
129
167
 
130
168
  A `.ez-i18n.json` cache file is generated to speed up subsequent builds. Add it to `.gitignore`:
@@ -248,6 +286,7 @@ function MyComponent() {
248
286
  - **Middleware included** - Auto-detects locale from cookie, query param, or Accept-Language header
249
287
  - **Multi-file support** - Organize translations in folders, use globs, or arrays
250
288
  - **Auto-discovery** - Automatic locale detection from folder structure
289
+ - **Path-based namespacing** - Automatic namespacing from folder structure (`auth/login.json` becomes `auth.login.*`)
251
290
  - **HMR in dev** - Hot reload translation changes without restart
252
291
 
253
292
  ## Locale Detection Priority
@@ -269,6 +308,7 @@ Astro integration function.
269
308
  | `defaultLocale` | `string` | Yes | Fallback locale |
270
309
  | `cookieName` | `string` | No | Cookie name (default: `'ez-locale'`) |
271
310
  | `translations` | `string \| Record<string, TranslationPath>` | No | Base directory or per-locale paths (default: `./public/i18n/`) |
311
+ | `pathBasedNamespacing` | `boolean` | No | Auto-namespace translations from folder paths (default: `true` for folder-based config) |
272
312
 
273
313
  **TranslationPath** can be:
274
314
  - Single file: `'./src/i18n/en.json'`
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { AstroIntegration } from 'astro';
2
- import { E as EzI18nConfig } from './types-CHyDGt_C.js';
3
- export { T as TranslateFunction } from './types-CHyDGt_C.js';
2
+ import { E as EzI18nConfig } from './types-Cd9e7Lkc.js';
3
+ export { T as TranslateFunction } from './types-Cd9e7Lkc.js';
4
4
 
5
5
  /**
6
6
  * ez-i18n Astro integration
package/dist/index.js CHANGED
@@ -155,6 +155,24 @@ function toGlobPattern(baseDir, projectRoot) {
155
155
  const normalized = relativePath.startsWith(".") ? relativePath : "./" + relativePath;
156
156
  return `${normalized}/**/*.json`;
157
157
  }
158
+ function getNamespaceFromPath(filePath, localeDir) {
159
+ const relative2 = path.relative(localeDir, filePath);
160
+ const withoutExt = relative2.replace(/\.json$/i, "");
161
+ const namespace = withoutExt.replace(/[\\/]/g, ".");
162
+ return namespace.replace(/\.index$/, "");
163
+ }
164
+ function generateNamespaceWrapperCode() {
165
+ return `
166
+ function __wrapWithNamespace(namespace, content) {
167
+ if (!namespace) return content;
168
+ const parts = namespace.split('.');
169
+ let result = content;
170
+ for (let i = parts.length - 1; i >= 0; i--) {
171
+ result = { [parts[i]]: result };
172
+ }
173
+ return result;
174
+ }`;
175
+ }
158
176
 
159
177
  // src/vite-plugin.ts
160
178
  import * as path2 from "path";
@@ -176,19 +194,32 @@ function vitePlugin(config) {
176
194
  },
177
195
  async buildStart() {
178
196
  const projectRoot = viteConfig.root;
197
+ const isAutoDiscovery = !config.translations || typeof config.translations === "string";
198
+ const pathBasedNamespacing = config.pathBasedNamespacing ?? isAutoDiscovery;
199
+ const translationsBaseDir = typeof config.translations === "string" ? path2.resolve(projectRoot, config.translations.replace(/\/$/, "")) : path2.resolve(projectRoot, "./public/i18n");
179
200
  let useCache = false;
180
201
  if (!isDev) {
181
202
  const cache = loadCache(projectRoot);
182
203
  if (cache && isCacheValid(cache, projectRoot)) {
204
+ const localeBaseDirs = {};
205
+ for (const locale of Object.keys(cache.discovered)) {
206
+ localeBaseDirs[locale] = path2.join(translationsBaseDir, locale);
207
+ }
183
208
  resolved = {
184
209
  locales: config.locales || Object.keys(cache.discovered),
185
210
  defaultLocale: config.defaultLocale,
186
211
  cookieName: config.cookieName ?? "ez-locale",
187
- translations: cache.discovered
212
+ translations: cache.discovered,
213
+ pathBasedNamespacing,
214
+ localeBaseDirs
188
215
  };
189
216
  useCache = true;
190
217
  for (const [locale, files] of Object.entries(cache.discovered)) {
191
- translationInfo.set(locale, { locale, files });
218
+ translationInfo.set(locale, {
219
+ locale,
220
+ files,
221
+ localeBaseDir: localeBaseDirs[locale]
222
+ });
192
223
  }
193
224
  }
194
225
  }
@@ -199,15 +230,43 @@ function vitePlugin(config) {
199
230
  config.locales
200
231
  );
201
232
  const finalLocales = config.locales && config.locales.length > 0 ? config.locales : locales;
233
+ const localeBaseDirs = {};
234
+ for (const locale of finalLocales) {
235
+ if (isAutoDiscovery) {
236
+ localeBaseDirs[locale] = path2.join(translationsBaseDir, locale);
237
+ } else if (typeof config.translations === "object" && config.translations[locale]) {
238
+ const localeConfig = config.translations[locale];
239
+ if (typeof localeConfig === "string") {
240
+ const pathType = detectPathType(localeConfig);
241
+ if (pathType === "folder" || pathType === "file") {
242
+ const resolved2 = path2.resolve(projectRoot, localeConfig.replace(/\/$/, ""));
243
+ localeBaseDirs[locale] = pathType === "folder" ? resolved2 : path2.dirname(resolved2);
244
+ } else {
245
+ const baseDir = localeConfig.split("*")[0].replace(/\/$/, "");
246
+ localeBaseDirs[locale] = path2.resolve(projectRoot, baseDir);
247
+ }
248
+ } else {
249
+ localeBaseDirs[locale] = translationsBaseDir;
250
+ }
251
+ } else {
252
+ localeBaseDirs[locale] = translationsBaseDir;
253
+ }
254
+ }
202
255
  resolved = {
203
256
  locales: finalLocales,
204
257
  defaultLocale: config.defaultLocale,
205
258
  cookieName: config.cookieName ?? "ez-locale",
206
- translations
259
+ translations,
260
+ pathBasedNamespacing,
261
+ localeBaseDirs
207
262
  };
208
263
  for (const locale of finalLocales) {
209
264
  const files = translations[locale] || [];
210
- const info = { locale, files };
265
+ const info = {
266
+ locale,
267
+ files,
268
+ localeBaseDir: localeBaseDirs[locale]
269
+ };
211
270
  if (isDev && config.translations) {
212
271
  const localeConfig = typeof config.translations === "string" ? path2.join(config.translations, locale) : config.translations[locale];
213
272
  if (localeConfig && typeof localeConfig === "string") {
@@ -295,7 +354,7 @@ export function t(key, params) {
295
354
  `;
296
355
  }
297
356
  if (id === RESOLVED_PREFIX + VIRTUAL_TRANSLATIONS) {
298
- return isDev ? generateDevTranslationsModule(translationInfo, viteConfig.root) : generateBuildTranslationsModule(translationInfo, viteConfig.root);
357
+ return isDev ? generateDevTranslationsModule(translationInfo, viteConfig.root, resolved.pathBasedNamespacing) : generateBuildTranslationsModule(translationInfo, viteConfig.root, resolved.pathBasedNamespacing);
299
358
  }
300
359
  return null;
301
360
  },
@@ -350,13 +409,34 @@ export function t(key, params) {
350
409
  }
351
410
  };
352
411
  }
353
- function generateDevTranslationsModule(translationInfo, projectRoot) {
412
+ function generateDevTranslationsModule(translationInfo, projectRoot, pathBasedNamespacing) {
354
413
  const imports = [];
355
414
  const loaderEntries = [];
356
415
  imports.push(getDeepMergeCode());
416
+ if (pathBasedNamespacing) {
417
+ imports.push(generateNamespaceWrapperCode());
418
+ }
357
419
  for (const [locale, info] of translationInfo) {
358
420
  if (info.files.length === 0) {
359
421
  loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
422
+ } else if (info.globPattern && pathBasedNamespacing && info.localeBaseDir) {
423
+ const varName = `__${locale}Modules`;
424
+ const localeBaseDir = info.localeBaseDir.replace(/\\/g, "/");
425
+ imports.push(
426
+ `const ${varName} = import.meta.glob(${JSON.stringify(info.globPattern)}, { eager: true, import: 'default' });`
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(localeBaseDir)};
432
+ const wrapped = entries.map(([filePath, content]) => {
433
+ // Extract relative path from locale base dir
434
+ const relativePath = filePath.replace(localeBaseDir + '/', '').replace(/\\.json$/i, '');
435
+ const namespace = relativePath.replace(/[\\/]/g, '.').replace(/\\.index$/, '');
436
+ return __wrapWithNamespace(namespace, content);
437
+ });
438
+ return __deepMerge({}, ...wrapped);
439
+ }`);
360
440
  } else if (info.globPattern) {
361
441
  const varName = `__${locale}Modules`;
362
442
  imports.push(
@@ -370,17 +450,38 @@ function generateDevTranslationsModule(translationInfo, projectRoot) {
370
450
  }`);
371
451
  } else if (info.files.length === 1) {
372
452
  const relativePath = toRelativeImport(info.files[0], projectRoot);
373
- loaderEntries.push(
374
- ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
375
- );
453
+ if (pathBasedNamespacing && info.localeBaseDir) {
454
+ const namespace = getNamespaceFromPath(info.files[0], info.localeBaseDir);
455
+ loaderEntries.push(
456
+ ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => __wrapWithNamespace(${JSON.stringify(namespace)}, m.default ?? m))`
457
+ );
458
+ } else {
459
+ loaderEntries.push(
460
+ ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
461
+ );
462
+ }
376
463
  } else {
377
- const fileImports = info.files.map((f) => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`).join(", ");
378
- loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
464
+ if (pathBasedNamespacing && info.localeBaseDir) {
465
+ const fileEntries = info.files.map((f) => {
466
+ const relativePath = toRelativeImport(f, projectRoot);
467
+ const namespace = getNamespaceFromPath(f, info.localeBaseDir);
468
+ return `{ path: ${JSON.stringify(relativePath)}, namespace: ${JSON.stringify(namespace)} }`;
469
+ });
470
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
471
+ const fileInfos = [${fileEntries.join(", ")}];
472
+ const modules = await Promise.all(fileInfos.map(f => import(f.path)));
473
+ const wrapped = modules.map((m, i) => __wrapWithNamespace(fileInfos[i].namespace, m.default ?? m));
474
+ return __deepMerge({}, ...wrapped);
475
+ }`);
476
+ } else {
477
+ const fileImports = info.files.map((f) => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`).join(", ");
478
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
379
479
  const modules = await Promise.all([${fileImports}]);
380
480
  const contents = modules.map(m => m.default ?? m);
381
481
  if (contents.length === 1) return contents[0];
382
482
  return __deepMerge({}, ...contents);
383
483
  }`);
484
+ }
384
485
  }
385
486
  }
386
487
  return `
@@ -410,30 +511,57 @@ export async function loadTranslations(locale) {
410
511
  }
411
512
  `;
412
513
  }
413
- function generateBuildTranslationsModule(translationInfo, projectRoot) {
514
+ function generateBuildTranslationsModule(translationInfo, projectRoot, pathBasedNamespacing) {
414
515
  const loaderEntries = [];
415
516
  let needsDeepMerge = false;
517
+ let needsNamespaceWrapper = false;
416
518
  for (const [locale, info] of translationInfo) {
417
519
  if (info.files.length === 0) {
418
520
  loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
419
521
  } else if (info.files.length === 1) {
420
522
  const relativePath = toRelativeImport(info.files[0], projectRoot);
421
- loaderEntries.push(
422
- ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
423
- );
523
+ if (pathBasedNamespacing && info.localeBaseDir) {
524
+ needsNamespaceWrapper = true;
525
+ const namespace = getNamespaceFromPath(info.files[0], info.localeBaseDir);
526
+ loaderEntries.push(
527
+ ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => __wrapWithNamespace(${JSON.stringify(namespace)}, m.default ?? m))`
528
+ );
529
+ } else {
530
+ loaderEntries.push(
531
+ ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
532
+ );
533
+ }
424
534
  } else {
425
535
  needsDeepMerge = true;
426
- const fileImports = info.files.map((f) => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`).join(", ");
427
- loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
536
+ if (pathBasedNamespacing && info.localeBaseDir) {
537
+ needsNamespaceWrapper = true;
538
+ const fileEntries = info.files.map((f) => {
539
+ const relativePath = toRelativeImport(f, projectRoot);
540
+ const namespace = getNamespaceFromPath(f, info.localeBaseDir);
541
+ return `{ path: ${JSON.stringify(relativePath)}, namespace: ${JSON.stringify(namespace)} }`;
542
+ });
543
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
544
+ const fileInfos = [${fileEntries.join(", ")}];
545
+ const modules = await Promise.all(fileInfos.map(f => import(f.path)));
546
+ const wrapped = modules.map((m, i) => __wrapWithNamespace(fileInfos[i].namespace, m.default ?? m));
547
+ return __deepMerge({}, ...wrapped);
548
+ }`);
549
+ } else {
550
+ const fileImports = info.files.map((f) => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`).join(", ");
551
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
428
552
  const modules = await Promise.all([${fileImports}]);
429
553
  const contents = modules.map(m => m.default ?? m);
430
554
  return __deepMerge({}, ...contents);
431
555
  }`);
556
+ }
432
557
  }
433
558
  }
434
- const deepMergeCode = needsDeepMerge ? getDeepMergeCode() : "";
559
+ const helperCode = [
560
+ needsDeepMerge ? getDeepMergeCode() : "",
561
+ needsNamespaceWrapper ? generateNamespaceWrapperCode() : ""
562
+ ].filter(Boolean).join("\n");
435
563
  return `
436
- ${deepMergeCode}
564
+ ${helperCode}
437
565
 
438
566
  export const translationLoaders = {
439
567
  ${loaderEntries.join(",\n")}
@@ -475,11 +603,14 @@ function __deepMerge(target, ...sources) {
475
603
  }`;
476
604
  }
477
605
  function resolveConfig(config) {
606
+ const isAutoDiscovery = !config.translations || typeof config.translations === "string";
478
607
  return {
479
608
  locales: config.locales || [],
480
609
  defaultLocale: config.defaultLocale,
481
610
  cookieName: config.cookieName ?? "ez-locale",
482
- translations: {}
611
+ translations: {},
612
+ pathBasedNamespacing: config.pathBasedNamespacing ?? isAutoDiscovery,
613
+ localeBaseDirs: {}
483
614
  };
484
615
  }
485
616
 
@@ -1,5 +1,5 @@
1
1
  import { setLocale } from './index.js';
2
- import { T as TranslateFunction } from '../types-CHyDGt_C.js';
2
+ import { T as TranslateFunction } from '../types-Cd9e7Lkc.js';
3
3
  import 'nanostores';
4
4
 
5
5
  /**
@@ -1,7 +1,7 @@
1
1
  import * as vue from 'vue';
2
2
  import { Plugin } from 'vue';
3
3
  import { setLocale } from './index.js';
4
- import { T as TranslateFunction } from '../types-CHyDGt_C.js';
4
+ import { T as TranslateFunction } from '../types-Cd9e7Lkc.js';
5
5
  import 'nanostores';
6
6
 
7
7
  /**
@@ -53,6 +53,18 @@ interface EzI18nConfig {
53
53
  * If not specified, auto-discovers from ./public/i18n/
54
54
  */
55
55
  translations?: TranslationsConfig;
56
+ /**
57
+ * Derive namespace from file path relative to locale folder.
58
+ *
59
+ * When enabled:
60
+ * - `en/auth/login.json` with `{ "title": "..." }` → `$t('auth.login.title')`
61
+ * - `en/common.json` with `{ "actions": {...} }` → `$t('common.actions.save')`
62
+ *
63
+ * The file path (minus locale folder and .json extension) becomes the key prefix.
64
+ *
65
+ * @default true when using folder-based translations config
66
+ */
67
+ pathBasedNamespacing?: boolean;
56
68
  }
57
69
  /**
58
70
  * Cache file structure (.ez-i18n.json)
@@ -1,4 +1,4 @@
1
- import { L as LocaleTranslationPath, a as TranslationsConfig, b as TranslationCache } from '../types-CHyDGt_C.js';
1
+ import { L as LocaleTranslationPath, a as TranslationsConfig, b as TranslationCache } from '../types-Cd9e7Lkc.js';
2
2
 
3
3
  type PathType = 'file' | 'folder' | 'glob' | 'array';
4
4
  /**
@@ -55,5 +55,31 @@ declare function toRelativeImport(absolutePath: string, projectRoot: string): st
55
55
  * Generate a glob pattern for import.meta.glob from a base directory
56
56
  */
57
57
  declare function toGlobPattern(baseDir: string, projectRoot: string): string;
58
+ /**
59
+ * Get namespace from file path relative to locale base directory.
60
+ *
61
+ * Examples:
62
+ * - filePath: /project/public/i18n/en/auth/login.json, localeDir: /project/public/i18n/en
63
+ * → namespace: 'auth.login'
64
+ * - filePath: /project/public/i18n/en/common.json, localeDir: /project/public/i18n/en
65
+ * → namespace: 'common'
66
+ * - filePath: /project/public/i18n/en/settings/index.json, localeDir: /project/public/i18n/en
67
+ * → namespace: 'settings' (index is stripped)
68
+ */
69
+ declare function getNamespaceFromPath(filePath: string, localeDir: string): string;
70
+ /**
71
+ * Wrap a translation object with its namespace.
72
+ *
73
+ * Example:
74
+ * - namespace: 'auth.login'
75
+ * - content: { title: 'Welcome', subtitle: 'Login here' }
76
+ * - result: { auth: { login: { title: 'Welcome', subtitle: 'Login here' } } }
77
+ */
78
+ declare function wrapWithNamespace(namespace: string, content: Record<string, unknown>): Record<string, unknown>;
79
+ /**
80
+ * Generate code that wraps imported content with namespace at runtime.
81
+ * Used in virtual module generation.
82
+ */
83
+ declare function generateNamespaceWrapperCode(): string;
58
84
 
59
- export { type PathType, autoDiscoverTranslations, deepMerge, detectPathType, isCacheValid, loadCache, resolveTranslationPaths, resolveTranslationsConfig, saveCache, toGlobPattern, toRelativeImport };
85
+ export { type PathType, autoDiscoverTranslations, deepMerge, detectPathType, generateNamespaceWrapperCode, getNamespaceFromPath, isCacheValid, loadCache, resolveTranslationPaths, resolveTranslationsConfig, saveCache, toGlobPattern, toRelativeImport, wrapWithNamespace };
@@ -176,15 +176,45 @@ function toGlobPattern(baseDir, projectRoot) {
176
176
  const normalized = relativePath.startsWith(".") ? relativePath : "./" + relativePath;
177
177
  return `${normalized}/**/*.json`;
178
178
  }
179
+ function getNamespaceFromPath(filePath, localeDir) {
180
+ const relative2 = path.relative(localeDir, filePath);
181
+ const withoutExt = relative2.replace(/\.json$/i, "");
182
+ const namespace = withoutExt.replace(/[\\/]/g, ".");
183
+ return namespace.replace(/\.index$/, "");
184
+ }
185
+ function wrapWithNamespace(namespace, content) {
186
+ if (!namespace) return content;
187
+ const parts = namespace.split(".");
188
+ let result = content;
189
+ for (let i = parts.length - 1; i >= 0; i--) {
190
+ result = { [parts[i]]: result };
191
+ }
192
+ return result;
193
+ }
194
+ function generateNamespaceWrapperCode() {
195
+ return `
196
+ function __wrapWithNamespace(namespace, content) {
197
+ if (!namespace) return content;
198
+ const parts = namespace.split('.');
199
+ let result = content;
200
+ for (let i = parts.length - 1; i >= 0; i--) {
201
+ result = { [parts[i]]: result };
202
+ }
203
+ return result;
204
+ }`;
205
+ }
179
206
  export {
180
207
  autoDiscoverTranslations,
181
208
  deepMerge,
182
209
  detectPathType,
210
+ generateNamespaceWrapperCode,
211
+ getNamespaceFromPath,
183
212
  isCacheValid,
184
213
  loadCache,
185
214
  resolveTranslationPaths,
186
215
  resolveTranslationsConfig,
187
216
  saveCache,
188
217
  toGlobPattern,
189
- toRelativeImport
218
+ toRelativeImport,
219
+ wrapWithNamespace
190
220
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zachhandley/ez-i18n",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/types.ts CHANGED
@@ -58,6 +58,19 @@ export interface EzI18nConfig {
58
58
  * If not specified, auto-discovers from ./public/i18n/
59
59
  */
60
60
  translations?: TranslationsConfig;
61
+
62
+ /**
63
+ * Derive namespace from file path relative to locale folder.
64
+ *
65
+ * When enabled:
66
+ * - `en/auth/login.json` with `{ "title": "..." }` → `$t('auth.login.title')`
67
+ * - `en/common.json` with `{ "actions": {...} }` → `$t('common.actions.save')`
68
+ *
69
+ * The file path (minus locale folder and .json extension) becomes the key prefix.
70
+ *
71
+ * @default true when using folder-based translations config
72
+ */
73
+ pathBasedNamespacing?: boolean;
61
74
  }
62
75
 
63
76
  /**
@@ -72,6 +85,10 @@ export interface ResolvedEzI18nConfig {
72
85
  cookieName: string;
73
86
  /** Normalized: locale → array of resolved absolute file paths */
74
87
  translations: Record<string, string[]>;
88
+ /** Whether to derive namespace from file path */
89
+ pathBasedNamespacing: boolean;
90
+ /** Base directory for each locale (used for namespace calculation) */
91
+ localeBaseDirs: Record<string, string>;
75
92
  }
76
93
 
77
94
  /**
@@ -9,5 +9,8 @@ export {
9
9
  isCacheValid,
10
10
  toRelativeImport,
11
11
  toGlobPattern,
12
+ getNamespaceFromPath,
13
+ wrapWithNamespace,
14
+ generateNamespaceWrapperCode,
12
15
  type PathType,
13
16
  } from './translations';
@@ -309,3 +309,70 @@ export function toGlobPattern(baseDir: string, projectRoot: string): string {
309
309
  const normalized = relativePath.startsWith('.') ? relativePath : './' + relativePath;
310
310
  return `${normalized}/**/*.json`;
311
311
  }
312
+
313
+ /**
314
+ * Get namespace from file path relative to locale base directory.
315
+ *
316
+ * Examples:
317
+ * - filePath: /project/public/i18n/en/auth/login.json, localeDir: /project/public/i18n/en
318
+ * → namespace: 'auth.login'
319
+ * - filePath: /project/public/i18n/en/common.json, localeDir: /project/public/i18n/en
320
+ * → namespace: 'common'
321
+ * - filePath: /project/public/i18n/en/settings/index.json, localeDir: /project/public/i18n/en
322
+ * → namespace: 'settings' (index is stripped)
323
+ */
324
+ export function getNamespaceFromPath(filePath: string, localeDir: string): string {
325
+ // Get relative path from locale directory
326
+ const relative = path.relative(localeDir, filePath);
327
+
328
+ // Remove .json extension
329
+ const withoutExt = relative.replace(/\.json$/i, '');
330
+
331
+ // Convert path separators to dots
332
+ const namespace = withoutExt.replace(/[\\/]/g, '.');
333
+
334
+ // Remove trailing .index (index.json files represent the folder itself)
335
+ return namespace.replace(/\.index$/, '');
336
+ }
337
+
338
+ /**
339
+ * Wrap a translation object with its namespace.
340
+ *
341
+ * Example:
342
+ * - namespace: 'auth.login'
343
+ * - content: { title: 'Welcome', subtitle: 'Login here' }
344
+ * - result: { auth: { login: { title: 'Welcome', subtitle: 'Login here' } } }
345
+ */
346
+ export function wrapWithNamespace(
347
+ namespace: string,
348
+ content: Record<string, unknown>
349
+ ): Record<string, unknown> {
350
+ if (!namespace) return content;
351
+
352
+ const parts = namespace.split('.');
353
+ let result: Record<string, unknown> = content;
354
+
355
+ // Build from inside out
356
+ for (let i = parts.length - 1; i >= 0; i--) {
357
+ result = { [parts[i]]: result };
358
+ }
359
+
360
+ return result;
361
+ }
362
+
363
+ /**
364
+ * Generate code that wraps imported content with namespace at runtime.
365
+ * Used in virtual module generation.
366
+ */
367
+ export function generateNamespaceWrapperCode(): string {
368
+ return `
369
+ function __wrapWithNamespace(namespace, content) {
370
+ if (!namespace) return content;
371
+ const parts = namespace.split('.');
372
+ let result = content;
373
+ for (let i = parts.length - 1; i >= 0; i--) {
374
+ result = { [parts[i]]: result };
375
+ }
376
+ return result;
377
+ }`;
378
+ }
@@ -8,6 +8,8 @@ import {
8
8
  saveCache,
9
9
  isCacheValid,
10
10
  detectPathType,
11
+ getNamespaceFromPath,
12
+ generateNamespaceWrapperCode,
11
13
  } from './utils/translations';
12
14
  import * as path from 'node:path';
13
15
 
@@ -21,6 +23,8 @@ interface TranslationInfo {
21
23
  files: string[];
22
24
  /** Glob pattern for dev mode HMR (if applicable) */
23
25
  globPattern?: string;
26
+ /** Base directory for this locale (used for namespace calculation) */
27
+ localeBaseDir?: string;
24
28
  }
25
29
 
26
30
  /**
@@ -44,23 +48,45 @@ export function vitePlugin(config: EzI18nConfig): Plugin {
44
48
  async buildStart() {
45
49
  const projectRoot = viteConfig.root;
46
50
 
51
+ // Determine if path-based namespacing should be enabled
52
+ // Default to true when using folder-based or auto-discovery config
53
+ const isAutoDiscovery = !config.translations || typeof config.translations === 'string';
54
+ const pathBasedNamespacing = config.pathBasedNamespacing ?? isAutoDiscovery;
55
+
56
+ // Calculate base translation directory
57
+ const translationsBaseDir = typeof config.translations === 'string'
58
+ ? path.resolve(projectRoot, config.translations.replace(/\/$/, ''))
59
+ : path.resolve(projectRoot, './public/i18n');
60
+
47
61
  // Try to use cache in production builds
48
62
  let useCache = false;
49
63
  if (!isDev) {
50
64
  const cache = loadCache(projectRoot);
51
65
  if (cache && isCacheValid(cache, projectRoot)) {
66
+ // Build locale base dirs
67
+ const localeBaseDirs: Record<string, string> = {};
68
+ for (const locale of Object.keys(cache.discovered)) {
69
+ localeBaseDirs[locale] = path.join(translationsBaseDir, locale);
70
+ }
71
+
52
72
  // Use cached discovery
53
73
  resolved = {
54
74
  locales: config.locales || Object.keys(cache.discovered),
55
75
  defaultLocale: config.defaultLocale,
56
76
  cookieName: config.cookieName ?? 'ez-locale',
57
77
  translations: cache.discovered,
78
+ pathBasedNamespacing,
79
+ localeBaseDirs,
58
80
  };
59
81
  useCache = true;
60
82
 
61
83
  // Populate translationInfo from cache
62
84
  for (const [locale, files] of Object.entries(cache.discovered)) {
63
- translationInfo.set(locale, { locale, files });
85
+ translationInfo.set(locale, {
86
+ locale,
87
+ files,
88
+ localeBaseDir: localeBaseDirs[locale],
89
+ });
64
90
  }
65
91
  }
66
92
  }
@@ -78,17 +104,52 @@ export function vitePlugin(config: EzI18nConfig): Plugin {
78
104
  ? config.locales
79
105
  : locales;
80
106
 
107
+ // Build locale base dirs
108
+ const localeBaseDirs: Record<string, string> = {};
109
+ for (const locale of finalLocales) {
110
+ if (isAutoDiscovery) {
111
+ // For auto-discovery, locale folder is under the base dir
112
+ localeBaseDirs[locale] = path.join(translationsBaseDir, locale);
113
+ } else if (typeof config.translations === 'object' && config.translations[locale]) {
114
+ // For explicit config, determine base from the config value
115
+ const localeConfig = config.translations[locale];
116
+ if (typeof localeConfig === 'string') {
117
+ const pathType = detectPathType(localeConfig);
118
+ if (pathType === 'folder' || pathType === 'file') {
119
+ // Use the folder itself or parent of file
120
+ const resolved = path.resolve(projectRoot, localeConfig.replace(/\/$/, ''));
121
+ localeBaseDirs[locale] = pathType === 'folder' ? resolved : path.dirname(resolved);
122
+ } else {
123
+ // Glob - use the non-glob prefix
124
+ const baseDir = localeConfig.split('*')[0].replace(/\/$/, '');
125
+ localeBaseDirs[locale] = path.resolve(projectRoot, baseDir);
126
+ }
127
+ } else {
128
+ // Array - use common parent directory
129
+ localeBaseDirs[locale] = translationsBaseDir;
130
+ }
131
+ } else {
132
+ localeBaseDirs[locale] = translationsBaseDir;
133
+ }
134
+ }
135
+
81
136
  resolved = {
82
137
  locales: finalLocales,
83
138
  defaultLocale: config.defaultLocale,
84
139
  cookieName: config.cookieName ?? 'ez-locale',
85
140
  translations,
141
+ pathBasedNamespacing,
142
+ localeBaseDirs,
86
143
  };
87
144
 
88
145
  // Build translation info for each locale
89
146
  for (const locale of finalLocales) {
90
147
  const files = translations[locale] || [];
91
- const info: TranslationInfo = { locale, files };
148
+ const info: TranslationInfo = {
149
+ locale,
150
+ files,
151
+ localeBaseDir: localeBaseDirs[locale],
152
+ };
92
153
 
93
154
  // For dev mode, determine if we can use import.meta.glob
94
155
  if (isDev && config.translations) {
@@ -197,8 +258,8 @@ export function t(key, params) {
197
258
  // ez-i18n:translations - Translation loaders
198
259
  if (id === RESOLVED_PREFIX + VIRTUAL_TRANSLATIONS) {
199
260
  return isDev
200
- ? generateDevTranslationsModule(translationInfo, viteConfig.root)
201
- : generateBuildTranslationsModule(translationInfo, viteConfig.root);
261
+ ? generateDevTranslationsModule(translationInfo, viteConfig.root, resolved.pathBasedNamespacing)
262
+ : generateBuildTranslationsModule(translationInfo, viteConfig.root, resolved.pathBasedNamespacing);
202
263
  }
203
264
 
204
265
  return null;
@@ -276,7 +337,8 @@ export function t(key, params) {
276
337
  */
277
338
  function generateDevTranslationsModule(
278
339
  translationInfo: Map<string, TranslationInfo>,
279
- projectRoot: string
340
+ projectRoot: string,
341
+ pathBasedNamespacing: boolean
280
342
  ): string {
281
343
  const imports: string[] = [];
282
344
  const loaderEntries: string[] = [];
@@ -284,12 +346,37 @@ function generateDevTranslationsModule(
284
346
  // Add deepMerge inline for runtime merging
285
347
  imports.push(getDeepMergeCode());
286
348
 
349
+ // Add namespace wrapper if needed
350
+ if (pathBasedNamespacing) {
351
+ imports.push(generateNamespaceWrapperCode());
352
+ }
353
+
287
354
  for (const [locale, info] of translationInfo) {
288
355
  if (info.files.length === 0) {
289
356
  // No files - return empty object
290
357
  loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
358
+ } else if (info.globPattern && pathBasedNamespacing && info.localeBaseDir) {
359
+ // Use import.meta.glob with namespace wrapping
360
+ const varName = `__${locale}Modules`;
361
+ const localeBaseDir = info.localeBaseDir.replace(/\\/g, '/');
362
+ imports.push(
363
+ `const ${varName} = import.meta.glob(${JSON.stringify(info.globPattern)}, { eager: true, import: 'default' });`
364
+ );
365
+
366
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
367
+ const entries = Object.entries(${varName});
368
+ if (entries.length === 0) return {};
369
+ const localeBaseDir = ${JSON.stringify(localeBaseDir)};
370
+ const wrapped = entries.map(([filePath, content]) => {
371
+ // Extract relative path from locale base dir
372
+ const relativePath = filePath.replace(localeBaseDir + '/', '').replace(/\\.json$/i, '');
373
+ const namespace = relativePath.replace(/[\\/]/g, '.').replace(/\\.index$/, '');
374
+ return __wrapWithNamespace(namespace, content);
375
+ });
376
+ return __deepMerge({}, ...wrapped);
377
+ }`);
291
378
  } else if (info.globPattern) {
292
- // Use import.meta.glob for HMR support
379
+ // Use import.meta.glob without namespace wrapping
293
380
  const varName = `__${locale}Modules`;
294
381
  imports.push(
295
382
  `const ${varName} = import.meta.glob(${JSON.stringify(info.globPattern)}, { eager: true, import: 'default' });`
@@ -302,23 +389,45 @@ function generateDevTranslationsModule(
302
389
  return __deepMerge({}, ...modules);
303
390
  }`);
304
391
  } else if (info.files.length === 1) {
305
- // Single file - simple dynamic import
392
+ // Single file
306
393
  const relativePath = toRelativeImport(info.files[0], projectRoot);
307
- loaderEntries.push(
308
- ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
309
- );
394
+ if (pathBasedNamespacing && info.localeBaseDir) {
395
+ const namespace = getNamespaceFromPath(info.files[0], info.localeBaseDir);
396
+ loaderEntries.push(
397
+ ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => __wrapWithNamespace(${JSON.stringify(namespace)}, m.default ?? m))`
398
+ );
399
+ } else {
400
+ loaderEntries.push(
401
+ ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
402
+ );
403
+ }
310
404
  } else {
311
405
  // Multiple explicit files - import all and merge
312
- const fileImports = info.files
313
- .map(f => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`)
314
- .join(', ');
406
+ if (pathBasedNamespacing && info.localeBaseDir) {
407
+ const fileEntries = info.files.map(f => {
408
+ const relativePath = toRelativeImport(f, projectRoot);
409
+ const namespace = getNamespaceFromPath(f, info.localeBaseDir!);
410
+ return `{ path: ${JSON.stringify(relativePath)}, namespace: ${JSON.stringify(namespace)} }`;
411
+ });
412
+
413
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
414
+ const fileInfos = [${fileEntries.join(', ')}];
415
+ const modules = await Promise.all(fileInfos.map(f => import(f.path)));
416
+ const wrapped = modules.map((m, i) => __wrapWithNamespace(fileInfos[i].namespace, m.default ?? m));
417
+ return __deepMerge({}, ...wrapped);
418
+ }`);
419
+ } else {
420
+ const fileImports = info.files
421
+ .map(f => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`)
422
+ .join(', ');
315
423
 
316
- loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
424
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
317
425
  const modules = await Promise.all([${fileImports}]);
318
426
  const contents = modules.map(m => m.default ?? m);
319
427
  if (contents.length === 1) return contents[0];
320
428
  return __deepMerge({}, ...contents);
321
429
  }`);
430
+ }
322
431
  }
323
432
  }
324
433
 
@@ -356,39 +465,69 @@ export async function loadTranslations(locale) {
356
465
  */
357
466
  function generateBuildTranslationsModule(
358
467
  translationInfo: Map<string, TranslationInfo>,
359
- projectRoot: string
468
+ projectRoot: string,
469
+ pathBasedNamespacing: boolean
360
470
  ): string {
361
471
  const loaderEntries: string[] = [];
362
472
  let needsDeepMerge = false;
473
+ let needsNamespaceWrapper = false;
363
474
 
364
475
  for (const [locale, info] of translationInfo) {
365
476
  if (info.files.length === 0) {
366
477
  loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
367
478
  } else if (info.files.length === 1) {
368
- // Single file - simple dynamic import
479
+ // Single file
369
480
  const relativePath = toRelativeImport(info.files[0], projectRoot);
370
- loaderEntries.push(
371
- ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
372
- );
481
+ if (pathBasedNamespacing && info.localeBaseDir) {
482
+ needsNamespaceWrapper = true;
483
+ const namespace = getNamespaceFromPath(info.files[0], info.localeBaseDir);
484
+ loaderEntries.push(
485
+ ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => __wrapWithNamespace(${JSON.stringify(namespace)}, m.default ?? m))`
486
+ );
487
+ } else {
488
+ loaderEntries.push(
489
+ ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
490
+ );
491
+ }
373
492
  } else {
374
493
  // Multiple files - import and merge
375
494
  needsDeepMerge = true;
376
- const fileImports = info.files
377
- .map(f => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`)
378
- .join(', ');
379
495
 
380
- loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
496
+ if (pathBasedNamespacing && info.localeBaseDir) {
497
+ needsNamespaceWrapper = true;
498
+ const fileEntries = info.files.map(f => {
499
+ const relativePath = toRelativeImport(f, projectRoot);
500
+ const namespace = getNamespaceFromPath(f, info.localeBaseDir!);
501
+ return `{ path: ${JSON.stringify(relativePath)}, namespace: ${JSON.stringify(namespace)} }`;
502
+ });
503
+
504
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
505
+ const fileInfos = [${fileEntries.join(', ')}];
506
+ const modules = await Promise.all(fileInfos.map(f => import(f.path)));
507
+ const wrapped = modules.map((m, i) => __wrapWithNamespace(fileInfos[i].namespace, m.default ?? m));
508
+ return __deepMerge({}, ...wrapped);
509
+ }`);
510
+ } else {
511
+ const fileImports = info.files
512
+ .map(f => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`)
513
+ .join(', ');
514
+
515
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
381
516
  const modules = await Promise.all([${fileImports}]);
382
517
  const contents = modules.map(m => m.default ?? m);
383
518
  return __deepMerge({}, ...contents);
384
519
  }`);
520
+ }
385
521
  }
386
522
  }
387
523
 
388
- const deepMergeCode = needsDeepMerge ? getDeepMergeCode() : '';
524
+ const helperCode = [
525
+ needsDeepMerge ? getDeepMergeCode() : '',
526
+ needsNamespaceWrapper ? generateNamespaceWrapperCode() : '',
527
+ ].filter(Boolean).join('\n');
389
528
 
390
529
  return `
391
- ${deepMergeCode}
530
+ ${helperCode}
392
531
 
393
532
  export const translationLoaders = {
394
533
  ${loaderEntries.join(',\n')}
@@ -437,10 +576,13 @@ function __deepMerge(target, ...sources) {
437
576
  // Re-export resolveConfig for backwards compatibility
438
577
  export function resolveConfig(config: EzI18nConfig): ResolvedEzI18nConfig {
439
578
  // This is now a simplified version - full resolution happens in buildStart
579
+ const isAutoDiscovery = !config.translations || typeof config.translations === 'string';
440
580
  return {
441
581
  locales: config.locales || [],
442
582
  defaultLocale: config.defaultLocale,
443
583
  cookieName: config.cookieName ?? 'ez-locale',
444
584
  translations: {},
585
+ pathBasedNamespacing: config.pathBasedNamespacing ?? isAutoDiscovery,
586
+ localeBaseDirs: {},
445
587
  };
446
588
  }