@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,21 +0,0 @@
1
- export type ZoneDeps = {
2
- fetch?: (input: string, init: RequestInit) => Promise<Response>;
3
- };
4
- /**
5
- * Read the zone's name: `GET /zones/{zoneId}`, `result.name`. Needs Zone › Zone › Read
6
- * on the token. Never throws: any failure (non-2xx, network error, unexpected body)
7
- * comes back as `{ error }`.
8
- *
9
- * `deps` replaces `fetch` in tests.
10
- */
11
- export declare function readZoneName(zoneId: string, apiToken: string, deps?: ZoneDeps): Promise<{
12
- name: string;
13
- } | {
14
- error: string;
15
- }>;
16
- /**
17
- * Print the keyPrefix warning when `keyPrefix` lies outside the zone, or one info line
18
- * when the zone name cannot be read. Advisory only: it returns nothing, and the purge
19
- * runs the same either way.
20
- */
21
- export declare function checkKeyPrefixZone(keyPrefix: string, zoneId: string, apiToken: string, deps?: ZoneDeps): Promise<void>;
package/dist/cli/zone.js DELETED
@@ -1,42 +0,0 @@
1
- import { keyPrefixZoneWarning } from "./key-prefix-check.js";
2
- /**
3
- * Read the zone's name: `GET /zones/{zoneId}`, `result.name`. Needs Zone › Zone › Read
4
- * on the token. Never throws: any failure (non-2xx, network error, unexpected body)
5
- * comes back as `{ error }`.
6
- *
7
- * `deps` replaces `fetch` in tests.
8
- */
9
- export async function readZoneName(zoneId, apiToken, deps = {}) {
10
- const { fetch: fetchFn = fetch } = deps;
11
- try {
12
- const response = await fetchFn(`https://api.cloudflare.com/client/v4/zones/${zoneId}`, {
13
- method: 'GET',
14
- headers: { Authorization: `Bearer ${apiToken}` },
15
- });
16
- if (!response.ok)
17
- return { error: `HTTP ${response.status}` };
18
- const body = (await response.json());
19
- const name = body?.result?.name;
20
- if (typeof name !== 'string' || name.trim() === '')
21
- return { error: 'no result.name in the response' };
22
- return { name };
23
- }
24
- catch (err) {
25
- return { error: err instanceof Error ? err.message : String(err) };
26
- }
27
- }
28
- /**
29
- * Print the keyPrefix warning when `keyPrefix` lies outside the zone, or one info line
30
- * when the zone name cannot be read. Advisory only: it returns nothing, and the purge
31
- * runs the same either way.
32
- */
33
- export async function checkKeyPrefixZone(keyPrefix, zoneId, apiToken, deps = {}) {
34
- const zone = await readZoneName(zoneId, apiToken, deps);
35
- if ('error' in zone) {
36
- console.log(`[i18n] ℹ️ Could not read the zone name (${zone.error}), so the keyPrefix check was skipped (needs Zone › Zone › Read on the token).`);
37
- return;
38
- }
39
- const warning = keyPrefixZoneWarning(keyPrefix, zone.name);
40
- if (warning)
41
- console.warn(warning);
42
- }
@@ -1,8 +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
- export declare function formatTranslationKvKey(keyPrefix: string, locale: string, ns: string): string;
8
- export declare function formatTranslationCacheUrl(keyPrefix: string, locale: string, ns: string): string;
@@ -1,15 +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
- // Format: <keyPrefix>:<namespace>:<locale>
8
- export function formatTranslationKvKey(keyPrefix, locale, ns) {
9
- return `${keyPrefix}:${ns}:${locale}`;
10
- }
11
- // Format: https://<keyPrefix>/<encodeURIComponent(i18n:<locale>:<namespace>)>
12
- export function formatTranslationCacheUrl(keyPrefix, locale, ns) {
13
- const cacheId = `i18n:${locale}:${ns}`;
14
- return `https://${keyPrefix}/${encodeURIComponent(cacheId)}`;
15
- }
@@ -1,56 +0,0 @@
1
- /**
2
- * The Accept-Language step of the middleware's fallback cascade, read from the header
3
- * itself. Astro's own match is no substitute: it keeps only the tags that equal a
4
- * configured locale before choosing, so a regional tag (`es-MX`) never reaches a
5
- * language-part match. Pure: no Astro, no virtual module, no `cloudflare:`.
6
- */
7
-
8
- export type AcceptLanguageEntry = { tag: string; q: number }
9
-
10
- /**
11
- * The header's language ranges, highest `q` first (header order among equals). A missing
12
- * or unparsable `q` is 1; `q=0` entries and the `*` wildcard are dropped.
13
- */
14
- export function parseAcceptLanguage(header: string | null): AcceptLanguageEntry[] {
15
- if (!header) return []
16
-
17
- const entries: AcceptLanguageEntry[] = []
18
- for (const part of header.split(',')) {
19
- const [rawTag = '', ...params] = part.split(';')
20
- const tag = rawTag.trim()
21
- if (!tag || tag === '*') continue
22
-
23
- let q = 1
24
- for (const param of params) {
25
- const [name, value] = param.split('=').map((s) => s.trim())
26
- if (name?.toLowerCase() !== 'q' || value === undefined) continue
27
- const parsed = Number.parseFloat(value)
28
- if (!Number.isNaN(parsed)) q = parsed
29
- }
30
- if (q <= 0) continue
31
-
32
- entries.push({ tag, q })
33
- }
34
-
35
- // Array.prototype.sort is stable: equal q keeps header order.
36
- return entries.sort((a, b) => b.q - a.q)
37
- }
38
-
39
- /**
40
- * The first supported locale the header names: for each tag in `q` order, the full tag
41
- * (lowercased), then its language subtag (before the first `-` or `_`). Null when none.
42
- */
43
- export function matchAcceptLanguage<L extends string>(
44
- header: string | null,
45
- parseLocale: (value: unknown) => L | null,
46
- ): L | null {
47
- for (const { tag } of parseAcceptLanguage(header)) {
48
- const lower = tag.toLowerCase()
49
- const full = parseLocale(lower)
50
- if (full) return full
51
-
52
- const language = parseLocale(lower.split(/[-_]/)[0])
53
- if (language) return language
54
- }
55
- return null
56
- }
package/src/assemble.ts DELETED
@@ -1,29 +0,0 @@
1
- import { deepMerge } from './deep-merge'
2
-
3
- /**
4
- * Assemble a single translation namespace from a raw value and its compiled
5
- * fallback dictionary.
6
- *
7
- * This is the ONLY place namespace assembly happens: `fetchTranslations` routes
8
- * its cache-HIT branch, its cache-miss/KV branch and `fetchAllFromKV` through it,
9
- * so a cached namespace and a freshly read one can never diverge in shape.
10
- *
11
- * Semantics (unchanged from the original miss path):
12
- * - the fallback provides the full known key set,
13
- * - the raw value (cached entry or KV read) overrides it wherever it has a value,
14
- * - `null` / `undefined` are tolerated for either argument.
15
- *
16
- * Consequence for stale cache entries: a namespace cached before a schema addition
17
- * degrades to default-locale text for the keys it predates instead of handing pages
18
- * `undefined` (see ADR-0014 and the 2026-07-18 `/en/blog/` incident).
19
- *
20
- * Deliberately pure — no `cloudflare:workers` imports, so it is testable under
21
- * plain Node (`npm test`).
22
- */
23
- export function assembleNamespace(rawValue: unknown, fallback: unknown): object {
24
- const raw = (rawValue as object | null | undefined) ?? {}
25
-
26
- if (!fallback) return raw
27
-
28
- return deepMerge(fallback as object, raw)
29
- }
@@ -1,205 +0,0 @@
1
- import { formatTranslationKvKey } from '../key-format.ts'
2
-
3
- /**
4
- * The CLI's code generation, as pure functions of the collected locale tree: no
5
- * filesystem, so the emitted text is testable. Formats are those of the site's
6
- * `scripts/bundle-translations.ts`, header aside.
7
- */
8
-
9
- export type TranslationTree = Record<string, unknown>
10
-
11
- // <locale> → <namespace> → the namespace's JSON
12
- export type CollectedData = Record<string, Record<string, TranslationTree>>
13
-
14
- export type KvBulkEntry = { key: string; value: string }
15
-
16
- const HEADER = `// Auto-generated by @edgekits/astro-i18n (edgekits-i18n CLI)
17
- // Do not edit manually.
18
- `
19
-
20
- // A namespace becomes part of a KV key, a cache URL and the `FALLBACK_<NAMESPACE>`
21
- // identifier, so its file name is restricted to what is valid in all three.
22
- export const NAMESPACE_NAME = /^[a-z0-9][a-z0-9_]*$/
23
-
24
- /**
25
- * Throws, naming the offender, when a locale folder is not one of the config's
26
- * `languages` or a namespace file name does not match NAMESPACE_NAME. Run on the
27
- * collected tree before anything is generated or pushed.
28
- */
29
- export function assertValidNames(collected: CollectedData, languages: Readonly<Record<string, unknown>>): void {
30
- const configured = Object.keys(languages)
31
-
32
- for (const [locale, namespaces] of Object.entries(collected)) {
33
- if (!configured.includes(locale)) {
34
- throw new Error(
35
- `Locale folder "${locale}" is not one of \`languages\` in edgekits-i18n.config.ts: ${configured.join(', ')}.`,
36
- )
37
- }
38
- for (const ns of Object.keys(namespaces)) {
39
- if (!NAMESPACE_NAME.test(ns)) {
40
- throw new Error(
41
- `Namespace "${ns}" (${locale}/${ns}.json) must match ${NAMESPACE_NAME}: lowercase letters, digits and underscores.`,
42
- )
43
- }
44
- }
45
- }
46
- }
47
-
48
- /**
49
- * A single-quoted TS string literal, escaped by JSON.stringify. For every name the
50
- * validation accepts it is byte-identical to the plain `'name'` the CLI always wrote, so
51
- * existing generated files do not change; anything else comes out escaped, not injected.
52
- */
53
- export function quoteString(value: string): string {
54
- const escaped = JSON.stringify(value).slice(1, -1).replace(/\\"/g, '"').replace(/'/g, "\\'")
55
- return `'${escaped}'`
56
- }
57
-
58
- /** Locales that have at least one namespace, sorted. */
59
- export function localesWithTranslations(collected: CollectedData): string[] {
60
- return Object.keys(collected).sort()
61
- }
62
-
63
- /**
64
- * The locale the schema and the namespace list are derived from: the default locale
65
- * when it has translations, otherwise the first locale that does.
66
- */
67
- export function schemaSourceLocale(collected: CollectedData, defaultLocale: string): string | undefined {
68
- if (collected[defaultLocale]) return defaultLocale
69
- return localesWithTranslations(collected)[0]
70
- }
71
-
72
- /** `wrangler kv bulk put` input: one entry per locale and namespace. */
73
- export function buildBulkData(collected: CollectedData, keyPrefix: string): KvBulkEntry[] {
74
- const bulkData: KvBulkEntry[] = []
75
-
76
- for (const [locale, namespaces] of Object.entries(collected)) {
77
- for (const [ns, json] of Object.entries(namespaces)) {
78
- bulkData.push({ key: formatTranslationKvKey(keyPrefix, locale, ns), value: JSON.stringify(json) })
79
- }
80
- }
81
-
82
- return bulkData
83
- }
84
-
85
- function isTree(value: unknown): value is TranslationTree {
86
- return typeof value === 'object' && value !== null && !Array.isArray(value)
87
- }
88
-
89
- // The JSON kind of an array element; an array is typed only when all its elements share one.
90
- function jsonKind(value: unknown): string {
91
- if (value === null) return 'null'
92
- if (Array.isArray(value)) return 'array'
93
- return typeof value
94
- }
95
-
96
- /**
97
- * The TS type of one JSON value. Objects become object types; arrays become `string[]`
98
- * (strings, other scalars, or empty) or `Array<…>` typed from their first element
99
- * (objects, nested arrays); every other leaf stays `string`. `keyPath` and `file` name the
100
- * value in the error a mixed array raises.
101
- */
102
- function generateSchemaTs(value: unknown, indent: number, keyPath: string, file: string): string {
103
- if (Array.isArray(value)) {
104
- if (value.length === 0) return 'string[]'
105
-
106
- const kinds = [...new Set(value.map(jsonKind))]
107
- if (kinds.length > 1) {
108
- throw new Error(
109
- `Key "${keyPath}" (${file}) is an array that mixes ${kinds.join(', ')} elements: ` +
110
- 'I18n.Schema cannot type it. Use one kind of element per array.',
111
- )
112
- }
113
-
114
- const first = value[0]
115
- if (!isTree(first) && !Array.isArray(first)) return 'string[]'
116
- return `Array<${generateSchemaTs(first, indent, `${keyPath}[0]`, file)}>`
117
- }
118
-
119
- if (!isTree(value)) return 'string'
120
-
121
- const entries = Object.entries(value)
122
- if (entries.length === 0) return '{}'
123
-
124
- const pad = ' '.repeat(indent)
125
- const padInner = ' '.repeat(indent + 2)
126
- const lines = entries.map(
127
- ([key, child]) =>
128
- `${padInner}${JSON.stringify(key)}: ${generateSchemaTs(child, indent + 2, `${keyPath}.${key}`, file)}`,
129
- )
130
-
131
- return `{\n${lines.join(';\n')};\n${pad}}`
132
- }
133
-
134
- /**
135
- * `src/i18n.generated.d.ts`: the global `I18n.Schema`, from the schema source locale.
136
- * Throws, naming the key, when an array in it mixes kinds of elements.
137
- */
138
- export function buildTypesContent(collected: CollectedData, defaultLocale: string): string {
139
- const source = schemaSourceLocale(collected, defaultLocale)
140
- const schemaObject: TranslationTree = (source && collected[source]) || {}
141
-
142
- const namespaceLines = Object.entries(schemaObject).map(([ns, value]) => {
143
- const valueTs = generateSchemaTs(value, 10, ns, `${source}/${ns}.json`)
144
- return ` ${JSON.stringify(ns)}: ${valueTs}`
145
- })
146
-
147
- const interfaceBody =
148
- namespaceLines.length > 0
149
- ? ` export interface Schema {\n${namespaceLines.join(';\n')};\n }`
150
- : ` export interface Schema {}`
151
-
152
- return `${HEADER}
153
- declare global {
154
- namespace I18n {
155
- ${interfaceBody}
156
- }
157
- }
158
-
159
- export {}
160
- `
161
- }
162
-
163
- /**
164
- * `runtime-constants.ts`:
165
- * - NAMESPACE_KEYS -> from the schema source locale (default locale preferred)
166
- * - LOCALES_WITH_TRANSLATIONS -> from all collected locales
167
- * - FULLY_TRANSLATED_LOCALES -> locales that have ALL namespace keys
168
- */
169
- export function buildRuntimeConstantsContent(collected: CollectedData, defaultLocale: string): string {
170
- const source = schemaSourceLocale(collected, defaultLocale)
171
- const namespaceKeys = Object.keys((source && collected[source]) || {}).sort()
172
- const locales = localesWithTranslations(collected)
173
-
174
- const fullyTranslatedLocales = locales.filter((locale) => {
175
- const localeNamespaces = Object.keys(collected[locale] || {})
176
- return namespaceKeys.every((ns) => localeNamespaces.includes(ns))
177
- })
178
-
179
- return `${HEADER}
180
- export const NAMESPACE_KEYS = [${namespaceKeys.map(quoteString).join(', ')}] as [string, ...string[]];
181
-
182
- export const LOCALES_WITH_TRANSLATIONS = [${locales.map(quoteString).join(', ')}] as const;
183
-
184
- export const FULLY_TRANSLATED_LOCALES = [${fullyTranslatedLocales.map(quoteString).join(', ')}] as const;
185
- `
186
- }
187
-
188
- /**
189
- * `fallbacks.generated.ts`: one `FALLBACK_<NAMESPACE>` per default-locale namespace.
190
- * Null when the default locale has no translations (nothing to fall back to).
191
- */
192
- export function buildFallbacksContent(collected: CollectedData, defaultLocale: string): string | null {
193
- const fallbackNamespaces = collected[defaultLocale]
194
- if (!fallbackNamespaces) return null
195
-
196
- const lines: string[] = [HEADER]
197
- lines.push(`export const FALLBACK_LOCALE = ${JSON.stringify(defaultLocale)} as const;\n`)
198
-
199
- for (const [ns, json] of Object.entries(fallbackNamespaces)) {
200
- const constName = `FALLBACK_${ns.toUpperCase()}`
201
- lines.push(`export const ${constName} = ${JSON.stringify(json, null, 2)} as const;\n`)
202
- }
203
-
204
- return lines.join('\n')
205
- }
package/src/cli/hashes.ts DELETED
@@ -1,66 +0,0 @@
1
- import crypto from 'node:crypto'
2
-
3
- import { formatTranslationCacheUrl } from '../key-format.ts'
4
- import type { CollectedData } from './generate.ts'
5
-
6
- /**
7
- * Per-namespace content hashes and the change diff that drives the cache purge.
8
- * Pure apart from hashing. Same algorithm as the site's `scripts/bundle-translations.ts`,
9
- * so an existing `.i18n-hashes.json` stays a valid baseline.
10
- */
11
-
12
- // Persisted hash map: "<locale>:<namespace>" → content hash
13
- export type HashMap = Record<string, string>
14
-
15
- export function stableStringify(value: unknown): string {
16
- if (value === null || typeof value !== 'object') {
17
- return JSON.stringify(value)
18
- }
19
-
20
- if (Array.isArray(value)) {
21
- return '[' + value.map(stableStringify).join(',') + ']'
22
- }
23
-
24
- const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b))
25
-
26
- return '{' + entries.map(([k, v]) => JSON.stringify(k) + ':' + stableStringify(v)).join(',') + '}'
27
- }
28
-
29
- export function computeHash(value: unknown): string {
30
- const json = stableStringify(value)
31
- return crypto.createHash('sha256').update(json).digest('hex').slice(0, 12)
32
- }
33
-
34
- /**
35
- * Hash every collected namespace and list the "<locale>:<namespace>" pairs whose hash
36
- * differs from the previous run (all of them when there is no previous run).
37
- */
38
- export function diffHashes(
39
- collected: CollectedData,
40
- previousHashes: HashMap,
41
- ): { currentHashes: HashMap; changedKeys: string[] } {
42
- const currentHashes: HashMap = {}
43
- const changedKeys: string[] = []
44
-
45
- for (const [locale, namespaces] of Object.entries(collected)) {
46
- for (const [ns, json] of Object.entries(namespaces)) {
47
- const hashKey = `${locale}:${ns}`
48
- const hash = computeHash(json)
49
- currentHashes[hashKey] = hash
50
-
51
- if (previousHashes[hashKey] !== hash) {
52
- changedKeys.push(hashKey)
53
- }
54
- }
55
- }
56
-
57
- return { currentHashes, changedKeys }
58
- }
59
-
60
- /** Purge API URLs for the changed "<locale>:<namespace>" pairs. */
61
- export function purgeUrlsFor(changedKeys: readonly string[], keyPrefix: string): string[] {
62
- return changedKeys.map((hashKey) => {
63
- const [locale, ns] = hashKey.split(':') as [string, string]
64
- return formatTranslationCacheUrl(keyPrefix, locale, ns)
65
- })
66
- }