@i18n-micro/astro 1.3.25 → 2.0.1

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 (75) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +8 -64
  3. package/dist/auto-middleware.d.ts +1 -0
  4. package/dist/client/core.d.cts +86 -0
  5. package/dist/client/core.d.ts +71 -8
  6. package/dist/client/index.d.cts +2 -6
  7. package/dist/client/index.d.ts +2 -6
  8. package/dist/client/index.js +1 -6
  9. package/dist/client/preact.d.ts +12 -23
  10. package/dist/client/preact.js +13 -37
  11. package/dist/client/react.d.ts +14 -21
  12. package/dist/client/react.js +12 -37
  13. package/dist/client/svelte.d.ts +14 -14
  14. package/dist/client/svelte.js +17 -47
  15. package/dist/client/vue.d.ts +15 -15
  16. package/dist/client/vue.js +37 -2
  17. package/dist/composer.d.cts +16 -0
  18. package/dist/composer.d.ts +10 -30
  19. package/dist/core-CgrgdmGy.cjs +1 -0
  20. package/dist/core-DTAcn2yF.js +72 -0
  21. package/dist/env.d.ts +8 -2
  22. package/dist/index.cjs +11 -3
  23. package/dist/index.d.cts +10 -13
  24. package/dist/index.d.ts +10 -13
  25. package/dist/index.mjs +171 -482
  26. package/dist/integration.d.cts +26 -0
  27. package/dist/integration.d.ts +14 -15
  28. package/dist/middleware-BT3OA5YL.cjs +1 -0
  29. package/dist/middleware-Ch8E0iV_.js +257 -0
  30. package/dist/middleware.d.cts +34 -0
  31. package/dist/middleware.d.ts +17 -8
  32. package/dist/middleware.js +23 -0
  33. package/dist/node.d.ts +26 -0
  34. package/dist/node.js +22 -0
  35. package/dist/request.d.ts +69 -0
  36. package/dist/router/adapter.d.cts +9 -0
  37. package/dist/router/adapter.d.ts +5 -4
  38. package/dist/router/types.d.cts +66 -0
  39. package/dist/router/types.d.ts +3 -2
  40. package/dist/utils.d.cts +112 -0
  41. package/dist/utils.d.ts +39 -35
  42. package/dist/virtual.d.ts +21 -0
  43. package/package.json +28 -15
  44. package/src/auto-middleware.ts +26 -0
  45. package/src/client/core.ts +124 -59
  46. package/src/client/index.ts +2 -11
  47. package/src/client/preact.tsx +12 -127
  48. package/src/client/react.tsx +11 -123
  49. package/src/client/svelte.ts +6 -69
  50. package/src/client/vue.ts +22 -77
  51. package/src/components/i18n-link.astro +2 -59
  52. package/src/components/i18n-switcher.astro +46 -149
  53. package/src/components/i18n-t.astro +6 -54
  54. package/src/composer.ts +25 -115
  55. package/src/env.d.ts +7 -0
  56. package/src/index.ts +1 -6
  57. package/src/integration.ts +113 -52
  58. package/src/middleware.ts +174 -120
  59. package/src/node.ts +51 -0
  60. package/src/request.ts +177 -0
  61. package/src/router/adapter.ts +47 -149
  62. package/src/router/types.ts +3 -2
  63. package/src/utils.ts +149 -323
  64. package/src/virtual.d.ts +21 -0
  65. package/dist/components/index.d.cts +0 -0
  66. package/dist/components/index.d.ts +0 -0
  67. package/dist/core-DwkpOV-H.cjs +0 -1
  68. package/dist/core-VSnkGRWR.js +0 -28
  69. package/dist/load-translations.d.ts +0 -43
  70. package/dist/routing-context.d.ts +0 -4
  71. package/dist/vue-BNiEwZr2.cjs +0 -5
  72. package/dist/vue-CWHjIOiS.js +0 -1724
  73. package/src/components/index.ts +0 -11
  74. package/src/load-translations.ts +0 -106
  75. package/src/routing-context.ts +0 -23
package/src/request.ts ADDED
@@ -0,0 +1,177 @@
1
+ import { externalLinkHref, pathEnd, withoutDots } from '@i18n-micro/core'
2
+ import { trimTrailingSlashes, withoutAppBaseURL } from '@i18n-micro/utils/app-path'
3
+ import { localeFromPath } from '@i18n-micro/utils/resolve-locale'
4
+ import type { I18nRoutingStrategy } from './router/types'
5
+
6
+ /**
7
+ * The one request model. Everything below works on the page's path without Astro's `base`:
8
+ * the middleware strips it once, every URL that goes out gets it back from {@link withBase}.
9
+ */
10
+ export interface I18nRouting {
11
+ defaultLocale: string
12
+ /** Codes of the locales in service: a disabled one is neither a URL's locale nor a redirect target. */
13
+ locales: string[]
14
+ /** `''` or `/docs`, no trailing slash. */
15
+ base: string
16
+ strategy: I18nRoutingStrategy
17
+ }
18
+
19
+ /** The parts of an Astro context a request is read from: also a mock in tests. */
20
+ export interface RequestContext {
21
+ url: URL
22
+ params?: Record<string, string | undefined>
23
+ routePattern?: string
24
+ isPrerendered?: boolean
25
+ request: { headers: Headers; method?: string }
26
+ }
27
+
28
+ export interface I18nRequest {
29
+ /** The page this request is for: another URL than `context.url` for a server island or action. */
30
+ url: URL
31
+ /** A server island or action: it belongs to the page in `url`, and is no page view itself. */
32
+ partOfPage: boolean
33
+ /** `url.pathname` without `base`. */
34
+ path: string
35
+ /** The locale `url` names, `null` for an unprefixed URL. */
36
+ urlLocale: string | null
37
+ /** The URL's locale, else the default one: an unprefixed URL is the default locale's page. */
38
+ locale: string
39
+ route: string
40
+ }
41
+
42
+ export const normalizeBase = (base: string | undefined): string => trimTrailingSlashes(base ?? '')
43
+
44
+ /** `path`, which has no `base`, as a URL of the site. */
45
+ export function withBase(path: string, base: string): string {
46
+ if (!base) return path
47
+ return path === '/' ? base : `${base}${path}`
48
+ }
49
+
50
+ /** A request header, or null where there is none (static prerendering). */
51
+ export function header(context: Pick<RequestContext, 'request'>, name: string): string | null {
52
+ try {
53
+ return context.request.headers.get(name)
54
+ } catch {
55
+ return null
56
+ }
57
+ }
58
+
59
+ /** The URL of the page this request comes from. A malformed header is no page, and prerendering has no visitor. */
60
+ function refererUrl(context: RequestContext): URL | null {
61
+ const referer = context.isPrerendered ? null : header(context, 'referer')
62
+ if (!referer) return null
63
+ try {
64
+ return new URL(referer)
65
+ } catch {
66
+ return null
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Whether `from` is this site. The browser's word first: behind a TLS proxy `context.url` may be the
72
+ * internal URL. Without that header the host decides, and a proxy needs `security.allowedDomains`.
73
+ */
74
+ function isSameSite(context: RequestContext, from: URL): boolean {
75
+ const site = header(context, 'sec-fetch-site')
76
+ return site ? site === 'same-origin' : from.host === context.url.host
77
+ }
78
+
79
+ /**
80
+ * A server island or action has no locale in its URL: it is a part of the page that asked for it.
81
+ * Any origin counts here: a forged `Referer` changes only the forger's own answer, and the internal
82
+ * URL behind a proxy matches no public one. The page's path, on this server's origin.
83
+ * ponytail: without a `Referer` (a `no-referrer` policy) it renders in the default locale, and on a
84
+ * dynamic page its route name comes from the URL; route patterns in the config would settle that.
85
+ */
86
+ function pageOfPart(context: RequestContext): URL | null {
87
+ const from = refererUrl(context)
88
+ return from && new URL(from.pathname + from.search, context.url)
89
+ }
90
+
91
+ const isPartOfPage = (pattern: string | undefined): boolean =>
92
+ pattern?.startsWith('/_server-islands/') === true || pattern?.startsWith('/_actions/') === true
93
+
94
+ /** The route's pattern, a `[locale]` param replaced by its value so the locale drops out of the name. */
95
+ function pagePattern(context: RequestContext, locales: string[]): string | undefined {
96
+ const { routePattern: pattern, params } = context
97
+ if (!pattern) return undefined
98
+ const first = /^\/\[([^\].]+)\]/.exec(pattern)
99
+ const value = first ? params?.[first[1]!] : undefined
100
+ if (!value) return pattern
101
+ const rest = pattern.slice(first![0].length)
102
+ if (locales.includes(value)) return `/${value}${rest}`
103
+ // a disabled locale still matches `[locale]`: it is no part of the page's name
104
+ return first![1] === 'locale' ? rest || '/' : pattern
105
+ }
106
+
107
+ /**
108
+ * Locale a path names: its prefix, or what the strategy reads from it when that is not the default
109
+ * (a strategy's `getLocaleFromPath` answers the default for a URL without a locale).
110
+ */
111
+ export function localeOfPath(path: string, { defaultLocale, locales, strategy }: Omit<I18nRouting, 'base'>): string | null {
112
+ const prefix = localeFromPath(path, locales)
113
+ if (prefix) return prefix
114
+ const read = strategy.getLocaleFromPath?.(path, defaultLocale, locales) ?? null
115
+ return read !== defaultLocale && read !== null && locales.includes(read) ? read : null
116
+ }
117
+
118
+ export function resolveRequest(context: RequestContext, routing: I18nRouting): I18nRequest {
119
+ const { strategy, locales, defaultLocale, base } = routing
120
+ const partOfPage = isPartOfPage(context.routePattern)
121
+ const url = (partOfPage && pageOfPart(context)) || context.url
122
+ const path = withoutAppBaseURL(url.pathname, base)
123
+ const urlLocale = localeOfPath(path, routing)
124
+ const pattern = partOfPage ? undefined : pagePattern(context, locales)
125
+ return { url, partOfPage, path, urlLocale, locale: urlLocale ?? defaultLocale, route: strategy.getRouteName!(path, locales, pattern) }
126
+ }
127
+
128
+ /** Whether the visitor comes from a page of this site in another locale. */
129
+ export function isSwitch(context: RequestContext, request: I18nRequest, routing: I18nRouting): boolean {
130
+ const from = refererUrl(context)
131
+ if (!from || !isSameSite(context, from)) return false
132
+ const locale = localeOfPath(withoutAppBaseURL(from.pathname, routing.base), routing) ?? routing.defaultLocale
133
+ return locale !== request.locale
134
+ }
135
+
136
+ /** A document may be asked for by this request: an island, an action or a form post is no landing. */
137
+ export function asksForPage(context: RequestContext, request: I18nRequest): boolean {
138
+ const method = context.request.method ?? 'GET'
139
+ return !request.partOfPage && !context.isPrerendered && (method === 'GET' || method === 'HEAD')
140
+ }
141
+
142
+ /**
143
+ * The visitor did see this response as a page: no endpoint, image, 404 or redirect, and not a
144
+ * prefetch of a link they may never follow.
145
+ * ponytail: a `fetch` of an HTML fragment from a page of another locale still counts; `<ClientRouter>`
146
+ * navigates by `fetch` too, and the two look the same from here.
147
+ */
148
+ export function isPageView(context: RequestContext, response: Response): boolean {
149
+ if (!response.ok || !response.headers.get('content-type')?.startsWith('text/html')) return false
150
+ return !`${header(context, 'sec-purpose')} ${header(context, 'purpose')}`.includes('prefetch')
151
+ }
152
+
153
+ /** A scheme this router has no business with: `sms:`, `ftp:`, `javascript:`. `externalLinkHref` handles the common ones. */
154
+ const SCHEME = /^[a-z][a-z\d+.-]*:/i
155
+
156
+ export interface HrefContext extends Pick<I18nRouting, 'base' | 'defaultLocale' | 'strategy'> {
157
+ codes: string[]
158
+ /** What a relative `href` resolves against: the page's path. */
159
+ from?: string
160
+ }
161
+
162
+ /**
163
+ * `href` as a link to `locale`. What leaves the app, and a bare `#hash` or `?query`, stays as
164
+ * written; a route is localized and keeps its query, hash and trailing slash.
165
+ */
166
+ export function localizeHref(href: string, locale: string, context: HrefContext): string {
167
+ if (href === '' || href[0] === '#' || href[0] === '?') return href
168
+ const external = externalLinkHref(href) ?? (SCHEME.test(href) ? href : undefined)
169
+ if (external) return external
170
+ const { strategy, codes, defaultLocale, base, from = '/' } = context
171
+ const cut = pathEnd(href)
172
+ const raw = cut === -1 ? href : href.slice(0, cut)
173
+ const path = withoutDots(raw[0] === '/' ? withoutAppBaseURL(raw, base) : `${trimTrailingSlashes(from)}/${raw}`)
174
+ const localized = strategy.localizePath!(path, locale, codes, defaultLocale)
175
+ const slash = raw.length > 1 && raw.endsWith('/') && !localized.endsWith('/') ? '/' : ''
176
+ return `${withBase(localized + slash, base)}${cut === -1 ? '' : href.slice(cut)}`
177
+ }
@@ -1,166 +1,64 @@
1
+ import { pathSegments, urlPath } from '@i18n-micro/core'
1
2
  import type { Locale } from '@i18n-micro/types'
3
+ import { trimLeadingSlashes } from '@i18n-micro/utils/app-path'
4
+ import { localeFromPath } from '@i18n-micro/utils/resolve-locale'
2
5
  import type { I18nRoutingStrategy } from './types'
3
6
 
4
- /**
5
- * Factory for Astro router adapter
6
- * Implements routing utilities for Astro file-based routing
7
- * Uses standard Astro APIs: Astro.url, context.url
8
- */
9
- export function createAstroRouterAdapter(locales: Locale[], defaultLocale: string, getCurrentUrl?: () => URL): I18nRoutingStrategy {
10
- const localeCodes = locales.map((loc) => loc.code)
11
-
12
- /**
13
- * Get route name from Astro path
14
- * Extracts route name from path (e.g., /en/about -> about)
15
- */
16
- const getRouteName = (path: string, locales: string[] = []): string => {
17
- const cleanPath = path.replace(/^\//, '').replace(/\/$/, '')
18
-
19
- if (!cleanPath) {
20
- return 'index'
21
- }
22
-
23
- const segments = cleanPath.split('/').filter(Boolean)
24
-
25
- // Remove locale from path if present
26
- const firstSegment = segments[0]
27
- if (firstSegment && locales.includes(firstSegment)) {
28
- segments.shift()
29
- }
30
-
31
- if (segments.length === 0) {
32
- return 'index'
33
- }
34
-
35
- return segments.join('-')
36
- }
37
-
38
- /**
39
- * Get locale from path
40
- * Checks if first segment is a locale code
41
- */
42
- const getLocaleFromPath = (path: string, defaultLocale: string = 'en', locales: string[] = []): string => {
43
- const segments = path.split('/').filter(Boolean)
44
- const firstSegment = segments[0]
45
- if (firstSegment && locales.includes(firstSegment)) {
46
- return firstSegment
47
- }
48
-
49
- return defaultLocale
50
- }
51
-
52
- /**
53
- * Switch locale in path
54
- * Replaces or adds locale prefix to path
55
- */
56
- const switchLocalePath = (path: string, newLocale: string, locales: string[] = [], defaultLocale?: string): string => {
57
- const segments = path.split('/').filter(Boolean)
7
+ /** A dynamic segment by its name: `[slug]` and `[...slug]` are `slug`, as a page dictionary's folder names them. */
8
+ const PARAM = /\[(?:\.\.\.)?([^\]]+)\]/g
58
9
 
59
- // Remove existing locale if present
60
- const firstSegment = segments[0]
61
- if (firstSegment && locales.includes(firstSegment)) {
62
- segments.shift()
63
- }
64
-
65
- // Add new locale if not default or if default should be included
66
- if (newLocale !== defaultLocale || defaultLocale === undefined) {
67
- segments.unshift(newLocale)
68
- }
69
-
70
- return `/${segments.join('/')}`
71
- }
72
-
73
- /**
74
- * Localize path with locale prefix
75
- */
76
- const localizePath = (path: string, locale: string, locales: string[] = [], defaultLocale?: string): string => {
77
- const cleanPath = path.replace(/^\//, '').replace(/\/$/, '') || ''
78
- const segments = cleanPath.split('/').filter(Boolean)
10
+ function segmentsOf(path: string, locales: readonly string[]): string[] {
11
+ const segments = pathSegments(urlPath(path))
12
+ if (segments[0] !== undefined && locales.includes(segments[0])) segments.shift()
13
+ return segments
14
+ }
79
15
 
80
- // Remove existing locale if present
81
- const firstSegment = segments[0]
82
- if (firstSegment && locales.includes(firstSegment)) {
83
- segments.shift()
84
- }
16
+ /** The query and the hash of `path`, as they are. */
17
+ const extrasOf = (path: string): string => path.slice(urlPath(path).length)
85
18
 
86
- // Add locale if not default or if defaultLocale should be included
87
- if (locale !== defaultLocale || defaultLocale === undefined) {
88
- segments.unshift(locale)
89
- }
19
+ /** `/en/blog/post` → `blog-post`, `/blog/[slug]` → `blog-slug`, `/` → `index`. */
20
+ export function routeNameFromPath(path: string, locales: readonly string[] = []): string {
21
+ const segments = segmentsOf(path, locales)
22
+ if (segments.length === 0) return 'index'
23
+ const name = segments.join('-')
24
+ return name.includes('[') ? name.replace(PARAM, '$1') : name
25
+ }
90
26
 
91
- return `/${segments.join('/')}`
92
- }
27
+ const removeLocale = (path: string, locales: readonly string[] = []): string => `/${segmentsOf(path, locales).join('/')}${extrasOf(path)}`
93
28
 
94
- /**
95
- * Remove locale from path
96
- */
97
- const removeLocaleFromPath = (path: string, locales: string[] = []): string => {
98
- const segments = path.split('/').filter(Boolean)
99
- const firstSegment = segments[0]
100
- if (firstSegment && locales.includes(firstSegment)) {
101
- segments.shift()
102
- }
103
- return `/${segments.join('/')}`
104
- }
29
+ /** `path` in `locale`: no prefix for the default locale, or for every locale when no default is given and `locale` is empty. */
30
+ function addLocale(path: string, locale: string, locales: readonly string[] = [], defaultLocale?: string): string {
31
+ const segments = segmentsOf(path, locales)
32
+ if (locale !== defaultLocale) segments.unshift(locale)
33
+ return `/${segments.join('/')}${extrasOf(path)}`
34
+ }
105
35
 
106
- /**
107
- * Resolve path for specific locale
108
- */
109
- const resolvePath = (to: string | { path?: string }, locale: string): string | { path?: string } => {
110
- const path = typeof to === 'string' ? to : to.path || '/'
111
- return localizePath(path, locale, localeCodes, defaultLocale)
112
- }
36
+ /**
37
+ * Prefix-except-default routing over plain paths. Paths here never carry Astro's `base`: the
38
+ * middleware strips it from what comes in and adds it to what goes out.
39
+ */
40
+ export function createAstroRouterAdapter(locales: Locale[], defaultLocale: string, getCurrentUrl?: () => URL): I18nRoutingStrategy {
41
+ const localeCodes = locales.map((locale) => locale.code)
42
+ const currentUrl = () => getCurrentUrl?.() ?? (typeof window !== 'undefined' ? new URL(window.location.href) : null)
113
43
 
114
44
  return {
115
- getCurrentPath: () => {
116
- // Use provided URL getter (from Astro.url or context.url)
117
- if (getCurrentUrl) {
118
- return getCurrentUrl().pathname
119
- }
120
- // Fallback for client-side islands
121
- if (typeof window !== 'undefined') {
122
- return window.location.pathname
123
- }
124
- return '/'
125
- },
126
- getRouteName,
127
- getLocaleFromPath,
128
- switchLocalePath,
129
- localizePath,
130
- removeLocaleFromPath,
131
- resolvePath,
45
+ getCurrentPath: () => currentUrl()?.pathname ?? '/',
46
+ // the pattern names one route for every URL it matches: `/blog/hello` is `blog-slug`
47
+ getRouteName: (path, codes = [], pattern) => routeNameFromPath(pattern ?? path, codes),
48
+ getLocaleFromPath: (path, fallback = 'en', codes = []) => localeFromPath(`/${trimLeadingSlashes(path)}`, codes) ?? fallback,
49
+ switchLocalePath: addLocale,
50
+ localizePath: addLocale,
51
+ removeLocaleFromPath: removeLocale,
52
+ resolvePath: (to, locale) => addLocale(typeof to === 'string' ? to : to.path || '/', locale, localeCodes, defaultLocale),
132
53
  getRoute: () => {
133
- // Use provided URL getter (from Astro.url or context.url)
134
- if (getCurrentUrl) {
135
- const url = getCurrentUrl()
136
- return {
137
- fullPath: url.pathname + url.search,
138
- query: Object.fromEntries(url.searchParams),
139
- }
140
- }
141
- // Fallback for client-side islands
142
- if (typeof window !== 'undefined') {
143
- const url = new URL(window.location.href)
144
- return {
145
- fullPath: url.pathname + url.search,
146
- query: Object.fromEntries(url.searchParams),
147
- }
148
- }
149
- return {
150
- fullPath: '/',
151
- query: {},
152
- }
54
+ const url = currentUrl()
55
+ return url ? { fullPath: url.pathname + url.search, query: Object.fromEntries(url.searchParams) } : { fullPath: '/', query: {} }
153
56
  },
154
- // Optional: client-side navigation for islands
155
- push: (target: { path: string }) => {
156
- if (typeof window !== 'undefined') {
157
- window.location.href = target.path
158
- }
57
+ push: (target) => {
58
+ if (typeof window !== 'undefined') window.location.href = target.path
159
59
  },
160
- replace: (target: { path: string }) => {
161
- if (typeof window !== 'undefined') {
162
- window.location.replace(target.path)
163
- }
60
+ replace: (target) => {
61
+ if (typeof window !== 'undefined') window.location.replace(target.path)
164
62
  },
165
63
  }
166
64
  }
@@ -18,9 +18,10 @@ export interface I18nRoutingStrategy {
18
18
 
19
19
  /**
20
20
  * Get route name from path (e.g., /en/about -> about)
21
- * Used in middleware to set route name
21
+ * Used in middleware to set route name. `routePattern` is the page's pattern (`/blog/[slug]`), with
22
+ * a `[locale]` param already replaced by its value: use it to give every URL of a page one name.
22
23
  */
23
- getRouteName?: (path: string, locales: string[]) => string
24
+ getRouteName?: (path: string, locales: string[], routePattern?: string) => string
24
25
 
25
26
  /**
26
27
  * Get locale from path