@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
package/src/fetcher.ts ADDED
@@ -0,0 +1,304 @@
1
+ import { env, waitUntil } from 'cloudflare:workers'
2
+ import * as fallbacksModule from 'virtual:@edgekits/astro-i18n/fallbacks'
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
+ // Cache API unavailable (e.g. Astro dev server without Miniflare)
65
+ debug('i18n Cache API unavailable, using KV only', {
66
+ locale,
67
+ namespaces,
68
+ })
69
+ return fetchAllFromKV(locale, namespaces)
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // 1. Check Cache API in parallel for each namespace individually
74
+ // ---------------------------------------------------------------------------
75
+
76
+ const cacheResults = await Promise.all(
77
+ namespaces.map(async (ns) => {
78
+ const cacheRequest = buildTranslationCacheRequest(locale, ns)
79
+ try {
80
+ const cached = await cache.match(cacheRequest)
81
+ if (cached) {
82
+ return { ns, data: await cached.json(), hit: true }
83
+ }
84
+ } catch (error) {
85
+ debug(`i18n cache READ error for ${ns}`, error)
86
+ }
87
+ return { ns, data: null, hit: false }
88
+ }),
89
+ )
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // 2. Separate hits from misses
93
+ // ---------------------------------------------------------------------------
94
+
95
+ const finalData: Partial<PickSchema<N>> = {}
96
+ const missingNamespaces: N[] = []
97
+
98
+ for (const { ns, data, hit } of cacheResults) {
99
+ if (hit) {
100
+ // Cached entries are merged with FALLBACK_* too. A namespace cached before
101
+ // a schema addition is missing the newer keys; without this merge it would
102
+ // reach pages as-is and any read of a new key would throw (ADR-0014).
103
+ ;(finalData as any)[ns] = assembleNamespace(data, getFallback(ns))
104
+ } else {
105
+ missingNamespaces.push(ns)
106
+ }
107
+ }
108
+
109
+ // All namespaces were in cache — return immediately, zero KV reads
110
+ if (missingNamespaces.length === 0) {
111
+ debug('i18n cache FULL HIT', { locale, namespaces })
112
+ return finalData as PickSchema<N>
113
+ }
114
+
115
+ debug('i18n cache PARTIAL/FULL MISS', {
116
+ locale,
117
+ hit: namespaces.length - missingNamespaces.length,
118
+ miss: missingNamespaces.length,
119
+ })
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // 3. Batch KV read for missing namespaces only
123
+ // ---------------------------------------------------------------------------
124
+
125
+ const missingKvKeys = missingNamespaces.map((ns) => buildTranslationKvKey(locale, ns))
126
+
127
+ let kvResults = new Map<string, unknown | null>()
128
+ let kvFailed = false
129
+
130
+ try {
131
+ kvResults = (await i18nEnv.TRANSLATIONS.get(missingKvKeys, KV_JSON_OPTIONS)) as Map<string, unknown | null>
132
+
133
+ debug('i18n KV batch OK', { locale, missingNamespaces })
134
+ } catch (error) {
135
+ kvFailed = true
136
+ debug('i18n KV ERROR, using FALLBACK_* for missing namespaces', error)
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // 4. Assemble missing namespaces + schedule individual cache writes
141
+ // ---------------------------------------------------------------------------
142
+
143
+ const putPromises: Promise<void>[] = []
144
+
145
+ for (let i = 0; i < missingNamespaces.length; i++) {
146
+ const ns = missingNamespaces[i] as N
147
+ const kvKey = missingKvKeys[i] as string
148
+
149
+ // Use KV value if available, otherwise empty object (fallback will fill it)
150
+ const kvValue = kvFailed ? {} : ((kvResults.get(kvKey) as object) ?? {})
151
+
152
+ // Merge with FALLBACK_* compiled dictionary if present
153
+ const fallback = getFallback(ns)
154
+
155
+ const nsData = assembleNamespace(kvValue, fallback)
156
+
157
+ if (kvFailed && !fallback) {
158
+ debug(`i18n namespace "${ns}" unavailable: KV failed and no fallback`)
159
+ }
160
+
161
+ ;(finalData as any)[ns] = nsData
162
+
163
+ // Cache the resolved namespace individually with a long TTL.
164
+ // Invalidation is handled explicitly via Cloudflare Purge API —
165
+ // no TTL-based expiry needed here.
166
+ const cacheRequest = buildTranslationCacheRequest(locale, ns)
167
+ const response = new Response(JSON.stringify(nsData), {
168
+ headers: {
169
+ 'Content-Type': 'application/json; charset=utf-8',
170
+ 'Cache-Control': 'public, s-maxage=31536000, immutable',
171
+ },
172
+ })
173
+
174
+ putPromises.push(
175
+ cache.put(cacheRequest, response.clone()).catch((error: unknown) => {
176
+ const errorMsg = error instanceof Error ? error.message : String(error)
177
+ debug(`i18n cache WRITE error for ${ns}`, errorMsg)
178
+ }),
179
+ )
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // 5. Schedule cache writes as background tasks
184
+ // ---------------------------------------------------------------------------
185
+
186
+ if (putPromises.length > 0) {
187
+ // Top-level waitUntil (cloudflare:workers): same semantics as
188
+ // ctx.waitUntil, no need to plumb an ExecutionContext through callers.
189
+ // putPromises only exist when the Cache API is available, so this never
190
+ // runs in plain `astro dev`.
191
+ waitUntil(Promise.all(putPromises))
192
+
193
+ debug('i18n cache PUT scheduled', { locale, missingNamespaces })
194
+ }
195
+
196
+ return finalData as PickSchema<N>
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // KV helpers
201
+ // ---------------------------------------------------------------------------
202
+
203
+ /**
204
+ * Fetch all requested namespaces directly from KV, bypassing the cache.
205
+ * Used when caching is disabled or the Cache API is unavailable.
206
+ */
207
+ async function fetchAllFromKV<N extends Namespace>(locale: Locale, namespaces: readonly N[]): Promise<PickSchema<N>> {
208
+ const keys = namespaces.map((ns) => buildTranslationKvKey(locale, ns))
209
+
210
+ let kvResults = new Map<string, unknown | null>()
211
+ let kvFailed = false
212
+
213
+ try {
214
+ kvResults = (await i18nEnv.TRANSLATIONS.get(keys, KV_JSON_OPTIONS)) as Map<string, unknown | null>
215
+ } catch (error) {
216
+ kvFailed = true
217
+ debug('i18n KV ERROR (cache bypassed), using FALLBACK_* for all namespaces', error)
218
+ }
219
+
220
+ if (kvFailed) {
221
+ return buildFallbackOnly(namespaces)
222
+ }
223
+
224
+ const data: Partial<PickSchema<N>> = {}
225
+
226
+ for (let i = 0; i < namespaces.length; i++) {
227
+ const ns = namespaces[i]
228
+ const kvValue = kvResults.get(keys[i] as string) as object | null
229
+ ;(data as any)[ns] = assembleNamespace(kvValue, getFallback(ns as N))
230
+ }
231
+
232
+ return data as PickSchema<N>
233
+ }
234
+
235
+ /**
236
+ * Look up the compiled FALLBACK_* dictionary for a namespace, if one was generated.
237
+ */
238
+ function getFallback(ns: Namespace): unknown {
239
+ return FALLBACKS[`FALLBACK_${String(ns).toUpperCase()}`]
240
+ }
241
+
242
+ /**
243
+ * Build a payload using only FALLBACK_* compiled dictionaries.
244
+ * Used when KV is completely unavailable.
245
+ */
246
+ function buildFallbackOnly<N extends Namespace>(namespaces: readonly N[]): PickSchema<N> {
247
+ const data: Partial<PickSchema<N>> = {}
248
+
249
+ for (const ns of namespaces) {
250
+ ;(data as any)[ns] = getFallback(ns) ?? {}
251
+ }
252
+
253
+ return data as PickSchema<N>
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Runtime helpers
258
+ // ---------------------------------------------------------------------------
259
+
260
+ /**
261
+ * Narrow the global `caches` to the minimal shape this module needs.
262
+ * (waitUntil comes straight from 'cloudflare:workers' — no ctx plumbing.)
263
+ */
264
+ function getEdgeCache(): {
265
+ cache: { match: any; put: any } | null
266
+ } {
267
+ const cacheStorage = caches as { default?: { match: any; put: any } }
268
+
269
+ return {
270
+ cache: cacheStorage?.default ?? null,
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Determine whether edge caching is enabled.
276
+ *
277
+ * Wrangler var:
278
+ * I18N_CACHE = "on" | "off" | "true" | "false" | "1" | "0"
279
+ *
280
+ * After adding the variable:
281
+ * - Update wrangler.jsonc / wrangler.toml or .dev.vars
282
+ * - Run: wrangler types (if the project generates its Env)
283
+ */
284
+ function isCacheEnabled(): boolean {
285
+ return parseBooleanFlag(i18nEnv.I18N_CACHE, /* defaultValue */ true)
286
+ }
287
+
288
+ /**
289
+ * Debug logger controlled by a Wrangler variable.
290
+ *
291
+ * Wrangler var:
292
+ * DEBUG_I18N = "on" | "off" | "true" | "false" | "1" | "0"
293
+ *
294
+ * After adding the variable:
295
+ * - Update wrangler.jsonc / wrangler.toml or .dev.vars
296
+ * - Run: wrangler types (if the project generates its Env)
297
+ */
298
+ function debug(...args: unknown[]): void {
299
+ const enabled = parseBooleanFlag(i18nEnv.DEBUG_I18N, /* defaultValue */ false)
300
+ if (!enabled) return
301
+
302
+ // eslint-disable-next-line no-console
303
+ console.log(...args)
304
+ }
package/src/format.ts ADDED
@@ -0,0 +1,107 @@
1
+ // Sanitizer: Turns <script> into &lt;script&gt;
2
+ function escapeHtml(unsafe: string | number): string {
3
+ return String(unsafe)
4
+ .replace(/&/g, '&amp;')
5
+ .replace(/</g, '&lt;')
6
+ .replace(/>/g, '&gt;')
7
+ .replace(/"/g, '&quot;')
8
+ .replace(/'/g, '&#039;')
9
+ }
10
+
11
+ type TemplateValues = Record<string, string | number>
12
+
13
+ // `./format` is a Node-safe entry: under plain Node (a project's unit tests, `astro sync`)
14
+ // there is no `import.meta.env`, and a missing one counts as "not production", so the
15
+ // dev warnings below still fire instead of throwing.
16
+ function isProd(): boolean {
17
+ return import.meta.env?.PROD === true
18
+ }
19
+
20
+ /**
21
+ * Interpolates {placeholders} in a template string and escapes values.
22
+ * - If `template` is undefined/null, returns an empty string (and logs in dev).
23
+ * - If `values` is undefined, returns the template unchanged.
24
+ */
25
+ export function fmt(template: string | null | undefined, values?: TemplateValues): string {
26
+ if (template == null) {
27
+ if (!isProd()) {
28
+ console.warn('[i18n] fmt() called with null/undefined template')
29
+ }
30
+ return ''
31
+ }
32
+
33
+ if (!values) return template
34
+
35
+ return template.replace(/{([^}]+)}/g, (match, key) => {
36
+ const replacement = values[key]
37
+
38
+ if (replacement === undefined || replacement === null) {
39
+ return match
40
+ }
41
+
42
+ return escapeHtml(replacement)
43
+ })
44
+ }
45
+
46
+ /**
47
+ * Simple two-form plural helper.
48
+ * Good for English-like languages where "1" vs "other" is enough.
49
+ */
50
+ export function plural(count: number, singular: string, plural: string): string {
51
+ return count === 1 ? singular : fmt(plural, { count })
52
+ }
53
+
54
+ /**
55
+ * ICU-style plural categories used by Intl.PluralRules.
56
+ */
57
+ export type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'
58
+
59
+ export type PluralPatterns = Partial<Record<PluralCategory, string>>
60
+
61
+ /**
62
+ * Advanced plural helper using Intl.PluralRules + fmt().
63
+ *
64
+ * - `locale` controls plural categories (en, de, es, ja, ...).
65
+ * - `patterns` comes from KV (e.g. { zero, one, other }).
66
+ * - Missing categories fall back to `other`.
67
+ *
68
+ * Example:
69
+ * pluralIcu(5, 'en', {
70
+ * zero: 'No items',
71
+ * one: '{count} item',
72
+ * other: '{count} items'
73
+ * })
74
+ */
75
+ export function pluralIcu(count: number, locale: string, patterns: PluralPatterns): string {
76
+ const rules = new Intl.PluralRules(locale)
77
+ const cat = rules.select(count) as PluralCategory
78
+
79
+ const template =
80
+ patterns[cat] ??
81
+ patterns.other ??
82
+ (isProd() ? '' : (console.warn('[i18n] pluralIcu(): missing "other" pattern'), ''))
83
+
84
+ return fmt(template, { count })
85
+ }
86
+
87
+ // Used in the LanguageSwitcher and SeoHreflangs components
88
+ export function replaceLocaleInPath(currentPath: string, currentLocale: string, newLocale: string): string {
89
+ const prefixRegex = new RegExp(`^/${currentLocale}/`)
90
+ return currentPath.replace(prefixRegex, `/${newLocale}/`)
91
+ }
92
+
93
+ // Used in the LanguageSwitcher component
94
+ export function getLangFromUrl(pathname: string) {
95
+ // Remove leading/trailing slashes: '/en/about/' -> 'en/about'
96
+ const cleaned = pathname.replace(/^\/+|\/+$/g, '')
97
+ const [lang] = cleaned.split('/')
98
+ return lang
99
+ }
100
+
101
+ // Converts a URL locale code to an IETF BCP 47 language tag.
102
+ // Used for the HTML lang attribute and hreflang values.
103
+ // 'pt-br' → 'pt-BR', 'en-us' → 'en-US', 'en' → 'en'
104
+ export function toIetfTag(locale: string): string {
105
+ const [lang, region] = locale.split('-')
106
+ return region ? `${lang}-${region.toUpperCase()}` : locale
107
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ // The config-time entry: what `astro.config` and `edgekits-i18n.config.ts` import.
2
+ // Pure under Node: nothing here reaches a virtual module or `cloudflare:workers`.
3
+ export { edgekitsI18n, type EdgekitsI18nOptions } from './integration/index.ts'
4
+ export {
5
+ defineI18nConfig,
6
+ type I18nBindings,
7
+ type I18nConfig,
8
+ type I18nLanguage,
9
+ type I18nLocales,
10
+ type Locale,
11
+ } from './types.ts'
@@ -0,0 +1,136 @@
1
+ import type { AstroConfig, AstroIntegration } from 'astro'
2
+
3
+ import { DEFAULT_GENERATED_DIR, defineI18nConfig, type I18nConfig } from '../types.ts'
4
+ import { i18nVitePlugin } from './vite-plugin.ts'
5
+ import { virtualModulesDeclaration } from './virtual-types.ts'
6
+
7
+ export type EdgekitsI18nOptions = {
8
+ // 'auto' registers the i18n middleware; 'manual' leaves it to the project, which
9
+ // then imports `i18nMiddleware` and places it in its own sequence.
10
+ middleware?: 'auto' | 'manual'
11
+ }
12
+
13
+ const MIDDLEWARE_MODES = ['auto', 'manual'] as const
14
+
15
+ export const PACKAGE_NAME = '@edgekits/astro-i18n'
16
+
17
+ /**
18
+ * The Astro integration. Build time only: it runs in Node while Astro loads its
19
+ * config, so it reaches the engine's runtime modules through nothing but the Vite
20
+ * plugin's virtual modules.
21
+ */
22
+ export function edgekitsI18n(config: I18nConfig, options: EdgekitsI18nOptions = {}): AstroIntegration {
23
+ const i18nConfig = defineI18nConfig(config)
24
+ const middleware = options.middleware ?? 'auto'
25
+
26
+ if (!MIDDLEWARE_MODES.includes(middleware)) {
27
+ throw new Error(
28
+ `[@edgekits/astro-i18n] \`middleware\` "${String(middleware)}" is not supported; use 'auto' or 'manual'.`,
29
+ )
30
+ }
31
+
32
+ return {
33
+ name: '@edgekits/astro-i18n',
34
+ hooks: {
35
+ 'astro:config:setup': ({ config: astroConfig, updateConfig, addMiddleware }) => {
36
+ const generatedDir = new URL(
37
+ `${trimSlashes(i18nConfig.generatedDir ?? DEFAULT_GENERATED_DIR)}/`,
38
+ astroConfig.root,
39
+ )
40
+
41
+ // Astro computes `preferredLocale` (the middleware's Accept-Language step) only
42
+ // with an `i18n` block: set it when the project has none, check it when it has one.
43
+ if (astroConfig.i18n) assertAstroI18n(astroConfig.i18n, i18nConfig)
44
+
45
+ updateConfig({
46
+ vite: {
47
+ plugins: [i18nVitePlugin(i18nConfig, generatedDir)],
48
+ // The runtime ships as source and imports the virtual modules above: Vite
49
+ // must process it rather than hand it to Node as an external dependency.
50
+ ssr: { noExternal: [PACKAGE_NAME] },
51
+ },
52
+ ...(astroConfig.i18n ? {} : { i18n: astroI18n(i18nConfig) }),
53
+ })
54
+
55
+ if (middleware === 'auto') {
56
+ addMiddleware({ entrypoint: '@edgekits/astro-i18n/middleware', order: 'pre' })
57
+ }
58
+ },
59
+ 'astro:config:done': ({ injectTypes }) => {
60
+ injectTypes({ filename: 'locales.d.ts', content: localesDeclaration(Object.keys(i18nConfig.languages)) })
61
+ // The shipped runtime imports the virtual modules; a consumer's program needs their
62
+ // declarations too. A separate file: these must not sit in a module (see virtual-types.ts).
63
+ injectTypes({ filename: 'virtual-modules.d.ts', content: virtualModulesDeclaration(PACKAGE_NAME) })
64
+ },
65
+ },
66
+ }
67
+ }
68
+
69
+ // Each entry that exports `I18nLocales` from its own declaration: the root entry's comes
70
+ // from dist/types.d.ts, the runtime entry's from the shipped source (src/types.ts).
71
+ const LOCALE_MODULES = ['@edgekits/astro-i18n', '@edgekits/astro-i18n/runtime']
72
+
73
+ /**
74
+ * Augments `I18nLocales` with the configured locales, so `Locale` narrows from
75
+ * `string` to their union in the project, through both entries. The `export {}` keeps
76
+ * the file a module: without it `declare module` would replace the package's types
77
+ * instead of merging.
78
+ */
79
+ export function localesDeclaration(locales: readonly string[]): string {
80
+ const members = locales.map((locale) => ` ${JSON.stringify(locale)}: true`)
81
+
82
+ return [
83
+ 'export {}',
84
+ ...LOCALE_MODULES.flatMap((specifier) => [
85
+ '',
86
+ `declare module '${specifier}' {`,
87
+ ' interface I18nLocales {',
88
+ ...members,
89
+ ' }',
90
+ '}',
91
+ ]),
92
+ '',
93
+ ].join('\n')
94
+ }
95
+
96
+ /**
97
+ * The Astro `i18n` block set when the project has none: locales and default from the
98
+ * config; every URL locale-prefixed (the middleware redirects `/` itself, so Astro
99
+ * does not). Complete, `fallbackType` included: Astro does not re-validate what
100
+ * `updateConfig` merges, so its schema defaults never fill a partial block.
101
+ */
102
+ export function astroI18n(config: I18nConfig): NonNullable<AstroConfig['i18n']> {
103
+ return {
104
+ locales: Object.keys(config.languages),
105
+ defaultLocale: config.defaultLocale,
106
+ routing: { prefixDefaultLocale: true, redirectToDefaultLocale: false, fallbackType: 'redirect' },
107
+ }
108
+ }
109
+
110
+ /**
111
+ * A project's own Astro `i18n` block must cover every configured locale (as a string
112
+ * entry, a `path` or one of its `codes`) and share the default locale.
113
+ */
114
+ function assertAstroI18n(block: NonNullable<AstroConfig['i18n']>, config: I18nConfig): void {
115
+ const declared = new Set(
116
+ block.locales.flatMap((entry) => (typeof entry === 'string' ? [entry] : [entry.path, ...entry.codes])),
117
+ )
118
+ const missing = Object.keys(config.languages).filter((locale) => !declared.has(locale))
119
+
120
+ if (missing.length > 0) {
121
+ throw new Error(
122
+ `[@edgekits/astro-i18n] Astro \`i18n.locales\` is missing ${missing.join(', ')} from \`languages\`. ` +
123
+ 'Add them, or remove the `i18n` block from astro.config and the integration sets it for you.',
124
+ )
125
+ }
126
+ if (block.defaultLocale !== config.defaultLocale) {
127
+ throw new Error(
128
+ `[@edgekits/astro-i18n] Astro \`i18n.defaultLocale\` "${block.defaultLocale}" differs from \`defaultLocale\` "${config.defaultLocale}". ` +
129
+ 'Make them equal, or remove the `i18n` block from astro.config and the integration sets it for you.',
130
+ )
131
+ }
132
+ }
133
+
134
+ function trimSlashes(path: string): string {
135
+ return path.replace(/^\/+|\/+$/g, '')
136
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The declarations of the three `virtual:@edgekits/astro-i18n/*` modules, in one place
3
+ * for both programs that need them:
4
+ * - the package's own typecheck, through `src/virtual.d.ts` (`typesModule` './types');
5
+ * test/virtual-types.test.ts fails when that file stops matching this text;
6
+ * - the consumer's, through the `virtual-modules.d.ts` the integration injects
7
+ * (`typesModule` the package name), because a consumer's program never loads
8
+ * `src/virtual.d.ts` from node_modules but does import the shipped `src/` files.
9
+ *
10
+ * The text has no top-level import or export on purpose: in a module file,
11
+ * `declare module 'virtual:…'` would augment a module that does not exist instead of
12
+ * declaring one. (The injected `locales.d.ts` is a module, so it cannot carry these.)
13
+ */
14
+ export function virtualModulesDeclaration(typesModule: string): string {
15
+ return `declare module 'virtual:@edgekits/astro-i18n/config' {
16
+ const config: import('${typesModule}').I18nConfig
17
+ export default config
18
+ }
19
+
20
+ // The consumer's \`<generatedDir>/runtime-constants.ts\`, or a stub with empty lists.
21
+ declare module 'virtual:@edgekits/astro-i18n/runtime-constants' {
22
+ export const NAMESPACE_KEYS: readonly string[]
23
+ export const LOCALES_WITH_TRANSLATIONS: readonly string[]
24
+ export const FULLY_TRANSLATED_LOCALES: readonly string[]
25
+ }
26
+
27
+ // The consumer's \`<generatedDir>/fallbacks.generated.ts\`, or an empty stub. Its exports
28
+ // (\`FALLBACK_<NAMESPACE>\`) depend on the project's namespaces, so none are declared here:
29
+ // the fetcher reads the module namespace by name.
30
+ declare module 'virtual:@edgekits/astro-i18n/fallbacks' {
31
+ export {}
32
+ }
33
+ `
34
+ }
@@ -0,0 +1,67 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
3
+ import type { AstroConfig } from 'astro'
4
+
5
+ import type { I18nConfig } from '../types.ts'
6
+
7
+ type VitePlugin = NonNullable<NonNullable<AstroConfig['vite']>['plugins']>[number]
8
+
9
+ export const CONFIG_ID = 'virtual:@edgekits/astro-i18n/config'
10
+ export const FALLBACKS_ID = 'virtual:@edgekits/astro-i18n/fallbacks'
11
+ export const RUNTIME_CONSTANTS_ID = 'virtual:@edgekits/astro-i18n/runtime-constants'
12
+
13
+ // Rollup convention: a `\0` prefix marks a resolved id as virtual, so no other plugin
14
+ // tries to read it from disk.
15
+ const resolved = (id: string) => `\0${id}`
16
+
17
+ const STUBS: Record<string, string> = {
18
+ [FALLBACKS_ID]: 'export {}\n',
19
+ [RUNTIME_CONSTANTS_ID]: [
20
+ 'export const NAMESPACE_KEYS = []',
21
+ 'export const LOCALES_WITH_TRANSLATIONS = []',
22
+ 'export const FULLY_TRANSLATED_LOCALES = []',
23
+ '',
24
+ ].join('\n'),
25
+ }
26
+
27
+ /**
28
+ * Serves the three virtual modules the engine imports:
29
+ * - the config, serialised as JSON;
30
+ * - the project's generated `fallbacks.generated.ts` and `runtime-constants.ts` under
31
+ * `generatedDir`, or a stub module when the file does not exist (yet).
32
+ *
33
+ * The file check runs on every resolve, so files the CLI generates while the dev
34
+ * server runs are picked up on the next resolve.
35
+ */
36
+ export function i18nVitePlugin(config: I18nConfig, generatedDir: URL): VitePlugin {
37
+ const generatedFiles: Record<string, string> = {
38
+ [FALLBACKS_ID]: toPath(new URL('fallbacks.generated.ts', generatedDir)),
39
+ [RUNTIME_CONSTANTS_ID]: toPath(new URL('runtime-constants.ts', generatedDir)),
40
+ }
41
+
42
+ return {
43
+ name: '@edgekits/astro-i18n',
44
+ resolveId(id: string) {
45
+ if (id === CONFIG_ID) return resolved(id)
46
+
47
+ const file = generatedFiles[id]
48
+ if (file === undefined) return undefined
49
+
50
+ return existsSync(file) ? file : resolved(id)
51
+ },
52
+ load(id: string) {
53
+ if (id === resolved(CONFIG_ID)) return `export default ${JSON.stringify(config)}\n`
54
+
55
+ for (const [virtualId, stub] of Object.entries(STUBS)) {
56
+ if (id === resolved(virtualId)) return stub
57
+ }
58
+
59
+ return undefined
60
+ },
61
+ }
62
+ }
63
+
64
+ // Vite ids use forward slashes on every platform.
65
+ function toPath(url: URL): string {
66
+ return fileURLToPath(url).replace(/\\/g, '/')
67
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The translation KV key and edge-cache URL formats, as pure functions of the key
3
+ * prefix. Shared by the runtime (`translations-keys.ts`, prefix from the virtual config)
4
+ * and the CLI (prefix from the loaded config), so the key a value is stored under and
5
+ * the URL a purge targets can never drift apart. Pinned by `test/i18n-keys.test.ts`.
6
+ */
7
+
8
+ // Format: <keyPrefix>:<namespace>:<locale>
9
+ export function formatTranslationKvKey(keyPrefix: string, locale: string, ns: string): string {
10
+ return `${keyPrefix}:${ns}:${locale}`
11
+ }
12
+
13
+ // Format: https://<keyPrefix>/<encodeURIComponent(i18n:<locale>:<namespace>)>
14
+ export function formatTranslationCacheUrl(keyPrefix: string, locale: string, ns: string): string {
15
+ const cacheId = `i18n:${locale}:${ns}`
16
+ return `https://${keyPrefix}/${encodeURIComponent(cacheId)}`
17
+ }