@zachhandley/ez-i18n 0.1.3 → 0.1.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.
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,13 +354,14 @@ 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
  },
302
361
  // HMR support for dev mode
303
362
  handleHotUpdate({ file, server }) {
304
363
  if (!isDev) return;
364
+ if (!file.endsWith(".json")) return;
305
365
  for (const info of translationInfo.values()) {
306
366
  if (info.files.includes(file)) {
307
367
  const mod = server.moduleGraph.getModuleById(RESOLVED_PREFIX + VIRTUAL_TRANSLATIONS);
@@ -350,13 +410,34 @@ export function t(key, params) {
350
410
  }
351
411
  };
352
412
  }
353
- function generateDevTranslationsModule(translationInfo, projectRoot) {
413
+ function generateDevTranslationsModule(translationInfo, projectRoot, pathBasedNamespacing) {
354
414
  const imports = [];
355
415
  const loaderEntries = [];
356
416
  imports.push(getDeepMergeCode());
417
+ if (pathBasedNamespacing) {
418
+ imports.push(generateNamespaceWrapperCode());
419
+ }
357
420
  for (const [locale, info] of translationInfo) {
358
421
  if (info.files.length === 0) {
359
422
  loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
423
+ } else if (info.globPattern && pathBasedNamespacing && info.localeBaseDir) {
424
+ const varName = `__${locale}Modules`;
425
+ const localeBaseDir = info.localeBaseDir.replace(/\\/g, "/");
426
+ imports.push(
427
+ `const ${varName} = import.meta.glob(${JSON.stringify(info.globPattern)}, { eager: true, import: 'default' });`
428
+ );
429
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
430
+ const entries = Object.entries(${varName});
431
+ if (entries.length === 0) return {};
432
+ const localeBaseDir = ${JSON.stringify(localeBaseDir)};
433
+ const wrapped = entries.map(([filePath, content]) => {
434
+ // Extract relative path from locale base dir
435
+ const relativePath = filePath.replace(localeBaseDir + '/', '').replace(/\\.json$/i, '');
436
+ const namespace = relativePath.replace(/[\\/]/g, '.').replace(/\\.index$/, '');
437
+ return __wrapWithNamespace(namespace, content);
438
+ });
439
+ return __deepMerge({}, ...wrapped);
440
+ }`);
360
441
  } else if (info.globPattern) {
361
442
  const varName = `__${locale}Modules`;
362
443
  imports.push(
@@ -370,17 +451,38 @@ function generateDevTranslationsModule(translationInfo, projectRoot) {
370
451
  }`);
371
452
  } else if (info.files.length === 1) {
372
453
  const relativePath = toRelativeImport(info.files[0], projectRoot);
373
- loaderEntries.push(
374
- ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
375
- );
454
+ if (pathBasedNamespacing && info.localeBaseDir) {
455
+ const namespace = getNamespaceFromPath(info.files[0], info.localeBaseDir);
456
+ loaderEntries.push(
457
+ ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => __wrapWithNamespace(${JSON.stringify(namespace)}, m.default ?? m))`
458
+ );
459
+ } else {
460
+ loaderEntries.push(
461
+ ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
462
+ );
463
+ }
376
464
  } else {
377
- const fileImports = info.files.map((f) => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`).join(", ");
378
- loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
465
+ if (pathBasedNamespacing && info.localeBaseDir) {
466
+ const fileEntries = info.files.map((f) => {
467
+ const relativePath = toRelativeImport(f, projectRoot);
468
+ const namespace = getNamespaceFromPath(f, info.localeBaseDir);
469
+ return `{ path: ${JSON.stringify(relativePath)}, namespace: ${JSON.stringify(namespace)} }`;
470
+ });
471
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
472
+ const fileInfos = [${fileEntries.join(", ")}];
473
+ const modules = await Promise.all(fileInfos.map(f => import(f.path)));
474
+ const wrapped = modules.map((m, i) => __wrapWithNamespace(fileInfos[i].namespace, m.default ?? m));
475
+ return __deepMerge({}, ...wrapped);
476
+ }`);
477
+ } else {
478
+ const fileImports = info.files.map((f) => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`).join(", ");
479
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
379
480
  const modules = await Promise.all([${fileImports}]);
380
481
  const contents = modules.map(m => m.default ?? m);
381
482
  if (contents.length === 1) return contents[0];
382
483
  return __deepMerge({}, ...contents);
383
484
  }`);
485
+ }
384
486
  }
385
487
  }
386
488
  return `
@@ -410,30 +512,57 @@ export async function loadTranslations(locale) {
410
512
  }
411
513
  `;
412
514
  }
413
- function generateBuildTranslationsModule(translationInfo, projectRoot) {
515
+ function generateBuildTranslationsModule(translationInfo, projectRoot, pathBasedNamespacing) {
414
516
  const loaderEntries = [];
415
517
  let needsDeepMerge = false;
518
+ let needsNamespaceWrapper = false;
416
519
  for (const [locale, info] of translationInfo) {
417
520
  if (info.files.length === 0) {
418
521
  loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
419
522
  } else if (info.files.length === 1) {
420
523
  const relativePath = toRelativeImport(info.files[0], projectRoot);
421
- loaderEntries.push(
422
- ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
423
- );
524
+ if (pathBasedNamespacing && info.localeBaseDir) {
525
+ needsNamespaceWrapper = true;
526
+ const namespace = getNamespaceFromPath(info.files[0], info.localeBaseDir);
527
+ loaderEntries.push(
528
+ ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => __wrapWithNamespace(${JSON.stringify(namespace)}, m.default ?? m))`
529
+ );
530
+ } else {
531
+ loaderEntries.push(
532
+ ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
533
+ );
534
+ }
424
535
  } else {
425
536
  needsDeepMerge = true;
426
- const fileImports = info.files.map((f) => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`).join(", ");
427
- loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
537
+ if (pathBasedNamespacing && info.localeBaseDir) {
538
+ needsNamespaceWrapper = true;
539
+ const fileEntries = info.files.map((f) => {
540
+ const relativePath = toRelativeImport(f, projectRoot);
541
+ const namespace = getNamespaceFromPath(f, info.localeBaseDir);
542
+ return `{ path: ${JSON.stringify(relativePath)}, namespace: ${JSON.stringify(namespace)} }`;
543
+ });
544
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
545
+ const fileInfos = [${fileEntries.join(", ")}];
546
+ const modules = await Promise.all(fileInfos.map(f => import(f.path)));
547
+ const wrapped = modules.map((m, i) => __wrapWithNamespace(fileInfos[i].namespace, m.default ?? m));
548
+ return __deepMerge({}, ...wrapped);
549
+ }`);
550
+ } else {
551
+ const fileImports = info.files.map((f) => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`).join(", ");
552
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
428
553
  const modules = await Promise.all([${fileImports}]);
429
554
  const contents = modules.map(m => m.default ?? m);
430
555
  return __deepMerge({}, ...contents);
431
556
  }`);
557
+ }
432
558
  }
433
559
  }
434
- const deepMergeCode = needsDeepMerge ? getDeepMergeCode() : "";
560
+ const helperCode = [
561
+ needsDeepMerge ? getDeepMergeCode() : "",
562
+ needsNamespaceWrapper ? generateNamespaceWrapperCode() : ""
563
+ ].filter(Boolean).join("\n");
435
564
  return `
436
- ${deepMergeCode}
565
+ ${helperCode}
437
566
 
438
567
  export const translationLoaders = {
439
568
  ${loaderEntries.join(",\n")}
@@ -475,11 +604,14 @@ function __deepMerge(target, ...sources) {
475
604
  }`;
476
605
  }
477
606
  function resolveConfig(config) {
607
+ const isAutoDiscovery = !config.translations || typeof config.translations === "string";
478
608
  return {
479
609
  locales: config.locales || [],
480
610
  defaultLocale: config.defaultLocale,
481
611
  cookieName: config.cookieName ?? "ez-locale",
482
- translations: {}
612
+ translations: {},
613
+ pathBasedNamespacing: config.pathBasedNamespacing ?? isAutoDiscovery,
614
+ localeBaseDirs: {}
483
615
  };
484
616
  }
485
617
 
@@ -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.6",
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;
@@ -208,6 +269,9 @@ export function t(key, params) {
208
269
  handleHotUpdate({ file, server }) {
209
270
  if (!isDev) return;
210
271
 
272
+ // Only process JSON files
273
+ if (!file.endsWith('.json')) return;
274
+
211
275
  // Check if the changed file is a translation file
212
276
  for (const info of translationInfo.values()) {
213
277
  if (info.files.includes(file)) {
@@ -276,7 +340,8 @@ export function t(key, params) {
276
340
  */
277
341
  function generateDevTranslationsModule(
278
342
  translationInfo: Map<string, TranslationInfo>,
279
- projectRoot: string
343
+ projectRoot: string,
344
+ pathBasedNamespacing: boolean
280
345
  ): string {
281
346
  const imports: string[] = [];
282
347
  const loaderEntries: string[] = [];
@@ -284,12 +349,37 @@ function generateDevTranslationsModule(
284
349
  // Add deepMerge inline for runtime merging
285
350
  imports.push(getDeepMergeCode());
286
351
 
352
+ // Add namespace wrapper if needed
353
+ if (pathBasedNamespacing) {
354
+ imports.push(generateNamespaceWrapperCode());
355
+ }
356
+
287
357
  for (const [locale, info] of translationInfo) {
288
358
  if (info.files.length === 0) {
289
359
  // No files - return empty object
290
360
  loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
361
+ } else if (info.globPattern && pathBasedNamespacing && info.localeBaseDir) {
362
+ // Use import.meta.glob with namespace wrapping
363
+ const varName = `__${locale}Modules`;
364
+ const localeBaseDir = info.localeBaseDir.replace(/\\/g, '/');
365
+ imports.push(
366
+ `const ${varName} = import.meta.glob(${JSON.stringify(info.globPattern)}, { eager: true, import: 'default' });`
367
+ );
368
+
369
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
370
+ const entries = Object.entries(${varName});
371
+ if (entries.length === 0) return {};
372
+ const localeBaseDir = ${JSON.stringify(localeBaseDir)};
373
+ const wrapped = entries.map(([filePath, content]) => {
374
+ // Extract relative path from locale base dir
375
+ const relativePath = filePath.replace(localeBaseDir + '/', '').replace(/\\.json$/i, '');
376
+ const namespace = relativePath.replace(/[\\/]/g, '.').replace(/\\.index$/, '');
377
+ return __wrapWithNamespace(namespace, content);
378
+ });
379
+ return __deepMerge({}, ...wrapped);
380
+ }`);
291
381
  } else if (info.globPattern) {
292
- // Use import.meta.glob for HMR support
382
+ // Use import.meta.glob without namespace wrapping
293
383
  const varName = `__${locale}Modules`;
294
384
  imports.push(
295
385
  `const ${varName} = import.meta.glob(${JSON.stringify(info.globPattern)}, { eager: true, import: 'default' });`
@@ -302,23 +392,45 @@ function generateDevTranslationsModule(
302
392
  return __deepMerge({}, ...modules);
303
393
  }`);
304
394
  } else if (info.files.length === 1) {
305
- // Single file - simple dynamic import
395
+ // Single file
306
396
  const relativePath = toRelativeImport(info.files[0], projectRoot);
307
- loaderEntries.push(
308
- ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
309
- );
397
+ if (pathBasedNamespacing && info.localeBaseDir) {
398
+ const namespace = getNamespaceFromPath(info.files[0], info.localeBaseDir);
399
+ loaderEntries.push(
400
+ ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => __wrapWithNamespace(${JSON.stringify(namespace)}, m.default ?? m))`
401
+ );
402
+ } else {
403
+ loaderEntries.push(
404
+ ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
405
+ );
406
+ }
310
407
  } else {
311
408
  // Multiple explicit files - import all and merge
312
- const fileImports = info.files
313
- .map(f => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`)
314
- .join(', ');
409
+ if (pathBasedNamespacing && info.localeBaseDir) {
410
+ const fileEntries = info.files.map(f => {
411
+ const relativePath = toRelativeImport(f, projectRoot);
412
+ const namespace = getNamespaceFromPath(f, info.localeBaseDir!);
413
+ return `{ path: ${JSON.stringify(relativePath)}, namespace: ${JSON.stringify(namespace)} }`;
414
+ });
415
+
416
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
417
+ const fileInfos = [${fileEntries.join(', ')}];
418
+ const modules = await Promise.all(fileInfos.map(f => import(f.path)));
419
+ const wrapped = modules.map((m, i) => __wrapWithNamespace(fileInfos[i].namespace, m.default ?? m));
420
+ return __deepMerge({}, ...wrapped);
421
+ }`);
422
+ } else {
423
+ const fileImports = info.files
424
+ .map(f => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`)
425
+ .join(', ');
315
426
 
316
- loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
427
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
317
428
  const modules = await Promise.all([${fileImports}]);
318
429
  const contents = modules.map(m => m.default ?? m);
319
430
  if (contents.length === 1) return contents[0];
320
431
  return __deepMerge({}, ...contents);
321
432
  }`);
433
+ }
322
434
  }
323
435
  }
324
436
 
@@ -356,39 +468,69 @@ export async function loadTranslations(locale) {
356
468
  */
357
469
  function generateBuildTranslationsModule(
358
470
  translationInfo: Map<string, TranslationInfo>,
359
- projectRoot: string
471
+ projectRoot: string,
472
+ pathBasedNamespacing: boolean
360
473
  ): string {
361
474
  const loaderEntries: string[] = [];
362
475
  let needsDeepMerge = false;
476
+ let needsNamespaceWrapper = false;
363
477
 
364
478
  for (const [locale, info] of translationInfo) {
365
479
  if (info.files.length === 0) {
366
480
  loaderEntries.push(` ${JSON.stringify(locale)}: async () => ({})`);
367
481
  } else if (info.files.length === 1) {
368
- // Single file - simple dynamic import
482
+ // Single file
369
483
  const relativePath = toRelativeImport(info.files[0], projectRoot);
370
- loaderEntries.push(
371
- ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
372
- );
484
+ if (pathBasedNamespacing && info.localeBaseDir) {
485
+ needsNamespaceWrapper = true;
486
+ const namespace = getNamespaceFromPath(info.files[0], info.localeBaseDir);
487
+ loaderEntries.push(
488
+ ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => __wrapWithNamespace(${JSON.stringify(namespace)}, m.default ?? m))`
489
+ );
490
+ } else {
491
+ loaderEntries.push(
492
+ ` ${JSON.stringify(locale)}: () => import(${JSON.stringify(relativePath)}).then(m => m.default ?? m)`
493
+ );
494
+ }
373
495
  } else {
374
496
  // Multiple files - import and merge
375
497
  needsDeepMerge = true;
376
- const fileImports = info.files
377
- .map(f => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`)
378
- .join(', ');
379
498
 
380
- loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
499
+ if (pathBasedNamespacing && info.localeBaseDir) {
500
+ needsNamespaceWrapper = true;
501
+ const fileEntries = info.files.map(f => {
502
+ const relativePath = toRelativeImport(f, projectRoot);
503
+ const namespace = getNamespaceFromPath(f, info.localeBaseDir!);
504
+ return `{ path: ${JSON.stringify(relativePath)}, namespace: ${JSON.stringify(namespace)} }`;
505
+ });
506
+
507
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
508
+ const fileInfos = [${fileEntries.join(', ')}];
509
+ const modules = await Promise.all(fileInfos.map(f => import(f.path)));
510
+ const wrapped = modules.map((m, i) => __wrapWithNamespace(fileInfos[i].namespace, m.default ?? m));
511
+ return __deepMerge({}, ...wrapped);
512
+ }`);
513
+ } else {
514
+ const fileImports = info.files
515
+ .map(f => `import(${JSON.stringify(toRelativeImport(f, projectRoot))})`)
516
+ .join(', ');
517
+
518
+ loaderEntries.push(` ${JSON.stringify(locale)}: async () => {
381
519
  const modules = await Promise.all([${fileImports}]);
382
520
  const contents = modules.map(m => m.default ?? m);
383
521
  return __deepMerge({}, ...contents);
384
522
  }`);
523
+ }
385
524
  }
386
525
  }
387
526
 
388
- const deepMergeCode = needsDeepMerge ? getDeepMergeCode() : '';
527
+ const helperCode = [
528
+ needsDeepMerge ? getDeepMergeCode() : '',
529
+ needsNamespaceWrapper ? generateNamespaceWrapperCode() : '',
530
+ ].filter(Boolean).join('\n');
389
531
 
390
532
  return `
391
- ${deepMergeCode}
533
+ ${helperCode}
392
534
 
393
535
  export const translationLoaders = {
394
536
  ${loaderEntries.join(',\n')}
@@ -437,10 +579,13 @@ function __deepMerge(target, ...sources) {
437
579
  // Re-export resolveConfig for backwards compatibility
438
580
  export function resolveConfig(config: EzI18nConfig): ResolvedEzI18nConfig {
439
581
  // This is now a simplified version - full resolution happens in buildStart
582
+ const isAutoDiscovery = !config.translations || typeof config.translations === 'string';
440
583
  return {
441
584
  locales: config.locales || [],
442
585
  defaultLocale: config.defaultLocale,
443
586
  cookieName: config.cookieName ?? 'ez-locale',
444
587
  translations: {},
588
+ pathBasedNamespacing: config.pathBasedNamespacing ?? isAutoDiscovery,
589
+ localeBaseDirs: {},
445
590
  };
446
591
  }