@edgekits/astro-i18n 0.1.8 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +92 -28
  3. package/dist/integration/index.d.ts +25 -4
  4. package/dist/integration/index.js +35 -9
  5. package/dist/integration/vite-plugin.d.ts +1 -1
  6. package/dist/types.d.ts +7 -55
  7. package/dist/types.js +1 -44
  8. package/package.json +7 -17
  9. package/src/components/LanguageSwitcher.astro +89 -93
  10. package/src/config.entry.ts +5 -36
  11. package/src/constants.ts +5 -3
  12. package/src/cookie-storage.ts +19 -25
  13. package/src/fetcher.ts +3 -304
  14. package/src/format.ts +3 -107
  15. package/src/instance.ts +44 -0
  16. package/src/integration/index.ts +38 -9
  17. package/src/integration/vite-plugin.ts +1 -1
  18. package/src/middleware/i18n.ts +232 -114
  19. package/src/resolve-locale.ts +7 -39
  20. package/src/runtime.ts +2 -2
  21. package/src/schema.ts +3 -11
  22. package/src/translations-keys.ts +7 -38
  23. package/src/types.ts +16 -116
  24. package/dist/cli/generate.d.ts +0 -50
  25. package/dist/cli/generate.js +0 -160
  26. package/dist/cli/hashes.d.ts +0 -19
  27. package/dist/cli/hashes.js +0 -42
  28. package/dist/cli/index.d.ts +0 -2
  29. package/dist/cli/index.js +0 -372
  30. package/dist/cli/key-prefix-check.d.ts +0 -6
  31. package/dist/cli/key-prefix-check.js +0 -27
  32. package/dist/cli/load-config.d.ts +0 -7
  33. package/dist/cli/load-config.js +0 -19
  34. package/dist/cli/purge.d.ts +0 -29
  35. package/dist/cli/purge.js +0 -94
  36. package/dist/cli/wrangler.d.ts +0 -10
  37. package/dist/cli/wrangler.js +0 -71
  38. package/dist/cli/zone.d.ts +0 -21
  39. package/dist/cli/zone.js +0 -42
  40. package/dist/key-format.d.ts +0 -8
  41. package/dist/key-format.js +0 -15
  42. package/src/accept-language.ts +0 -56
  43. package/src/assemble.ts +0 -29
  44. package/src/cli/generate.ts +0 -205
  45. package/src/cli/hashes.ts +0 -66
  46. package/src/cli/index.ts +0 -472
  47. package/src/cli/key-prefix-check.ts +0 -31
  48. package/src/cli/load-config.ts +0 -26
  49. package/src/cli/purge.ts +0 -122
  50. package/src/cli/wrangler.ts +0 -96
  51. package/src/cli/zone.ts +0 -64
  52. package/src/deep-merge.ts +0 -38
  53. package/src/key-format.ts +0 -17
  54. package/src/locale-fallback.ts +0 -58
  55. package/src/markdown-path.ts +0 -31
  56. package/src/parse-boolean.ts +0 -20
@@ -1,10 +1,15 @@
1
- import type { MiddlewareHandler } from 'astro'
1
+ import type { AstroCookies, MiddlewareHandler } from 'astro'
2
+ import {
3
+ localizedPath,
4
+ MARKDOWN_PATH,
5
+ markdownRedirectTarget,
6
+ resolveFallbackLocale as resolveFallbackFrom,
7
+ type I18nRouting,
8
+ } from '@edgekits/i18n-core'
2
9
  import { LocaleSchema } from '../schema'
3
10
  import { I18N_CONFIG } from '../config'
4
11
  import { DEFAULT_LOCALE, type Locale } from '../constants'
5
- import { getCookieLang, readCookieLang, setCookieLang } from '../cookie-storage'
6
- import { resolveFallbackLocale as resolveFallbackFrom } from '../locale-fallback'
7
- import { MARKDOWN_PATH, markdownRedirectTarget } from '../markdown-path'
12
+ import { readCookieLang, setCookieLang, type CookieLang } from '../cookie-storage'
8
13
  import { resolveLocaleForTranslations } from '../resolve-locale'
9
14
 
10
15
  // Regex to identify public static assets (images, fonts, scripts, etc.)
@@ -35,14 +40,32 @@ const IGNORED_PREFIXES = [
35
40
  // Extracts the context type from Astro's MiddlewareHandler for cleaner function signatures.
36
41
  type I18nMiddlewareContext = Parameters<MiddlewareHandler>[0]
37
42
 
43
+ /**
44
+ * What the middleware reads: the resolved routing mode, the locale settings and the lang
45
+ * cookie over Astro's cookies. `i18nMiddleware` below is built from the project's config
46
+ * and instance; tests build one for either mode or a single locale.
47
+ */
48
+ export type I18nMiddlewareDeps = {
49
+ routing: I18nRouting
50
+ defaultLocale: Locale
51
+ markdownLocale: string | undefined
52
+ countryToLocale: Record<string, string> | undefined
53
+ // The locale when the value is a supported one, otherwise null.
54
+ parseLocale: (value: unknown) => Locale | null
55
+ resolveLocaleForTranslations: (locale: Locale) => Locale
56
+ readCookieLang: (cookies: AstroCookies) => CookieLang
57
+ setCookieLang: (cookies: AstroCookies, locale: Locale) => void
58
+ }
59
+
38
60
  // Two kinds of redirect, by whether the target depends on the visitor.
39
61
  //
40
62
  // Deterministic normalisations (a missing trailing slash, a `.md` path moved to
41
- // `markdownLocale`) always lead to the same URL, so they are permanent in production:
42
- // 301 consolidates canonical signals in Google. They use REDIRECT_STATUS: 302 under
43
- // `astro dev` (`import.meta.env.DEV`) so nothing gets cached while testing, 301 in any
44
- // production build (incl. `astro preview` / `wrangler dev`). `?.`: plain Node (the tests)
45
- // has no `import.meta.env`, which counts as production.
63
+ // `markdownLocale`, the default locale's prefix dropped in 'prefix-except-default') always
64
+ // lead to the same URL, so they are permanent in production: 301 consolidates canonical
65
+ // signals in Google. They use REDIRECT_STATUS: 302 under `astro dev`
66
+ // (`import.meta.env.DEV`) so nothing gets cached while testing, 301 in any production build
67
+ // (incl. `astro preview` / `wrangler dev`). `?.`: plain Node (the tests) has no
68
+ // `import.meta.env`, which counts as production.
46
69
  //
47
70
  // Negotiated redirects (no locale or an invalid one in the URL: the target comes from the
48
71
  // visitor's cookie, Accept-Language or country) are always temporary and uncacheable, in
@@ -53,14 +76,15 @@ const REDIRECT_STATUS = import.meta.env?.DEV ? 302 : 301
53
76
 
54
77
  // A redirect whose target depends on the visitor: temporary, never stored by a shared
55
78
  // cache, and keyed on the request headers that chose it (the country is not a request
56
- // header, hence `no-store` rather than relying on Vary alone).
57
- function negotiatedRedirect(target: string): Response {
79
+ // header, hence `no-store` rather than relying on Vary alone). `vary` names only the
80
+ // headers that were consulted: the root of 'prefix-except-default' reads the cookie alone.
81
+ function negotiatedRedirect(target: string, vary = 'Cookie, Accept-Language'): Response {
58
82
  return new Response(null, {
59
83
  status: 302,
60
84
  headers: {
61
85
  Location: target,
62
86
  'Cache-Control': 'private, no-store',
63
- Vary: 'Cookie, Accept-Language',
87
+ Vary: vary,
64
88
  },
65
89
  })
66
90
  }
@@ -82,106 +106,56 @@ function buildLocalizedPath(locale: Locale, rest: string[]): string {
82
106
  return suffix ? `/${locale}/${suffix}/` : `/${locale}/`
83
107
  }
84
108
 
85
- // Resolves the most appropriate locale for the user based on a fallback cascade
86
- // (locale-fallback.ts, where the order is documented and tested):
87
- // 1. User's saved cookie preference.
88
- // 2. The Accept-Language header, read here rather than through Astro (which drops
89
- // regional tags before matching): each tag by q, full tag, then its language part.
90
- // 3. Only when no tag matched: the Cloudflare Geo-IP country, through the project's
91
- // `countryToLocale` table (skipped when the config has none).
92
- // 4. Finally, falls back to the application's DEFAULT_LOCALE.
93
- function resolveFallbackLocale(context: I18nMiddlewareContext): Locale {
94
- return resolveFallbackFrom<Locale>(
95
- {
96
- cookieLocale: () => getCookieLang(context.cookies),
97
- acceptLanguage: () => context.request.headers.get('accept-language'),
98
- // cf is undefined in local dev unless simulated
99
- country: () => context.request?.cf?.country,
100
- },
101
- {
102
- parseLocale: (value) => {
103
- const parsed = LocaleSchema.safeParse(value)
104
- return parsed.success ? parsed.data : null
105
- },
106
- defaultLocale: DEFAULT_LOCALE,
107
- countryToLocale: I18N_CONFIG.countryToLocale,
108
- },
109
- )
110
- }
109
+ /** The i18n middleware for one set of deps (one routing mode, one locale list). */
110
+ export function createI18nMiddleware(deps: I18nMiddlewareDeps): MiddlewareHandler {
111
+ const isLocale = (segment: string) => deps.parseLocale(segment) !== null
112
+ const pathOptions = { routing: deps.routing, defaultLocale: deps.defaultLocale, isLocale }
111
113
 
112
- // Main i18n middleware handler. Intercepts incoming requests, manages routing,
113
- // sets locale context, and handles soft 404 protections for edge environments.
114
- export const i18nMiddleware: MiddlewareHandler = async (context, next) => {
115
- const url = new URL(context.request.url)
116
- const pathname = url.pathname
117
-
118
- // 1. Split the URL early to access its segments
119
- const segments = pathname.split('/').filter(Boolean) // "/es/about/" -> ["es", "about"]
120
- const firstSegment = segments[0] ?? null
121
-
122
- // 2. SAFETY NET (For static file 404 pages)
123
- // Attempt to infer the locale from the URL, or fallback to the default.
124
- let safeLocale = DEFAULT_LOCALE
125
- if (firstSegment) {
126
- const parsed = LocaleSchema.safeParse(firstSegment)
127
- if (parsed.success) {
128
- safeLocale = parsed.data
129
- }
114
+ // Resolves the most appropriate locale for the user based on a fallback cascade
115
+ // (the core's locale-fallback.ts, where the order is documented and tested):
116
+ // 1. User's saved cookie preference.
117
+ // 2. The Accept-Language header, read here rather than through Astro (which drops
118
+ // regional tags before matching): each tag by q, full tag, then its language part.
119
+ // 3. Only when no tag matched: the Cloudflare Geo-IP country, through the project's
120
+ // `countryToLocale` table (skipped when the config has none).
121
+ // 4. Finally, falls back to the application's default locale.
122
+ function resolveFallbackLocale(context: I18nMiddlewareContext): Locale {
123
+ return resolveFallbackFrom<Locale>(
124
+ {
125
+ cookieLocale: () => deps.readCookieLang(context.cookies).locale,
126
+ acceptLanguage: () => context.request.headers.get('accept-language'),
127
+ // cf is undefined in local dev unless simulated
128
+ country: () => context.request?.cf?.country,
129
+ },
130
+ {
131
+ parseLocale: deps.parseLocale,
132
+ defaultLocale: deps.defaultLocale,
133
+ countryToLocale: deps.countryToLocale,
134
+ },
135
+ )
130
136
  }
131
137
 
132
- // Inject the locale into the context BEFORE the bypass check!
133
- context.locals.uiLocale = safeLocale
134
- context.locals.translationLocale = resolveLocaleForTranslations(safeLocale)
135
-
136
- // 3. Bypass static files and system paths (with Soft 404 protection)
137
- if (shouldBypassI18n(pathname)) {
138
- // .md endpoints: one redirect to the project's `markdownLocale`, no trailing slash
139
- // (markdown-path.ts). Without `markdownLocale` they pass through untouched.
140
- const markdownTarget = markdownRedirectTarget(pathname, {
141
- isLocale: (segment) => LocaleSchema.safeParse(segment).success,
142
- markdownLocale: I18N_CONFIG.markdownLocale,
143
- })
144
- if (markdownTarget) {
145
- return context.redirect(markdownTarget, REDIRECT_STATUS)
146
- }
147
-
148
- const response = await next()
149
-
150
- const contentType = response.headers.get('content-type') || ''
151
- const isHtml = contentType.includes('text/html')
152
- const isRedirect = response.status >= 300 && response.status < 400
153
-
154
- // If Astro attempts to return HTML for a static/API request (and it's not a redirect),
155
- // it means the file wasn't found and Astro rendered a fallback page (404.astro or catch-all).
156
- // We strictly intercept this and return a lightweight text-based 404 instead.
157
- // Why? To mitigate Directory Bruteforcing / Fuzzing attacks and save resources.
158
- if (isHtml && !isRedirect) {
159
- // Aggressively save Worker CPU cycles for missing static files
160
- return new Response('Not found', {
161
- status: 404,
162
- headers: { 'Content-Type': 'text/plain; charset=utf-8' },
163
- })
164
- }
138
+ // The `.md` redirect target, or null. 'prefix': the core's markdown-path.ts as is.
139
+ // 'prefix-except-default': the same target without the default locale's prefix (through
140
+ // localizedPath, minus the trailing slash a `.md` path never has); a path already at
141
+ // `/<default>/…` is redirected too, since that is no longer canonical.
142
+ function markdownTarget(pathname: string): string | null {
143
+ const target = markdownRedirectTarget(pathname, { isLocale, markdownLocale: deps.markdownLocale })
144
+ if (deps.routing === 'prefix') return target
145
+ if (!deps.markdownLocale || !MARKDOWN_PATH.test(pathname)) return null
165
146
 
166
- return response
147
+ const localized = localizedPath(deps.markdownLocale, target ?? pathname, pathOptions).slice(0, -1)
148
+ return localized === pathname ? null : localized
167
149
  }
168
150
 
169
- const fallbackLocale = resolveFallbackLocale(context)
170
-
171
- // 1. No locale in URL — redirect to fallback locale root
172
- if (!firstSegment) {
173
- const target = buildLocalizedPath(fallbackLocale, [])
174
- if (pathname !== target) {
175
- return negotiatedRedirect(target)
176
- }
177
- return await next()
178
- }
179
-
180
- // 2. URL has first segment — validate as locale
181
- const parsed = LocaleSchema.safeParse(firstSegment)
182
- const urlLocale: Locale | null = parsed.success ? parsed.data : null
183
-
184
- if (urlLocale) {
151
+ // A URL that carries a valid locale: cookie sync, locals, trailing-slash normalisation.
152
+ async function serveLocalized(
153
+ context: I18nMiddlewareContext,
154
+ next: Parameters<MiddlewareHandler>[1],
155
+ urlLocale: Locale,
156
+ segments: string[],
157
+ ): Promise<Response> {
158
+ const pathname = new URL(context.request.url).pathname
185
159
  // Keep the lang cookie in sync with the URL locale — but ONLY update an
186
160
  // EXISTING cookie that differs or does not parse. Rationale (HTML edge cache, P1/P2):
187
161
  // - The old unconditional set emitted `Set-Cookie` on EVERY localized
@@ -196,14 +170,14 @@ export const i18nMiddleware: MiddlewareHandler = async (context, next) => {
196
170
  // - A cookie that is present but invalid (a foreign value from another site
197
171
  // on the same host, a locale this site does not have) is corrected to the
198
172
  // URL locale too, so a stale value never outlives the next request.
199
- const cookie = readCookieLang(context.cookies)
173
+ const cookie = deps.readCookieLang(context.cookies)
200
174
  if (cookie.present && cookie.locale !== urlLocale) {
201
- setCookieLang(context.cookies, urlLocale)
175
+ deps.setCookieLang(context.cookies, urlLocale)
202
176
  }
203
177
  // Set uiLocale to Astro locals
204
178
  context.locals.uiLocale = urlLocale
205
179
  // Define and set translationLocale to Astro locals
206
- const translationLocale = resolveLocaleForTranslations(urlLocale)
180
+ const translationLocale = deps.resolveLocaleForTranslations(urlLocale)
207
181
  context.locals.translationLocale = translationLocale
208
182
 
209
183
  // Normalize trailing slash and structure
@@ -215,12 +189,156 @@ export const i18nMiddleware: MiddlewareHandler = async (context, next) => {
215
189
  return await next()
216
190
  }
217
191
 
218
- // 3. Invalid locale in URL -> treat the whole path as content missing the locale prefix.
219
- const target = buildLocalizedPath(fallbackLocale, segments)
192
+ // 'prefix-except-default': the default locale at the root, the others under their prefix.
193
+ async function serveExceptDefault(
194
+ context: I18nMiddlewareContext,
195
+ next: Parameters<MiddlewareHandler>[1],
196
+ segments: string[],
197
+ firstSegment: string | null,
198
+ ): Promise<Response> {
199
+ const pathname = new URL(context.request.url).pathname
200
+ const urlLocale = firstSegment ? deps.parseLocale(firstSegment) : null
201
+
202
+ // The default locale's prefix is not canonical: one permanent hop to the path without it.
203
+ if (urlLocale === deps.defaultLocale) {
204
+ return context.redirect(localizedPath(deps.defaultLocale, pathname, pathOptions), REDIRECT_STATUS)
205
+ }
206
+
207
+ if (urlLocale) {
208
+ return await serveLocalized(context, next, urlLocale, segments)
209
+ }
210
+
211
+ // A default-locale page (the root included). The URL names its language, so neither
212
+ // Accept-Language nor the country is consulted; only a saved choice of another locale
213
+ // leads away from it.
214
+ context.locals.uiLocale = deps.defaultLocale
215
+ context.locals.translationLocale = deps.resolveLocaleForTranslations(deps.defaultLocale)
216
+
217
+ const cookie = deps.readCookieLang(context.cookies)
218
+ if (cookie.present && cookie.locale !== deps.defaultLocale) {
219
+ if (cookie.locale) {
220
+ return negotiatedRedirect(localizedPath(cookie.locale, pathname, pathOptions), 'Cookie')
221
+ }
222
+ // Present but invalid: corrected to the URL's locale, as serveLocalized does.
223
+ deps.setCookieLang(context.cookies, deps.defaultLocale)
224
+ }
225
+
226
+ const normalized = localizedPath(deps.defaultLocale, pathname, pathOptions)
227
+ if (pathname !== normalized) {
228
+ return context.redirect(normalized, REDIRECT_STATUS)
229
+ }
220
230
 
221
- if (pathname !== target) {
222
- return negotiatedRedirect(target)
231
+ // The project's pages live under `src/pages/[lang]/`: render `/<default>/…` while the
232
+ // URL stays. `next(path)` rewrites without running the middleware again
233
+ // (astro/dist/core/middleware/astro-middleware.js:33-40) and keeps `locals`;
234
+ // `context.rewrite()` would re-run it (astro/dist/core/rewrites/handler.js:67-71).
235
+ return await next(`/${deps.defaultLocale}${pathname}`)
223
236
  }
224
237
 
225
- return await next()
238
+ // Main i18n middleware handler. Intercepts incoming requests, manages routing,
239
+ // sets locale context, and handles soft 404 protections for edge environments.
240
+ return async (context, next) => {
241
+ const url = new URL(context.request.url)
242
+ const pathname = url.pathname
243
+
244
+ // 1. Split the URL early to access its segments
245
+ const segments = pathname.split('/').filter(Boolean) // "/es/about/" -> ["es", "about"]
246
+ const firstSegment = segments[0] ?? null
247
+
248
+ // 2. SAFETY NET (For static file 404 pages)
249
+ // Attempt to infer the locale from the URL, or fallback to the default.
250
+ let safeLocale = deps.defaultLocale
251
+ if (firstSegment) {
252
+ const parsed = deps.parseLocale(firstSegment)
253
+ if (parsed !== null) {
254
+ safeLocale = parsed
255
+ }
256
+ }
257
+
258
+ // Inject the locale into the context BEFORE the bypass check!
259
+ context.locals.uiLocale = safeLocale
260
+ context.locals.translationLocale = deps.resolveLocaleForTranslations(safeLocale)
261
+
262
+ // 3. Bypass static files and system paths (with Soft 404 protection)
263
+ if (shouldBypassI18n(pathname)) {
264
+ // .md endpoints: one redirect to the project's `markdownLocale`, no trailing slash
265
+ // (the core's markdown-path.ts). Without `markdownLocale` they pass through untouched.
266
+ const target = markdownTarget(pathname)
267
+ if (target) {
268
+ return context.redirect(target, REDIRECT_STATUS)
269
+ }
270
+
271
+ // 'prefix-except-default': a `.md` path without a locale segment belongs to the
272
+ // default locale, whose endpoint lives under `[lang]`.
273
+ const rootMarkdown =
274
+ deps.routing !== 'prefix' && MARKDOWN_PATH.test(pathname) && (!firstSegment || !isLocale(firstSegment))
275
+ const response = rootMarkdown ? await next(`/${deps.defaultLocale}${pathname}`) : await next()
276
+
277
+ const contentType = response.headers.get('content-type') || ''
278
+ const isHtml = contentType.includes('text/html')
279
+ const isRedirect = response.status >= 300 && response.status < 400
280
+
281
+ // If Astro attempts to return HTML for a static/API request (and it's not a redirect),
282
+ // it means the file wasn't found and Astro rendered a fallback page (404.astro or catch-all).
283
+ // We strictly intercept this and return a lightweight text-based 404 instead.
284
+ // Why? To mitigate Directory Bruteforcing / Fuzzing attacks and save resources.
285
+ if (isHtml && !isRedirect) {
286
+ // Aggressively save Worker CPU cycles for missing static files
287
+ return new Response('Not found', {
288
+ status: 404,
289
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
290
+ })
291
+ }
292
+
293
+ return response
294
+ }
295
+
296
+ if (deps.routing !== 'prefix') {
297
+ return await serveExceptDefault(context, next, segments, firstSegment)
298
+ }
299
+
300
+ const fallbackLocale = resolveFallbackLocale(context)
301
+
302
+ // 1. No locale in URL — redirect to fallback locale root
303
+ if (!firstSegment) {
304
+ const target = buildLocalizedPath(fallbackLocale, [])
305
+ if (pathname !== target) {
306
+ return negotiatedRedirect(target)
307
+ }
308
+ return await next()
309
+ }
310
+
311
+ // 2. URL has first segment — validate as locale
312
+ const urlLocale = deps.parseLocale(firstSegment)
313
+
314
+ if (urlLocale) {
315
+ return await serveLocalized(context, next, urlLocale, segments)
316
+ }
317
+
318
+ // 3. Invalid locale in URL -> treat the whole path as content missing the locale prefix.
319
+ const target = buildLocalizedPath(fallbackLocale, segments)
320
+
321
+ if (pathname !== target) {
322
+ return negotiatedRedirect(target)
323
+ }
324
+
325
+ return await next()
326
+ }
226
327
  }
328
+
329
+ /** The project's middleware: its resolved config and its engine instance. */
330
+ export const i18nMiddleware: MiddlewareHandler = createI18nMiddleware({
331
+ // The integration resolves an absent `routing` before serialising the config; the
332
+ // fallback covers a config that reached the virtual module some other way.
333
+ routing: I18N_CONFIG.routing ?? 'prefix-except-default',
334
+ defaultLocale: DEFAULT_LOCALE,
335
+ markdownLocale: I18N_CONFIG.markdownLocale,
336
+ countryToLocale: I18N_CONFIG.countryToLocale,
337
+ parseLocale: (value) => {
338
+ const parsed = LocaleSchema.safeParse(value)
339
+ return parsed.success ? parsed.data : null
340
+ },
341
+ resolveLocaleForTranslations,
342
+ readCookieLang,
343
+ setCookieLang,
344
+ })
@@ -1,40 +1,17 @@
1
1
  import type { AstroGlobal } from 'astro'
2
2
 
3
+ import { i18n } from './instance'
3
4
  import { LocaleSchema } from './schema'
4
5
  import type { Locale } from './constants'
5
- import { DEFAULT_LOCALE, ENABLE_MISSING_TRANSLATION_BANNER } from './constants'
6
- import { LOCALES_WITH_TRANSLATIONS, FULLY_TRANSLATED_LOCALES } from 'virtual:@edgekits/astro-i18n/runtime-constants'
7
-
8
- // Checking for the presence of at least one file with translations
9
- function hasTranslations(locale: Locale): boolean {
10
- return (LOCALES_WITH_TRANSLATIONS as readonly string[]).includes(locale)
11
- }
12
-
13
- // Checking the completeness of translations
14
- function isFullyTranslated(locale: Locale): boolean {
15
- return (FULLY_TRANSLATED_LOCALES as readonly string[]).includes(locale)
16
- }
6
+ import { DEFAULT_LOCALE } from './constants'
17
7
 
18
8
  /**
19
- * Optional: return a “missing translation” banner message
20
- * when uiLocale has no translations or (blog) content is missing in required language.
21
- *
22
- * Returns:
23
- * - MissingTranslationType: type of message to show ('ui' or 'content')
24
- * - null: if banner should not be shown
9
+ * Optional: which “missing translation” banner to show ('ui' or 'content'), or null for
10
+ * none, when uiLocale has no translations or (blog) content is missing in the required
11
+ * language. Translation locale: what is actually loaded from KV, the default locale when
12
+ * there is no bundle for the UI locale. Both are the core's (`createI18n`).
25
13
  */
26
- type MissingTranslationType = 'ui' | 'content' | null
27
-
28
- export function checkMissingTranslation(
29
- uiLocale: Locale,
30
- isMissingContent: boolean | undefined,
31
- ): MissingTranslationType {
32
- if (!ENABLE_MISSING_TRANSLATION_BANNER) return null
33
-
34
- if (isFullyTranslated(uiLocale) && !isMissingContent) return null
35
-
36
- return isMissingContent ? 'content' : 'ui'
37
- }
14
+ export const { checkMissingTranslation, resolveLocaleForTranslations } = i18n
38
15
 
39
16
  /**
40
17
  * UI / routing locale:
@@ -46,12 +23,3 @@ export function resolveLocale(Astro: AstroGlobal): Locale {
46
23
  const parsed = LocaleSchema.safeParse(raw)
47
24
  return parsed.success ? parsed.data : DEFAULT_LOCALE
48
25
  }
49
-
50
- /**
51
- * Translation locale:
52
- * - What we actually load from KV
53
- * - Falls back if there is no bundle for the UI locale
54
- */
55
- export function resolveLocaleForTranslations(locale: Locale): Locale {
56
- return hasTranslations(locale) ? locale : DEFAULT_LOCALE
57
- }
package/src/runtime.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  // import. Ships as source and runs through the project's Vite, next to the virtual
3
3
  // modules the integration serves.
4
4
  export { fetchTranslations } from './fetcher'
5
- export { fmt, plural, pluralIcu, toIetfTag, replaceLocaleInPath, getLangFromUrl } from './format'
6
- export type { PluralCategory, PluralPatterns } from './format'
5
+ export { fmt, plural, pluralIcu, toIetfTag, replaceLocaleInPath, getLangFromUrl, localizedPath } from './format'
6
+ export type { LocalizedPathOptions, PluralCategory, PluralPatterns } from './format'
7
7
  export { I18N_CONFIG } from './config'
8
8
  export {
9
9
  DEFAULT_LOCALE,
package/src/schema.ts CHANGED
@@ -15,14 +15,6 @@ const hasNamespaces = (NAMESPACE_KEYS as readonly string[]).length > 0
15
15
 
16
16
  export const NamespaceSchema = hasNamespaces ? z.enum(NAMESPACE_KEYS as [string, ...string[]]) : z.string()
17
17
 
18
- type GlobalSchema = I18n.Schema
19
-
20
- // Strictly bind the type to the keys of I18n.Schema.
21
- // If the interface is empty (e.g., on a fresh project clone), fall back to string.
22
- export type Namespace = keyof GlobalSchema extends never ? string : keyof GlobalSchema
23
-
24
- // Added intersection N & keyof GlobalSchema so the compiler
25
- // doesn't throw an error inside Pick when Namespace falls back to string.
26
- export type PickSchema<N extends Namespace> = [N] extends [keyof GlobalSchema]
27
- ? Pick<GlobalSchema, N & keyof GlobalSchema>
28
- : any
18
+ // The keys of I18n.Schema (string on a fresh clone) and the namespaces picked from it:
19
+ // the core's types, which its fetcher is typed by.
20
+ export type { Namespace, PickSchema } from '@edgekits/i18n-core'
@@ -1,42 +1,11 @@
1
- import { I18N_CONFIG } from './config'
2
- import type { Locale } from './constants'
3
- import { formatTranslationCacheUrl, formatTranslationKvKey } from './key-format'
4
- import type { Namespace } from './schema'
1
+ import { i18n } from './instance'
5
2
 
6
3
  /**
7
- * Single source of truth for translation KV key construction.
4
+ * The translation KV key and edge-cache URL for this project: the core's builders
5
+ * (`createI18n`), over the same `key-format.ts` the CLI uses for the KV bulk data and the
6
+ * Purge API.
8
7
  *
9
- * Used by both:
10
- * - fetcher.ts (runtime: cache.match / cache.put)
11
- * - the CLI (build: KV bulk data), through the same `key-format.ts`
12
- *
13
- * Format: <I18N_CONFIG.keyPrefix>:<namespace>:<locale>
14
- */
15
- export function buildTranslationKvKey(locale: Locale, ns: Namespace): string {
16
- return formatTranslationKvKey(I18N_CONFIG.keyPrefix, locale, ns)
17
- }
18
-
19
- /**
20
- * Single source of truth for translation cache URL construction.
21
- *
22
- * Used by both:
23
- * - fetcher.ts (runtime: cache.match / cache.put)
24
- * - the CLI (build: Cloudflare Purge API), through the same `key-format.ts`
25
- *
26
- * The URL uses a static, non-versioned structure. Cache invalidation is
27
- * handled explicitly via the Cloudflare Purge API in `edgekits-i18n migrate`
28
- * rather than by changing the key on every deploy.
29
- *
30
- * Format: https://<I18N_CONFIG.keyPrefix>/<encodeURIComponent(i18n:<locale>:<namespace>)>
31
- */
32
- export function buildTranslationCacheUrl(locale: Locale, ns: Namespace): string {
33
- return formatTranslationCacheUrl(I18N_CONFIG.keyPrefix, locale, ns)
34
- }
35
-
36
- /**
37
- * Build a Cache API Request object from a translation cache URL.
38
- * The Cache API requires a Request object, not a plain string.
8
+ * Formats: <keyPrefix>:<namespace>:<locale> and
9
+ * https://<keyPrefix>/<encodeURIComponent(i18n:<locale>:<namespace>)>
39
10
  */
40
- export function buildTranslationCacheRequest(locale: Locale, ns: Namespace): Request {
41
- return new Request(buildTranslationCacheUrl(locale, ns))
42
- }
11
+ export const { buildTranslationKvKey, buildTranslationCacheUrl, buildTranslationCacheRequest } = i18n