@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +394 -2
  4. package/dist/cli/generate.d.ts +47 -0
  5. package/dist/cli/generate.js +133 -0
  6. package/dist/cli/hashes.d.ts +19 -0
  7. package/dist/cli/hashes.js +42 -0
  8. package/dist/cli/index.d.ts +2 -0
  9. package/dist/cli/index.js +362 -0
  10. package/dist/cli/load-config.d.ts +7 -0
  11. package/dist/cli/load-config.js +19 -0
  12. package/dist/cli/purge.d.ts +29 -0
  13. package/dist/cli/purge.js +94 -0
  14. package/dist/cli/wrangler.d.ts +10 -0
  15. package/dist/cli/wrangler.js +71 -0
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.js +4 -0
  18. package/dist/integration/index.d.ts +26 -0
  19. package/dist/integration/index.js +103 -0
  20. package/dist/integration/virtual-types.d.ts +14 -0
  21. package/dist/integration/virtual-types.js +34 -0
  22. package/dist/integration/vite-plugin.d.ts +17 -0
  23. package/dist/integration/vite-plugin.js +56 -0
  24. package/dist/key-format.d.ts +8 -0
  25. package/dist/key-format.js +15 -0
  26. package/dist/types.d.ts +66 -0
  27. package/dist/types.js +38 -0
  28. package/package.json +77 -4
  29. package/src/assemble.ts +29 -0
  30. package/src/cli/generate.ts +175 -0
  31. package/src/cli/hashes.ts +66 -0
  32. package/src/cli/index.ts +461 -0
  33. package/src/cli/load-config.ts +26 -0
  34. package/src/cli/purge.ts +122 -0
  35. package/src/cli/wrangler.ts +96 -0
  36. package/src/components/LanguageSwitcher.astro +210 -0
  37. package/src/components/MissingTranslationBanner.astro +31 -0
  38. package/src/config.entry.ts +38 -0
  39. package/src/config.ts +11 -0
  40. package/src/constants.ts +29 -0
  41. package/src/cookie-storage.ts +32 -0
  42. package/src/deep-merge.ts +38 -0
  43. package/src/fetcher.ts +304 -0
  44. package/src/format.ts +107 -0
  45. package/src/index.ts +11 -0
  46. package/src/integration/index.ts +136 -0
  47. package/src/integration/virtual-types.ts +34 -0
  48. package/src/integration/vite-plugin.ts +67 -0
  49. package/src/key-format.ts +17 -0
  50. package/src/locale-fallback.ts +65 -0
  51. package/src/markdown-path.ts +31 -0
  52. package/src/middleware/i18n.ts +223 -0
  53. package/src/middleware.ts +2 -0
  54. package/src/parse-boolean.ts +20 -0
  55. package/src/resolve-locale.ts +57 -0
  56. package/src/runtime.ts +19 -0
  57. package/src/schema.ts +28 -0
  58. package/src/translations-keys.ts +42 -0
  59. package/src/types.ts +119 -0
  60. package/src/virtual.d.ts +23 -0
@@ -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
+ }