@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,96 @@
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
+ }
@@ -0,0 +1,210 @@
1
+ ---
2
+ import { I18N_CONFIG } from '../config'
3
+ import { SUPPORTED_LOCALES, LANGUAGES, LANG_COOKIE_NAME } from '../constants'
4
+ import type { Locale } from '../constants'
5
+
6
+ interface Props {
7
+ // The project's flag component (an Astro component reference), passed in so the
8
+ // engine imports no project UI. Without it, items render label-only.
9
+ flag?: (props: { code: string; size?: number }) => any
10
+ // The project's icons for the trigger button and the active item (Astro component
11
+ // references, like `flag`). Without them, the minimal inline defaults below render.
12
+ triggerIcon?: (props: Record<string, unknown>) => any
13
+ activeIcon?: (props: Record<string, unknown>) => any
14
+ // The component is headless: every class comes from here. A slot left out renders no
15
+ // `class` attribute; `itemActive` is appended to `item` on the active entry.
16
+ classes?: Partial<
17
+ Record<'root' | 'trigger' | 'triggerIcon' | 'dropdown' | 'item' | 'itemActive' | 'itemLabel' | 'activeIcon', string>
18
+ >
19
+ }
20
+
21
+ const { flag: Flag, triggerIcon: TriggerIcon, activeIcon: ActiveIcon, classes = {} } = Astro.props
22
+ const { uiLocale: currentLang } = Astro.locals
23
+ ---
24
+
25
+ <language-switcher
26
+ data-current-lang={currentLang}
27
+ data-cookie-name={LANG_COOKIE_NAME}
28
+ data-cookie-max-age={I18N_CONFIG.cookie.maxAge}
29
+ data-open="false"
30
+ >
31
+ <div class={classes.root}>
32
+ <button
33
+ type="button"
34
+ data-ls-trigger
35
+ class={classes.trigger}
36
+ aria-haspopup="true"
37
+ aria-expanded="false"
38
+ aria-label="Switch language"
39
+ >
40
+ {
41
+ TriggerIcon ? (
42
+ <TriggerIcon class={classes.triggerIcon} stroke-width="1" />
43
+ ) : (
44
+ <svg
45
+ class={classes.triggerIcon}
46
+ viewBox="0 0 24 24"
47
+ fill="none"
48
+ stroke="currentColor"
49
+ stroke-width="1"
50
+ aria-hidden="true"
51
+ >
52
+ <circle cx="12" cy="12" r="9" />
53
+ <ellipse cx="12" cy="12" rx="4" ry="9" />
54
+ <path d="M3 12h18" />
55
+ </svg>
56
+ )
57
+ }
58
+ </button>
59
+
60
+ <div data-ls-dropdown class={classes.dropdown} role="menu" hidden>
61
+ {
62
+ SUPPORTED_LOCALES.map((lang) => {
63
+ const locale = lang as Locale
64
+ const isActive = locale === currentLang
65
+ const label = LANGUAGES[locale]
66
+ const flagCode = label.countryCode
67
+
68
+ return (
69
+ <button
70
+ type="button"
71
+ class:list={[classes.item, isActive && classes.itemActive]}
72
+ data-ls-item
73
+ data-lang={locale}
74
+ data-active={isActive ? 'true' : undefined}
75
+ role="menuitem"
76
+ disabled={isActive}
77
+ >
78
+ <span class={classes.itemLabel}>
79
+ {Flag && <Flag code={flagCode} size={16} />}
80
+ {label.name}
81
+ </span>
82
+ {isActive &&
83
+ (ActiveIcon ? (
84
+ <ActiveIcon class={classes.activeIcon} />
85
+ ) : (
86
+ <svg class={classes.activeIcon} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
87
+ <circle cx="12" cy="12" r="3" />
88
+ </svg>
89
+ ))}
90
+ </button>
91
+ )
92
+ })
93
+ }
94
+ </div>
95
+ </div>
96
+ </language-switcher>
97
+
98
+ <script>
99
+ // Config arrives as data attributes on the element: a client script in the engine
100
+ // cannot import the project's config.
101
+ import type { Locale } from '../constants'
102
+
103
+ class LanguageSwitcher extends HTMLElement {
104
+ private trigger: HTMLButtonElement | null = null
105
+ private dropdown: HTMLElement | null = null
106
+
107
+ connectedCallback() {
108
+ this.trigger = this.querySelector('[data-ls-trigger]')
109
+ this.dropdown = this.querySelector('[data-ls-dropdown]')
110
+
111
+ if (!this.trigger || !this.dropdown) return
112
+
113
+ this.trigger.addEventListener('click', this.onToggle)
114
+ this.dropdown.addEventListener('click', this.onSelect)
115
+ }
116
+
117
+ disconnectedCallback() {
118
+ this.trigger?.removeEventListener('click', this.onToggle)
119
+ this.dropdown?.removeEventListener('click', this.onSelect)
120
+ this.removeGlobalListeners()
121
+ }
122
+
123
+ private open() {
124
+ if (!this.dropdown || !this.trigger) return
125
+ this.dropdown.hidden = false
126
+ this.dataset.open = 'true'
127
+ this.trigger.setAttribute('aria-expanded', 'true')
128
+
129
+ document.addEventListener('click', this.onDocumentClick)
130
+ document.addEventListener('keydown', this.onKeyDown)
131
+ }
132
+
133
+ private close() {
134
+ if (!this.dropdown || !this.trigger) return
135
+ this.dropdown.hidden = true
136
+ this.dataset.open = 'false'
137
+ this.trigger.setAttribute('aria-expanded', 'false')
138
+
139
+ this.removeGlobalListeners()
140
+ }
141
+
142
+ private removeGlobalListeners() {
143
+ document.removeEventListener('click', this.onDocumentClick)
144
+ document.removeEventListener('keydown', this.onKeyDown)
145
+ }
146
+
147
+ private onToggle = (e: MouseEvent) => {
148
+ e.stopPropagation()
149
+ if (!this.dropdown) return
150
+
151
+ this.dropdown.hidden ? this.open() : this.close()
152
+ }
153
+
154
+ // Isolated logic for getting language from URL
155
+ private getLangFromUrl(pathname: string): string | undefined {
156
+ const segments = pathname.split('/')
157
+ return segments[1] || undefined
158
+ }
159
+
160
+ // Isolated logic for substituting language in URLs
161
+ private replaceLocaleInPath(pathname: string, currentLang: string, targetLang: string): string {
162
+ const segments = pathname.split('/')
163
+ if (segments[1] === currentLang) {
164
+ segments[1] = targetLang
165
+ }
166
+ return segments.join('/')
167
+ }
168
+
169
+ private onSelect = (event: Event) => {
170
+ const target = event.target as HTMLElement
171
+ const button = target.closest<HTMLButtonElement>('[data-ls-item]')
172
+ if (!button) return
173
+
174
+ const lang = button.dataset.lang as Locale | undefined
175
+ if (!lang) return
176
+
177
+ const pathname = window.location.pathname
178
+ const currentLang = this.getLangFromUrl(pathname)
179
+
180
+ if (!currentLang) return
181
+
182
+ const { cookieName, cookieMaxAge } = this.dataset
183
+
184
+ // Persist the EXPLICIT language choice client-side. The i18n middleware
185
+ // no longer creates the cookie on plain page views (a cookie-less request
186
+ // must stay free of Set-Cookie so the HTML edge cache can serve it), so
187
+ // the switcher is the place where the preference is born. Not httpOnly —
188
+ // the server only ever reads it (schema-validated), never trusts it.
189
+ document.cookie = `${cookieName}=${lang}; path=/; max-age=${cookieMaxAge}; SameSite=Lax; Secure`
190
+
191
+ window.location.href = this.replaceLocaleInPath(pathname, currentLang, lang)
192
+ }
193
+
194
+ private onDocumentClick = (event: MouseEvent) => {
195
+ if (!this.contains(event.target as Node)) {
196
+ this.close()
197
+ }
198
+ }
199
+
200
+ private onKeyDown = (event: KeyboardEvent) => {
201
+ if (event.key === 'Escape') {
202
+ this.close()
203
+ }
204
+ }
205
+ }
206
+
207
+ if (!customElements.get('language-switcher')) {
208
+ customElements.define('language-switcher', LanguageSwitcher)
209
+ }
210
+ </script>
@@ -0,0 +1,31 @@
1
+ ---
2
+ import { checkMissingTranslation } from '../resolve-locale'
3
+
4
+ interface Props {
5
+ // The project's copy for both cases, from its own translations: the engine fetches
6
+ // nothing here and assumes no namespace. `content`: the page's content is missing in
7
+ // the selected language; `ui`: the interface is not fully translated.
8
+ texts: { content: string; ui: string }
9
+ // Headless: the class comes from here; without it the banner renders no `class`.
10
+ classes?: Partial<Record<'root', string>>
11
+ }
12
+
13
+ const { texts, classes = {} } = Astro.props
14
+ const { uiLocale, isMissingContent } = Astro.locals
15
+
16
+ const missingType = checkMissingTranslation(uiLocale, isMissingContent)
17
+
18
+ let bannerText: string | null = null
19
+
20
+ if (missingType) {
21
+ bannerText = missingType === 'content' ? texts.content : texts.ui
22
+ }
23
+ ---
24
+
25
+ {
26
+ bannerText && (
27
+ <div class={classes.root} data-missing={missingType}>
28
+ {bannerText}
29
+ </div>
30
+ )
31
+ }
@@ -0,0 +1,38 @@
1
+ // The `./config` entry: Node-safe. It imports nothing from workerd and no virtual module,
2
+ // so a project's pure modules (unit tests under Node, `src/content.config.ts` evaluated by
3
+ // `astro sync`) can derive the locale constants from the config file directly.
4
+ // `test/node-safe-entries.test.ts` loads it under plain Node to keep it that way.
5
+ import { defineI18nConfig, type I18nConfig, type I18nLanguage, type LocaleOf } from './types'
6
+
7
+ export { defineI18nConfig }
8
+ export type { I18nBindings, I18nConfig, I18nLanguage, I18nLocales, Locale, LocaleOf } from './types'
9
+
10
+ export type LocalesOf<C extends I18nConfig> = {
11
+ DEFAULT_LOCALE: LocaleOf<C>
12
+ SUPPORTED_LOCALES: [LocaleOf<C>, ...LocaleOf<C>[]]
13
+ LANGUAGES: Record<LocaleOf<C>, I18nLanguage>
14
+ }
15
+
16
+ /**
17
+ * The locale constants of a config: the one derivation, shared by the runtime
18
+ * (`constants.ts`, over the virtual config) and by project code (over its own
19
+ * `edgekits-i18n.config.ts`, typed by its own keys without the `I18nLocales` augmentation).
20
+ *
21
+ * `defaultLocale` must be one of `languages` at the type level, and is validated at run time.
22
+ */
23
+ export function localesOf<const C extends I18nConfig>(
24
+ config: C & {
25
+ defaultLocale: LocaleOf<C>
26
+ countryToLocale?: Record<string, LocaleOf<C>>
27
+ markdownLocale?: LocaleOf<C>
28
+ },
29
+ ): LocalesOf<C> {
30
+ defineI18nConfig<C>(config)
31
+
32
+ return {
33
+ DEFAULT_LOCALE: config.defaultLocale,
34
+ // Object.keys widens to string[]; the keys are the config's own `languages` keys.
35
+ SUPPORTED_LOCALES: Object.keys(config.languages) as [LocaleOf<C>, ...LocaleOf<C>[]],
36
+ LANGUAGES: config.languages as Record<LocaleOf<C>, I18nLanguage>,
37
+ }
38
+ }
package/src/config.ts ADDED
@@ -0,0 +1,11 @@
1
+ import config from 'virtual:@edgekits/astro-i18n/config'
2
+
3
+ /**
4
+ * The project values the i18n engine uses, in one place.
5
+ *
6
+ * Contract: this is the ONLY file in `src/` that reads the config, and it reads it
7
+ * from the virtual module the integration's Vite plugin serves (`test/i18n-boundary.test.ts`
8
+ * enforces it). The rest of the engine reads these values through `constants.ts` and
9
+ * `translations-keys.ts`.
10
+ */
11
+ export const I18N_CONFIG = config
@@ -0,0 +1,29 @@
1
+ import { I18N_CONFIG } from './config'
2
+ import { localesOf } from './config.entry'
3
+ import type { I18nLanguage, Locale } from './types'
4
+
5
+ export type { Locale }
6
+
7
+ export type Language = I18nLanguage
8
+
9
+ // Languages are configured in the project's edgekits-i18n.config.ts; the derivation is
10
+ // `localesOf` (config.entry.ts), shared with project code. Inside the package the config
11
+ // is not literal, so its keys are narrowed to the augmented `Locale` here.
12
+ const LOCALES = localesOf(I18N_CONFIG)
13
+
14
+ export const LANGUAGES = LOCALES.LANGUAGES as Record<Locale, Language>
15
+
16
+ // Locales Configuration
17
+ export const SUPPORTED_LOCALES = LOCALES.SUPPORTED_LOCALES as [Locale, ...Locale[]]
18
+
19
+ // export const SUPPORTED_LOCALES = ['en', 'de', 'es', 'pt-br', 'ja'] as const
20
+ export type SupportedLocales = (typeof SUPPORTED_LOCALES)[number]
21
+
22
+ // Default locale
23
+ export const DEFAULT_LOCALE: SupportedLocales = LOCALES.DEFAULT_LOCALE as Locale
24
+
25
+ // Missing translation banner flag
26
+ export const ENABLE_MISSING_TRANSLATION_BANNER = I18N_CONFIG.missingTranslationBanner
27
+
28
+ // Cookie name
29
+ export const LANG_COOKIE_NAME = I18N_CONFIG.cookie.name
@@ -0,0 +1,32 @@
1
+ import { type AstroCookies } from 'astro'
2
+
3
+ import { I18N_CONFIG } from './config'
4
+ import { LocaleSchema } from './schema'
5
+ import type { Locale } from './constants'
6
+ import { LANG_COOKIE_NAME } from './constants'
7
+
8
+ // Self-contained on purpose: the same options and the same invalid-value warning
9
+ // as the project's src/utils/server/cookies.ts, without importing it.
10
+ export function setCookieLang(cookies: AstroCookies, locale: Locale) {
11
+ cookies.set(LANG_COOKIE_NAME, locale, {
12
+ path: '/',
13
+ httpOnly: true,
14
+ secure: import.meta.env.PROD,
15
+ sameSite: 'lax',
16
+ maxAge: I18N_CONFIG.cookie.maxAge,
17
+ })
18
+ }
19
+
20
+ export function getCookieLang(cookies: AstroCookies): Locale | null {
21
+ const entry = cookies.get(LANG_COOKIE_NAME)
22
+ if (!entry) return null
23
+
24
+ const result = LocaleSchema.safeParse(entry.value)
25
+ if (result.success) return result.data
26
+
27
+ if (!import.meta.env.PROD) {
28
+ console.warn(`Invalid cookie "${LANG_COOKIE_NAME}"`, result.error.issues)
29
+ }
30
+
31
+ return null
32
+ }
@@ -0,0 +1,38 @@
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
+ }