@edgekits/astro-i18n 0.1.8 → 0.2.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/CHANGELOG.md +22 -0
- package/README.md +87 -23
- package/dist/integration/index.d.ts +25 -4
- package/dist/integration/index.js +35 -9
- package/dist/integration/vite-plugin.d.ts +1 -1
- package/dist/types.d.ts +7 -55
- package/dist/types.js +1 -44
- package/package.json +7 -17
- package/src/components/LanguageSwitcher.astro +89 -93
- package/src/config.entry.ts +5 -36
- package/src/constants.ts +5 -3
- package/src/cookie-storage.ts +19 -25
- package/src/fetcher.ts +3 -304
- package/src/format.ts +3 -107
- package/src/instance.ts +44 -0
- package/src/integration/index.ts +38 -9
- package/src/integration/vite-plugin.ts +1 -1
- package/src/middleware/i18n.ts +232 -114
- package/src/resolve-locale.ts +7 -39
- package/src/runtime.ts +2 -2
- package/src/schema.ts +3 -11
- package/src/translations-keys.ts +7 -38
- package/src/types.ts +16 -116
- package/dist/cli/generate.d.ts +0 -50
- package/dist/cli/generate.js +0 -160
- package/dist/cli/hashes.d.ts +0 -19
- package/dist/cli/hashes.js +0 -42
- package/dist/cli/index.d.ts +0 -2
- package/dist/cli/index.js +0 -372
- package/dist/cli/key-prefix-check.d.ts +0 -6
- package/dist/cli/key-prefix-check.js +0 -27
- package/dist/cli/load-config.d.ts +0 -7
- package/dist/cli/load-config.js +0 -19
- package/dist/cli/purge.d.ts +0 -29
- package/dist/cli/purge.js +0 -94
- package/dist/cli/wrangler.d.ts +0 -10
- package/dist/cli/wrangler.js +0 -71
- package/dist/cli/zone.d.ts +0 -21
- package/dist/cli/zone.js +0 -42
- package/dist/key-format.d.ts +0 -8
- package/dist/key-format.js +0 -15
- package/src/accept-language.ts +0 -56
- package/src/assemble.ts +0 -29
- package/src/cli/generate.ts +0 -205
- package/src/cli/hashes.ts +0 -66
- package/src/cli/index.ts +0 -472
- package/src/cli/key-prefix-check.ts +0 -31
- package/src/cli/load-config.ts +0 -26
- package/src/cli/purge.ts +0 -122
- package/src/cli/wrangler.ts +0 -96
- package/src/cli/zone.ts +0 -64
- package/src/deep-merge.ts +0 -38
- package/src/key-format.ts +0 -17
- package/src/locale-fallback.ts +0 -58
- package/src/markdown-path.ts +0 -31
- package/src/parse-boolean.ts +0 -20
package/src/middleware/i18n.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import type { MiddlewareHandler } from 'astro'
|
|
1
|
+
import type { AstroCookies, MiddlewareHandler } from 'astro'
|
|
2
|
+
import {
|
|
3
|
+
localizedPath,
|
|
4
|
+
MARKDOWN_PATH,
|
|
5
|
+
markdownRedirectTarget,
|
|
6
|
+
resolveFallbackLocale as resolveFallbackFrom,
|
|
7
|
+
type I18nRouting,
|
|
8
|
+
} from '@edgekits/i18n-core'
|
|
2
9
|
import { LocaleSchema } from '../schema'
|
|
3
10
|
import { I18N_CONFIG } from '../config'
|
|
4
11
|
import { DEFAULT_LOCALE, type Locale } from '../constants'
|
|
5
|
-
import {
|
|
6
|
-
import { resolveFallbackLocale as resolveFallbackFrom } from '../locale-fallback'
|
|
7
|
-
import { MARKDOWN_PATH, markdownRedirectTarget } from '../markdown-path'
|
|
12
|
+
import { readCookieLang, setCookieLang, type CookieLang } from '../cookie-storage'
|
|
8
13
|
import { resolveLocaleForTranslations } from '../resolve-locale'
|
|
9
14
|
|
|
10
15
|
// Regex to identify public static assets (images, fonts, scripts, etc.)
|
|
@@ -35,14 +40,32 @@ const IGNORED_PREFIXES = [
|
|
|
35
40
|
// Extracts the context type from Astro's MiddlewareHandler for cleaner function signatures.
|
|
36
41
|
type I18nMiddlewareContext = Parameters<MiddlewareHandler>[0]
|
|
37
42
|
|
|
43
|
+
/**
|
|
44
|
+
* What the middleware reads: the resolved routing mode, the locale settings and the lang
|
|
45
|
+
* cookie over Astro's cookies. `i18nMiddleware` below is built from the project's config
|
|
46
|
+
* and instance; tests build one for either mode or a single locale.
|
|
47
|
+
*/
|
|
48
|
+
export type I18nMiddlewareDeps = {
|
|
49
|
+
routing: I18nRouting
|
|
50
|
+
defaultLocale: Locale
|
|
51
|
+
markdownLocale: string | undefined
|
|
52
|
+
countryToLocale: Record<string, string> | undefined
|
|
53
|
+
// The locale when the value is a supported one, otherwise null.
|
|
54
|
+
parseLocale: (value: unknown) => Locale | null
|
|
55
|
+
resolveLocaleForTranslations: (locale: Locale) => Locale
|
|
56
|
+
readCookieLang: (cookies: AstroCookies) => CookieLang
|
|
57
|
+
setCookieLang: (cookies: AstroCookies, locale: Locale) => void
|
|
58
|
+
}
|
|
59
|
+
|
|
38
60
|
// Two kinds of redirect, by whether the target depends on the visitor.
|
|
39
61
|
//
|
|
40
62
|
// Deterministic normalisations (a missing trailing slash, a `.md` path moved to
|
|
41
|
-
// `markdownLocale
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
63
|
+
// `markdownLocale`, the default locale's prefix dropped in 'prefix-except-default') always
|
|
64
|
+
// lead to the same URL, so they are permanent in production: 301 consolidates canonical
|
|
65
|
+
// signals in Google. They use REDIRECT_STATUS: 302 under `astro dev`
|
|
66
|
+
// (`import.meta.env.DEV`) so nothing gets cached while testing, 301 in any production build
|
|
67
|
+
// (incl. `astro preview` / `wrangler dev`). `?.`: plain Node (the tests) has no
|
|
68
|
+
// `import.meta.env`, which counts as production.
|
|
46
69
|
//
|
|
47
70
|
// Negotiated redirects (no locale or an invalid one in the URL: the target comes from the
|
|
48
71
|
// visitor's cookie, Accept-Language or country) are always temporary and uncacheable, in
|
|
@@ -53,14 +76,15 @@ const REDIRECT_STATUS = import.meta.env?.DEV ? 302 : 301
|
|
|
53
76
|
|
|
54
77
|
// A redirect whose target depends on the visitor: temporary, never stored by a shared
|
|
55
78
|
// cache, and keyed on the request headers that chose it (the country is not a request
|
|
56
|
-
// header, hence `no-store` rather than relying on Vary alone).
|
|
57
|
-
|
|
79
|
+
// header, hence `no-store` rather than relying on Vary alone). `vary` names only the
|
|
80
|
+
// headers that were consulted: the root of 'prefix-except-default' reads the cookie alone.
|
|
81
|
+
function negotiatedRedirect(target: string, vary = 'Cookie, Accept-Language'): Response {
|
|
58
82
|
return new Response(null, {
|
|
59
83
|
status: 302,
|
|
60
84
|
headers: {
|
|
61
85
|
Location: target,
|
|
62
86
|
'Cache-Control': 'private, no-store',
|
|
63
|
-
Vary:
|
|
87
|
+
Vary: vary,
|
|
64
88
|
},
|
|
65
89
|
})
|
|
66
90
|
}
|
|
@@ -82,106 +106,56 @@ function buildLocalizedPath(locale: Locale, rest: string[]): string {
|
|
|
82
106
|
return suffix ? `/${locale}/${suffix}/` : `/${locale}/`
|
|
83
107
|
}
|
|
84
108
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
// regional tags before matching): each tag by q, full tag, then its language part.
|
|
90
|
-
// 3. Only when no tag matched: the Cloudflare Geo-IP country, through the project's
|
|
91
|
-
// `countryToLocale` table (skipped when the config has none).
|
|
92
|
-
// 4. Finally, falls back to the application's DEFAULT_LOCALE.
|
|
93
|
-
function resolveFallbackLocale(context: I18nMiddlewareContext): Locale {
|
|
94
|
-
return resolveFallbackFrom<Locale>(
|
|
95
|
-
{
|
|
96
|
-
cookieLocale: () => getCookieLang(context.cookies),
|
|
97
|
-
acceptLanguage: () => context.request.headers.get('accept-language'),
|
|
98
|
-
// cf is undefined in local dev unless simulated
|
|
99
|
-
country: () => context.request?.cf?.country,
|
|
100
|
-
},
|
|
101
|
-
{
|
|
102
|
-
parseLocale: (value) => {
|
|
103
|
-
const parsed = LocaleSchema.safeParse(value)
|
|
104
|
-
return parsed.success ? parsed.data : null
|
|
105
|
-
},
|
|
106
|
-
defaultLocale: DEFAULT_LOCALE,
|
|
107
|
-
countryToLocale: I18N_CONFIG.countryToLocale,
|
|
108
|
-
},
|
|
109
|
-
)
|
|
110
|
-
}
|
|
109
|
+
/** The i18n middleware for one set of deps (one routing mode, one locale list). */
|
|
110
|
+
export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandler {
|
|
111
|
+
const isLocale = (segment: string) => deps.parseLocale(segment) !== null
|
|
112
|
+
const pathOptions = { routing: deps.routing, defaultLocale: deps.defaultLocale, isLocale }
|
|
111
113
|
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
114
|
+
// Resolves the most appropriate locale for the user based on a fallback cascade
|
|
115
|
+
// (the core's locale-fallback.ts, where the order is documented and tested):
|
|
116
|
+
// 1. User's saved cookie preference.
|
|
117
|
+
// 2. The Accept-Language header, read here rather than through Astro (which drops
|
|
118
|
+
// regional tags before matching): each tag by q, full tag, then its language part.
|
|
119
|
+
// 3. Only when no tag matched: the Cloudflare Geo-IP country, through the project's
|
|
120
|
+
// `countryToLocale` table (skipped when the config has none).
|
|
121
|
+
// 4. Finally, falls back to the application's default locale.
|
|
122
|
+
function resolveFallbackLocale(context: I18nMiddlewareContext): Locale {
|
|
123
|
+
return resolveFallbackFrom<Locale>(
|
|
124
|
+
{
|
|
125
|
+
cookieLocale: () => deps.readCookieLang(context.cookies).locale,
|
|
126
|
+
acceptLanguage: () => context.request.headers.get('accept-language'),
|
|
127
|
+
// cf is undefined in local dev unless simulated
|
|
128
|
+
country: () => context.request?.cf?.country,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
parseLocale: deps.parseLocale,
|
|
132
|
+
defaultLocale: deps.defaultLocale,
|
|
133
|
+
countryToLocale: deps.countryToLocale,
|
|
134
|
+
},
|
|
135
|
+
)
|
|
130
136
|
}
|
|
131
137
|
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const markdownTarget = markdownRedirectTarget(pathname, {
|
|
141
|
-
isLocale: (segment) => LocaleSchema.safeParse(segment).success,
|
|
142
|
-
markdownLocale: I18N_CONFIG.markdownLocale,
|
|
143
|
-
})
|
|
144
|
-
if (markdownTarget) {
|
|
145
|
-
return context.redirect(markdownTarget, REDIRECT_STATUS)
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const response = await next()
|
|
149
|
-
|
|
150
|
-
const contentType = response.headers.get('content-type') || ''
|
|
151
|
-
const isHtml = contentType.includes('text/html')
|
|
152
|
-
const isRedirect = response.status >= 300 && response.status < 400
|
|
153
|
-
|
|
154
|
-
// If Astro attempts to return HTML for a static/API request (and it's not a redirect),
|
|
155
|
-
// it means the file wasn't found and Astro rendered a fallback page (404.astro or catch-all).
|
|
156
|
-
// We strictly intercept this and return a lightweight text-based 404 instead.
|
|
157
|
-
// Why? To mitigate Directory Bruteforcing / Fuzzing attacks and save resources.
|
|
158
|
-
if (isHtml && !isRedirect) {
|
|
159
|
-
// Aggressively save Worker CPU cycles for missing static files
|
|
160
|
-
return new Response('Not found', {
|
|
161
|
-
status: 404,
|
|
162
|
-
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
163
|
-
})
|
|
164
|
-
}
|
|
138
|
+
// The `.md` redirect target, or null. 'prefix': the core's markdown-path.ts as is.
|
|
139
|
+
// 'prefix-except-default': the same target without the default locale's prefix (through
|
|
140
|
+
// localizedPath, minus the trailing slash a `.md` path never has); a path already at
|
|
141
|
+
// `/<default>/…` is redirected too, since that is no longer canonical.
|
|
142
|
+
function markdownTarget(pathname: string): string | null {
|
|
143
|
+
const target = markdownRedirectTarget(pathname, { isLocale, markdownLocale: deps.markdownLocale })
|
|
144
|
+
if (deps.routing === 'prefix') return target
|
|
145
|
+
if (!deps.markdownLocale || !MARKDOWN_PATH.test(pathname)) return null
|
|
165
146
|
|
|
166
|
-
|
|
147
|
+
const localized = localizedPath(deps.markdownLocale, target ?? pathname, pathOptions).slice(0, -1)
|
|
148
|
+
return localized === pathname ? null : localized
|
|
167
149
|
}
|
|
168
150
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
return await next()
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// 2. URL has first segment — validate as locale
|
|
181
|
-
const parsed = LocaleSchema.safeParse(firstSegment)
|
|
182
|
-
const urlLocale: Locale | null = parsed.success ? parsed.data : null
|
|
183
|
-
|
|
184
|
-
if (urlLocale) {
|
|
151
|
+
// A URL that carries a valid locale: cookie sync, locals, trailing-slash normalisation.
|
|
152
|
+
async function serveLocalized(
|
|
153
|
+
context: I18nMiddlewareContext,
|
|
154
|
+
next: Parameters<MiddlewareHandler>[1],
|
|
155
|
+
urlLocale: Locale,
|
|
156
|
+
segments: string[],
|
|
157
|
+
): Promise<Response> {
|
|
158
|
+
const pathname = new URL(context.request.url).pathname
|
|
185
159
|
// Keep the lang cookie in sync with the URL locale — but ONLY update an
|
|
186
160
|
// EXISTING cookie that differs or does not parse. Rationale (HTML edge cache, P1/P2):
|
|
187
161
|
// - The old unconditional set emitted `Set-Cookie` on EVERY localized
|
|
@@ -196,14 +170,14 @@ export const i18nMiddleware: MiddlewareHandler = async (context, next) => {
|
|
|
196
170
|
// - A cookie that is present but invalid (a foreign value from another site
|
|
197
171
|
// on the same host, a locale this site does not have) is corrected to the
|
|
198
172
|
// URL locale too, so a stale value never outlives the next request.
|
|
199
|
-
const cookie = readCookieLang(context.cookies)
|
|
173
|
+
const cookie = deps.readCookieLang(context.cookies)
|
|
200
174
|
if (cookie.present && cookie.locale !== urlLocale) {
|
|
201
|
-
setCookieLang(context.cookies, urlLocale)
|
|
175
|
+
deps.setCookieLang(context.cookies, urlLocale)
|
|
202
176
|
}
|
|
203
177
|
// Set uiLocale to Astro locals
|
|
204
178
|
context.locals.uiLocale = urlLocale
|
|
205
179
|
// Define and set translationLocale to Astro locals
|
|
206
|
-
const translationLocale = resolveLocaleForTranslations(urlLocale)
|
|
180
|
+
const translationLocale = deps.resolveLocaleForTranslations(urlLocale)
|
|
207
181
|
context.locals.translationLocale = translationLocale
|
|
208
182
|
|
|
209
183
|
// Normalize trailing slash and structure
|
|
@@ -215,12 +189,156 @@ export const i18nMiddleware: MiddlewareHandler = async (context, next) => {
|
|
|
215
189
|
return await next()
|
|
216
190
|
}
|
|
217
191
|
|
|
218
|
-
//
|
|
219
|
-
|
|
192
|
+
// 'prefix-except-default': the default locale at the root, the others under their prefix.
|
|
193
|
+
async function serveExceptDefault(
|
|
194
|
+
context: I18nMiddlewareContext,
|
|
195
|
+
next: Parameters<MiddlewareHandler>[1],
|
|
196
|
+
segments: string[],
|
|
197
|
+
firstSegment: string | null,
|
|
198
|
+
): Promise<Response> {
|
|
199
|
+
const pathname = new URL(context.request.url).pathname
|
|
200
|
+
const urlLocale = firstSegment ? deps.parseLocale(firstSegment) : null
|
|
201
|
+
|
|
202
|
+
// The default locale's prefix is not canonical: one permanent hop to the path without it.
|
|
203
|
+
if (urlLocale === deps.defaultLocale) {
|
|
204
|
+
return context.redirect(localizedPath(deps.defaultLocale, pathname, pathOptions), REDIRECT_STATUS)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (urlLocale) {
|
|
208
|
+
return await serveLocalized(context, next, urlLocale, segments)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// A default-locale page (the root included). The URL names its language, so neither
|
|
212
|
+
// Accept-Language nor the country is consulted; only a saved choice of another locale
|
|
213
|
+
// leads away from it.
|
|
214
|
+
context.locals.uiLocale = deps.defaultLocale
|
|
215
|
+
context.locals.translationLocale = deps.resolveLocaleForTranslations(deps.defaultLocale)
|
|
216
|
+
|
|
217
|
+
const cookie = deps.readCookieLang(context.cookies)
|
|
218
|
+
if (cookie.present && cookie.locale !== deps.defaultLocale) {
|
|
219
|
+
if (cookie.locale) {
|
|
220
|
+
return negotiatedRedirect(localizedPath(cookie.locale, pathname, pathOptions), 'Cookie')
|
|
221
|
+
}
|
|
222
|
+
// Present but invalid: corrected to the URL's locale, as serveLocalized does.
|
|
223
|
+
deps.setCookieLang(context.cookies, deps.defaultLocale)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const normalized = localizedPath(deps.defaultLocale, pathname, pathOptions)
|
|
227
|
+
if (pathname !== normalized) {
|
|
228
|
+
return context.redirect(normalized, REDIRECT_STATUS)
|
|
229
|
+
}
|
|
220
230
|
|
|
221
|
-
|
|
222
|
-
|
|
231
|
+
// The project's pages live under `src/pages/[lang]/`: render `/<default>/…` while the
|
|
232
|
+
// URL stays. `next(path)` rewrites without running the middleware again
|
|
233
|
+
// (astro/dist/core/middleware/astro-middleware.js:33-40) and keeps `locals`;
|
|
234
|
+
// `context.rewrite()` would re-run it (astro/dist/core/rewrites/handler.js:67-71).
|
|
235
|
+
return await next(`/${deps.defaultLocale}${pathname}`)
|
|
223
236
|
}
|
|
224
237
|
|
|
225
|
-
|
|
238
|
+
// Main i18n middleware handler. Intercepts incoming requests, manages routing,
|
|
239
|
+
// sets locale context, and handles soft 404 protections for edge environments.
|
|
240
|
+
return async (context, next) => {
|
|
241
|
+
const url = new URL(context.request.url)
|
|
242
|
+
const pathname = url.pathname
|
|
243
|
+
|
|
244
|
+
// 1. Split the URL early to access its segments
|
|
245
|
+
const segments = pathname.split('/').filter(Boolean) // "/es/about/" -> ["es", "about"]
|
|
246
|
+
const firstSegment = segments[0] ?? null
|
|
247
|
+
|
|
248
|
+
// 2. SAFETY NET (For static file 404 pages)
|
|
249
|
+
// Attempt to infer the locale from the URL, or fallback to the default.
|
|
250
|
+
let safeLocale = deps.defaultLocale
|
|
251
|
+
if (firstSegment) {
|
|
252
|
+
const parsed = deps.parseLocale(firstSegment)
|
|
253
|
+
if (parsed !== null) {
|
|
254
|
+
safeLocale = parsed
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Inject the locale into the context BEFORE the bypass check!
|
|
259
|
+
context.locals.uiLocale = safeLocale
|
|
260
|
+
context.locals.translationLocale = deps.resolveLocaleForTranslations(safeLocale)
|
|
261
|
+
|
|
262
|
+
// 3. Bypass static files and system paths (with Soft 404 protection)
|
|
263
|
+
if (shouldBypassI18n(pathname)) {
|
|
264
|
+
// .md endpoints: one redirect to the project's `markdownLocale`, no trailing slash
|
|
265
|
+
// (the core's markdown-path.ts). Without `markdownLocale` they pass through untouched.
|
|
266
|
+
const target = markdownTarget(pathname)
|
|
267
|
+
if (target) {
|
|
268
|
+
return context.redirect(target, REDIRECT_STATUS)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 'prefix-except-default': a `.md` path without a locale segment belongs to the
|
|
272
|
+
// default locale, whose endpoint lives under `[lang]`.
|
|
273
|
+
const rootMarkdown =
|
|
274
|
+
deps.routing !== 'prefix' && MARKDOWN_PATH.test(pathname) && (!firstSegment || !isLocale(firstSegment))
|
|
275
|
+
const response = rootMarkdown ? await next(`/${deps.defaultLocale}${pathname}`) : await next()
|
|
276
|
+
|
|
277
|
+
const contentType = response.headers.get('content-type') || ''
|
|
278
|
+
const isHtml = contentType.includes('text/html')
|
|
279
|
+
const isRedirect = response.status >= 300 && response.status < 400
|
|
280
|
+
|
|
281
|
+
// If Astro attempts to return HTML for a static/API request (and it's not a redirect),
|
|
282
|
+
// it means the file wasn't found and Astro rendered a fallback page (404.astro or catch-all).
|
|
283
|
+
// We strictly intercept this and return a lightweight text-based 404 instead.
|
|
284
|
+
// Why? To mitigate Directory Bruteforcing / Fuzzing attacks and save resources.
|
|
285
|
+
if (isHtml && !isRedirect) {
|
|
286
|
+
// Aggressively save Worker CPU cycles for missing static files
|
|
287
|
+
return new Response('Not found', {
|
|
288
|
+
status: 404,
|
|
289
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return response
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (deps.routing !== 'prefix') {
|
|
297
|
+
return await serveExceptDefault(context, next, segments, firstSegment)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const fallbackLocale = resolveFallbackLocale(context)
|
|
301
|
+
|
|
302
|
+
// 1. No locale in URL — redirect to fallback locale root
|
|
303
|
+
if (!firstSegment) {
|
|
304
|
+
const target = buildLocalizedPath(fallbackLocale, [])
|
|
305
|
+
if (pathname !== target) {
|
|
306
|
+
return negotiatedRedirect(target)
|
|
307
|
+
}
|
|
308
|
+
return await next()
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// 2. URL has first segment — validate as locale
|
|
312
|
+
const urlLocale = deps.parseLocale(firstSegment)
|
|
313
|
+
|
|
314
|
+
if (urlLocale) {
|
|
315
|
+
return await serveLocalized(context, next, urlLocale, segments)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 3. Invalid locale in URL -> treat the whole path as content missing the locale prefix.
|
|
319
|
+
const target = buildLocalizedPath(fallbackLocale, segments)
|
|
320
|
+
|
|
321
|
+
if (pathname !== target) {
|
|
322
|
+
return negotiatedRedirect(target)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return await next()
|
|
326
|
+
}
|
|
226
327
|
}
|
|
328
|
+
|
|
329
|
+
/** The project's middleware: its resolved config and its engine instance. */
|
|
330
|
+
export const i18nMiddleware: MiddlewareHandler = createI18nMiddleware({
|
|
331
|
+
// The integration resolves an absent `routing` before serialising the config; the
|
|
332
|
+
// fallback covers a config that reached the virtual module some other way.
|
|
333
|
+
routing: I18N_CONFIG.routing ?? 'prefix-except-default',
|
|
334
|
+
defaultLocale: DEFAULT_LOCALE,
|
|
335
|
+
markdownLocale: I18N_CONFIG.markdownLocale,
|
|
336
|
+
countryToLocale: I18N_CONFIG.countryToLocale,
|
|
337
|
+
parseLocale: (value) => {
|
|
338
|
+
const parsed = LocaleSchema.safeParse(value)
|
|
339
|
+
return parsed.success ? parsed.data : null
|
|
340
|
+
},
|
|
341
|
+
resolveLocaleForTranslations,
|
|
342
|
+
readCookieLang,
|
|
343
|
+
setCookieLang,
|
|
344
|
+
})
|
package/src/resolve-locale.ts
CHANGED
|
@@ -1,40 +1,17 @@
|
|
|
1
1
|
import type { AstroGlobal } from 'astro'
|
|
2
2
|
|
|
3
|
+
import { i18n } from './instance'
|
|
3
4
|
import { LocaleSchema } from './schema'
|
|
4
5
|
import type { Locale } from './constants'
|
|
5
|
-
import { DEFAULT_LOCALE
|
|
6
|
-
import { LOCALES_WITH_TRANSLATIONS, FULLY_TRANSLATED_LOCALES } from 'virtual:@edgekits/astro-i18n/runtime-constants'
|
|
7
|
-
|
|
8
|
-
// Checking for the presence of at least one file with translations
|
|
9
|
-
function hasTranslations(locale: Locale): boolean {
|
|
10
|
-
return (LOCALES_WITH_TRANSLATIONS as readonly string[]).includes(locale)
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
// Checking the completeness of translations
|
|
14
|
-
function isFullyTranslated(locale: Locale): boolean {
|
|
15
|
-
return (FULLY_TRANSLATED_LOCALES as readonly string[]).includes(locale)
|
|
16
|
-
}
|
|
6
|
+
import { DEFAULT_LOCALE } from './constants'
|
|
17
7
|
|
|
18
8
|
/**
|
|
19
|
-
* Optional:
|
|
20
|
-
* when uiLocale has no translations or (blog) content is missing in required
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* - MissingTranslationType: type of message to show ('ui' or 'content')
|
|
24
|
-
* - null: if banner should not be shown
|
|
9
|
+
* Optional: which “missing translation” banner to show ('ui' or 'content'), or null for
|
|
10
|
+
* none, when uiLocale has no translations or (blog) content is missing in the required
|
|
11
|
+
* language. Translation locale: what is actually loaded from KV, the default locale when
|
|
12
|
+
* there is no bundle for the UI locale. Both are the core's (`createI18n`).
|
|
25
13
|
*/
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
export function checkMissingTranslation(
|
|
29
|
-
uiLocale: Locale,
|
|
30
|
-
isMissingContent: boolean | undefined,
|
|
31
|
-
): MissingTranslationType {
|
|
32
|
-
if (!ENABLE_MISSING_TRANSLATION_BANNER) return null
|
|
33
|
-
|
|
34
|
-
if (isFullyTranslated(uiLocale) && !isMissingContent) return null
|
|
35
|
-
|
|
36
|
-
return isMissingContent ? 'content' : 'ui'
|
|
37
|
-
}
|
|
14
|
+
export const { checkMissingTranslation, resolveLocaleForTranslations } = i18n
|
|
38
15
|
|
|
39
16
|
/**
|
|
40
17
|
* UI / routing locale:
|
|
@@ -46,12 +23,3 @@ export function resolveLocale(Astro: AstroGlobal): Locale {
|
|
|
46
23
|
const parsed = LocaleSchema.safeParse(raw)
|
|
47
24
|
return parsed.success ? parsed.data : DEFAULT_LOCALE
|
|
48
25
|
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Translation locale:
|
|
52
|
-
* - What we actually load from KV
|
|
53
|
-
* - Falls back if there is no bundle for the UI locale
|
|
54
|
-
*/
|
|
55
|
-
export function resolveLocaleForTranslations(locale: Locale): Locale {
|
|
56
|
-
return hasTranslations(locale) ? locale : DEFAULT_LOCALE
|
|
57
|
-
}
|
package/src/runtime.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// import. Ships as source and runs through the project's Vite, next to the virtual
|
|
3
3
|
// modules the integration serves.
|
|
4
4
|
export { fetchTranslations } from './fetcher'
|
|
5
|
-
export { fmt, plural, pluralIcu, toIetfTag, replaceLocaleInPath, getLangFromUrl } from './format'
|
|
6
|
-
export type { PluralCategory, PluralPatterns } from './format'
|
|
5
|
+
export { fmt, plural, pluralIcu, toIetfTag, replaceLocaleInPath, getLangFromUrl, localizedPath } from './format'
|
|
6
|
+
export type { LocalizedPathOptions, PluralCategory, PluralPatterns } from './format'
|
|
7
7
|
export { I18N_CONFIG } from './config'
|
|
8
8
|
export {
|
|
9
9
|
DEFAULT_LOCALE,
|
package/src/schema.ts
CHANGED
|
@@ -15,14 +15,6 @@ const hasNamespaces = (NAMESPACE_KEYS as readonly string[]).length > 0
|
|
|
15
15
|
|
|
16
16
|
export const NamespaceSchema = hasNamespaces ? z.enum(NAMESPACE_KEYS as [string, ...string[]]) : z.string()
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
// If the interface is empty (e.g., on a fresh project clone), fall back to string.
|
|
22
|
-
export type Namespace = keyof GlobalSchema extends never ? string : keyof GlobalSchema
|
|
23
|
-
|
|
24
|
-
// Added intersection N & keyof GlobalSchema so the compiler
|
|
25
|
-
// doesn't throw an error inside Pick when Namespace falls back to string.
|
|
26
|
-
export type PickSchema<N extends Namespace> = [N] extends [keyof GlobalSchema]
|
|
27
|
-
? Pick<GlobalSchema, N & keyof GlobalSchema>
|
|
28
|
-
: any
|
|
18
|
+
// The keys of I18n.Schema (string on a fresh clone) and the namespaces picked from it:
|
|
19
|
+
// the core's types, which its fetcher is typed by.
|
|
20
|
+
export type { Namespace, PickSchema } from '@edgekits/i18n-core'
|
package/src/translations-keys.ts
CHANGED
|
@@ -1,42 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { Locale } from './constants'
|
|
3
|
-
import { formatTranslationCacheUrl, formatTranslationKvKey } from './key-format'
|
|
4
|
-
import type { Namespace } from './schema'
|
|
1
|
+
import { i18n } from './instance'
|
|
5
2
|
|
|
6
3
|
/**
|
|
7
|
-
*
|
|
4
|
+
* The translation KV key and edge-cache URL for this project: the core's builders
|
|
5
|
+
* (`createI18n`), over the same `key-format.ts` the CLI uses for the KV bulk data and the
|
|
6
|
+
* Purge API.
|
|
8
7
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* - the CLI (build: KV bulk data), through the same `key-format.ts`
|
|
12
|
-
*
|
|
13
|
-
* Format: <I18N_CONFIG.keyPrefix>:<namespace>:<locale>
|
|
14
|
-
*/
|
|
15
|
-
export function buildTranslationKvKey(locale: Locale, ns: Namespace): string {
|
|
16
|
-
return formatTranslationKvKey(I18N_CONFIG.keyPrefix, locale, ns)
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Single source of truth for translation cache URL construction.
|
|
21
|
-
*
|
|
22
|
-
* Used by both:
|
|
23
|
-
* - fetcher.ts (runtime: cache.match / cache.put)
|
|
24
|
-
* - the CLI (build: Cloudflare Purge API), through the same `key-format.ts`
|
|
25
|
-
*
|
|
26
|
-
* The URL uses a static, non-versioned structure. Cache invalidation is
|
|
27
|
-
* handled explicitly via the Cloudflare Purge API in `edgekits-i18n migrate`
|
|
28
|
-
* rather than by changing the key on every deploy.
|
|
29
|
-
*
|
|
30
|
-
* Format: https://<I18N_CONFIG.keyPrefix>/<encodeURIComponent(i18n:<locale>:<namespace>)>
|
|
31
|
-
*/
|
|
32
|
-
export function buildTranslationCacheUrl(locale: Locale, ns: Namespace): string {
|
|
33
|
-
return formatTranslationCacheUrl(I18N_CONFIG.keyPrefix, locale, ns)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Build a Cache API Request object from a translation cache URL.
|
|
38
|
-
* The Cache API requires a Request object, not a plain string.
|
|
8
|
+
* Formats: <keyPrefix>:<namespace>:<locale> and
|
|
9
|
+
* https://<keyPrefix>/<encodeURIComponent(i18n:<locale>:<namespace>)>
|
|
39
10
|
*/
|
|
40
|
-
export
|
|
41
|
-
return new Request(buildTranslationCacheUrl(locale, ns))
|
|
42
|
-
}
|
|
11
|
+
export const { buildTranslationKvKey, buildTranslationCacheUrl, buildTranslationCacheRequest } = i18n
|