@pyreon/zero 0.11.5 → 0.11.6
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/README.md +22 -22
- 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.map +1 -1
- package/lib/index.js.map +1 -1
- package/lib/link.js.map +1 -1
- package/lib/script.js.map +1 -1
- package/lib/seo.js.map +1 -1
- package/lib/theme.js.map +1 -1
- package/package.json +12 -12
- package/src/actions.ts +17 -17
- package/src/adapters/bun.ts +7 -7
- package/src/adapters/index.ts +11 -11
- package/src/adapters/node.ts +8 -8
- package/src/adapters/static.ts +3 -3
- package/src/api-routes.ts +21 -21
- package/src/app.ts +8 -8
- package/src/cache.ts +14 -14
- package/src/client.ts +8 -8
- package/src/compression.ts +19 -19
- package/src/config.ts +6 -6
- package/src/cors.ts +20 -20
- package/src/entry-server.ts +13 -13
- package/src/error-overlay.ts +9 -9
- package/src/font.ts +41 -41
- package/src/fs-router.ts +39 -39
- package/src/image-plugin.ts +45 -45
- package/src/image.tsx +39 -39
- package/src/index.ts +36 -56
- package/src/isr.ts +8 -8
- package/src/link.tsx +23 -23
- package/src/rate-limit.ts +15 -15
- package/src/script.tsx +20 -20
- package/src/seo.ts +46 -46
- package/src/sharp.d.ts +1 -1
- package/src/testing.ts +7 -7
- package/src/theme.tsx +18 -18
- package/src/types.ts +6 -6
- package/src/utils/use-intersection-observer.ts +2 -2
- package/src/vite-plugin.ts +21 -21
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 {
|
|
@@ -109,7 +109,7 @@ function doPrefetch(href: string) {
|
|
|
109
109
|
export function useLink(props: LinkProps): UseLinkReturn {
|
|
110
110
|
const router = useRouter()
|
|
111
111
|
const elementRef = createRef<HTMLAnchorElement>()
|
|
112
|
-
const strategy = props.prefetch ??
|
|
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
|
|
|
@@ -164,7 +164,7 @@ export function useLink(props: LinkProps): UseLinkReturn {
|
|
|
164
164
|
if (props.class) cls.push(props.class)
|
|
165
165
|
if (props.activeClass && isActive()) cls.push(props.activeClass)
|
|
166
166
|
if (props.exactActiveClass && isExactActive()) cls.push(props.exactActiveClass)
|
|
167
|
-
return cls.join(
|
|
167
|
+
return cls.join(' ')
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
return {
|
|
@@ -228,8 +228,8 @@ export function createLink(Component: (props: LinkRenderProps) => any): (props:
|
|
|
228
228
|
isExactActive={link.isExactActive}
|
|
229
229
|
class={link.classes}
|
|
230
230
|
{...(props.style ? { style: props.style } : {})}
|
|
231
|
-
{...(props.external ? { target:
|
|
232
|
-
{...(props[
|
|
231
|
+
{...(props.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
|
232
|
+
{...(props['aria-label'] ? { 'aria-label': props['aria-label'] } : {})}
|
|
233
233
|
children={props.children}
|
|
234
234
|
/>
|
|
235
235
|
)
|
|
@@ -251,8 +251,8 @@ export const Link = createLink((props: LinkRenderProps) => (
|
|
|
251
251
|
{...(props.style ? { style: props.style } : {})}
|
|
252
252
|
{...(props.target ? { target: props.target } : {})}
|
|
253
253
|
{...(props.rel ? { rel: props.rel } : {})}
|
|
254
|
-
{...(props[
|
|
255
|
-
{...(props.isExactActive() ? {
|
|
254
|
+
{...(props['aria-label'] ? { 'aria-label': props['aria-label'] } : {})}
|
|
255
|
+
{...(props.isExactActive() ? { 'aria-current': 'page' as const } : {})}
|
|
256
256
|
onClick={props.onClick}
|
|
257
257
|
onMouseEnter={props.onMouseEnter}
|
|
258
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,7 +55,7 @@ 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
|
|
@@ -73,28 +73,28 @@ export function Script(props: ScriptProps): VNodeChild {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
onMount(() => {
|
|
76
|
-
const strategy = props.strategy ??
|
|
76
|
+
const strategy = props.strategy ?? 'afterHydration'
|
|
77
77
|
|
|
78
78
|
switch (strategy) {
|
|
79
|
-
case
|
|
79
|
+
case 'beforeHydration':
|
|
80
80
|
// Already in HTML — do nothing
|
|
81
81
|
break
|
|
82
82
|
|
|
83
|
-
case
|
|
83
|
+
case 'afterHydration':
|
|
84
84
|
// Load immediately after mount (hydration is complete)
|
|
85
85
|
loadScript()
|
|
86
86
|
break
|
|
87
87
|
|
|
88
|
-
case
|
|
89
|
-
if (
|
|
88
|
+
case 'onIdle':
|
|
89
|
+
if ('requestIdleCallback' in window) {
|
|
90
90
|
requestIdleCallback(() => loadScript(), { timeout: 5000 })
|
|
91
91
|
} else {
|
|
92
92
|
setTimeout(loadScript, 200)
|
|
93
93
|
}
|
|
94
94
|
break
|
|
95
95
|
|
|
96
|
-
case
|
|
97
|
-
const events = [
|
|
96
|
+
case 'onInteraction': {
|
|
97
|
+
const events = ['click', 'scroll', 'keydown', 'touchstart']
|
|
98
98
|
function handler() {
|
|
99
99
|
for (const e of events) document.removeEventListener(e, handler)
|
|
100
100
|
loadScript()
|
|
@@ -108,7 +108,7 @@ export function Script(props: ScriptProps): VNodeChild {
|
|
|
108
108
|
break
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
case
|
|
111
|
+
case 'onViewport':
|
|
112
112
|
// Handled below via useIntersectionObserver on the sentinel element
|
|
113
113
|
break
|
|
114
114
|
}
|
|
@@ -116,16 +116,16 @@ export function Script(props: ScriptProps): VNodeChild {
|
|
|
116
116
|
})
|
|
117
117
|
|
|
118
118
|
const sentinelRef = createRef<HTMLElement>()
|
|
119
|
-
const strategy = props.strategy ??
|
|
119
|
+
const strategy = props.strategy ?? 'afterHydration'
|
|
120
120
|
|
|
121
|
-
if (strategy ===
|
|
121
|
+
if (strategy === 'onViewport') {
|
|
122
122
|
useIntersectionObserver(
|
|
123
123
|
() => sentinelRef.current ?? undefined,
|
|
124
124
|
() => loadScript(),
|
|
125
125
|
)
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
if (strategy ===
|
|
128
|
+
if (strategy === 'onViewport') {
|
|
129
129
|
return <div ref={sentinelRef} style="width:0;height:0;overflow:hidden" />
|
|
130
130
|
}
|
|
131
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,37 +29,37 @@ export interface SitemapEntry {
|
|
|
29
29
|
lastmod?: string
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
export type ChangeFreq =
|
|
32
|
+
export type ChangeFreq = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never'
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* Generate a sitemap.xml string from route file paths.
|
|
36
36
|
*/
|
|
37
37
|
export function generateSitemap(routeFiles: string[], config: SitemapConfig): string {
|
|
38
|
-
const { origin, exclude = [], changefreq =
|
|
38
|
+
const { origin, exclude = [], changefreq = 'weekly', priority = 0.7 } = config
|
|
39
39
|
|
|
40
40
|
const paths = routeFiles
|
|
41
41
|
.filter((f) => {
|
|
42
42
|
// Exclude layout, error, loading files
|
|
43
43
|
const name = f
|
|
44
|
-
.split(
|
|
44
|
+
.split('/')
|
|
45
45
|
.pop()
|
|
46
|
-
?.replace(/\.\w+$/,
|
|
47
|
-
return name !==
|
|
46
|
+
?.replace(/\.\w+$/, '')
|
|
47
|
+
return name !== '_layout' && name !== '_error' && name !== '_loading'
|
|
48
48
|
})
|
|
49
49
|
.map((f) => {
|
|
50
50
|
// Convert file path to URL
|
|
51
51
|
let path = f
|
|
52
|
-
.replace(/\.\w+$/,
|
|
53
|
-
.replace(/\/index$/,
|
|
54
|
-
.replace(/^index$/,
|
|
52
|
+
.replace(/\.\w+$/, '')
|
|
53
|
+
.replace(/\/index$/, '/')
|
|
54
|
+
.replace(/^index$/, '/')
|
|
55
55
|
|
|
56
56
|
// Skip dynamic routes — they need additionalPaths
|
|
57
|
-
if (path.includes(
|
|
57
|
+
if (path.includes('[')) return null
|
|
58
58
|
|
|
59
59
|
// Strip route groups
|
|
60
|
-
path = path.replace(/\([\w-]+\)\//g,
|
|
60
|
+
path = path.replace(/\([\w-]+\)\//g, '')
|
|
61
61
|
|
|
62
|
-
if (!path.startsWith(
|
|
62
|
+
if (!path.startsWith('/')) path = `/${path}`
|
|
63
63
|
return path
|
|
64
64
|
})
|
|
65
65
|
.filter((p): p is string => p !== null)
|
|
@@ -72,14 +72,14 @@ export function generateSitemap(routeFiles: string[], config: SitemapConfig): st
|
|
|
72
72
|
|
|
73
73
|
const entries = allPaths
|
|
74
74
|
.map((entry) => {
|
|
75
|
-
const loc = `${origin}${entry.path ===
|
|
75
|
+
const loc = `${origin}${entry.path === '/' ? '' : entry.path}`
|
|
76
76
|
return ` <url>
|
|
77
77
|
<loc>${escapeXml(loc)}</loc>
|
|
78
78
|
<changefreq>${entry.changefreq ?? changefreq}</changefreq>
|
|
79
|
-
<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>` : ''}
|
|
80
80
|
</url>`
|
|
81
81
|
})
|
|
82
|
-
.join(
|
|
82
|
+
.join('\n')
|
|
83
83
|
|
|
84
84
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
85
85
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
@@ -89,11 +89,11 @@ ${entries}
|
|
|
89
89
|
|
|
90
90
|
function escapeXml(str: string): string {
|
|
91
91
|
return str
|
|
92
|
-
.replace(/&/g,
|
|
93
|
-
.replace(/</g,
|
|
94
|
-
.replace(/>/g,
|
|
95
|
-
.replace(/"/g,
|
|
96
|
-
.replace(/'/g,
|
|
92
|
+
.replace(/&/g, '&')
|
|
93
|
+
.replace(/</g, '<')
|
|
94
|
+
.replace(/>/g, '>')
|
|
95
|
+
.replace(/"/g, '"')
|
|
96
|
+
.replace(/'/g, ''')
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
// ─── Robots.txt ─────────────────────────────────────────────────────────────
|
|
@@ -118,7 +118,7 @@ export interface RobotsRule {
|
|
|
118
118
|
* Generate a robots.txt string.
|
|
119
119
|
*/
|
|
120
120
|
export function generateRobots(config: RobotsConfig = {}): string {
|
|
121
|
-
const { rules = [{ userAgent:
|
|
121
|
+
const { rules = [{ userAgent: '*', allow: ['/'] }], sitemap, host } = config
|
|
122
122
|
const lines: string[] = []
|
|
123
123
|
|
|
124
124
|
for (const rule of rules) {
|
|
@@ -130,27 +130,27 @@ export function generateRobots(config: RobotsConfig = {}): string {
|
|
|
130
130
|
for (const path of rule.disallow) lines.push(`Disallow: ${path}`)
|
|
131
131
|
}
|
|
132
132
|
if (rule.crawlDelay) lines.push(`Crawl-delay: ${rule.crawlDelay}`)
|
|
133
|
-
lines.push(
|
|
133
|
+
lines.push('')
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
if (sitemap) lines.push(`Sitemap: ${sitemap}`)
|
|
137
137
|
if (host) lines.push(`Host: ${host}`)
|
|
138
138
|
|
|
139
|
-
return lines.join(
|
|
139
|
+
return lines.join('\n')
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
// ─── Structured data (JSON-LD) ──────────────────────────────────────────────
|
|
143
143
|
|
|
144
144
|
export type JsonLdType =
|
|
145
|
-
|
|
|
146
|
-
|
|
|
147
|
-
|
|
|
148
|
-
|
|
|
149
|
-
|
|
|
150
|
-
|
|
|
151
|
-
|
|
|
152
|
-
|
|
|
153
|
-
|
|
|
145
|
+
| 'WebSite'
|
|
146
|
+
| 'WebPage'
|
|
147
|
+
| 'Article'
|
|
148
|
+
| 'BlogPosting'
|
|
149
|
+
| 'Product'
|
|
150
|
+
| 'Organization'
|
|
151
|
+
| 'Person'
|
|
152
|
+
| 'BreadcrumbList'
|
|
153
|
+
| 'FAQPage'
|
|
154
154
|
| (string & {})
|
|
155
155
|
|
|
156
156
|
/**
|
|
@@ -167,7 +167,7 @@ export type JsonLdType =
|
|
|
167
167
|
*/
|
|
168
168
|
export function jsonLd(data: Record<string, unknown>): string {
|
|
169
169
|
const ld = {
|
|
170
|
-
|
|
170
|
+
'@context': 'https://schema.org',
|
|
171
171
|
...data,
|
|
172
172
|
}
|
|
173
173
|
return `<script type="application/ld+json">${JSON.stringify(ld)}</script>`
|
|
@@ -202,13 +202,13 @@ export interface SeoPluginConfig {
|
|
|
202
202
|
*/
|
|
203
203
|
export function seoPlugin(config: SeoPluginConfig = {}): Plugin {
|
|
204
204
|
return {
|
|
205
|
-
name:
|
|
206
|
-
apply:
|
|
205
|
+
name: 'pyreon-zero-seo',
|
|
206
|
+
apply: 'build',
|
|
207
207
|
|
|
208
208
|
async generateBundle(_, _bundle) {
|
|
209
209
|
// Generate sitemap.xml
|
|
210
210
|
if (config.sitemap) {
|
|
211
|
-
const { scanRouteFiles } = await import(
|
|
211
|
+
const { scanRouteFiles } = await import('./fs-router')
|
|
212
212
|
const routesDir = `${process.cwd()}/src/routes`
|
|
213
213
|
|
|
214
214
|
try {
|
|
@@ -216,8 +216,8 @@ export function seoPlugin(config: SeoPluginConfig = {}): Plugin {
|
|
|
216
216
|
const sitemap = generateSitemap(files, config.sitemap)
|
|
217
217
|
|
|
218
218
|
this.emitFile({
|
|
219
|
-
type:
|
|
220
|
-
fileName:
|
|
219
|
+
type: 'asset',
|
|
220
|
+
fileName: 'sitemap.xml',
|
|
221
221
|
source: sitemap,
|
|
222
222
|
})
|
|
223
223
|
} catch {
|
|
@@ -230,8 +230,8 @@ export function seoPlugin(config: SeoPluginConfig = {}): Plugin {
|
|
|
230
230
|
const robots = generateRobots(config.robots)
|
|
231
231
|
|
|
232
232
|
this.emitFile({
|
|
233
|
-
type:
|
|
234
|
-
fileName:
|
|
233
|
+
type: 'asset',
|
|
234
|
+
fileName: 'robots.txt',
|
|
235
235
|
source: robots,
|
|
236
236
|
})
|
|
237
237
|
}
|
|
@@ -247,21 +247,21 @@ export function seoPlugin(config: SeoPluginConfig = {}): Plugin {
|
|
|
247
247
|
*/
|
|
248
248
|
export function seoMiddleware(config: SeoPluginConfig = {}): Middleware {
|
|
249
249
|
return async (ctx) => {
|
|
250
|
-
if (ctx.url.pathname ===
|
|
250
|
+
if (ctx.url.pathname === '/robots.txt' && config.robots) {
|
|
251
251
|
return new Response(generateRobots(config.robots), {
|
|
252
|
-
headers: {
|
|
252
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
253
253
|
})
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
-
if (ctx.url.pathname ===
|
|
256
|
+
if (ctx.url.pathname === '/sitemap.xml' && config.sitemap) {
|
|
257
257
|
try {
|
|
258
|
-
const { scanRouteFiles } = await import(
|
|
258
|
+
const { scanRouteFiles } = await import('./fs-router')
|
|
259
259
|
const routesDir = `${process.cwd()}/src/routes`
|
|
260
260
|
const files = await scanRouteFiles(routesDir)
|
|
261
261
|
const sitemap = generateSitemap(files, config.sitemap)
|
|
262
262
|
|
|
263
263
|
return new Response(sitemap, {
|
|
264
|
-
headers: {
|
|
264
|
+
headers: { 'Content-Type': 'application/xml' },
|
|
265
265
|
})
|
|
266
266
|
} catch {
|
|
267
267
|
// Sitemap generation failed — continue to rendering
|
package/src/sharp.d.ts
CHANGED
package/src/testing.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { Middleware, MiddlewareContext } from
|
|
2
|
-
import type { ApiHandler, ApiRouteEntry } from
|
|
3
|
-
import { createApiMiddleware } from
|
|
1
|
+
import type { Middleware, MiddlewareContext } from '@pyreon/server'
|
|
2
|
+
import type { ApiHandler, ApiRouteEntry } from './api-routes'
|
|
3
|
+
import { createApiMiddleware } from './api-routes'
|
|
4
4
|
|
|
5
5
|
// ─── Test helpers for Zero applications ─────────────────────────────────────
|
|
6
6
|
|
|
@@ -21,14 +21,14 @@ export function createTestContext(
|
|
|
21
21
|
body?: unknown
|
|
22
22
|
} = {},
|
|
23
23
|
): MiddlewareContext {
|
|
24
|
-
const { method =
|
|
24
|
+
const { method = 'GET', headers = {}, body } = options
|
|
25
25
|
const url = new URL(`http://localhost${path}`)
|
|
26
26
|
|
|
27
27
|
const requestHeaders: Record<string, string> = { ...headers }
|
|
28
28
|
let requestBody: string | undefined
|
|
29
29
|
|
|
30
30
|
if (body !== undefined) {
|
|
31
|
-
requestHeaders[
|
|
31
|
+
requestHeaders['Content-Type'] = 'application/json'
|
|
32
32
|
requestBody = JSON.stringify(body)
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -107,7 +107,7 @@ export function createTestApiServer(routes: ApiRouteEntry[]) {
|
|
|
107
107
|
const ctx = createTestContext(path, options)
|
|
108
108
|
const result = await middleware(ctx)
|
|
109
109
|
if (!result) {
|
|
110
|
-
return new Response(
|
|
110
|
+
return new Response('Not Found', { status: 404 })
|
|
111
111
|
}
|
|
112
112
|
return result
|
|
113
113
|
},
|
|
@@ -138,7 +138,7 @@ export function createMockHandler(
|
|
|
138
138
|
calls.push({ path: ctx.path, params: ctx.params })
|
|
139
139
|
return new Response(JSON.stringify(body), {
|
|
140
140
|
status,
|
|
141
|
-
headers: {
|
|
141
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
142
142
|
})
|
|
143
143
|
}
|
|
144
144
|
|