@edgekits/astro-i18n 0.0.0-stage → 0.1.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 +21 -0
- package/LICENSE +21 -0
- package/README.md +394 -2
- package/dist/cli/generate.d.ts +47 -0
- package/dist/cli/generate.js +133 -0
- package/dist/cli/hashes.d.ts +19 -0
- package/dist/cli/hashes.js +42 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +362 -0
- package/dist/cli/load-config.d.ts +7 -0
- package/dist/cli/load-config.js +19 -0
- package/dist/cli/purge.d.ts +29 -0
- package/dist/cli/purge.js +94 -0
- package/dist/cli/wrangler.d.ts +10 -0
- package/dist/cli/wrangler.js +71 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/integration/index.d.ts +26 -0
- package/dist/integration/index.js +103 -0
- package/dist/integration/virtual-types.d.ts +14 -0
- package/dist/integration/virtual-types.js +34 -0
- package/dist/integration/vite-plugin.d.ts +17 -0
- package/dist/integration/vite-plugin.js +56 -0
- package/dist/key-format.d.ts +8 -0
- package/dist/key-format.js +15 -0
- package/dist/types.d.ts +66 -0
- package/dist/types.js +38 -0
- package/package.json +77 -4
- package/src/assemble.ts +29 -0
- package/src/cli/generate.ts +175 -0
- package/src/cli/hashes.ts +66 -0
- package/src/cli/index.ts +461 -0
- package/src/cli/load-config.ts +26 -0
- package/src/cli/purge.ts +122 -0
- package/src/cli/wrangler.ts +96 -0
- package/src/components/LanguageSwitcher.astro +210 -0
- package/src/components/MissingTranslationBanner.astro +31 -0
- package/src/config.entry.ts +38 -0
- package/src/config.ts +11 -0
- package/src/constants.ts +29 -0
- package/src/cookie-storage.ts +32 -0
- package/src/deep-merge.ts +38 -0
- package/src/fetcher.ts +304 -0
- package/src/format.ts +107 -0
- package/src/index.ts +11 -0
- package/src/integration/index.ts +136 -0
- package/src/integration/virtual-types.ts +34 -0
- package/src/integration/vite-plugin.ts +67 -0
- package/src/key-format.ts +17 -0
- package/src/locale-fallback.ts +65 -0
- package/src/markdown-path.ts +31 -0
- package/src/middleware/i18n.ts +223 -0
- package/src/middleware.ts +2 -0
- package/src/parse-boolean.ts +20 -0
- package/src/resolve-locale.ts +57 -0
- package/src/runtime.ts +19 -0
- package/src/schema.ts +28 -0
- package/src/translations-keys.ts +42 -0
- package/src/types.ts +119 -0
- package/src/virtual.d.ts +23 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The middleware's fallback cascade for a request whose URL carries no valid locale,
|
|
3
|
+
* as a pure function: the request-bound inputs arrive as getters, so each one is read
|
|
4
|
+
* exactly when, and only if, the cascade reaches it (same split as `env` in the fetcher).
|
|
5
|
+
*
|
|
6
|
+
* Order (unchanged from the middleware):
|
|
7
|
+
* 1. the lang cookie, if valid;
|
|
8
|
+
* 2. the preferred locale (the middleware passes Astro's `preferredLocale`: its match of
|
|
9
|
+
* Accept-Language against the project's Astro `i18n.locales`, undefined when nothing
|
|
10
|
+
* matches or `i18n` is not configured), full match, then its language part
|
|
11
|
+
* ('pt-br' -> 'pt');
|
|
12
|
+
* 3. ONLY when there is no preferred locale at all: the Cloudflare geo-IP country,
|
|
13
|
+
* through the project's `countryToLocale` table (skipped without one);
|
|
14
|
+
* 4. the default locale.
|
|
15
|
+
* A preferred locale that does not parse goes straight to 4: geo is not consulted.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type FallbackInputs<L extends string> = {
|
|
19
|
+
cookieLocale: () => L | null
|
|
20
|
+
preferredLocale: () => string | undefined
|
|
21
|
+
country: () => unknown
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type FallbackOptions<L extends string> = {
|
|
25
|
+
// Returns the locale when the value is a supported one, otherwise null.
|
|
26
|
+
parseLocale: (value: unknown) => L | null
|
|
27
|
+
defaultLocale: L
|
|
28
|
+
countryToLocale?: Readonly<Record<string, string>>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveFallbackLocale<L extends string>(inputs: FallbackInputs<L>, options: FallbackOptions<L>): L {
|
|
32
|
+
const cookieLocale = inputs.cookieLocale()
|
|
33
|
+
if (cookieLocale) return cookieLocale
|
|
34
|
+
|
|
35
|
+
const browserRaw = inputs.preferredLocale()
|
|
36
|
+
if (browserRaw) {
|
|
37
|
+
// Currently we support 2-chars locales only.
|
|
38
|
+
// 1) Try full match (for cases where you actually have 'pt-br' in SUPPORTED_LOCALES)
|
|
39
|
+
const full = options.parseLocale(browserRaw)
|
|
40
|
+
if (full) return full
|
|
41
|
+
|
|
42
|
+
// 2) Fallback: language part only, e.g. 'pt-br' -> 'pt'
|
|
43
|
+
const short = options.parseLocale(browserRaw.split('-')[0])
|
|
44
|
+
if (short) return short
|
|
45
|
+
} else if (options.countryToLocale) {
|
|
46
|
+
// 3) Cloudflare Geo-IP Strategy
|
|
47
|
+
// cf is undefined in local dev unless simulated
|
|
48
|
+
const geo = options.parseLocale(mapCountryToLocale(inputs.country(), options.countryToLocale))
|
|
49
|
+
if (geo) return geo
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return options.defaultLocale
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Maps a Cloudflare country code (`request.cf.country`) to a locale through the
|
|
57
|
+
* project's table. Returns undefined if the country is not in it.
|
|
58
|
+
*/
|
|
59
|
+
export function mapCountryToLocale(country: unknown, table: Readonly<Record<string, string>>): string | undefined {
|
|
60
|
+
// Safety: In dev mode (localhost), 'country' might be null or undefined.
|
|
61
|
+
if (typeof country !== 'string') return undefined
|
|
62
|
+
|
|
63
|
+
// Cloudflare always sends uppercase (e.g., 'US', 'DE'), but safety first.
|
|
64
|
+
return table[country.toUpperCase()]
|
|
65
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The middleware's `.md` normalisation, as a pure function. Markdown endpoints
|
|
3
|
+
* (`/de/blog/x.md/`, `/blog/x.md`) are served in one locale, the project's
|
|
4
|
+
* `markdownLocale`; this returns the single redirect target for such a path, or null
|
|
5
|
+
* when there is nothing to redirect:
|
|
6
|
+
* - not a `.md` path, or the project has no `markdownLocale`: null (the middleware
|
|
7
|
+
* lets the request through untouched);
|
|
8
|
+
* - a leading segment that is a locale is replaced, any other leading segment is kept
|
|
9
|
+
* (`/foo.md` -> `/<markdownLocale>/foo.md`, never the homepage);
|
|
10
|
+
* - a trailing slash is dropped, in the same redirect;
|
|
11
|
+
* - a path already in canonical form: null.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const MARKDOWN_PATH = /\.md\/?$/
|
|
15
|
+
|
|
16
|
+
export type MarkdownOptions = {
|
|
17
|
+
// True when the segment is a supported locale.
|
|
18
|
+
isLocale: (segment: string) => boolean
|
|
19
|
+
markdownLocale?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function markdownRedirectTarget(pathname: string, options: MarkdownOptions): string | null {
|
|
23
|
+
if (!options.markdownLocale || !MARKDOWN_PATH.test(pathname)) return null
|
|
24
|
+
|
|
25
|
+
const segments = pathname.split('/').filter(Boolean)
|
|
26
|
+
const first = segments[0]
|
|
27
|
+
const contentSegments = first !== undefined && options.isLocale(first) ? segments.slice(1) : segments
|
|
28
|
+
const target = `/${options.markdownLocale}/${contentSegments.join('/')}`
|
|
29
|
+
|
|
30
|
+
return target === pathname ? null : target
|
|
31
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from 'astro'
|
|
2
|
+
import { LocaleSchema } from '../schema'
|
|
3
|
+
import { I18N_CONFIG } from '../config'
|
|
4
|
+
import { DEFAULT_LOCALE, type Locale } from '../constants'
|
|
5
|
+
import { getCookieLang, setCookieLang } from '../cookie-storage'
|
|
6
|
+
import { resolveFallbackLocale as resolveFallbackFrom } from '../locale-fallback'
|
|
7
|
+
import { MARKDOWN_PATH, markdownRedirectTarget } from '../markdown-path'
|
|
8
|
+
import { resolveLocaleForTranslations } from '../resolve-locale'
|
|
9
|
+
|
|
10
|
+
// Regex to identify public static assets (images, fonts, scripts, etc.)
|
|
11
|
+
// These files should bypass i18n routing and locale detection.
|
|
12
|
+
const PUBLIC_FILE_REGEX = /\.(ico|png|jpg|jpeg|svg|webp|gif|css|js|map|txt|xml|json|woff2?|avif|md)$/i
|
|
13
|
+
|
|
14
|
+
// List of URL prefixes that should be ignored by the i18n middleware.
|
|
15
|
+
// Includes API endpoints, internal Astro paths, and standard static routes.
|
|
16
|
+
// The Vite dev-server prefixes (/@vite, /@id, /@fs, /src, /node_modules) make
|
|
17
|
+
// dev survive `run_worker_first: ["/*"]` being left on: without them every
|
|
18
|
+
// dev-module request fell through to the locale logic, got redirected to
|
|
19
|
+
// /<locale>/@vite/... and rendered a full 404 page (with its translation
|
|
20
|
+
// fetches) per module. Harmless in prod — no real route starts with these.
|
|
21
|
+
const IGNORED_PREFIXES = [
|
|
22
|
+
'/api',
|
|
23
|
+
'/assets',
|
|
24
|
+
'/_astro',
|
|
25
|
+
'/_image',
|
|
26
|
+
'/_actions',
|
|
27
|
+
'/favicon',
|
|
28
|
+
'/@vite',
|
|
29
|
+
'/@id',
|
|
30
|
+
'/@fs',
|
|
31
|
+
'/src',
|
|
32
|
+
'/node_modules',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
// Extracts the context type from Astro's MiddlewareHandler for cleaner function signatures.
|
|
36
|
+
type I18nMiddlewareContext = Parameters<MiddlewareHandler>[0]
|
|
37
|
+
|
|
38
|
+
// Two kinds of redirect, by whether the target depends on the visitor.
|
|
39
|
+
//
|
|
40
|
+
// Deterministic normalisations (a missing trailing slash, a `.md` path moved to
|
|
41
|
+
// `markdownLocale`) always lead to the same URL, so they are permanent in production:
|
|
42
|
+
// 301 consolidates canonical signals in Google. They use REDIRECT_STATUS: 302 under
|
|
43
|
+
// `astro dev` (`import.meta.env.DEV`) so nothing gets cached while testing, 301 in any
|
|
44
|
+
// production build (incl. `astro preview` / `wrangler dev`). `?.`: plain Node (the tests)
|
|
45
|
+
// has no `import.meta.env`, which counts as production.
|
|
46
|
+
//
|
|
47
|
+
// Negotiated redirects (no locale or an invalid one in the URL: the target comes from the
|
|
48
|
+
// visitor's cookie, Accept-Language or country) are always temporary and uncacheable, in
|
|
49
|
+
// dev and production alike: see negotiatedRedirect(). A browser keeps a 301 and never asks
|
|
50
|
+
// the server again, so a language chosen later in the switcher would be bypassed on the
|
|
51
|
+
// next visit to `/`, and the URL-wins cookie sync below would then overwrite that choice.
|
|
52
|
+
const REDIRECT_STATUS = import.meta.env?.DEV ? 302 : 301
|
|
53
|
+
|
|
54
|
+
// A redirect whose target depends on the visitor: temporary, never stored by a shared
|
|
55
|
+
// 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
|
+
function negotiatedRedirect(target: string): Response {
|
|
58
|
+
return new Response(null, {
|
|
59
|
+
status: 302,
|
|
60
|
+
headers: {
|
|
61
|
+
Location: target,
|
|
62
|
+
'Cache-Control': 'private, no-store',
|
|
63
|
+
Vary: 'Cookie, Accept-Language',
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Determines if the current request path matches static assets or ignored prefixes.
|
|
69
|
+
// Returns true to skip i18n processing for this request.
|
|
70
|
+
function shouldBypassI18n(pathname: string): boolean {
|
|
71
|
+
if (PUBLIC_FILE_REGEX.test(pathname)) return true
|
|
72
|
+
if (IGNORED_PREFIXES.some((p) => pathname.startsWith(p))) return true
|
|
73
|
+
// Markdown endpoints: /en/blog/slug.md/ (with or without trailing slash)
|
|
74
|
+
if (MARKDOWN_PATH.test(pathname)) return true
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Constructs a standardized localized URL path ensuring a trailing slash.
|
|
79
|
+
// Example: buildLocalizedPath('en', ['about', 'team']) -> '/en/about/team/'
|
|
80
|
+
function buildLocalizedPath(locale: Locale, rest: string[]): string {
|
|
81
|
+
const suffix = rest.join('/')
|
|
82
|
+
return suffix ? `/${locale}/${suffix}/` : `/${locale}/`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Resolves the most appropriate locale for the user based on a fallback cascade
|
|
86
|
+
// (locale-fallback.ts, where the order is documented and tested):
|
|
87
|
+
// 1. User's saved cookie preference.
|
|
88
|
+
// 2. Astro's `preferredLocale` (Accept-Language matched against the project's Astro
|
|
89
|
+
// `i18n.locales`): full tag, then its language part.
|
|
90
|
+
// 3. Only when Astro reports no preferred locale: the Cloudflare Geo-IP country, through
|
|
91
|
+
// the project's `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
|
+
preferredLocale: () => context.preferredLocale,
|
|
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
|
+
}
|
|
111
|
+
|
|
112
|
+
// Main i18n middleware handler. Intercepts incoming requests, manages routing,
|
|
113
|
+
// sets locale context, and handles soft 404 protections for edge environments.
|
|
114
|
+
export const i18nMiddleware: MiddlewareHandler = async (context, next) => {
|
|
115
|
+
const url = new URL(context.request.url)
|
|
116
|
+
const pathname = url.pathname
|
|
117
|
+
|
|
118
|
+
// 1. Split the URL early to access its segments
|
|
119
|
+
const segments = pathname.split('/').filter(Boolean) // "/es/about/" -> ["es", "about"]
|
|
120
|
+
const firstSegment = segments[0] ?? null
|
|
121
|
+
|
|
122
|
+
// 2. SAFETY NET (For static file 404 pages)
|
|
123
|
+
// Attempt to infer the locale from the URL, or fallback to the default.
|
|
124
|
+
let safeLocale = DEFAULT_LOCALE
|
|
125
|
+
if (firstSegment) {
|
|
126
|
+
const parsed = LocaleSchema.safeParse(firstSegment)
|
|
127
|
+
if (parsed.success) {
|
|
128
|
+
safeLocale = parsed.data
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Inject the locale into the context BEFORE the bypass check!
|
|
133
|
+
context.locals.uiLocale = safeLocale
|
|
134
|
+
context.locals.translationLocale = resolveLocaleForTranslations(safeLocale)
|
|
135
|
+
|
|
136
|
+
// 3. Bypass static files and system paths (with Soft 404 protection)
|
|
137
|
+
if (shouldBypassI18n(pathname)) {
|
|
138
|
+
// .md endpoints: one redirect to the project's `markdownLocale`, no trailing slash
|
|
139
|
+
// (markdown-path.ts). Without `markdownLocale` they pass through untouched.
|
|
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
|
+
}
|
|
165
|
+
|
|
166
|
+
return response
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const fallbackLocale = resolveFallbackLocale(context)
|
|
170
|
+
|
|
171
|
+
// 1. No locale in URL — redirect to fallback locale root
|
|
172
|
+
if (!firstSegment) {
|
|
173
|
+
const target = buildLocalizedPath(fallbackLocale, [])
|
|
174
|
+
if (pathname !== target) {
|
|
175
|
+
return negotiatedRedirect(target)
|
|
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) {
|
|
185
|
+
// Keep the lang cookie in sync with the URL locale — but ONLY update an
|
|
186
|
+
// EXISTING cookie that differs. Rationale (HTML edge cache, P1/P2):
|
|
187
|
+
// - The old unconditional set emitted `Set-Cookie` on EVERY localized
|
|
188
|
+
// response, which made every response uncacheable (the edge cache
|
|
189
|
+
// refuses anything carrying Set-Cookie).
|
|
190
|
+
// - Cookie-less requests (first visits, crawlers, every PSI/GTmetrix run)
|
|
191
|
+
// must produce NO Set-Cookie at all, otherwise lab tests never hit the
|
|
192
|
+
// cache and the TTFB jitter (P2) stays. So a missing cookie is NOT
|
|
193
|
+
// created here — the explicit language choice is persisted client-side
|
|
194
|
+
// by the LanguageSwitcher, and an existing cookie is re-synced below
|
|
195
|
+
// when the user lands on a different locale by any other means.
|
|
196
|
+
const cookieLocale = getCookieLang(context.cookies)
|
|
197
|
+
if (cookieLocale && cookieLocale !== urlLocale) {
|
|
198
|
+
setCookieLang(context.cookies, urlLocale)
|
|
199
|
+
}
|
|
200
|
+
// Set uiLocale to Astro locals
|
|
201
|
+
context.locals.uiLocale = urlLocale
|
|
202
|
+
// Define and set translationLocale to Astro locals
|
|
203
|
+
const translationLocale = resolveLocaleForTranslations(urlLocale)
|
|
204
|
+
context.locals.translationLocale = translationLocale
|
|
205
|
+
|
|
206
|
+
// Normalize trailing slash and structure
|
|
207
|
+
const normalized = buildLocalizedPath(urlLocale, segments.slice(1))
|
|
208
|
+
if (pathname !== normalized) {
|
|
209
|
+
return context.redirect(normalized, REDIRECT_STATUS)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return await next()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 3. Invalid locale in URL -> treat the whole path as content missing the locale prefix.
|
|
216
|
+
const target = buildLocalizedPath(fallbackLocale, segments)
|
|
217
|
+
|
|
218
|
+
if (pathname !== target) {
|
|
219
|
+
return negotiatedRedirect(target)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return await next()
|
|
223
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Engine-owned copy of src/utils/shared/parse-boolean.ts: the package ships its own.
|
|
2
|
+
// The project copy stays for its other consumers (seo/utils.ts, htmlCacheMiddleware.ts).
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse a boolean flag from Wrangler-env string.
|
|
6
|
+
*
|
|
7
|
+
* Accepts:
|
|
8
|
+
* "1", "true", "on" → true
|
|
9
|
+
* "0", "false", "off" → false
|
|
10
|
+
* undefined → defaultValue
|
|
11
|
+
*/
|
|
12
|
+
export function parseBooleanFlag(value: string | undefined, defaultValue: boolean): boolean {
|
|
13
|
+
if (value === undefined) return defaultValue
|
|
14
|
+
|
|
15
|
+
const v = value.toLowerCase()
|
|
16
|
+
if (v === '1' || v === 'true' || v === 'on') return true
|
|
17
|
+
if (v === '0' || v === 'false' || v === 'off') return false
|
|
18
|
+
|
|
19
|
+
return defaultValue
|
|
20
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { AstroGlobal } from 'astro'
|
|
2
|
+
|
|
3
|
+
import { LocaleSchema } from './schema'
|
|
4
|
+
import type { Locale } from './constants'
|
|
5
|
+
import { DEFAULT_LOCALE, ENABLE_MISSING_TRANSLATION_BANNER } from './constants'
|
|
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
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Optional: return a “missing translation” banner message
|
|
20
|
+
* when uiLocale has no translations or (blog) content is missing in required language.
|
|
21
|
+
*
|
|
22
|
+
* Returns:
|
|
23
|
+
* - MissingTranslationType: type of message to show ('ui' or 'content')
|
|
24
|
+
* - null: if banner should not be shown
|
|
25
|
+
*/
|
|
26
|
+
type MissingTranslationType = 'ui' | 'content' | null
|
|
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
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* UI / routing locale:
|
|
41
|
+
* - What user selected
|
|
42
|
+
* - What is used for <html lang>, URL, etc.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveLocale(Astro: AstroGlobal): Locale {
|
|
45
|
+
const raw = Astro.currentLocale ?? DEFAULT_LOCALE
|
|
46
|
+
const parsed = LocaleSchema.safeParse(raw)
|
|
47
|
+
return parsed.success ? parsed.data : DEFAULT_LOCALE
|
|
48
|
+
}
|
|
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
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The runtime entry (`@edgekits/astro-i18n/runtime`): what pages, layouts and endpoints
|
|
2
|
+
// import. Ships as source and runs through the project's Vite, next to the virtual
|
|
3
|
+
// modules the integration serves.
|
|
4
|
+
export { fetchTranslations } from './fetcher'
|
|
5
|
+
export { fmt, plural, pluralIcu, toIetfTag, replaceLocaleInPath, getLangFromUrl } from './format'
|
|
6
|
+
export type { PluralCategory, PluralPatterns } from './format'
|
|
7
|
+
export { I18N_CONFIG } from './config'
|
|
8
|
+
export {
|
|
9
|
+
DEFAULT_LOCALE,
|
|
10
|
+
ENABLE_MISSING_TRANSLATION_BANNER,
|
|
11
|
+
LANG_COOKIE_NAME,
|
|
12
|
+
LANGUAGES,
|
|
13
|
+
SUPPORTED_LOCALES,
|
|
14
|
+
} from './constants'
|
|
15
|
+
export type { Language, SupportedLocales } from './constants'
|
|
16
|
+
export { LocaleSchema } from './schema'
|
|
17
|
+
export type { Namespace, PickSchema } from './schema'
|
|
18
|
+
export { checkMissingTranslation, resolveLocale, resolveLocaleForTranslations } from './resolve-locale'
|
|
19
|
+
export type { I18nBindings, I18nConfig, I18nLanguage, I18nLocales, Locale } from './types'
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from 'astro/zod'
|
|
2
|
+
|
|
3
|
+
import { SUPPORTED_LOCALES } from './constants'
|
|
4
|
+
import { NAMESPACE_KEYS } from 'virtual:@edgekits/astro-i18n/runtime-constants'
|
|
5
|
+
|
|
6
|
+
// Generated namespaces and real supported locales that have translations: the project's
|
|
7
|
+
// runtime-constants.ts through the virtual module (empty lists until the CLI generates it)
|
|
8
|
+
export * from 'virtual:@edgekits/astro-i18n/runtime-constants'
|
|
9
|
+
|
|
10
|
+
// Locale schema from shared constant
|
|
11
|
+
export const LocaleSchema = z.enum(SUPPORTED_LOCALES)
|
|
12
|
+
|
|
13
|
+
// Runtime safety: if namespaces are empty (fresh clone), fall back to string
|
|
14
|
+
const hasNamespaces = (NAMESPACE_KEYS as readonly string[]).length > 0
|
|
15
|
+
|
|
16
|
+
export const NamespaceSchema = hasNamespaces ? z.enum(NAMESPACE_KEYS as [string, ...string[]]) : z.string()
|
|
17
|
+
|
|
18
|
+
type GlobalSchema = I18n.Schema
|
|
19
|
+
|
|
20
|
+
// Strictly bind the type to the keys of I18n.Schema.
|
|
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
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { I18N_CONFIG } from './config'
|
|
2
|
+
import type { Locale } from './constants'
|
|
3
|
+
import { formatTranslationCacheUrl, formatTranslationKvKey } from './key-format'
|
|
4
|
+
import type { Namespace } from './schema'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Single source of truth for translation KV key construction.
|
|
8
|
+
*
|
|
9
|
+
* Used by both:
|
|
10
|
+
* - fetcher.ts (runtime: cache.match / cache.put)
|
|
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.
|
|
39
|
+
*/
|
|
40
|
+
export function buildTranslationCacheRequest(locale: Locale, ns: Namespace): Request {
|
|
41
|
+
return new Request(buildTranslationCacheUrl(locale, ns))
|
|
42
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public types of the package and the config validator. Pure: no virtual modules,
|
|
3
|
+
* no `cloudflare:workers`, so the consumer's `astro.config` and the CLI can import it
|
|
4
|
+
* under plain Node.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type I18nLanguage = { countryCode: string; name: string }
|
|
8
|
+
|
|
9
|
+
export type I18nConfig = {
|
|
10
|
+
// The only source of truth for languages: the keys are the locales.
|
|
11
|
+
languages: Record<string, I18nLanguage>
|
|
12
|
+
defaultLocale: string
|
|
13
|
+
// The lang cookie the middleware keeps in sync with the URL locale.
|
|
14
|
+
cookie: { name: string; maxAge: number }
|
|
15
|
+
// Prefix of every translation KV key and the host of every edge-cache URL.
|
|
16
|
+
keyPrefix: string
|
|
17
|
+
missingTranslationBanner: boolean
|
|
18
|
+
routing: 'prefix'
|
|
19
|
+
// Where the CLI writes `fallbacks.generated.ts` and `runtime-constants.ts`,
|
|
20
|
+
// relative to the project root.
|
|
21
|
+
generatedDir?: string
|
|
22
|
+
// Where the CLI reads `<locale>/<namespace>.json`, relative to the project root.
|
|
23
|
+
localesDir?: string
|
|
24
|
+
// Geo-IP step of the middleware's locale fallback: ISO 3166-1 alpha-2 country code
|
|
25
|
+
// (uppercase, as `request.cf.country` sends it) → one of `languages`. Without it the
|
|
26
|
+
// step is skipped; the package ships no table of its own.
|
|
27
|
+
countryToLocale?: Record<string, string>
|
|
28
|
+
// The locale of the project's `.md` endpoints: a `.md` path is redirected once to
|
|
29
|
+
// `/<markdownLocale>/<rest>` (a leading locale segment replaced, otherwise kept). Without
|
|
30
|
+
// it, `.md` paths pass through the middleware untouched.
|
|
31
|
+
markdownLocale?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_GENERATED_DIR = 'src/i18n'
|
|
35
|
+
export const DEFAULT_LOCALES_DIR = 'locales'
|
|
36
|
+
|
|
37
|
+
/** The locale keys of a config's `languages`: a literal union for a literal config. */
|
|
38
|
+
export type LocaleOf<C extends I18nConfig> = Extract<keyof C['languages'], string>
|
|
39
|
+
|
|
40
|
+
const COUNTRY_CODE = /^[A-Z]{2}$/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Validates and returns the config unchanged. Throws at config time on an empty
|
|
44
|
+
* `languages`, a `defaultLocale` or `markdownLocale` that is not one of its keys, an
|
|
45
|
+
* unknown `routing`, or a `countryToLocale` entry whose key is not an uppercase country
|
|
46
|
+
* code or whose value is not one of `languages` (also a type error for a literal config).
|
|
47
|
+
*/
|
|
48
|
+
export function defineI18nConfig<const C extends I18nConfig>(
|
|
49
|
+
config: C & { countryToLocale?: Record<string, LocaleOf<C>>; markdownLocale?: LocaleOf<C> },
|
|
50
|
+
): C {
|
|
51
|
+
const locales = Object.keys(config.languages ?? {})
|
|
52
|
+
|
|
53
|
+
if (locales.length === 0) {
|
|
54
|
+
throw new Error('[@edgekits/astro-i18n] `languages` must define at least one locale.')
|
|
55
|
+
}
|
|
56
|
+
if (!locales.includes(config.defaultLocale)) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`[@edgekits/astro-i18n] \`defaultLocale\` "${config.defaultLocale}" is not one of \`languages\`: ${locales.join(', ')}.`,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
if (config.markdownLocale !== undefined && !locales.includes(config.markdownLocale)) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`[@edgekits/astro-i18n] \`markdownLocale\` "${config.markdownLocale}" is not one of \`languages\`: ${locales.join(', ')}.`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
if (config.routing !== 'prefix') {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`[@edgekits/astro-i18n] \`routing\` "${String(config.routing)}" is not supported; the only routing is 'prefix'.`,
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
for (const [country, locale] of Object.entries(config.countryToLocale ?? {})) {
|
|
72
|
+
if (!COUNTRY_CODE.test(country)) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`[@edgekits/astro-i18n] \`countryToLocale\` key "${country}" is not an uppercase ISO 3166-1 alpha-2 country code.`,
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
if (!locales.includes(locale)) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`[@edgekits/astro-i18n] \`countryToLocale\` maps "${country}" to "${locale}", which is not one of \`languages\`: ${locales.join(', ')}.`,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return config
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The project's locales, as keys. Empty here; the integration augments it from the
|
|
89
|
+
* config's `languages` (`injectTypes` at `astro:config:done`).
|
|
90
|
+
*/
|
|
91
|
+
export interface I18nLocales {}
|
|
92
|
+
|
|
93
|
+
type LocaleKey = Extract<keyof I18nLocales, string>
|
|
94
|
+
|
|
95
|
+
// Falls back to `string` while `I18nLocales` is empty, like `Namespace` over `I18n.Schema`.
|
|
96
|
+
export type Locale = [LocaleKey] extends [never] ? string : LocaleKey
|
|
97
|
+
|
|
98
|
+
/** The Worker bindings the engine reads from `cloudflare:workers`. */
|
|
99
|
+
export interface I18nBindings {
|
|
100
|
+
TRANSLATIONS: KVNamespace
|
|
101
|
+
I18N_CACHE?: string
|
|
102
|
+
DEBUG_I18N?: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
declare global {
|
|
106
|
+
// Augmented by the consumer's generated `i18n.generated.d.ts`.
|
|
107
|
+
namespace I18n {
|
|
108
|
+
interface Schema {}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Set by the i18n middleware on every request.
|
|
112
|
+
namespace App {
|
|
113
|
+
interface Locals {
|
|
114
|
+
uiLocale: Locale
|
|
115
|
+
translationLocale: Locale
|
|
116
|
+
isMissingContent?: boolean
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/virtual.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The virtual modules served by the integration's Vite plugin (src/integration/vite-plugin.ts),
|
|
2
|
+
// declared for the package's own typecheck. A copy of `virtualModulesDeclaration('./types')`
|
|
3
|
+
// (src/integration/virtual-types.ts), which consumers receive through `injectTypes`; edit
|
|
4
|
+
// that function, then this file. test/virtual-types.test.ts fails while the two differ.
|
|
5
|
+
|
|
6
|
+
declare module 'virtual:@edgekits/astro-i18n/config' {
|
|
7
|
+
const config: import('./types').I18nConfig
|
|
8
|
+
export default config
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// The consumer's `<generatedDir>/runtime-constants.ts`, or a stub with empty lists.
|
|
12
|
+
declare module 'virtual:@edgekits/astro-i18n/runtime-constants' {
|
|
13
|
+
export const NAMESPACE_KEYS: readonly string[]
|
|
14
|
+
export const LOCALES_WITH_TRANSLATIONS: readonly string[]
|
|
15
|
+
export const FULLY_TRANSLATED_LOCALES: readonly string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The consumer's `<generatedDir>/fallbacks.generated.ts`, or an empty stub. Its exports
|
|
19
|
+
// (`FALLBACK_<NAMESPACE>`) depend on the project's namespaces, so none are declared here:
|
|
20
|
+
// the fetcher reads the module namespace by name.
|
|
21
|
+
declare module 'virtual:@edgekits/astro-i18n/fallbacks' {
|
|
22
|
+
export {}
|
|
23
|
+
}
|