@edgekits/astro-i18n 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +394 -2
- package/dist/cli/generate.d.ts +47 -0
- package/dist/cli/generate.js +133 -0
- package/dist/cli/hashes.d.ts +19 -0
- package/dist/cli/hashes.js +42 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +362 -0
- package/dist/cli/load-config.d.ts +7 -0
- package/dist/cli/load-config.js +19 -0
- package/dist/cli/purge.d.ts +29 -0
- package/dist/cli/purge.js +94 -0
- package/dist/cli/wrangler.d.ts +10 -0
- package/dist/cli/wrangler.js +71 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/integration/index.d.ts +26 -0
- package/dist/integration/index.js +103 -0
- package/dist/integration/virtual-types.d.ts +14 -0
- package/dist/integration/virtual-types.js +34 -0
- package/dist/integration/vite-plugin.d.ts +17 -0
- package/dist/integration/vite-plugin.js +56 -0
- package/dist/key-format.d.ts +8 -0
- package/dist/key-format.js +15 -0
- package/dist/types.d.ts +66 -0
- package/dist/types.js +38 -0
- package/package.json +77 -4
- package/src/assemble.ts +29 -0
- package/src/cli/generate.ts +175 -0
- package/src/cli/hashes.ts +66 -0
- package/src/cli/index.ts +461 -0
- package/src/cli/load-config.ts +26 -0
- package/src/cli/purge.ts +122 -0
- package/src/cli/wrangler.ts +96 -0
- package/src/components/LanguageSwitcher.astro +210 -0
- package/src/components/MissingTranslationBanner.astro +31 -0
- package/src/config.entry.ts +38 -0
- package/src/config.ts +11 -0
- package/src/constants.ts +29 -0
- package/src/cookie-storage.ts +32 -0
- package/src/deep-merge.ts +38 -0
- package/src/fetcher.ts +304 -0
- package/src/format.ts +107 -0
- package/src/index.ts +11 -0
- package/src/integration/index.ts +136 -0
- package/src/integration/virtual-types.ts +34 -0
- package/src/integration/vite-plugin.ts +67 -0
- package/src/key-format.ts +17 -0
- package/src/locale-fallback.ts +65 -0
- package/src/markdown-path.ts +31 -0
- package/src/middleware/i18n.ts +223 -0
- package/src/middleware.ts +2 -0
- package/src/parse-boolean.ts +20 -0
- package/src/resolve-locale.ts +57 -0
- package/src/runtime.ts +19 -0
- package/src/schema.ts +28 -0
- package/src/translations-keys.ts +42 -0
- package/src/types.ts +119 -0
- package/src/virtual.d.ts +23 -0
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 <script>
|
|
2
|
+
function escapeHtml(unsafe: string | number): string {
|
|
3
|
+
return String(unsafe)
|
|
4
|
+
.replace(/&/g, '&')
|
|
5
|
+
.replace(/</g, '<')
|
|
6
|
+
.replace(/>/g, '>')
|
|
7
|
+
.replace(/"/g, '"')
|
|
8
|
+
.replace(/'/g, ''')
|
|
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
|
+
}
|