@i18n-micro/astro 1.3.24 → 2.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.
- package/LICENSE +1 -1
- package/README.md +8 -64
- package/dist/auto-middleware.d.ts +1 -0
- package/dist/client/core.d.cts +86 -0
- package/dist/client/core.d.ts +71 -8
- package/dist/client/index.d.cts +2 -6
- package/dist/client/index.d.ts +2 -6
- package/dist/client/index.js +1 -6
- package/dist/client/preact.d.ts +12 -23
- package/dist/client/preact.js +13 -37
- package/dist/client/react.d.ts +14 -21
- package/dist/client/react.js +12 -37
- package/dist/client/svelte.d.ts +14 -14
- package/dist/client/svelte.js +17 -47
- package/dist/client/vue.d.ts +15 -15
- package/dist/client/vue.js +37 -2
- package/dist/composer.d.cts +16 -0
- package/dist/composer.d.ts +10 -30
- package/dist/core-CgrgdmGy.cjs +1 -0
- package/dist/core-DTAcn2yF.js +72 -0
- package/dist/env.d.ts +8 -2
- package/dist/index.cjs +11 -3
- package/dist/index.d.cts +10 -13
- package/dist/index.d.ts +10 -13
- package/dist/index.mjs +171 -482
- package/dist/integration.d.cts +26 -0
- package/dist/integration.d.ts +14 -15
- package/dist/middleware-BT3OA5YL.cjs +1 -0
- package/dist/middleware-Ch8E0iV_.js +257 -0
- package/dist/middleware.d.cts +34 -0
- package/dist/middleware.d.ts +17 -8
- package/dist/middleware.js +23 -0
- package/dist/node.d.ts +26 -0
- package/dist/node.js +22 -0
- package/dist/request.d.ts +69 -0
- package/dist/router/adapter.d.cts +9 -0
- package/dist/router/adapter.d.ts +5 -4
- package/dist/router/types.d.cts +66 -0
- package/dist/router/types.d.ts +3 -2
- package/dist/utils.d.cts +112 -0
- package/dist/utils.d.ts +39 -35
- package/dist/virtual.d.ts +21 -0
- package/package.json +28 -15
- package/src/auto-middleware.ts +26 -0
- package/src/client/core.ts +124 -59
- package/src/client/index.ts +2 -11
- package/src/client/preact.tsx +12 -127
- package/src/client/react.tsx +11 -123
- package/src/client/svelte.ts +6 -69
- package/src/client/vue.ts +22 -77
- package/src/components/i18n-link.astro +2 -59
- package/src/components/i18n-switcher.astro +46 -149
- package/src/components/i18n-t.astro +6 -54
- package/src/composer.ts +25 -115
- package/src/env.d.ts +7 -0
- package/src/index.ts +1 -6
- package/src/integration.ts +113 -52
- package/src/middleware.ts +174 -120
- package/src/node.ts +51 -0
- package/src/request.ts +177 -0
- package/src/router/adapter.ts +47 -149
- package/src/router/types.ts +3 -2
- package/src/utils.ts +149 -323
- package/src/virtual.d.ts +21 -0
- package/dist/components/index.d.cts +0 -0
- package/dist/components/index.d.ts +0 -0
- package/dist/core-DwkpOV-H.cjs +0 -1
- package/dist/core-VSnkGRWR.js +0 -28
- package/dist/load-translations.d.ts +0 -43
- package/dist/routing-context.d.ts +0 -4
- package/dist/vue-BNiEwZr2.cjs +0 -5
- package/dist/vue-CWHjIOiS.js +0 -1724
- package/src/components/index.ts +0 -11
- package/src/load-translations.ts +0 -106
- package/src/routing-context.ts +0 -23
package/src/integration.ts
CHANGED
|
@@ -1,78 +1,132 @@
|
|
|
1
|
-
import type { Locale
|
|
2
|
-
import
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
28
|
-
const
|
|
60
|
+
function i18nVitePlugin(options: I18nIntegrationOptions, config: AstroConfig) {
|
|
61
|
+
const code = `export const config = ${JSON.stringify(publishedConfig(options, config.base))}`
|
|
29
62
|
|
|
30
|
-
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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 =
|
|
107
|
+
localeCookie: string | null = DEFAULT_ASTRO_LOCALE_COOKIE,
|
|
123
108
|
routingStrategy?: I18nRoutingStrategy | null,
|
|
109
|
+
autoDetect = true,
|
|
124
110
|
): string {
|
|
125
|
-
const strategy =
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
+
}
|