@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.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +394 -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 +77 -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 +210 -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 +119 -0
- 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
|
package/src/constants.ts
ADDED
|
@@ -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
|
+
}
|