@edgekits/astro-i18n 0.0.0-stage → 0.1.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.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +657 -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 +78 -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 +211 -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 +121 -0
- package/src/virtual.d.ts +23 -0
package/src/assemble.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
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 generateSchemaTs(schema: TranslationTree, indent = 4): string {
|
|
86
|
+
const pad = ' '.repeat(indent)
|
|
87
|
+
const padInner = ' '.repeat(indent + 2)
|
|
88
|
+
|
|
89
|
+
if (schema && typeof schema === 'object' && !Array.isArray(schema)) {
|
|
90
|
+
const entries = Object.entries(schema)
|
|
91
|
+
if (entries.length === 0) return '{}'
|
|
92
|
+
|
|
93
|
+
const lines = entries.map(([key, value]) => {
|
|
94
|
+
const valueTs =
|
|
95
|
+
value && typeof value === 'object' && !Array.isArray(value)
|
|
96
|
+
? generateSchemaTs(value as TranslationTree, indent + 2)
|
|
97
|
+
: 'string'
|
|
98
|
+
return `${padInner}${JSON.stringify(key)}: ${valueTs}`
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
return `{\n${lines.join(';\n')};\n${pad}}`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return 'string'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `src/i18n.generated.d.ts`: the global `I18n.Schema`, from the schema source locale. */
|
|
108
|
+
export function buildTypesContent(collected: CollectedData, defaultLocale: string): string {
|
|
109
|
+
const source = schemaSourceLocale(collected, defaultLocale)
|
|
110
|
+
const schemaObject: TranslationTree = (source && collected[source]) || {}
|
|
111
|
+
|
|
112
|
+
const namespaceLines = Object.entries(schemaObject).map(([ns, value]) => {
|
|
113
|
+
const valueTs = generateSchemaTs(value as TranslationTree, 10)
|
|
114
|
+
return ` ${JSON.stringify(ns)}: ${valueTs}`
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const interfaceBody =
|
|
118
|
+
namespaceLines.length > 0
|
|
119
|
+
? ` export interface Schema {\n${namespaceLines.join(';\n')};\n }`
|
|
120
|
+
: ` export interface Schema {}`
|
|
121
|
+
|
|
122
|
+
return `${HEADER}
|
|
123
|
+
declare global {
|
|
124
|
+
namespace I18n {
|
|
125
|
+
${interfaceBody}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export {}
|
|
130
|
+
`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* `runtime-constants.ts`:
|
|
135
|
+
* - NAMESPACE_KEYS -> from the schema source locale (default locale preferred)
|
|
136
|
+
* - LOCALES_WITH_TRANSLATIONS -> from all collected locales
|
|
137
|
+
* - FULLY_TRANSLATED_LOCALES -> locales that have ALL namespace keys
|
|
138
|
+
*/
|
|
139
|
+
export function buildRuntimeConstantsContent(collected: CollectedData, defaultLocale: string): string {
|
|
140
|
+
const source = schemaSourceLocale(collected, defaultLocale)
|
|
141
|
+
const namespaceKeys = Object.keys((source && collected[source]) || {}).sort()
|
|
142
|
+
const locales = localesWithTranslations(collected)
|
|
143
|
+
|
|
144
|
+
const fullyTranslatedLocales = locales.filter((locale) => {
|
|
145
|
+
const localeNamespaces = Object.keys(collected[locale] || {})
|
|
146
|
+
return namespaceKeys.every((ns) => localeNamespaces.includes(ns))
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
return `${HEADER}
|
|
150
|
+
export const NAMESPACE_KEYS = [${namespaceKeys.map(quoteString).join(', ')}] as [string, ...string[]];
|
|
151
|
+
|
|
152
|
+
export const LOCALES_WITH_TRANSLATIONS = [${locales.map(quoteString).join(', ')}] as const;
|
|
153
|
+
|
|
154
|
+
export const FULLY_TRANSLATED_LOCALES = [${fullyTranslatedLocales.map(quoteString).join(', ')}] as const;
|
|
155
|
+
`
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* `fallbacks.generated.ts`: one `FALLBACK_<NAMESPACE>` per default-locale namespace.
|
|
160
|
+
* Null when the default locale has no translations (nothing to fall back to).
|
|
161
|
+
*/
|
|
162
|
+
export function buildFallbacksContent(collected: CollectedData, defaultLocale: string): string | null {
|
|
163
|
+
const fallbackNamespaces = collected[defaultLocale]
|
|
164
|
+
if (!fallbackNamespaces) return null
|
|
165
|
+
|
|
166
|
+
const lines: string[] = [HEADER]
|
|
167
|
+
lines.push(`export const FALLBACK_LOCALE = ${JSON.stringify(defaultLocale)} as const;\n`)
|
|
168
|
+
|
|
169
|
+
for (const [ns, json] of Object.entries(fallbackNamespaces)) {
|
|
170
|
+
const constName = `FALLBACK_${ns.toUpperCase()}`
|
|
171
|
+
lines.push(`export const ${constName} = ${JSON.stringify(json, null, 2)} as const;\n`)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return lines.join('\n')
|
|
175
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
}
|