@i18n-micro/astro 1.3.25 → 2.0.1

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 (75) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +8 -64
  3. package/dist/auto-middleware.d.ts +1 -0
  4. package/dist/client/core.d.cts +86 -0
  5. package/dist/client/core.d.ts +71 -8
  6. package/dist/client/index.d.cts +2 -6
  7. package/dist/client/index.d.ts +2 -6
  8. package/dist/client/index.js +1 -6
  9. package/dist/client/preact.d.ts +12 -23
  10. package/dist/client/preact.js +13 -37
  11. package/dist/client/react.d.ts +14 -21
  12. package/dist/client/react.js +12 -37
  13. package/dist/client/svelte.d.ts +14 -14
  14. package/dist/client/svelte.js +17 -47
  15. package/dist/client/vue.d.ts +15 -15
  16. package/dist/client/vue.js +37 -2
  17. package/dist/composer.d.cts +16 -0
  18. package/dist/composer.d.ts +10 -30
  19. package/dist/core-CgrgdmGy.cjs +1 -0
  20. package/dist/core-DTAcn2yF.js +72 -0
  21. package/dist/env.d.ts +8 -2
  22. package/dist/index.cjs +11 -3
  23. package/dist/index.d.cts +10 -13
  24. package/dist/index.d.ts +10 -13
  25. package/dist/index.mjs +171 -482
  26. package/dist/integration.d.cts +26 -0
  27. package/dist/integration.d.ts +14 -15
  28. package/dist/middleware-BT3OA5YL.cjs +1 -0
  29. package/dist/middleware-Ch8E0iV_.js +257 -0
  30. package/dist/middleware.d.cts +34 -0
  31. package/dist/middleware.d.ts +17 -8
  32. package/dist/middleware.js +23 -0
  33. package/dist/node.d.ts +26 -0
  34. package/dist/node.js +22 -0
  35. package/dist/request.d.ts +69 -0
  36. package/dist/router/adapter.d.cts +9 -0
  37. package/dist/router/adapter.d.ts +5 -4
  38. package/dist/router/types.d.cts +66 -0
  39. package/dist/router/types.d.ts +3 -2
  40. package/dist/utils.d.cts +112 -0
  41. package/dist/utils.d.ts +39 -35
  42. package/dist/virtual.d.ts +21 -0
  43. package/package.json +28 -15
  44. package/src/auto-middleware.ts +26 -0
  45. package/src/client/core.ts +124 -59
  46. package/src/client/index.ts +2 -11
  47. package/src/client/preact.tsx +12 -127
  48. package/src/client/react.tsx +11 -123
  49. package/src/client/svelte.ts +6 -69
  50. package/src/client/vue.ts +22 -77
  51. package/src/components/i18n-link.astro +2 -59
  52. package/src/components/i18n-switcher.astro +46 -149
  53. package/src/components/i18n-t.astro +6 -54
  54. package/src/composer.ts +25 -115
  55. package/src/env.d.ts +7 -0
  56. package/src/index.ts +1 -6
  57. package/src/integration.ts +113 -52
  58. package/src/middleware.ts +174 -120
  59. package/src/node.ts +51 -0
  60. package/src/request.ts +177 -0
  61. package/src/router/adapter.ts +47 -149
  62. package/src/router/types.ts +3 -2
  63. package/src/utils.ts +149 -323
  64. package/src/virtual.d.ts +21 -0
  65. package/dist/components/index.d.cts +0 -0
  66. package/dist/components/index.d.ts +0 -0
  67. package/dist/core-DwkpOV-H.cjs +0 -1
  68. package/dist/core-VSnkGRWR.js +0 -28
  69. package/dist/load-translations.d.ts +0 -43
  70. package/dist/routing-context.d.ts +0 -4
  71. package/dist/vue-BNiEwZr2.cjs +0 -5
  72. package/dist/vue-CWHjIOiS.js +0 -1724
  73. package/src/components/index.ts +0 -11
  74. package/src/load-translations.ts +0 -106
  75. package/src/routing-context.ts +0 -23
@@ -1,78 +1,132 @@
1
- import type { Locale, ModuleOptions, PluralFunc } from '@i18n-micro/types'
2
- import type { AstroIntegration, HookParameters } from 'astro'
1
+ import type { Locale } from '@i18n-micro/types'
2
+ import { getEnabledLocaleCodes } from '@i18n-micro/utils/active-locales'
3
+ import { forwardSlashes } from '@i18n-micro/utils/app-path'
4
+ import type { AstroConfig, AstroIntegration, HookParameters } from 'astro'
5
+ import type { FSWatcher } from 'vite'
3
6
  import { AstroI18n, type AstroI18nOptions } from './composer'
4
- import { setGlobalRoutingStrategy } from './routing-context'
5
- import type { I18nRoutingStrategy } from './router/types'
6
7
 
7
- export interface I18nIntegrationOptions extends Omit<ModuleOptions, 'plural'> {
8
+ export interface I18nIntegrationOptions {
8
9
  locale: string
9
10
  fallbackLocale?: string
10
11
  locales?: Locale[]
11
- messages?: Record<string, Record<string, unknown>>
12
- plural?: PluralFunc
13
12
  missingWarn?: boolean
14
- missingHandler?: (locale: string, key: string, routeName: string) => void
15
13
  localeCookie?: string | null
16
14
  autoDetect?: boolean
15
+ /** `/` also redirects when nothing prefers another locale: see `createI18nMiddleware`. */
17
16
  redirectToDefault?: boolean
17
+ /** Bundled into the server at build time, relative to the project root. */
18
18
  translationDir?: string
19
- routingStrategy?: I18nRoutingStrategy
19
+ /** Page files (`pages/about/de.json`) count as root translations. */
20
+ disablePageLocales?: boolean
21
+ /**
22
+ * `false`: no middleware from the integration. Write `src/middleware.ts` with `createI18nMiddleware`
23
+ * for what cannot cross from the Astro config to the server bundle: `plural`, `missingHandler`, a strategy.
24
+ */
25
+ middleware?: boolean
20
26
  }
21
27
 
22
- export { getGlobalRoutingStrategy, runWithRoutingStrategy, setGlobalRoutingStrategy } from './routing-context'
28
+ /** Options that moved to the server bundle: passing one here would silently lose it. */
29
+ const MOVED_OPTIONS = ['messages', 'plural', 'missingHandler', 'routingStrategy']
30
+
31
+ const CONFIG_MODULE = 'virtual:i18n-micro/config'
32
+ const MESSAGES_MODULE = 'virtual:i18n-micro/messages'
33
+ const hidden = (id: string) => `\0${id}`
34
+ /** Not a literal, so a server bundle of this entry never sees `node:fs`: only the config process comes here. */
35
+ const NODE_ENTRY = '@i18n-micro/astro/node'
36
+
37
+ type ConfigSetup = HookParameters<'astro:config:setup'>
38
+
39
+ /** What crosses from the Astro config process to the server bundle: only serializable values. */
40
+ function publishedConfig(options: I18nIntegrationOptions, base: string) {
41
+ const locales = options.locales || []
42
+ return {
43
+ defaultLocale: options.locale,
44
+ fallbackLocale: options.fallbackLocale || options.locale,
45
+ locales,
46
+ localeCodes: getEnabledLocaleCodes(locales),
47
+ translationDir: options.translationDir || null,
48
+ autoDetect: options.autoDetect ?? true,
49
+ redirectToDefault: options.redirectToDefault ?? false,
50
+ localeCookie: options.localeCookie === null ? null : options.localeCookie || 'i18n-locale',
51
+ missingWarn: options.missingWarn ?? false,
52
+ base: base === '/' ? '' : base,
53
+ }
54
+ }
23
55
 
24
56
  /**
25
- * Astro Integration for i18n-micro
57
+ * The two virtual modules. The dictionaries become code at build time, so the server reads no disk
58
+ * and an edge runtime works. Their files are watched: in dev an edit reloads the module.
26
59
  */
27
- export function i18nIntegration(options: I18nIntegrationOptions): AstroIntegration {
28
- const { locale: defaultLocale, fallbackLocale, translationDir, routingStrategy } = options
60
+ function i18nVitePlugin(options: I18nIntegrationOptions, config: AstroConfig) {
61
+ const code = `export const config = ${JSON.stringify(publishedConfig(options, config.base))}`
29
62
 
30
- setGlobalRoutingStrategy(routingStrategy || null)
63
+ let watched: string[] = []
64
+
65
+ const loadMessages = async (watch: (file: string) => void) => {
66
+ const { translationDir, disablePageLocales } = options
67
+ if (!translationDir) return 'export const messages = {"root":{},"routes":{}}'
68
+ const { readTranslationDir } = (await import(/* @vite-ignore */ NODE_ENTRY)) as typeof import('./node')
69
+ const { messages, files } = readTranslationDir({ translationDir, rootDir: config.root, disablePageLocales })
70
+ watched = files
71
+ for (const file of files) watch(file)
72
+ return `export const messages = ${JSON.stringify(messages)}`
73
+ }
74
+
75
+ return {
76
+ name: 'vite-plugin-i18n-micro-config',
77
+ resolveId(id: string) {
78
+ return id === CONFIG_MODULE || id === MESSAGES_MODULE ? hidden(id) : undefined
79
+ },
80
+ load(this: { addWatchFile(file: string): void }, id: string) {
81
+ if (id === hidden(CONFIG_MODULE)) return code
82
+ return id === hidden(MESSAGES_MODULE) ? loadMessages((file) => this.addWatchFile(file)) : undefined
83
+ },
84
+ configureServer(server: { watcher: FSWatcher }) {
85
+ const dir = options.translationDir && `/${options.translationDir.replace(/^\.?\/+|\/+$/g, '')}/`
86
+ if (!dir) return
87
+ // A file Vite knows reloads by itself; a new one has no module yet: a change of a known file does it.
88
+ const reload = (file: string) => {
89
+ const isLocaleFile = file.endsWith('.json') && forwardSlashes(file).includes(dir)
90
+ if (isLocaleFile && watched[0]) server.watcher.emit('change', watched[0])
91
+ }
92
+ server.watcher.on('add', reload)
93
+ },
94
+ }
95
+ }
96
+
97
+ /**
98
+ * `Astro.currentLocale` and `astro:i18n` know the locales, the routing stays ours. Only with a
99
+ * `src/middleware.ts`: Astro rejects `routing: 'manual'` when the only middleware is an integration's
100
+ * (`MissingMiddlewareForInternationalization`), and any other routing would fight this one.
101
+ */
102
+ function astroI18nConfig(options: I18nIntegrationOptions, config: AstroConfig) {
103
+ const locales = getEnabledLocaleCodes(options.locales)
104
+ if (options.middleware !== false || config.i18n || !locales.includes(options.locale)) return {}
105
+ return { i18n: { locales, defaultLocale: options.locale, routing: 'manual' as const } }
106
+ }
107
+
108
+ /** Astro integration for i18n-micro: publishes the config and dictionaries, and installs the middleware. */
109
+ export function i18nIntegration(options: I18nIntegrationOptions): AstroIntegration {
110
+ const moved = MOVED_OPTIONS.find((key) => (options as unknown as Record<string, unknown>)[key] !== undefined)
111
+ if (moved)
112
+ throw new Error(`[i18n-micro] i18nIntegration no longer takes "${moved}": pass it to createI18n / createI18nMiddleware in src/middleware.ts`)
31
113
 
32
114
  return {
33
115
  name: '@i18n-micro/astro',
34
116
  hooks: {
35
- // 1. Vite setup (virtual module) happens here
36
117
  'astro:config:setup': (params) => {
37
- const { updateConfig } = params as HookParameters<'astro:config:setup'>
38
-
39
- const virtualModuleId = 'virtual:i18n-micro/config'
40
- const resolvedVirtualModuleId = `\0${virtualModuleId}`
41
-
42
- const configData = {
43
- defaultLocale,
44
- fallbackLocale: fallbackLocale || defaultLocale,
45
- locales: options.locales || [],
46
- localeCodes: (options.locales || []).map((l) => l.code),
47
- translationDir: translationDir || null,
48
- autoDetect: options.autoDetect ?? true,
49
- redirectToDefault: options.redirectToDefault ?? false,
50
- localeCookie: options.localeCookie === null ? null : options.localeCookie || 'i18n-locale',
51
- missingWarn: options.missingWarn ?? false,
52
- }
53
-
118
+ const { updateConfig, addMiddleware, config } = params as ConfigSetup
54
119
  updateConfig({
120
+ ...astroI18nConfig(options, config),
55
121
  vite: {
56
- plugins: [
57
- {
58
- name: 'vite-plugin-i18n-micro-config',
59
- resolveId(id) {
60
- if (id === virtualModuleId) {
61
- return resolvedVirtualModuleId
62
- }
63
- },
64
- load(id) {
65
- if (id === resolvedVirtualModuleId) {
66
- return `export const config = ${JSON.stringify(configData)}`
67
- }
68
- },
69
- },
70
- ],
122
+ // the middleware and the components import virtual modules and `.astro` files: Vite has to process them
123
+ ssr: { noExternal: ['@i18n-micro/astro'] },
124
+ plugins: [i18nVitePlugin(options, config)],
71
125
  },
72
126
  })
127
+ if (options.middleware !== false) addMiddleware({ order: 'pre', entrypoint: '@i18n-micro/astro/middleware' })
73
128
  },
74
129
 
75
- // 2. Type injection happens here (per Astro documentation)
76
130
  'astro:config:done': (params) => {
77
131
  const { injectTypes } = params as HookParameters<'astro:config:done'>
78
132
 
@@ -91,7 +145,15 @@ export function i18nIntegration(options: I18nIntegrationOptions): AstroIntegrati
91
145
  autoDetect: boolean;
92
146
  redirectToDefault: boolean;
93
147
  localeCookie: string | null;
94
- missingWarn: boolean | null;
148
+ missingWarn: boolean;
149
+ base: string;
150
+ }
151
+ }
152
+
153
+ declare module 'virtual:i18n-micro/messages' {
154
+ export const messages: {
155
+ root: Record<string, import('@i18n-micro/types').Translations>;
156
+ routes: Record<string, Record<string, import('@i18n-micro/types').Translations>>;
95
157
  }
96
158
  }
97
159
  `,
@@ -100,9 +162,8 @@ export function i18nIntegration(options: I18nIntegrationOptions): AstroIntegrati
100
162
  },
101
163
  }
102
164
  }
103
- /**
104
- * Create i18n instance (for manual setup)
105
- */
165
+
166
+ /** Create an i18n instance (for a `src/middleware.ts` of your own). */
106
167
  export function createI18n(options: AstroI18nOptions): AstroI18n {
107
168
  return new AstroI18n(options)
108
169
  }
package/src/middleware.ts CHANGED
@@ -1,8 +1,22 @@
1
1
  import type { Locale } from '@i18n-micro/types'
2
- import { detectLocaleFromAcceptLanguage } from '@i18n-micro/utils/accept-language'
2
+ import { getEnabledLocaleCodes } from '@i18n-micro/utils/active-locales'
3
+ import { DEFAULT_COOKIE_MAX_AGE } from '@i18n-micro/utils/cookie'
4
+ import { resolveLocale } from '@i18n-micro/utils/resolve-locale'
3
5
  import type { MiddlewareHandler } from 'astro'
4
6
  import type { AstroI18n } from './composer'
5
- import { getGlobalRoutingStrategy, runWithRoutingStrategy } from './routing-context'
7
+ import {
8
+ asksForPage,
9
+ header,
10
+ isPageView,
11
+ isSwitch,
12
+ localeOfPath,
13
+ normalizeBase,
14
+ resolveRequest,
15
+ withBase,
16
+ type I18nRequest,
17
+ type I18nRouting,
18
+ } from './request'
19
+ import { createAstroRouterAdapter } from './router/adapter'
6
20
  import type { I18nRoutingStrategy } from './router/types'
7
21
  // Import shim to ensure App.Locals is available
8
22
  import './env.d'
@@ -12,106 +26,77 @@ export interface I18nMiddlewareOptions {
12
26
  defaultLocale: string
13
27
  locales: string[]
14
28
  localeObjects?: Locale[]
29
+ /** `/` redirects to the locale the cookie / Accept-Language prefer (default `true`); pages keep the URL's locale. */
15
30
  autoDetect?: boolean
31
+ /**
32
+ * `/` also redirects when nothing prefers another locale: to the home `routingStrategy` resolves
33
+ * for the default locale (default `false`). Without one `/` stays: it is the default locale's page.
34
+ */
16
35
  redirectToDefault?: boolean
17
36
  routingStrategy?: I18nRoutingStrategy
37
+ /** Cookie holding the visitor's locale (default `'i18n-locale'`); `null` disables it. */
38
+ localeCookie?: string | null
39
+ /** Astro's `base`, `config.base` of the virtual config. */
40
+ base?: string
18
41
  }
19
42
 
20
- /**
21
- * Create Astro middleware for i18n locale detection
22
- */
23
- export function createI18nMiddleware(options: I18nMiddlewareOptions): MiddlewareHandler {
24
- const {
25
- i18n: globalI18n, // This is the global singleton with cache
26
- defaultLocale,
27
- locales,
28
- localeObjects,
29
- autoDetect: _autoDetect = true,
30
- redirectToDefault: _redirectToDefault = false,
31
- routingStrategy,
32
- } = options
33
-
34
- // Get routing strategy from options or global context
35
- const strategy = routingStrategy || getGlobalRoutingStrategy()
36
-
37
- return async (context, next) => {
38
- // FIX: If locale is already preserved in locals (e.g. during Astro.rewrite),
39
- // skip redetection to prevent overwriting with default locale.
40
- if (context.locals.locale && context.locals.i18n) {
41
- return next()
42
- }
43
-
44
- const url = context.url
45
- const pathname = url.pathname
46
-
47
- // If no routing strategy, use basic fallback
48
- if (!strategy) {
49
- // Basic fallback: just set route name from path
50
- const requestI18n = globalI18n.clone(defaultLocale)
51
- const routeName = pathname === '/' || pathname === '' ? 'index' : pathname.split('/').filter(Boolean).join('-')
52
- requestI18n.setRoute(routeName)
43
+ export const DEFAULT_ASTRO_LOCALE_COOKIE = 'i18n-locale'
53
44
 
54
- // @ts-ignore private property mismatch between src and dist types
55
- context.locals.i18n = requestI18n
56
- context.locals.locale = defaultLocale
57
- context.locals.defaultLocale = defaultLocale
58
- context.locals.locales = localeObjects || locales.map((code) => ({ code }))
59
- context.locals.currentUrl = url
60
-
61
- return next()
62
- }
63
-
64
- // Create context-aware routing strategy with current URL
65
- const contextStrategy: I18nRoutingStrategy = {
66
- ...strategy,
67
- getCurrentPath: () => pathname,
68
- getRoute: () => ({
69
- fullPath: url.pathname + url.search,
70
- query: Object.fromEntries(url.searchParams),
71
- }),
72
- }
73
-
74
- // 1. Check if locale is in path
75
- const pathSegments = pathname.split('/').filter(Boolean)
76
- const firstSegment = pathSegments[0]
77
- const hasLocalePrefix = firstSegment !== undefined && locales.includes(firstSegment)
78
-
79
- // 2. Determine locale
80
- let detectedLocale: string
81
-
82
- if (hasLocalePrefix && firstSegment) {
83
- detectedLocale = firstSegment
84
- } else {
85
- // Use routing strategy if available, otherwise fallback
86
- if (contextStrategy.getLocaleFromPath) {
87
- detectedLocale = contextStrategy.getLocaleFromPath(pathname, defaultLocale, locales)
88
- } else {
89
- detectedLocale = defaultLocale
90
- }
91
- }
45
+ interface CookieReader {
46
+ get: (name: string) => { value: string } | undefined
47
+ }
92
48
 
93
- // 3. Create per-request instance sharing the cache
94
- const requestI18n = globalI18n.clone(detectedLocale)
49
+ function cookie(cookies: CookieReader, name: string | null): string | null {
50
+ if (name === null) return null
51
+ try {
52
+ return cookies.get(name)?.value ?? null
53
+ } catch {
54
+ return null
55
+ }
56
+ }
95
57
 
96
- // 4. Set route name using routing strategy
97
- const routeName = contextStrategy.getRouteName ? contextStrategy.getRouteName(pathname, locales) : 'index'
98
- requestI18n.setRoute(routeName)
58
+ /**
59
+ * Every path method a strategy leaves out comes from the default adapter. The strategy's own stay
60
+ * bound to it: a class with methods on its prototype or `#private` fields works.
61
+ */
62
+ function completeStrategy(strategy: I18nRoutingStrategy | null | undefined, locales: Locale[], defaultLocale: string): I18nRoutingStrategy {
63
+ const fallback = createAstroRouterAdapter(locales, defaultLocale)
64
+ if (!strategy) return fallback
65
+ const source = strategy as unknown as Record<string, unknown>
66
+ const complete: Record<string, unknown> = {}
67
+ for (const [name, method] of Object.entries(fallback)) {
68
+ const own = source[name]
69
+ complete[name] = typeof own === 'function' ? own.bind(strategy) : method
70
+ }
71
+ return complete as unknown as I18nRoutingStrategy
72
+ }
99
73
 
100
- // 5. Store in locals (Type-safe now thanks to astro-shim.d.ts)
101
- // @ts-ignore private property mismatch between src and dist types
102
- context.locals.i18n = requestI18n
103
- context.locals.locale = detectedLocale
104
- context.locals.defaultLocale = defaultLocale
105
- context.locals.locales = localeObjects || locales.map((code) => ({ code }))
106
- context.locals.currentUrl = url
107
- context.locals.routingStrategy = contextStrategy
74
+ interface PreferenceInput {
75
+ cookies: CookieReader
76
+ headers: Headers
77
+ localeCookie: string | null
78
+ autoDetect: boolean
79
+ }
108
80
 
109
- return runWithRoutingStrategy(contextStrategy, () => next())
110
- }
81
+ /** Precedence of a visitor's preference over a routing that is already complete. */
82
+ function preferredLocale(
83
+ pathname: string,
84
+ routing: Omit<I18nRouting, 'base'>,
85
+ { cookies, headers, localeCookie, autoDetect }: PreferenceInput,
86
+ ): string {
87
+ return resolveLocale({
88
+ defaultLocale: routing.defaultLocale,
89
+ validLocales: routing.locales,
90
+ autoDetectLanguage: autoDetect,
91
+ url: localeOfPath(pathname, routing),
92
+ cookie: autoDetect ? cookie(cookies, localeCookie) : null,
93
+ acceptLanguage: autoDetect ? header({ request: { headers } }, 'accept-language') : null,
94
+ })
111
95
  }
112
96
 
113
97
  /**
114
- * Detect locale from request
98
+ * Locale a visitor prefers: URL prefix → cookie → Accept-Language → default, the one precedence of
99
+ * `resolveLocale`. An explicit prefix always wins; `autoDetect: false` leaves only the URL.
115
100
  */
116
101
  export function detectLocale(
117
102
  pathname: string,
@@ -119,44 +104,113 @@ export function detectLocale(
119
104
  headers: Headers,
120
105
  defaultLocale: string,
121
106
  locales: string[],
122
- localeCookie: string | null = 'i18n-locale',
107
+ localeCookie: string | null = DEFAULT_ASTRO_LOCALE_COOKIE,
123
108
  routingStrategy?: I18nRoutingStrategy | null,
109
+ autoDetect = true,
124
110
  ): string {
125
- const strategy = routingStrategy ?? getGlobalRoutingStrategy()
126
-
127
- // 1. Try path
128
- let locale = defaultLocale
129
- if (strategy?.getLocaleFromPath) {
130
- locale = strategy.getLocaleFromPath(pathname, defaultLocale, locales)
131
- } else {
132
- // Fallback: check if first segment is a locale
133
- const segments = pathname.split('/').filter(Boolean)
134
- const firstSegment = segments[0]
135
- if (firstSegment && locales.includes(firstSegment)) {
136
- locale = firstSegment
137
- }
111
+ const strategy = completeStrategy(
112
+ routingStrategy,
113
+ locales.map((code) => ({ code })),
114
+ defaultLocale,
115
+ )
116
+ return preferredLocale(pathname, { defaultLocale, locales, strategy }, { cookies, headers, localeCookie, autoDetect })
117
+ }
118
+
119
+ let warnedTwice = false
120
+
121
+ /** Two middlewares made by this function: the first wins, the second one's options would be lost silently. */
122
+ function warnTwice(): void {
123
+ if (warnedTwice) return
124
+ warnedTwice = true
125
+ console.warn('[i18n-micro] i18nIntegration already installs the middleware: pass `middleware: false` to it to use your own createI18nMiddleware.')
126
+ }
127
+
128
+ /** Create Astro middleware for i18n locale detection */
129
+ export function createI18nMiddleware(options: I18nMiddlewareOptions): MiddlewareHandler {
130
+ const { i18n: globalI18n, defaultLocale, localeObjects, autoDetect = true } = options
131
+ const localeCookie = options.localeCookie === undefined ? DEFAULT_ASTRO_LOCALE_COOKIE : options.localeCookie
132
+ const allLocales = localeObjects ?? options.locales.map((code) => ({ code }))
133
+ const base = normalizeBase(options.base)
134
+ const routing: I18nRouting = {
135
+ defaultLocale,
136
+ base,
137
+ locales: getEnabledLocaleCodes(allLocales).filter((code) => options.locales.includes(code)),
138
+ strategy: completeStrategy(options.routingStrategy, allLocales, defaultLocale),
138
139
  }
140
+ const cookieOptions = { path: base || '/', maxAge: DEFAULT_COOKIE_MAX_AGE, sameSite: 'lax' as const }
141
+ const varyOn = localeCookie === null ? 'Accept-Language' : 'Cookie, Accept-Language'
139
142
 
140
- // 2. Try cookie (skip if localeCookie is null)
141
- if (localeCookie !== null && locale === defaultLocale && cookies.get(localeCookie)) {
142
- const cookieLocale = cookies.get(localeCookie)?.value
143
- if (cookieLocale && locales.includes(cookieLocale)) {
144
- locale = cookieLocale
143
+ const handler: MiddlewareHandler = async (context, next) => {
144
+ // A locale already in locals (Astro.rewrite) is kept, not redetected.
145
+ if (context.locals.locale && context.locals.i18n) {
146
+ if (context.locals.i18nHandler && context.locals.i18nHandler !== handler) warnTwice()
147
+ return next()
145
148
  }
149
+
150
+ const request = resolveRequest(context, routing)
151
+ const { path, url, locale, partOfPage } = request
152
+
153
+ // Coming from a page of another locale is the visitor's choice, whatever link took them here:
154
+ // no landing to redirect, or a switcher could never reach `/`.
155
+ const page = asksForPage(context, request)
156
+ const switched = page && isSwitch(context, request, routing)
157
+
158
+ // autoDetect is a redirect hint on the home page only (as the Nuxt module's autoDetectPath '/'):
159
+ // a visitor whose cookie / Accept-Language prefers another locale is sent to that locale's home.
160
+ // Only a page view: a redirect in answer to a server island would put a whole page in its slot.
161
+ const home = page && path === '/'
162
+ const hinted = autoDetect && home && !switched
163
+ // only here: reading the headers of a prerendered page makes Astro warn
164
+ const preferred = hinted
165
+ ? preferredLocale(path, routing, { cookies: context.cookies, headers: context.request.headers, localeCookie, autoDetect: true })
166
+ : locale
167
+ // the home page is a redirect or not depending on the cookie and the header, a part of a page and a switch on the Referer: shared caches key on them
168
+ const vary = [partOfPage || switched ? 'Referer' : '', autoDetect && home ? varyOn : ''].filter(Boolean).join(', ') || null
169
+ const target = preferred !== locale || (home && options.redirectToDefault) ? homeOf(preferred, routing) : path
170
+ if (target !== path) return withVary(context.redirect(withBase(target, base) + url.search, 302), vary)
171
+
172
+ Object.assign(context.locals, requestLocals(globalI18n, request, routing, allLocales, handler))
173
+ const response = await next()
174
+ // Remembered once it is a page the visitor saw: an endpoint they only fetched is no choice.
175
+ if (switched && localeCookie !== null && isPageView(context, response)) context.cookies.set(localeCookie, locale, cookieOptions)
176
+ return withVary(response, vary)
146
177
  }
178
+ return handler
179
+ }
147
180
 
148
- // 3. Try Accept-Language
149
- if (locale === defaultLocale) {
150
- try {
151
- const detectedFromHeader = detectLocaleFromAcceptLanguage(headers.get('accept-language') ?? undefined, locales)
152
- if (detectedFromHeader) {
153
- locale = detectedFromHeader
154
- }
155
- } catch {
156
- // Headers may be unavailable in static mode
157
- // Fallback to default locale
158
- }
181
+ /** What a page reads: a per-request instance sharing the global dictionaries, and the request's model. */
182
+ function requestLocals(globalI18n: AstroI18n, request: I18nRequest, routing: I18nRouting, locales: Locale[], handler: MiddlewareHandler) {
183
+ const { url, path, locale, route } = request
184
+ const i18n = globalI18n.fork(locale)
185
+ i18n.setRoute(route)
186
+ const strategy = Object.create(routing.strategy) as I18nRoutingStrategy
187
+ strategy.getCurrentPath = () => path
188
+ strategy.getRoute = () => ({ fullPath: path + url.search, query: Object.fromEntries(url.searchParams) })
189
+ return {
190
+ i18n,
191
+ locale,
192
+ defaultLocale: routing.defaultLocale,
193
+ locales,
194
+ currentUrl: url,
195
+ routingStrategy: strategy,
196
+ i18nBase: routing.base,
197
+ i18nHandler: handler,
159
198
  }
199
+ }
160
200
 
161
- return locale
201
+ /** A locale's home: the strategy's path for `/` in that locale, else `/<locale>` (an unprefixed URL is the default locale's). */
202
+ function homeOf(locale: string, { strategy, defaultLocale }: I18nRouting): string {
203
+ const resolved = strategy.resolvePath!('/', locale)
204
+ const path = typeof resolved === 'string' ? resolved : resolved.path
205
+ return path || (locale === defaultLocale ? '/' : `/${locale}`)
206
+ }
207
+
208
+ function withVary(response: Response, vary: string | null): Response {
209
+ if (vary === null) return response
210
+ try {
211
+ response.headers.append('Vary', vary)
212
+ } catch {
213
+ // immutable headers (a fetched Response): left as they are
214
+ }
215
+ return response
162
216
  }
package/src/node.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `@i18n-micro/astro/node`: reading a locales directory at runtime, for a `src/middleware.ts` of your
3
+ * own. Node only. `i18nIntegration` bundles the directory by itself, which also works on the edge.
4
+ */
5
+ import { resolve } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import type { Translations } from '@i18n-micro/types'
8
+ import { listTranslationFiles, loadTranslationDir, loadTranslationFiles } from '@i18n-micro/utils/load-dir'
9
+ import type { TranslationFileBuckets } from '@i18n-micro/utils/parse-path'
10
+
11
+ export interface LoadTranslationsOptions {
12
+ translationDir: string
13
+ rootDir?: string
14
+ disablePageLocales?: boolean
15
+ }
16
+
17
+ export interface LoadedTranslations extends TranslationFileBuckets<Translations> {}
18
+
19
+ /** Load all translations from a directory: flat (`en.json`) and nested (`pages/home/en.json`). */
20
+ export function loadTranslationsFromDir(options: LoadTranslationsOptions): LoadedTranslations {
21
+ const { translationDir, rootDir = process.cwd(), disablePageLocales = false } = options
22
+ return loadTranslationDir<Translations>(resolve(rootDir, translationDir), disablePageLocales)
23
+ }
24
+
25
+ /** The directory's dictionaries and the files they came from, for the integration's build. */
26
+ export function readTranslationDir(options: { translationDir: string; rootDir: URL; disablePageLocales?: boolean }) {
27
+ const dir = resolve(fileURLToPath(options.rootDir), options.translationDir)
28
+ const files = listTranslationFiles(dir)
29
+ return {
30
+ messages: loadTranslationFiles<Translations>(files, options.disablePageLocales),
31
+ // the folder too: with no file yet, it is what a first one reloads through
32
+ files: [dir, ...files.map((file) => file.absolutePath)],
33
+ }
34
+ }
35
+
36
+ /** Load a directory into an instance, replacing the chunks it finds. */
37
+ export function loadTranslationsIntoI18n(
38
+ i18n: {
39
+ addTranslations: (locale: string, translations: Translations, merge?: boolean) => void
40
+ addRouteTranslations: (locale: string, routeName: string, translations: Translations, merge?: boolean) => void
41
+ keepMessages?: (messages: Record<string, Translations>, routeMessages: Record<string, Record<string, Translations>>) => void
42
+ },
43
+ options: LoadTranslationsOptions,
44
+ ): void {
45
+ const { root, routes } = loadTranslationsFromDir(options)
46
+ for (const [locale, translations] of Object.entries(root)) i18n.addTranslations(locale, translations, false)
47
+ for (const [routeName, routeTranslations] of Object.entries(routes)) {
48
+ for (const [locale, translations] of Object.entries(routeTranslations)) i18n.addRouteTranslations(locale, routeName, translations, false)
49
+ }
50
+ i18n.keepMessages?.(root, routes)
51
+ }