@edgekits/astro-i18n 0.1.7 → 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.
Files changed (50) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +98 -27
  3. package/dist/integration/index.d.ts +25 -4
  4. package/dist/integration/index.js +35 -9
  5. package/dist/integration/vite-plugin.d.ts +1 -1
  6. package/dist/types.d.ts +7 -55
  7. package/dist/types.js +1 -44
  8. package/package.json +7 -17
  9. package/src/components/LanguageSwitcher.astro +89 -91
  10. package/src/config.entry.ts +5 -36
  11. package/src/constants.ts +5 -3
  12. package/src/cookie-storage.ts +19 -25
  13. package/src/fetcher.ts +3 -304
  14. package/src/format.ts +3 -107
  15. package/src/instance.ts +44 -0
  16. package/src/integration/index.ts +38 -9
  17. package/src/integration/vite-plugin.ts +1 -1
  18. package/src/middleware/i18n.ts +232 -114
  19. package/src/resolve-locale.ts +7 -39
  20. package/src/runtime.ts +2 -2
  21. package/src/schema.ts +3 -11
  22. package/src/translations-keys.ts +7 -38
  23. package/src/types.ts +16 -116
  24. package/dist/cli/generate.d.ts +0 -50
  25. package/dist/cli/generate.js +0 -160
  26. package/dist/cli/hashes.d.ts +0 -19
  27. package/dist/cli/hashes.js +0 -42
  28. package/dist/cli/index.d.ts +0 -2
  29. package/dist/cli/index.js +0 -368
  30. package/dist/cli/load-config.d.ts +0 -7
  31. package/dist/cli/load-config.js +0 -19
  32. package/dist/cli/purge.d.ts +0 -29
  33. package/dist/cli/purge.js +0 -94
  34. package/dist/cli/wrangler.d.ts +0 -10
  35. package/dist/cli/wrangler.js +0 -71
  36. package/dist/key-format.d.ts +0 -8
  37. package/dist/key-format.js +0 -15
  38. package/src/accept-language.ts +0 -56
  39. package/src/assemble.ts +0 -29
  40. package/src/cli/generate.ts +0 -205
  41. package/src/cli/hashes.ts +0 -66
  42. package/src/cli/index.ts +0 -467
  43. package/src/cli/load-config.ts +0 -26
  44. package/src/cli/purge.ts +0 -122
  45. package/src/cli/wrangler.ts +0 -96
  46. package/src/deep-merge.ts +0 -38
  47. package/src/key-format.ts +0 -17
  48. package/src/locale-fallback.ts +0 -58
  49. package/src/markdown-path.ts +0 -31
  50. package/src/parse-boolean.ts +0 -20
package/src/format.ts CHANGED
@@ -1,107 +1,3 @@
1
- // Sanitizer: Turns <script> into &lt;script&gt;
2
- function escapeHtml(unsafe: string | number): string {
3
- return String(unsafe)
4
- .replace(/&/g, '&amp;')
5
- .replace(/</g, '&lt;')
6
- .replace(/>/g, '&gt;')
7
- .replace(/"/g, '&quot;')
8
- .replace(/'/g, '&#039;')
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'
@@ -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)
@@ -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 = defineI18nConfig(config)
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
- // Each entry that exports `I18nLocales` from its own declaration: the root entry's comes
70
- // from dist/types.d.ts, the runtime entry's from the shipped source (src/types.ts).
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; every URL locale-prefixed (the middleware redirects `/` itself, so Astro
99
- * does not). Complete, `fallbackType` included: Astro does not re-validate what
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`) and share the default locale.
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 '../types.ts'
5
+ import type { I18nConfig } from '@edgekits/i18n-core'
6
6
 
7
7
  type VitePlugin = NonNullable<NonNullable<AstroConfig['vite']>['plugins']>[number]
8
8