@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/lib/index.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/app.ts","../src/entry-server.ts","../src/config.ts","../src/vite-plugin.ts","../src/isr.ts","../src/adapters/bun.ts","../src/adapters/node.ts","../src/adapters/static.ts","../src/adapters/index.ts","../src/utils/use-intersection-observer.ts","../src/image.tsx","../src/link.tsx","../src/script.tsx","../src/cache.ts","../src/font.ts","../src/image-plugin.ts","../src/theme.tsx","../src/seo.ts"],"sourcesContent":["import type { ComponentFn, Props } from '@pyreon/core'\nimport { Fragment, h } from '@pyreon/core'\nimport { HeadProvider } from '@pyreon/head'\nimport type { RouteRecord } from '@pyreon/router'\nimport { createRouter, RouterProvider, RouterView } from '@pyreon/router'\n\n// ─── App assembly ────────────────────────────────────────────────────────────\n\nexport interface CreateAppOptions {\n /** Route definitions (from file-based routing or manual). */\n routes: RouteRecord[]\n\n /** Router mode. Default: \"history\" for SSR, \"hash\" for SPA. */\n routerMode?: 'hash' | 'history'\n\n /** Initial URL for SSR. */\n url?: string\n\n /** Root layout component wrapping all routes. */\n layout?: ComponentFn\n\n /** Global error component. */\n errorComponent?: ComponentFn\n}\n\n/**\n * Create a full Zero app — assembles router, head provider, and root layout.\n *\n * Used internally by entry-server and entry-client.\n */\nexport function createApp(options: CreateAppOptions) {\n const router = createRouter({\n routes: options.routes,\n mode: options.routerMode ?? 'history',\n url: options.url,\n scrollBehavior: 'top',\n })\n\n const Layout = options.layout ?? DefaultLayout\n\n function App() {\n return h(\n HeadProvider,\n null,\n h(\n RouterProvider as ComponentFn<Props>,\n { router },\n h(Layout, null, h(RouterView as ComponentFn<Props>, null)),\n ),\n )\n }\n\n return { App, router }\n}\n\nfunction DefaultLayout(props: Props) {\n return h(\n Fragment,\n null,\n ...(Array.isArray(props.children) ? props.children : [props.children]),\n )\n}\n","import type { RouteRecord } from '@pyreon/router'\nimport type { Middleware } from '@pyreon/server'\nimport { createHandler } from '@pyreon/server'\nimport { createApp } from './app'\nimport type { ZeroConfig } from './types'\n\n// ─── Server entry factory ───────────────────────────────────────────────────\n\nexport interface CreateServerOptions {\n /** Route definitions. */\n routes: RouteRecord[]\n /** Zero config. */\n config?: ZeroConfig\n /** Additional middleware. */\n middleware?: Middleware[]\n /** HTML template override. */\n template?: string\n /** Client entry path. */\n clientEntry?: string\n}\n\n/**\n * Create the SSR request handler for production.\n *\n * @example\n * import { routes } from \"virtual:zero/routes\"\n * import { createServer } from \"@pyreon/zero\"\n *\n * export default createServer({ routes })\n */\nexport function createServer(options: CreateServerOptions) {\n const config = options.config ?? {}\n const allMiddleware = [\n ...(config.middleware ?? []),\n ...(options.middleware ?? []),\n ]\n\n const { App } = createApp({\n routes: options.routes,\n routerMode: 'history',\n })\n\n return createHandler({\n App,\n routes: options.routes,\n middleware: allMiddleware,\n mode: config.ssr?.mode ?? 'string',\n template: options.template,\n clientEntry: options.clientEntry,\n })\n}\n","import type { ZeroConfig } from './types'\n\n/**\n * Define a Zero configuration.\n * Used in `zero.config.ts` at the project root.\n *\n * @example\n * import { defineConfig } from \"@pyreon/zero/config\"\n *\n * export default defineConfig({\n * mode: \"ssr\",\n * ssr: { mode: \"stream\" },\n * port: 3000,\n * })\n */\nexport function defineConfig(config: ZeroConfig): ZeroConfig {\n return config\n}\n\n/** Merge user config with defaults. */\nexport function resolveConfig(\n userConfig: ZeroConfig = {},\n): Required<Pick<ZeroConfig, 'mode' | 'base' | 'port' | 'adapter'>> &\n ZeroConfig {\n return {\n mode: 'ssr',\n base: '/',\n port: 3000,\n adapter: 'node',\n ...userConfig,\n ssr: {\n mode: 'string',\n ...userConfig.ssr,\n },\n }\n}\n","import type { Plugin } from 'vite'\nimport { resolveConfig } from './config'\nimport { generateRouteModule, scanRouteFiles } from './fs-router'\nimport type { ZeroConfig } from './types'\n\nconst VIRTUAL_ROUTES_ID = 'virtual:zero/routes'\nconst RESOLVED_VIRTUAL_ROUTES_ID = `\\0${VIRTUAL_ROUTES_ID}`\n\n/**\n * Zero Vite plugin — adds file-based routing and zero-config conventions\n * on top of @pyreon/vite-plugin.\n *\n * @example\n * // vite.config.ts\n * import pyreon from \"@pyreon/vite-plugin\"\n * import zero from \"@pyreon/zero\"\n *\n * export default {\n * plugins: [pyreon(), zero()],\n * }\n */\nexport function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {\n const config = resolveConfig(userConfig)\n let routesDir: string\n let root: string\n\n const plugin: Plugin & { _zeroConfig: ZeroConfig } = {\n name: 'pyreon-zero',\n enforce: 'pre',\n _zeroConfig: userConfig,\n\n configResolved(resolvedConfig) {\n root = resolvedConfig.root\n routesDir = `${root}/src/routes`\n },\n\n resolveId(id) {\n if (id === VIRTUAL_ROUTES_ID) {\n return RESOLVED_VIRTUAL_ROUTES_ID\n }\n },\n\n async load(id) {\n if (id === RESOLVED_VIRTUAL_ROUTES_ID) {\n try {\n const files = await scanRouteFiles(routesDir)\n return generateRouteModule(files, routesDir)\n } catch (_err) {\n return `export const routes = []`\n }\n }\n },\n\n configureServer(server) {\n // Watch routes directory for changes\n server.watcher.add(`${routesDir}/**/*.{tsx,jsx,ts,js}`)\n\n // Invalidate virtual module when route files change\n server.watcher.on('all', (event, path) => {\n if (\n path.startsWith(routesDir) &&\n (event === 'add' || event === 'unlink')\n ) {\n const mod = server.moduleGraph.getModuleById(\n RESOLVED_VIRTUAL_ROUTES_ID,\n )\n if (mod) {\n server.moduleGraph.invalidateModule(mod)\n server.ws.send({ type: 'full-reload' })\n }\n }\n })\n },\n\n config() {\n return {\n resolve: {\n conditions: ['bun'],\n },\n server: {\n port: config.port,\n },\n define: {\n __ZERO_MODE__: JSON.stringify(config.mode),\n __ZERO_BASE__: JSON.stringify(config.base),\n },\n }\n },\n }\n\n return plugin\n}\n","import type { ISRConfig } from './types'\n\n// ─── ISR Cache ───────────────────────────────────────────────────────────────\n\ninterface CacheEntry {\n html: string\n headers: Record<string, string>\n timestamp: number\n}\n\n/**\n * In-memory ISR cache with stale-while-revalidate semantics.\n *\n * Wraps an SSR handler and caches responses per URL path.\n * Serves stale content immediately while revalidating in the background.\n */\nexport function createISRHandler(\n handler: (req: Request) => Promise<Response>,\n config: ISRConfig,\n): (req: Request) => Promise<Response> {\n const cache = new Map<string, CacheEntry>()\n const revalidating = new Set<string>()\n const revalidateMs = config.revalidate * 1000\n\n async function revalidate(url: URL) {\n const key = url.pathname\n if (revalidating.has(key)) return\n revalidating.add(key)\n\n try {\n const req = new Request(url.href, { method: 'GET' })\n const res = await handler(req)\n const html = await res.text()\n const headers: Record<string, string> = {}\n res.headers.forEach((v, k) => {\n headers[k] = v\n })\n\n cache.set(key, { html, headers, timestamp: Date.now() })\n } catch {\n // Revalidation failed — stale cache entry remains valid\n } finally {\n revalidating.delete(key)\n }\n }\n\n return async (req: Request): Promise<Response> => {\n // Only cache GET requests\n if (req.method !== 'GET') {\n return handler(req)\n }\n\n const url = new URL(req.url)\n const key = url.pathname\n const entry = cache.get(key)\n\n if (entry) {\n const age = Date.now() - entry.timestamp\n\n if (age > revalidateMs) {\n // Stale — serve cached but revalidate in background\n revalidate(url)\n }\n\n return new Response(entry.html, {\n status: 200,\n headers: {\n ...entry.headers,\n 'content-type': 'text/html; charset=utf-8',\n 'x-isr-cache': age > revalidateMs ? 'STALE' : 'HIT',\n 'x-isr-age': String(Math.round(age / 1000)),\n },\n })\n }\n\n // Cache miss — render, cache, and return\n const res = await handler(req)\n const html = await res.text()\n const headers: Record<string, string> = {}\n res.headers.forEach((v, k) => {\n headers[k] = v\n })\n\n cache.set(key, { html, headers, timestamp: Date.now() })\n\n return new Response(html, {\n status: 200,\n headers: {\n ...headers,\n 'content-type': 'text/html; charset=utf-8',\n 'x-isr-cache': 'MISS',\n },\n })\n }\n}\n","import type { Adapter, AdapterBuildOptions } from '../types'\n\n/**\n * Bun adapter — generates a standalone Bun.serve() entry.\n */\nexport function bunAdapter(): Adapter {\n return {\n name: 'bun',\n async build(options: AdapterBuildOptions) {\n const { writeFile, cp, mkdir } = await import('node:fs/promises')\n const { join } = await import('node:path')\n\n const outDir = options.outDir\n await mkdir(outDir, { recursive: true })\n\n // Copy server and client builds\n await cp(options.clientOutDir, join(outDir, 'client'), {\n recursive: true,\n })\n await cp(join(options.serverEntry, '..'), join(outDir, 'server'), {\n recursive: true,\n })\n\n const port = options.config.port ?? 3000\n const serverEntry = `\nconst handler = (await import(\"./server/entry-server.js\")).default\nconst clientDir = new URL(\"./client/\", import.meta.url).pathname\n\nBun.serve({\n port: ${port},\n async fetch(req) {\n const url = new URL(req.url)\n\n // Try static files first\n if (req.method === \"GET\") {\n const filePath = clientDir + (url.pathname === \"/\" ? \"index.html\" : url.pathname)\n // Prevent path traversal — ensure resolved path stays within clientDir\n const resolved = Bun.resolveSync(filePath, \".\")\n if (!resolved.startsWith(Bun.resolveSync(clientDir, \".\"))) {\n return new Response(\"Forbidden\", { status: 403 })\n }\n const file = Bun.file(filePath)\n if (await file.exists()) {\n return new Response(file, {\n headers: {\n \"cache-control\": filePath.endsWith(\".js\") || filePath.endsWith(\".css\")\n ? \"public, max-age=31536000, immutable\"\n : \"public, max-age=3600\",\n },\n })\n }\n }\n\n // Fall through to SSR handler\n return handler(req)\n },\n})\n\nconsole.log(\"\\\\n ⚡ Zero production server running on http://localhost:${port}\\\\n\")\n`.trimStart()\n\n await writeFile(join(outDir, 'index.ts'), serverEntry)\n },\n }\n}\n","import type { Adapter, AdapterBuildOptions } from '../types'\n\n/**\n * Node.js adapter — generates a standalone server entry using node:http.\n */\nexport function nodeAdapter(): Adapter {\n return {\n name: 'node',\n async build(options: AdapterBuildOptions) {\n const { writeFile, cp, mkdir } = await import('node:fs/promises')\n const { join } = await import('node:path')\n\n const outDir = options.outDir\n await mkdir(outDir, { recursive: true })\n\n // Copy server and client builds\n await cp(options.clientOutDir, join(outDir, 'client'), {\n recursive: true,\n })\n await cp(join(options.serverEntry, '..'), join(outDir, 'server'), {\n recursive: true,\n })\n\n // Generate standalone server entry\n const port = options.config.port ?? 3000\n const serverEntry = `\nimport { createServer } from \"node:http\"\nimport { readFile } from \"node:fs/promises\"\nimport { join, extname } from \"node:path\"\nimport { fileURLToPath } from \"node:url\"\n\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url))\nconst handler = (await import(\"./server/entry-server.js\")).default\nconst clientDir = join(__dirname, \"client\")\n\nconst MIME_TYPES = {\n \".html\": \"text/html\",\n \".js\": \"application/javascript\",\n \".css\": \"text/css\",\n \".json\": \"application/json\",\n \".png\": \"image/png\",\n \".jpg\": \"image/jpeg\",\n \".svg\": \"image/svg+xml\",\n \".woff2\": \"font/woff2\",\n \".woff\": \"font/woff\",\n \".ico\": \"image/x-icon\",\n}\n\nconst server = createServer(async (req, res) => {\n const url = new URL(req.url ?? \"/\", \"http://localhost\")\n\n // Try to serve static files first\n if (req.method === \"GET\") {\n try {\n const filePath = join(clientDir, url.pathname === \"/\" ? \"index.html\" : url.pathname)\n // Prevent path traversal — ensure resolved path stays within clientDir\n const { resolve } = await import(\"node:path\")\n const resolved = resolve(filePath)\n if (!resolved.startsWith(resolve(clientDir))) {\n res.writeHead(403)\n res.end(\"Forbidden\")\n return\n }\n const ext = extname(filePath)\n if (ext && ext !== \".html\") {\n const data = await readFile(filePath)\n const mime = MIME_TYPES[ext] || \"application/octet-stream\"\n res.writeHead(200, {\n \"content-type\": mime,\n \"cache-control\": ext === \".js\" || ext === \".css\"\n ? \"public, max-age=31536000, immutable\"\n : \"public, max-age=3600\",\n })\n res.end(data)\n return\n }\n } catch {}\n }\n\n // Fall through to SSR handler\n const headers = {}\n for (const [key, value] of Object.entries(req.headers)) {\n if (value) headers[key] = Array.isArray(value) ? value.join(\", \") : value\n }\n\n const request = new Request(url.href, {\n method: req.method,\n headers,\n })\n\n const response = await handler(request)\n const body = await response.text()\n\n const responseHeaders = {}\n response.headers.forEach((v, k) => { responseHeaders[k] = v })\n\n res.writeHead(response.status, responseHeaders)\n res.end(body)\n})\n\nserver.listen(${port}, () => {\n console.log(\"\\\\n ⚡ Zero production server running on http://localhost:${port}\\\\n\")\n})\n`.trimStart()\n\n await writeFile(join(outDir, 'index.js'), serverEntry)\n await writeFile(\n join(outDir, 'package.json'),\n JSON.stringify({ type: 'module' }, null, 2),\n )\n },\n }\n}\n","import type { Adapter, AdapterBuildOptions } from '../types'\n\n/**\n * Static adapter — just copies the client build output.\n * Used with SSG mode where all pages are pre-rendered at build time.\n */\nexport function staticAdapter(): Adapter {\n return {\n name: 'static',\n async build(options: AdapterBuildOptions) {\n const { cp, mkdir } = await import('node:fs/promises')\n\n await mkdir(options.outDir, { recursive: true })\n await cp(options.clientOutDir, options.outDir, { recursive: true })\n },\n }\n}\n","export { bunAdapter } from './bun'\nexport { nodeAdapter } from './node'\nexport { staticAdapter } from './static'\n\nimport type { Adapter, ZeroConfig } from '../types'\nimport { bunAdapter } from './bun'\nimport { nodeAdapter } from './node'\nimport { staticAdapter } from './static'\n\n/**\n * Resolve the adapter from config.\n * Returns a built-in adapter or throws if unknown.\n */\nexport function resolveAdapter(config: ZeroConfig): Adapter {\n const name = config.adapter ?? 'node'\n\n switch (name) {\n case 'node':\n return nodeAdapter()\n case 'bun':\n return bunAdapter()\n case 'static':\n return staticAdapter()\n default:\n throw new Error(\n `[zero] Unknown adapter: \"${name}\". Use \"node\", \"bun\", or \"static\".`,\n )\n }\n}\n","import { onMount, onUnmount } from '@pyreon/core'\n\n/**\n * Observes an element and calls `onIntersect` once it enters the viewport.\n * Automatically disconnects after the first intersection.\n *\n * @param getElement - Getter for the target element (may be undefined before mount).\n * @param onIntersect - Callback fired when the element becomes visible.\n * @param rootMargin - IntersectionObserver rootMargin. Default: \"200px\".\n */\nexport function useIntersectionObserver(\n getElement: () => HTMLElement | undefined,\n onIntersect: () => void,\n rootMargin = '200px',\n) {\n onMount(() => {\n const el = getElement()\n if (!el) return undefined\n\n const observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n onIntersect()\n observer.disconnect()\n }\n }\n },\n { rootMargin },\n )\n\n observer.observe(el)\n onUnmount(() => observer.disconnect())\n return undefined\n })\n}\n","import { createRef } from '@pyreon/core'\nimport { signal } from '@pyreon/reactivity'\nimport type { FormatSource } from './image-plugin'\nimport { useIntersectionObserver } from './utils/use-intersection-observer'\n\n// ─── Image optimization component ───────────────────────────────────────────\n//\n// <Image> provides:\n// - Lazy loading via IntersectionObserver (loads when near viewport)\n// - Automatic width/height to prevent CLS (Cumulative Layout Shift)\n// - Responsive srcset generation from width descriptors\n// - Multi-format support via <picture> (WebP/AVIF with fallback)\n// - Blur-up placeholder while loading\n// - Priority loading for above-the-fold images\n\nexport interface ImageProps {\n /** Image source URL. */\n src: string\n /** Alt text (required for accessibility). */\n alt: string\n /** Intrinsic width of the image. */\n width: number\n /** Intrinsic height of the image. */\n height: number\n /** Responsive sizes attribute. Default: \"100vw\" */\n sizes?: string\n /** Responsive srcset string or source array. */\n srcset?: string | ImageSource[]\n /** Per-format source sets for <picture>. Provided automatically by imagePlugin. */\n formats?: FormatSource[]\n /** Loading strategy. \"lazy\" uses IntersectionObserver, \"eager\" loads immediately. Default: \"lazy\" */\n loading?: 'lazy' | 'eager'\n /** Mark as priority (LCP image). Disables lazy loading, adds fetchpriority=\"high\". */\n priority?: boolean\n /** Low-quality placeholder image URL or base64 data URI for blur-up effect. */\n placeholder?: string\n /** CSS class name. */\n class?: string\n /** Inline styles. */\n style?: string\n /** CSS object-fit. Default: \"cover\" */\n fit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'\n /** Decode async. Default: true */\n decoding?: 'sync' | 'async' | 'auto'\n}\n\nexport interface ImageSource {\n src: string\n width: number\n}\n\n/**\n * Optimized image component with lazy loading, responsive images,\n * multi-format <picture> support, and blur-up placeholders.\n *\n * @example\n * // With imagePlugin — spread the import directly\n * import hero from \"./hero.jpg?optimize\"\n * <Image {...hero} alt=\"Hero\" priority />\n *\n * @example\n * // Manual usage\n * <Image src=\"/hero.jpg\" alt=\"Hero\" width={1200} height={630} />\n */\nexport function Image(props: ImageProps) {\n const isEager = props.priority || props.loading === 'eager'\n const loaded = signal(isEager)\n const inView = signal(isEager)\n const containerRef = createRef<HTMLElement>()\n\n // Resolve srcset from string or array\n const resolvedSrcset =\n typeof props.srcset === 'string'\n ? props.srcset\n : props.srcset?.map((s) => `${s.src} ${s.width}w`).join(', ')\n\n const sizes = props.sizes ?? '100vw'\n const fit = props.fit ?? 'cover'\n const hasFormats = props.formats && props.formats.length > 0\n const aspectRatio = `${props.width} / ${props.height}`\n\n if (!isEager) {\n useIntersectionObserver(\n () => containerRef.current ?? undefined,\n () => inView.set(true),\n )\n }\n\n // Static styles (don't depend on signals)\n const containerStyle = [\n 'position: relative',\n 'overflow: hidden',\n `aspect-ratio: ${aspectRatio}`,\n `max-width: ${props.width}px`,\n 'width: 100%',\n props.style,\n ]\n .filter(Boolean)\n .join('; ')\n\n const imgEl = (\n <img\n src={() => (inView() ? props.src : '')}\n srcset={() =>\n !hasFormats && inView() && resolvedSrcset ? resolvedSrcset : ''\n }\n sizes={resolvedSrcset ? sizes : undefined}\n alt={props.alt}\n width={props.width}\n height={props.height}\n loading={isEager ? 'eager' : 'lazy'}\n decoding={props.decoding ?? 'async'}\n fetchpriority={props.priority ? 'high' : undefined}\n onload={() => loaded.set(true)}\n style={() =>\n [\n 'display: block',\n 'width: 100%',\n 'height: 100%',\n `object-fit: ${fit}`,\n 'transition: opacity 0.3s ease',\n props.placeholder && !loaded() ? 'opacity: 0' : 'opacity: 1',\n ].join('; ')\n }\n />\n )\n\n return (\n <div ref={containerRef} class={props.class} style={containerStyle}>\n {props.placeholder && (\n <img\n src={props.placeholder}\n alt=\"\"\n aria-hidden=\"true\"\n loading=\"eager\"\n style={() =>\n [\n 'position: absolute',\n 'inset: 0',\n 'width: 100%',\n 'height: 100%',\n 'object-fit: cover',\n 'filter: blur(20px)',\n 'transform: scale(1.1)',\n 'transition: opacity 0.4s ease',\n loaded() ? 'opacity: 0; pointer-events: none' : 'opacity: 1',\n ].join('; ')\n }\n />\n )}\n {hasFormats ? (\n <picture>\n {props.formats?.map((fmt) => (\n <source\n type={fmt.type}\n srcset={() => (inView() ? fmt.srcset : undefined)}\n sizes={sizes}\n />\n ))}\n {imgEl}\n </picture>\n ) : (\n imgEl\n )}\n </div>\n )\n}\n","import { createRef } from '@pyreon/core'\nimport { useRouter } from '@pyreon/router'\nimport { useIntersectionObserver } from './utils/use-intersection-observer'\n\n// ─── Link component with prefetching ────────────────────────────────────────\n//\n// Provides client-side navigation, prefetching, and active state tracking.\n// Three levels of API:\n//\n// 1. useLink(props) — composable returning handlers, state, and ref callback\n// 2. createLink(Comp) — HOC wrapping any component with link behavior\n// 3. Link — default <a>-based link (built on createLink)\n\nexport interface LinkProps {\n /** Target URL path. */\n href: string\n /** Link content. */\n children?: any\n /** CSS class name. */\n class?: string\n /** Class applied when this link matches the current route. */\n activeClass?: string\n /** Class applied when this link exactly matches the current route. */\n exactActiveClass?: string\n /** Prefetch strategy. Default: \"hover\" */\n prefetch?: 'hover' | 'viewport' | 'none'\n /** Open in new tab. */\n external?: boolean\n /** Inline styles. */\n style?: string\n /** ARIA label. */\n 'aria-label'?: string\n}\n\n/** Props passed to a custom component via createLink. */\nexport interface LinkRenderProps {\n href: string\n ref: import('@pyreon/core').Ref<HTMLElement>\n onClick: (e: MouseEvent) => void\n onMouseEnter: () => void\n onTouchStart: () => void\n isActive: () => boolean\n isExactActive: () => boolean\n /** Reactive class string — pass directly to element for auto-updates on route change. */\n class: (() => string) | string | undefined\n style?: string\n target?: string\n rel?: string\n 'aria-label'?: string\n children?: any\n}\n\n/** Return type of useLink. */\nexport interface UseLinkReturn {\n /** Ref object — attach to the root element for viewport-based prefetch. */\n ref: import('@pyreon/core').Ref<HTMLElement>\n /** Click handler — performs client-side navigation. */\n handleClick: (e: MouseEvent) => void\n /** Mouse enter handler — triggers hover prefetch. */\n handleMouseEnter: () => void\n /** Touch start handler — triggers prefetch on mobile. */\n handleTouchStart: () => void\n /** Whether the link partially matches the current route. */\n isActive: () => boolean\n /** Whether the link exactly matches the current route. */\n isExactActive: () => boolean\n /** Resolved class string including active classes. */\n classes: () => string\n}\n\nconst prefetched = new Set<string>()\n\nfunction doPrefetch(href: string) {\n if (prefetched.has(href)) return\n prefetched.add(href)\n\n const docLink = document.createElement('link')\n docLink.rel = 'prefetch'\n docLink.href = href\n docLink.as = 'document'\n document.head.appendChild(docLink)\n\n try {\n const chunkHint = document.createElement('link')\n chunkHint.rel = 'modulepreload'\n chunkHint.href = href\n document.head.appendChild(chunkHint)\n } catch {\n // modulepreload is a hint, not critical\n }\n}\n\n/**\n * Composable that provides all link behavior — navigation, prefetching,\n * active state, and viewport observation.\n *\n * Use this for full control when `createLink` is too opinionated.\n *\n * @example\n * function MyLink(props: LinkProps) {\n * const link = useLink(props)\n * return (\n * <button ref={link.ref} class={link.classes()} onclick={link.handleClick}>\n * {props.children}\n * </button>\n * )\n * }\n */\nexport function useLink(props: LinkProps): UseLinkReturn {\n const router = useRouter()\n const elementRef = createRef<HTMLElement>()\n const strategy = props.prefetch ?? 'hover'\n\n function handleClick(e: MouseEvent) {\n if (\n e.defaultPrevented ||\n e.button !== 0 ||\n e.metaKey ||\n e.ctrlKey ||\n e.shiftKey ||\n e.altKey ||\n props.external\n ) {\n return\n }\n e.preventDefault()\n router.push(props.href)\n }\n\n function handleMouseEnter() {\n if (strategy === 'hover') {\n doPrefetch(props.href)\n }\n }\n\n function handleTouchStart() {\n if (strategy === 'hover' || strategy === 'viewport') {\n doPrefetch(props.href)\n }\n }\n\n if (strategy === 'viewport') {\n useIntersectionObserver(\n () => elementRef.current ?? undefined,\n () => doPrefetch(props.href),\n )\n }\n\n const isActive = () => {\n const currentPath = router.currentRoute()?.path\n if (!currentPath || !props.href) return false\n if (props.href === '/') return currentPath === '/'\n return currentPath.startsWith(props.href)\n }\n\n const isExactActive = () => {\n const currentPath = router.currentRoute()?.path\n if (!currentPath) return false\n return currentPath === props.href\n }\n\n const classes = () => {\n const cls: string[] = []\n if (props.class) cls.push(props.class)\n if (props.activeClass && isActive()) cls.push(props.activeClass)\n if (props.exactActiveClass && isExactActive())\n cls.push(props.exactActiveClass)\n return cls.join(' ')\n }\n\n return {\n ref: elementRef,\n handleClick,\n handleMouseEnter,\n handleTouchStart,\n isActive,\n isExactActive,\n classes,\n }\n}\n\n/**\n * Higher-order component that wraps any component with link behavior.\n *\n * The wrapped component receives {@link LinkRenderProps} with all handlers,\n * active state, and accessibility attributes pre-wired.\n *\n * @example\n * // Custom button link\n * const ButtonLink = createLink((props) => (\n * <button\n * ref={props.ref}\n * class={props.class}\n * onclick={props.onClick}\n * onmouseenter={props.onMouseEnter}\n * >\n * {props.children}\n * </button>\n * ))\n *\n * // Custom styled component\n * const CardLink = createLink((props) => (\n * <div\n * ref={props.ref}\n * class={`card ${props.isActive() ? \"card--active\" : \"\"}`}\n * onclick={props.onClick}\n * onmouseenter={props.onMouseEnter}\n * >\n * {props.children}\n * </div>\n * ))\n *\n * // Usage\n * <ButtonLink href=\"/about\">About</ButtonLink>\n * <CardLink href=\"/posts\" prefetch=\"viewport\">Posts</CardLink>\n */\nexport function createLink(\n Component: (props: LinkRenderProps) => any,\n): (props: LinkProps) => any {\n return function WrappedLink(props: LinkProps) {\n const link = useLink(props)\n\n return (\n <Component\n href={props.href}\n ref={link.ref}\n onClick={link.handleClick}\n onMouseEnter={link.handleMouseEnter}\n onTouchStart={link.handleTouchStart}\n isActive={link.isActive}\n isExactActive={link.isExactActive}\n class={link.classes}\n style={props.style}\n target={props.external ? '_blank' : undefined}\n rel={props.external ? 'noopener noreferrer' : undefined}\n aria-label={props['aria-label']}\n children={props.children}\n />\n )\n }\n}\n\n/**\n * Default navigation link built on an `<a>` tag.\n *\n * @example\n * <Link href=\"/about\" prefetch=\"viewport\">About</Link>\n * <Link href=\"/posts\" activeClass=\"nav-active\">Posts</Link>\n */\nexport const Link = createLink((props: LinkRenderProps) => (\n <a\n ref={props.ref}\n href={props.href}\n class={props.class}\n style={props.style}\n target={props.target}\n rel={props.rel}\n aria-label={props['aria-label']}\n aria-current={props.isExactActive() ? 'page' : undefined}\n onclick={props.onClick}\n onmouseenter={props.onMouseEnter}\n ontouchstart={props.onTouchStart}\n >\n {props.children}\n </a>\n))\n","import { createRef, onMount, onUnmount } from '@pyreon/core'\nimport { useIntersectionObserver } from './utils/use-intersection-observer'\n\n// ─── Script optimization component ─────────────────────────────────────────\n//\n// <Script> provides optimized third-party script loading:\n// - Defer loading until after hydration\n// - Load on idle (requestIdleCallback)\n// - Load on interaction (click, scroll, etc.)\n// - Load on viewport entry\n// - Worker offloading for analytics scripts\n\nexport interface ScriptProps {\n /** Script source URL. */\n src: string\n /** Loading strategy. Default: \"afterHydration\" */\n strategy?: ScriptStrategy\n /** Inline script content (alternative to src). */\n children?: string\n /** Script id for deduplication. */\n id?: string\n /** Async attribute. Default: true */\n async?: boolean\n /** onLoad callback. */\n onLoad?: () => void\n /** onError callback. */\n onError?: (error: Error) => void\n}\n\nexport type ScriptStrategy =\n | 'beforeHydration'\n | 'afterHydration'\n | 'onIdle'\n | 'onInteraction'\n | 'onViewport'\n\n/**\n * Optimized script loading component.\n *\n * @example\n * // Load analytics after page is interactive\n * <Script src=\"https://analytics.example.com/script.js\" strategy=\"onIdle\" />\n *\n * // Load chat widget when user scrolls\n * <Script src=\"/chat-widget.js\" strategy=\"onViewport\" />\n *\n * // Inline script with deferred execution\n * <Script strategy=\"afterHydration\">\n * {`console.log(\"App hydrated!\")`}\n * </Script>\n */\nexport function Script(props: ScriptProps) {\n function loadScript() {\n // Deduplication\n if (props.id && document.getElementById(props.id)) return\n\n const script = document.createElement('script')\n if (props.src) script.src = props.src\n if (props.id) script.id = props.id\n script.async = props.async !== false\n\n if (props.onLoad) script.onload = props.onLoad\n if (props.onError) {\n script.onerror = () =>\n props.onError?.(new Error(`Failed to load: ${props.src}`))\n }\n\n if (props.children && !props.src) {\n script.textContent = props.children\n }\n\n document.head.appendChild(script)\n }\n\n onMount(() => {\n const strategy = props.strategy ?? 'afterHydration'\n\n switch (strategy) {\n case 'beforeHydration':\n // Already in HTML — do nothing\n break\n\n case 'afterHydration':\n // Load immediately after mount (hydration is complete)\n loadScript()\n break\n\n case 'onIdle':\n if ('requestIdleCallback' in window) {\n requestIdleCallback(() => loadScript(), { timeout: 5000 })\n } else {\n setTimeout(loadScript, 200)\n }\n break\n\n case 'onInteraction': {\n const events = ['click', 'scroll', 'keydown', 'touchstart']\n function handler() {\n for (const e of events) document.removeEventListener(e, handler)\n loadScript()\n }\n for (const e of events) {\n document.addEventListener(e, handler, { once: true, passive: true })\n }\n onUnmount(() => {\n for (const e of events) document.removeEventListener(e, handler)\n })\n break\n }\n\n case 'onViewport':\n // Handled below via useIntersectionObserver on the sentinel element\n break\n }\n return undefined\n })\n\n const sentinelRef = createRef<HTMLElement>()\n const strategy = props.strategy ?? 'afterHydration'\n\n if (strategy === 'onViewport') {\n useIntersectionObserver(\n () => sentinelRef.current ?? undefined,\n () => loadScript(),\n )\n }\n\n if (strategy === 'onViewport') {\n return <div ref={sentinelRef} style=\"width:0;height:0;overflow:hidden\" />\n }\n\n return null\n}\n","import type { Middleware, MiddlewareContext } from '@pyreon/server'\n\n// ─── Cache control middleware ───────────────────────────────────────────────\n//\n// Smart caching middleware that sets appropriate cache headers based on\n// asset type, URL patterns, and build hashes.\n//\n// Strategies:\n// - Immutable: hashed assets (JS/CSS bundles) — cached forever\n// - Static: images, fonts, media — long cache with revalidation\n// - Dynamic: HTML pages — short or no cache, stale-while-revalidate\n// - API: JSON responses — no cache by default\n\nexport interface CacheConfig {\n /** Cache duration for immutable hashed assets (seconds). Default: 31536000 (1 year) */\n immutable?: number\n /** Cache duration for static assets like images/fonts (seconds). Default: 86400 (1 day) */\n static?: number\n /** Cache duration for pages (seconds). Default: 0 (no cache) */\n pages?: number\n /** Stale-while-revalidate window for pages (seconds). Default: 60 */\n staleWhileRevalidate?: number\n /** Custom rules by URL pattern. */\n rules?: CacheRule[]\n}\n\nexport interface CacheRule {\n /** URL pattern to match (glob-style). e.g. \"/api/*\" */\n match: string\n /** Cache-Control header value. */\n control: string\n}\n\nconst HASHED_ASSET = /\\.[a-f0-9]{8,}\\.\\w+$/\nconst STATIC_EXT =\n /\\.(png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf|eot|mp4|webm|ogg|mp3|wav)$/i\nconst SCRIPT_EXT = /\\.(js|css|mjs)$/i\n\n/** @internal Exported for testing */\nexport function matchGlob(pattern: string, path: string): boolean {\n // Escape regex special chars, then convert glob wildcards\n const escaped = pattern.replace(/[.+^${}()|[\\]\\\\]/g, '\\\\$&')\n const regex = escaped.replace(/\\*/g, '.*').replace(/\\?/g, '.')\n return new RegExp(`^${regex}$`).test(path)\n}\n\nfunction resolveControl(\n path: string,\n immutableDuration: number,\n staticDuration: number,\n pageDuration: number,\n swr: number,\n): string {\n if (HASHED_ASSET.test(path)) {\n return `public, max-age=${immutableDuration}, immutable`\n }\n if (SCRIPT_EXT.test(path)) {\n return `public, max-age=3600, stale-while-revalidate=${swr}`\n }\n if (STATIC_EXT.test(path)) {\n return `public, max-age=${staticDuration}, stale-while-revalidate=${swr}`\n }\n if (pageDuration > 0) {\n return `public, max-age=${pageDuration}, stale-while-revalidate=${swr}`\n }\n return 'no-cache'\n}\n\n/**\n * Cache control middleware for Zero.\n * Sets Cache-Control headers on the response based on asset type.\n *\n * @example\n * import { cacheMiddleware } from \"@pyreon/zero/cache\"\n *\n * export default createHandler({\n * routes,\n * middleware: [\n * cacheMiddleware({\n * pages: 60,\n * staleWhileRevalidate: 300,\n * rules: [\n * { match: \"/api/*\", control: \"no-store\" },\n * ],\n * }),\n * ],\n * })\n */\nexport function cacheMiddleware(config: CacheConfig = {}): Middleware {\n const immutableDuration = config.immutable ?? 31536000\n const staticDuration = config.static ?? 86400\n const pageDuration = config.pages ?? 0\n const swr = config.staleWhileRevalidate ?? 60\n const rules = config.rules ?? []\n\n return (ctx: MiddlewareContext) => {\n const path = ctx.url.pathname\n\n for (const rule of rules) {\n if (matchGlob(rule.match, path)) {\n ctx.headers.set('Cache-Control', rule.control)\n return\n }\n }\n\n const control = resolveControl(\n path,\n immutableDuration,\n staticDuration,\n pageDuration,\n swr,\n )\n ctx.headers.set('Cache-Control', control)\n }\n}\n\n/**\n * Security headers middleware.\n * Adds common security headers to all responses.\n */\nexport function securityHeaders(): Middleware {\n return (ctx: MiddlewareContext) => {\n ctx.headers.set('X-Content-Type-Options', 'nosniff')\n ctx.headers.set('X-Frame-Options', 'DENY')\n ctx.headers.set('X-XSS-Protection', '1; mode=block')\n ctx.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')\n ctx.headers.set(\n 'Permissions-Policy',\n 'camera=(), microphone=(), geolocation=()',\n )\n }\n}\n\n/**\n * Compression detection middleware.\n * Sets Vary: Accept-Encoding header so caches can serve compressed variants.\n * Actual compression is handled by the runtime (Bun/Node) or reverse proxy.\n */\nexport function varyEncoding(): Middleware {\n return (ctx: MiddlewareContext) => {\n const existing = ctx.headers.get('Vary')\n if (!existing?.includes('Accept-Encoding')) {\n ctx.headers.set(\n 'Vary',\n existing ? `${existing}, Accept-Encoding` : 'Accept-Encoding',\n )\n }\n }\n}\n","import type { Plugin } from 'vite'\n\n// ─── Font optimization ──────────────────────────────────────────────────────\n//\n// Zero provides automatic font optimization:\n// - Downloads and self-hosts Google Fonts at build time (privacy + performance)\n// - Falls back to CDN link in dev mode (for fast dev startup)\n// - Injects preconnect/preload hints into the HTML\n// - Sets font-display: swap to prevent FOIT (Flash of Invisible Text)\n// - Generates optimized @font-face declarations\n// - Size-adjusted fallback fonts to reduce CLS\n\nexport interface FontConfig {\n /**\n * Google Fonts families.\n *\n * Accepts both string shorthand and structured objects:\n * - String: \"Inter:wght@400;500;700\" or \"Inter:wght@100..900\"\n * - Object: { family: \"Inter\", weights: [400, 500, 700] }\n * - Variable: { family: \"Inter\", variable: true, weightRange: [100, 900] }\n */\n google?: GoogleFontInput[]\n /** Local font files. */\n local?: LocalFont[]\n /** Default font-display strategy. Default: \"swap\" */\n display?: FontDisplay\n /** Preload critical fonts. Default: true */\n preload?: boolean\n /** Self-host Google Fonts at build time. Default: true */\n selfHost?: boolean\n /** Fallback font metrics for reducing CLS. */\n fallbacks?: Record<string, FallbackMetrics>\n}\n\n/** Static Google Font config. */\nexport interface GoogleFontStatic {\n family: string\n weights: number[]\n italic?: boolean\n variable?: false\n}\n\n/** Variable Google Font config. */\nexport interface GoogleFontVariable {\n family: string\n /** Weight range as [min, max] tuple. e.g. [100, 900] */\n weightRange: [number, number]\n italic?: boolean\n variable: true\n}\n\n/** Google font input: structured object or string shorthand. */\nexport type GoogleFontInput = GoogleFontStatic | GoogleFontVariable | string\n\nexport interface LocalFont {\n family: string\n src: string\n /** Single weight (400) or variable range (\"100 900\"). */\n weight?: number | `${number} ${number}`\n style?: 'normal' | 'italic'\n display?: FontDisplay\n}\n\nexport type FontDisplay = 'auto' | 'block' | 'swap' | 'fallback' | 'optional'\n\n/** Metrics for generating size-adjusted fallback fonts to reduce CLS. */\nexport interface FallbackMetrics {\n /** The fallback font to adjust. e.g. \"Arial\", \"Georgia\" */\n fallback: string\n /** Size adjustment factor. e.g. 1.05 */\n sizeAdjust?: number\n /** Ascent override percentage. e.g. 90 */\n ascentOverride?: number\n /** Descent override percentage. e.g. 22 */\n descentOverride?: number\n /** Line gap override percentage. e.g. 0 */\n lineGapOverride?: number\n}\n\ninterface ResolvedFontBase {\n family: string\n italic: boolean\n}\n\ninterface StaticFont extends ResolvedFontBase {\n variable: false\n weights: number[]\n}\n\ninterface VariableFont extends ResolvedFontBase {\n variable: true\n weightRange: [number, number]\n}\n\ntype ResolvedFont = StaticFont | VariableFont\n\n/**\n * Normalize a GoogleFontInput (string or object) into a ResolvedFont.\n */\nexport function resolveGoogleFont(input: GoogleFontInput): ResolvedFont {\n if (typeof input === 'string') {\n return parseGoogleFamily(input)\n }\n\n if (input.variable) {\n return {\n family: input.family,\n italic: input.italic ?? false,\n variable: true,\n weightRange: input.weightRange,\n }\n }\n\n return {\n family: input.family,\n italic: input.italic ?? false,\n variable: false,\n weights: input.weights,\n }\n}\n\n/**\n * Parse Google Fonts family string shorthand.\n *\n * Static weights: \"Inter:wght@400;500;700\"\n * Variable range: \"Inter:wght@100..900\"\n * Variable with italic: \"Inter:ital,wght@100..900\"\n */\nexport function parseGoogleFamily(input: string): ResolvedFont {\n const [familyPart, spec] = input.split(':')\n const family = familyPart?.trim()\n let italic = false\n\n if (spec) {\n italic = spec.includes('ital')\n\n // Variable font range syntax: wght@100..900\n const rangeMatch = spec.match(/wght@(\\d+)\\.\\.(\\d+)/)\n if (rangeMatch) {\n return {\n family,\n italic,\n variable: true,\n weightRange: [Number(rangeMatch[1]), Number(rangeMatch[2])],\n }\n }\n\n // Static weights: wght@400;500;700\n const weightMatch = spec.match(/wght@([\\d;]+)/)\n if (weightMatch) {\n return {\n family,\n italic,\n variable: false,\n weights: weightMatch[1]?.split(';').map(Number),\n }\n }\n }\n\n return { family, italic, variable: false, weights: [400] }\n}\n\n/**\n * Generate a Google Fonts CSS URL.\n */\nexport function googleFontsUrl(\n families: ResolvedFont[],\n display: FontDisplay = 'swap',\n): string {\n const params = families\n .map((f) => {\n const axes = f.italic ? 'ital,wght' : 'wght'\n const name = f.family.replace(/ /g, '+')\n\n if (f.variable) {\n const range = `${f.weightRange[0]}..${f.weightRange[1]}`\n const value = f.italic ? `0,${range};1,${range}` : range\n return `family=${name}:${axes}@${value}`\n }\n\n const values = f.weights\n .map((w) => (f.italic ? `0,${w};1,${w}` : String(w)))\n .join(';')\n return `family=${name}:${axes}@${values}`\n })\n .join('&')\n\n return `https://fonts.googleapis.com/css2?${params}&display=${display}`\n}\n\n/**\n * Generate @font-face CSS for local fonts.\n */\nfunction localFontFaces(fonts: LocalFont[], display: FontDisplay): string {\n return fonts\n .map(\n (f) => `@font-face {\n font-family: \"${f.family}\";\n src: url(\"${f.src}\");\n font-weight: ${f.weight ?? '400'};\n font-style: ${f.style ?? 'normal'};\n font-display: ${f.display ?? display};\n}`,\n )\n .join('\\n\\n')\n}\n\n/**\n * Generate size-adjusted fallback @font-face declarations to reduce CLS.\n */\nfunction fallbackFontFaces(fallbacks: Record<string, FallbackMetrics>): string {\n return Object.entries(fallbacks)\n .map(([family, metrics]) => {\n const overrides: string[] = []\n if (metrics.sizeAdjust != null)\n overrides.push(` size-adjust: ${metrics.sizeAdjust * 100}%;`)\n if (metrics.ascentOverride != null)\n overrides.push(` ascent-override: ${metrics.ascentOverride}%;`)\n if (metrics.descentOverride != null)\n overrides.push(` descent-override: ${metrics.descentOverride}%;`)\n if (metrics.lineGapOverride != null)\n overrides.push(` line-gap-override: ${metrics.lineGapOverride}%;`)\n\n return `@font-face {\n font-family: \"${family} Fallback\";\n src: local(\"${metrics.fallback}\");\n${overrides.join('\\n')}\n}`\n })\n .join('\\n\\n')\n}\n\n/**\n * Generate preload link tags for critical font files.\n */\nfunction preloadTags(fonts: LocalFont[]): string {\n return fonts\n .map((f) => {\n const ext = f.src.split('.').pop()\n const type =\n ext === 'woff2'\n ? 'font/woff2'\n : ext === 'woff'\n ? 'font/woff'\n : ext === 'ttf'\n ? 'font/ttf'\n : 'font/otf'\n return `<link rel=\"preload\" href=\"${f.src}\" as=\"font\" type=\"${type}\" crossorigin>`\n })\n .join('\\n')\n}\n\n/**\n * Download Google Fonts CSS with woff2 user agent.\n */\nasync function downloadGoogleFontsCSS(url: string): Promise<string> {\n const response = await fetch(url, {\n headers: {\n 'User-Agent':\n 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',\n },\n })\n if (!response.ok) {\n throw new Error(`Failed to fetch Google Fonts CSS: ${response.status}`)\n }\n return response.text()\n}\n\n/**\n * Download a font file.\n */\nasync function downloadFontFile(url: string): Promise<Buffer> {\n const response = await fetch(url)\n if (!response.ok) throw new Error(`Failed to download font: ${url}`)\n const arrayBuffer = await response.arrayBuffer()\n return Buffer.from(arrayBuffer)\n}\n\n/**\n * Extract font file URLs from Google Fonts CSS.\n */\nfunction extractFontUrls(css: string): string[] {\n const urls: string[] = []\n const regex = /url\\((https:\\/\\/fonts\\.gstatic\\.com\\/[^)]+)\\)/g\n for (const match of css.matchAll(regex)) {\n if (match[1]) urls.push(match[1])\n }\n return urls\n}\n\n/**\n * Self-host Google Fonts: download CSS + font files, rewrite URLs to local paths.\n */\nasync function selfHostFonts(\n cssUrl: string,\n fontsSubDir: string,\n): Promise<{\n css: string\n fontFiles: Array<{ name: string; content: Buffer }>\n}> {\n const css = await downloadGoogleFontsCSS(cssUrl)\n const fontUrls = extractFontUrls(css)\n const fontFiles: Array<{ name: string; content: Buffer }> = []\n\n let rewrittenCss = css\n\n for (const url of fontUrls) {\n const urlParts = url.split('/')\n const fileName = urlParts.at(-1)?.split('?')[0] ?? 'font'\n const content = await downloadFontFile(url)\n\n fontFiles.push({ name: fileName, content })\n rewrittenCss = rewrittenCss.replace(url, `/${fontsSubDir}/${fileName}`)\n }\n\n return { css: rewrittenCss, fontFiles }\n}\n\n/**\n * Zero font optimization Vite plugin.\n *\n * Dev mode: injects Google Fonts CDN link for fast startup.\n * Build mode: downloads and self-hosts fonts for maximum performance + privacy.\n *\n * @example\n * import { fontPlugin } from \"@pyreon/zero/font\"\n *\n * export default {\n * plugins: [\n * pyreon(),\n * zero(),\n * fontPlugin({\n * google: [\"Inter:wght@400;500;600;700\", \"JetBrains Mono:wght@400\"],\n * fallbacks: {\n * \"Inter\": { fallback: \"Arial\", sizeAdjust: 1.07, ascentOverride: 90 },\n * },\n * }),\n * ],\n * }\n */\nexport function fontPlugin(config: FontConfig = {}): Plugin {\n const display = config.display ?? 'swap'\n const shouldPreload = config.preload !== false\n const shouldSelfHost = config.selfHost !== false\n const googleFamilies = (config.google ?? []).map(resolveGoogleFont)\n\n let isBuild = false\n let selfHostedCSS = ''\n let selfHostedFontFiles: Array<{ name: string; content: Buffer }> = []\n\n return {\n name: 'pyreon-zero-fonts',\n\n configResolved(resolvedConfig) {\n isBuild = resolvedConfig.command === 'build'\n },\n\n async buildStart() {\n if (isBuild && shouldSelfHost && googleFamilies.length > 0) {\n const cssUrl = googleFontsUrl(googleFamilies, display)\n try {\n const result = await selfHostFonts(cssUrl, 'assets/fonts')\n selfHostedCSS = result.css\n selfHostedFontFiles = result.fontFiles\n } catch {\n // Self-hosting failed — fall back to CDN link\n }\n }\n },\n\n generateBundle() {\n // Emit self-hosted font files as assets\n for (const file of selfHostedFontFiles) {\n this.emitFile({\n type: 'asset',\n fileName: `assets/fonts/${file.name}`,\n source: file.content,\n })\n }\n },\n\n transformIndexHtml(html) {\n const tags: string[] = []\n\n collectGoogleFontTags(tags, {\n isBuild,\n selfHostedCSS,\n selfHostedFontFiles,\n shouldPreload,\n googleFamilies,\n display,\n })\n collectLocalFontTags(tags, config, shouldPreload, display)\n\n if (tags.length === 0) return html\n return html.replace('</head>', `${tags.join('\\n')}\\n</head>`)\n },\n }\n}\n\nfunction collectGoogleFontTags(\n tags: string[],\n opts: {\n isBuild: boolean\n selfHostedCSS: string\n selfHostedFontFiles: Array<{ name: string; content: Buffer }>\n shouldPreload: boolean\n googleFamilies: ResolvedFont[]\n display: FontDisplay\n },\n) {\n if (opts.isBuild && opts.selfHostedCSS) {\n tags.push(`<style>${opts.selfHostedCSS}</style>`)\n if (opts.shouldPreload) {\n for (const file of opts.selfHostedFontFiles.slice(\n 0,\n opts.googleFamilies.length,\n )) {\n const ext = file.name.split('.').pop()\n const type = ext === 'woff2' ? 'font/woff2' : 'font/woff'\n tags.push(\n `<link rel=\"preload\" href=\"/assets/fonts/${file.name}\" as=\"font\" type=\"${type}\" crossorigin>`,\n )\n }\n }\n } else if (opts.googleFamilies.length > 0) {\n const cssUrl = googleFontsUrl(opts.googleFamilies, opts.display)\n tags.push(`<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">`)\n tags.push(\n `<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>`,\n )\n tags.push(`<link rel=\"stylesheet\" href=\"${cssUrl}\">`)\n }\n}\n\nfunction collectLocalFontTags(\n tags: string[],\n config: FontConfig,\n shouldPreload: boolean,\n display: FontDisplay,\n) {\n if (shouldPreload && config.local?.length) {\n tags.push(preloadTags(config.local))\n }\n if (config.local?.length) {\n tags.push(`<style>${localFontFaces(config.local, display)}</style>`)\n }\n if (config.fallbacks && Object.keys(config.fallbacks).length > 0) {\n tags.push(`<style>${fallbackFontFaces(config.fallbacks)}</style>`)\n }\n}\n\n/**\n * Generate CSS variables for font families.\n */\nexport function fontVariables(families: Record<string, string>): string {\n const vars = Object.entries(families)\n .map(([key, value]) => ` --font-${key}: ${value};`)\n .join('\\n')\n return `:root {\\n${vars}\\n}`\n}\n","import { existsSync } from 'node:fs'\nimport { mkdir, readFile, writeFile } from 'node:fs/promises'\nimport { basename, extname, join } from 'node:path'\nimport type { Plugin } from 'vite'\n\nlet sharpWarned = false\nfunction warnSharpMissing() {\n if (sharpWarned) return\n sharpWarned = true\n // biome-ignore lint/suspicious/noConsole: intentional build-time warning\n console.warn(\n '\\n[zero:image] sharp not installed — images will not be optimized. Install for full support: bun add -D sharp\\n',\n )\n}\n\n// ─── Image processing Vite plugin ──────────────────────────────────────────\n//\n// Processes images at build time:\n// - Generates multiple sizes for responsive srcset\n// - Converts to modern formats (WebP, AVIF)\n// - Creates tiny blur placeholders (base64 inline)\n// - Outputs optimized images to the build directory\n//\n// Usage in code:\n// import heroImg from \"./hero.jpg?optimize\"\n// // → { src, srcset, width, height, placeholder }\n//\n// Or use the component helper:\n// import { Image } from \"@pyreon/zero/image\"\n// <Image src=\"/hero.jpg\" width={1920} height={1080} optimize />\n\nexport interface ImagePluginConfig {\n /** Output directory for processed images. Default: \"assets/img\" */\n outDir?: string\n /** Default widths for responsive images. Default: [640, 1024, 1920] */\n widths?: number[]\n /** Output formats. Default: [\"webp\"] */\n formats?: ImageFormat[]\n /** Quality for lossy formats (1-100). Default: 80 */\n quality?: number\n /** Blur placeholder size in px. Default: 16 */\n placeholderSize?: number\n /** File patterns to process. Default: /\\.(jpe?g|png|webp|avif)$/i */\n include?: RegExp\n}\n\nexport type ImageFormat = 'webp' | 'avif' | 'jpeg' | 'png'\n\n/** Per-format source set for <picture> <source> elements. */\nexport interface FormatSource {\n /** MIME type. e.g. \"image/webp\", \"image/avif\" */\n type: string\n /** srcset string for this format. e.g. \"/img-640.webp 640w, /img-1920.webp 1920w\" */\n srcset: string\n}\n\nexport interface ProcessedImage {\n /** Fallback source path (last format, largest width). */\n src: string\n /** Fallback srcset string (last format). */\n srcset: string\n /** Intrinsic width. */\n width: number\n /** Intrinsic height. */\n height: number\n /** Base64 blur placeholder data URI. */\n placeholder: string\n /** Per-format source sets for <picture> element. Ordered by priority (best format first). */\n formats: FormatSource[]\n /** Flat list of all sources. */\n sources: Array<{ src: string; width: number; format: string }>\n}\n\nconst IMAGE_EXT_RE = /\\.(jpe?g|png|webp|avif)$/i\n\n/**\n * Zero image processing Vite plugin.\n *\n * Transforms image imports with query params into optimized responsive images:\n *\n * @example\n * // vite.config.ts\n * import { imagePlugin } from \"@pyreon/zero/image-plugin\"\n *\n * export default {\n * plugins: [\n * pyreon(),\n * zero(),\n * imagePlugin({ widths: [480, 960, 1440], quality: 85 }),\n * ],\n * }\n *\n * @example\n * // In a component — import with ?optimize\n * import hero from \"./images/hero.jpg?optimize\"\n * // hero = { src, srcset, width, height, placeholder }\n *\n * <Image {...hero} alt=\"Hero\" priority />\n */\nexport function imagePlugin(config: ImagePluginConfig = {}): Plugin {\n const defaultWidths = config.widths ?? [640, 1024, 1920]\n const defaultFormats = config.formats ?? ['webp']\n const quality = config.quality ?? 80\n const placeholderSize = config.placeholderSize ?? 16\n const outSubDir = config.outDir ?? 'assets/img'\n const include = config.include ?? IMAGE_EXT_RE\n\n let root = ''\n let outDir = ''\n let isBuild = false\n\n return {\n name: 'pyreon-zero-images',\n enforce: 'pre',\n\n configResolved(resolvedConfig) {\n root = resolvedConfig.root\n outDir = resolvedConfig.build.outDir\n isBuild = resolvedConfig.command === 'build'\n },\n\n async resolveId(id) {\n // Handle ?optimize query on image imports\n if (id.includes('?optimize') && include.test(id.split('?')[0]!)) {\n return `\\0virtual:zero-image:${id}`\n }\n return null\n },\n\n async load(id) {\n if (!id.startsWith('\\0virtual:zero-image:')) return null\n\n const rawPath =\n id.replace('\\0virtual:zero-image:', '').split('?')[0] ?? id\n const absPath = rawPath.startsWith('/')\n ? join(root, 'public', rawPath)\n : rawPath\n\n if (!isBuild) {\n const result = await loadDevImage(absPath, rawPath, placeholderSize)\n return `export default ${JSON.stringify(result)}`\n }\n\n const processed = await processImage(absPath, {\n widths: defaultWidths,\n formats: defaultFormats,\n quality,\n placeholderSize,\n outSubDir,\n outDir: join(root, outDir),\n })\n\n await emitProcessedSources(processed, outSubDir, this)\n rebuildFormatSrcsets(processed, absPath)\n\n return `export default ${JSON.stringify(processed)}`\n },\n }\n}\n\nasync function loadDevImage(\n absPath: string,\n rawPath: string,\n placeholderSize: number,\n): Promise<ProcessedImage> {\n const metadata = await getImageMetadata(absPath)\n const publicPath = rawPath.startsWith('/') ? rawPath : `/@fs/${absPath}`\n\n return {\n src: publicPath,\n srcset: '',\n width: metadata.width,\n height: metadata.height,\n placeholder: await generateBlurPlaceholder(absPath, placeholderSize),\n formats: [],\n sources: [{ src: publicPath, width: metadata.width, format: 'original' }],\n }\n}\n\nasync function emitProcessedSources(\n processed: ProcessedImage,\n outSubDir: string,\n ctx: {\n emitFile: (f: {\n type: 'asset'\n fileName: string\n source: Uint8Array\n }) => void\n },\n) {\n for (const source of processed.sources) {\n const fileName = join(outSubDir, basename(source.src))\n const content = await readFile(source.src)\n ctx.emitFile({ type: 'asset', fileName, source: content })\n source.src = `/${fileName}`\n }\n}\n\nfunction rebuildFormatSrcsets(processed: ProcessedImage, fallbackPath: string) {\n const formatGroups = new Map<string, string[]>()\n for (const s of processed.sources) {\n let group = formatGroups.get(s.format)\n if (!group) {\n group = []\n formatGroups.set(s.format, group)\n }\n group.push(`${s.src} ${s.width}w`)\n }\n processed.formats = [...formatGroups.entries()].map(([fmt, entries]) => ({\n type: `image/${fmt}`,\n srcset: entries.join(', '),\n }))\n\n const lastFormat = processed.formats.at(-1)\n processed.srcset = lastFormat?.srcset ?? ''\n processed.src = processed.sources.at(-1)?.src ?? fallbackPath\n}\n\n// ─── Image processing utilities ─────────────────────────────────────────────\n\ninterface ProcessOptions {\n widths: number[]\n formats: ImageFormat[]\n quality: number\n placeholderSize: number\n outSubDir: string\n outDir: string\n}\n\nasync function processImage(\n absPath: string,\n opts: ProcessOptions,\n): Promise<ProcessedImage> {\n const metadata = await getImageMetadata(absPath)\n const ext = extname(absPath)\n const name = basename(absPath, ext)\n const sources: Array<{ src: string; width: number; format: string }> = []\n\n // Ensure output directory exists\n const processedDir = join(opts.outDir, opts.outSubDir)\n if (!existsSync(processedDir)) {\n await mkdir(processedDir, { recursive: true })\n }\n\n // Generate resized variants — iterate formats first so sources are grouped by format\n for (const format of opts.formats) {\n for (const targetWidth of opts.widths) {\n // Don't upscale\n const width = Math.min(targetWidth, metadata.width)\n const outName = `${name}-${width}.${format}`\n const outPath = join(processedDir, outName)\n\n await resizeImage(absPath, outPath, width, format, opts.quality)\n sources.push({ src: outPath, width, format })\n }\n }\n\n // Build per-format source sets for <picture>\n const formatGroups = new Map<string, Array<{ src: string; width: number }>>()\n for (const s of sources) {\n let group = formatGroups.get(s.format)\n if (!group) {\n group = []\n formatGroups.set(s.format, group)\n }\n group.push({ src: s.src, width: s.width })\n }\n\n const formats: FormatSource[] = [...formatGroups.entries()].map(\n ([fmt, group]) => ({\n type: `image/${fmt === 'jpeg' ? 'jpeg' : fmt}`,\n srcset: group.map((s) => `${s.src} ${s.width}w`).join(', '),\n }),\n )\n\n // Fallback: last format's srcset\n const fallbackFormat = formats[formats.length - 1]\n const fallbackSources = formatGroups.get([...formatGroups.keys()].pop()!)!\n\n // Generate blur placeholder\n const placeholder = await generateBlurPlaceholder(\n absPath,\n opts.placeholderSize,\n )\n\n return {\n src: fallbackSources[fallbackSources.length - 1]?.src ?? absPath,\n srcset: fallbackFormat?.srcset ?? '',\n width: metadata.width,\n height: metadata.height,\n placeholder,\n formats,\n sources,\n }\n}\n\ninterface ImageMetadata {\n width: number\n height: number\n format: string\n}\n\n/**\n * Read basic image metadata.\n * Uses minimal binary header parsing — no external dependencies.\n */\nasync function getImageMetadata(absPath: string): Promise<ImageMetadata> {\n const buffer = await readFile(absPath)\n const ext = extname(absPath).toLowerCase()\n\n if (ext === '.png') {\n // PNG: width at bytes 16-19, height at 20-23 (big-endian)\n const width = buffer.readUInt32BE(16)\n const height = buffer.readUInt32BE(20)\n return { width, height, format: 'png' }\n }\n\n if (ext === '.jpg' || ext === '.jpeg') {\n // JPEG: scan for SOF markers\n const dimensions = parseJpegDimensions(buffer)\n return { ...dimensions, format: 'jpeg' }\n }\n\n if (ext === '.webp') {\n // WebP: VP8 header\n const dimensions = parseWebPDimensions(buffer)\n return { ...dimensions, format: 'webp' }\n }\n\n // Fallback\n return { width: 0, height: 0, format: ext.slice(1) }\n}\n\n/** @internal Exported for testing */\nexport function parseJpegDimensions(buffer: Buffer): {\n width: number\n height: number\n} {\n let offset = 2 // Skip SOI marker\n while (offset < buffer.length) {\n if (buffer[offset] !== 0xff) break\n const marker = buffer[offset + 1]!\n // SOF markers (0xC0-0xCF except 0xC4, 0xC8, 0xCC)\n if (\n marker >= 0xc0 &&\n marker <= 0xcf &&\n marker !== 0xc4 &&\n marker !== 0xc8 &&\n marker !== 0xcc\n ) {\n const height = buffer.readUInt16BE(offset + 5)\n const width = buffer.readUInt16BE(offset + 7)\n return { width, height }\n }\n const length = buffer.readUInt16BE(offset + 2)\n offset += 2 + length\n }\n return { width: 0, height: 0 }\n}\n\n/** @internal Exported for testing */\nexport function parseWebPDimensions(buffer: Buffer): {\n width: number\n height: number\n} {\n // RIFF header: bytes 0-3 = \"RIFF\", 8-11 = \"WEBP\"\n const chunk = buffer.toString('ascii', 12, 16)\n if (chunk === 'VP8 ') {\n // Lossy VP8\n const width = buffer.readUInt16LE(26) & 0x3fff\n const height = buffer.readUInt16LE(28) & 0x3fff\n return { width, height }\n }\n if (chunk === 'VP8L') {\n // Lossless VP8L\n const bits = buffer.readUInt32LE(21)\n const width = (bits & 0x3fff) + 1\n const height = ((bits >> 14) & 0x3fff) + 1\n return { width, height }\n }\n if (chunk === 'VP8X') {\n // Extended VP8X\n const width =\n 1 + ((buffer[24]! | (buffer[25]! << 8) | (buffer[26]! << 16)) & 0xffffff)\n const height =\n 1 + ((buffer[27]! | (buffer[28]! << 8) | (buffer[29]! << 16)) & 0xffffff)\n return { width, height }\n }\n return { width: 0, height: 0 }\n}\n\n/**\n * Resize an image using native platform capabilities.\n * Uses sharp if available, falls back to canvas API.\n */\nasync function resizeImage(\n input: string,\n output: string,\n width: number,\n format: ImageFormat,\n quality: number,\n): Promise<void> {\n try {\n // Try sharp (the standard Node.js image processing library)\n const sharp = await import('sharp').then((m) => m.default ?? m)\n let pipeline = sharp(input).resize(width)\n\n switch (format) {\n case 'webp':\n pipeline = pipeline.webp({ quality })\n break\n case 'avif':\n pipeline = pipeline.avif({ quality })\n break\n case 'jpeg':\n pipeline = pipeline.jpeg({ quality, mozjpeg: true })\n break\n case 'png':\n pipeline = pipeline.png({ compressionLevel: 9 })\n break\n }\n\n await pipeline.toFile(output)\n } catch {\n // sharp not available — copy original as fallback\n warnSharpMissing()\n const content = await readFile(input)\n await writeFile(output, content)\n }\n}\n\n/**\n * Generate a tiny blur placeholder as a base64 data URI.\n */\nasync function generateBlurPlaceholder(\n input: string,\n size: number,\n): Promise<string> {\n try {\n const sharp = await import('sharp').then((m) => m.default ?? m)\n const buffer = await sharp(input)\n .resize(size, size, { fit: 'inside' })\n .blur(2)\n .webp({ quality: 20 })\n .toBuffer()\n\n return `data:image/webp;base64,${buffer.toString('base64')}`\n } catch {\n // sharp not available — return a transparent placeholder\n return \"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='1'%3E%3C/svg%3E\"\n }\n}\n","import { onMount, onUnmount } from '@pyreon/core'\nimport { effect, signal } from '@pyreon/reactivity'\n\n// ─── Theme system ───────────────────────────────────────────────────────────\n//\n// Provides dark/light/system theme support with:\n// - System preference detection via matchMedia\n// - Persistent preference via localStorage\n// - No flash of wrong theme (inline script in HTML)\n// - Reactive theme signal for components\n\nexport type Theme = 'light' | 'dark' | 'system'\n\nconst STORAGE_KEY = 'zero-theme'\n\n/** Reactive theme signal. */\nexport const theme = signal<Theme>('system')\n\n/** Computed resolved theme (what's actually applied). */\nexport function resolvedTheme(): 'light' | 'dark' {\n const t = theme()\n if (t === 'system') {\n if (typeof window === 'undefined') return 'dark'\n return window.matchMedia('(prefers-color-scheme: dark)').matches\n ? 'dark'\n : 'light'\n }\n return t\n}\n\n/** Toggle between light and dark. */\nexport function toggleTheme() {\n const current = resolvedTheme()\n setTheme(current === 'dark' ? 'light' : 'dark')\n}\n\n/** Set theme explicitly. */\nexport function setTheme(t: Theme) {\n theme.set(t)\n if (typeof document !== 'undefined') {\n document.documentElement.dataset.theme = resolvedTheme()\n try {\n localStorage.setItem(STORAGE_KEY, t)\n } catch {\n // localStorage may not be available (SSR, private browsing)\n }\n }\n}\n\n/**\n * Initialize the theme system. Call once in your app entry or layout.\n * Reads from localStorage, listens for system preference changes.\n */\nexport function initTheme() {\n onMount(() => {\n // Read persisted preference\n try {\n const stored = localStorage.getItem(STORAGE_KEY) as Theme | null\n if (stored === 'light' || stored === 'dark' || stored === 'system') {\n theme.set(stored)\n }\n } catch {\n // localStorage may not be available\n }\n\n // Apply to document\n document.documentElement.dataset.theme = resolvedTheme()\n\n // Watch for system preference changes\n const mq = window.matchMedia('(prefers-color-scheme: dark)')\n function onChange() {\n if (theme() === 'system') {\n document.documentElement.dataset.theme = resolvedTheme()\n }\n }\n mq.addEventListener('change', onChange)\n onUnmount(() => mq.removeEventListener('change', onChange))\n\n // Re-apply when theme signal changes\n const dispose = effect(() => {\n document.documentElement.dataset.theme = resolvedTheme()\n })\n if (dispose) onUnmount(() => dispose.dispose())\n\n return undefined\n })\n}\n\n/**\n * Theme toggle button component.\n *\n * @example\n * import { ThemeToggle } from \"@pyreon/zero/theme\"\n * <ThemeToggle />\n */\nexport function ThemeToggle(props: { class?: string; style?: string }) {\n initTheme()\n\n return (\n <button\n class={props.class}\n style={props.style}\n onclick={toggleTheme}\n aria-label=\"Toggle theme\"\n title=\"Toggle theme\"\n type=\"button\"\n >\n {() =>\n resolvedTheme() === 'dark' ? (\n <svg\n width=\"18\"\n height=\"18\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"5\" />\n <line x1=\"12\" y1=\"1\" x2=\"12\" y2=\"3\" />\n <line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"23\" />\n <line x1=\"4.22\" y1=\"4.22\" x2=\"5.64\" y2=\"5.64\" />\n <line x1=\"18.36\" y1=\"18.36\" x2=\"19.78\" y2=\"19.78\" />\n <line x1=\"1\" y1=\"12\" x2=\"3\" y2=\"12\" />\n <line x1=\"21\" y1=\"12\" x2=\"23\" y2=\"12\" />\n <line x1=\"4.22\" y1=\"19.78\" x2=\"5.64\" y2=\"18.36\" />\n <line x1=\"18.36\" y1=\"5.64\" x2=\"19.78\" y2=\"4.22\" />\n </svg>\n ) : (\n <svg\n width=\"18\"\n height=\"18\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z\" />\n </svg>\n )\n }\n </button>\n )\n}\n\n/**\n * Inline script to prevent flash of wrong theme.\n * Include this in your index.html <head> BEFORE any stylesheets.\n *\n * @example\n * // index.html\n * <head>\n * <script>{themeScript}</script>\n * ...\n * </head>\n */\nexport 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){}})()`\n","import type { Middleware } from '@pyreon/server'\nimport type { Plugin } from 'vite'\n\n// ─── SEO utilities ──────────────────────────────────────────────────────────\n//\n// Zero provides built-in SEO tooling:\n// - Automatic sitemap.xml generation from file-based routes\n// - Configurable robots.txt\n// - Structured data (JSON-LD) helpers\n// - Open Graph / Twitter Card meta helpers\n\nexport interface SitemapConfig {\n /** Base URL of the site (required). e.g. \"https://example.com\" */\n origin: string\n /** Paths to exclude from the sitemap. */\n exclude?: string[]\n /** Default change frequency. Default: \"weekly\" */\n changefreq?: ChangeFreq\n /** Default priority. Default: 0.7 */\n priority?: number\n /** Additional URLs to include (for dynamic routes). */\n additionalPaths?: SitemapEntry[]\n}\n\nexport interface SitemapEntry {\n path: string\n changefreq?: ChangeFreq\n priority?: number\n lastmod?: string\n}\n\nexport type ChangeFreq =\n | 'always'\n | 'hourly'\n | 'daily'\n | 'weekly'\n | 'monthly'\n | 'yearly'\n | 'never'\n\n/**\n * Generate a sitemap.xml string from route file paths.\n */\nexport function generateSitemap(\n routeFiles: string[],\n config: SitemapConfig,\n): string {\n const { origin, exclude = [], changefreq = 'weekly', priority = 0.7 } = config\n\n const paths = routeFiles\n .filter((f) => {\n // Exclude layout, error, loading files\n const name = f\n .split('/')\n .pop()\n ?.replace(/\\.\\w+$/, '')\n return name !== '_layout' && name !== '_error' && name !== '_loading'\n })\n .map((f) => {\n // Convert file path to URL\n let path = f\n .replace(/\\.\\w+$/, '')\n .replace(/\\/index$/, '/')\n .replace(/^index$/, '/')\n\n // Skip dynamic routes — they need additionalPaths\n if (path.includes('[')) return null\n\n // Strip route groups\n path = path.replace(/\\([\\w-]+\\)\\//g, '')\n\n if (!path.startsWith('/')) path = `/${path}`\n return path\n })\n .filter((p): p is string => p !== null)\n .filter((p) => !exclude.some((e) => p.startsWith(e)))\n\n const allPaths: SitemapEntry[] = [\n ...paths.map((p) => ({ path: p, changefreq, priority })),\n ...(config.additionalPaths ?? []),\n ]\n\n const entries = allPaths\n .map((entry) => {\n const loc = `${origin}${entry.path === '/' ? '' : entry.path}`\n return ` <url>\n <loc>${escapeXml(loc)}</loc>\n <changefreq>${entry.changefreq ?? changefreq}</changefreq>\n <priority>${entry.priority ?? priority}</priority>${entry.lastmod ? `\\n <lastmod>${entry.lastmod}</lastmod>` : ''}\n </url>`\n })\n .join('\\n')\n\n return `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n${entries}\n</urlset>`\n}\n\nfunction escapeXml(str: string): string {\n return str\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''')\n}\n\n// ─── Robots.txt ─────────────────────────────────────────────────────────────\n\nexport interface RobotsConfig {\n /** Rules per user-agent. */\n rules?: RobotsRule[]\n /** Sitemap URL. */\n sitemap?: string\n /** Host directive. */\n host?: string\n}\n\nexport interface RobotsRule {\n userAgent: string\n allow?: string[]\n disallow?: string[]\n crawlDelay?: number\n}\n\n/**\n * Generate a robots.txt string.\n */\nexport function generateRobots(config: RobotsConfig = {}): string {\n const { rules = [{ userAgent: '*', allow: ['/'] }], sitemap, host } = config\n const lines: string[] = []\n\n for (const rule of rules) {\n lines.push(`User-agent: ${rule.userAgent}`)\n if (rule.allow) {\n for (const path of rule.allow) lines.push(`Allow: ${path}`)\n }\n if (rule.disallow) {\n for (const path of rule.disallow) lines.push(`Disallow: ${path}`)\n }\n if (rule.crawlDelay) lines.push(`Crawl-delay: ${rule.crawlDelay}`)\n lines.push('')\n }\n\n if (sitemap) lines.push(`Sitemap: ${sitemap}`)\n if (host) lines.push(`Host: ${host}`)\n\n return lines.join('\\n')\n}\n\n// ─── Structured data (JSON-LD) ──────────────────────────────────────────────\n\nexport type JsonLdType =\n | 'WebSite'\n | 'WebPage'\n | 'Article'\n | 'BlogPosting'\n | 'Product'\n | 'Organization'\n | 'Person'\n | 'BreadcrumbList'\n | 'FAQPage'\n | (string & {})\n\n/**\n * Generate a JSON-LD script tag string for structured data.\n *\n * @example\n * useHead({\n * script: [jsonLd({\n * \"@type\": \"WebSite\",\n * name: \"My Site\",\n * url: \"https://example.com\",\n * })],\n * })\n */\nexport function jsonLd(data: Record<string, unknown>): string {\n const ld = {\n '@context': 'https://schema.org',\n ...data,\n }\n return `<script type=\"application/ld+json\">${JSON.stringify(ld)}</script>`\n}\n\n// ─── SEO Vite plugin ────────────────────────────────────────────────────────\n\nexport interface SeoPluginConfig {\n /** Sitemap configuration. */\n sitemap?: SitemapConfig\n /** Robots.txt configuration. */\n robots?: RobotsConfig\n}\n\n/**\n * Zero SEO Vite plugin.\n * Generates sitemap.xml and robots.txt at build time.\n *\n * @example\n * import { seoPlugin } from \"@pyreon/zero/seo\"\n *\n * export default {\n * plugins: [\n * pyreon(),\n * zero(),\n * seoPlugin({\n * sitemap: { origin: \"https://example.com\" },\n * robots: { sitemap: \"https://example.com/sitemap.xml\" },\n * }),\n * ],\n * }\n */\nexport function seoPlugin(config: SeoPluginConfig = {}): Plugin {\n return {\n name: 'pyreon-zero-seo',\n apply: 'build',\n\n async generateBundle(_, _bundle) {\n // Generate sitemap.xml\n if (config.sitemap) {\n const { scanRouteFiles } = await import('./fs-router')\n const routesDir = `${process.cwd()}/src/routes`\n\n try {\n const files = await scanRouteFiles(routesDir)\n const sitemap = generateSitemap(files, config.sitemap)\n\n this.emitFile({\n type: 'asset',\n fileName: 'sitemap.xml',\n source: sitemap,\n })\n } catch {\n // Sitemap generation failed — skip silently\n }\n }\n\n // Generate robots.txt\n if (config.robots) {\n const robots = generateRobots(config.robots)\n\n this.emitFile({\n type: 'asset',\n fileName: 'robots.txt',\n source: robots,\n })\n }\n },\n }\n}\n\n// ─── SEO middleware (serve sitemap/robots in dev) ────────────────────────────\n\n/**\n * SEO middleware for dev server.\n * Serves sitemap.xml and robots.txt dynamically during development.\n */\nexport function seoMiddleware(config: SeoPluginConfig = {}): Middleware {\n return async (ctx) => {\n if (ctx.url.pathname === '/robots.txt' && config.robots) {\n return new Response(generateRobots(config.robots), {\n headers: { 'Content-Type': 'text/plain' },\n })\n }\n\n if (ctx.url.pathname === '/sitemap.xml' && config.sitemap) {\n try {\n const { scanRouteFiles } = await import('./fs-router')\n const routesDir = `${process.cwd()}/src/routes`\n const files = await scanRouteFiles(routesDir)\n const sitemap = generateSitemap(files, config.sitemap)\n\n return new Response(sitemap, {\n headers: { 'Content-Type': 'application/xml' },\n })\n } catch {\n // Sitemap generation failed — continue to rendering\n }\n }\n }\n}\n"],"mappings":"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"}
|
package/lib/link.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { createRef, onMount, onUnmount } from "@pyreon/core";
|
|
2
|
+
import { useRouter } from "@pyreon/router";
|
|
3
|
+
import { jsx } from "@pyreon/core/jsx-runtime";
|
|
4
|
+
|
|
5
|
+
//#region src/utils/use-intersection-observer.ts
|
|
6
|
+
/**
|
|
7
|
+
* Observes an element and calls `onIntersect` once it enters the viewport.
|
|
8
|
+
* Automatically disconnects after the first intersection.
|
|
9
|
+
*
|
|
10
|
+
* @param getElement - Getter for the target element (may be undefined before mount).
|
|
11
|
+
* @param onIntersect - Callback fired when the element becomes visible.
|
|
12
|
+
* @param rootMargin - IntersectionObserver rootMargin. Default: "200px".
|
|
13
|
+
*/
|
|
14
|
+
function useIntersectionObserver(getElement, onIntersect, rootMargin = "200px") {
|
|
15
|
+
onMount(() => {
|
|
16
|
+
const el = getElement();
|
|
17
|
+
if (!el) return void 0;
|
|
18
|
+
const observer = new IntersectionObserver((entries) => {
|
|
19
|
+
for (const entry of entries) if (entry.isIntersecting) {
|
|
20
|
+
onIntersect();
|
|
21
|
+
observer.disconnect();
|
|
22
|
+
}
|
|
23
|
+
}, { rootMargin });
|
|
24
|
+
observer.observe(el);
|
|
25
|
+
onUnmount(() => observer.disconnect());
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/link.tsx
|
|
31
|
+
const prefetched = /* @__PURE__ */ new Set();
|
|
32
|
+
function doPrefetch(href) {
|
|
33
|
+
if (prefetched.has(href)) return;
|
|
34
|
+
prefetched.add(href);
|
|
35
|
+
const docLink = document.createElement("link");
|
|
36
|
+
docLink.rel = "prefetch";
|
|
37
|
+
docLink.href = href;
|
|
38
|
+
docLink.as = "document";
|
|
39
|
+
document.head.appendChild(docLink);
|
|
40
|
+
try {
|
|
41
|
+
const chunkHint = document.createElement("link");
|
|
42
|
+
chunkHint.rel = "modulepreload";
|
|
43
|
+
chunkHint.href = href;
|
|
44
|
+
document.head.appendChild(chunkHint);
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Composable that provides all link behavior — navigation, prefetching,
|
|
49
|
+
* active state, and viewport observation.
|
|
50
|
+
*
|
|
51
|
+
* Use this for full control when `createLink` is too opinionated.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* function MyLink(props: LinkProps) {
|
|
55
|
+
* const link = useLink(props)
|
|
56
|
+
* return (
|
|
57
|
+
* <button ref={link.ref} class={link.classes()} onclick={link.handleClick}>
|
|
58
|
+
* {props.children}
|
|
59
|
+
* </button>
|
|
60
|
+
* )
|
|
61
|
+
* }
|
|
62
|
+
*/
|
|
63
|
+
function useLink(props) {
|
|
64
|
+
const router = useRouter();
|
|
65
|
+
const elementRef = createRef();
|
|
66
|
+
const strategy = props.prefetch ?? "hover";
|
|
67
|
+
function handleClick(e) {
|
|
68
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || props.external) return;
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
router.push(props.href);
|
|
71
|
+
}
|
|
72
|
+
function handleMouseEnter() {
|
|
73
|
+
if (strategy === "hover") doPrefetch(props.href);
|
|
74
|
+
}
|
|
75
|
+
function handleTouchStart() {
|
|
76
|
+
if (strategy === "hover" || strategy === "viewport") doPrefetch(props.href);
|
|
77
|
+
}
|
|
78
|
+
if (strategy === "viewport") useIntersectionObserver(() => elementRef.current ?? void 0, () => doPrefetch(props.href));
|
|
79
|
+
const isActive = () => {
|
|
80
|
+
const currentPath = router.currentRoute()?.path;
|
|
81
|
+
if (!currentPath || !props.href) return false;
|
|
82
|
+
if (props.href === "/") return currentPath === "/";
|
|
83
|
+
return currentPath.startsWith(props.href);
|
|
84
|
+
};
|
|
85
|
+
const isExactActive = () => {
|
|
86
|
+
const currentPath = router.currentRoute()?.path;
|
|
87
|
+
if (!currentPath) return false;
|
|
88
|
+
return currentPath === props.href;
|
|
89
|
+
};
|
|
90
|
+
const classes = () => {
|
|
91
|
+
const cls = [];
|
|
92
|
+
if (props.class) cls.push(props.class);
|
|
93
|
+
if (props.activeClass && isActive()) cls.push(props.activeClass);
|
|
94
|
+
if (props.exactActiveClass && isExactActive()) cls.push(props.exactActiveClass);
|
|
95
|
+
return cls.join(" ");
|
|
96
|
+
};
|
|
97
|
+
return {
|
|
98
|
+
ref: elementRef,
|
|
99
|
+
handleClick,
|
|
100
|
+
handleMouseEnter,
|
|
101
|
+
handleTouchStart,
|
|
102
|
+
isActive,
|
|
103
|
+
isExactActive,
|
|
104
|
+
classes
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Higher-order component that wraps any component with link behavior.
|
|
109
|
+
*
|
|
110
|
+
* The wrapped component receives {@link LinkRenderProps} with all handlers,
|
|
111
|
+
* active state, and accessibility attributes pre-wired.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* // Custom button link
|
|
115
|
+
* const ButtonLink = createLink((props) => (
|
|
116
|
+
* <button
|
|
117
|
+
* ref={props.ref}
|
|
118
|
+
* class={props.class}
|
|
119
|
+
* onclick={props.onClick}
|
|
120
|
+
* onmouseenter={props.onMouseEnter}
|
|
121
|
+
* >
|
|
122
|
+
* {props.children}
|
|
123
|
+
* </button>
|
|
124
|
+
* ))
|
|
125
|
+
*
|
|
126
|
+
* // Custom styled component
|
|
127
|
+
* const CardLink = createLink((props) => (
|
|
128
|
+
* <div
|
|
129
|
+
* ref={props.ref}
|
|
130
|
+
* class={`card ${props.isActive() ? "card--active" : ""}`}
|
|
131
|
+
* onclick={props.onClick}
|
|
132
|
+
* onmouseenter={props.onMouseEnter}
|
|
133
|
+
* >
|
|
134
|
+
* {props.children}
|
|
135
|
+
* </div>
|
|
136
|
+
* ))
|
|
137
|
+
*
|
|
138
|
+
* // Usage
|
|
139
|
+
* <ButtonLink href="/about">About</ButtonLink>
|
|
140
|
+
* <CardLink href="/posts" prefetch="viewport">Posts</CardLink>
|
|
141
|
+
*/
|
|
142
|
+
function createLink(Component) {
|
|
143
|
+
return function WrappedLink(props) {
|
|
144
|
+
const link = useLink(props);
|
|
145
|
+
return /* @__PURE__ */ jsx(Component, {
|
|
146
|
+
href: props.href,
|
|
147
|
+
ref: link.ref,
|
|
148
|
+
onClick: link.handleClick,
|
|
149
|
+
onMouseEnter: link.handleMouseEnter,
|
|
150
|
+
onTouchStart: link.handleTouchStart,
|
|
151
|
+
isActive: link.isActive,
|
|
152
|
+
isExactActive: link.isExactActive,
|
|
153
|
+
class: link.classes,
|
|
154
|
+
style: props.style,
|
|
155
|
+
target: props.external ? "_blank" : void 0,
|
|
156
|
+
rel: props.external ? "noopener noreferrer" : void 0,
|
|
157
|
+
"aria-label": props["aria-label"],
|
|
158
|
+
children: props.children
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Default navigation link built on an `<a>` tag.
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* <Link href="/about" prefetch="viewport">About</Link>
|
|
167
|
+
* <Link href="/posts" activeClass="nav-active">Posts</Link>
|
|
168
|
+
*/
|
|
169
|
+
const Link = createLink((props) => /* @__PURE__ */ jsx("a", {
|
|
170
|
+
ref: props.ref,
|
|
171
|
+
href: props.href,
|
|
172
|
+
class: props.class,
|
|
173
|
+
style: props.style,
|
|
174
|
+
target: props.target,
|
|
175
|
+
rel: props.rel,
|
|
176
|
+
"aria-label": props["aria-label"],
|
|
177
|
+
"aria-current": props.isExactActive() ? "page" : void 0,
|
|
178
|
+
onclick: props.onClick,
|
|
179
|
+
onmouseenter: props.onMouseEnter,
|
|
180
|
+
ontouchstart: props.onTouchStart,
|
|
181
|
+
children: props.children
|
|
182
|
+
}));
|
|
183
|
+
|
|
184
|
+
//#endregion
|
|
185
|
+
export { Link, createLink, useLink };
|
|
186
|
+
//# sourceMappingURL=link.js.map
|
package/lib/link.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"link.js","names":[],"sources":["../src/utils/use-intersection-observer.ts","../src/link.tsx"],"sourcesContent":["import { onMount, onUnmount } from '@pyreon/core'\n\n/**\n * Observes an element and calls `onIntersect` once it enters the viewport.\n * Automatically disconnects after the first intersection.\n *\n * @param getElement - Getter for the target element (may be undefined before mount).\n * @param onIntersect - Callback fired when the element becomes visible.\n * @param rootMargin - IntersectionObserver rootMargin. Default: \"200px\".\n */\nexport function useIntersectionObserver(\n getElement: () => HTMLElement | undefined,\n onIntersect: () => void,\n rootMargin = '200px',\n) {\n onMount(() => {\n const el = getElement()\n if (!el) return undefined\n\n const observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n onIntersect()\n observer.disconnect()\n }\n }\n },\n { rootMargin },\n )\n\n observer.observe(el)\n onUnmount(() => observer.disconnect())\n return undefined\n })\n}\n","import { createRef } from '@pyreon/core'\nimport { useRouter } from '@pyreon/router'\nimport { useIntersectionObserver } from './utils/use-intersection-observer'\n\n// ─── Link component with prefetching ────────────────────────────────────────\n//\n// Provides client-side navigation, prefetching, and active state tracking.\n// Three levels of API:\n//\n// 1. useLink(props) — composable returning handlers, state, and ref callback\n// 2. createLink(Comp) — HOC wrapping any component with link behavior\n// 3. Link — default <a>-based link (built on createLink)\n\nexport interface LinkProps {\n /** Target URL path. */\n href: string\n /** Link content. */\n children?: any\n /** CSS class name. */\n class?: string\n /** Class applied when this link matches the current route. */\n activeClass?: string\n /** Class applied when this link exactly matches the current route. */\n exactActiveClass?: string\n /** Prefetch strategy. Default: \"hover\" */\n prefetch?: 'hover' | 'viewport' | 'none'\n /** Open in new tab. */\n external?: boolean\n /** Inline styles. */\n style?: string\n /** ARIA label. */\n 'aria-label'?: string\n}\n\n/** Props passed to a custom component via createLink. */\nexport interface LinkRenderProps {\n href: string\n ref: import('@pyreon/core').Ref<HTMLElement>\n onClick: (e: MouseEvent) => void\n onMouseEnter: () => void\n onTouchStart: () => void\n isActive: () => boolean\n isExactActive: () => boolean\n /** Reactive class string — pass directly to element for auto-updates on route change. */\n class: (() => string) | string | undefined\n style?: string\n target?: string\n rel?: string\n 'aria-label'?: string\n children?: any\n}\n\n/** Return type of useLink. */\nexport interface UseLinkReturn {\n /** Ref object — attach to the root element for viewport-based prefetch. */\n ref: import('@pyreon/core').Ref<HTMLElement>\n /** Click handler — performs client-side navigation. */\n handleClick: (e: MouseEvent) => void\n /** Mouse enter handler — triggers hover prefetch. */\n handleMouseEnter: () => void\n /** Touch start handler — triggers prefetch on mobile. */\n handleTouchStart: () => void\n /** Whether the link partially matches the current route. */\n isActive: () => boolean\n /** Whether the link exactly matches the current route. */\n isExactActive: () => boolean\n /** Resolved class string including active classes. */\n classes: () => string\n}\n\nconst prefetched = new Set<string>()\n\nfunction doPrefetch(href: string) {\n if (prefetched.has(href)) return\n prefetched.add(href)\n\n const docLink = document.createElement('link')\n docLink.rel = 'prefetch'\n docLink.href = href\n docLink.as = 'document'\n document.head.appendChild(docLink)\n\n try {\n const chunkHint = document.createElement('link')\n chunkHint.rel = 'modulepreload'\n chunkHint.href = href\n document.head.appendChild(chunkHint)\n } catch {\n // modulepreload is a hint, not critical\n }\n}\n\n/**\n * Composable that provides all link behavior — navigation, prefetching,\n * active state, and viewport observation.\n *\n * Use this for full control when `createLink` is too opinionated.\n *\n * @example\n * function MyLink(props: LinkProps) {\n * const link = useLink(props)\n * return (\n * <button ref={link.ref} class={link.classes()} onclick={link.handleClick}>\n * {props.children}\n * </button>\n * )\n * }\n */\nexport function useLink(props: LinkProps): UseLinkReturn {\n const router = useRouter()\n const elementRef = createRef<HTMLElement>()\n const strategy = props.prefetch ?? 'hover'\n\n function handleClick(e: MouseEvent) {\n if (\n e.defaultPrevented ||\n e.button !== 0 ||\n e.metaKey ||\n e.ctrlKey ||\n e.shiftKey ||\n e.altKey ||\n props.external\n ) {\n return\n }\n e.preventDefault()\n router.push(props.href)\n }\n\n function handleMouseEnter() {\n if (strategy === 'hover') {\n doPrefetch(props.href)\n }\n }\n\n function handleTouchStart() {\n if (strategy === 'hover' || strategy === 'viewport') {\n doPrefetch(props.href)\n }\n }\n\n if (strategy === 'viewport') {\n useIntersectionObserver(\n () => elementRef.current ?? undefined,\n () => doPrefetch(props.href),\n )\n }\n\n const isActive = () => {\n const currentPath = router.currentRoute()?.path\n if (!currentPath || !props.href) return false\n if (props.href === '/') return currentPath === '/'\n return currentPath.startsWith(props.href)\n }\n\n const isExactActive = () => {\n const currentPath = router.currentRoute()?.path\n if (!currentPath) return false\n return currentPath === props.href\n }\n\n const classes = () => {\n const cls: string[] = []\n if (props.class) cls.push(props.class)\n if (props.activeClass && isActive()) cls.push(props.activeClass)\n if (props.exactActiveClass && isExactActive())\n cls.push(props.exactActiveClass)\n return cls.join(' ')\n }\n\n return {\n ref: elementRef,\n handleClick,\n handleMouseEnter,\n handleTouchStart,\n isActive,\n isExactActive,\n classes,\n }\n}\n\n/**\n * Higher-order component that wraps any component with link behavior.\n *\n * The wrapped component receives {@link LinkRenderProps} with all handlers,\n * active state, and accessibility attributes pre-wired.\n *\n * @example\n * // Custom button link\n * const ButtonLink = createLink((props) => (\n * <button\n * ref={props.ref}\n * class={props.class}\n * onclick={props.onClick}\n * onmouseenter={props.onMouseEnter}\n * >\n * {props.children}\n * </button>\n * ))\n *\n * // Custom styled component\n * const CardLink = createLink((props) => (\n * <div\n * ref={props.ref}\n * class={`card ${props.isActive() ? \"card--active\" : \"\"}`}\n * onclick={props.onClick}\n * onmouseenter={props.onMouseEnter}\n * >\n * {props.children}\n * </div>\n * ))\n *\n * // Usage\n * <ButtonLink href=\"/about\">About</ButtonLink>\n * <CardLink href=\"/posts\" prefetch=\"viewport\">Posts</CardLink>\n */\nexport function createLink(\n Component: (props: LinkRenderProps) => any,\n): (props: LinkProps) => any {\n return function WrappedLink(props: LinkProps) {\n const link = useLink(props)\n\n return (\n <Component\n href={props.href}\n ref={link.ref}\n onClick={link.handleClick}\n onMouseEnter={link.handleMouseEnter}\n onTouchStart={link.handleTouchStart}\n isActive={link.isActive}\n isExactActive={link.isExactActive}\n class={link.classes}\n style={props.style}\n target={props.external ? '_blank' : undefined}\n rel={props.external ? 'noopener noreferrer' : undefined}\n aria-label={props['aria-label']}\n children={props.children}\n />\n )\n }\n}\n\n/**\n * Default navigation link built on an `<a>` tag.\n *\n * @example\n * <Link href=\"/about\" prefetch=\"viewport\">About</Link>\n * <Link href=\"/posts\" activeClass=\"nav-active\">Posts</Link>\n */\nexport const Link = createLink((props: LinkRenderProps) => (\n <a\n ref={props.ref}\n href={props.href}\n class={props.class}\n style={props.style}\n target={props.target}\n rel={props.rel}\n aria-label={props['aria-label']}\n aria-current={props.isExactActive() ? 'page' : undefined}\n onclick={props.onClick}\n onmouseenter={props.onMouseEnter}\n ontouchstart={props.onTouchStart}\n >\n {props.children}\n </a>\n))\n"],"mappings":"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"}
|
package/lib/script.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createRef, onMount, onUnmount } from "@pyreon/core";
|
|
2
|
+
import { jsx } from "@pyreon/core/jsx-runtime";
|
|
3
|
+
|
|
4
|
+
//#region src/utils/use-intersection-observer.ts
|
|
5
|
+
/**
|
|
6
|
+
* Observes an element and calls `onIntersect` once it enters the viewport.
|
|
7
|
+
* Automatically disconnects after the first intersection.
|
|
8
|
+
*
|
|
9
|
+
* @param getElement - Getter for the target element (may be undefined before mount).
|
|
10
|
+
* @param onIntersect - Callback fired when the element becomes visible.
|
|
11
|
+
* @param rootMargin - IntersectionObserver rootMargin. Default: "200px".
|
|
12
|
+
*/
|
|
13
|
+
function useIntersectionObserver(getElement, onIntersect, rootMargin = "200px") {
|
|
14
|
+
onMount(() => {
|
|
15
|
+
const el = getElement();
|
|
16
|
+
if (!el) return void 0;
|
|
17
|
+
const observer = new IntersectionObserver((entries) => {
|
|
18
|
+
for (const entry of entries) if (entry.isIntersecting) {
|
|
19
|
+
onIntersect();
|
|
20
|
+
observer.disconnect();
|
|
21
|
+
}
|
|
22
|
+
}, { rootMargin });
|
|
23
|
+
observer.observe(el);
|
|
24
|
+
onUnmount(() => observer.disconnect());
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/script.tsx
|
|
30
|
+
/**
|
|
31
|
+
* Optimized script loading component.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* // Load analytics after page is interactive
|
|
35
|
+
* <Script src="https://analytics.example.com/script.js" strategy="onIdle" />
|
|
36
|
+
*
|
|
37
|
+
* // Load chat widget when user scrolls
|
|
38
|
+
* <Script src="/chat-widget.js" strategy="onViewport" />
|
|
39
|
+
*
|
|
40
|
+
* // Inline script with deferred execution
|
|
41
|
+
* <Script strategy="afterHydration">
|
|
42
|
+
* {`console.log("App hydrated!")`}
|
|
43
|
+
* <\/Script>
|
|
44
|
+
*/
|
|
45
|
+
function Script(props) {
|
|
46
|
+
function loadScript() {
|
|
47
|
+
if (props.id && document.getElementById(props.id)) return;
|
|
48
|
+
const script = document.createElement("script");
|
|
49
|
+
if (props.src) script.src = props.src;
|
|
50
|
+
if (props.id) script.id = props.id;
|
|
51
|
+
script.async = props.async !== false;
|
|
52
|
+
if (props.onLoad) script.onload = props.onLoad;
|
|
53
|
+
if (props.onError) script.onerror = () => props.onError?.(/* @__PURE__ */ new Error(`Failed to load: ${props.src}`));
|
|
54
|
+
if (props.children && !props.src) script.textContent = props.children;
|
|
55
|
+
document.head.appendChild(script);
|
|
56
|
+
}
|
|
57
|
+
onMount(() => {
|
|
58
|
+
switch (props.strategy ?? "afterHydration") {
|
|
59
|
+
case "beforeHydration": break;
|
|
60
|
+
case "afterHydration":
|
|
61
|
+
loadScript();
|
|
62
|
+
break;
|
|
63
|
+
case "onIdle":
|
|
64
|
+
if ("requestIdleCallback" in window) requestIdleCallback(() => loadScript(), { timeout: 5e3 });
|
|
65
|
+
else setTimeout(loadScript, 200);
|
|
66
|
+
break;
|
|
67
|
+
case "onInteraction": {
|
|
68
|
+
const events = [
|
|
69
|
+
"click",
|
|
70
|
+
"scroll",
|
|
71
|
+
"keydown",
|
|
72
|
+
"touchstart"
|
|
73
|
+
];
|
|
74
|
+
function handler() {
|
|
75
|
+
for (const e of events) document.removeEventListener(e, handler);
|
|
76
|
+
loadScript();
|
|
77
|
+
}
|
|
78
|
+
for (const e of events) document.addEventListener(e, handler, {
|
|
79
|
+
once: true,
|
|
80
|
+
passive: true
|
|
81
|
+
});
|
|
82
|
+
onUnmount(() => {
|
|
83
|
+
for (const e of events) document.removeEventListener(e, handler);
|
|
84
|
+
});
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
case "onViewport": break;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
const sentinelRef = createRef();
|
|
91
|
+
const strategy = props.strategy ?? "afterHydration";
|
|
92
|
+
if (strategy === "onViewport") useIntersectionObserver(() => sentinelRef.current ?? void 0, () => loadScript());
|
|
93
|
+
if (strategy === "onViewport") return /* @__PURE__ */ jsx("div", {
|
|
94
|
+
ref: sentinelRef,
|
|
95
|
+
style: "width:0;height:0;overflow:hidden"
|
|
96
|
+
});
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
//#endregion
|
|
101
|
+
export { Script };
|
|
102
|
+
//# sourceMappingURL=script.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"script.js","names":[],"sources":["../src/utils/use-intersection-observer.ts","../src/script.tsx"],"sourcesContent":["import { onMount, onUnmount } from '@pyreon/core'\n\n/**\n * Observes an element and calls `onIntersect` once it enters the viewport.\n * Automatically disconnects after the first intersection.\n *\n * @param getElement - Getter for the target element (may be undefined before mount).\n * @param onIntersect - Callback fired when the element becomes visible.\n * @param rootMargin - IntersectionObserver rootMargin. Default: \"200px\".\n */\nexport function useIntersectionObserver(\n getElement: () => HTMLElement | undefined,\n onIntersect: () => void,\n rootMargin = '200px',\n) {\n onMount(() => {\n const el = getElement()\n if (!el) return undefined\n\n const observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n onIntersect()\n observer.disconnect()\n }\n }\n },\n { rootMargin },\n )\n\n observer.observe(el)\n onUnmount(() => observer.disconnect())\n return undefined\n })\n}\n","import { createRef, onMount, onUnmount } from '@pyreon/core'\nimport { useIntersectionObserver } from './utils/use-intersection-observer'\n\n// ─── Script optimization component ─────────────────────────────────────────\n//\n// <Script> provides optimized third-party script loading:\n// - Defer loading until after hydration\n// - Load on idle (requestIdleCallback)\n// - Load on interaction (click, scroll, etc.)\n// - Load on viewport entry\n// - Worker offloading for analytics scripts\n\nexport interface ScriptProps {\n /** Script source URL. */\n src: string\n /** Loading strategy. Default: \"afterHydration\" */\n strategy?: ScriptStrategy\n /** Inline script content (alternative to src). */\n children?: string\n /** Script id for deduplication. */\n id?: string\n /** Async attribute. Default: true */\n async?: boolean\n /** onLoad callback. */\n onLoad?: () => void\n /** onError callback. */\n onError?: (error: Error) => void\n}\n\nexport type ScriptStrategy =\n | 'beforeHydration'\n | 'afterHydration'\n | 'onIdle'\n | 'onInteraction'\n | 'onViewport'\n\n/**\n * Optimized script loading component.\n *\n * @example\n * // Load analytics after page is interactive\n * <Script src=\"https://analytics.example.com/script.js\" strategy=\"onIdle\" />\n *\n * // Load chat widget when user scrolls\n * <Script src=\"/chat-widget.js\" strategy=\"onViewport\" />\n *\n * // Inline script with deferred execution\n * <Script strategy=\"afterHydration\">\n * {`console.log(\"App hydrated!\")`}\n * </Script>\n */\nexport function Script(props: ScriptProps) {\n function loadScript() {\n // Deduplication\n if (props.id && document.getElementById(props.id)) return\n\n const script = document.createElement('script')\n if (props.src) script.src = props.src\n if (props.id) script.id = props.id\n script.async = props.async !== false\n\n if (props.onLoad) script.onload = props.onLoad\n if (props.onError) {\n script.onerror = () =>\n props.onError?.(new Error(`Failed to load: ${props.src}`))\n }\n\n if (props.children && !props.src) {\n script.textContent = props.children\n }\n\n document.head.appendChild(script)\n }\n\n onMount(() => {\n const strategy = props.strategy ?? 'afterHydration'\n\n switch (strategy) {\n case 'beforeHydration':\n // Already in HTML — do nothing\n break\n\n case 'afterHydration':\n // Load immediately after mount (hydration is complete)\n loadScript()\n break\n\n case 'onIdle':\n if ('requestIdleCallback' in window) {\n requestIdleCallback(() => loadScript(), { timeout: 5000 })\n } else {\n setTimeout(loadScript, 200)\n }\n break\n\n case 'onInteraction': {\n const events = ['click', 'scroll', 'keydown', 'touchstart']\n function handler() {\n for (const e of events) document.removeEventListener(e, handler)\n loadScript()\n }\n for (const e of events) {\n document.addEventListener(e, handler, { once: true, passive: true })\n }\n onUnmount(() => {\n for (const e of events) document.removeEventListener(e, handler)\n })\n break\n }\n\n case 'onViewport':\n // Handled below via useIntersectionObserver on the sentinel element\n break\n }\n return undefined\n })\n\n const sentinelRef = createRef<HTMLElement>()\n const strategy = props.strategy ?? 'afterHydration'\n\n if (strategy === 'onViewport') {\n useIntersectionObserver(\n () => sentinelRef.current ?? undefined,\n () => loadScript(),\n )\n }\n\n if (strategy === 'onViewport') {\n return <div ref={sentinelRef} style=\"width:0;height:0;overflow:hidden\" />\n }\n\n return null\n}\n"],"mappings":";;;;;;;;;;;;AAUA,SAAgB,wBACd,YACA,aACA,aAAa,SACb;AACA,eAAc;EACZ,MAAM,KAAK,YAAY;AACvB,MAAI,CAAC,GAAI,QAAO;EAEhB,MAAM,WAAW,IAAI,sBAClB,YAAY;AACX,QAAK,MAAM,SAAS,QAClB,KAAI,MAAM,gBAAgB;AACxB,iBAAa;AACb,aAAS,YAAY;;KAI3B,EAAE,YAAY,CACf;AAED,WAAS,QAAQ,GAAG;AACpB,kBAAgB,SAAS,YAAY,CAAC;GAEtC;;;;;;;;;;;;;;;;;;;;ACiBJ,SAAgB,OAAO,OAAoB;CACzC,SAAS,aAAa;AAEpB,MAAI,MAAM,MAAM,SAAS,eAAe,MAAM,GAAG,CAAE;EAEnD,MAAM,SAAS,SAAS,cAAc,SAAS;AAC/C,MAAI,MAAM,IAAK,QAAO,MAAM,MAAM;AAClC,MAAI,MAAM,GAAI,QAAO,KAAK,MAAM;AAChC,SAAO,QAAQ,MAAM,UAAU;AAE/B,MAAI,MAAM,OAAQ,QAAO,SAAS,MAAM;AACxC,MAAI,MAAM,QACR,QAAO,gBACL,MAAM,0BAAU,IAAI,MAAM,mBAAmB,MAAM,MAAM,CAAC;AAG9D,MAAI,MAAM,YAAY,CAAC,MAAM,IAC3B,QAAO,cAAc,MAAM;AAG7B,WAAS,KAAK,YAAY,OAAO;;AAGnC,eAAc;AAGZ,UAFiB,MAAM,YAAY,kBAEnC;GACE,KAAK,kBAEH;GAEF,KAAK;AAEH,gBAAY;AACZ;GAEF,KAAK;AACH,QAAI,yBAAyB,OAC3B,2BAA0B,YAAY,EAAE,EAAE,SAAS,KAAM,CAAC;QAE1D,YAAW,YAAY,IAAI;AAE7B;GAEF,KAAK,iBAAiB;IACpB,MAAM,SAAS;KAAC;KAAS;KAAU;KAAW;KAAa;IAC3D,SAAS,UAAU;AACjB,UAAK,MAAM,KAAK,OAAQ,UAAS,oBAAoB,GAAG,QAAQ;AAChE,iBAAY;;AAEd,SAAK,MAAM,KAAK,OACd,UAAS,iBAAiB,GAAG,SAAS;KAAE,MAAM;KAAM,SAAS;KAAM,CAAC;AAEtE,oBAAgB;AACd,UAAK,MAAM,KAAK,OAAQ,UAAS,oBAAoB,GAAG,QAAQ;MAChE;AACF;;GAGF,KAAK,aAEH;;GAGJ;CAEF,MAAM,cAAc,WAAwB;CAC5C,MAAM,WAAW,MAAM,YAAY;AAEnC,KAAI,aAAa,aACf,+BACQ,YAAY,WAAW,cACvB,YAAY,CACnB;AAGH,KAAI,aAAa,aACf,QAAO,oBAAC,OAAD;EAAK,KAAK;EAAa,OAAM;EAAqC;AAG3E,QAAO"}
|