@pyreon/zero 0.5.0 → 0.11.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/lib/cache.js.map +1 -1
- package/lib/client.js.map +1 -1
- package/lib/config.js.map +1 -1
- package/lib/font.js.map +1 -1
- package/lib/fs-router-BkbIWqek.js.map +1 -1
- package/lib/fs-router-n4VA4lxu.js.map +1 -1
- package/lib/image-plugin.js.map +1 -1
- package/lib/image.js +1 -1
- package/lib/image.js.map +1 -1
- package/lib/index.js +1 -1
- package/lib/index.js.map +1 -1
- package/lib/link.js +1 -1
- package/lib/link.js.map +1 -1
- package/lib/script.js +1 -1
- package/lib/script.js.map +1 -1
- package/lib/seo.js.map +1 -1
- package/lib/theme.js +1 -1
- package/lib/theme.js.map +1 -1
- package/package.json +14 -13
- package/src/actions.ts +20 -28
- package/src/adapters/bun.ts +7 -7
- package/src/adapters/index.ts +12 -14
- package/src/adapters/node.ts +8 -11
- package/src/adapters/static.ts +3 -3
- package/src/api-routes.ts +23 -50
- package/src/app.ts +9 -13
- package/src/cache.ts +16 -29
- package/src/client.ts +8 -8
- package/src/compression.ts +21 -28
- package/src/config.ts +6 -7
- package/src/cors.ts +20 -28
- package/src/entry-server.ts +15 -19
- package/src/error-overlay.ts +10 -13
- package/src/font.ts +44 -55
- package/src/fs-router.ts +44 -63
- package/src/image-plugin.ts +53 -79
- package/src/image.tsx +39 -41
- package/src/index.ts +36 -36
- package/src/isr.ts +8 -8
- package/src/link.tsx +27 -30
- package/src/rate-limit.ts +15 -15
- package/src/script.tsx +21 -22
- package/src/seo.ts +47 -57
- package/src/sharp.d.ts +2 -6
- package/src/testing.ts +8 -12
- package/src/theme.tsx +18 -20
- package/src/types.ts +6 -6
- package/src/utils/use-intersection-observer.ts +2 -2
- package/src/utils/with-headers.ts +1 -4
- package/src/vite-plugin.ts +21 -28
- package/lib/types/actions.d.ts +0 -57
- package/lib/types/actions.d.ts.map +0 -1
- package/lib/types/adapters/bun.d.ts +0 -6
- package/lib/types/adapters/bun.d.ts.map +0 -1
- package/lib/types/adapters/index.d.ts +0 -10
- package/lib/types/adapters/index.d.ts.map +0 -1
- package/lib/types/adapters/node.d.ts +0 -6
- package/lib/types/adapters/node.d.ts.map +0 -1
- package/lib/types/adapters/static.d.ts +0 -7
- package/lib/types/adapters/static.d.ts.map +0 -1
- package/lib/types/api-routes.d.ts +0 -66
- package/lib/types/api-routes.d.ts.map +0 -1
- package/lib/types/app.d.ts +0 -24
- package/lib/types/app.d.ts.map +0 -1
- package/lib/types/cache.d.ts +0 -54
- package/lib/types/cache.d.ts.map +0 -1
- package/lib/types/client.d.ts +0 -19
- package/lib/types/client.d.ts.map +0 -1
- package/lib/types/compression.d.ts +0 -33
- package/lib/types/compression.d.ts.map +0 -1
- package/lib/types/config.d.ts +0 -18
- package/lib/types/config.d.ts.map +0 -1
- package/lib/types/cors.d.ts +0 -32
- package/lib/types/cors.d.ts.map +0 -1
- package/lib/types/entry-server.d.ts +0 -34
- package/lib/types/entry-server.d.ts.map +0 -1
- package/lib/types/error-overlay.d.ts +0 -6
- package/lib/types/error-overlay.d.ts.map +0 -1
- package/lib/types/font.d.ts +0 -119
- package/lib/types/font.d.ts.map +0 -1
- package/lib/types/fs-router.d.ts +0 -38
- package/lib/types/fs-router.d.ts.map +0 -1
- package/lib/types/image-plugin.d.ts +0 -79
- package/lib/types/image-plugin.d.ts.map +0 -1
- package/lib/types/image.d.ts +0 -51
- package/lib/types/image.d.ts.map +0 -1
- package/lib/types/index.d.ts +0 -37
- package/lib/types/index.d.ts.map +0 -1
- package/lib/types/isr.d.ts +0 -9
- package/lib/types/isr.d.ts.map +0 -1
- package/lib/types/link.d.ts +0 -116
- package/lib/types/link.d.ts.map +0 -1
- package/lib/types/rate-limit.d.ts +0 -34
- package/lib/types/rate-limit.d.ts.map +0 -1
- package/lib/types/script.d.ts +0 -35
- package/lib/types/script.d.ts.map +0 -1
- package/lib/types/seo.d.ts +0 -88
- package/lib/types/seo.d.ts.map +0 -1
- package/lib/types/testing.d.ts +0 -85
- package/lib/types/testing.d.ts.map +0 -1
- package/lib/types/theme.d.ts +0 -39
- package/lib/types/theme.d.ts.map +0 -1
- package/lib/types/types.d.ts +0 -109
- package/lib/types/types.d.ts.map +0 -1
- package/lib/types/utils/use-intersection-observer.d.ts +0 -10
- package/lib/types/utils/use-intersection-observer.d.ts.map +0 -1
- package/lib/types/utils/with-headers.d.ts +0 -6
- package/lib/types/utils/with-headers.d.ts.map +0 -1
- package/lib/types/vite-plugin.d.ts +0 -17
- package/lib/types/vite-plugin.d.ts.map +0 -1
package/src/isr.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ISRConfig } from
|
|
1
|
+
import type { ISRConfig } from "./types"
|
|
2
2
|
|
|
3
3
|
// ─── ISR Cache ───────────────────────────────────────────────────────────────
|
|
4
4
|
|
|
@@ -28,7 +28,7 @@ export function createISRHandler(
|
|
|
28
28
|
revalidating.add(key)
|
|
29
29
|
|
|
30
30
|
try {
|
|
31
|
-
const req = new Request(url.href, { method:
|
|
31
|
+
const req = new Request(url.href, { method: "GET" })
|
|
32
32
|
const res = await handler(req)
|
|
33
33
|
const html = await res.text()
|
|
34
34
|
const headers: Record<string, string> = {}
|
|
@@ -46,7 +46,7 @@ export function createISRHandler(
|
|
|
46
46
|
|
|
47
47
|
return async (req: Request): Promise<Response> => {
|
|
48
48
|
// Only cache GET requests
|
|
49
|
-
if (req.method !==
|
|
49
|
+
if (req.method !== "GET") {
|
|
50
50
|
return handler(req)
|
|
51
51
|
}
|
|
52
52
|
|
|
@@ -66,9 +66,9 @@ export function createISRHandler(
|
|
|
66
66
|
status: 200,
|
|
67
67
|
headers: {
|
|
68
68
|
...entry.headers,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
72
|
},
|
|
73
73
|
})
|
|
74
74
|
}
|
|
@@ -87,8 +87,8 @@ export function createISRHandler(
|
|
|
87
87
|
status: 200,
|
|
88
88
|
headers: {
|
|
89
89
|
...headers,
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
"content-type": "text/html; charset=utf-8",
|
|
91
|
+
"x-isr-cache": "MISS",
|
|
92
92
|
},
|
|
93
93
|
})
|
|
94
94
|
}
|
package/src/link.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { createRef } from
|
|
2
|
-
import { useRouter } from
|
|
3
|
-
import { useIntersectionObserver } from
|
|
1
|
+
import { createRef } from "@pyreon/core"
|
|
2
|
+
import { useRouter } from "@pyreon/router"
|
|
3
|
+
import { useIntersectionObserver } from "./utils/use-intersection-observer"
|
|
4
4
|
|
|
5
5
|
// ─── Link component with prefetching ────────────────────────────────────────
|
|
6
6
|
//
|
|
@@ -23,19 +23,19 @@ export interface LinkProps {
|
|
|
23
23
|
/** Class applied when this link exactly matches the current route. */
|
|
24
24
|
exactActiveClass?: string
|
|
25
25
|
/** Prefetch strategy. Default: "hover" */
|
|
26
|
-
prefetch?:
|
|
26
|
+
prefetch?: "hover" | "viewport" | "none"
|
|
27
27
|
/** Open in new tab. */
|
|
28
28
|
external?: boolean
|
|
29
29
|
/** Inline styles. */
|
|
30
30
|
style?: string
|
|
31
31
|
/** ARIA label. */
|
|
32
|
-
|
|
32
|
+
"aria-label"?: string
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/** Props passed to a custom component via createLink. */
|
|
36
36
|
export interface LinkRenderProps {
|
|
37
37
|
href: string
|
|
38
|
-
ref: import(
|
|
38
|
+
ref: import("@pyreon/core").Ref<HTMLAnchorElement>
|
|
39
39
|
onClick: (e: MouseEvent) => void
|
|
40
40
|
onMouseEnter: () => void
|
|
41
41
|
onTouchStart: () => void
|
|
@@ -46,14 +46,14 @@ export interface LinkRenderProps {
|
|
|
46
46
|
style?: string
|
|
47
47
|
target?: string
|
|
48
48
|
rel?: string
|
|
49
|
-
|
|
49
|
+
"aria-label"?: string
|
|
50
50
|
children?: any
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/** Return type of useLink. */
|
|
54
54
|
export interface UseLinkReturn {
|
|
55
55
|
/** Ref object — attach to the root element for viewport-based prefetch. */
|
|
56
|
-
ref: import(
|
|
56
|
+
ref: import("@pyreon/core").Ref<HTMLAnchorElement>
|
|
57
57
|
/** Click handler — performs client-side navigation. */
|
|
58
58
|
handleClick: (e: MouseEvent) => void
|
|
59
59
|
/** Mouse enter handler — triggers hover prefetch. */
|
|
@@ -74,15 +74,15 @@ function doPrefetch(href: string) {
|
|
|
74
74
|
if (prefetched.has(href)) return
|
|
75
75
|
prefetched.add(href)
|
|
76
76
|
|
|
77
|
-
const docLink = document.createElement(
|
|
78
|
-
docLink.rel =
|
|
77
|
+
const docLink = document.createElement("link")
|
|
78
|
+
docLink.rel = "prefetch"
|
|
79
79
|
docLink.href = href
|
|
80
|
-
docLink.as =
|
|
80
|
+
docLink.as = "document"
|
|
81
81
|
document.head.appendChild(docLink)
|
|
82
82
|
|
|
83
83
|
try {
|
|
84
|
-
const chunkHint = document.createElement(
|
|
85
|
-
chunkHint.rel =
|
|
84
|
+
const chunkHint = document.createElement("link")
|
|
85
|
+
chunkHint.rel = "modulepreload"
|
|
86
86
|
chunkHint.href = href
|
|
87
87
|
document.head.appendChild(chunkHint)
|
|
88
88
|
} catch {
|
|
@@ -108,8 +108,8 @@ function doPrefetch(href: string) {
|
|
|
108
108
|
*/
|
|
109
109
|
export function useLink(props: LinkProps): UseLinkReturn {
|
|
110
110
|
const router = useRouter()
|
|
111
|
-
const elementRef = createRef<
|
|
112
|
-
const strategy = props.prefetch ??
|
|
111
|
+
const elementRef = createRef<HTMLAnchorElement>()
|
|
112
|
+
const strategy = props.prefetch ?? "hover"
|
|
113
113
|
|
|
114
114
|
function handleClick(e: MouseEvent) {
|
|
115
115
|
if (
|
|
@@ -128,18 +128,18 @@ export function useLink(props: LinkProps): UseLinkReturn {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
function handleMouseEnter() {
|
|
131
|
-
if (strategy ===
|
|
131
|
+
if (strategy === "hover") {
|
|
132
132
|
doPrefetch(props.href)
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
function handleTouchStart() {
|
|
137
|
-
if (strategy ===
|
|
137
|
+
if (strategy === "hover" || strategy === "viewport") {
|
|
138
138
|
doPrefetch(props.href)
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
if (strategy ===
|
|
142
|
+
if (strategy === "viewport") {
|
|
143
143
|
useIntersectionObserver(
|
|
144
144
|
() => elementRef.current ?? undefined,
|
|
145
145
|
() => doPrefetch(props.href),
|
|
@@ -149,7 +149,7 @@ export function useLink(props: LinkProps): UseLinkReturn {
|
|
|
149
149
|
const isActive = () => {
|
|
150
150
|
const currentPath = router.currentRoute()?.path
|
|
151
151
|
if (!currentPath || !props.href) return false
|
|
152
|
-
if (props.href ===
|
|
152
|
+
if (props.href === "/") return currentPath === "/"
|
|
153
153
|
return currentPath.startsWith(props.href)
|
|
154
154
|
}
|
|
155
155
|
|
|
@@ -163,9 +163,8 @@ export function useLink(props: LinkProps): UseLinkReturn {
|
|
|
163
163
|
const cls: string[] = []
|
|
164
164
|
if (props.class) cls.push(props.class)
|
|
165
165
|
if (props.activeClass && isActive()) cls.push(props.activeClass)
|
|
166
|
-
if (props.exactActiveClass && isExactActive())
|
|
167
|
-
|
|
168
|
-
return cls.join(' ')
|
|
166
|
+
if (props.exactActiveClass && isExactActive()) cls.push(props.exactActiveClass)
|
|
167
|
+
return cls.join(" ")
|
|
169
168
|
}
|
|
170
169
|
|
|
171
170
|
return {
|
|
@@ -214,9 +213,7 @@ export function useLink(props: LinkProps): UseLinkReturn {
|
|
|
214
213
|
* <ButtonLink href="/about">About</ButtonLink>
|
|
215
214
|
* <CardLink href="/posts" prefetch="viewport">Posts</CardLink>
|
|
216
215
|
*/
|
|
217
|
-
export function createLink(
|
|
218
|
-
Component: (props: LinkRenderProps) => any,
|
|
219
|
-
): (props: LinkProps) => any {
|
|
216
|
+
export function createLink(Component: (props: LinkRenderProps) => any): (props: LinkProps) => any {
|
|
220
217
|
return function WrappedLink(props: LinkProps) {
|
|
221
218
|
const link = useLink(props)
|
|
222
219
|
|
|
@@ -231,8 +228,8 @@ export function createLink(
|
|
|
231
228
|
isExactActive={link.isExactActive}
|
|
232
229
|
class={link.classes}
|
|
233
230
|
{...(props.style ? { style: props.style } : {})}
|
|
234
|
-
{...(props.external ? { target:
|
|
235
|
-
{...(props[
|
|
231
|
+
{...(props.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
232
|
+
{...(props["aria-label"] ? { "aria-label": props["aria-label"] } : {})}
|
|
236
233
|
children={props.children}
|
|
237
234
|
/>
|
|
238
235
|
)
|
|
@@ -248,14 +245,14 @@ export function createLink(
|
|
|
248
245
|
*/
|
|
249
246
|
export const Link = createLink((props: LinkRenderProps) => (
|
|
250
247
|
<a
|
|
251
|
-
ref={props.ref
|
|
248
|
+
ref={props.ref}
|
|
252
249
|
href={props.href}
|
|
253
250
|
{...(props.class ? { class: props.class } : {})}
|
|
254
251
|
{...(props.style ? { style: props.style } : {})}
|
|
255
252
|
{...(props.target ? { target: props.target } : {})}
|
|
256
253
|
{...(props.rel ? { rel: props.rel } : {})}
|
|
257
|
-
{...(props[
|
|
258
|
-
{...(props.isExactActive() ? {
|
|
254
|
+
{...(props["aria-label"] ? { "aria-label": props["aria-label"] } : {})}
|
|
255
|
+
{...(props.isExactActive() ? { "aria-current": "page" as const } : {})}
|
|
259
256
|
onClick={props.onClick}
|
|
260
257
|
onMouseEnter={props.onMouseEnter}
|
|
261
258
|
onTouchStart={props.onTouchStart}
|
package/src/rate-limit.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Middleware, MiddlewareContext } from
|
|
1
|
+
import type { Middleware, MiddlewareContext } from "@pyreon/server"
|
|
2
2
|
|
|
3
3
|
// ─── Rate limiting middleware ───────────────────────────────────────────────
|
|
4
4
|
|
|
@@ -61,7 +61,7 @@ export function rateLimitMiddleware(config: RateLimitConfig = {}): Middleware {
|
|
|
61
61
|
}, windowMs)
|
|
62
62
|
|
|
63
63
|
// Allow GC to clean up the interval
|
|
64
|
-
if (typeof cleanupInterval ===
|
|
64
|
+
if (typeof cleanupInterval === "object" && "unref" in cleanupInterval) {
|
|
65
65
|
cleanupInterval.unref()
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -84,21 +84,21 @@ export function rateLimitMiddleware(config: RateLimitConfig = {}): Middleware {
|
|
|
84
84
|
const resetSeconds = Math.ceil((entry.resetAt - now) / 1000)
|
|
85
85
|
|
|
86
86
|
// Set rate limit headers on all responses
|
|
87
|
-
ctx.headers.set(
|
|
88
|
-
ctx.headers.set(
|
|
89
|
-
ctx.headers.set(
|
|
87
|
+
ctx.headers.set("X-RateLimit-Limit", String(max))
|
|
88
|
+
ctx.headers.set("X-RateLimit-Remaining", String(remaining))
|
|
89
|
+
ctx.headers.set("X-RateLimit-Reset", String(resetSeconds))
|
|
90
90
|
|
|
91
91
|
if (entry.count > max) {
|
|
92
92
|
if (onLimit) return onLimit(ctx)
|
|
93
93
|
|
|
94
|
-
return new Response(JSON.stringify({ error:
|
|
94
|
+
return new Response(JSON.stringify({ error: "Too many requests" }), {
|
|
95
95
|
status: 429,
|
|
96
96
|
headers: {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
97
|
+
"Content-Type": "application/json",
|
|
98
|
+
"Retry-After": String(resetSeconds),
|
|
99
|
+
"X-RateLimit-Limit": String(max),
|
|
100
|
+
"X-RateLimit-Remaining": "0",
|
|
101
|
+
"X-RateLimit-Reset": String(resetSeconds),
|
|
102
102
|
},
|
|
103
103
|
})
|
|
104
104
|
}
|
|
@@ -107,15 +107,15 @@ export function rateLimitMiddleware(config: RateLimitConfig = {}): Middleware {
|
|
|
107
107
|
|
|
108
108
|
function defaultKeyFn(ctx: MiddlewareContext): string {
|
|
109
109
|
return (
|
|
110
|
-
ctx.req.headers.get(
|
|
111
|
-
ctx.req.headers.get(
|
|
112
|
-
|
|
110
|
+
ctx.req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
111
|
+
ctx.req.headers.get("x-real-ip") ??
|
|
112
|
+
"unknown"
|
|
113
113
|
)
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
/** Simple glob matching for path patterns. Supports trailing `*`. */
|
|
117
117
|
function matchSimpleGlob(pattern: string, path: string): boolean {
|
|
118
|
-
if (pattern.endsWith(
|
|
118
|
+
if (pattern.endsWith("/*")) {
|
|
119
119
|
return path.startsWith(pattern.slice(0, -1))
|
|
120
120
|
}
|
|
121
121
|
return pattern === path
|
package/src/script.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { VNodeChild } from
|
|
2
|
-
import { createRef, onMount, onUnmount } from
|
|
3
|
-
import { useIntersectionObserver } from
|
|
1
|
+
import type { VNodeChild } from "@pyreon/core"
|
|
2
|
+
import { createRef, onMount, onUnmount } from "@pyreon/core"
|
|
3
|
+
import { useIntersectionObserver } from "./utils/use-intersection-observer"
|
|
4
4
|
|
|
5
5
|
// ─── Script optimization component ─────────────────────────────────────────
|
|
6
6
|
//
|
|
@@ -29,11 +29,11 @@ export interface ScriptProps {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export type ScriptStrategy =
|
|
32
|
-
|
|
|
33
|
-
|
|
|
34
|
-
|
|
|
35
|
-
|
|
|
36
|
-
|
|
|
32
|
+
| "beforeHydration"
|
|
33
|
+
| "afterHydration"
|
|
34
|
+
| "onIdle"
|
|
35
|
+
| "onInteraction"
|
|
36
|
+
| "onViewport"
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
39
|
* Optimized script loading component.
|
|
@@ -55,15 +55,14 @@ export function Script(props: ScriptProps): VNodeChild {
|
|
|
55
55
|
// Deduplication
|
|
56
56
|
if (props.id && document.getElementById(props.id)) return
|
|
57
57
|
|
|
58
|
-
const script = document.createElement(
|
|
58
|
+
const script = document.createElement("script")
|
|
59
59
|
if (props.src) script.src = props.src
|
|
60
60
|
if (props.id) script.id = props.id
|
|
61
61
|
script.async = props.async !== false
|
|
62
62
|
|
|
63
63
|
if (props.onLoad) script.onload = props.onLoad
|
|
64
64
|
if (props.onError) {
|
|
65
|
-
script.onerror = () =>
|
|
66
|
-
props.onError?.(new Error(`Failed to load: ${props.src}`))
|
|
65
|
+
script.onerror = () => props.onError?.(new Error(`Failed to load: ${props.src}`))
|
|
67
66
|
}
|
|
68
67
|
|
|
69
68
|
if (props.children && !props.src) {
|
|
@@ -74,28 +73,28 @@ export function Script(props: ScriptProps): VNodeChild {
|
|
|
74
73
|
}
|
|
75
74
|
|
|
76
75
|
onMount(() => {
|
|
77
|
-
const strategy = props.strategy ??
|
|
76
|
+
const strategy = props.strategy ?? "afterHydration"
|
|
78
77
|
|
|
79
78
|
switch (strategy) {
|
|
80
|
-
case
|
|
79
|
+
case "beforeHydration":
|
|
81
80
|
// Already in HTML — do nothing
|
|
82
81
|
break
|
|
83
82
|
|
|
84
|
-
case
|
|
83
|
+
case "afterHydration":
|
|
85
84
|
// Load immediately after mount (hydration is complete)
|
|
86
85
|
loadScript()
|
|
87
86
|
break
|
|
88
87
|
|
|
89
|
-
case
|
|
90
|
-
if (
|
|
88
|
+
case "onIdle":
|
|
89
|
+
if ("requestIdleCallback" in window) {
|
|
91
90
|
requestIdleCallback(() => loadScript(), { timeout: 5000 })
|
|
92
91
|
} else {
|
|
93
92
|
setTimeout(loadScript, 200)
|
|
94
93
|
}
|
|
95
94
|
break
|
|
96
95
|
|
|
97
|
-
case
|
|
98
|
-
const events = [
|
|
96
|
+
case "onInteraction": {
|
|
97
|
+
const events = ["click", "scroll", "keydown", "touchstart"]
|
|
99
98
|
function handler() {
|
|
100
99
|
for (const e of events) document.removeEventListener(e, handler)
|
|
101
100
|
loadScript()
|
|
@@ -109,7 +108,7 @@ export function Script(props: ScriptProps): VNodeChild {
|
|
|
109
108
|
break
|
|
110
109
|
}
|
|
111
110
|
|
|
112
|
-
case
|
|
111
|
+
case "onViewport":
|
|
113
112
|
// Handled below via useIntersectionObserver on the sentinel element
|
|
114
113
|
break
|
|
115
114
|
}
|
|
@@ -117,16 +116,16 @@ export function Script(props: ScriptProps): VNodeChild {
|
|
|
117
116
|
})
|
|
118
117
|
|
|
119
118
|
const sentinelRef = createRef<HTMLElement>()
|
|
120
|
-
const strategy = props.strategy ??
|
|
119
|
+
const strategy = props.strategy ?? "afterHydration"
|
|
121
120
|
|
|
122
|
-
if (strategy ===
|
|
121
|
+
if (strategy === "onViewport") {
|
|
123
122
|
useIntersectionObserver(
|
|
124
123
|
() => sentinelRef.current ?? undefined,
|
|
125
124
|
() => loadScript(),
|
|
126
125
|
)
|
|
127
126
|
}
|
|
128
127
|
|
|
129
|
-
if (strategy ===
|
|
128
|
+
if (strategy === "onViewport") {
|
|
130
129
|
return <div ref={sentinelRef} style="width:0;height:0;overflow:hidden" />
|
|
131
130
|
}
|
|
132
131
|
|
package/src/seo.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { Middleware } from
|
|
2
|
-
import type { Plugin } from
|
|
1
|
+
import type { Middleware } from "@pyreon/server"
|
|
2
|
+
import type { Plugin } from "vite"
|
|
3
3
|
|
|
4
4
|
// ─── SEO utilities ──────────────────────────────────────────────────────────
|
|
5
5
|
//
|
|
@@ -29,47 +29,37 @@ export interface SitemapEntry {
|
|
|
29
29
|
lastmod?: string
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
export type ChangeFreq =
|
|
33
|
-
| 'always'
|
|
34
|
-
| 'hourly'
|
|
35
|
-
| 'daily'
|
|
36
|
-
| 'weekly'
|
|
37
|
-
| 'monthly'
|
|
38
|
-
| 'yearly'
|
|
39
|
-
| 'never'
|
|
32
|
+
export type ChangeFreq = "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never"
|
|
40
33
|
|
|
41
34
|
/**
|
|
42
35
|
* Generate a sitemap.xml string from route file paths.
|
|
43
36
|
*/
|
|
44
|
-
export function generateSitemap(
|
|
45
|
-
|
|
46
|
-
config: SitemapConfig,
|
|
47
|
-
): string {
|
|
48
|
-
const { origin, exclude = [], changefreq = 'weekly', priority = 0.7 } = config
|
|
37
|
+
export function generateSitemap(routeFiles: string[], config: SitemapConfig): string {
|
|
38
|
+
const { origin, exclude = [], changefreq = "weekly", priority = 0.7 } = config
|
|
49
39
|
|
|
50
40
|
const paths = routeFiles
|
|
51
41
|
.filter((f) => {
|
|
52
42
|
// Exclude layout, error, loading files
|
|
53
43
|
const name = f
|
|
54
|
-
.split(
|
|
44
|
+
.split("/")
|
|
55
45
|
.pop()
|
|
56
|
-
?.replace(/\.\w+$/,
|
|
57
|
-
return name !==
|
|
46
|
+
?.replace(/\.\w+$/, "")
|
|
47
|
+
return name !== "_layout" && name !== "_error" && name !== "_loading"
|
|
58
48
|
})
|
|
59
49
|
.map((f) => {
|
|
60
50
|
// Convert file path to URL
|
|
61
51
|
let path = f
|
|
62
|
-
.replace(/\.\w+$/,
|
|
63
|
-
.replace(/\/index$/,
|
|
64
|
-
.replace(/^index$/,
|
|
52
|
+
.replace(/\.\w+$/, "")
|
|
53
|
+
.replace(/\/index$/, "/")
|
|
54
|
+
.replace(/^index$/, "/")
|
|
65
55
|
|
|
66
56
|
// Skip dynamic routes — they need additionalPaths
|
|
67
|
-
if (path.includes(
|
|
57
|
+
if (path.includes("[")) return null
|
|
68
58
|
|
|
69
59
|
// Strip route groups
|
|
70
|
-
path = path.replace(/\([\w-]+\)\//g,
|
|
60
|
+
path = path.replace(/\([\w-]+\)\//g, "")
|
|
71
61
|
|
|
72
|
-
if (!path.startsWith(
|
|
62
|
+
if (!path.startsWith("/")) path = `/${path}`
|
|
73
63
|
return path
|
|
74
64
|
})
|
|
75
65
|
.filter((p): p is string => p !== null)
|
|
@@ -82,14 +72,14 @@ export function generateSitemap(
|
|
|
82
72
|
|
|
83
73
|
const entries = allPaths
|
|
84
74
|
.map((entry) => {
|
|
85
|
-
const loc = `${origin}${entry.path ===
|
|
75
|
+
const loc = `${origin}${entry.path === "/" ? "" : entry.path}`
|
|
86
76
|
return ` <url>
|
|
87
77
|
<loc>${escapeXml(loc)}</loc>
|
|
88
78
|
<changefreq>${entry.changefreq ?? changefreq}</changefreq>
|
|
89
|
-
<priority>${entry.priority ?? priority}</priority>${entry.lastmod ? `\n <lastmod>${entry.lastmod}</lastmod>` :
|
|
79
|
+
<priority>${entry.priority ?? priority}</priority>${entry.lastmod ? `\n <lastmod>${entry.lastmod}</lastmod>` : ""}
|
|
90
80
|
</url>`
|
|
91
81
|
})
|
|
92
|
-
.join(
|
|
82
|
+
.join("\n")
|
|
93
83
|
|
|
94
84
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
95
85
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
@@ -99,11 +89,11 @@ ${entries}
|
|
|
99
89
|
|
|
100
90
|
function escapeXml(str: string): string {
|
|
101
91
|
return str
|
|
102
|
-
.replace(/&/g,
|
|
103
|
-
.replace(/</g,
|
|
104
|
-
.replace(/>/g,
|
|
105
|
-
.replace(/"/g,
|
|
106
|
-
.replace(/'/g,
|
|
92
|
+
.replace(/&/g, "&")
|
|
93
|
+
.replace(/</g, "<")
|
|
94
|
+
.replace(/>/g, ">")
|
|
95
|
+
.replace(/"/g, """)
|
|
96
|
+
.replace(/'/g, "'")
|
|
107
97
|
}
|
|
108
98
|
|
|
109
99
|
// ─── Robots.txt ─────────────────────────────────────────────────────────────
|
|
@@ -128,7 +118,7 @@ export interface RobotsRule {
|
|
|
128
118
|
* Generate a robots.txt string.
|
|
129
119
|
*/
|
|
130
120
|
export function generateRobots(config: RobotsConfig = {}): string {
|
|
131
|
-
const { rules = [{ userAgent:
|
|
121
|
+
const { rules = [{ userAgent: "*", allow: ["/"] }], sitemap, host } = config
|
|
132
122
|
const lines: string[] = []
|
|
133
123
|
|
|
134
124
|
for (const rule of rules) {
|
|
@@ -140,27 +130,27 @@ export function generateRobots(config: RobotsConfig = {}): string {
|
|
|
140
130
|
for (const path of rule.disallow) lines.push(`Disallow: ${path}`)
|
|
141
131
|
}
|
|
142
132
|
if (rule.crawlDelay) lines.push(`Crawl-delay: ${rule.crawlDelay}`)
|
|
143
|
-
lines.push(
|
|
133
|
+
lines.push("")
|
|
144
134
|
}
|
|
145
135
|
|
|
146
136
|
if (sitemap) lines.push(`Sitemap: ${sitemap}`)
|
|
147
137
|
if (host) lines.push(`Host: ${host}`)
|
|
148
138
|
|
|
149
|
-
return lines.join(
|
|
139
|
+
return lines.join("\n")
|
|
150
140
|
}
|
|
151
141
|
|
|
152
142
|
// ─── Structured data (JSON-LD) ──────────────────────────────────────────────
|
|
153
143
|
|
|
154
144
|
export type JsonLdType =
|
|
155
|
-
|
|
|
156
|
-
|
|
|
157
|
-
|
|
|
158
|
-
|
|
|
159
|
-
|
|
|
160
|
-
|
|
|
161
|
-
|
|
|
162
|
-
|
|
|
163
|
-
|
|
|
145
|
+
| "WebSite"
|
|
146
|
+
| "WebPage"
|
|
147
|
+
| "Article"
|
|
148
|
+
| "BlogPosting"
|
|
149
|
+
| "Product"
|
|
150
|
+
| "Organization"
|
|
151
|
+
| "Person"
|
|
152
|
+
| "BreadcrumbList"
|
|
153
|
+
| "FAQPage"
|
|
164
154
|
| (string & {})
|
|
165
155
|
|
|
166
156
|
/**
|
|
@@ -177,7 +167,7 @@ export type JsonLdType =
|
|
|
177
167
|
*/
|
|
178
168
|
export function jsonLd(data: Record<string, unknown>): string {
|
|
179
169
|
const ld = {
|
|
180
|
-
|
|
170
|
+
"@context": "https://schema.org",
|
|
181
171
|
...data,
|
|
182
172
|
}
|
|
183
173
|
return `<script type="application/ld+json">${JSON.stringify(ld)}</script>`
|
|
@@ -212,13 +202,13 @@ export interface SeoPluginConfig {
|
|
|
212
202
|
*/
|
|
213
203
|
export function seoPlugin(config: SeoPluginConfig = {}): Plugin {
|
|
214
204
|
return {
|
|
215
|
-
name:
|
|
216
|
-
apply:
|
|
205
|
+
name: "pyreon-zero-seo",
|
|
206
|
+
apply: "build",
|
|
217
207
|
|
|
218
208
|
async generateBundle(_, _bundle) {
|
|
219
209
|
// Generate sitemap.xml
|
|
220
210
|
if (config.sitemap) {
|
|
221
|
-
const { scanRouteFiles } = await import(
|
|
211
|
+
const { scanRouteFiles } = await import("./fs-router")
|
|
222
212
|
const routesDir = `${process.cwd()}/src/routes`
|
|
223
213
|
|
|
224
214
|
try {
|
|
@@ -226,8 +216,8 @@ export function seoPlugin(config: SeoPluginConfig = {}): Plugin {
|
|
|
226
216
|
const sitemap = generateSitemap(files, config.sitemap)
|
|
227
217
|
|
|
228
218
|
this.emitFile({
|
|
229
|
-
type:
|
|
230
|
-
fileName:
|
|
219
|
+
type: "asset",
|
|
220
|
+
fileName: "sitemap.xml",
|
|
231
221
|
source: sitemap,
|
|
232
222
|
})
|
|
233
223
|
} catch {
|
|
@@ -240,8 +230,8 @@ export function seoPlugin(config: SeoPluginConfig = {}): Plugin {
|
|
|
240
230
|
const robots = generateRobots(config.robots)
|
|
241
231
|
|
|
242
232
|
this.emitFile({
|
|
243
|
-
type:
|
|
244
|
-
fileName:
|
|
233
|
+
type: "asset",
|
|
234
|
+
fileName: "robots.txt",
|
|
245
235
|
source: robots,
|
|
246
236
|
})
|
|
247
237
|
}
|
|
@@ -257,21 +247,21 @@ export function seoPlugin(config: SeoPluginConfig = {}): Plugin {
|
|
|
257
247
|
*/
|
|
258
248
|
export function seoMiddleware(config: SeoPluginConfig = {}): Middleware {
|
|
259
249
|
return async (ctx) => {
|
|
260
|
-
if (ctx.url.pathname ===
|
|
250
|
+
if (ctx.url.pathname === "/robots.txt" && config.robots) {
|
|
261
251
|
return new Response(generateRobots(config.robots), {
|
|
262
|
-
headers: {
|
|
252
|
+
headers: { "Content-Type": "text/plain" },
|
|
263
253
|
})
|
|
264
254
|
}
|
|
265
255
|
|
|
266
|
-
if (ctx.url.pathname ===
|
|
256
|
+
if (ctx.url.pathname === "/sitemap.xml" && config.sitemap) {
|
|
267
257
|
try {
|
|
268
|
-
const { scanRouteFiles } = await import(
|
|
258
|
+
const { scanRouteFiles } = await import("./fs-router")
|
|
269
259
|
const routesDir = `${process.cwd()}/src/routes`
|
|
270
260
|
const files = await scanRouteFiles(routesDir)
|
|
271
261
|
const sitemap = generateSitemap(files, config.sitemap)
|
|
272
262
|
|
|
273
263
|
return new Response(sitemap, {
|
|
274
|
-
headers: {
|
|
264
|
+
headers: { "Content-Type": "application/xml" },
|
|
275
265
|
})
|
|
276
266
|
} catch {
|
|
277
267
|
// Sitemap generation failed — continue to rendering
|
package/src/sharp.d.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
declare module
|
|
1
|
+
declare module "sharp" {
|
|
2
2
|
interface SharpInstance {
|
|
3
|
-
resize(
|
|
4
|
-
width: number,
|
|
5
|
-
height?: number,
|
|
6
|
-
options?: { fit?: string },
|
|
7
|
-
): SharpInstance
|
|
3
|
+
resize(width: number, height?: number, options?: { fit?: string }): SharpInstance
|
|
8
4
|
webp(options?: { quality?: number }): SharpInstance
|
|
9
5
|
avif(options?: { quality?: number }): SharpInstance
|
|
10
6
|
jpeg(options?: { quality?: number; mozjpeg?: boolean }): SharpInstance
|