@edgekits/astro-i18n 0.1.8 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +92 -28
  3. package/dist/integration/index.d.ts +25 -4
  4. package/dist/integration/index.js +35 -9
  5. package/dist/integration/vite-plugin.d.ts +1 -1
  6. package/dist/types.d.ts +7 -55
  7. package/dist/types.js +1 -44
  8. package/package.json +7 -17
  9. package/src/components/LanguageSwitcher.astro +89 -93
  10. package/src/config.entry.ts +5 -36
  11. package/src/constants.ts +5 -3
  12. package/src/cookie-storage.ts +19 -25
  13. package/src/fetcher.ts +3 -304
  14. package/src/format.ts +3 -107
  15. package/src/instance.ts +44 -0
  16. package/src/integration/index.ts +38 -9
  17. package/src/integration/vite-plugin.ts +1 -1
  18. package/src/middleware/i18n.ts +232 -114
  19. package/src/resolve-locale.ts +7 -39
  20. package/src/runtime.ts +2 -2
  21. package/src/schema.ts +3 -11
  22. package/src/translations-keys.ts +7 -38
  23. package/src/types.ts +16 -116
  24. package/dist/cli/generate.d.ts +0 -50
  25. package/dist/cli/generate.js +0 -160
  26. package/dist/cli/hashes.d.ts +0 -19
  27. package/dist/cli/hashes.js +0 -42
  28. package/dist/cli/index.d.ts +0 -2
  29. package/dist/cli/index.js +0 -372
  30. package/dist/cli/key-prefix-check.d.ts +0 -6
  31. package/dist/cli/key-prefix-check.js +0 -27
  32. package/dist/cli/load-config.d.ts +0 -7
  33. package/dist/cli/load-config.js +0 -19
  34. package/dist/cli/purge.d.ts +0 -29
  35. package/dist/cli/purge.js +0 -94
  36. package/dist/cli/wrangler.d.ts +0 -10
  37. package/dist/cli/wrangler.js +0 -71
  38. package/dist/cli/zone.d.ts +0 -21
  39. package/dist/cli/zone.js +0 -42
  40. package/dist/key-format.d.ts +0 -8
  41. package/dist/key-format.js +0 -15
  42. package/src/accept-language.ts +0 -56
  43. package/src/assemble.ts +0 -29
  44. package/src/cli/generate.ts +0 -205
  45. package/src/cli/hashes.ts +0 -66
  46. package/src/cli/index.ts +0 -472
  47. package/src/cli/key-prefix-check.ts +0 -31
  48. package/src/cli/load-config.ts +0 -26
  49. package/src/cli/purge.ts +0 -122
  50. package/src/cli/wrangler.ts +0 -96
  51. package/src/cli/zone.ts +0 -64
  52. package/src/deep-merge.ts +0 -38
  53. package/src/key-format.ts +0 -17
  54. package/src/locale-fallback.ts +0 -58
  55. package/src/markdown-path.ts +0 -31
  56. package/src/parse-boolean.ts +0 -20
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  import { I18N_CONFIG } from '../config'
3
- import { SUPPORTED_LOCALES, LANGUAGES, LANG_COOKIE_NAME } from '../constants'
3
+ import { DEFAULT_LOCALE, SUPPORTED_LOCALES, LANGUAGES, LANG_COOKIE_NAME } from '../constants'
4
4
  import type { Locale } from '../constants'
5
5
 
6
6
  interface Props {
@@ -24,83 +24,87 @@ interface Props {
24
24
 
25
25
  const { flag: Flag, triggerIcon: TriggerIcon, activeIcon: ActiveIcon, classes = {}, texts } = Astro.props
26
26
  const { uiLocale: currentLang } = Astro.locals
27
+ // One configured locale: nothing to switch to, so nothing renders.
28
+ const hasChoice = SUPPORTED_LOCALES.length > 1
27
29
  ---
28
30
 
29
- <language-switcher
30
- data-current-lang={currentLang}
31
- data-cookie-name={LANG_COOKIE_NAME}
32
- data-cookie-max-age={I18N_CONFIG.cookie.maxAge}
33
- data-open="false"
34
- >
35
- <div class={classes.root}>
36
- <button
37
- type="button"
38
- data-ls-trigger
39
- class={classes.trigger}
40
- aria-haspopup="true"
41
- aria-expanded="false"
42
- aria-label={texts?.trigger ?? 'Switch language'}
31
+ {
32
+ hasChoice && (
33
+ <language-switcher
34
+ data-current-lang={currentLang}
35
+ data-cookie-name={LANG_COOKIE_NAME}
36
+ data-cookie-max-age={I18N_CONFIG.cookie.maxAge}
37
+ data-routing={I18N_CONFIG.routing ?? 'prefix-except-default'}
38
+ data-default-locale={DEFAULT_LOCALE}
39
+ data-open="false"
43
40
  >
44
- {
45
- TriggerIcon ? (
46
- <TriggerIcon class={classes.triggerIcon} stroke-width="1" />
47
- ) : (
48
- <svg
49
- class={classes.triggerIcon}
50
- viewBox="0 0 24 24"
51
- fill="none"
52
- stroke="currentColor"
53
- stroke-width="1"
54
- aria-hidden="true"
55
- >
56
- <circle cx="12" cy="12" r="9" />
57
- <ellipse cx="12" cy="12" rx="4" ry="9" />
58
- <path d="M3 12h18" />
59
- </svg>
60
- )
61
- }
62
- </button>
63
-
64
- <div data-ls-dropdown class={classes.dropdown} role="menu" hidden>
65
- {
66
- SUPPORTED_LOCALES.map((lang) => {
67
- const locale = lang as Locale
68
- const isActive = locale === currentLang
69
- const label = LANGUAGES[locale]
70
- const flagCode = label.countryCode
71
-
72
- // The label is the language's own name: `lang` lets a screen reader say it in that
73
- // language. (A comment here, not among the button's children: there it splits the
74
- // whitespace run, and compressHTML keeps two spaces before the label.)
75
- return (
76
- <button
77
- type="button"
78
- class:list={[classes.item, isActive && classes.itemActive]}
79
- data-ls-item
80
- data-lang={locale}
81
- data-active={isActive ? 'true' : undefined}
82
- role="menuitem"
83
- disabled={isActive}
41
+ <div class={classes.root}>
42
+ <button
43
+ type="button"
44
+ data-ls-trigger
45
+ class={classes.trigger}
46
+ aria-haspopup="true"
47
+ aria-expanded="false"
48
+ aria-label={texts?.trigger ?? 'Switch language'}
49
+ >
50
+ {TriggerIcon ? (
51
+ <TriggerIcon class={classes.triggerIcon} stroke-width="1" />
52
+ ) : (
53
+ <svg
54
+ class={classes.triggerIcon}
55
+ viewBox="0 0 24 24"
56
+ fill="none"
57
+ stroke="currentColor"
58
+ stroke-width="1"
59
+ aria-hidden="true"
84
60
  >
85
- <span class={classes.itemLabel} lang={locale}>
86
- {Flag && flagCode && <Flag code={flagCode} size={16} />}
87
- {label.name}
88
- </span>
89
- {isActive &&
90
- (ActiveIcon ? (
91
- <ActiveIcon class={classes.activeIcon} />
92
- ) : (
93
- <svg class={classes.activeIcon} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
94
- <circle cx="12" cy="12" r="3" />
95
- </svg>
96
- ))}
97
- </button>
98
- )
99
- })
100
- }
101
- </div>
102
- </div>
103
- </language-switcher>
61
+ <circle cx="12" cy="12" r="9" />
62
+ <ellipse cx="12" cy="12" rx="4" ry="9" />
63
+ <path d="M3 12h18" />
64
+ </svg>
65
+ )}
66
+ </button>
67
+
68
+ <div data-ls-dropdown class={classes.dropdown} role="menu" hidden>
69
+ {SUPPORTED_LOCALES.map((lang) => {
70
+ const locale = lang as Locale
71
+ const isActive = locale === currentLang
72
+ const label = LANGUAGES[locale]
73
+ const flagCode = label.countryCode
74
+
75
+ // The label is the language's own name: `lang` lets a screen reader say it in that
76
+ // language. (A comment here, not among the button's children: there it splits the
77
+ // whitespace run, and compressHTML keeps two spaces before the label.)
78
+ return (
79
+ <button
80
+ type="button"
81
+ class:list={[classes.item, isActive && classes.itemActive]}
82
+ data-ls-item
83
+ data-lang={locale}
84
+ data-active={isActive ? 'true' : undefined}
85
+ role="menuitem"
86
+ disabled={isActive}
87
+ >
88
+ <span class={classes.itemLabel} lang={locale}>
89
+ {Flag && flagCode && <Flag code={flagCode} size={16} />}
90
+ {label.name}
91
+ </span>
92
+ {isActive &&
93
+ (ActiveIcon ? (
94
+ <ActiveIcon class={classes.activeIcon} />
95
+ ) : (
96
+ <svg class={classes.activeIcon} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
97
+ <circle cx="12" cy="12" r="3" />
98
+ </svg>
99
+ ))}
100
+ </button>
101
+ )
102
+ })}
103
+ </div>
104
+ </div>
105
+ </language-switcher>
106
+ )
107
+ }
104
108
 
105
109
  <script>
106
110
  // Config arrives as data attributes on the element: a client script in the engine
@@ -158,19 +162,16 @@ const { uiLocale: currentLang } = Astro.locals
158
162
  this.dropdown.hidden ? this.open() : this.close()
159
163
  }
160
164
 
161
- // Isolated logic for getting language from URL
162
- private getLangFromUrl(pathname: string): string | undefined {
163
- const segments = pathname.split('/')
164
- return segments[1] || undefined
165
- }
166
-
167
- // Isolated logic for substituting language in URLs
168
- private replaceLocaleInPath(pathname: string, currentLang: string, targetLang: string): string {
169
- const segments = pathname.split('/')
170
- if (segments[1] === currentLang) {
171
- segments[1] = targetLang
172
- }
173
- return segments.join('/')
165
+ // The core's `localizedPath`, inlined (a client script imports types only): the page's
166
+ // path in `targetLang`. The URL carries the current locale's prefix exactly when it has
167
+ // one (every locale in 'prefix', all but the default in 'prefix-except-default'), so
168
+ // `data-current-lang` is the only segment to strip.
169
+ private localizedPath(pathname: string, targetLang: string): string {
170
+ const { currentLang, routing, defaultLocale } = this.dataset
171
+ const segments = pathname.split('/').filter(Boolean)
172
+ if (segments[0] === currentLang) segments.shift()
173
+ const rest = segments.length > 0 ? `${segments.join('/')}/` : ''
174
+ return routing !== 'prefix' && targetLang === defaultLocale ? `/${rest}` : `/${targetLang}/${rest}`
174
175
  }
175
176
 
176
177
  private onSelect = (event: Event) => {
@@ -181,11 +182,6 @@ const { uiLocale: currentLang } = Astro.locals
181
182
  const lang = button.dataset.lang as Locale | undefined
182
183
  if (!lang) return
183
184
 
184
- const pathname = window.location.pathname
185
- const currentLang = this.getLangFromUrl(pathname)
186
-
187
- if (!currentLang) return
188
-
189
185
  const { cookieName, cookieMaxAge } = this.dataset
190
186
 
191
187
  // Persist the EXPLICIT language choice client-side. The i18n middleware
@@ -195,7 +191,7 @@ const { uiLocale: currentLang } = Astro.locals
195
191
  // either side (cookie-storage.ts): the server validates it, never trusts it.
196
192
  document.cookie = `${cookieName}=${lang}; path=/; max-age=${cookieMaxAge}; SameSite=Lax; Secure`
197
193
 
198
- window.location.href = this.replaceLocaleInPath(pathname, currentLang, lang)
194
+ window.location.href = this.localizedPath(window.location.pathname, lang)
199
195
  }
200
196
 
201
197
  private onDocumentClick = (event: MouseEvent) => {
@@ -1,38 +1,7 @@
1
1
  // The `./config` entry: Node-safe. It imports nothing from workerd and no virtual module,
2
2
  // so a project's pure modules (unit tests under Node, `src/content.config.ts` evaluated by
3
- // `astro sync`) can derive the locale constants from the config file directly.
4
- // `test/node-safe-entries.test.ts` loads it under plain Node to keep it that way.
5
- import { defineI18nConfig, type I18nConfig, type I18nLanguage, type LocaleOf } from './types'
6
-
7
- export { defineI18nConfig }
8
- export type { I18nBindings, I18nConfig, I18nLanguage, I18nLocales, Locale, LocaleOf } from './types'
9
-
10
- export type LocalesOf<C extends I18nConfig> = {
11
- DEFAULT_LOCALE: LocaleOf<C>
12
- SUPPORTED_LOCALES: [LocaleOf<C>, ...LocaleOf<C>[]]
13
- LANGUAGES: Record<LocaleOf<C>, I18nLanguage>
14
- }
15
-
16
- /**
17
- * The locale constants of a config: the one derivation, shared by the runtime
18
- * (`constants.ts`, over the virtual config) and by project code (over its own
19
- * `edgekits-i18n.config.ts`, typed by its own keys without the `I18nLocales` augmentation).
20
- *
21
- * `defaultLocale` must be one of `languages` at the type level, and is validated at run time.
22
- */
23
- export function localesOf<const C extends I18nConfig>(
24
- config: C & {
25
- defaultLocale: LocaleOf<C>
26
- countryToLocale?: Record<string, LocaleOf<C>>
27
- markdownLocale?: LocaleOf<C>
28
- },
29
- ): LocalesOf<C> {
30
- defineI18nConfig<C>(config)
31
-
32
- return {
33
- DEFAULT_LOCALE: config.defaultLocale,
34
- // Object.keys widens to string[]; the keys are the config's own `languages` keys.
35
- SUPPORTED_LOCALES: Object.keys(config.languages) as [LocaleOf<C>, ...LocaleOf<C>[]],
36
- LANGUAGES: config.languages as Record<LocaleOf<C>, I18nLanguage>,
37
- }
38
- }
3
+ // `astro sync`) can derive the locale constants from the config file directly. The helpers
4
+ // are the core's (`@edgekits/i18n-core`). `test/node-safe-entries.test.ts` loads it under
5
+ // plain Node to keep it that way.
6
+ export { defineI18nConfig, localesOf } from './types'
7
+ export type { I18nBindings, I18nConfig, I18nLanguage, I18nLocales, Locale, LocaleOf, LocalesOf } from './types'
package/src/constants.ts CHANGED
@@ -6,9 +6,11 @@ export type { Locale }
6
6
 
7
7
  export type Language = I18nLanguage
8
8
 
9
- // Languages are configured in the project's edgekits-i18n.config.ts; the derivation is
10
- // `localesOf` (config.entry.ts), shared with project code. Inside the package the config
11
- // is not literal, so its keys are narrowed to the augmented `Locale` here.
9
+ // Languages are configured in the project's edgekits-i18n.config.ts; the derivation is the
10
+ // core's `localesOf`, shared with project code and with `createI18n` (instance.ts). Taken
11
+ // from the config, not the instance, so the components that need only these constants
12
+ // reach neither `cloudflare:workers` nor the generated modules. Inside the package the
13
+ // config is not literal, so its keys are narrowed to the augmented `Locale` here.
12
14
  const LOCALES = localesOf(I18N_CONFIG)
13
15
 
14
16
  export const LANGUAGES = LOCALES.LANGUAGES as Record<Locale, Language>
@@ -1,24 +1,22 @@
1
1
  import { type AstroCookies } from 'astro'
2
+ import type { CookieJar } from '@edgekits/i18n-core'
2
3
 
3
- import { I18N_CONFIG } from './config'
4
+ import { i18n } from './instance'
4
5
  import { LocaleSchema } from './schema'
5
6
  import type { Locale } from './constants'
6
7
  import { LANG_COOKIE_NAME } from './constants'
7
8
 
8
- // The lang cookie is a preference, not a secret, and it has two writers: the
9
- // LanguageSwitcher's client script (`document.cookie`) and the middleware's URL-wins
10
- // sync. Hence `httpOnly: false`: an httpOnly copy set here could never be overwritten
11
- // by the switcher again, and a stale value would disable it for good. The server only
12
- // reads the value through LocaleSchema, never trusts it. `?.`: plain Node (the tests)
13
- // has no `import.meta.env`.
9
+ // The core's lang-cookie helpers (cookie.ts there: why the cookie is not httpOnly) over
10
+ // Astro's cookies. `?.`: plain Node (the tests) has no `import.meta.env`.
11
+ function jar(cookies: AstroCookies): CookieJar {
12
+ return {
13
+ get: (name) => cookies.get(name)?.value,
14
+ set: (name, value, options) => cookies.set(name, value, options),
15
+ }
16
+ }
17
+
14
18
  export function setCookieLang(cookies: AstroCookies, locale: Locale) {
15
- cookies.set(LANG_COOKIE_NAME, locale, {
16
- path: '/',
17
- httpOnly: false,
18
- secure: import.meta.env?.PROD === true,
19
- sameSite: 'lax',
20
- maxAge: I18N_CONFIG.cookie.maxAge,
21
- })
19
+ i18n.setCookieLang(jar(cookies), locale, { secure: import.meta.env?.PROD === true })
22
20
  }
23
21
 
24
22
  export type CookieLang = {
@@ -29,19 +27,15 @@ export type CookieLang = {
29
27
  }
30
28
 
31
29
  export function readCookieLang(cookies: AstroCookies): CookieLang {
32
- const entry = cookies.get(LANG_COOKIE_NAME)
33
- if (!entry) return { present: false, locale: null }
34
-
35
- const result = LocaleSchema.safeParse(entry.value)
36
- if (result.success) return { present: true, locale: result.data }
37
-
38
- if (import.meta.env?.PROD !== true) {
39
- console.warn(`Invalid cookie "${LANG_COOKIE_NAME}"`, result.error.issues)
40
- }
41
-
42
- return { present: true, locale: null }
30
+ return i18n.readCookieLang(jar(cookies), { onInvalid: warnInvalidCookie })
43
31
  }
44
32
 
45
33
  export function getCookieLang(cookies: AstroCookies): Locale | null {
46
34
  return readCookieLang(cookies).locale
47
35
  }
36
+
37
+ function warnInvalidCookie(value: string): void {
38
+ if (import.meta.env?.PROD !== true) {
39
+ console.warn(`Invalid cookie "${LANG_COOKIE_NAME}"`, LocaleSchema.safeParse(value).error?.issues)
40
+ }
41
+ }
package/src/fetcher.ts CHANGED
@@ -1,305 +1,4 @@
1
- import { env, waitUntil } from 'cloudflare:workers'
2
- import * as fallbacksModule from 'virtual:@edgekits/astro-i18n/fallbacks'
1
+ // The core's fetcher, bound to this project's config and Worker bindings (instance.ts).
2
+ import { i18n } from './instance'
3
3
 
4
- import { assembleNamespace } from './assemble'
5
- import { buildTranslationKvKey, buildTranslationCacheRequest } from './translations-keys'
6
- import { parseBooleanFlag } from './parse-boolean'
7
- import type { Locale } from './constants'
8
- import type { Namespace, PickSchema } from './schema'
9
- import type { I18nBindings } from './types'
10
-
11
- // The bindings this module reads, typed by the package rather than the project's Env.
12
- const i18nEnv = env as unknown as I18nBindings
13
-
14
- // Optional fallbacks: the project's fallbacks.generated.ts if generated, otherwise an
15
- // empty stub module (no FALLBACK_* exports), served by the integration's Vite plugin.
16
- const FALLBACKS: Record<string, unknown> = fallbacksModule
17
-
18
- const KV_JSON_OPTIONS = { type: 'json' as const }
19
-
20
- /**
21
- * Fetch translations from KV for a given locale and list of namespaces.
22
- *
23
- * Cache strategy — per-namespace static cache keys:
24
- *
25
- * Each namespace is cached individually under a static (non-versioned) URL:
26
- * https://<I18N_CONFIG.keyPrefix>/<encodeURIComponent(i18n:<locale>:<ns>)>
27
- *
28
- * Cache invalidation is handled explicitly by the Cloudflare Purge API
29
- * in `edgekits-i18n migrate` — only namespaces whose content actually changed
30
- * are purged, leaving untouched namespaces warm in the cache.
31
- *
32
- * Hot path (all namespaces cached):
33
- * Parallel cache.match() for each namespace → all HIT → merge each with
34
- * FALLBACK_* → return immediately. Zero KV reads.
35
- *
36
- * Partial or full cache miss:
37
- * Missing namespaces collected → single KV getAll() batch →
38
- * merge with FALLBACK_* → individual cache.put() per namespace → return.
39
- *
40
- * Every namespace — cached or freshly read — is assembled through
41
- * assembleNamespace(), so a cache HIT is merged with FALLBACK_* exactly like a
42
- * miss. An entry cached before a schema addition therefore degrades to
43
- * default-locale text for the keys it predates instead of yielding `undefined`
44
- * (ADR-0014).
45
- *
46
- * KV unavailable:
47
- * Falls back to FALLBACK_* compiled dictionaries (always available).
48
- * If none were generated, returns empty objects per namespace — no crash.
49
- */
50
- export async function fetchTranslations<N extends Namespace>(
51
- locale: Locale,
52
- namespaces: readonly N[],
53
- ): Promise<PickSchema<N>> {
54
- const useCache = isCacheEnabled()
55
-
56
- if (!useCache) {
57
- debug('i18n cache DISABLED, using KV only', { locale, namespaces })
58
- return fetchAllFromKV(locale, namespaces)
59
- }
60
-
61
- const { cache } = getEdgeCache()
62
-
63
- if (!cache) {
64
- // `caches.default` absent (e.g. an untrusted Workers for Platforms namespace): read KV
65
- // directly. The package does not use `caches.open()`.
66
- debug('i18n caches.default absent, reading KV directly', {
67
- locale,
68
- namespaces,
69
- })
70
- return fetchAllFromKV(locale, namespaces)
71
- }
72
-
73
- // ---------------------------------------------------------------------------
74
- // 1. Check Cache API in parallel for each namespace individually
75
- // ---------------------------------------------------------------------------
76
-
77
- const cacheResults = await Promise.all(
78
- namespaces.map(async (ns) => {
79
- const cacheRequest = buildTranslationCacheRequest(locale, ns)
80
- try {
81
- const cached = await cache.match(cacheRequest)
82
- if (cached) {
83
- return { ns, data: await cached.json(), hit: true }
84
- }
85
- } catch (error) {
86
- debug(`i18n cache READ error for ${ns}`, error)
87
- }
88
- return { ns, data: null, hit: false }
89
- }),
90
- )
91
-
92
- // ---------------------------------------------------------------------------
93
- // 2. Separate hits from misses
94
- // ---------------------------------------------------------------------------
95
-
96
- const finalData: Partial<PickSchema<N>> = {}
97
- const missingNamespaces: N[] = []
98
-
99
- for (const { ns, data, hit } of cacheResults) {
100
- if (hit) {
101
- // Cached entries are merged with FALLBACK_* too. A namespace cached before
102
- // a schema addition is missing the newer keys; without this merge it would
103
- // reach pages as-is and any read of a new key would throw (ADR-0014).
104
- ;(finalData as any)[ns] = assembleNamespace(data, getFallback(ns))
105
- } else {
106
- missingNamespaces.push(ns)
107
- }
108
- }
109
-
110
- // All namespaces were in cache — return immediately, zero KV reads
111
- if (missingNamespaces.length === 0) {
112
- debug('i18n cache FULL HIT', { locale, namespaces })
113
- return finalData as PickSchema<N>
114
- }
115
-
116
- debug('i18n cache PARTIAL/FULL MISS', {
117
- locale,
118
- hit: namespaces.length - missingNamespaces.length,
119
- miss: missingNamespaces.length,
120
- })
121
-
122
- // ---------------------------------------------------------------------------
123
- // 3. Batch KV read for missing namespaces only
124
- // ---------------------------------------------------------------------------
125
-
126
- const missingKvKeys = missingNamespaces.map((ns) => buildTranslationKvKey(locale, ns))
127
-
128
- let kvResults = new Map<string, unknown | null>()
129
- let kvFailed = false
130
-
131
- try {
132
- kvResults = (await i18nEnv.TRANSLATIONS.get(missingKvKeys, KV_JSON_OPTIONS)) as Map<string, unknown | null>
133
-
134
- debug('i18n KV batch OK', { locale, missingNamespaces })
135
- } catch (error) {
136
- kvFailed = true
137
- debug('i18n KV ERROR, using FALLBACK_* for missing namespaces', error)
138
- }
139
-
140
- // ---------------------------------------------------------------------------
141
- // 4. Assemble missing namespaces + schedule individual cache writes
142
- // ---------------------------------------------------------------------------
143
-
144
- const putPromises: Promise<void>[] = []
145
-
146
- for (let i = 0; i < missingNamespaces.length; i++) {
147
- const ns = missingNamespaces[i] as N
148
- const kvKey = missingKvKeys[i] as string
149
-
150
- // Use KV value if available, otherwise empty object (fallback will fill it)
151
- const kvValue = kvFailed ? {} : ((kvResults.get(kvKey) as object) ?? {})
152
-
153
- // Merge with FALLBACK_* compiled dictionary if present
154
- const fallback = getFallback(ns)
155
-
156
- const nsData = assembleNamespace(kvValue, fallback)
157
-
158
- if (kvFailed && !fallback) {
159
- debug(`i18n namespace "${ns}" unavailable: KV failed and no fallback`)
160
- }
161
-
162
- ;(finalData as any)[ns] = nsData
163
-
164
- // Cache the resolved namespace individually with a long TTL.
165
- // Invalidation is handled explicitly via Cloudflare Purge API —
166
- // no TTL-based expiry needed here.
167
- const cacheRequest = buildTranslationCacheRequest(locale, ns)
168
- const response = new Response(JSON.stringify(nsData), {
169
- headers: {
170
- 'Content-Type': 'application/json; charset=utf-8',
171
- 'Cache-Control': 'public, s-maxage=31536000, immutable',
172
- },
173
- })
174
-
175
- putPromises.push(
176
- cache.put(cacheRequest, response.clone()).catch((error: unknown) => {
177
- const errorMsg = error instanceof Error ? error.message : String(error)
178
- debug(`i18n cache WRITE error for ${ns}`, errorMsg)
179
- }),
180
- )
181
- }
182
-
183
- // ---------------------------------------------------------------------------
184
- // 5. Schedule cache writes as background tasks
185
- // ---------------------------------------------------------------------------
186
-
187
- if (putPromises.length > 0) {
188
- // Top-level waitUntil (cloudflare:workers): same semantics as
189
- // ctx.waitUntil, no need to plumb an ExecutionContext through callers.
190
- // putPromises only exist when the Cache API is available, so this never
191
- // runs in plain `astro dev`.
192
- waitUntil(Promise.all(putPromises))
193
-
194
- debug('i18n cache PUT scheduled', { locale, missingNamespaces })
195
- }
196
-
197
- return finalData as PickSchema<N>
198
- }
199
-
200
- // ---------------------------------------------------------------------------
201
- // KV helpers
202
- // ---------------------------------------------------------------------------
203
-
204
- /**
205
- * Fetch all requested namespaces directly from KV, bypassing the cache.
206
- * Used when caching is disabled or the Cache API is unavailable.
207
- */
208
- async function fetchAllFromKV<N extends Namespace>(locale: Locale, namespaces: readonly N[]): Promise<PickSchema<N>> {
209
- const keys = namespaces.map((ns) => buildTranslationKvKey(locale, ns))
210
-
211
- let kvResults = new Map<string, unknown | null>()
212
- let kvFailed = false
213
-
214
- try {
215
- kvResults = (await i18nEnv.TRANSLATIONS.get(keys, KV_JSON_OPTIONS)) as Map<string, unknown | null>
216
- } catch (error) {
217
- kvFailed = true
218
- debug('i18n KV ERROR (cache bypassed), using FALLBACK_* for all namespaces', error)
219
- }
220
-
221
- if (kvFailed) {
222
- return buildFallbackOnly(namespaces)
223
- }
224
-
225
- const data: Partial<PickSchema<N>> = {}
226
-
227
- for (let i = 0; i < namespaces.length; i++) {
228
- const ns = namespaces[i]
229
- const kvValue = kvResults.get(keys[i] as string) as object | null
230
- ;(data as any)[ns] = assembleNamespace(kvValue, getFallback(ns as N))
231
- }
232
-
233
- return data as PickSchema<N>
234
- }
235
-
236
- /**
237
- * Look up the compiled FALLBACK_* dictionary for a namespace, if one was generated.
238
- */
239
- function getFallback(ns: Namespace): unknown {
240
- return FALLBACKS[`FALLBACK_${String(ns).toUpperCase()}`]
241
- }
242
-
243
- /**
244
- * Build a payload using only FALLBACK_* compiled dictionaries.
245
- * Used when KV is completely unavailable.
246
- */
247
- function buildFallbackOnly<N extends Namespace>(namespaces: readonly N[]): PickSchema<N> {
248
- const data: Partial<PickSchema<N>> = {}
249
-
250
- for (const ns of namespaces) {
251
- ;(data as any)[ns] = getFallback(ns) ?? {}
252
- }
253
-
254
- return data as PickSchema<N>
255
- }
256
-
257
- // ---------------------------------------------------------------------------
258
- // Runtime helpers
259
- // ---------------------------------------------------------------------------
260
-
261
- /**
262
- * Narrow the global `caches` to the minimal shape this module needs.
263
- * (waitUntil comes straight from 'cloudflare:workers' — no ctx plumbing.)
264
- */
265
- function getEdgeCache(): {
266
- cache: { match: any; put: any } | null
267
- } {
268
- const cacheStorage = caches as { default?: { match: any; put: any } }
269
-
270
- return {
271
- cache: cacheStorage?.default ?? null,
272
- }
273
- }
274
-
275
- /**
276
- * Determine whether edge caching is enabled.
277
- *
278
- * Wrangler var:
279
- * I18N_CACHE = "on" | "off" | "true" | "false" | "1" | "0"
280
- *
281
- * After adding the variable:
282
- * - Update wrangler.jsonc / wrangler.toml or .dev.vars
283
- * - Run: wrangler types (if the project generates its Env)
284
- */
285
- function isCacheEnabled(): boolean {
286
- return parseBooleanFlag(i18nEnv.I18N_CACHE, /* defaultValue */ true)
287
- }
288
-
289
- /**
290
- * Debug logger controlled by a Wrangler variable.
291
- *
292
- * Wrangler var:
293
- * DEBUG_I18N = "on" | "off" | "true" | "false" | "1" | "0"
294
- *
295
- * After adding the variable:
296
- * - Update wrangler.jsonc / wrangler.toml or .dev.vars
297
- * - Run: wrangler types (if the project generates its Env)
298
- */
299
- function debug(...args: unknown[]): void {
300
- const enabled = parseBooleanFlag(i18nEnv.DEBUG_I18N, /* defaultValue */ false)
301
- if (!enabled) return
302
-
303
- // eslint-disable-next-line no-console
304
- console.log(...args)
305
- }
4
+ export const { fetchTranslations } = i18n