@edgekits/astro-i18n 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +87 -23
  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,96 +0,0 @@
1
- import fs from 'node:fs'
2
- import { createRequire } from 'node:module'
3
- import path from 'node:path'
4
-
5
- import { parse as parseJsonc, printParseErrorCode, type ParseError } from 'jsonc-parser'
6
-
7
- // ---------------------------------------------------------------------------
8
- // Wrangler config reader
9
- // Parses wrangler.jsonc to extract KV namespace IDs and vars by key.
10
- // Single source of truth — no hardcoded IDs or zone IDs in package.json.
11
- // ---------------------------------------------------------------------------
12
-
13
- export const KV_BINDING = 'TRANSLATIONS'
14
-
15
- function readWranglerConfig(root: string): Record<string, unknown> {
16
- const wranglerConfigPath = path.join(root, 'wrangler.jsonc')
17
-
18
- if (!fs.existsSync(wranglerConfigPath)) {
19
- console.error(`[i18n] wrangler.jsonc not found at ${wranglerConfigPath}`)
20
- process.exit(1)
21
- }
22
-
23
- const raw = fs.readFileSync(wranglerConfigPath, 'utf8')
24
-
25
- const errors: ParseError[] = []
26
- const config = parseJsonc(raw, errors, { allowTrailingComma: true })
27
-
28
- if (!config || typeof config !== 'object') {
29
- console.error('[i18n] Failed to parse wrangler.jsonc — result is empty.')
30
- for (const err of errors) {
31
- const snippet = raw.slice(Math.max(0, err.offset - 10), err.offset + 20)
32
- console.error(` ${printParseErrorCode(err.error)} at offset ${err.offset}: ...${JSON.stringify(snippet)}...`)
33
- }
34
- process.exit(1)
35
- }
36
-
37
- return config
38
- }
39
-
40
- export function getKvNamespaceId(root: string, binding: string): string {
41
- const config = readWranglerConfig(root)
42
- const kvNamespaces = config.kv_namespaces as
43
- | Array<{
44
- binding: string
45
- id: string
46
- }>
47
- | undefined
48
-
49
- const entry = kvNamespaces?.find((kv) => kv.binding === binding)
50
-
51
- if (!entry) {
52
- console.error(`[i18n] KV binding "${binding}" not found in wrangler.jsonc`)
53
- process.exit(1)
54
- }
55
-
56
- return entry.id
57
- }
58
-
59
- export function getWranglerVar(root: string, key: string): string | undefined {
60
- const config = readWranglerConfig(root)
61
- const vars = config.vars as Record<string, string> | undefined
62
- return vars?.[key]
63
- }
64
-
65
- // ---------------------------------------------------------------------------
66
- // Wrangler binary
67
- // The CLI runs wrangler's JavaScript entry with the current Node binary, never through
68
- // a shell (no `.cmd` shim on Windows, nothing for a shell to interpret).
69
- // ---------------------------------------------------------------------------
70
-
71
- /**
72
- * The absolute path of the project's wrangler entry (`bin.wrangler` in its package.json),
73
- * resolved from `root` as the project itself would resolve it. `wrangler/package.json` is
74
- * an exported subpath; `bin/` is not, hence the detour through the manifest. Throws when
75
- * the project has no wrangler or its manifest declares no `bin.wrangler`.
76
- */
77
- export function resolveWranglerBin(root: string): string {
78
- const requireFromRoot = createRequire(path.join(root, 'package.json'))
79
-
80
- let manifestPath: string
81
- try {
82
- manifestPath = requireFromRoot.resolve('wrangler/package.json')
83
- } catch {
84
- throw new Error(
85
- `wrangler is not installed in ${root}. The CLI runs the project's own wrangler: install it there (npm i -D wrangler).`,
86
- )
87
- }
88
-
89
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { bin?: string | Record<string, string> }
90
- const bin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.wrangler
91
- if (!bin) {
92
- throw new Error(`wrangler at ${path.dirname(manifestPath)} declares no bin.wrangler in its package.json.`)
93
- }
94
-
95
- return path.join(path.dirname(manifestPath), bin)
96
- }
package/src/cli/zone.ts DELETED
@@ -1,64 +0,0 @@
1
- import { keyPrefixZoneWarning } from './key-prefix-check.ts'
2
-
3
- // ---------------------------------------------------------------------------
4
- // Zone name lookup for the keyPrefix check (migrate only)
5
- // ---------------------------------------------------------------------------
6
-
7
- export type ZoneDeps = {
8
- fetch?: (input: string, init: RequestInit) => Promise<Response>
9
- }
10
-
11
- /**
12
- * Read the zone's name: `GET /zones/{zoneId}`, `result.name`. Needs Zone › Zone › Read
13
- * on the token. Never throws: any failure (non-2xx, network error, unexpected body)
14
- * comes back as `{ error }`.
15
- *
16
- * `deps` replaces `fetch` in tests.
17
- */
18
- export async function readZoneName(
19
- zoneId: string,
20
- apiToken: string,
21
- deps: ZoneDeps = {},
22
- ): Promise<{ name: string } | { error: string }> {
23
- const { fetch: fetchFn = fetch } = deps
24
-
25
- try {
26
- const response = await fetchFn(`https://api.cloudflare.com/client/v4/zones/${zoneId}`, {
27
- method: 'GET',
28
- headers: { Authorization: `Bearer ${apiToken}` },
29
- })
30
- if (!response.ok) return { error: `HTTP ${response.status}` }
31
-
32
- const body = (await response.json()) as { result?: { name?: unknown } } | null
33
- const name = body?.result?.name
34
- if (typeof name !== 'string' || name.trim() === '') return { error: 'no result.name in the response' }
35
-
36
- return { name }
37
- } catch (err) {
38
- return { error: err instanceof Error ? err.message : String(err) }
39
- }
40
- }
41
-
42
- /**
43
- * Print the keyPrefix warning when `keyPrefix` lies outside the zone, or one info line
44
- * when the zone name cannot be read. Advisory only: it returns nothing, and the purge
45
- * runs the same either way.
46
- */
47
- export async function checkKeyPrefixZone(
48
- keyPrefix: string,
49
- zoneId: string,
50
- apiToken: string,
51
- deps: ZoneDeps = {},
52
- ): Promise<void> {
53
- const zone = await readZoneName(zoneId, apiToken, deps)
54
-
55
- if ('error' in zone) {
56
- console.log(
57
- `[i18n] ℹ️ Could not read the zone name (${zone.error}), so the keyPrefix check was skipped (needs Zone › Zone › Read on the token).`,
58
- )
59
- return
60
- }
61
-
62
- const warning = keyPrefixZoneWarning(keyPrefix, zone.name)
63
- if (warning) console.warn(warning)
64
- }
package/src/deep-merge.ts DELETED
@@ -1,38 +0,0 @@
1
- function isObject(value: unknown): value is Record<string, unknown> {
2
- return typeof value === 'object' && value !== null && !Array.isArray(value)
3
- }
4
-
5
- // Keys that would reach an object's prototype instead of its own properties. JSON.parse
6
- // creates `__proto__` as an own key, so a KV value could carry one.
7
- const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
8
-
9
- /**
10
- * Deep-merge two dictionaries.
11
- * - `base` provides known structure (fallback)
12
- * - `overrides` can provide partial values
13
- * - Both types are preserved and merged correctly
14
- * - `__proto__`, `constructor` and `prototype` keys in `overrides` are skipped
15
- */
16
- export function deepMerge<A extends object, B extends Partial<A>>(base: A, overrides: B): A {
17
- const result: any = { ...base }
18
-
19
- for (const key of Object.keys(overrides) as (keyof A)[]) {
20
- if (UNSAFE_KEYS.has(key as string)) continue
21
-
22
- const overrideValue = overrides[key]
23
- const baseValue = base[key]
24
-
25
- if (overrideValue === undefined) continue
26
-
27
- // For primitives or different types — override directly
28
- if (!isObject(baseValue) || !isObject(overrideValue)) {
29
- result[key] = overrideValue
30
- continue
31
- }
32
-
33
- // Both are objects — recurse
34
- result[key] = deepMerge(baseValue as any, overrideValue as any)
35
- }
36
-
37
- return result
38
- }
package/src/key-format.ts DELETED
@@ -1,17 +0,0 @@
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
- }
@@ -1,58 +0,0 @@
1
- /**
2
- * The middleware's fallback cascade for a request whose URL carries no valid locale,
3
- * as a pure function: the request-bound inputs arrive as getters, so each one is read
4
- * exactly when, and only if, the cascade reaches it (same split as `env` in the fetcher).
5
- *
6
- * Order (unchanged from the middleware):
7
- * 1. the lang cookie, if valid;
8
- * 2. the Accept-Language header (the middleware passes the request header): each tag in
9
- * `q` order, full tag, then its language part ('es-mx' -> 'es'); the first tag that
10
- * names a supported locale wins (accept-language.ts);
11
- * 3. ONLY when no tag matched (or there is no header): the Cloudflare geo-IP country,
12
- * through the project's `countryToLocale` table (skipped without one);
13
- * 4. the default locale.
14
- */
15
-
16
- import { matchAcceptLanguage } from './accept-language'
17
-
18
- export type FallbackInputs<L extends string> = {
19
- cookieLocale: () => L | null
20
- acceptLanguage: () => string | null
21
- country: () => unknown
22
- }
23
-
24
- export type FallbackOptions<L extends string> = {
25
- // Returns the locale when the value is a supported one, otherwise null.
26
- parseLocale: (value: unknown) => L | null
27
- defaultLocale: L
28
- countryToLocale?: Readonly<Record<string, string>>
29
- }
30
-
31
- export function resolveFallbackLocale<L extends string>(inputs: FallbackInputs<L>, options: FallbackOptions<L>): L {
32
- const cookieLocale = inputs.cookieLocale()
33
- if (cookieLocale) return cookieLocale
34
-
35
- const browser = matchAcceptLanguage(inputs.acceptLanguage(), options.parseLocale)
36
- if (browser) return browser
37
-
38
- if (options.countryToLocale) {
39
- // 3) Cloudflare Geo-IP Strategy
40
- // cf is undefined in local dev unless simulated
41
- const geo = options.parseLocale(mapCountryToLocale(inputs.country(), options.countryToLocale))
42
- if (geo) return geo
43
- }
44
-
45
- return options.defaultLocale
46
- }
47
-
48
- /**
49
- * Maps a Cloudflare country code (`request.cf.country`) to a locale through the
50
- * project's table. Returns undefined if the country is not in it.
51
- */
52
- export function mapCountryToLocale(country: unknown, table: Readonly<Record<string, string>>): string | undefined {
53
- // Safety: In dev mode (localhost), 'country' might be null or undefined.
54
- if (typeof country !== 'string') return undefined
55
-
56
- // Cloudflare always sends uppercase (e.g., 'US', 'DE'), but safety first.
57
- return table[country.toUpperCase()]
58
- }
@@ -1,31 +0,0 @@
1
- /**
2
- * The middleware's `.md` normalisation, as a pure function. Markdown endpoints
3
- * (`/de/blog/x.md/`, `/blog/x.md`) are served in one locale, the project's
4
- * `markdownLocale`; this returns the single redirect target for such a path, or null
5
- * when there is nothing to redirect:
6
- * - not a `.md` path, or the project has no `markdownLocale`: null (the middleware
7
- * lets the request through untouched);
8
- * - a leading segment that is a locale is replaced, any other leading segment is kept
9
- * (`/foo.md` -> `/<markdownLocale>/foo.md`, never the homepage);
10
- * - a trailing slash is dropped, in the same redirect;
11
- * - a path already in canonical form: null.
12
- */
13
-
14
- export const MARKDOWN_PATH = /\.md\/?$/
15
-
16
- export type MarkdownOptions = {
17
- // True when the segment is a supported locale.
18
- isLocale: (segment: string) => boolean
19
- markdownLocale?: string
20
- }
21
-
22
- export function markdownRedirectTarget(pathname: string, options: MarkdownOptions): string | null {
23
- if (!options.markdownLocale || !MARKDOWN_PATH.test(pathname)) return null
24
-
25
- const segments = pathname.split('/').filter(Boolean)
26
- const first = segments[0]
27
- const contentSegments = first !== undefined && options.isLocale(first) ? segments.slice(1) : segments
28
- const target = `/${options.markdownLocale}/${contentSegments.join('/')}`
29
-
30
- return target === pathname ? null : target
31
- }
@@ -1,20 +0,0 @@
1
- // Engine-owned copy of src/utils/shared/parse-boolean.ts: the package ships its own.
2
- // The project copy stays for its other consumers (seo/utils.ts, htmlCacheMiddleware.ts).
3
-
4
- /**
5
- * Parse a boolean flag from Wrangler-env string.
6
- *
7
- * Accepts:
8
- * "1", "true", "on" → true
9
- * "0", "false", "off" → false
10
- * undefined → defaultValue
11
- */
12
- export function parseBooleanFlag(value: string | undefined, defaultValue: boolean): boolean {
13
- if (value === undefined) return defaultValue
14
-
15
- const v = value.toLowerCase()
16
- if (v === '1' || v === 'true' || v === 'on') return true
17
- if (v === '0' || v === 'false' || v === 'off') return false
18
-
19
- return defaultValue
20
- }