@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/index.ts ADDED
@@ -0,0 +1,119 @@
1
+ // ─── Core ─────────────────────────────────────────────────────────────────────
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'
7
+
8
+ // ─── Vite plugin ─────────────────────────────────────────────────────────────
9
+
10
+ export { zeroPlugin as default } from './vite-plugin'
11
+
12
+ // ─── File-system routing ─────────────────────────────────────────────────────
13
+
14
+ export {
15
+ filePathToUrlPath,
16
+ generateRouteModule,
17
+ parseFileRoutes,
18
+ scanRouteFiles,
19
+ } from './fs-router'
20
+
21
+ // ─── Config ──────────────────────────────────────────────────────────────────
22
+
23
+ export { defineConfig, resolveConfig } from './config'
24
+
25
+ // ─── ISR ─────────────────────────────────────────────────────────────────────
26
+
27
+ export { createISRHandler } from './isr'
28
+
29
+ // ─── Adapters ────────────────────────────────────────────────────────────────
30
+
31
+ export {
32
+ bunAdapter,
33
+ nodeAdapter,
34
+ resolveAdapter,
35
+ staticAdapter,
36
+ } from './adapters'
37
+
38
+ // ─── Components ─────────────────────────────────────────────────────────────
39
+
40
+ export type { ImageProps, ImageSource } from './image'
41
+ export { Image } from './image'
42
+ export type { LinkProps, LinkRenderProps, UseLinkReturn } from './link'
43
+ export { createLink, Link, useLink } from './link'
44
+ export type { ScriptProps, ScriptStrategy } from './script'
45
+ export { Script } from './script'
46
+
47
+ // ─── Middleware ──────────────────────────────────────────────────────────────
48
+
49
+ export type { CacheConfig, CacheRule } from './cache'
50
+ export { cacheMiddleware, securityHeaders, varyEncoding } from './cache'
51
+
52
+ // ─── Font optimization ─────────────────────────────────────────────────────
53
+
54
+ export type {
55
+ FallbackMetrics,
56
+ FontConfig,
57
+ FontDisplay,
58
+ GoogleFontInput,
59
+ GoogleFontStatic,
60
+ GoogleFontVariable,
61
+ LocalFont,
62
+ } from './font'
63
+ export { fontPlugin, fontVariables } from './font'
64
+
65
+ // ─── Image processing ──────────────────────────────────────────────────────
66
+
67
+ export type {
68
+ FormatSource,
69
+ ImageFormat,
70
+ ImagePluginConfig,
71
+ ProcessedImage,
72
+ } from './image-plugin'
73
+ export { imagePlugin } from './image-plugin'
74
+
75
+ // ─── Theme ──────────────────────────────────────────────────────────────────
76
+
77
+ export type { Theme } from './theme'
78
+ export {
79
+ initTheme,
80
+ resolvedTheme,
81
+ setTheme,
82
+ ThemeToggle,
83
+ theme,
84
+ themeScript,
85
+ toggleTheme,
86
+ } from './theme'
87
+
88
+ // ─── SEO ────────────────────────────────────────────────────────────────────
89
+
90
+ export type {
91
+ ChangeFreq,
92
+ JsonLdType,
93
+ RobotsConfig,
94
+ RobotsRule,
95
+ SeoPluginConfig,
96
+ SitemapConfig,
97
+ SitemapEntry,
98
+ } from './seo'
99
+ export {
100
+ generateRobots,
101
+ generateSitemap,
102
+ jsonLd,
103
+ seoMiddleware,
104
+ seoPlugin,
105
+ } from './seo'
106
+
107
+ // ─── Types ───────────────────────────────────────────────────────────────────
108
+
109
+ export type {
110
+ Adapter,
111
+ AdapterBuildOptions,
112
+ FileRoute,
113
+ ISRConfig,
114
+ LoaderContext,
115
+ RenderMode,
116
+ RouteMeta,
117
+ RouteModule,
118
+ ZeroConfig,
119
+ } from './types'
package/src/isr.ts ADDED
@@ -0,0 +1,95 @@
1
+ import type { ISRConfig } from './types'
2
+
3
+ // ─── ISR Cache ───────────────────────────────────────────────────────────────
4
+
5
+ interface CacheEntry {
6
+ html: string
7
+ headers: Record<string, string>
8
+ timestamp: number
9
+ }
10
+
11
+ /**
12
+ * In-memory ISR cache with stale-while-revalidate semantics.
13
+ *
14
+ * Wraps an SSR handler and caches responses per URL path.
15
+ * Serves stale content immediately while revalidating in the background.
16
+ */
17
+ export function createISRHandler(
18
+ handler: (req: Request) => Promise<Response>,
19
+ config: ISRConfig,
20
+ ): (req: Request) => Promise<Response> {
21
+ const cache = new Map<string, CacheEntry>()
22
+ const revalidating = new Set<string>()
23
+ const revalidateMs = config.revalidate * 1000
24
+
25
+ async function revalidate(url: URL) {
26
+ const key = url.pathname
27
+ if (revalidating.has(key)) return
28
+ revalidating.add(key)
29
+
30
+ try {
31
+ const req = new Request(url.href, { method: 'GET' })
32
+ const res = await handler(req)
33
+ const html = await res.text()
34
+ const headers: Record<string, string> = {}
35
+ res.headers.forEach((v, k) => {
36
+ headers[k] = v
37
+ })
38
+
39
+ cache.set(key, { html, headers, timestamp: Date.now() })
40
+ } catch {
41
+ // Revalidation failed — stale cache entry remains valid
42
+ } finally {
43
+ revalidating.delete(key)
44
+ }
45
+ }
46
+
47
+ return async (req: Request): Promise<Response> => {
48
+ // Only cache GET requests
49
+ if (req.method !== 'GET') {
50
+ return handler(req)
51
+ }
52
+
53
+ const url = new URL(req.url)
54
+ const key = url.pathname
55
+ const entry = cache.get(key)
56
+
57
+ if (entry) {
58
+ const age = Date.now() - entry.timestamp
59
+
60
+ if (age > revalidateMs) {
61
+ // Stale — serve cached but revalidate in background
62
+ revalidate(url)
63
+ }
64
+
65
+ return new Response(entry.html, {
66
+ status: 200,
67
+ headers: {
68
+ ...entry.headers,
69
+ 'content-type': 'text/html; charset=utf-8',
70
+ 'x-isr-cache': age > revalidateMs ? 'STALE' : 'HIT',
71
+ 'x-isr-age': String(Math.round(age / 1000)),
72
+ },
73
+ })
74
+ }
75
+
76
+ // Cache miss — render, cache, and return
77
+ const res = await handler(req)
78
+ const html = await res.text()
79
+ const headers: Record<string, string> = {}
80
+ res.headers.forEach((v, k) => {
81
+ headers[k] = v
82
+ })
83
+
84
+ cache.set(key, { html, headers, timestamp: Date.now() })
85
+
86
+ return new Response(html, {
87
+ status: 200,
88
+ headers: {
89
+ ...headers,
90
+ 'content-type': 'text/html; charset=utf-8',
91
+ 'x-isr-cache': 'MISS',
92
+ },
93
+ })
94
+ }
95
+ }
package/src/link.tsx ADDED
@@ -0,0 +1,266 @@
1
+ import { createRef } from '@pyreon/core'
2
+ import { useRouter } from '@pyreon/router'
3
+ import { useIntersectionObserver } from './utils/use-intersection-observer'
4
+
5
+ // ─── Link component with prefetching ────────────────────────────────────────
6
+ //
7
+ // Provides client-side navigation, prefetching, and active state tracking.
8
+ // Three levels of API:
9
+ //
10
+ // 1. useLink(props) — composable returning handlers, state, and ref callback
11
+ // 2. createLink(Comp) — HOC wrapping any component with link behavior
12
+ // 3. Link — default <a>-based link (built on createLink)
13
+
14
+ export interface LinkProps {
15
+ /** Target URL path. */
16
+ href: string
17
+ /** Link content. */
18
+ children?: any
19
+ /** CSS class name. */
20
+ class?: string
21
+ /** Class applied when this link matches the current route. */
22
+ activeClass?: string
23
+ /** Class applied when this link exactly matches the current route. */
24
+ exactActiveClass?: string
25
+ /** Prefetch strategy. Default: "hover" */
26
+ prefetch?: 'hover' | 'viewport' | 'none'
27
+ /** Open in new tab. */
28
+ external?: boolean
29
+ /** Inline styles. */
30
+ style?: string
31
+ /** ARIA label. */
32
+ 'aria-label'?: string
33
+ }
34
+
35
+ /** Props passed to a custom component via createLink. */
36
+ export interface LinkRenderProps {
37
+ href: string
38
+ ref: import('@pyreon/core').Ref<HTMLElement>
39
+ onClick: (e: MouseEvent) => void
40
+ onMouseEnter: () => void
41
+ onTouchStart: () => void
42
+ isActive: () => boolean
43
+ isExactActive: () => boolean
44
+ /** Reactive class string — pass directly to element for auto-updates on route change. */
45
+ class: (() => string) | string | undefined
46
+ style?: string
47
+ target?: string
48
+ rel?: string
49
+ 'aria-label'?: string
50
+ children?: any
51
+ }
52
+
53
+ /** Return type of useLink. */
54
+ export interface UseLinkReturn {
55
+ /** Ref object — attach to the root element for viewport-based prefetch. */
56
+ ref: import('@pyreon/core').Ref<HTMLElement>
57
+ /** Click handler — performs client-side navigation. */
58
+ handleClick: (e: MouseEvent) => void
59
+ /** Mouse enter handler — triggers hover prefetch. */
60
+ handleMouseEnter: () => void
61
+ /** Touch start handler — triggers prefetch on mobile. */
62
+ handleTouchStart: () => void
63
+ /** Whether the link partially matches the current route. */
64
+ isActive: () => boolean
65
+ /** Whether the link exactly matches the current route. */
66
+ isExactActive: () => boolean
67
+ /** Resolved class string including active classes. */
68
+ classes: () => string
69
+ }
70
+
71
+ const prefetched = new Set<string>()
72
+
73
+ function doPrefetch(href: string) {
74
+ if (prefetched.has(href)) return
75
+ prefetched.add(href)
76
+
77
+ const docLink = document.createElement('link')
78
+ docLink.rel = 'prefetch'
79
+ docLink.href = href
80
+ docLink.as = 'document'
81
+ document.head.appendChild(docLink)
82
+
83
+ try {
84
+ const chunkHint = document.createElement('link')
85
+ chunkHint.rel = 'modulepreload'
86
+ chunkHint.href = href
87
+ document.head.appendChild(chunkHint)
88
+ } catch {
89
+ // modulepreload is a hint, not critical
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Composable that provides all link behavior — navigation, prefetching,
95
+ * active state, and viewport observation.
96
+ *
97
+ * Use this for full control when `createLink` is too opinionated.
98
+ *
99
+ * @example
100
+ * function MyLink(props: LinkProps) {
101
+ * const link = useLink(props)
102
+ * return (
103
+ * <button ref={link.ref} class={link.classes()} onclick={link.handleClick}>
104
+ * {props.children}
105
+ * </button>
106
+ * )
107
+ * }
108
+ */
109
+ export function useLink(props: LinkProps): UseLinkReturn {
110
+ const router = useRouter()
111
+ const elementRef = createRef<HTMLElement>()
112
+ const strategy = props.prefetch ?? 'hover'
113
+
114
+ function handleClick(e: MouseEvent) {
115
+ if (
116
+ e.defaultPrevented ||
117
+ e.button !== 0 ||
118
+ e.metaKey ||
119
+ e.ctrlKey ||
120
+ e.shiftKey ||
121
+ e.altKey ||
122
+ props.external
123
+ ) {
124
+ return
125
+ }
126
+ e.preventDefault()
127
+ router.push(props.href)
128
+ }
129
+
130
+ function handleMouseEnter() {
131
+ if (strategy === 'hover') {
132
+ doPrefetch(props.href)
133
+ }
134
+ }
135
+
136
+ function handleTouchStart() {
137
+ if (strategy === 'hover' || strategy === 'viewport') {
138
+ doPrefetch(props.href)
139
+ }
140
+ }
141
+
142
+ if (strategy === 'viewport') {
143
+ useIntersectionObserver(
144
+ () => elementRef.current ?? undefined,
145
+ () => doPrefetch(props.href),
146
+ )
147
+ }
148
+
149
+ const isActive = () => {
150
+ const currentPath = router.currentRoute()?.path
151
+ if (!currentPath || !props.href) return false
152
+ if (props.href === '/') return currentPath === '/'
153
+ return currentPath.startsWith(props.href)
154
+ }
155
+
156
+ const isExactActive = () => {
157
+ const currentPath = router.currentRoute()?.path
158
+ if (!currentPath) return false
159
+ return currentPath === props.href
160
+ }
161
+
162
+ const classes = () => {
163
+ const cls: string[] = []
164
+ if (props.class) cls.push(props.class)
165
+ if (props.activeClass && isActive()) cls.push(props.activeClass)
166
+ if (props.exactActiveClass && isExactActive())
167
+ cls.push(props.exactActiveClass)
168
+ return cls.join(' ')
169
+ }
170
+
171
+ return {
172
+ ref: elementRef,
173
+ handleClick,
174
+ handleMouseEnter,
175
+ handleTouchStart,
176
+ isActive,
177
+ isExactActive,
178
+ classes,
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Higher-order component that wraps any component with link behavior.
184
+ *
185
+ * The wrapped component receives {@link LinkRenderProps} with all handlers,
186
+ * active state, and accessibility attributes pre-wired.
187
+ *
188
+ * @example
189
+ * // Custom button link
190
+ * const ButtonLink = createLink((props) => (
191
+ * <button
192
+ * ref={props.ref}
193
+ * class={props.class}
194
+ * onclick={props.onClick}
195
+ * onmouseenter={props.onMouseEnter}
196
+ * >
197
+ * {props.children}
198
+ * </button>
199
+ * ))
200
+ *
201
+ * // Custom styled component
202
+ * const CardLink = createLink((props) => (
203
+ * <div
204
+ * ref={props.ref}
205
+ * class={`card ${props.isActive() ? "card--active" : ""}`}
206
+ * onclick={props.onClick}
207
+ * onmouseenter={props.onMouseEnter}
208
+ * >
209
+ * {props.children}
210
+ * </div>
211
+ * ))
212
+ *
213
+ * // Usage
214
+ * <ButtonLink href="/about">About</ButtonLink>
215
+ * <CardLink href="/posts" prefetch="viewport">Posts</CardLink>
216
+ */
217
+ export function createLink(
218
+ Component: (props: LinkRenderProps) => any,
219
+ ): (props: LinkProps) => any {
220
+ return function WrappedLink(props: LinkProps) {
221
+ const link = useLink(props)
222
+
223
+ return (
224
+ <Component
225
+ href={props.href}
226
+ ref={link.ref}
227
+ onClick={link.handleClick}
228
+ onMouseEnter={link.handleMouseEnter}
229
+ onTouchStart={link.handleTouchStart}
230
+ isActive={link.isActive}
231
+ isExactActive={link.isExactActive}
232
+ class={link.classes}
233
+ style={props.style}
234
+ target={props.external ? '_blank' : undefined}
235
+ rel={props.external ? 'noopener noreferrer' : undefined}
236
+ aria-label={props['aria-label']}
237
+ children={props.children}
238
+ />
239
+ )
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Default navigation link built on an `<a>` tag.
245
+ *
246
+ * @example
247
+ * <Link href="/about" prefetch="viewport">About</Link>
248
+ * <Link href="/posts" activeClass="nav-active">Posts</Link>
249
+ */
250
+ export const Link = createLink((props: LinkRenderProps) => (
251
+ <a
252
+ ref={props.ref}
253
+ href={props.href}
254
+ class={props.class}
255
+ style={props.style}
256
+ target={props.target}
257
+ rel={props.rel}
258
+ aria-label={props['aria-label']}
259
+ aria-current={props.isExactActive() ? 'page' : undefined}
260
+ onclick={props.onClick}
261
+ onmouseenter={props.onMouseEnter}
262
+ ontouchstart={props.onTouchStart}
263
+ >
264
+ {props.children}
265
+ </a>
266
+ ))
package/src/script.tsx ADDED
@@ -0,0 +1,133 @@
1
+ import { createRef, onMount, onUnmount } from '@pyreon/core'
2
+ import { useIntersectionObserver } from './utils/use-intersection-observer'
3
+
4
+ // ─── Script optimization component ─────────────────────────────────────────
5
+ //
6
+ // <Script> provides optimized third-party script loading:
7
+ // - Defer loading until after hydration
8
+ // - Load on idle (requestIdleCallback)
9
+ // - Load on interaction (click, scroll, etc.)
10
+ // - Load on viewport entry
11
+ // - Worker offloading for analytics scripts
12
+
13
+ export interface ScriptProps {
14
+ /** Script source URL. */
15
+ src: string
16
+ /** Loading strategy. Default: "afterHydration" */
17
+ strategy?: ScriptStrategy
18
+ /** Inline script content (alternative to src). */
19
+ children?: string
20
+ /** Script id for deduplication. */
21
+ id?: string
22
+ /** Async attribute. Default: true */
23
+ async?: boolean
24
+ /** onLoad callback. */
25
+ onLoad?: () => void
26
+ /** onError callback. */
27
+ onError?: (error: Error) => void
28
+ }
29
+
30
+ export type ScriptStrategy =
31
+ | 'beforeHydration'
32
+ | 'afterHydration'
33
+ | 'onIdle'
34
+ | 'onInteraction'
35
+ | 'onViewport'
36
+
37
+ /**
38
+ * Optimized script loading component.
39
+ *
40
+ * @example
41
+ * // Load analytics after page is interactive
42
+ * <Script src="https://analytics.example.com/script.js" strategy="onIdle" />
43
+ *
44
+ * // Load chat widget when user scrolls
45
+ * <Script src="/chat-widget.js" strategy="onViewport" />
46
+ *
47
+ * // Inline script with deferred execution
48
+ * <Script strategy="afterHydration">
49
+ * {`console.log("App hydrated!")`}
50
+ * </Script>
51
+ */
52
+ export function Script(props: ScriptProps) {
53
+ function loadScript() {
54
+ // Deduplication
55
+ if (props.id && document.getElementById(props.id)) return
56
+
57
+ const script = document.createElement('script')
58
+ if (props.src) script.src = props.src
59
+ if (props.id) script.id = props.id
60
+ script.async = props.async !== false
61
+
62
+ if (props.onLoad) script.onload = props.onLoad
63
+ if (props.onError) {
64
+ script.onerror = () =>
65
+ props.onError?.(new Error(`Failed to load: ${props.src}`))
66
+ }
67
+
68
+ if (props.children && !props.src) {
69
+ script.textContent = props.children
70
+ }
71
+
72
+ document.head.appendChild(script)
73
+ }
74
+
75
+ onMount(() => {
76
+ const strategy = props.strategy ?? 'afterHydration'
77
+
78
+ switch (strategy) {
79
+ case 'beforeHydration':
80
+ // Already in HTML — do nothing
81
+ break
82
+
83
+ case 'afterHydration':
84
+ // Load immediately after mount (hydration is complete)
85
+ loadScript()
86
+ break
87
+
88
+ case 'onIdle':
89
+ if ('requestIdleCallback' in window) {
90
+ requestIdleCallback(() => loadScript(), { timeout: 5000 })
91
+ } else {
92
+ setTimeout(loadScript, 200)
93
+ }
94
+ break
95
+
96
+ case 'onInteraction': {
97
+ const events = ['click', 'scroll', 'keydown', 'touchstart']
98
+ function handler() {
99
+ for (const e of events) document.removeEventListener(e, handler)
100
+ loadScript()
101
+ }
102
+ for (const e of events) {
103
+ document.addEventListener(e, handler, { once: true, passive: true })
104
+ }
105
+ onUnmount(() => {
106
+ for (const e of events) document.removeEventListener(e, handler)
107
+ })
108
+ break
109
+ }
110
+
111
+ case 'onViewport':
112
+ // Handled below via useIntersectionObserver on the sentinel element
113
+ break
114
+ }
115
+ return undefined
116
+ })
117
+
118
+ const sentinelRef = createRef<HTMLElement>()
119
+ const strategy = props.strategy ?? 'afterHydration'
120
+
121
+ if (strategy === 'onViewport') {
122
+ useIntersectionObserver(
123
+ () => sentinelRef.current ?? undefined,
124
+ () => loadScript(),
125
+ )
126
+ }
127
+
128
+ if (strategy === 'onViewport') {
129
+ return <div ref={sentinelRef} style="width:0;height:0;overflow:hidden" />
130
+ }
131
+
132
+ return null
133
+ }