nuxt-i18n-micro 3.26.0 → 3.28.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.
@@ -10,10 +10,19 @@ export interface RouteTranslationContext {
10
10
  }
11
11
  export type GetRouteTranslationContext = (route?: unknown) => RouteTranslationContext;
12
12
  export type LoadTranslationsChunk = (locale: string, routeName?: string) => Promise<Record<string, unknown>>;
13
+ /** Why the active translation context changed — used by Vue DevTools timeline. */
14
+ export type I18nContextChangeReason = 'load' | 'refresh';
13
15
  export interface NuxtI18nOptions extends BaseI18nOptions {
16
+ /**
17
+ * Cap for `storage.translations` chunk entries (`locale:route`).
18
+ * `0` means no limit. Same module option as the fetch/server `CacheControl` layers.
19
+ * @default 0
20
+ */
21
+ cacheMaxSize?: number;
14
22
  }
15
23
  export declare class NuxtI18n extends BaseI18n {
16
24
  readonly storage: TranslationStorage;
25
+ private readonly cacheMaxSize;
17
26
  private readonly contextSignal;
18
27
  private cachedTranslations;
19
28
  /**
@@ -26,6 +35,8 @@ export declare class NuxtI18n extends BaseI18n {
26
35
  private currentLocale;
27
36
  private currentRouteName;
28
37
  private resolveRouteContext?;
38
+ private readonly contextListeners;
39
+ private missingKeyListener?;
29
40
  constructor(options?: NuxtI18nOptions);
30
41
  setRouteContextResolver(resolver: GetRouteTranslationContext): void;
31
42
  getCacheKey(locale: string, routeName?: string): string;
@@ -43,11 +54,22 @@ export declare class NuxtI18n extends BaseI18n {
43
54
  seedChunks(chunks: Record<string, Record<string, unknown>>): void;
44
55
  hasChunk(locale: string, routeName?: string): boolean;
45
56
  mergeChunk(locale: string, routeName: string | undefined, data: Record<string, unknown>): Record<string, unknown>;
57
+ /**
58
+ * FIFO eviction for `storage.translations` when `cacheMaxSize` is set.
59
+ * Prefers keeping the just-written key and the active page chunk. When those
60
+ * two differ and `max` cannot hold both (e.g. `cacheMaxSize: 1`), the just-written
61
+ * key wins so the configured bound is always enforced. Active `$t` still resolves
62
+ * via `cachedTranslations` / `outgoingTranslations` even if the Map drops the old key.
63
+ */
64
+ private evictChunks;
46
65
  getLocale(): string;
47
66
  getFallbackLocale(): string;
48
67
  getRoute(): string;
49
68
  getCurrentLocale(): string;
50
69
  getCurrentRouteName(): string;
70
+ onContextChange(listener: (reason: I18nContextChangeReason) => void): () => void;
71
+ onMissingKey(listener: (locale: string, key: string, routeName: string) => void): () => void;
72
+ private notifyContextChange;
51
73
  /**
52
74
  * Hot-reload the active dictionary for the next route — replace, don't merge.
53
75
  *
@@ -82,11 +104,11 @@ export declare class NuxtI18n extends BaseI18n {
82
104
  * first makes the change vanish on the next navigation.
83
105
  */
84
106
  setTranslation(key: TranslationKey, value: unknown): void;
85
- protected getMissingContext(_routeContext?: unknown): {
107
+ protected getMissingContext(routeContext?: unknown): {
86
108
  locale: string;
87
109
  routeName: string;
88
110
  };
89
- protected warnMissing(key: TranslationKey): void;
111
+ protected warnMissing(key: TranslationKey, routeContext?: unknown): void;
90
112
  clearCache(): void;
91
113
  }
92
114
  export interface NuxtTranslationLoaderOptions {
@@ -32,6 +32,7 @@ function extractSwitchLocalePath(localeRoute, resolveRouter) {
32
32
  }
33
33
  export class NuxtI18n extends BaseI18n {
34
34
  storage;
35
+ cacheMaxSize;
35
36
  contextSignal;
36
37
  cachedTranslations = {};
37
38
  /**
@@ -44,12 +45,16 @@ export class NuxtI18n extends BaseI18n {
44
45
  currentLocale = "";
45
46
  currentRouteName = "";
46
47
  resolveRouteContext;
48
+ contextListeners = /* @__PURE__ */ new Set();
49
+ missingKeyListener;
47
50
  constructor(options = {}) {
51
+ const { cacheMaxSize = 0, ...baseOptions } = options;
48
52
  const storage = {
49
53
  translations: /* @__PURE__ */ new Map()
50
54
  };
51
- super({ ...options, storage });
55
+ super({ ...baseOptions, storage });
52
56
  this.storage = storage;
57
+ this.cacheMaxSize = cacheMaxSize;
53
58
  this.contextSignal = shallowRef(0);
54
59
  }
55
60
  setRouteContextResolver(resolver) {
@@ -62,7 +67,9 @@ export class NuxtI18n extends BaseI18n {
62
67
  return this.storage.translations.get(this.getCacheKey(locale, routeName)) ?? {};
63
68
  }
64
69
  setChunk(locale, routeName, data) {
65
- this.storage.translations.set(this.getCacheKey(locale, routeName), data);
70
+ const key = this.getCacheKey(locale, routeName);
71
+ this.storage.translations.set(key, data);
72
+ this.evictChunks(key);
66
73
  }
67
74
  /**
68
75
  * Install SSR chunks from the payload into the view layer.
@@ -74,11 +81,14 @@ export class NuxtI18n extends BaseI18n {
74
81
  * Existing entries win: anything already loaded is kept as-is.
75
82
  */
76
83
  seedChunks(chunks) {
84
+ let lastKey = "";
77
85
  for (const [cacheKey, data] of Object.entries(chunks)) {
78
86
  if (!this.storage.translations.has(cacheKey)) {
79
87
  this.storage.translations.set(cacheKey, data);
88
+ lastKey = cacheKey;
80
89
  }
81
90
  }
91
+ if (lastKey) this.evictChunks(lastKey);
82
92
  }
83
93
  hasChunk(locale, routeName) {
84
94
  return this.storage.translations.has(this.getCacheKey(locale, routeName));
@@ -88,8 +98,32 @@ export class NuxtI18n extends BaseI18n {
88
98
  const existing = this.storage.translations.get(cacheKey) ?? {};
89
99
  const merged = mergeTranslationChunk(existing, data);
90
100
  this.storage.translations.set(cacheKey, merged);
101
+ this.evictChunks(cacheKey);
91
102
  return merged;
92
103
  }
104
+ /**
105
+ * FIFO eviction for `storage.translations` when `cacheMaxSize` is set.
106
+ * Prefers keeping the just-written key and the active page chunk. When those
107
+ * two differ and `max` cannot hold both (e.g. `cacheMaxSize: 1`), the just-written
108
+ * key wins so the configured bound is always enforced. Active `$t` still resolves
109
+ * via `cachedTranslations` / `outgoingTranslations` even if the Map drops the old key.
110
+ */
111
+ evictChunks(keep) {
112
+ const max = this.cacheMaxSize;
113
+ if (max <= 0 || this.storage.translations.size <= max) return;
114
+ const current = this.getCacheKey(this.currentLocale, this.currentRouteName);
115
+ for (const key of this.storage.translations.keys()) {
116
+ if (this.storage.translations.size <= max) break;
117
+ if (key === keep || key === current) continue;
118
+ this.storage.translations.delete(key);
119
+ }
120
+ if (this.storage.translations.size <= max) return;
121
+ for (const key of this.storage.translations.keys()) {
122
+ if (this.storage.translations.size <= max) break;
123
+ if (key === keep) continue;
124
+ this.storage.translations.delete(key);
125
+ }
126
+ }
93
127
  getLocale() {
94
128
  return this.currentLocale;
95
129
  }
@@ -105,6 +139,25 @@ export class NuxtI18n extends BaseI18n {
105
139
  getCurrentRouteName() {
106
140
  return this.currentRouteName;
107
141
  }
142
+ onContextChange(listener) {
143
+ this.contextListeners.add(listener);
144
+ return () => {
145
+ this.contextListeners.delete(listener);
146
+ };
147
+ }
148
+ onMissingKey(listener) {
149
+ this.missingKeyListener = listener;
150
+ return () => {
151
+ if (this.missingKeyListener === listener) {
152
+ this.missingKeyListener = void 0;
153
+ }
154
+ };
155
+ }
156
+ notifyContextChange(reason) {
157
+ for (const listener of this.contextListeners) {
158
+ listener(reason);
159
+ }
160
+ }
108
161
  /**
109
162
  * Hot-reload the active dictionary for the next route — replace, don't merge.
110
163
  *
@@ -116,29 +169,33 @@ export class NuxtI18n extends BaseI18n {
116
169
  * and correct for the short hot-reload window.
117
170
  */
118
171
  applySwitchContext(locale, routeName, data) {
119
- this.setChunk(locale, routeName, data);
120
172
  this.outgoingTranslations = this.currentLocale === locale ? this.cachedTranslations : null;
121
173
  this.cachedTranslations = data;
122
174
  this.currentLocale = locale;
123
175
  this.currentRouteName = routeName || "";
176
+ this.setChunk(locale, routeName, data);
124
177
  triggerRef(this.contextSignal);
178
+ this.notifyContextChange("load");
125
179
  }
126
180
  /** The outgoing page has unmounted, so its keys must stop resolving. */
127
181
  finishTransition() {
128
182
  if (this.outgoingTranslations === null) return;
129
183
  this.outgoingTranslations = null;
130
184
  triggerRef(this.contextSignal);
185
+ this.notifyContextChange("refresh");
131
186
  }
132
187
  mergeTranslations(newTranslations) {
133
188
  this.mergeChunk(this.currentLocale, this.currentRouteName, newTranslations);
134
189
  this.cachedTranslations = deepMergeTranslationsRecursive(this.cachedTranslations, newTranslations);
135
190
  triggerRef(this.contextSignal);
191
+ this.notifyContextChange("load");
136
192
  }
137
193
  async loadPageTranslations(locale, routeName, translations) {
138
194
  this.mergeChunk(locale, routeName, translations);
139
195
  if (locale === this.currentLocale && routeName === this.currentRouteName) {
140
196
  this.cachedTranslations = deepMergeTranslationsRecursive(this.cachedTranslations, translations);
141
197
  triggerRef(this.contextSignal);
198
+ this.notifyContextChange("load");
142
199
  }
143
200
  }
144
201
  async mergeTranslationAsync(locale, routeName, newTranslations, loadIfMissing) {
@@ -150,6 +207,7 @@ export class NuxtI18n extends BaseI18n {
150
207
  if (locale === this.currentLocale && routeName === this.currentRouteName) {
151
208
  this.cachedTranslations = deepMergeTranslationsRecursive(this.cachedTranslations, newTranslations);
152
209
  triggerRef(this.contextSignal);
210
+ this.notifyContextChange("load");
153
211
  }
154
212
  }
155
213
  tForRoute(route) {
@@ -201,18 +259,24 @@ export class NuxtI18n extends BaseI18n {
201
259
  setTranslationAtKey(this.getChunk(this.currentLocale, this.currentRouteName), String(key), value)
202
260
  );
203
261
  triggerRef(this.contextSignal);
262
+ this.notifyContextChange("refresh");
204
263
  }
205
- getMissingContext(_routeContext) {
264
+ getMissingContext(routeContext) {
265
+ if (routeContext && this.resolveRouteContext) {
266
+ return this.resolveRouteContext(routeContext);
267
+ }
206
268
  return { locale: this.currentLocale, routeName: this.currentRouteName };
207
269
  }
208
- warnMissing(key) {
270
+ warnMissing(key, routeContext) {
271
+ const { locale, routeName } = this.getMissingContext(routeContext);
272
+ this.missingKeyListener?.(locale, String(key), routeName);
209
273
  const customHandler = this.getCustomMissingHandler?.();
210
274
  if (customHandler) {
211
- customHandler(this.currentLocale, String(key), this.currentRouteName);
275
+ customHandler(locale, String(key), routeName);
212
276
  return;
213
277
  }
214
278
  if (this.missingWarn && process.env.NODE_ENV !== "production" && import.meta.client) {
215
- console.warn(`[i18n] Missing key '${key}' in '${this.currentLocale}' for route '${this.currentRouteName}'`);
279
+ console.warn(`[i18n] Missing key '${key}' in '${locale}' for route '${routeName}'`);
216
280
  }
217
281
  }
218
282
  clearCache() {
@@ -220,6 +284,7 @@ export class NuxtI18n extends BaseI18n {
220
284
  this.cachedTranslations = {};
221
285
  this.outgoingTranslations = null;
222
286
  triggerRef(this.contextSignal);
287
+ this.notifyContextChange("refresh");
223
288
  }
224
289
  }
225
290
  export class NuxtTranslationLoader {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-i18n-micro",
3
- "version": "3.26.0",
4
- "description": "Nuxt I18n Micro is a lightweight, high-performance internationalization module for Nuxt, designed to handle multi-language support with minimal overhead, fast build times, and efficient runtime performance.",
3
+ "version": "3.28.0",
4
+ "description": "Fast, lightweight i18n for Nuxt with strategy-based routing and minimal overhead.",
5
5
  "keywords": [
6
6
  "i18n",
7
7
  "internationalization",
@@ -14,7 +14,7 @@
14
14
  "seo",
15
15
  "translation"
16
16
  ],
17
- "homepage": "https://github.com/s00d/nuxt-i18n-micro",
17
+ "homepage": "https://s00d.github.io/nuxt-i18n-micro/",
18
18
  "bugs": {
19
19
  "url": "https://github.com/s00d/nuxt-i18n-micro/issues"
20
20
  },
@@ -27,6 +27,7 @@
27
27
  "repository": "s00d/nuxt-i18n-micro",
28
28
  "workspaces": [
29
29
  "client",
30
+ "docs",
30
31
  "packages/**",
31
32
  "test/fixtures/**/*"
32
33
  ],
@@ -81,38 +82,38 @@
81
82
  "sirv": "^2.0.3",
82
83
  "ufo": "^1.5.4",
83
84
  "unplugin": "^1.16.1 || ^2.0.0 || ^3.3.0",
84
- "@i18n-micro/path-strategy": "^1.3.9",
85
- "@i18n-micro/test-utils": "^1.2.8",
86
- "@i18n-micro/core": "^1.3.11",
87
- "@i18n-micro/hmr": "^1.0.6",
88
- "@i18n-micro/types": "^1.2.10",
89
- "@i18n-micro/utils": "^1.0.13",
90
- "@i18n-micro/route-strategy": "^1.1.14"
85
+ "@i18n-micro/route-strategy": "^1.1.15",
86
+ "@i18n-micro/core": "^1.3.13",
87
+ "@i18n-micro/hmr": "^1.0.7",
88
+ "@i18n-micro/types": "^1.2.11",
89
+ "@i18n-micro/path-strategy": "^1.3.10",
90
+ "@i18n-micro/utils": "^1.0.14",
91
+ "@i18n-micro/test-utils": "^1.2.9"
91
92
  },
92
93
  "devDependencies": {
93
94
  "@nuxt/devtools": "^3.3.1",
94
95
  "@nuxt/devtools-ui-kit": "^3.3.1",
96
+ "@vue/devtools-api": "^8.1.5",
95
97
  "@nuxt/module-builder": "^1.0.0",
96
98
  "@nuxt/schema": "^3.18.1 || ^4.0.0",
97
99
  "@nuxt/test-utils": "^4.0.3",
98
100
  "@playwright/test": "^1.55.0",
99
101
  "@types/node": "^20.19.26",
100
102
  "changelogen": "^0.5.7",
101
- "chart.js": "^4.5.1",
102
- "mermaid": "^11.12.2",
103
103
  "oxfmt": "^0.43.0",
104
104
  "oxlint": "^1.58.0",
105
105
  "typescript": "^5.9.3",
106
- "vitepress": "^2.0.0-alpha.18",
107
- "vitepress-plugin-chartjs": "^1.2.1",
108
- "vitepress-plugin-folder-tree": "^1.2.1",
109
- "vitepress-plugin-llms": "^1.13.3",
110
- "vitepress-plugin-mermaid": "^2.0.17",
111
106
  "vitest": "^4.1.10",
112
107
  "vue-tsc": "^2.0.21"
113
108
  },
114
109
  "peerDependencies": {
115
- "vue-router": "^4.6.3 || ^5.0.0"
110
+ "vue-router": "^4.6.3 || ^5.0.0",
111
+ "@vue/devtools-api": "^8.1.5"
112
+ },
113
+ "peerDependenciesMeta": {
114
+ "@vue/devtools-api": {
115
+ "optional": true
116
+ }
116
117
  },
117
118
  "optionalDependencies": {
118
119
  "@rollup/rollup-linux-x64-gnu": "^4.28.1",
@@ -161,11 +162,11 @@
161
162
  "test:dist:packages": "pnpm --filter \"@i18n-micro/core\" --filter \"@i18n-micro/utils\" --filter \"@i18n-micro/hmr\" --filter \"@i18n-micro/vue\" --filter \"@i18n-micro/path-strategy\" --filter \"@i18n-micro/route-strategy\" --filter \"@i18n-micro/devtools-ui\" --filter \"@i18n-micro/astro\" run test:dist",
162
163
  "typecheck": "tsc --noEmit",
163
164
  "typecheck:nuxt": "nuxt typecheck --no-emit",
164
- "docs:dev": "pnpm run docs:generate && vitepress dev docs",
165
- "docs:build": "pnpm run docs:generate && vitepress build docs",
166
- "docs:serve": "vitepress serve docs",
167
- "docs:clean-dev": "pnpm run docs:generate && vitepress dev docs --clean-cache",
168
- "docs:preview": "vitepress preview docs",
165
+ "docs:dev": "pnpm --filter @i18n-micro/docs dev",
166
+ "docs:build": "pnpm --filter @i18n-micro/docs build",
167
+ "docs:serve": "pnpm --filter @i18n-micro/docs preview",
168
+ "docs:clean-dev": "pnpm --filter @i18n-micro/docs clean-dev",
169
+ "docs:preview": "pnpm --filter @i18n-micro/docs preview",
169
170
  "smoke:pack": "pnpm -C scripts cli smoke-pack",
170
171
  "smoke:verify": "pnpm -C scripts cli smoke-verify",
171
172
  "smoke:browser": "pnpm -C scripts cli smoke-browser",
@@ -1 +0,0 @@
1
- {"id":"24f3c578-f02b-41bc-953a-c7ff7b504d56","timestamp":1785527483273,"prerendered":[]}
@@ -1 +0,0 @@
1
- .grid[data-v-2a91edc4]{display:grid}.mb-2[data-v-2a91edc4]{margin-bottom:.5rem}.mb-4[data-v-2a91edc4]{margin-bottom:1rem}.max-w-520px[data-v-2a91edc4]{max-width:520px}.min-h-screen[data-v-2a91edc4]{min-height:100vh}.w-full[data-v-2a91edc4]{width:100%}.flex[data-v-2a91edc4]{display:flex}.place-content-center[data-v-2a91edc4]{place-content:center}.items-center[data-v-2a91edc4]{align-items:center}.justify-center[data-v-2a91edc4]{justify-content:center}.overflow-hidden[data-v-2a91edc4]{overflow:hidden}.bg-white[data-v-2a91edc4]{--un-bg-opacity:1;background-color:rgb(255 255 255/var(--un-bg-opacity))}.px-2[data-v-2a91edc4]{padding-left:.5rem;padding-right:.5rem}.text-center[data-v-2a91edc4]{text-align:center}.text-\[80px\][data-v-2a91edc4]{font-size:80px}.text-2xl[data-v-2a91edc4]{font-size:1.5rem;line-height:2rem}.text-sm[data-v-2a91edc4]{font-size:.875rem;line-height:1.25rem}.text-\[\#020420\][data-v-2a91edc4]{--un-text-opacity:1;color:rgb(2 4 32/var(--un-text-opacity))}.text-\[\#64748B\][data-v-2a91edc4]{--un-text-opacity:1;color:rgb(100 116 139/var(--un-text-opacity))}.hover\:text-\[\#00DC82\][data-v-2a91edc4]:hover{--un-text-opacity:1;color:rgb(0 220 130/var(--un-text-opacity))}.font-medium[data-v-2a91edc4]{font-weight:500}.font-semibold[data-v-2a91edc4]{font-weight:600}.leading-none[data-v-2a91edc4]{line-height:1}.tracking-wide[data-v-2a91edc4]{letter-spacing:.025em}.font-sans[data-v-2a91edc4]{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.tabular-nums[data-v-2a91edc4]{--un-numeric-spacing:tabular-nums;font-variant-numeric:var(--un-ordinal) var(--un-slashed-zero) var(--un-numeric-figure) var(--un-numeric-spacing) var(--un-numeric-fraction)}.underline[data-v-2a91edc4]{text-decoration-line:underline}.underline-offset-3[data-v-2a91edc4]{text-underline-offset:3px}.antialiased[data-v-2a91edc4]{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}@media (prefers-color-scheme:dark){.dark\:bg-\[\#020420\][data-v-2a91edc4]{--un-bg-opacity:1;background-color:rgb(2 4 32/var(--un-bg-opacity))}.dark\:text-white[data-v-2a91edc4]{--un-text-opacity:1;color:rgb(255 255 255/var(--un-text-opacity))}}@media (width>=640px){.sm\:text-\[110px\][data-v-2a91edc4]{font-size:110px}.sm\:text-3xl[data-v-2a91edc4]{font-size:1.875rem;line-height:2.25rem}}
@@ -1 +0,0 @@
1
- .grid[data-v-f975ae25]{display:grid}.mb-2[data-v-f975ae25]{margin-bottom:.5rem}.mb-4[data-v-f975ae25]{margin-bottom:1rem}.max-w-520px[data-v-f975ae25]{max-width:520px}.min-h-screen[data-v-f975ae25]{min-height:100vh}.place-content-center[data-v-f975ae25]{place-content:center}.overflow-hidden[data-v-f975ae25]{overflow:hidden}.bg-white[data-v-f975ae25]{--un-bg-opacity:1;background-color:rgb(255 255 255/var(--un-bg-opacity))}.px-2[data-v-f975ae25]{padding-left:.5rem;padding-right:.5rem}.text-center[data-v-f975ae25]{text-align:center}.text-\[80px\][data-v-f975ae25]{font-size:80px}.text-2xl[data-v-f975ae25]{font-size:1.5rem;line-height:2rem}.text-\[\#020420\][data-v-f975ae25]{--un-text-opacity:1;color:rgb(2 4 32/var(--un-text-opacity))}.text-\[\#64748B\][data-v-f975ae25]{--un-text-opacity:1;color:rgb(100 116 139/var(--un-text-opacity))}.font-semibold[data-v-f975ae25]{font-weight:600}.leading-none[data-v-f975ae25]{line-height:1}.tracking-wide[data-v-f975ae25]{letter-spacing:.025em}.font-sans[data-v-f975ae25]{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.tabular-nums[data-v-f975ae25]{--un-numeric-spacing:tabular-nums;font-variant-numeric:var(--un-ordinal) var(--un-slashed-zero) var(--un-numeric-figure) var(--un-numeric-spacing) var(--un-numeric-fraction)}.antialiased[data-v-f975ae25]{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}@media (prefers-color-scheme:dark){.dark\:bg-\[\#020420\][data-v-f975ae25]{--un-bg-opacity:1;background-color:rgb(2 4 32/var(--un-bg-opacity))}.dark\:text-white[data-v-f975ae25]{--un-text-opacity:1;color:rgb(255 255 255/var(--un-text-opacity))}}@media (width>=640px){.sm\:text-\[110px\][data-v-f975ae25]{font-size:110px}.sm\:text-3xl[data-v-f975ae25]{font-size:1.875rem;line-height:2.25rem}}