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