nuxt-i18n-micro 3.30.3 → 4.0.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.
Files changed (111) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -2
  3. package/dist/client/200.html +1 -1
  4. package/dist/client/404.html +1 -1
  5. package/dist/client/_nuxt/BVna_KKx.js +3 -0
  6. package/dist/client/_nuxt/Bw81RhAv.js +1 -0
  7. package/dist/client/_nuxt/CPz_jhsD.js +1 -0
  8. package/dist/client/_nuxt/CmvObVGq.js +1 -0
  9. package/dist/client/_nuxt/DKUOBR6F.js +5 -0
  10. package/dist/client/_nuxt/builds/latest.json +1 -1
  11. package/dist/client/_nuxt/builds/meta/f0c8d4bf-40d2-4915-8b4a-ebe46bde61c8.json +1 -0
  12. package/dist/client/_nuxt/{entry.BM-mexAv.css → entry.PSWtHhay.css} +1 -1
  13. package/dist/client/_nuxt/error-404.xs3fxhVJ.css +1 -0
  14. package/dist/client/_nuxt/error-500.C2S-4iLg.css +1 -0
  15. package/dist/client/index.html +1 -1
  16. package/dist/module.d.mts +14 -5
  17. package/dist/module.json +4 -1
  18. package/dist/module.mjs +850 -475
  19. package/dist/runtime/components/i18n-group.d.vue.ts +17 -9
  20. package/dist/runtime/components/i18n-group.vue +2 -4
  21. package/dist/runtime/components/i18n-group.vue.d.ts +17 -9
  22. package/dist/runtime/components/i18n-link.d.vue.ts +9 -6
  23. package/dist/runtime/components/i18n-link.vue +7 -28
  24. package/dist/runtime/components/i18n-link.vue.d.ts +9 -6
  25. package/dist/runtime/components/i18n-switcher.d.vue.ts +128 -54
  26. package/dist/runtime/components/i18n-switcher.vue +56 -156
  27. package/dist/runtime/components/i18n-switcher.vue.d.ts +128 -54
  28. package/dist/runtime/components/i18n-t.d.vue.ts +59 -116
  29. package/dist/runtime/components/i18n-t.vue +24 -71
  30. package/dist/runtime/components/i18n-t.vue.d.ts +59 -116
  31. package/dist/runtime/composables/useI18n.js +2 -38
  32. package/dist/runtime/composables/useI18nHead.js +21 -6
  33. package/dist/runtime/composables/useI18nLocale.d.ts +4 -1
  34. package/dist/runtime/composables/useI18nLocale.js +20 -22
  35. package/dist/runtime/composables/useLocaleHead.d.ts +26 -31
  36. package/dist/runtime/composables/useLocaleHead.js +47 -167
  37. package/dist/runtime/devtools/bridge.d.ts +2 -2
  38. package/dist/runtime/devtools/vue-devtools.d.ts +2 -2
  39. package/dist/runtime/devtools/vue-devtools.js +5 -5
  40. package/dist/runtime/middleware/i18n-redirect.global.d.ts +3 -2
  41. package/dist/runtime/middleware/i18n-redirect.global.js +30 -41
  42. package/dist/runtime/plugins/01.plugin.d.ts +156 -34
  43. package/dist/runtime/plugins/01.plugin.js +184 -138
  44. package/dist/runtime/plugins/02.meta.js +36 -43
  45. package/dist/runtime/plugins/04.vue-devtools.js +1 -1
  46. package/dist/runtime/plugins/06.redirect.d.ts +3 -3
  47. package/dist/runtime/plugins/06.redirect.js +65 -91
  48. package/dist/runtime/server/{utils → internal}/dev-live-translations.js +4 -3
  49. package/dist/runtime/server/internal/server-translation-cache.d.ts +9 -0
  50. package/dist/runtime/server/internal/server-translation-cache.js +10 -0
  51. package/dist/runtime/server/internal/shared-cache.d.ts +24 -0
  52. package/dist/runtime/server/internal/shared-cache.js +71 -0
  53. package/dist/runtime/server/internal/translation-watcher.d.ts +79 -0
  54. package/dist/runtime/server/internal/translation-watcher.js +105 -0
  55. package/dist/runtime/server/middleware/i18n.global.js +24 -37
  56. package/dist/runtime/server/payload-source.assets.d.ts +1 -0
  57. package/dist/runtime/server/payload-source.assets.js +3 -8
  58. package/dist/runtime/server/payload-source.fs.d.ts +1 -0
  59. package/dist/runtime/server/payload-source.fs.js +18 -7
  60. package/dist/runtime/server/plugins/failed-read.d.ts +7 -0
  61. package/dist/runtime/server/plugins/failed-read.js +23 -0
  62. package/dist/runtime/server/plugins/payload-cache.d.ts +6 -0
  63. package/dist/runtime/server/plugins/payload-cache.js +16 -0
  64. package/dist/runtime/server/plugins/watcher.dev.js +15 -79
  65. package/dist/runtime/server/routes/i18n.js +13 -14
  66. package/dist/runtime/server/utils/locale-detector.d.ts +32 -2
  67. package/dist/runtime/server/utils/locale-detector.js +48 -27
  68. package/dist/runtime/server/utils/locale-server-middleware.js +14 -33
  69. package/dist/runtime/server/utils/payload-cache.d.ts +4 -0
  70. package/dist/runtime/server/utils/payload-cache.js +13 -0
  71. package/dist/runtime/server/utils/server-loader.d.ts +17 -6
  72. package/dist/runtime/server/utils/server-loader.js +105 -51
  73. package/dist/runtime/server/utils/translation-server-middleware.js +18 -35
  74. package/dist/runtime/utils/cache-keys.d.ts +6 -0
  75. package/dist/runtime/utils/cache-keys.js +2 -0
  76. package/dist/runtime/utils/i18n-api.d.ts +185 -0
  77. package/dist/runtime/utils/i18n-api.js +196 -0
  78. package/dist/runtime/utils/i18n-config.d.ts +18 -0
  79. package/dist/runtime/utils/i18n-config.js +47 -0
  80. package/dist/runtime/utils/i18n-navigation.d.ts +26 -0
  81. package/dist/runtime/utils/i18n-navigation.js +96 -0
  82. package/dist/runtime/utils/nuxt-i18n-server.d.ts +26 -0
  83. package/dist/runtime/utils/nuxt-i18n-server.js +83 -0
  84. package/dist/runtime/utils/nuxt-i18n.d.ts +81 -96
  85. package/dist/runtime/utils/nuxt-i18n.js +180 -252
  86. package/dist/runtime/utils/page-head.d.ts +12 -0
  87. package/dist/runtime/utils/page-head.js +6 -0
  88. package/dist/runtime/utils/request-url.d.ts +6 -0
  89. package/dist/runtime/utils/request-url.js +11 -0
  90. package/dist/runtime/utils/storage.d.ts +31 -19
  91. package/dist/runtime/utils/storage.js +100 -49
  92. package/dist/runtime/utils/translation-entry.d.ts +6 -0
  93. package/dist/runtime/utils/translation-entry.js +16 -0
  94. package/dist/types.d.mts +4 -6
  95. package/internals.d.mts +9 -2
  96. package/package.json +78 -79
  97. package/dist/client/_nuxt/BOxWElQP.js +0 -21
  98. package/dist/client/_nuxt/CUWB4CwN.js +0 -3
  99. package/dist/client/_nuxt/CmUbe-uv.js +0 -1
  100. package/dist/client/_nuxt/DAzShkZF.js +0 -1
  101. package/dist/client/_nuxt/MokPP5zi.js +0 -1
  102. package/dist/client/_nuxt/builds/meta/60974e48-d00e-4f9b-9467-9d4b322519da.json +0 -1
  103. package/dist/client/_nuxt/error-404.D9Svp6Hk.css +0 -1
  104. package/dist/client/_nuxt/error-500.B-mjI9_9.css +0 -1
  105. package/dist/runtime/plugins/03.define.d.ts +0 -7
  106. package/dist/runtime/plugins/03.define.js +0 -42
  107. package/dist/runtime/plugins/05.hooks.d.ts +0 -2
  108. package/dist/runtime/plugins/05.hooks.js +0 -47
  109. package/dist/runtime/server/utils/server-translation-cache.d.ts +0 -11
  110. package/dist/runtime/server/utils/server-translation-cache.js +0 -20
  111. /package/dist/runtime/server/{utils → internal}/dev-live-translations.d.ts +0 -0
@@ -1,10 +1,8 @@
1
+ import { trimTrailingSlashes } from "@i18n-micro/utils/app-path";
1
2
  import { mergeI18nHead } from "@i18n-micro/utils/merge-i18n-head";
2
- import { isMetaDisabledForRoute } from "@i18n-micro/utils/route";
3
- import { resolveI18nConfigWithRuntimeOverrides } from "@i18n-micro/utils/runtime-config";
4
- import { computed, watch } from "vue";
5
- import { getI18nConfig } from "#build/i18n.strategy.mjs";
6
- import { defineNuxtPlugin, useHead, useRequestURL, useRoute, useState } from "#imports";
7
- import { useI18nHead } from "../composables/useI18nHead.js";
3
+ import { joinURL } from "ufo";
4
+ import { defineNuxtPlugin, useHead, useRequestURL, useRoute, useRuntimeConfig } from "#imports";
5
+ import { usePageHead } from "../utils/page-head.js";
8
6
  import { useLocaleHead } from "../composables/useLocaleHead.js";
9
7
  function readSiteConfigUrl(nuxtApp) {
10
8
  const stack = nuxtApp.$nuxtSiteConfig;
@@ -16,57 +14,52 @@ function readSiteConfigUrl(nuxtApp) {
16
14
  return void 0;
17
15
  }
18
16
  }
17
+ function requestOrigin(i18nConfig) {
18
+ const xForwardedProto = i18nConfig.metaTrustForwardedProto !== false;
19
+ try {
20
+ return useRequestURL({ xForwardedHost: i18nConfig.metaTrustForwardedHost !== false, xForwardedProto }).origin;
21
+ } catch {
22
+ try {
23
+ return useRequestURL({ xForwardedProto }).origin;
24
+ } catch {
25
+ return "";
26
+ }
27
+ }
28
+ }
19
29
  function resolveSeoBaseUrl(i18nConfig, siteUrl) {
20
30
  const configured = i18nConfig.metaBaseUrl || siteUrl;
21
- if (configured) return configured.replace(/\/+$/, "");
22
- const url = useRequestURL({
23
- xForwardedHost: i18nConfig.metaTrustForwardedHost !== false,
24
- xForwardedProto: i18nConfig.metaTrustForwardedProto !== false
25
- });
26
- return url.origin.replace(/\/+$/, "");
31
+ if (configured) return trimTrailingSlashes(configured);
32
+ return trimTrailingSlashes(joinURL(requestOrigin(i18nConfig), useRuntimeConfig().app.baseURL));
27
33
  }
34
+ let warnedCachedRender = false;
28
35
  export default defineNuxtPlugin((nuxtApp) => {
29
36
  const route = useRoute();
30
- const i18nRouteParams = useState("i18n-route-params", () => ({}));
31
- const localeState = useState("i18n-locale", () => null);
32
- const { pageHead } = useI18nHead();
33
- const getRuntimeConfig = nuxtApp.$getI18nConfig;
34
- const i18nConfig = resolveI18nConfigWithRuntimeOverrides(
35
- typeof getRuntimeConfig === "function" ? getRuntimeConfig() : getI18nConfig()
36
- );
37
+ const pageHead = usePageHead();
38
+ const i18nConfig = nuxtApp.$getI18nConfig();
37
39
  const siteUrl = readSiteConfigUrl(nuxtApp);
38
40
  const baseUrl = resolveSeoBaseUrl(i18nConfig, siteUrl);
39
- const { metaObject, updateMeta } = useLocaleHead({
40
- addDirAttribute: true,
41
- identifierAttribute: "id",
42
- addSeoAttributes: true,
41
+ const event = nuxtApp.ssrContext?.event;
42
+ const hostless = event?.context?.cache && !event.node?.req?.headers?.host;
43
+ if (import.meta.server && !warnedCachedRender && hostless && !i18nConfig.metaBaseUrl && !siteUrl) {
44
+ warnedCachedRender = true;
45
+ console.warn(
46
+ "[i18n] A route cached by `swr` or `cache` renders without the request host, so canonical and hreflang URLs point to http://localhost. Set `i18n.metaBaseUrl` or `site.url`."
47
+ );
48
+ }
49
+ const { build } = useLocaleHead({
43
50
  baseUrl,
44
51
  autoUpdate: false
45
52
  });
46
- const mergedHead = computed(() => {
53
+ useHead(() => {
47
54
  const { $getLocales, $getLocale } = nuxtApp;
48
- const allLocales = $getLocales?.() ?? i18nConfig.locales ?? [];
49
- const locale = $getLocale?.() || i18nConfig.defaultLocale || "en";
50
- if (isMetaDisabledForRoute(route, i18nConfig.routeDisableMeta, locale, i18nConfig.localizedRouteNamePrefix || "localized-")) {
51
- return { htmlAttrs: {}, link: [], meta: [] };
52
- }
53
- return mergeI18nHead(metaObject.value, pageHead.value, {
55
+ const allLocales = $getLocales();
56
+ const locale = $getLocale() || i18nConfig.defaultLocale;
57
+ const head = build();
58
+ if (!pageHead.value || nuxtApp.$i18nStrategy.isMetaDisabled(route, locale)) return head;
59
+ return mergeI18nHead(head, pageHead.value, {
54
60
  identifierAttribute: "id",
55
61
  locales: allLocales,
56
62
  currentLocale: locale
57
63
  });
58
64
  });
59
- useHead(mergedHead);
60
- const refreshMeta = () => updateMeta();
61
- if (import.meta.server) {
62
- nuxtApp.hook("app:rendered", refreshMeta);
63
- } else {
64
- refreshMeta();
65
- nuxtApp.hook("page:finish", refreshMeta);
66
- }
67
- watch(() => i18nRouteParams.value, refreshMeta, { deep: true, flush: "post" });
68
- watch(() => [route.fullPath, route.name, route.matched.length, localeState.value], refreshMeta, {
69
- flush: "post"
70
- });
71
- watch(pageHead, refreshMeta, { deep: true, flush: "post" });
72
65
  });
@@ -25,7 +25,7 @@ export default defineNuxtPlugin({
25
25
  return;
26
26
  }
27
27
  const notifyCurrentLoad = () => {
28
- const locale = bridge.i18n.getCurrentLocale();
28
+ const locale = bridge.i18n.getLocale();
29
29
  const routeName = bridge.i18n.getCurrentRouteName() || "index";
30
30
  notifier.notifyLoad(locale, routeName, countLoadedTranslationKeys(bridge.i18n, locale, routeName));
31
31
  };
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Universal redirect plugin for i18n routes (works on both server and client).
3
- * Handles locale detection, 404 checks, and redirects.
4
- * Client SPA redirects are handled by i18n-redirect route middleware.
2
+ * Server-side locale redirects and 404s for i18n routes. Every decision is the strategy's
3
+ * (`shouldReturn404`, `getClientRedirect`); this plugin only reads the request (cookie,
4
+ * Accept-Language) and writes the response. Client SPA redirects: the i18n-redirect middleware.
5
5
  */
6
6
  declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
7
7
  export default _default;
@@ -1,103 +1,77 @@
1
1
  import { getEnabledLocaleCodes } from "@i18n-micro/utils/active-locales";
2
2
  import { isInternalPath, withoutAppBaseURL } from "@i18n-micro/utils/app-path";
3
- import { shouldAttemptLocaleRedirect } from "@i18n-micro/utils/auto-detect-path";
4
- import { getLocaleCookieName, getLocaleCookieOptions } from "@i18n-micro/utils/cookie";
5
- import { resolvePreferredLocale } from "@i18n-micro/utils/resolve-locale";
6
- import { getCookie, getHeader, getRequestURL, setCookie } from "h3";
7
- import { createError, defineNuxtPlugin, navigateTo, useRequestEvent, useRuntimeConfig, useState } from "#imports";
3
+ import { getLocaleCookieName, getLocaleCookieOptions, getPreferenceCookieName } from "@i18n-micro/utils/cookie";
4
+ import { preferenceCounts, redirectLocale } from "@i18n-micro/utils/resolve-locale";
5
+ import { appendResponseHeader, getCookie, getHeader, setCookie } from "h3";
6
+ import { requestURL } from "../utils/request-url.js";
7
+ import { applyTrailingSlash } from "../utils/trailing-slash.js";
8
+ import { createError, defineNuxtPlugin, navigateTo, useRequestEvent, useRouter, useRuntimeConfig, useState } from "#imports";
8
9
  const DEBUG = process.env.NUXT_I18N_DEBUG_REDIRECT === "1";
9
- function resolveI18nStrategy(nuxtApp) {
10
- if (nuxtApp.$i18nStrategy) {
11
- return nuxtApp.$i18nStrategy;
12
- }
13
- throw new Error("[nuxt-i18n-micro] i18n redirect plugin requires the main i18n plugin ($i18nStrategy).");
14
- }
15
10
  export default defineNuxtPlugin({
16
11
  name: "i18n-redirect",
17
12
  dependsOn: ["i18n-plugin-loader"],
13
+ // annotated: the plugin types are inferred from every plugin's setup, this one included
18
14
  setup(nuxtApp) {
19
- const i18nStrategy = resolveI18nStrategy(nuxtApp);
15
+ const i18nStrategy = nuxtApp.$i18nStrategy;
20
16
  const getRuntimeConfig = nuxtApp.$getI18nConfig;
21
- if (typeof getRuntimeConfig !== "function") {
22
- throw new Error("[nuxt-i18n-micro] i18n redirect plugin requires $getI18nConfig from the main i18n plugin.");
23
- }
17
+ const event = useRequestEvent();
18
+ if (!event) return;
24
19
  const i18nConfig = getRuntimeConfig();
25
- const appBaseURL = useRuntimeConfig().app.baseURL;
26
- const validLocales = getEnabledLocaleCodes(i18nConfig.locales);
27
- const defaultLocale = i18nConfig.defaultLocale || "en";
28
- const autoDetectPath = i18nConfig.autoDetectPath || "/";
29
- const cookieName = getLocaleCookieName(i18nConfig);
30
- if (import.meta.server) {
31
- const event = useRequestEvent();
32
- if (!event) return;
33
- const url = getRequestURL(event);
34
- const path = withoutAppBaseURL(url.pathname, appBaseURL);
35
- const performRedirect = (targetUrl, code = 302) => {
36
- return navigateTo(targetUrl, { redirectCode: code });
37
- };
38
- if (isInternalPath(path, i18nConfig.excludePatterns)) return;
39
- const pathSegments = path.replace(/^\//, "").split("/").filter(Boolean);
40
- const firstSegment = pathSegments[0];
41
- const customRegex = i18nConfig.customRegexMatcher;
42
- if (firstSegment && customRegex && !validLocales.includes(firstSegment)) {
43
- const source = typeof customRegex === "string" ? customRegex : customRegex.source;
44
- const regex = new RegExp(`^${source}$`);
45
- if (regex.test(firstSegment)) {
46
- if (DEBUG) console.error("[i18n-redirect] 404: unknown locale", firstSegment);
47
- throw createError({ statusCode: 404, statusMessage: "Page Not Found" });
48
- }
49
- }
50
- const errorMessage = i18nStrategy.shouldReturn404(path);
51
- if (errorMessage) {
52
- if (DEBUG) console.error("[i18n-redirect] 404:", errorMessage, path);
53
- throw createError({ statusCode: 404, statusMessage: "Page Not Found" });
54
- }
55
- const hasLocalePrefix = Boolean(firstSegment && validLocales.includes(firstSegment));
56
- const shouldDeferCookieSync = i18nConfig.redirects !== false && autoDetectPath === "*";
57
- if (hasLocalePrefix && cookieName && !shouldDeferCookieSync) {
58
- const currentLocale = firstSegment;
59
- const { watch: _w, ...cookieOpts } = getLocaleCookieOptions();
60
- setCookie(event, cookieName, currentLocale, cookieOpts);
61
- }
62
- if (i18nConfig.redirects !== false) {
63
- const allowPreferenceRedirect = shouldAttemptLocaleRedirect(path, { autoDetectPath, hasLocalePrefix });
64
- const localeState = autoDetectPath !== "*" ? useState("i18n-locale", () => null) : null;
65
- let preferredLocale = resolvePreferredLocale({
66
- defaultLocale,
67
- validLocales,
68
- autoDetectLanguage: i18nConfig.autoDetectLanguage,
69
- stateLocale: localeState?.value ?? null,
70
- cookieLocale: cookieName ? getCookie(event, cookieName) : null,
71
- acceptLanguageHeader: getHeader(event, "accept-language"),
72
- ignoreStateLocale: autoDetectPath === "*"
73
- });
74
- if (!hasLocalePrefix && !allowPreferenceRedirect) {
75
- preferredLocale = defaultLocale;
76
- }
77
- if (autoDetectPath === "*" && !hasLocalePrefix) {
78
- preferredLocale = defaultLocale;
79
- }
80
- if (autoDetectPath === "*" && hasLocalePrefix && firstSegment !== preferredLocale) {
81
- const rest = pathSegments.slice(1).join("/");
82
- let targetPath;
83
- if (preferredLocale === defaultLocale && i18nConfig.strategy === "prefix_except_default") {
84
- targetPath = rest ? `/${rest}` : "/";
85
- } else {
86
- targetPath = rest ? `/${preferredLocale}/${rest}` : `/${preferredLocale}`;
87
- }
88
- if (cookieName) {
89
- const { watch: _w2, ...cookieOpts2 } = getLocaleCookieOptions();
90
- setCookie(event, cookieName, preferredLocale, cookieOpts2);
91
- }
92
- if (DEBUG) console.error("[i18n-redirect] REDIRECT autoDetectPath *", { path, targetPath, preferredLocale });
93
- return performRedirect(targetPath + (url.search || "") + (url.hash || ""));
94
- }
95
- const redirectPath = i18nStrategy.getClientRedirect(path, preferredLocale);
96
- if (redirectPath) {
97
- if (DEBUG) console.error("[i18n-redirect] REDIRECT", { path, redirectPath, preferredLocale });
98
- return performRedirect(redirectPath + (url.search || "") + (url.hash || ""));
99
- }
100
- }
20
+ const url = requestURL(event);
21
+ const path = withoutAppBaseURL(url.pathname, useRuntimeConfig().app.baseURL);
22
+ const alwaysPrefixed = i18nConfig.strategy === "prefix";
23
+ const preferenceCookie = getPreferenceCookieName(i18nConfig);
24
+ const varyOnVisitor = () => {
25
+ const vary = [preferenceCookie && "Cookie", i18nConfig.autoDetectLanguage && "Accept-Language"].filter(Boolean);
26
+ if (vary.length) appendResponseHeader(event, "Vary", vary.join(", "));
27
+ };
28
+ const urlless = i18nConfig.strategy === "no_prefix" || i18nConfig.hashMode && !alwaysPrefixed;
29
+ if (urlless) varyOnVisitor();
30
+ if (isInternalPath(path, i18nConfig.excludePatterns, i18nConfig.apiBaseUrl)) return;
31
+ const notFound = i18nStrategy.shouldReturn404(path);
32
+ if (notFound) {
33
+ if (DEBUG) console.error("[i18n-redirect] 404:", notFound, path);
34
+ throw createError({ statusCode: 404, statusMessage: "Page Not Found" });
101
35
  }
36
+ const defaultLocale = i18nConfig.defaultLocale;
37
+ const autoDetectPath = i18nConfig.autoDetectPath;
38
+ const anyPath = autoDetectPath === "*";
39
+ const redirects = i18nConfig.redirects !== false;
40
+ const cookieName = getLocaleCookieName(i18nConfig);
41
+ const urlLocale = i18nStrategy.getLocaleFromPath(path);
42
+ const writeCookie = (locale) => {
43
+ if (!cookieName) return;
44
+ const { watch: _watch, decode: _decode, ...options } = getLocaleCookieOptions();
45
+ setCookie(event, cookieName, locale, options);
46
+ };
47
+ const redirectable = redirects && !urlless && !i18nConfig.hashMode && (urlLocale || i18nConfig.strategy !== "prefix_and_default");
48
+ if (redirectable && preferenceCounts({ path, urlLocale, autoDetectPath, alwaysPrefixed })) varyOnVisitor();
49
+ const router = useRouter();
50
+ const served = i18nConfig.strategy !== "no_prefix" && (router.getRoutes().length === 0 || router.currentRoute.value.matched.length > 0);
51
+ if (urlLocale && served && !(redirects && anyPath)) writeCookie(urlLocale);
52
+ if (i18nConfig.hashMode && alwaysPrefixed)
53
+ return navigateTo(`/#${applyTrailingSlash(path, i18nConfig.trailingSlash)}${url.search}`, { redirectCode: 302 });
54
+ if (!redirects || i18nConfig.hashMode) return;
55
+ const preferred = redirectLocale({
56
+ path,
57
+ urlLocale,
58
+ autoDetectPath,
59
+ alwaysPrefixed,
60
+ defaultLocale,
61
+ validLocales: getEnabledLocaleCodes(i18nConfig.locales),
62
+ autoDetectLanguage: i18nConfig.autoDetectLanguage,
63
+ cookie: preferenceCookie ? getCookie(event, preferenceCookie) : null,
64
+ acceptLanguage: getHeader(event, "accept-language"),
65
+ // a state differing from what the URL resolved to was set by the app (a plugin): a choice
66
+ state: anyPath ? null : chosenState(useState("i18n-locale", () => null).value, event.context.i18n?.locale)
67
+ });
68
+ const target = i18nStrategy.getClientRedirect(path, preferred, { switchPrefixed: anyPath });
69
+ if (!target) return;
70
+ if (anyPath && urlLocale) writeCookie(preferred);
71
+ if (DEBUG) console.error("[i18n-redirect] REDIRECT", { path, target, preferred });
72
+ return navigateTo(applyTrailingSlash(target, i18nConfig.trailingSlash) + url.search + url.hash, { redirectCode: 302 });
102
73
  }
103
74
  });
75
+ function chosenState(state, urlLocale) {
76
+ return state && state !== urlLocale ? state : null;
77
+ }
@@ -1,10 +1,10 @@
1
- import { createTranslationDirReader, mergeDevTranslations, resolveLiveTranslationDirs } from "@i18n-micro/hmr/watcher";
1
+ import { createTranslationDirReader, mergeDevTranslations, resolveLiveTranslationDirs } from "./translation-watcher.js";
2
2
  import { toTranslations } from "@i18n-micro/utils/normalize";
3
3
  import { getI18nPrivateConfig } from "#i18n-internal/config";
4
4
  export async function loadLiveDevTranslations(locale, pageName, config) {
5
5
  const privateConfig = getI18nPrivateConfig();
6
6
  const { translationRoots, additionalRoots } = resolveLiveTranslationDirs({
7
- rootDirs: privateConfig.rootDirs?.length ? privateConfig.rootDirs : [privateConfig.rootDir],
7
+ rootDirs: privateConfig.rootDirs,
8
8
  translationDir: privateConfig.translationDir,
9
9
  additionalTranslationDirs: privateConfig.additionalTranslationDirs
10
10
  });
@@ -12,7 +12,8 @@ export async function loadLiveDevTranslations(locale, pageName, config) {
12
12
  const merged = await mergeDevTranslations({
13
13
  locale,
14
14
  pageName,
15
- locales: config.locales,
15
+ // a fallback may live on another domain, as in production
16
+ locales: config.linkLocales ?? config.locales,
16
17
  fallbackLocale: config.fallbackLocale,
17
18
  disablePageLocales: config.disablePageLocales,
18
19
  readLocaleFile
@@ -0,0 +1,9 @@
1
+ import type { Translations } from '@i18n-micro/types';
2
+ import { CacheControl } from '@i18n-micro/utils/cache-control';
3
+ export type ServerTranslationCacheEntry = {
4
+ data: Translations;
5
+ json: string;
6
+ /** Of a root read from a file: what a delta names. */
7
+ mark?: string;
8
+ };
9
+ export declare function getOrCreateServerTranslationCache(): CacheControl<ServerTranslationCacheEntry>;
@@ -0,0 +1,10 @@
1
+ import { DEFAULT_SERVER_CACHE_SIZE } from "../../utils/cache-keys.js";
2
+ import { CacheControl } from "@i18n-micro/utils/cache-control";
3
+ import { getI18nConfig } from "#i18n-internal/strategy";
4
+ let cache;
5
+ export function getOrCreateServerTranslationCache() {
6
+ if (cache) return cache;
7
+ const { cacheMaxSize, cacheTtl } = getI18nConfig();
8
+ cache = new CacheControl({ maxSize: cacheMaxSize || DEFAULT_SERVER_CACHE_SIZE, ttl: cacheTtl });
9
+ return cache;
10
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Optional level between the process LRU and the dictionary source: a Nitro storage mount several
3
+ * instances share (`cacheStorage`), so one of them reads the source and the others read its copy.
4
+ *
5
+ * Only ever an optimisation: every failure is a miss, and the source answers. The process LRU stays
6
+ * in front because `$t` is synchronous and needs the tree in memory. No lock: two instances that
7
+ * miss at once both read the source and write the same value.
8
+ */
9
+ import type { ResolvedI18nConfig } from '@i18n-micro/types';
10
+ import type { ServerTranslationCacheEntry } from './server-translation-cache.js';
11
+ /**
12
+ * The chunk's key, or null when it must not touch the layer. `cacheStorageBuild` keeps two builds
13
+ * apart in a rolling deploy. `dateBuild` alone would not: it hashes only the translation files, and
14
+ * `0`/`''` turns it off. A runtime `fallbackLocale` changes what `source` and remote chunks hold, and
15
+ * a runtime host is another backend: one artifact can run against several.
16
+ */
17
+ export declare function sharedChunkKey(locale: string, page: string, config: ResolvedI18nConfig, host?: string): string | null;
18
+ export declare function readSharedChunk(key: string): Promise<ServerTranslationCacheEntry | null>;
19
+ /**
20
+ * A string reaches the driver as is: no second serialization. Awaited: an edge runtime drops what outlives
21
+ * the response. Only while the store is trusted: a write must not wait out a store whose reads just failed.
22
+ * A failed write closes nothing: a read-only replica or a KV write limit still serves reads.
23
+ */
24
+ export declare function writeSharedChunk(key: string, json: string, config: ResolvedI18nConfig): Promise<void>;
@@ -0,0 +1,71 @@
1
+ import { useStorage } from "nitropack/runtime";
2
+ import { getI18nPrivateConfig } from "#i18n-internal/config";
3
+ import { toEntry } from "../../utils/translation-entry.js";
4
+ const TRUSTED_TIMEOUT_MS = 1e3;
5
+ const PROBATION_TIMEOUT_MS = 250;
6
+ const FAILURES_TO_CLOSE = 3;
7
+ const COOLDOWN_MS = 5e3;
8
+ const WARN_EVERY_MS = 6e4;
9
+ const DEFAULT_TTL_SECONDS = 86400;
10
+ const MAX_KEY_LENGTH = 400;
11
+ const MAX_PAGE_LENGTH = 200;
12
+ let shared;
13
+ let failures = 0;
14
+ let closedUntil = 0;
15
+ let warnedAt = Number.NEGATIVE_INFINITY;
16
+ function sharedStorage() {
17
+ if (shared !== void 0) return shared;
18
+ const name = getI18nPrivateConfig().cacheStorage;
19
+ if (import.meta.dev || import.meta.prerender || !name) return shared = null;
20
+ if (useStorage().getMount(name).base) return shared = useStorage(name);
21
+ console.warn(`[nuxt-i18n-micro] cacheStorage: no Nitro storage is mounted as "${name}" (nitro.storage). Working without the shared cache.`);
22
+ return shared = null;
23
+ }
24
+ function warn(error) {
25
+ const now = Date.now();
26
+ if (now - warnedAt < WARN_EVERY_MS) return;
27
+ warnedAt = now;
28
+ console.warn(`[nuxt-i18n-micro] cacheStorage failed, reading dictionaries from their source: ${error?.message ?? error}`);
29
+ }
30
+ async function withDeadline(call, ms) {
31
+ let timer;
32
+ const deadline = new Promise((_, reject) => {
33
+ timer = setTimeout(() => reject(new Error(`no answer in ${ms} ms`)), ms);
34
+ });
35
+ try {
36
+ return await Promise.race([call(), deadline]);
37
+ } finally {
38
+ clearTimeout(timer);
39
+ }
40
+ }
41
+ const segment = (value) => String(value).replace(/[^\w-]/g, (char) => `%${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
42
+ export function sharedChunkKey(locale, page, config, host = "") {
43
+ if (!sharedStorage()) return null;
44
+ const pageSegment = segment(page);
45
+ if (pageSegment.length > MAX_PAGE_LENGTH) return null;
46
+ const build = [getI18nPrivateConfig().cacheStorageBuild ?? "", config.dateBuild, config.fallbackLocale ?? "", host].map(segment).join("~");
47
+ const key = `i18n-micro:${build}:${segment(locale)}:${pageSegment}`;
48
+ return key.length > MAX_KEY_LENGTH ? null : key;
49
+ }
50
+ function toChunk(value) {
51
+ if (!value || Object.getPrototypeOf(value) !== Object.prototype || !Object.keys(value).length) return null;
52
+ return toEntry(value);
53
+ }
54
+ export async function readSharedChunk(key) {
55
+ if (Date.now() < closedUntil) return null;
56
+ try {
57
+ const value = await withDeadline(() => sharedStorage().getItem(key), failures ? PROBATION_TIMEOUT_MS : TRUSTED_TIMEOUT_MS);
58
+ failures = 0;
59
+ return toChunk(value);
60
+ } catch (error) {
61
+ if (++failures >= FAILURES_TO_CLOSE) closedUntil = Date.now() + COOLDOWN_MS;
62
+ warn(error);
63
+ return null;
64
+ }
65
+ }
66
+ export async function writeSharedChunk(key, json, config) {
67
+ if (failures) return;
68
+ const { cacheTtl } = config;
69
+ const ttl = cacheTtl > 0 && Number.isFinite(cacheTtl) ? Math.ceil(cacheTtl) : DEFAULT_TTL_SECONDS;
70
+ await withDeadline(() => sharedStorage().setItem(key, json, { ttl }), TRUSTED_TIMEOUT_MS).catch(warn);
71
+ }
@@ -0,0 +1,79 @@
1
+ import type { Locale } from '@i18n-micro/types';
2
+ import { type ParsedTranslationRelativePath } from '@i18n-micro/utils/parse-path';
3
+ /** A cache the watcher drops on change (the server cache, the SSR storage cache). */
4
+ interface TranslationWatchCache {
5
+ clear(): void;
6
+ }
7
+ export interface DevTranslationMergeInput {
8
+ locale: string;
9
+ pageName: string;
10
+ locales?: Array<string | Locale>;
11
+ fallbackLocale?: string;
12
+ disablePageLocales?: boolean;
13
+ readLocaleFile: (relativePath: string) => Record<string, unknown> | Promise<Record<string, unknown>>;
14
+ }
15
+ export interface HandleTranslationWatchChangeInput {
16
+ relativePath: string;
17
+ configuredLocales: Set<string>;
18
+ serverCache: TranslationWatchCache;
19
+ storageCache?: TranslationWatchCache | null;
20
+ }
21
+ /**
22
+ * A locale file as an object: `{}` when missing; broken JSON warns with the path and is `{}`
23
+ * (one bad file must not silently empty a dictionary, nor crash dev).
24
+ */
25
+ export declare function readTranslationFile(filePath: string): Record<string, unknown>;
26
+ /**
27
+ * Absolute translation + additional roots for live/HMR reads.
28
+ * `rootDirs` order matches build: extends first, project last (last wins).
29
+ */
30
+ export declare function resolveLiveTranslationDirs(input: {
31
+ rootDirs: string[];
32
+ translationDir: string;
33
+ additionalTranslationDirs?: string[];
34
+ }): {
35
+ translationRoots: string[];
36
+ additionalRoots: string[];
37
+ };
38
+ /**
39
+ * The path of a live translation file relative to its root, or null when the file is not one:
40
+ * any `.json` under a `translationDir` root, a top-level `{locale}.json` in an additional root
41
+ * (only those take part in the global merge).
42
+ */
43
+ export declare function translationRelativePath(filePath: string, dirs: {
44
+ translationRoots: string[];
45
+ additionalRoots: string[];
46
+ }): string | null;
47
+ /**
48
+ * Reader for live translation dirs (Nuxt layers + optional additional roots).
49
+ * Matches build merge: additional first (all layers), then `translationDir` layers (last wins).
50
+ */
51
+ export declare function createTranslationDirReader(translationsRoot: string | string[], additionalRoots?: string[]): (relativeFilePath: string) => Record<string, unknown>;
52
+ /**
53
+ * Merge live locale files for HMR + `import.meta.dev` cache misses.
54
+ * Always source-style fallback (buildDir snapshots bake fallback; live files do not).
55
+ */
56
+ export declare function mergeDevTranslations(input: DevTranslationMergeInput): Promise<Record<string, unknown>>;
57
+ /**
58
+ * A translation file changed: drop every cached dictionary. A root file feeds every page, and
59
+ * through fallback chains other locales too, so any narrower invalidation leaves stale chunks
60
+ * (pages it did not list, `disablePageLocales` keys, fallback locales). Dev reads are live
61
+ * merges, so the next request rebuilds only what is asked for.
62
+ */
63
+ export declare function handleTranslationWatchChange(input: HandleTranslationWatchChangeInput): Promise<ParsedTranslationRelativePath & {
64
+ dropped: boolean;
65
+ }>;
66
+ /**
67
+ * Tracks translation file contents so a write that changes nothing is not acted on.
68
+ *
69
+ * Editors save on focus loss and formatters rewrite files byte-for-byte; a change drops
70
+ * the caches and reloads the page, which is far too much to spend on a file identical to
71
+ * the one already merged.
72
+ */
73
+ export declare class TranslationContentTracker {
74
+ private readonly hashes;
75
+ /** `true` when the file should be processed: new content, or unreadable so assume changed. */
76
+ shouldProcess(filePath: string): boolean;
77
+ forget(filePath: string): void;
78
+ }
79
+ export {};
@@ -0,0 +1,105 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
+ import { forwardSlashes } from "@i18n-micro/utils/app-path";
5
+ import { deepMergeTranslationsRecursive } from "@i18n-micro/utils/deep-merge";
6
+ import { parseJsonFile } from "@i18n-micro/utils/load-dir";
7
+ import { mergeSourceTranslations, normalizeConfiguredLocales } from "@i18n-micro/utils/merge-source";
8
+ import { parseTranslationRelativePath } from "@i18n-micro/utils/parse-path";
9
+ export function readTranslationFile(filePath) {
10
+ if (!existsSync(filePath)) return {};
11
+ let parsed;
12
+ try {
13
+ parsed = parseJsonFile(readFileSync(filePath, "utf-8"));
14
+ } catch (error) {
15
+ return skip(filePath, error.message);
16
+ }
17
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return skip(filePath, "not a JSON object");
18
+ return parsed;
19
+ }
20
+ function skip(filePath, reason) {
21
+ if (!WARNED.has(filePath)) console.warn(`[nuxt-i18n-micro] Skipping invalid JSON in ${filePath}: ${reason}`);
22
+ WARNED.add(filePath);
23
+ return {};
24
+ }
25
+ const WARNED = /* @__PURE__ */ new Set();
26
+ export function resolveLiveTranslationDirs(input) {
27
+ const translationRoots = input.rootDirs.map((root) => resolve(root, input.translationDir));
28
+ const additionalRoots = [];
29
+ for (const dir of input.additionalTranslationDirs ?? []) {
30
+ for (const root of input.rootDirs) {
31
+ additionalRoots.push(resolve(root, dir));
32
+ }
33
+ }
34
+ return { translationRoots, additionalRoots };
35
+ }
36
+ export function translationRelativePath(filePath, dirs) {
37
+ const inside = (root) => {
38
+ const rel = relative(root, filePath);
39
+ return rel.startsWith("..") || isAbsolute(rel) ? null : forwardSlashes(rel);
40
+ };
41
+ for (const root of dirs.additionalRoots) {
42
+ const rel = inside(root);
43
+ if (rel && !rel.includes("/") && rel.endsWith(".json")) return rel;
44
+ }
45
+ for (const root of dirs.translationRoots) {
46
+ const rel = inside(root);
47
+ if (rel?.endsWith(".json")) return rel;
48
+ }
49
+ return null;
50
+ }
51
+ export function createTranslationDirReader(translationsRoot, additionalRoots = []) {
52
+ const translationRoots = Array.isArray(translationsRoot) ? translationsRoot : [translationsRoot];
53
+ return (relativeFilePath) => {
54
+ let content = {};
55
+ if (!relativeFilePath.includes("/") && relativeFilePath.endsWith(".json")) {
56
+ const locale = relativeFilePath.slice(0, -".json".length);
57
+ for (const extraRoot of additionalRoots) {
58
+ content = deepMergeTranslationsRecursive(content, readTranslationFile(join(extraRoot, `${locale}.json`)));
59
+ }
60
+ }
61
+ for (const root of translationRoots) {
62
+ content = deepMergeTranslationsRecursive(content, readTranslationFile(join(root, relativeFilePath)));
63
+ }
64
+ return content;
65
+ };
66
+ }
67
+ export async function mergeDevTranslations(input) {
68
+ const locales = normalizeConfiguredLocales(input.locales);
69
+ const effectiveLocales = locales.length > 0 ? locales : [{ code: input.locale }];
70
+ return mergeSourceTranslations({
71
+ locale: input.locale,
72
+ pageName: input.pageName,
73
+ locales: effectiveLocales,
74
+ globalFallbackLocale: input.fallbackLocale,
75
+ disablePageLocales: input.disablePageLocales,
76
+ readLocaleFile: input.readLocaleFile
77
+ });
78
+ }
79
+ export async function handleTranslationWatchChange(input) {
80
+ const parsed = parseTranslationRelativePath(input.relativePath);
81
+ const dropped = parsed.type === "page" || parsed.type === "root" && input.configuredLocales.has(parsed.locale);
82
+ if (dropped) {
83
+ input.serverCache.clear();
84
+ input.storageCache?.clear();
85
+ }
86
+ return { ...parsed, dropped };
87
+ }
88
+ export class TranslationContentTracker {
89
+ hashes = /* @__PURE__ */ new Map();
90
+ /** `true` when the file should be processed: new content, or unreadable so assume changed. */
91
+ shouldProcess(filePath) {
92
+ let hash;
93
+ try {
94
+ hash = createHash("sha256").update(readFileSync(filePath)).digest("hex");
95
+ } catch {
96
+ return true;
97
+ }
98
+ if (this.hashes.get(filePath) === hash) return false;
99
+ this.hashes.set(filePath, hash);
100
+ return true;
101
+ }
102
+ forget(filePath) {
103
+ this.hashes.delete(filePath);
104
+ }
105
+ }