@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.
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/lib/cache.js +80 -0
- package/lib/cache.js.map +1 -0
- package/lib/client.js +58 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +35 -0
- package/lib/config.js.map +1 -0
- package/lib/font.js +251 -0
- package/lib/font.js.map +1 -0
- package/lib/fs-router-BkbIWqek.js +30 -0
- package/lib/fs-router-BkbIWqek.js.map +1 -0
- package/lib/fs-router-jfd1QGLB.js +261 -0
- package/lib/fs-router-jfd1QGLB.js.map +1 -0
- package/lib/image-plugin.js +289 -0
- package/lib/image-plugin.js.map +1 -0
- package/lib/image.js +113 -0
- package/lib/image.js.map +1 -0
- package/lib/index.js +1665 -0
- package/lib/index.js.map +1 -0
- package/lib/link.js +186 -0
- package/lib/link.js.map +1 -0
- package/lib/script.js +102 -0
- package/lib/script.js.map +1 -0
- package/lib/seo.js +136 -0
- package/lib/seo.js.map +1 -0
- package/lib/theme.js +165 -0
- package/lib/theme.js.map +1 -0
- package/lib/types/adapters/bun.d.ts +6 -0
- package/lib/types/adapters/bun.d.ts.map +1 -0
- package/lib/types/adapters/index.d.ts +10 -0
- package/lib/types/adapters/index.d.ts.map +1 -0
- package/lib/types/adapters/node.d.ts +6 -0
- package/lib/types/adapters/node.d.ts.map +1 -0
- package/lib/types/adapters/static.d.ts +7 -0
- package/lib/types/adapters/static.d.ts.map +1 -0
- package/lib/types/app.d.ts +24 -0
- package/lib/types/app.d.ts.map +1 -0
- package/lib/types/cache.d.ts +54 -0
- package/lib/types/cache.d.ts.map +1 -0
- package/lib/types/client.d.ts +19 -0
- package/lib/types/client.d.ts.map +1 -0
- package/lib/types/config.d.ts +18 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/entry-server.d.ts +26 -0
- package/lib/types/entry-server.d.ts.map +1 -0
- package/lib/types/font.d.ts +119 -0
- package/lib/types/font.d.ts.map +1 -0
- package/lib/types/fs-router.d.ts +33 -0
- package/lib/types/fs-router.d.ts.map +1 -0
- package/lib/types/image-plugin.d.ts +79 -0
- package/lib/types/image-plugin.d.ts.map +1 -0
- package/lib/types/image.d.ts +50 -0
- package/lib/types/image.d.ts.map +1 -0
- package/lib/types/index.d.ts +27 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/isr.d.ts +9 -0
- package/lib/types/isr.d.ts.map +1 -0
- package/lib/types/link.d.ts +116 -0
- package/lib/types/link.d.ts.map +1 -0
- package/lib/types/script.d.ts +34 -0
- package/lib/types/script.d.ts.map +1 -0
- package/lib/types/seo.d.ts +88 -0
- package/lib/types/seo.d.ts.map +1 -0
- package/lib/types/theme.d.ts +38 -0
- package/lib/types/theme.d.ts.map +1 -0
- package/lib/types/types.d.ts +104 -0
- package/lib/types/types.d.ts.map +1 -0
- package/lib/types/utils/use-intersection-observer.d.ts +10 -0
- package/lib/types/utils/use-intersection-observer.d.ts.map +1 -0
- package/lib/types/utils/with-headers.d.ts +6 -0
- package/lib/types/utils/with-headers.d.ts.map +1 -0
- package/lib/types/vite-plugin.d.ts +17 -0
- package/lib/types/vite-plugin.d.ts.map +1 -0
- package/package.json +100 -0
- package/src/adapters/bun.ts +65 -0
- package/src/adapters/index.ts +29 -0
- package/src/adapters/node.ts +113 -0
- package/src/adapters/static.ts +17 -0
- package/src/app.ts +62 -0
- package/src/cache.ts +149 -0
- package/src/client.ts +43 -0
- package/src/config.ts +36 -0
- package/src/entry-server.ts +51 -0
- package/src/font.ts +461 -0
- package/src/fs-router.ts +380 -0
- package/src/image-plugin.ts +452 -0
- package/src/image.tsx +167 -0
- package/src/index.ts +119 -0
- package/src/isr.ts +95 -0
- package/src/link.tsx +266 -0
- package/src/script.tsx +133 -0
- package/src/seo.ts +281 -0
- package/src/sharp.d.ts +20 -0
- package/src/theme.tsx +162 -0
- package/src/types.ts +130 -0
- package/src/utils/use-intersection-observer.ts +36 -0
- package/src/utils/with-headers.ts +16 -0
- 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
|
+
}
|