@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +394 -2
  4. package/dist/cli/generate.d.ts +47 -0
  5. package/dist/cli/generate.js +133 -0
  6. package/dist/cli/hashes.d.ts +19 -0
  7. package/dist/cli/hashes.js +42 -0
  8. package/dist/cli/index.d.ts +2 -0
  9. package/dist/cli/index.js +362 -0
  10. package/dist/cli/load-config.d.ts +7 -0
  11. package/dist/cli/load-config.js +19 -0
  12. package/dist/cli/purge.d.ts +29 -0
  13. package/dist/cli/purge.js +94 -0
  14. package/dist/cli/wrangler.d.ts +10 -0
  15. package/dist/cli/wrangler.js +71 -0
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.js +4 -0
  18. package/dist/integration/index.d.ts +26 -0
  19. package/dist/integration/index.js +103 -0
  20. package/dist/integration/virtual-types.d.ts +14 -0
  21. package/dist/integration/virtual-types.js +34 -0
  22. package/dist/integration/vite-plugin.d.ts +17 -0
  23. package/dist/integration/vite-plugin.js +56 -0
  24. package/dist/key-format.d.ts +8 -0
  25. package/dist/key-format.js +15 -0
  26. package/dist/types.d.ts +66 -0
  27. package/dist/types.js +38 -0
  28. package/package.json +77 -4
  29. package/src/assemble.ts +29 -0
  30. package/src/cli/generate.ts +175 -0
  31. package/src/cli/hashes.ts +66 -0
  32. package/src/cli/index.ts +461 -0
  33. package/src/cli/load-config.ts +26 -0
  34. package/src/cli/purge.ts +122 -0
  35. package/src/cli/wrangler.ts +96 -0
  36. package/src/components/LanguageSwitcher.astro +210 -0
  37. package/src/components/MissingTranslationBanner.astro +31 -0
  38. package/src/config.entry.ts +38 -0
  39. package/src/config.ts +11 -0
  40. package/src/constants.ts +29 -0
  41. package/src/cookie-storage.ts +32 -0
  42. package/src/deep-merge.ts +38 -0
  43. package/src/fetcher.ts +304 -0
  44. package/src/format.ts +107 -0
  45. package/src/index.ts +11 -0
  46. package/src/integration/index.ts +136 -0
  47. package/src/integration/virtual-types.ts +34 -0
  48. package/src/integration/vite-plugin.ts +67 -0
  49. package/src/key-format.ts +17 -0
  50. package/src/locale-fallback.ts +65 -0
  51. package/src/markdown-path.ts +31 -0
  52. package/src/middleware/i18n.ts +223 -0
  53. package/src/middleware.ts +2 -0
  54. package/src/parse-boolean.ts +20 -0
  55. package/src/resolve-locale.ts +57 -0
  56. package/src/runtime.ts +19 -0
  57. package/src/schema.ts +28 -0
  58. package/src/translations-keys.ts +42 -0
  59. package/src/types.ts +119 -0
  60. 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,2 @@
1
+ // The middleware entrypoint the integration registers (`middleware: 'auto'`).
2
+ export { i18nMiddleware as onRequest } from './middleware/i18n'
@@ -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
+ }
@@ -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
+ }