@pyreon/zero 0.11.8 → 0.11.9

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.
@@ -0,0 +1,299 @@
1
+ import { createContext } from '@pyreon/core'
2
+ import { signal } from '@pyreon/reactivity'
3
+ import type { Plugin } from 'vite'
4
+
5
+ // ─── Localized routing ─────────────────────────────────────────────────────
6
+ //
7
+ // Adds locale-prefixed routes to Zero's file-system router:
8
+ // - /about → /en/about, /de/about, /cs/about
9
+ // - / → /en, /de, /cs (or default locale without prefix)
10
+ // - Automatic locale detection from Accept-Language header
11
+ // - Redirect to preferred locale
12
+ // - hreflang link generation
13
+ //
14
+ // Usage:
15
+ // import { i18nRouting } from "@pyreon/zero"
16
+ // export default { plugins: [zero(), i18nRouting({ locales: ["en", "de"], defaultLocale: "en" })] }
17
+
18
+ export interface I18nRoutingConfig {
19
+ /** Supported locales. e.g. ["en", "de", "cs"] */
20
+ locales: string[]
21
+ /** Default locale — served without prefix (/ instead of /en/). */
22
+ defaultLocale: string
23
+ /** Redirect root to detected locale. Default: true */
24
+ detectLocale?: boolean
25
+ /** Cookie name to persist locale preference. Default: "locale" */
26
+ cookieName?: string
27
+ /** URL strategy. Default: "prefix-except-default" */
28
+ strategy?: 'prefix' | 'prefix-except-default'
29
+ }
30
+
31
+ export interface LocaleContext {
32
+ /** Current locale code. e.g. "en", "de" */
33
+ locale: string
34
+ /** All supported locales. */
35
+ locales: string[]
36
+ /** Default locale. */
37
+ defaultLocale: string
38
+ /** Build a localized path. e.g. localePath("/about", "de") → "/de/about" */
39
+ localePath: (path: string, locale?: string) => string
40
+ /** Get hreflang alternates for the current path. */
41
+ alternates: () => Array<{ locale: string; url: string }>
42
+ }
43
+
44
+ /**
45
+ * Detect preferred locale from Accept-Language header.
46
+ */
47
+ export function detectLocaleFromHeader(
48
+ acceptLanguage: string | null | undefined,
49
+ locales: string[],
50
+ defaultLocale: string,
51
+ ): string {
52
+ if (!acceptLanguage) return defaultLocale
53
+
54
+ // Parse Accept-Language: en-US,en;q=0.9,de;q=0.8
55
+ const preferred = acceptLanguage
56
+ .split(',')
57
+ .map((part) => {
58
+ const [lang, q] = part.trim().split(';q=')
59
+ return {
60
+ lang: lang?.split('-')[0]?.toLowerCase() ?? '',
61
+ quality: q ? Number.parseFloat(q) : 1,
62
+ }
63
+ })
64
+ .sort((a, b) => b.quality - a.quality)
65
+
66
+ for (const { lang } of preferred) {
67
+ if (locales.includes(lang)) return lang
68
+ }
69
+
70
+ return defaultLocale
71
+ }
72
+
73
+ /**
74
+ * Extract locale from a URL path.
75
+ * Returns { locale, pathWithoutLocale }.
76
+ */
77
+ export function extractLocaleFromPath(
78
+ path: string,
79
+ locales: string[],
80
+ defaultLocale: string,
81
+ ): { locale: string; pathWithoutLocale: string } {
82
+ const segments = path.split('/').filter(Boolean)
83
+ const firstSegment = segments[0]?.toLowerCase()
84
+
85
+ if (firstSegment && locales.includes(firstSegment)) {
86
+ return {
87
+ locale: firstSegment,
88
+ pathWithoutLocale: '/' + segments.slice(1).join('/') || '/',
89
+ }
90
+ }
91
+
92
+ return { locale: defaultLocale, pathWithoutLocale: path }
93
+ }
94
+
95
+ /**
96
+ * Build a localized path.
97
+ */
98
+ export function buildLocalePath(
99
+ path: string,
100
+ locale: string,
101
+ defaultLocale: string,
102
+ strategy: 'prefix' | 'prefix-except-default',
103
+ ): string {
104
+ const clean = path === '/' ? '' : path
105
+ if (strategy === 'prefix-except-default' && locale === defaultLocale) {
106
+ return path
107
+ }
108
+ return `/${locale}${clean}`
109
+ }
110
+
111
+ /**
112
+ * Create a LocaleContext for use in components and loaders.
113
+ */
114
+ export function createLocaleContext(
115
+ locale: string,
116
+ path: string,
117
+ config: I18nRoutingConfig,
118
+ ): LocaleContext {
119
+ const strategy = config.strategy ?? 'prefix-except-default'
120
+
121
+ return {
122
+ locale,
123
+ locales: config.locales,
124
+ defaultLocale: config.defaultLocale,
125
+
126
+ localePath(targetPath: string, targetLocale?: string) {
127
+ return buildLocalePath(
128
+ targetPath,
129
+ targetLocale ?? locale,
130
+ config.defaultLocale,
131
+ strategy,
132
+ )
133
+ },
134
+
135
+ alternates() {
136
+ const { pathWithoutLocale } = extractLocaleFromPath(
137
+ path,
138
+ config.locales,
139
+ config.defaultLocale,
140
+ )
141
+ return config.locales.map((loc) => ({
142
+ locale: loc,
143
+ url: buildLocalePath(pathWithoutLocale, loc, config.defaultLocale, strategy),
144
+ }))
145
+ },
146
+ }
147
+ }
148
+
149
+ /**
150
+ * I18n routing middleware for Zero's server.
151
+ *
152
+ * - Detects locale from URL prefix or Accept-Language header
153
+ * - Redirects root to preferred locale (when detectLocale is true)
154
+ * - Sets locale context for loaders and components
155
+ *
156
+ * @example
157
+ * ```ts
158
+ * // zero.config.ts
159
+ * import { i18nRouting } from "@pyreon/zero"
160
+ *
161
+ * export default defineConfig({
162
+ * plugins: [
163
+ * i18nRouting({
164
+ * locales: ["en", "de", "cs"],
165
+ * defaultLocale: "en",
166
+ * }),
167
+ * ],
168
+ * })
169
+ * ```
170
+ */
171
+ export function i18nRouting(config: I18nRoutingConfig): Plugin {
172
+ const strategy = config.strategy ?? 'prefix-except-default'
173
+ const detectEnabled = config.detectLocale !== false
174
+ const cookieName = config.cookieName ?? 'locale'
175
+
176
+ return {
177
+ name: 'pyreon-zero-i18n-routing',
178
+
179
+ // Route duplication is NOT handled here. The fs-router's `scanRouteFiles`
180
+ // consumes the i18n config to duplicate routes per locale at build time.
181
+ // This plugin only provides: (1) the server middleware for locale detection
182
+ // and (2) the runtime hooks (useLocale, setLocale) for client-side use.
183
+ configResolved() {},
184
+
185
+ configureServer(server) {
186
+ server.middlewares.use((req, res, next) => {
187
+ const url = req.url ?? '/'
188
+
189
+ // Skip static assets
190
+ if (url.startsWith('/@') || url.startsWith('/__') || url.includes('.')) {
191
+ return next()
192
+ }
193
+
194
+ const { locale } = extractLocaleFromPath(
195
+ url,
196
+ config.locales,
197
+ config.defaultLocale,
198
+ )
199
+
200
+ // Redirect root to detected locale
201
+ if (detectEnabled && url === '/') {
202
+ const cookies = parseCookies(req.headers.cookie)
203
+ const preferredFromCookie = cookies[cookieName]
204
+ const preferredFromHeader = detectLocaleFromHeader(
205
+ req.headers['accept-language'],
206
+ config.locales,
207
+ config.defaultLocale,
208
+ )
209
+ const preferred = preferredFromCookie && config.locales.includes(preferredFromCookie)
210
+ ? preferredFromCookie
211
+ : preferredFromHeader
212
+
213
+ if (strategy === 'prefix' || preferred !== config.defaultLocale) {
214
+ res.writeHead(302, { Location: `/${preferred}/` })
215
+ res.end()
216
+ return
217
+ }
218
+ }
219
+
220
+ // Attach locale context to request for loaders
221
+ ;(req as any).__locale = locale
222
+ ;(req as any).__localeContext = createLocaleContext(locale, url, config)
223
+
224
+ // Update the module-level signal so useLocale() returns the correct value
225
+ localeSignal.set(locale)
226
+
227
+ next()
228
+ })
229
+ },
230
+ }
231
+ }
232
+
233
+ function parseCookies(header: string | undefined): Record<string, string> {
234
+ if (!header) return {}
235
+ const result: Record<string, string> = {}
236
+ for (const pair of header.split(';')) {
237
+ const [key, value] = pair.trim().split('=')
238
+ if (key && value) result[key] = decodeURIComponent(value)
239
+ }
240
+ return result
241
+ }
242
+
243
+ // ─── Reactive locale hook ───────────────────────────────────────────────────
244
+
245
+ /** @internal Context for the current locale. */
246
+ export const LocaleCtx = createContext<string>('en')
247
+
248
+ /** Current locale signal — set by the server middleware or client-side detection. */
249
+ export const localeSignal = signal('en')
250
+
251
+ /**
252
+ * Read the current locale reactively.
253
+ *
254
+ * Returns the locale signal value directly — reactive in both SSR and CSR.
255
+ * The server middleware sets `localeSignal` per-request, and client-side
256
+ * `setLocale()` updates it as well.
257
+ *
258
+ * @example
259
+ * ```tsx
260
+ * const locale = useLocale() // "en", "de", etc.
261
+ * ```
262
+ */
263
+ export function useLocale(): string {
264
+ return localeSignal()
265
+ }
266
+
267
+ /**
268
+ * Set the locale client-side and update the URL.
269
+ *
270
+ * @example
271
+ * ```tsx
272
+ * <button onClick={() => setLocale('de')}>Deutsch</button>
273
+ * ```
274
+ */
275
+ export function setLocale(
276
+ locale: string,
277
+ config: I18nRoutingConfig,
278
+ ): void {
279
+ localeSignal.set(locale)
280
+
281
+ // Persist to cookie
282
+ if (typeof document !== 'undefined') {
283
+ document.cookie = `${config.cookieName ?? 'locale'}=${locale}; path=/; max-age=31536000`
284
+ }
285
+
286
+ // Navigate to localized URL — use pushState to avoid full page reload
287
+ if (typeof window !== 'undefined') {
288
+ const strategy = config.strategy ?? 'prefix-except-default'
289
+ const { pathWithoutLocale } = extractLocaleFromPath(
290
+ window.location.pathname,
291
+ config.locales,
292
+ config.defaultLocale,
293
+ )
294
+ const newPath = buildLocalePath(pathWithoutLocale, locale, config.defaultLocale, strategy)
295
+ window.history.pushState(null, '', newPath)
296
+ // Dispatch popstate so @pyreon/router picks up the URL change
297
+ window.dispatchEvent(new PopStateEvent('popstate'))
298
+ }
299
+ }
package/src/index.ts CHANGED
@@ -1,136 +1,185 @@
1
1
  // ─── Core ─────────────────────────────────────────────────────────────────────
2
2
 
3
- export type { CreateAppOptions } from './app'
4
- export { createApp } from './app'
5
- export type { CreateServerOptions } from './entry-server'
6
- export { createServer } from './entry-server'
3
+ export type { CreateAppOptions } from "./app";
4
+ export { createApp } from "./app";
5
+ export type { CreateServerOptions } from "./entry-server";
6
+ export { createServer } from "./entry-server";
7
7
 
8
8
  // ─── Vite plugin ─────────────────────────────────────────────────────────────
9
9
 
10
- export { zeroPlugin as default } from './vite-plugin'
10
+ export { zeroPlugin as default } from "./vite-plugin";
11
11
 
12
12
  // ─── File-system routing ─────────────────────────────────────────────────────
13
13
 
14
+ export type { GenerateRouteModuleOptions } from './fs-router'
14
15
  export {
15
- filePathToUrlPath,
16
- generateMiddlewareModule,
17
- generateRouteModule,
18
- parseFileRoutes,
19
- scanRouteFiles,
16
+ filePathToUrlPath,
17
+ generateMiddlewareModule,
18
+ generateRouteModule,
19
+ parseFileRoutes,
20
+ scanRouteFiles,
20
21
  } from './fs-router'
21
22
 
22
23
  // ─── Config ──────────────────────────────────────────────────────────────────
23
24
 
24
- export { defineConfig, resolveConfig } from './config'
25
+ export { defineConfig, resolveConfig } from "./config";
25
26
 
26
27
  // ─── ISR ─────────────────────────────────────────────────────────────────────
27
28
 
28
- export { createISRHandler } from './isr'
29
+ export { createISRHandler } from "./isr";
29
30
 
30
31
  // ─── Adapters ────────────────────────────────────────────────────────────────
31
32
 
32
- export { bunAdapter, nodeAdapter, resolveAdapter, staticAdapter } from './adapters'
33
+ export {
34
+ bunAdapter,
35
+ nodeAdapter,
36
+ resolveAdapter,
37
+ staticAdapter,
38
+ } from "./adapters";
33
39
 
34
40
  // ─── Components ─────────────────────────────────────────────────────────────
35
41
 
36
- export type { ImageProps, ImageSource } from './image'
37
- export { Image } from './image'
38
- export type { LinkProps, LinkRenderProps, UseLinkReturn } from './link'
39
- export { createLink, Link, useLink } from './link'
40
- export type { ScriptProps, ScriptStrategy } from './script'
41
- export { Script } from './script'
42
+ export type { ImageProps, ImageSource } from "./image";
43
+ export { Image } from "./image";
44
+ export type { LinkProps, LinkRenderProps, UseLinkReturn } from "./link";
45
+ export { createLink, Link, prefetchRoute, useLink } from "./link";
46
+ export type { ScriptProps, ScriptStrategy } from "./script";
47
+ export { Script } from "./script";
48
+
49
+ // ─── 404 Not Found ──────────────────────────────────────────────────────────
50
+
51
+ export { render404Page } from "./not-found";
42
52
 
43
53
  // ─── Middleware ──────────────────────────────────────────────────────────────
44
54
 
45
- export type { CacheConfig, CacheRule } from './cache'
46
- export { cacheMiddleware, securityHeaders, varyEncoding } from './cache'
55
+ export type { CacheConfig, CacheRule } from "./cache";
56
+ export { cacheMiddleware, securityHeaders, varyEncoding } from "./cache";
57
+ export { compose, getContext } from "./middleware";
47
58
 
48
59
  // ─── Font optimization ─────────────────────────────────────────────────────
49
60
 
50
61
  export type {
51
- FallbackMetrics,
52
- FontConfig,
53
- FontDisplay,
54
- GoogleFontInput,
55
- GoogleFontStatic,
56
- GoogleFontVariable,
57
- LocalFont,
58
- } from './font'
59
- export { fontPlugin, fontVariables } from './font'
62
+ FallbackMetrics,
63
+ FontConfig,
64
+ FontDisplay,
65
+ GoogleFontInput,
66
+ GoogleFontStatic,
67
+ GoogleFontVariable,
68
+ LocalFont,
69
+ } from "./font";
70
+ export { fontPlugin, fontVariables } from "./font";
60
71
 
61
72
  // ─── Image processing ──────────────────────────────────────────────────────
62
73
 
63
- export type { FormatSource, ImageFormat, ImagePluginConfig, ProcessedImage } from './image-plugin'
64
- export { imagePlugin } from './image-plugin'
74
+ export type {
75
+ FormatSource,
76
+ ImageFormat,
77
+ ImagePluginConfig,
78
+ ProcessedImage,
79
+ } from "./image-plugin";
80
+ export { imagePlugin } from "./image-plugin";
65
81
 
66
82
  // ─── Theme ──────────────────────────────────────────────────────────────────
67
83
 
68
- export type { Theme } from './theme'
84
+ export type { Theme } from "./theme";
69
85
  export {
70
- initTheme,
71
- resolvedTheme,
72
- setTheme,
73
- ThemeToggle,
74
- theme,
75
- themeScript,
76
- toggleTheme,
77
- } from './theme'
86
+ initTheme,
87
+ resolvedTheme,
88
+ setTheme,
89
+ ThemeToggle,
90
+ theme,
91
+ themeScript,
92
+ toggleTheme,
93
+ } from "./theme";
78
94
 
79
95
  // ─── SEO ────────────────────────────────────────────────────────────────────
80
96
 
81
97
  export type {
82
- ChangeFreq,
83
- JsonLdType,
84
- RobotsConfig,
85
- RobotsRule,
86
- SeoPluginConfig,
87
- SitemapConfig,
88
- SitemapEntry,
89
- } from './seo'
90
- export { generateRobots, generateSitemap, jsonLd, seoMiddleware, seoPlugin } from './seo'
98
+ ChangeFreq,
99
+ JsonLdType,
100
+ RobotsConfig,
101
+ RobotsRule,
102
+ SeoPluginConfig,
103
+ SitemapConfig,
104
+ SitemapEntry,
105
+ } from "./seo";
106
+ export {
107
+ generateRobots,
108
+ generateSitemap,
109
+ jsonLd,
110
+ seoMiddleware,
111
+ seoPlugin,
112
+ } from "./seo";
91
113
 
92
114
  // ─── API routes ──────────────────────────────────────────────────────────────
93
115
 
94
116
  export type {
95
- ApiContext,
96
- ApiHandler,
97
- ApiRouteEntry,
98
- ApiRouteModule,
99
- HttpMethod,
100
- } from './api-routes'
101
- export { createApiMiddleware, generateApiRouteModule } from './api-routes'
117
+ ApiContext,
118
+ ApiHandler,
119
+ ApiRouteEntry,
120
+ ApiRouteModule,
121
+ HttpMethod,
122
+ } from "./api-routes";
123
+ export { createApiMiddleware, generateApiRouteModule } from "./api-routes";
102
124
 
103
125
  // ─── CORS ────────────────────────────────────────────────────────────────────
104
126
 
105
- export type { CorsConfig } from './cors'
106
- export { corsMiddleware } from './cors'
127
+ export type { CorsConfig } from "./cors";
128
+ export { corsMiddleware } from "./cors";
107
129
 
108
130
  // ─── Rate limiting ──────────────────────────────────────────────────────────
109
131
 
110
- export type { RateLimitConfig } from './rate-limit'
111
- export { rateLimitMiddleware } from './rate-limit'
132
+ export type { RateLimitConfig } from "./rate-limit";
133
+ export { rateLimitMiddleware } from "./rate-limit";
112
134
 
113
135
  // ─── Compression ────────────────────────────────────────────────────────────
114
136
 
115
- export type { CompressionConfig } from './compression'
116
- export { compressionMiddleware, compressResponse, isCompressible } from './compression'
137
+ export type { CompressionConfig } from "./compression";
138
+ export {
139
+ compressionMiddleware,
140
+ compressResponse,
141
+ isCompressible,
142
+ } from "./compression";
117
143
 
118
144
  // ─── Actions ─────────────────────────────────────────────────────────────────
119
145
 
120
- export type { Action, ActionContext, ActionHandler } from './actions'
121
- export { createActionMiddleware, defineAction } from './actions'
146
+ export type { Action, ActionContext, ActionHandler } from "./actions";
147
+ export { createActionMiddleware, defineAction } from "./actions";
148
+
149
+ // ─── Favicon ────────────────────────────────────────────────────────────────
150
+
151
+ export type { FaviconPluginConfig } from "./favicon";
152
+ export { faviconPlugin } from "./favicon";
153
+
154
+ // ─── Meta ───────────────────────────────────────────────────────────────────
155
+
156
+ export type { MetaProps } from "./meta";
157
+ export { buildMetaTags, Meta } from "./meta";
158
+
159
+ // ─── I18n routing ───────────────────────────────────────────────────────────
160
+
161
+ export type { I18nRoutingConfig, LocaleContext } from "./i18n-routing";
162
+ export {
163
+ buildLocalePath,
164
+ createLocaleContext,
165
+ detectLocaleFromHeader,
166
+ extractLocaleFromPath,
167
+ i18nRouting,
168
+ setLocale,
169
+ useLocale,
170
+ } from "./i18n-routing";
122
171
 
123
172
  // ─── Types ───────────────────────────────────────────────────────────────────
124
173
 
125
174
  export type {
126
- Adapter,
127
- AdapterBuildOptions,
128
- FileRoute,
129
- ISRConfig,
130
- LoaderContext,
131
- RenderMode,
132
- RouteMeta,
133
- RouteMiddlewareEntry,
134
- RouteModule,
135
- ZeroConfig,
136
- } from './types'
175
+ Adapter,
176
+ AdapterBuildOptions,
177
+ FileRoute,
178
+ ISRConfig,
179
+ LoaderContext,
180
+ RenderMode,
181
+ RouteMeta,
182
+ RouteMiddlewareEntry,
183
+ RouteModule,
184
+ ZeroConfig,
185
+ } from "./types";
package/src/link.tsx CHANGED
@@ -90,6 +90,18 @@ function doPrefetch(href: string) {
90
90
  }
91
91
  }
92
92
 
93
+ /**
94
+ * Prefetch a route's JS chunk by injecting `<link rel="prefetch">` into the
95
+ * document head. Deduplicates — calling with the same href twice is a no-op.
96
+ *
97
+ * @example
98
+ * prefetchRoute('/about')
99
+ * prefetchRoute('/dashboard')
100
+ */
101
+ export function prefetchRoute(href: string): void {
102
+ doPrefetch(href)
103
+ }
104
+
93
105
  /**
94
106
  * Composable that provides all link behavior — navigation, prefetching,
95
107
  * active state, and viewport observation.