@edgekits/astro-i18n 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +98 -27
- package/dist/integration/index.d.ts +25 -4
- package/dist/integration/index.js +35 -9
- package/dist/integration/vite-plugin.d.ts +1 -1
- package/dist/types.d.ts +7 -55
- package/dist/types.js +1 -44
- package/package.json +7 -17
- package/src/components/LanguageSwitcher.astro +89 -91
- package/src/config.entry.ts +5 -36
- package/src/constants.ts +5 -3
- package/src/cookie-storage.ts +19 -25
- package/src/fetcher.ts +3 -304
- package/src/format.ts +3 -107
- package/src/instance.ts +44 -0
- package/src/integration/index.ts +38 -9
- package/src/integration/vite-plugin.ts +1 -1
- package/src/middleware/i18n.ts +232 -114
- package/src/resolve-locale.ts +7 -39
- package/src/runtime.ts +2 -2
- package/src/schema.ts +3 -11
- package/src/translations-keys.ts +7 -38
- package/src/types.ts +16 -116
- package/dist/cli/generate.d.ts +0 -50
- package/dist/cli/generate.js +0 -160
- package/dist/cli/hashes.d.ts +0 -19
- package/dist/cli/hashes.js +0 -42
- package/dist/cli/index.d.ts +0 -2
- package/dist/cli/index.js +0 -368
- package/dist/cli/load-config.d.ts +0 -7
- package/dist/cli/load-config.js +0 -19
- package/dist/cli/purge.d.ts +0 -29
- package/dist/cli/purge.js +0 -94
- package/dist/cli/wrangler.d.ts +0 -10
- package/dist/cli/wrangler.js +0 -71
- package/dist/key-format.d.ts +0 -8
- package/dist/key-format.js +0 -15
- package/src/accept-language.ts +0 -56
- package/src/assemble.ts +0 -29
- package/src/cli/generate.ts +0 -205
- package/src/cli/hashes.ts +0 -66
- package/src/cli/index.ts +0 -467
- package/src/cli/load-config.ts +0 -26
- package/src/cli/purge.ts +0 -122
- package/src/cli/wrangler.ts +0 -96
- package/src/deep-merge.ts +0 -38
- package/src/key-format.ts +0 -17
- package/src/locale-fallback.ts +0 -58
- package/src/markdown-path.ts +0 -31
- package/src/parse-boolean.ts +0 -20
|
@@ -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,81 +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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
return (
|
|
73
|
-
<button
|
|
74
|
-
type="button"
|
|
75
|
-
class:list={[classes.item, isActive && classes.itemActive]}
|
|
76
|
-
data-ls-item
|
|
77
|
-
data-lang={locale}
|
|
78
|
-
data-active={isActive ? 'true' : undefined}
|
|
79
|
-
role="menuitem"
|
|
80
|
-
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"
|
|
81
60
|
>
|
|
82
|
-
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
}
|
|
102
108
|
|
|
103
109
|
<script>
|
|
104
110
|
// Config arrives as data attributes on the element: a client script in the engine
|
|
@@ -156,19 +162,16 @@ const { uiLocale: currentLang } = Astro.locals
|
|
|
156
162
|
this.dropdown.hidden ? this.open() : this.close()
|
|
157
163
|
}
|
|
158
164
|
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
segments[1] = targetLang
|
|
170
|
-
}
|
|
171
|
-
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}`
|
|
172
175
|
}
|
|
173
176
|
|
|
174
177
|
private onSelect = (event: Event) => {
|
|
@@ -179,11 +182,6 @@ const { uiLocale: currentLang } = Astro.locals
|
|
|
179
182
|
const lang = button.dataset.lang as Locale | undefined
|
|
180
183
|
if (!lang) return
|
|
181
184
|
|
|
182
|
-
const pathname = window.location.pathname
|
|
183
|
-
const currentLang = this.getLangFromUrl(pathname)
|
|
184
|
-
|
|
185
|
-
if (!currentLang) return
|
|
186
|
-
|
|
187
185
|
const { cookieName, cookieMaxAge } = this.dataset
|
|
188
186
|
|
|
189
187
|
// Persist the EXPLICIT language choice client-side. The i18n middleware
|
|
@@ -193,7 +191,7 @@ const { uiLocale: currentLang } = Astro.locals
|
|
|
193
191
|
// either side (cookie-storage.ts): the server validates it, never trusts it.
|
|
194
192
|
document.cookie = `${cookieName}=${lang}; path=/; max-age=${cookieMaxAge}; SameSite=Lax; Secure`
|
|
195
193
|
|
|
196
|
-
window.location.href = this.
|
|
194
|
+
window.location.href = this.localizedPath(window.location.pathname, lang)
|
|
197
195
|
}
|
|
198
196
|
|
|
199
197
|
private onDocumentClick = (event: MouseEvent) => {
|
package/src/config.entry.ts
CHANGED
|
@@ -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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export {
|
|
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
|
|
11
|
-
//
|
|
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>
|
package/src/cookie-storage.ts
CHANGED
|
@@ -1,24 +1,22 @@
|
|
|
1
1
|
import { type AstroCookies } from 'astro'
|
|
2
|
+
import type { CookieJar } from '@edgekits/i18n-core'
|
|
2
3
|
|
|
3
|
-
import {
|
|
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
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2
|
-
import
|
|
1
|
+
// The core's fetcher, bound to this project's config and Worker bindings (instance.ts).
|
|
2
|
+
import { i18n } from './instance'
|
|
3
3
|
|
|
4
|
-
|
|
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
|