@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.
- package/CHANGELOG.md +22 -0
- package/README.md +87 -23
- package/dist/integration/index.d.ts +25 -4
- package/dist/integration/index.js +35 -9
- package/dist/integration/vite-plugin.d.ts +1 -1
- package/dist/types.d.ts +7 -55
- package/dist/types.js +1 -44
- package/package.json +7 -17
- package/src/components/LanguageSwitcher.astro +89 -93
- package/src/config.entry.ts +5 -36
- package/src/constants.ts +5 -3
- package/src/cookie-storage.ts +19 -25
- package/src/fetcher.ts +3 -304
- package/src/format.ts +3 -107
- package/src/instance.ts +44 -0
- package/src/integration/index.ts +38 -9
- package/src/integration/vite-plugin.ts +1 -1
- package/src/middleware/i18n.ts +232 -114
- package/src/resolve-locale.ts +7 -39
- package/src/runtime.ts +2 -2
- package/src/schema.ts +3 -11
- package/src/translations-keys.ts +7 -38
- package/src/types.ts +16 -116
- package/dist/cli/generate.d.ts +0 -50
- package/dist/cli/generate.js +0 -160
- package/dist/cli/hashes.d.ts +0 -19
- package/dist/cli/hashes.js +0 -42
- package/dist/cli/index.d.ts +0 -2
- package/dist/cli/index.js +0 -372
- package/dist/cli/key-prefix-check.d.ts +0 -6
- package/dist/cli/key-prefix-check.js +0 -27
- package/dist/cli/load-config.d.ts +0 -7
- package/dist/cli/load-config.js +0 -19
- package/dist/cli/purge.d.ts +0 -29
- package/dist/cli/purge.js +0 -94
- package/dist/cli/wrangler.d.ts +0 -10
- package/dist/cli/wrangler.js +0 -71
- package/dist/cli/zone.d.ts +0 -21
- package/dist/cli/zone.js +0 -42
- package/dist/key-format.d.ts +0 -8
- package/dist/key-format.js +0 -15
- package/src/accept-language.ts +0 -56
- package/src/assemble.ts +0 -29
- package/src/cli/generate.ts +0 -205
- package/src/cli/hashes.ts +0 -66
- package/src/cli/index.ts +0 -472
- package/src/cli/key-prefix-check.ts +0 -31
- package/src/cli/load-config.ts +0 -26
- package/src/cli/purge.ts +0 -122
- package/src/cli/wrangler.ts +0 -96
- package/src/cli/zone.ts +0 -64
- package/src/deep-merge.ts +0 -38
- package/src/key-format.ts +0 -17
- package/src/locale-fallback.ts +0 -58
- package/src/markdown-path.ts +0 -31
- package/src/parse-boolean.ts +0 -20
package/src/format.ts
CHANGED
|
@@ -1,107 +1,3 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
.replace(/&/g, '&')
|
|
5
|
-
.replace(/</g, '<')
|
|
6
|
-
.replace(/>/g, '>')
|
|
7
|
-
.replace(/"/g, '"')
|
|
8
|
-
.replace(/'/g, ''')
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
type TemplateValues = Record<string, string | number>
|
|
12
|
-
|
|
13
|
-
// `./format` is a Node-safe entry: under plain Node (a project's unit tests, `astro sync`)
|
|
14
|
-
// there is no `import.meta.env`, and a missing one counts as "not production", so the
|
|
15
|
-
// dev warnings below still fire instead of throwing.
|
|
16
|
-
function isProd(): boolean {
|
|
17
|
-
return import.meta.env?.PROD === true
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Interpolates {placeholders} in a template string and escapes values.
|
|
22
|
-
* - If `template` is undefined/null, returns an empty string (and logs in dev).
|
|
23
|
-
* - If `values` is undefined, returns the template unchanged.
|
|
24
|
-
*/
|
|
25
|
-
export function fmt(template: string | null | undefined, values?: TemplateValues): string {
|
|
26
|
-
if (template == null) {
|
|
27
|
-
if (!isProd()) {
|
|
28
|
-
console.warn('[i18n] fmt() called with null/undefined template')
|
|
29
|
-
}
|
|
30
|
-
return ''
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
if (!values) return template
|
|
34
|
-
|
|
35
|
-
return template.replace(/{([^}]+)}/g, (match, key) => {
|
|
36
|
-
const replacement = values[key]
|
|
37
|
-
|
|
38
|
-
if (replacement === undefined || replacement === null) {
|
|
39
|
-
return match
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return escapeHtml(replacement)
|
|
43
|
-
})
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Simple two-form plural helper.
|
|
48
|
-
* Good for English-like languages where "1" vs "other" is enough.
|
|
49
|
-
*/
|
|
50
|
-
export function plural(count: number, singular: string, plural: string): string {
|
|
51
|
-
return count === 1 ? singular : fmt(plural, { count })
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* ICU-style plural categories used by Intl.PluralRules.
|
|
56
|
-
*/
|
|
57
|
-
export type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'
|
|
58
|
-
|
|
59
|
-
export type PluralPatterns = Partial<Record<PluralCategory, string>>
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Advanced plural helper using Intl.PluralRules + fmt().
|
|
63
|
-
*
|
|
64
|
-
* - `locale` controls plural categories (en, de, es, ja, ...).
|
|
65
|
-
* - `patterns` comes from KV (e.g. { zero, one, other }).
|
|
66
|
-
* - Missing categories fall back to `other`.
|
|
67
|
-
*
|
|
68
|
-
* Example:
|
|
69
|
-
* pluralIcu(5, 'en', {
|
|
70
|
-
* zero: 'No items',
|
|
71
|
-
* one: '{count} item',
|
|
72
|
-
* other: '{count} items'
|
|
73
|
-
* })
|
|
74
|
-
*/
|
|
75
|
-
export function pluralIcu(count: number, locale: string, patterns: PluralPatterns): string {
|
|
76
|
-
const rules = new Intl.PluralRules(locale)
|
|
77
|
-
const cat = rules.select(count) as PluralCategory
|
|
78
|
-
|
|
79
|
-
const template =
|
|
80
|
-
patterns[cat] ??
|
|
81
|
-
patterns.other ??
|
|
82
|
-
(isProd() ? '' : (console.warn('[i18n] pluralIcu(): missing "other" pattern'), ''))
|
|
83
|
-
|
|
84
|
-
return fmt(template, { count })
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Used in the LanguageSwitcher and SeoHreflangs components
|
|
88
|
-
export function replaceLocaleInPath(currentPath: string, currentLocale: string, newLocale: string): string {
|
|
89
|
-
const prefixRegex = new RegExp(`^/${currentLocale}/`)
|
|
90
|
-
return currentPath.replace(prefixRegex, `/${newLocale}/`)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Used in the LanguageSwitcher component
|
|
94
|
-
export function getLangFromUrl(pathname: string) {
|
|
95
|
-
// Remove leading/trailing slashes: '/en/about/' -> 'en/about'
|
|
96
|
-
const cleaned = pathname.replace(/^\/+|\/+$/g, '')
|
|
97
|
-
const [lang] = cleaned.split('/')
|
|
98
|
-
return lang
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Converts a URL locale code to an IETF BCP 47 language tag.
|
|
102
|
-
// Used for the HTML lang attribute and hreflang values.
|
|
103
|
-
// 'pt-br' → 'pt-BR', 'en-us' → 'en-US', 'en' → 'en'
|
|
104
|
-
export function toIetfTag(locale: string): string {
|
|
105
|
-
const [lang, region] = locale.split('-')
|
|
106
|
-
return region ? `${lang}-${region.toUpperCase()}` : locale
|
|
107
|
-
}
|
|
1
|
+
// The `./format` entry: the core's formatting helpers. Node-safe, like the core's
|
|
2
|
+
// `./format` it re-exports (`test/node-safe-entries.test.ts`).
|
|
3
|
+
export * from '@edgekits/i18n-core/format'
|
package/src/instance.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { env, waitUntil } from 'cloudflare:workers'
|
|
2
|
+
import * as fallbacksModule from 'virtual:@edgekits/astro-i18n/fallbacks'
|
|
3
|
+
import * as runtimeConstants from 'virtual:@edgekits/astro-i18n/runtime-constants'
|
|
4
|
+
import { createI18n, parseBooleanFlag, type Bindings, type EdgeCache, type I18nBindings } from '@edgekits/i18n-core'
|
|
5
|
+
|
|
6
|
+
import { I18N_CONFIG } from './config'
|
|
7
|
+
|
|
8
|
+
// The bindings this module reads, typed by the package rather than the project's Env.
|
|
9
|
+
const i18nEnv = env as unknown as I18nBindings
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The platform as the core reads it. Getters: in Astro on Workers, `env` is valid only
|
|
13
|
+
* inside a request, and the core reads each property at call time.
|
|
14
|
+
*
|
|
15
|
+
* Wrangler vars (wrangler.jsonc / wrangler.toml or .dev.vars; then `wrangler types` if
|
|
16
|
+
* the project generates its Env), each "on" | "off" | "true" | "false" | "1" | "0":
|
|
17
|
+
* I18N_CACHE: the edge cache, on by default;
|
|
18
|
+
* DEBUG_I18N: the fetcher's debug log, off by default.
|
|
19
|
+
*/
|
|
20
|
+
const bindings: Bindings = {
|
|
21
|
+
get kv() {
|
|
22
|
+
return i18nEnv.TRANSLATIONS
|
|
23
|
+
},
|
|
24
|
+
get cache() {
|
|
25
|
+
// `caches.default` only: absent (an untrusted Workers for Platforms namespace), the
|
|
26
|
+
// core reads KV directly.
|
|
27
|
+
const cacheStorage = caches as { default?: EdgeCache }
|
|
28
|
+
return cacheStorage?.default ?? null
|
|
29
|
+
},
|
|
30
|
+
// Top-level waitUntil (cloudflare:workers): same semantics as ctx.waitUntil.
|
|
31
|
+
waitUntil: (promise) => waitUntil(promise),
|
|
32
|
+
get cacheEnabled() {
|
|
33
|
+
return parseBooleanFlag(i18nEnv.I18N_CACHE, /* defaultValue */ true)
|
|
34
|
+
},
|
|
35
|
+
get debug() {
|
|
36
|
+
return parseBooleanFlag(i18nEnv.DEBUG_I18N, /* defaultValue */ false)
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The one engine instance of the project: the core's `createI18n` over the virtual config,
|
|
42
|
+
* the generated modules (or the Vite plugin's stubs) and the Worker bindings.
|
|
43
|
+
*/
|
|
44
|
+
export const i18n = createI18n(I18N_CONFIG, { fallbacks: fallbacksModule, runtimeConstants }, bindings)
|
package/src/integration/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AstroConfig, AstroIntegration } from 'astro'
|
|
2
|
+
import { DEFAULT_GENERATED_DIR, defineI18nConfig, type I18nConfig, type I18nRouting } from '@edgekits/i18n-core'
|
|
2
3
|
|
|
3
|
-
import { DEFAULT_GENERATED_DIR, defineI18nConfig, type I18nConfig } from '../types.ts'
|
|
4
4
|
import { i18nVitePlugin } from './vite-plugin.ts'
|
|
5
5
|
import { virtualModulesDeclaration } from './virtual-types.ts'
|
|
6
6
|
|
|
@@ -20,7 +20,7 @@ export const PACKAGE_NAME = '@edgekits/astro-i18n'
|
|
|
20
20
|
* plugin's virtual modules.
|
|
21
21
|
*/
|
|
22
22
|
export function edgekitsI18n(config: I18nConfig, options: EdgekitsI18nOptions = {}): AstroIntegration {
|
|
23
|
-
const i18nConfig =
|
|
23
|
+
const i18nConfig = resolveConfig(config)
|
|
24
24
|
const middleware = options.middleware ?? 'auto'
|
|
25
25
|
|
|
26
26
|
if (!MIDDLEWARE_MODES.includes(middleware)) {
|
|
@@ -66,8 +66,19 @@ export function edgekitsI18n(config: I18nConfig, options: EdgekitsI18nOptions =
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
/**
|
|
70
|
+
* The validated config with `routing` resolved: absent means 'prefix-except-default', the
|
|
71
|
+
* mode for new projects. Resolved here, before the Vite plugin serialises it, so the
|
|
72
|
+
* virtual config (and `I18N_CONFIG`) always carries the mode the middleware runs.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveConfig(config: I18nConfig): I18nConfig & { routing: I18nRouting } {
|
|
75
|
+
const validated = defineI18nConfig(config)
|
|
76
|
+
return { ...validated, routing: validated.routing ?? 'prefix-except-default' }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Each entry that exports `I18nLocales`: both re-export the core's interface (root through
|
|
80
|
+
// dist/types.d.ts, runtime through the shipped src/types.ts), so either augmentation
|
|
81
|
+
// merges into it; both are kept so neither entry depends on how the other resolves.
|
|
71
82
|
const LOCALE_MODULES = ['@edgekits/astro-i18n', '@edgekits/astro-i18n/runtime']
|
|
72
83
|
|
|
73
84
|
/**
|
|
@@ -95,9 +106,16 @@ export function localesDeclaration(locales: readonly string[]): string {
|
|
|
95
106
|
|
|
96
107
|
/**
|
|
97
108
|
* The Astro `i18n` block set when the project has none: locales and default from the
|
|
98
|
-
* config;
|
|
99
|
-
* does not). Complete, `fallbackType` included: Astro does not
|
|
100
|
-
* `updateConfig` merges, so its schema defaults never fill a partial block.
|
|
109
|
+
* config; routed as locale-prefixed in both modes (the middleware redirects `/` itself in
|
|
110
|
+
* 'prefix', so Astro does not). Complete, `fallbackType` included: Astro does not
|
|
111
|
+
* re-validate what `updateConfig` merges, so its schema defaults never fill a partial block.
|
|
112
|
+
*
|
|
113
|
+
* `prefixDefaultLocale: true` in 'prefix-except-default' too: Astro's own i18n check runs
|
|
114
|
+
* after the middleware, on the rewritten URL (astro/dist/core/routing/handler.js:128-130),
|
|
115
|
+
* and with `prefixDefaultLocale: false` the strategy `pathname-prefix-other-locales`
|
|
116
|
+
* answers notFound to any path carrying the default locale (astro/dist/i18n/router.js:
|
|
117
|
+
* 104-121) — the rewritten `/<default>/about/` is exactly that. With `true` the check
|
|
118
|
+
* passes and `Astro.currentLocale` works on root pages.
|
|
101
119
|
*/
|
|
102
120
|
export function astroI18n(config: I18nConfig): NonNullable<AstroConfig['i18n']> {
|
|
103
121
|
return {
|
|
@@ -109,9 +127,10 @@ export function astroI18n(config: I18nConfig): NonNullable<AstroConfig['i18n']>
|
|
|
109
127
|
|
|
110
128
|
/**
|
|
111
129
|
* A project's own Astro `i18n` block must cover every configured locale (as a string
|
|
112
|
-
* entry, a `path` or one of its `codes`)
|
|
130
|
+
* entry, a `path` or one of its `codes`), share the default locale, and route as
|
|
131
|
+
* `astroI18n` does (`prefixDefaultLocale: true`, `redirectToDefaultLocale: false`).
|
|
113
132
|
*/
|
|
114
|
-
function assertAstroI18n(block: NonNullable<AstroConfig['i18n']>, config: I18nConfig): void {
|
|
133
|
+
export function assertAstroI18n(block: NonNullable<AstroConfig['i18n']>, config: I18nConfig): void {
|
|
115
134
|
const declared = new Set(
|
|
116
135
|
block.locales.flatMap((entry) => (typeof entry === 'string' ? [entry] : [entry.path, ...entry.codes])),
|
|
117
136
|
)
|
|
@@ -129,6 +148,16 @@ function assertAstroI18n(block: NonNullable<AstroConfig['i18n']>, config: I18nCo
|
|
|
129
148
|
'Make them equal, or remove the `i18n` block from astro.config and the integration sets it for you.',
|
|
130
149
|
)
|
|
131
150
|
}
|
|
151
|
+
// Absent values are Astro's schema defaults (both false): `redirectToDefaultLocale` left
|
|
152
|
+
// out is fine, `prefixDefaultLocale` left out is not.
|
|
153
|
+
const routing = typeof block.routing === 'object' ? block.routing : undefined
|
|
154
|
+
if (routing?.prefixDefaultLocale !== true || routing.redirectToDefaultLocale === true) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
'[@edgekits/astro-i18n] Astro `i18n.routing` must set `prefixDefaultLocale: true` and `redirectToDefaultLocale: false` ' +
|
|
157
|
+
`(found ${JSON.stringify(block.routing)}): the middleware handles the default locale itself, and Astro's ` +
|
|
158
|
+
'own check would otherwise answer 404 or redirect the pages it serves. Set them, or remove the `i18n` block from astro.config and the integration sets it for you.',
|
|
159
|
+
)
|
|
160
|
+
}
|
|
132
161
|
}
|
|
133
162
|
|
|
134
163
|
function trimSlashes(path: string): string {
|
|
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
3
|
import type { AstroConfig } from 'astro'
|
|
4
4
|
|
|
5
|
-
import type { I18nConfig } from '
|
|
5
|
+
import type { I18nConfig } from '@edgekits/i18n-core'
|
|
6
6
|
|
|
7
7
|
type VitePlugin = NonNullable<NonNullable<AstroConfig['vite']>['plugins']>[number]
|
|
8
8
|
|