@pyreon/zero 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.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/lib/cache.js +80 -0
  4. package/lib/cache.js.map +1 -0
  5. package/lib/client.js +58 -0
  6. package/lib/client.js.map +1 -0
  7. package/lib/config.js +35 -0
  8. package/lib/config.js.map +1 -0
  9. package/lib/font.js +251 -0
  10. package/lib/font.js.map +1 -0
  11. package/lib/fs-router-BkbIWqek.js +30 -0
  12. package/lib/fs-router-BkbIWqek.js.map +1 -0
  13. package/lib/fs-router-jfd1QGLB.js +261 -0
  14. package/lib/fs-router-jfd1QGLB.js.map +1 -0
  15. package/lib/image-plugin.js +289 -0
  16. package/lib/image-plugin.js.map +1 -0
  17. package/lib/image.js +113 -0
  18. package/lib/image.js.map +1 -0
  19. package/lib/index.js +1665 -0
  20. package/lib/index.js.map +1 -0
  21. package/lib/link.js +186 -0
  22. package/lib/link.js.map +1 -0
  23. package/lib/script.js +102 -0
  24. package/lib/script.js.map +1 -0
  25. package/lib/seo.js +136 -0
  26. package/lib/seo.js.map +1 -0
  27. package/lib/theme.js +165 -0
  28. package/lib/theme.js.map +1 -0
  29. package/lib/types/adapters/bun.d.ts +6 -0
  30. package/lib/types/adapters/bun.d.ts.map +1 -0
  31. package/lib/types/adapters/index.d.ts +10 -0
  32. package/lib/types/adapters/index.d.ts.map +1 -0
  33. package/lib/types/adapters/node.d.ts +6 -0
  34. package/lib/types/adapters/node.d.ts.map +1 -0
  35. package/lib/types/adapters/static.d.ts +7 -0
  36. package/lib/types/adapters/static.d.ts.map +1 -0
  37. package/lib/types/app.d.ts +24 -0
  38. package/lib/types/app.d.ts.map +1 -0
  39. package/lib/types/cache.d.ts +54 -0
  40. package/lib/types/cache.d.ts.map +1 -0
  41. package/lib/types/client.d.ts +19 -0
  42. package/lib/types/client.d.ts.map +1 -0
  43. package/lib/types/config.d.ts +18 -0
  44. package/lib/types/config.d.ts.map +1 -0
  45. package/lib/types/entry-server.d.ts +26 -0
  46. package/lib/types/entry-server.d.ts.map +1 -0
  47. package/lib/types/font.d.ts +119 -0
  48. package/lib/types/font.d.ts.map +1 -0
  49. package/lib/types/fs-router.d.ts +33 -0
  50. package/lib/types/fs-router.d.ts.map +1 -0
  51. package/lib/types/image-plugin.d.ts +79 -0
  52. package/lib/types/image-plugin.d.ts.map +1 -0
  53. package/lib/types/image.d.ts +50 -0
  54. package/lib/types/image.d.ts.map +1 -0
  55. package/lib/types/index.d.ts +27 -0
  56. package/lib/types/index.d.ts.map +1 -0
  57. package/lib/types/isr.d.ts +9 -0
  58. package/lib/types/isr.d.ts.map +1 -0
  59. package/lib/types/link.d.ts +116 -0
  60. package/lib/types/link.d.ts.map +1 -0
  61. package/lib/types/script.d.ts +34 -0
  62. package/lib/types/script.d.ts.map +1 -0
  63. package/lib/types/seo.d.ts +88 -0
  64. package/lib/types/seo.d.ts.map +1 -0
  65. package/lib/types/theme.d.ts +38 -0
  66. package/lib/types/theme.d.ts.map +1 -0
  67. package/lib/types/types.d.ts +104 -0
  68. package/lib/types/types.d.ts.map +1 -0
  69. package/lib/types/utils/use-intersection-observer.d.ts +10 -0
  70. package/lib/types/utils/use-intersection-observer.d.ts.map +1 -0
  71. package/lib/types/utils/with-headers.d.ts +6 -0
  72. package/lib/types/utils/with-headers.d.ts.map +1 -0
  73. package/lib/types/vite-plugin.d.ts +17 -0
  74. package/lib/types/vite-plugin.d.ts.map +1 -0
  75. package/package.json +100 -0
  76. package/src/adapters/bun.ts +65 -0
  77. package/src/adapters/index.ts +29 -0
  78. package/src/adapters/node.ts +113 -0
  79. package/src/adapters/static.ts +17 -0
  80. package/src/app.ts +62 -0
  81. package/src/cache.ts +149 -0
  82. package/src/client.ts +43 -0
  83. package/src/config.ts +36 -0
  84. package/src/entry-server.ts +51 -0
  85. package/src/font.ts +461 -0
  86. package/src/fs-router.ts +380 -0
  87. package/src/image-plugin.ts +452 -0
  88. package/src/image.tsx +167 -0
  89. package/src/index.ts +119 -0
  90. package/src/isr.ts +95 -0
  91. package/src/link.tsx +266 -0
  92. package/src/script.tsx +133 -0
  93. package/src/seo.ts +281 -0
  94. package/src/sharp.d.ts +20 -0
  95. package/src/theme.tsx +162 -0
  96. package/src/types.ts +130 -0
  97. package/src/utils/use-intersection-observer.ts +36 -0
  98. package/src/utils/with-headers.ts +16 -0
  99. package/src/vite-plugin.ts +92 -0
package/src/seo.ts ADDED
@@ -0,0 +1,281 @@
1
+ import type { Middleware } from '@pyreon/server'
2
+ import type { Plugin } from 'vite'
3
+
4
+ // ─── SEO utilities ──────────────────────────────────────────────────────────
5
+ //
6
+ // Zero provides built-in SEO tooling:
7
+ // - Automatic sitemap.xml generation from file-based routes
8
+ // - Configurable robots.txt
9
+ // - Structured data (JSON-LD) helpers
10
+ // - Open Graph / Twitter Card meta helpers
11
+
12
+ export interface SitemapConfig {
13
+ /** Base URL of the site (required). e.g. "https://example.com" */
14
+ origin: string
15
+ /** Paths to exclude from the sitemap. */
16
+ exclude?: string[]
17
+ /** Default change frequency. Default: "weekly" */
18
+ changefreq?: ChangeFreq
19
+ /** Default priority. Default: 0.7 */
20
+ priority?: number
21
+ /** Additional URLs to include (for dynamic routes). */
22
+ additionalPaths?: SitemapEntry[]
23
+ }
24
+
25
+ export interface SitemapEntry {
26
+ path: string
27
+ changefreq?: ChangeFreq
28
+ priority?: number
29
+ lastmod?: string
30
+ }
31
+
32
+ export type ChangeFreq =
33
+ | 'always'
34
+ | 'hourly'
35
+ | 'daily'
36
+ | 'weekly'
37
+ | 'monthly'
38
+ | 'yearly'
39
+ | 'never'
40
+
41
+ /**
42
+ * Generate a sitemap.xml string from route file paths.
43
+ */
44
+ export function generateSitemap(
45
+ routeFiles: string[],
46
+ config: SitemapConfig,
47
+ ): string {
48
+ const { origin, exclude = [], changefreq = 'weekly', priority = 0.7 } = config
49
+
50
+ const paths = routeFiles
51
+ .filter((f) => {
52
+ // Exclude layout, error, loading files
53
+ const name = f
54
+ .split('/')
55
+ .pop()
56
+ ?.replace(/\.\w+$/, '')
57
+ return name !== '_layout' && name !== '_error' && name !== '_loading'
58
+ })
59
+ .map((f) => {
60
+ // Convert file path to URL
61
+ let path = f
62
+ .replace(/\.\w+$/, '')
63
+ .replace(/\/index$/, '/')
64
+ .replace(/^index$/, '/')
65
+
66
+ // Skip dynamic routes — they need additionalPaths
67
+ if (path.includes('[')) return null
68
+
69
+ // Strip route groups
70
+ path = path.replace(/\([\w-]+\)\//g, '')
71
+
72
+ if (!path.startsWith('/')) path = `/${path}`
73
+ return path
74
+ })
75
+ .filter((p): p is string => p !== null)
76
+ .filter((p) => !exclude.some((e) => p.startsWith(e)))
77
+
78
+ const allPaths: SitemapEntry[] = [
79
+ ...paths.map((p) => ({ path: p, changefreq, priority })),
80
+ ...(config.additionalPaths ?? []),
81
+ ]
82
+
83
+ const entries = allPaths
84
+ .map((entry) => {
85
+ const loc = `${origin}${entry.path === '/' ? '' : entry.path}`
86
+ return ` <url>
87
+ <loc>${escapeXml(loc)}</loc>
88
+ <changefreq>${entry.changefreq ?? changefreq}</changefreq>
89
+ <priority>${entry.priority ?? priority}</priority>${entry.lastmod ? `\n <lastmod>${entry.lastmod}</lastmod>` : ''}
90
+ </url>`
91
+ })
92
+ .join('\n')
93
+
94
+ return `<?xml version="1.0" encoding="UTF-8"?>
95
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
96
+ ${entries}
97
+ </urlset>`
98
+ }
99
+
100
+ function escapeXml(str: string): string {
101
+ return str
102
+ .replace(/&/g, '&amp;')
103
+ .replace(/</g, '&lt;')
104
+ .replace(/>/g, '&gt;')
105
+ .replace(/"/g, '&quot;')
106
+ .replace(/'/g, '&apos;')
107
+ }
108
+
109
+ // ─── Robots.txt ─────────────────────────────────────────────────────────────
110
+
111
+ export interface RobotsConfig {
112
+ /** Rules per user-agent. */
113
+ rules?: RobotsRule[]
114
+ /** Sitemap URL. */
115
+ sitemap?: string
116
+ /** Host directive. */
117
+ host?: string
118
+ }
119
+
120
+ export interface RobotsRule {
121
+ userAgent: string
122
+ allow?: string[]
123
+ disallow?: string[]
124
+ crawlDelay?: number
125
+ }
126
+
127
+ /**
128
+ * Generate a robots.txt string.
129
+ */
130
+ export function generateRobots(config: RobotsConfig = {}): string {
131
+ const { rules = [{ userAgent: '*', allow: ['/'] }], sitemap, host } = config
132
+ const lines: string[] = []
133
+
134
+ for (const rule of rules) {
135
+ lines.push(`User-agent: ${rule.userAgent}`)
136
+ if (rule.allow) {
137
+ for (const path of rule.allow) lines.push(`Allow: ${path}`)
138
+ }
139
+ if (rule.disallow) {
140
+ for (const path of rule.disallow) lines.push(`Disallow: ${path}`)
141
+ }
142
+ if (rule.crawlDelay) lines.push(`Crawl-delay: ${rule.crawlDelay}`)
143
+ lines.push('')
144
+ }
145
+
146
+ if (sitemap) lines.push(`Sitemap: ${sitemap}`)
147
+ if (host) lines.push(`Host: ${host}`)
148
+
149
+ return lines.join('\n')
150
+ }
151
+
152
+ // ─── Structured data (JSON-LD) ──────────────────────────────────────────────
153
+
154
+ export type JsonLdType =
155
+ | 'WebSite'
156
+ | 'WebPage'
157
+ | 'Article'
158
+ | 'BlogPosting'
159
+ | 'Product'
160
+ | 'Organization'
161
+ | 'Person'
162
+ | 'BreadcrumbList'
163
+ | 'FAQPage'
164
+ | (string & {})
165
+
166
+ /**
167
+ * Generate a JSON-LD script tag string for structured data.
168
+ *
169
+ * @example
170
+ * useHead({
171
+ * script: [jsonLd({
172
+ * "@type": "WebSite",
173
+ * name: "My Site",
174
+ * url: "https://example.com",
175
+ * })],
176
+ * })
177
+ */
178
+ export function jsonLd(data: Record<string, unknown>): string {
179
+ const ld = {
180
+ '@context': 'https://schema.org',
181
+ ...data,
182
+ }
183
+ return `<script type="application/ld+json">${JSON.stringify(ld)}</script>`
184
+ }
185
+
186
+ // ─── SEO Vite plugin ────────────────────────────────────────────────────────
187
+
188
+ export interface SeoPluginConfig {
189
+ /** Sitemap configuration. */
190
+ sitemap?: SitemapConfig
191
+ /** Robots.txt configuration. */
192
+ robots?: RobotsConfig
193
+ }
194
+
195
+ /**
196
+ * Zero SEO Vite plugin.
197
+ * Generates sitemap.xml and robots.txt at build time.
198
+ *
199
+ * @example
200
+ * import { seoPlugin } from "@pyreon/zero/seo"
201
+ *
202
+ * export default {
203
+ * plugins: [
204
+ * pyreon(),
205
+ * zero(),
206
+ * seoPlugin({
207
+ * sitemap: { origin: "https://example.com" },
208
+ * robots: { sitemap: "https://example.com/sitemap.xml" },
209
+ * }),
210
+ * ],
211
+ * }
212
+ */
213
+ export function seoPlugin(config: SeoPluginConfig = {}): Plugin {
214
+ return {
215
+ name: 'pyreon-zero-seo',
216
+ apply: 'build',
217
+
218
+ async generateBundle(_, _bundle) {
219
+ // Generate sitemap.xml
220
+ if (config.sitemap) {
221
+ const { scanRouteFiles } = await import('./fs-router')
222
+ const routesDir = `${process.cwd()}/src/routes`
223
+
224
+ try {
225
+ const files = await scanRouteFiles(routesDir)
226
+ const sitemap = generateSitemap(files, config.sitemap)
227
+
228
+ this.emitFile({
229
+ type: 'asset',
230
+ fileName: 'sitemap.xml',
231
+ source: sitemap,
232
+ })
233
+ } catch {
234
+ // Sitemap generation failed — skip silently
235
+ }
236
+ }
237
+
238
+ // Generate robots.txt
239
+ if (config.robots) {
240
+ const robots = generateRobots(config.robots)
241
+
242
+ this.emitFile({
243
+ type: 'asset',
244
+ fileName: 'robots.txt',
245
+ source: robots,
246
+ })
247
+ }
248
+ },
249
+ }
250
+ }
251
+
252
+ // ─── SEO middleware (serve sitemap/robots in dev) ────────────────────────────
253
+
254
+ /**
255
+ * SEO middleware for dev server.
256
+ * Serves sitemap.xml and robots.txt dynamically during development.
257
+ */
258
+ export function seoMiddleware(config: SeoPluginConfig = {}): Middleware {
259
+ return async (ctx) => {
260
+ if (ctx.url.pathname === '/robots.txt' && config.robots) {
261
+ return new Response(generateRobots(config.robots), {
262
+ headers: { 'Content-Type': 'text/plain' },
263
+ })
264
+ }
265
+
266
+ if (ctx.url.pathname === '/sitemap.xml' && config.sitemap) {
267
+ try {
268
+ const { scanRouteFiles } = await import('./fs-router')
269
+ const routesDir = `${process.cwd()}/src/routes`
270
+ const files = await scanRouteFiles(routesDir)
271
+ const sitemap = generateSitemap(files, config.sitemap)
272
+
273
+ return new Response(sitemap, {
274
+ headers: { 'Content-Type': 'application/xml' },
275
+ })
276
+ } catch {
277
+ // Sitemap generation failed — continue to rendering
278
+ }
279
+ }
280
+ }
281
+ }
package/src/sharp.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ declare module 'sharp' {
2
+ interface SharpInstance {
3
+ resize(
4
+ width: number,
5
+ height?: number,
6
+ options?: { fit?: string },
7
+ ): SharpInstance
8
+ webp(options?: { quality?: number }): SharpInstance
9
+ avif(options?: { quality?: number }): SharpInstance
10
+ jpeg(options?: { quality?: number; mozjpeg?: boolean }): SharpInstance
11
+ png(options?: { compressionLevel?: number }): SharpInstance
12
+ blur(sigma?: number): SharpInstance
13
+ toFile(path: string): Promise<void>
14
+ toBuffer(): Promise<Buffer>
15
+ metadata(): Promise<{ width?: number; height?: number; format?: string }>
16
+ }
17
+
18
+ function sharp(input: string | Buffer): SharpInstance
19
+ export default sharp
20
+ }
package/src/theme.tsx ADDED
@@ -0,0 +1,162 @@
1
+ import { onMount, onUnmount } from '@pyreon/core'
2
+ import { effect, signal } from '@pyreon/reactivity'
3
+
4
+ // ─── Theme system ───────────────────────────────────────────────────────────
5
+ //
6
+ // Provides dark/light/system theme support with:
7
+ // - System preference detection via matchMedia
8
+ // - Persistent preference via localStorage
9
+ // - No flash of wrong theme (inline script in HTML)
10
+ // - Reactive theme signal for components
11
+
12
+ export type Theme = 'light' | 'dark' | 'system'
13
+
14
+ const STORAGE_KEY = 'zero-theme'
15
+
16
+ /** Reactive theme signal. */
17
+ export const theme = signal<Theme>('system')
18
+
19
+ /** Computed resolved theme (what's actually applied). */
20
+ export function resolvedTheme(): 'light' | 'dark' {
21
+ const t = theme()
22
+ if (t === 'system') {
23
+ if (typeof window === 'undefined') return 'dark'
24
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
25
+ ? 'dark'
26
+ : 'light'
27
+ }
28
+ return t
29
+ }
30
+
31
+ /** Toggle between light and dark. */
32
+ export function toggleTheme() {
33
+ const current = resolvedTheme()
34
+ setTheme(current === 'dark' ? 'light' : 'dark')
35
+ }
36
+
37
+ /** Set theme explicitly. */
38
+ export function setTheme(t: Theme) {
39
+ theme.set(t)
40
+ if (typeof document !== 'undefined') {
41
+ document.documentElement.dataset.theme = resolvedTheme()
42
+ try {
43
+ localStorage.setItem(STORAGE_KEY, t)
44
+ } catch {
45
+ // localStorage may not be available (SSR, private browsing)
46
+ }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Initialize the theme system. Call once in your app entry or layout.
52
+ * Reads from localStorage, listens for system preference changes.
53
+ */
54
+ export function initTheme() {
55
+ onMount(() => {
56
+ // Read persisted preference
57
+ try {
58
+ const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
59
+ if (stored === 'light' || stored === 'dark' || stored === 'system') {
60
+ theme.set(stored)
61
+ }
62
+ } catch {
63
+ // localStorage may not be available
64
+ }
65
+
66
+ // Apply to document
67
+ document.documentElement.dataset.theme = resolvedTheme()
68
+
69
+ // Watch for system preference changes
70
+ const mq = window.matchMedia('(prefers-color-scheme: dark)')
71
+ function onChange() {
72
+ if (theme() === 'system') {
73
+ document.documentElement.dataset.theme = resolvedTheme()
74
+ }
75
+ }
76
+ mq.addEventListener('change', onChange)
77
+ onUnmount(() => mq.removeEventListener('change', onChange))
78
+
79
+ // Re-apply when theme signal changes
80
+ const dispose = effect(() => {
81
+ document.documentElement.dataset.theme = resolvedTheme()
82
+ })
83
+ if (dispose) onUnmount(() => dispose.dispose())
84
+
85
+ return undefined
86
+ })
87
+ }
88
+
89
+ /**
90
+ * Theme toggle button component.
91
+ *
92
+ * @example
93
+ * import { ThemeToggle } from "@pyreon/zero/theme"
94
+ * <ThemeToggle />
95
+ */
96
+ export function ThemeToggle(props: { class?: string; style?: string }) {
97
+ initTheme()
98
+
99
+ return (
100
+ <button
101
+ class={props.class}
102
+ style={props.style}
103
+ onclick={toggleTheme}
104
+ aria-label="Toggle theme"
105
+ title="Toggle theme"
106
+ type="button"
107
+ >
108
+ {() =>
109
+ resolvedTheme() === 'dark' ? (
110
+ <svg
111
+ width="18"
112
+ height="18"
113
+ viewBox="0 0 24 24"
114
+ fill="none"
115
+ stroke="currentColor"
116
+ stroke-width="2"
117
+ stroke-linecap="round"
118
+ stroke-linejoin="round"
119
+ aria-hidden="true"
120
+ >
121
+ <circle cx="12" cy="12" r="5" />
122
+ <line x1="12" y1="1" x2="12" y2="3" />
123
+ <line x1="12" y1="21" x2="12" y2="23" />
124
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
125
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
126
+ <line x1="1" y1="12" x2="3" y2="12" />
127
+ <line x1="21" y1="12" x2="23" y2="12" />
128
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
129
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
130
+ </svg>
131
+ ) : (
132
+ <svg
133
+ width="18"
134
+ height="18"
135
+ viewBox="0 0 24 24"
136
+ fill="none"
137
+ stroke="currentColor"
138
+ stroke-width="2"
139
+ stroke-linecap="round"
140
+ stroke-linejoin="round"
141
+ aria-hidden="true"
142
+ >
143
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
144
+ </svg>
145
+ )
146
+ }
147
+ </button>
148
+ )
149
+ }
150
+
151
+ /**
152
+ * Inline script to prevent flash of wrong theme.
153
+ * Include this in your index.html <head> BEFORE any stylesheets.
154
+ *
155
+ * @example
156
+ * // index.html
157
+ * <head>
158
+ * <script>{themeScript}</script>
159
+ * ...
160
+ * </head>
161
+ */
162
+ export const themeScript = `(function(){try{var t=localStorage.getItem("${STORAGE_KEY}");var r=t==="light"?"light":t==="dark"?"dark":window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";document.documentElement.dataset.theme=r}catch(e){}})()`
package/src/types.ts ADDED
@@ -0,0 +1,130 @@
1
+ import type { ComponentFn } from '@pyreon/core'
2
+ import type { NavigationGuard } from '@pyreon/router'
3
+ import type { Middleware } from '@pyreon/server'
4
+
5
+ // ─── Route module conventions ────────────────────────────────────────────────
6
+
7
+ /** What a route file (e.g. `src/routes/index.tsx`) can export. */
8
+ export interface RouteModule {
9
+ /** Default export is the page component. */
10
+ default?: ComponentFn
11
+ /** Layout wrapper — wraps this route and all children. */
12
+ layout?: ComponentFn
13
+ /** Loading component shown while lazy-loading or during Suspense. */
14
+ loading?: ComponentFn
15
+ /** Error component shown when the route errors. */
16
+ error?: ComponentFn
17
+ /** Server-side data loader. */
18
+ loader?: (ctx: LoaderContext) => Promise<unknown>
19
+ /** Per-route middleware. */
20
+ middleware?: Middleware | Middleware[]
21
+ /** Navigation guard — can redirect or block navigation. */
22
+ guard?: NavigationGuard
23
+ /** Route metadata. */
24
+ meta?: RouteMeta
25
+ /** Rendering mode override for this route. */
26
+ renderMode?: RenderMode
27
+ }
28
+
29
+ /** Context passed to route loaders. */
30
+ export interface LoaderContext {
31
+ params: Record<string, string>
32
+ query: Record<string, string>
33
+ signal: AbortSignal
34
+ request: Request
35
+ }
36
+
37
+ /** Per-route metadata. */
38
+ export interface RouteMeta {
39
+ title?: string
40
+ description?: string
41
+ [key: string]: unknown
42
+ }
43
+
44
+ // ─── Rendering modes ─────────────────────────────────────────────────────────
45
+
46
+ export type RenderMode = 'ssr' | 'ssg' | 'spa' | 'isr'
47
+
48
+ export interface ISRConfig {
49
+ /** Revalidation interval in seconds. */
50
+ revalidate: number
51
+ }
52
+
53
+ // ─── Zero config ─────────────────────────────────────────────────────────────
54
+
55
+ export interface ZeroConfig {
56
+ /** Default rendering mode. Default: "ssr" */
57
+ mode?: RenderMode
58
+
59
+ /** Vite config overrides. */
60
+ vite?: Record<string, unknown>
61
+
62
+ /** SSR options. */
63
+ ssr?: {
64
+ /** Streaming mode. Default: "string" */
65
+ mode?: 'string' | 'stream'
66
+ }
67
+
68
+ /** SSG options — only used when mode is "ssg". */
69
+ ssg?: {
70
+ /** Paths to prerender (or function returning paths). */
71
+ paths?: string[] | (() => string[] | Promise<string[]>)
72
+ }
73
+
74
+ /** ISR config — only used when mode is "isr". */
75
+ isr?: ISRConfig
76
+
77
+ /** Deploy adapter. Default: "node" */
78
+ adapter?: 'node' | 'bun' | 'static'
79
+
80
+ /** Base URL path. Default: "/" */
81
+ base?: string
82
+
83
+ /** App-level middleware applied to all routes. */
84
+ middleware?: Middleware[]
85
+
86
+ /** Server port for dev/preview. Default: 3000 */
87
+ port?: number
88
+ }
89
+
90
+ // ─── File-system route ───────────────────────────────────────────────────────
91
+
92
+ /** Internal representation of a file-system route before conversion to RouteRecord. */
93
+ export interface FileRoute {
94
+ /** File path relative to routes dir (e.g. "users/[id].tsx") */
95
+ filePath: string
96
+ /** Parsed URL path pattern (e.g. "/users/:id") */
97
+ urlPath: string
98
+ /** Directory path for grouping (e.g. "users" or "" for root) */
99
+ dirPath: string
100
+ /** Route segment depth for nesting. */
101
+ depth: number
102
+ /** Whether this is a layout file. */
103
+ isLayout: boolean
104
+ /** Whether this is an error boundary file. */
105
+ isError: boolean
106
+ /** Whether this is a loading fallback file. */
107
+ isLoading: boolean
108
+ /** Whether this is a catch-all route. */
109
+ isCatchAll: boolean
110
+ /** Resolved rendering mode. */
111
+ renderMode: RenderMode
112
+ }
113
+
114
+ // ─── Adapter ─────────────────────────────────────────────────────────────────
115
+
116
+ export interface Adapter {
117
+ name: string
118
+ /** Build the production server/output for this adapter. */
119
+ build(options: AdapterBuildOptions): Promise<void>
120
+ }
121
+
122
+ export interface AdapterBuildOptions {
123
+ /** Path to the built server entry. */
124
+ serverEntry: string
125
+ /** Path to the client build output. */
126
+ clientOutDir: string
127
+ /** Final output directory. */
128
+ outDir: string
129
+ config: ZeroConfig
130
+ }
@@ -0,0 +1,36 @@
1
+ import { onMount, onUnmount } from '@pyreon/core'
2
+
3
+ /**
4
+ * Observes an element and calls `onIntersect` once it enters the viewport.
5
+ * Automatically disconnects after the first intersection.
6
+ *
7
+ * @param getElement - Getter for the target element (may be undefined before mount).
8
+ * @param onIntersect - Callback fired when the element becomes visible.
9
+ * @param rootMargin - IntersectionObserver rootMargin. Default: "200px".
10
+ */
11
+ export function useIntersectionObserver(
12
+ getElement: () => HTMLElement | undefined,
13
+ onIntersect: () => void,
14
+ rootMargin = '200px',
15
+ ) {
16
+ onMount(() => {
17
+ const el = getElement()
18
+ if (!el) return undefined
19
+
20
+ const observer = new IntersectionObserver(
21
+ (entries) => {
22
+ for (const entry of entries) {
23
+ if (entry.isIntersecting) {
24
+ onIntersect()
25
+ observer.disconnect()
26
+ }
27
+ }
28
+ },
29
+ { rootMargin },
30
+ )
31
+
32
+ observer.observe(el)
33
+ onUnmount(() => observer.disconnect())
34
+ return undefined
35
+ })
36
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Clone a Response with modified headers.
3
+ * Avoids repeating the `new Response(body, { status, statusText, headers })` pattern.
4
+ */
5
+ export function withHeaders(
6
+ response: Response,
7
+ modify: (headers: Headers) => void,
8
+ ): Response {
9
+ const headers = new Headers(response.headers)
10
+ modify(headers)
11
+ return new Response(response.body, {
12
+ status: response.status,
13
+ statusText: response.statusText,
14
+ headers,
15
+ })
16
+ }