@pyreon/zero 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,8 +1,11 @@
1
- import { createContext, createRef, onMount, onUnmount, splitProps } from "@pyreon/core";
2
- import { jsx, jsxs } from "@pyreon/core/jsx-runtime";
3
- import { effect, signal } from "@pyreon/reactivity";
4
- import { useRouter } from "@pyreon/router";
5
- import { useHead } from "@pyreon/head";
1
+ import { Image, createImage, useImage } from "./image.js";
2
+ import { Link, createLink, prefetchRoute, useLink } from "./link.js";
3
+ import { Script, createScript, useScript } from "./script.js";
4
+ import { buildLocalePath, extractLocaleFromPath, setLocale, useLocale } from "./i18n-routing.js";
5
+ import { Meta, buildMetaTags } from "./meta.js";
6
+ import { ThemeToggle, initTheme, resolvedTheme, setSSRThemeDefault, setTheme, theme, themeScript, toggleTheme } from "./theme.js";
7
+ import { splitProps } from "@pyreon/core";
8
+ import { jsx } from "@pyreon/core/jsx-runtime";
6
9
 
7
10
  //#region src/icon.tsx
8
11
  const FILL_STYLE = "display:block;width:100%;height:100%";
@@ -96,1086 +99,6 @@ function createNamedIcon(registry, options = {}) {
96
99
  };
97
100
  }
98
101
 
99
- //#endregion
100
- //#region src/utils/use-intersection-observer.ts
101
- /**
102
- * Observes an element and calls `onIntersect` once it enters the viewport.
103
- * Automatically disconnects after the first intersection.
104
- *
105
- * @param getElement - Getter for the target element (may be undefined before mount).
106
- * @param onIntersect - Callback fired when the element becomes visible.
107
- * @param rootMargin - IntersectionObserver rootMargin. Default: "200px".
108
- */
109
- function useIntersectionObserver(getElement, onIntersect, rootMargin = "200px") {
110
- onMount(() => {
111
- const el = getElement();
112
- if (!el) return void 0;
113
- const observer = new IntersectionObserver((entries) => {
114
- for (const entry of entries) if (entry.isIntersecting) {
115
- onIntersect();
116
- observer.disconnect();
117
- }
118
- }, { rootMargin });
119
- observer.observe(el);
120
- onUnmount(() => observer.disconnect());
121
- });
122
- }
123
-
124
- //#endregion
125
- //#region src/image.tsx
126
- /**
127
- * Composable that provides all image optimization behavior — lazy loading,
128
- * srcset/sizes resolution, format selection, blur-placeholder state,
129
- * load tracking.
130
- *
131
- * Use this for full control when `createImage` is too opinionated about
132
- * the surrounding markup (e.g. custom container layouts, non-`<div>`
133
- * wrappers, additional overlay elements).
134
- *
135
- * @example
136
- * function MyImage(props: ImageProps) {
137
- * const img = useImage(props)
138
- * return (
139
- * <figure ref={img.containerRef} style={img.containerStyle}>
140
- * <img
141
- * src={img.src}
142
- * srcSet={img.srcSet}
143
- * sizes={img.sizes}
144
- * alt={props.alt}
145
- * loading={img.loading}
146
- * onLoad={img.handleLoad}
147
- * style={img.imageStyle}
148
- * />
149
- * <figcaption>{props.alt}</figcaption>
150
- * </figure>
151
- * )
152
- * }
153
- */
154
- function useImage(props) {
155
- const isEager = props.priority || props.loading === "eager";
156
- const loaded = signal(isEager);
157
- const inView = signal(isEager);
158
- const containerRef = createRef();
159
- const resolvedSrcset = typeof props.srcset === "string" ? props.srcset : props.srcset?.map((s) => `${s.src} ${s.width}w`).join(", ");
160
- const sizes = props.sizes ?? "100vw";
161
- const fit = props.fit ?? "cover";
162
- const hasFormats = !!(props.formats && props.formats.length > 0);
163
- const aspectRatio = `${props.width} / ${props.height}`;
164
- if (!isEager) useIntersectionObserver(() => containerRef.current ?? void 0, () => inView.set(true));
165
- const containerStyle = [
166
- "position: relative",
167
- "overflow: hidden",
168
- `aspect-ratio: ${aspectRatio}`,
169
- `max-width: ${props.width}px`,
170
- "width: 100%",
171
- props.style
172
- ].filter(Boolean).join("; ");
173
- const imageStyle = () => [
174
- "display: block",
175
- "width: 100%",
176
- "height: 100%",
177
- `object-fit: ${fit}`,
178
- "transition: opacity 0.3s ease",
179
- props.placeholder && !loaded() ? "opacity: 0" : "opacity: 1"
180
- ].join("; ");
181
- const placeholderStyle = () => [
182
- "position: absolute",
183
- "inset: 0",
184
- "width: 100%",
185
- "height: 100%",
186
- "object-fit: cover",
187
- "filter: blur(20px)",
188
- "transform: scale(1.1)",
189
- "transition: opacity 0.4s ease",
190
- loaded() ? "opacity: 0; pointer-events: none" : "opacity: 1"
191
- ].join("; ");
192
- return {
193
- containerRef,
194
- inView,
195
- loaded,
196
- src: () => inView() ? props.src : "",
197
- srcSet: () => !hasFormats && inView() && resolvedSrcset ? resolvedSrcset : "",
198
- sizes: resolvedSrcset ? sizes : void 0,
199
- aspectRatio,
200
- containerStyle,
201
- imageStyle,
202
- placeholderStyle,
203
- loading: isEager ? "eager" : "lazy",
204
- fetchPriority: props.priority ? "high" : void 0,
205
- handleLoad: () => loaded.set(true),
206
- formats: props.formats,
207
- hasFormats
208
- };
209
- }
210
- /**
211
- * Higher-order component that wraps any component with image optimization.
212
- *
213
- * The wrapped component receives {@link ImageRenderProps} with the pre-rendered
214
- * `image` JSX (bare `<img>` OR `<picture>` tree depending on formats), the
215
- * pre-rendered `placeholder` JSX, and the container ref + styles. Consumers
216
- * compose those pieces with whatever wrapper element / layout they want.
217
- *
218
- * @example
219
- * // Custom figure-based image with caption
220
- * const FigureImage = createImage((props) => (
221
- * <figure ref={props.containerRef} class={props.class} style={props.containerStyle}>
222
- * {props.placeholder}
223
- * {props.image}
224
- * <figcaption>Caption goes here</figcaption>
225
- * </figure>
226
- * ))
227
- *
228
- * // Usage — identical to default <Image>
229
- * <FigureImage src="/hero.jpg" alt="Hero" width={1200} height={630} />
230
- */
231
- function createImage(Component) {
232
- return function WrappedImage(props) {
233
- if (props.raw) return /* @__PURE__ */ jsx("img", {
234
- src: props.src,
235
- alt: props.alt,
236
- width: props.width,
237
- height: props.height,
238
- class: props.class,
239
- style: props.style,
240
- decoding: props.decoding ?? "async",
241
- loading: props.loading ?? "lazy",
242
- fetchPriority: props.priority ? "high" : void 0
243
- });
244
- const img = useImage(props);
245
- const imgEl = /* @__PURE__ */ jsx("img", {
246
- src: img.src,
247
- srcSet: img.srcSet,
248
- sizes: img.sizes,
249
- alt: props.alt,
250
- width: props.width,
251
- height: props.height,
252
- loading: img.loading,
253
- decoding: props.decoding ?? "async",
254
- fetchPriority: img.fetchPriority,
255
- onLoad: img.handleLoad,
256
- style: img.imageStyle
257
- });
258
- const placeholderEl = props.placeholder ? /* @__PURE__ */ jsx("img", {
259
- src: props.placeholder,
260
- alt: "",
261
- "aria-hidden": "true",
262
- loading: "eager",
263
- style: img.placeholderStyle
264
- }) : null;
265
- const imageEl = img.hasFormats ? /* @__PURE__ */ jsxs("picture", { children: [img.formats?.map((fmt) => /* @__PURE__ */ jsx("source", {
266
- type: fmt.type,
267
- srcSet: () => img.inView() ? fmt.srcset ?? "" : "",
268
- sizes: img.sizes
269
- })), imgEl] }) : imgEl;
270
- return /* @__PURE__ */ jsx(Component, {
271
- containerRef: img.containerRef,
272
- class: props.class,
273
- containerStyle: img.containerStyle,
274
- placeholder: placeholderEl,
275
- image: imageEl
276
- });
277
- };
278
- }
279
- /**
280
- * Default optimized image component with lazy loading, responsive srcset,
281
- * `<picture>` multi-format support, and blur-up placeholders.
282
- *
283
- * @example
284
- * // With imagePlugin — spread the import directly
285
- * import hero from "./hero.jpg?optimize"
286
- * <Image {...hero} alt="Hero" priority />
287
- *
288
- * @example
289
- * // Manual usage
290
- * <Image src="/hero.jpg" alt="Hero" width={1200} height={630} />
291
- */
292
- const Image = createImage((props) => /* @__PURE__ */ jsxs("div", {
293
- ref: props.containerRef,
294
- class: props.class,
295
- style: props.containerStyle,
296
- children: [props.placeholder, props.image]
297
- }));
298
-
299
- //#endregion
300
- //#region src/link.tsx
301
- const MAX_PREFETCH_CACHE = 200;
302
- const prefetched = /* @__PURE__ */ new Map();
303
- function doPrefetch(href) {
304
- if (typeof document === "undefined") return;
305
- if (prefetched.has(href)) return;
306
- if (prefetched.size >= MAX_PREFETCH_CACHE) {
307
- const firstEntry = prefetched.entries().next().value;
308
- if (firstEntry) {
309
- const [oldestHref, oldestLinks] = firstEntry;
310
- for (const link of oldestLinks) link.remove();
311
- prefetched.delete(oldestHref);
312
- }
313
- }
314
- const injected = [];
315
- const docLink = document.createElement("link");
316
- docLink.rel = "prefetch";
317
- docLink.href = href;
318
- docLink.as = "document";
319
- document.head.appendChild(docLink);
320
- injected.push(docLink);
321
- try {
322
- const chunkHint = document.createElement("link");
323
- chunkHint.rel = "modulepreload";
324
- chunkHint.href = href;
325
- document.head.appendChild(chunkHint);
326
- injected.push(chunkHint);
327
- } catch {}
328
- prefetched.set(href, injected);
329
- }
330
- /**
331
- * Prefetch a route's JS chunk by injecting `<link rel="prefetch">` into the
332
- * document head. Deduplicates — calling with the same href twice is a no-op.
333
- *
334
- * @example
335
- * prefetchRoute('/about')
336
- * prefetchRoute('/dashboard')
337
- */
338
- function prefetchRoute(href) {
339
- doPrefetch(href);
340
- }
341
- /**
342
- * Composable that provides all link behavior — navigation, prefetching,
343
- * active state, and viewport observation.
344
- *
345
- * Use this for full control when `createLink` is too opinionated.
346
- *
347
- * @example
348
- * function MyLink(props: LinkProps) {
349
- * const link = useLink(props)
350
- * return (
351
- * <button ref={link.ref} class={link.classes()} onClick={link.handleClick}>
352
- * {props.children}
353
- * </button>
354
- * )
355
- * }
356
- */
357
- function useLink(props) {
358
- const router = useRouter();
359
- const elementRef = createRef();
360
- const strategy = props.prefetch ?? "hover";
361
- function handleClick(e) {
362
- if (props.onClick) props.onClick(e);
363
- if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || props.external || !props.href) return;
364
- e.preventDefault();
365
- router.push(props.href);
366
- }
367
- function handleMouseEnter() {
368
- if (strategy === "hover") doPrefetch(props.href);
369
- }
370
- function handleTouchStart() {
371
- if (strategy === "hover" || strategy === "viewport") doPrefetch(props.href);
372
- }
373
- if (strategy === "viewport") useIntersectionObserver(() => elementRef.current ?? void 0, () => doPrefetch(props.href));
374
- const isActive = () => {
375
- const currentPath = router.currentRoute()?.path;
376
- if (!currentPath || !props.href) return false;
377
- if (props.href === "/") return currentPath === "/";
378
- return currentPath.startsWith(props.href);
379
- };
380
- const isExactActive = () => {
381
- const currentPath = router.currentRoute()?.path;
382
- if (!currentPath) return false;
383
- return currentPath === props.href;
384
- };
385
- const classes = () => {
386
- const cls = [];
387
- if (props.class) cls.push(props.class);
388
- if (props.activeClass && isActive()) cls.push(props.activeClass);
389
- if (props.exactActiveClass && isExactActive()) cls.push(props.exactActiveClass);
390
- return cls.join(" ");
391
- };
392
- return {
393
- ref: elementRef,
394
- handleClick,
395
- handleMouseEnter,
396
- handleTouchStart,
397
- isActive,
398
- isExactActive,
399
- classes
400
- };
401
- }
402
- /**
403
- * Higher-order component that wraps any component with link behavior.
404
- *
405
- * The wrapped component receives {@link LinkRenderProps} with all handlers,
406
- * active state, and accessibility attributes pre-wired.
407
- *
408
- * @example
409
- * // Custom button link
410
- * const ButtonLink = createLink((props) => (
411
- * <button
412
- * ref={props.ref}
413
- * class={props.class}
414
- * onClick={props.onClick}
415
- * onMouseEnter={props.onMouseEnter}
416
- * >
417
- * {props.children}
418
- * </button>
419
- * ))
420
- *
421
- * // Custom styled component
422
- * const CardLink = createLink((props) => (
423
- * <div
424
- * ref={props.ref}
425
- * class={`card ${props.isActive() ? "card--active" : ""}`}
426
- * onClick={props.onClick}
427
- * onMouseEnter={props.onMouseEnter}
428
- * >
429
- * {props.children}
430
- * </div>
431
- * ))
432
- *
433
- * // Usage
434
- * <ButtonLink href="/about">About</ButtonLink>
435
- * <CardLink href="/posts" prefetch="viewport">Posts</CardLink>
436
- */
437
- function createLink(Component) {
438
- return function WrappedLink(props) {
439
- const link = useLink(props);
440
- return /* @__PURE__ */ jsx(Component, {
441
- href: props.href,
442
- ref: link.ref,
443
- onClick: link.handleClick,
444
- onMouseEnter: link.handleMouseEnter,
445
- onTouchStart: link.handleTouchStart,
446
- isActive: link.isActive,
447
- isExactActive: link.isExactActive,
448
- class: link.classes,
449
- ...props.style ? { style: props.style } : {},
450
- ...props.external ? {
451
- target: "_blank",
452
- rel: "noopener noreferrer"
453
- } : {},
454
- ...props["aria-label"] ? { "aria-label": props["aria-label"] } : {},
455
- children: props.children
456
- });
457
- };
458
- }
459
- /**
460
- * Default navigation link built on an `<a>` tag.
461
- *
462
- * @example
463
- * <Link href="/about" prefetch="viewport">About</Link>
464
- * <Link href="/posts" activeClass="nav-active">Posts</Link>
465
- */
466
- const Link = createLink((props) => /* @__PURE__ */ jsx("a", {
467
- ref: props.ref,
468
- href: props.href,
469
- ...props.class ? { class: props.class } : {},
470
- ...props.style ? { style: props.style } : {},
471
- ...props.target ? { target: props.target } : {},
472
- ...props.rel ? { rel: props.rel } : {},
473
- ...props["aria-label"] ? { "aria-label": props["aria-label"] } : {},
474
- ...props.isExactActive() ? { "aria-current": "page" } : {},
475
- onClick: props.onClick,
476
- onMouseEnter: props.onMouseEnter,
477
- onTouchStart: props.onTouchStart,
478
- children: props.children
479
- }));
480
-
481
- //#endregion
482
- //#region src/script.tsx
483
- /**
484
- * Composable that provides all script loading behavior — strategy state
485
- * machine (afterHydration / onIdle / onInteraction / onViewport),
486
- * deduplication, load/error tracking.
487
- *
488
- * Returns reactive signals (`loaded`, `errored`, `pending`) so consumers
489
- * can render loading indicators, retry buttons, or analytics-readiness
490
- * gates without re-implementing the strategy machine.
491
- *
492
- * @example
493
- * function MyScript(props: ScriptProps) {
494
- * const s = useScript(props)
495
- * return (
496
- * <>
497
- * {() => s.loaded() ? <Analytics /> : <Skeleton />}
498
- * {() => s.needsSentinel && <div ref={s.sentinelRef} style="width:0;height:0" />}
499
- * </>
500
- * )
501
- * }
502
- */
503
- function useScript(props) {
504
- const strategy = props.strategy ?? "afterHydration";
505
- const loaded = signal(false);
506
- const errored = signal(false);
507
- const pending = signal(strategy !== "beforeHydration" && strategy !== "afterHydration");
508
- const sentinelRef = strategy === "onViewport" ? createRef() : void 0;
509
- function loadScript() {
510
- if (typeof document === "undefined") return;
511
- if (props.id && document.getElementById(props.id)) {
512
- loaded.set(true);
513
- pending.set(false);
514
- return;
515
- }
516
- const script = document.createElement("script");
517
- if (props.src) script.src = props.src;
518
- if (props.id) script.id = props.id;
519
- script.async = props.async !== false;
520
- script.onload = () => {
521
- loaded.set(true);
522
- pending.set(false);
523
- props.onLoad?.();
524
- };
525
- script.onerror = () => {
526
- errored.set(true);
527
- pending.set(false);
528
- props.onError?.(/* @__PURE__ */ new Error(`Failed to load: ${props.src}`));
529
- };
530
- if (props.children && !props.src) {
531
- script.textContent = props.children;
532
- setTimeout(() => {
533
- loaded.set(true);
534
- pending.set(false);
535
- }, 0);
536
- }
537
- document.head.appendChild(script);
538
- }
539
- onMount(() => {
540
- switch (strategy) {
541
- case "beforeHydration":
542
- loaded.set(true);
543
- pending.set(false);
544
- break;
545
- case "afterHydration":
546
- loadScript();
547
- break;
548
- case "onIdle":
549
- if ("requestIdleCallback" in window) requestIdleCallback(() => loadScript(), { timeout: 5e3 });
550
- else setTimeout(loadScript, 200);
551
- break;
552
- case "onInteraction": {
553
- const events = [
554
- "click",
555
- "scroll",
556
- "keydown",
557
- "touchstart"
558
- ];
559
- function handler() {
560
- for (const e of events) document.removeEventListener(e, handler);
561
- loadScript();
562
- }
563
- for (const e of events) document.addEventListener(e, handler, {
564
- once: true,
565
- passive: true
566
- });
567
- onUnmount(() => {
568
- for (const e of events) document.removeEventListener(e, handler);
569
- });
570
- break;
571
- }
572
- case "onViewport": break;
573
- }
574
- });
575
- if (strategy === "onViewport") useIntersectionObserver(() => sentinelRef.current ?? void 0, () => loadScript());
576
- return {
577
- sentinelRef,
578
- loaded,
579
- errored,
580
- pending,
581
- needsSentinel: strategy === "onViewport",
582
- load: loadScript
583
- };
584
- }
585
- /**
586
- * Higher-order component that wraps any component with script load behavior.
587
- *
588
- * The wrapped component receives {@link ScriptRenderProps} with the sentinel
589
- * ref, load-state signals, and a `needsSentinel` flag. Use this when you want
590
- * to render a loading indicator, retry button, or custom analytics-readiness
591
- * gate around the script load.
592
- *
593
- * @example
594
- * // Script with a loading indicator
595
- * const TrackedScript = createScript((props) => (
596
- * <>
597
- * {() => props.pending() && <Spinner />}
598
- * {() => props.errored() && <button onClick={() => location.reload()}>Retry</button>}
599
- * {props.needsSentinel && <div ref={props.sentinelRef} style="width:0;height:0" />}
600
- * </>
601
- * ))
602
- *
603
- * <TrackedScript src="/analytics.js" strategy="onIdle" />
604
- */
605
- function createScript(Component) {
606
- return function WrappedScript(props) {
607
- const s = useScript(props);
608
- return /* @__PURE__ */ jsx(Component, {
609
- sentinelRef: s.sentinelRef,
610
- needsSentinel: s.needsSentinel,
611
- loaded: s.loaded,
612
- errored: s.errored,
613
- pending: s.pending
614
- });
615
- };
616
- }
617
- /**
618
- * Default optimized script component. Renders a 0×0 sentinel `<div>` for the
619
- * `onViewport` strategy (so IntersectionObserver has an element to observe),
620
- * `null` for every other strategy.
621
- *
622
- * @example
623
- * // Load analytics after page is interactive
624
- * <Script src="https://analytics.example.com/script.js" strategy="onIdle" />
625
- *
626
- * // Load chat widget when user scrolls
627
- * <Script src="/chat-widget.js" strategy="onViewport" />
628
- *
629
- * // Inline script with deferred execution
630
- * <Script strategy="afterHydration">
631
- * {`console.log("App hydrated!")`}
632
- * <\/Script>
633
- */
634
- const Script = createScript((props) => {
635
- if (!props.needsSentinel) return null;
636
- return /* @__PURE__ */ jsx("div", {
637
- ref: props.sentinelRef,
638
- style: "width:0;height:0;overflow:hidden"
639
- });
640
- });
641
-
642
- //#endregion
643
- //#region src/i18n-routing.ts
644
- /**
645
- * Extract locale from a URL path.
646
- * Returns { locale, pathWithoutLocale }.
647
- */
648
- function extractLocaleFromPath(path, locales, defaultLocale) {
649
- const segments = path.split("/").filter(Boolean);
650
- const firstSegment = segments[0]?.toLowerCase();
651
- if (firstSegment && locales.includes(firstSegment)) return {
652
- locale: firstSegment,
653
- pathWithoutLocale: "/" + segments.slice(1).join("/") || "/"
654
- };
655
- return {
656
- locale: defaultLocale,
657
- pathWithoutLocale: path
658
- };
659
- }
660
- /**
661
- * Build a localized path.
662
- */
663
- function buildLocalePath(path, locale, defaultLocale, strategy) {
664
- const clean = path === "/" ? "" : path;
665
- if (strategy === "prefix-except-default" && locale === defaultLocale) return path;
666
- return `/${locale}${clean}`;
667
- }
668
- /** @internal Context for the current locale. */
669
- const LocaleCtx = createContext("en");
670
- /** Current locale signal — set by the server middleware or client-side detection. */
671
- const localeSignal = signal("en");
672
- /**
673
- * Read the current locale reactively.
674
- *
675
- * Returns the locale signal value directly — reactive in both SSR and CSR.
676
- * The server middleware sets `localeSignal` per-request, and client-side
677
- * `setLocale()` updates it as well.
678
- *
679
- * @example
680
- * ```tsx
681
- * const locale = useLocale() // "en", "de", etc.
682
- * ```
683
- */
684
- function useLocale() {
685
- return localeSignal();
686
- }
687
- /**
688
- * Set the locale client-side and update the URL.
689
- *
690
- * @example
691
- * ```tsx
692
- * <button onClick={() => setLocale('de')}>Deutsch</button>
693
- * ```
694
- */
695
- function setLocale(locale, config) {
696
- localeSignal.set(locale);
697
- if (typeof document !== "undefined") document.cookie = `${config.cookieName ?? "locale"}=${locale}; path=/; max-age=31536000`;
698
- if (typeof window !== "undefined") {
699
- const strategy = config.strategy ?? "prefix-except-default";
700
- const { pathWithoutLocale } = extractLocaleFromPath(window.location.pathname, config.locales, config.defaultLocale);
701
- const newPath = buildLocalePath(pathWithoutLocale, locale, config.defaultLocale, strategy);
702
- window.history.pushState(null, "", newPath);
703
- window.dispatchEvent(new PopStateEvent("popstate"));
704
- }
705
- }
706
-
707
- //#endregion
708
- //#region src/meta.tsx
709
- function faviconLinks(locale, config) {
710
- const hasLocaleOverride = locale && config.locales?.[locale];
711
- const prefix = hasLocaleOverride ? `/${locale}` : "";
712
- const isSvg = (hasLocaleOverride ? config.locales[locale].source : config.source).endsWith(".svg");
713
- const links = [];
714
- if (isSvg) links.push({
715
- rel: "icon",
716
- type: "image/svg+xml",
717
- href: `${prefix}/favicon.svg`
718
- });
719
- links.push({
720
- rel: "icon",
721
- type: "image/png",
722
- sizes: "32x32",
723
- href: `${prefix}/favicon-32x32.png`
724
- }, {
725
- rel: "icon",
726
- type: "image/png",
727
- sizes: "16x16",
728
- href: `${prefix}/favicon-16x16.png`
729
- }, {
730
- rel: "apple-touch-icon",
731
- sizes: "180x180",
732
- href: `${prefix}/apple-touch-icon.png`
733
- });
734
- if (config.manifest !== false) links.push({
735
- rel: "manifest",
736
- href: `${prefix}/site.webmanifest`
737
- });
738
- return links;
739
- }
740
- function ogImagePath(templateName, locale, outDir = "og", format = "png") {
741
- const ext = format === "jpeg" ? "jpg" : "png";
742
- return `/${outDir}/${templateName}${locale ? `-${locale}` : ""}.${ext}`;
743
- }
744
- const resolveStr = (v) => typeof v === "function" ? v() : v;
745
- /**
746
- * Declarative meta component for SSR-compatible page metadata.
747
- *
748
- * Supports reactive title/description — when passed as `() => string` accessors,
749
- * they are forwarded to `useHead()` as a reactive getter so updates propagate
750
- * automatically via signal tracking.
751
- *
752
- * @example
753
- * ```tsx
754
- * <Meta title="My Page" description="..." image="/og.jpg" canonical="https://..." />
755
- * ```
756
- *
757
- * @example Reactive title
758
- * ```tsx
759
- * const count = signal(0)
760
- * <Meta title={() => `${count()} items`} />
761
- * ```
762
- */
763
- function Meta(props) {
764
- const hasReactiveTitle = typeof props.title === "function";
765
- const hasReactiveDescription = typeof props.description === "function";
766
- if (hasReactiveTitle || hasReactiveDescription) useHead(() => {
767
- const title = resolveStr(props.title);
768
- const description = resolveStr(props.description);
769
- const tags = buildMetaTags({
770
- ...props,
771
- title,
772
- description
773
- });
774
- const input = {
775
- meta: tags.meta,
776
- link: tags.link,
777
- script: tags.script
778
- };
779
- if (title) input.title = title;
780
- return input;
781
- });
782
- else {
783
- const title = resolveStr(props.title);
784
- const description = resolveStr(props.description);
785
- const tags = buildMetaTags({
786
- ...props,
787
- title,
788
- description
789
- });
790
- const input = {
791
- meta: tags.meta,
792
- link: tags.link,
793
- script: tags.script
794
- };
795
- if (title) input.title = title;
796
- useHead(input);
797
- }
798
- return props.children ?? null;
799
- }
800
- function buildMetaTags(props) {
801
- const meta = [];
802
- const link = [];
803
- const script = [];
804
- const { title, description, canonical, imageAlt, imageWidth, imageHeight, type = "website", siteName, twitterCard = "summary_large_image", twitterSite, twitterCreator, locale = "en_US", alternateLocales, publishedTime, modifiedTime, author, tags, jsonLd, extra, video, videoWidth, videoHeight, audio, favicon, ogTemplate, ogImageDir, ogImageFormat } = props;
805
- const robots = props.noIndex ? "noindex, nofollow" : props.robots ?? "index, follow";
806
- const image = props.image ?? (ogTemplate ? ogImagePath(ogTemplate, locale !== "en_US" ? locale : void 0, ogImageDir, ogImageFormat) : void 0);
807
- const resolvedImageWidth = imageWidth ?? (ogTemplate && !props.image ? 1200 : void 0);
808
- const resolvedImageHeight = imageHeight ?? (ogTemplate && !props.image ? 630 : void 0);
809
- if (description) meta.push({
810
- name: "description",
811
- content: description
812
- });
813
- if (robots) meta.push({
814
- name: "robots",
815
- content: robots
816
- });
817
- if (author) meta.push({
818
- name: "author",
819
- content: author
820
- });
821
- if (title) meta.push({
822
- property: "og:title",
823
- content: title
824
- });
825
- if (description) meta.push({
826
- property: "og:description",
827
- content: description
828
- });
829
- if (canonical) meta.push({
830
- property: "og:url",
831
- content: canonical
832
- });
833
- if (image) meta.push({
834
- property: "og:image",
835
- content: image
836
- });
837
- if (imageAlt) meta.push({
838
- property: "og:image:alt",
839
- content: imageAlt
840
- });
841
- if (resolvedImageWidth) meta.push({
842
- property: "og:image:width",
843
- content: String(resolvedImageWidth)
844
- });
845
- if (resolvedImageHeight) meta.push({
846
- property: "og:image:height",
847
- content: String(resolvedImageHeight)
848
- });
849
- meta.push({
850
- property: "og:type",
851
- content: type
852
- });
853
- if (siteName) meta.push({
854
- property: "og:site_name",
855
- content: siteName
856
- });
857
- meta.push({
858
- property: "og:locale",
859
- content: locale
860
- });
861
- if (video) {
862
- meta.push({
863
- property: "og:video",
864
- content: video
865
- });
866
- if (videoWidth) meta.push({
867
- property: "og:video:width",
868
- content: String(videoWidth)
869
- });
870
- if (videoHeight) meta.push({
871
- property: "og:video:height",
872
- content: String(videoHeight)
873
- });
874
- if (video.endsWith(".mp4")) meta.push({
875
- property: "og:video:type",
876
- content: "video/mp4"
877
- });
878
- else if (video.endsWith(".webm")) meta.push({
879
- property: "og:video:type",
880
- content: "video/webm"
881
- });
882
- }
883
- if (audio) meta.push({
884
- property: "og:audio",
885
- content: audio
886
- });
887
- if (type === "article") {
888
- if (publishedTime) meta.push({
889
- property: "article:published_time",
890
- content: publishedTime
891
- });
892
- if (modifiedTime) meta.push({
893
- property: "article:modified_time",
894
- content: modifiedTime
895
- });
896
- if (author) meta.push({
897
- property: "article:author",
898
- content: author
899
- });
900
- if (tags) for (const tag of tags) meta.push({
901
- property: "article:tag",
902
- content: tag
903
- });
904
- }
905
- meta.push({
906
- name: "twitter:card",
907
- content: twitterCard
908
- });
909
- if (title) meta.push({
910
- name: "twitter:title",
911
- content: title
912
- });
913
- if (description) meta.push({
914
- name: "twitter:description",
915
- content: description
916
- });
917
- if (image) meta.push({
918
- name: "twitter:image",
919
- content: image
920
- });
921
- if (imageAlt) meta.push({
922
- name: "twitter:image:alt",
923
- content: imageAlt
924
- });
925
- if (twitterSite) meta.push({
926
- name: "twitter:site",
927
- content: twitterSite
928
- });
929
- if (twitterCreator) meta.push({
930
- name: "twitter:creator",
931
- content: twitterCreator
932
- });
933
- if (canonical) link.push({
934
- rel: "canonical",
935
- href: canonical
936
- });
937
- if (alternateLocales) for (const alt of alternateLocales) link.push({
938
- rel: "alternate",
939
- hreflang: alt.locale,
940
- href: alt.url
941
- });
942
- if (jsonLd) script.push({
943
- type: "application/ld+json",
944
- children: JSON.stringify({
945
- "@context": "https://schema.org",
946
- ...jsonLd
947
- })
948
- });
949
- if (extra) for (const tag of extra) meta.push(tag);
950
- if (props.i18n) {
951
- const i18nConfig = props.i18n;
952
- const origin = props.origin ?? "";
953
- const { pathWithoutLocale } = extractLocaleFromPath(canonical?.replace(origin, "") ?? "/", i18nConfig.locales, i18nConfig.defaultLocale);
954
- const strategy = i18nConfig.strategy ?? "prefix-except-default";
955
- for (const loc of i18nConfig.locales) {
956
- const localizedPath = strategy === "prefix-except-default" && loc === i18nConfig.defaultLocale ? pathWithoutLocale : `/${loc}${pathWithoutLocale === "/" ? "" : pathWithoutLocale}`;
957
- link.push({
958
- rel: "alternate",
959
- hreflang: loc,
960
- href: `${origin}${localizedPath}`
961
- });
962
- if (loc !== locale) meta.push({
963
- property: "og:locale:alternate",
964
- content: loc
965
- });
966
- }
967
- link.push({
968
- rel: "alternate",
969
- hreflang: "x-default",
970
- href: `${origin}${pathWithoutLocale}`
971
- });
972
- }
973
- if (favicon) {
974
- const faviconLocale = locale !== "en_US" ? locale : void 0;
975
- for (const fl of faviconLinks(faviconLocale, favicon)) link.push(fl);
976
- if (favicon.themeColor) meta.push({
977
- name: "theme-color",
978
- content: favicon.themeColor
979
- });
980
- }
981
- return {
982
- meta,
983
- link,
984
- script
985
- };
986
- }
987
-
988
- //#endregion
989
- //#region src/theme.tsx
990
- const STORAGE_KEY = "zero-theme";
991
- /** Reactive theme signal. */
992
- const theme = signal("system");
993
- /**
994
- * Reactive signal tracking the OS color-scheme preference. Updated by the
995
- * `matchMedia('(prefers-color-scheme: dark)').change` event registered in
996
- * `initTheme`. Components reading `resolvedTheme()` subscribe to BOTH
997
- * `theme` and this signal, so a user toggling dark mode at the OS level
998
- * re-renders everything reactively — not just the `<html data-theme>`
999
- * attribute.
1000
- *
1001
- * SSR default is `_ssrDefault` (mutable via `setSSRThemeDefault`) so the
1002
- * server-rendered theme can differ from the client's OS preference.
1003
- */
1004
- const _osPrefersDark = signal(false);
1005
- /** SSR fallback when system preference can't be detected. Default: 'light'. */
1006
- let _ssrDefault = "light";
1007
- /**
1008
- * Set the default theme for SSR (when `matchMedia` is unavailable).
1009
- * Call once at server startup before rendering.
1010
- */
1011
- function setSSRThemeDefault(value) {
1012
- _ssrDefault = value;
1013
- }
1014
- /**
1015
- * Reactive read of the resolved theme. Subscribes to `theme` (explicit
1016
- * user choice) and — when `theme === 'system'` — to `_osPrefersDark`
1017
- * (OS color-scheme preference). Components using `resolvedTheme()`
1018
- * inside JSX / effects / computeds re-render when either changes.
1019
- */
1020
- function resolvedTheme() {
1021
- const t = theme();
1022
- if (t === "system") {
1023
- if (typeof window === "undefined") return _ssrDefault;
1024
- return _osPrefersDark() ? "dark" : "light";
1025
- }
1026
- return t;
1027
- }
1028
- /** Toggle between light and dark. */
1029
- function toggleTheme() {
1030
- setTheme(resolvedTheme() === "dark" ? "light" : "dark");
1031
- }
1032
- /** Set theme explicitly. */
1033
- function setTheme(t) {
1034
- theme.set(t);
1035
- if (typeof document !== "undefined") {
1036
- document.documentElement.dataset.theme = resolvedTheme();
1037
- try {
1038
- localStorage.setItem(STORAGE_KEY, t);
1039
- } catch {}
1040
- }
1041
- }
1042
- /**
1043
- * Initialize the theme system. Call once in your app entry or layout.
1044
- * Reads from localStorage, listens for system preference changes.
1045
- */
1046
- function initTheme() {
1047
- onMount(() => {
1048
- try {
1049
- const stored = localStorage.getItem(STORAGE_KEY);
1050
- if (stored === "light" || stored === "dark" || stored === "system") theme.set(stored);
1051
- } catch {}
1052
- document.documentElement.dataset.theme = resolvedTheme();
1053
- const mq = window.matchMedia("(prefers-color-scheme: dark)");
1054
- _osPrefersDark.set(mq.matches);
1055
- function onChange(e) {
1056
- _osPrefersDark.set(e.matches);
1057
- }
1058
- mq.addEventListener("change", onChange);
1059
- const dispose = effect(() => {
1060
- const mode = resolvedTheme();
1061
- document.documentElement.dataset.theme = mode;
1062
- const faviconLinks = document.querySelectorAll("[data-favicon-theme]");
1063
- for (const link of faviconLinks) link.media = link.dataset.faviconTheme === mode ? "" : "not all";
1064
- });
1065
- return () => {
1066
- mq.removeEventListener("change", onChange);
1067
- dispose?.dispose();
1068
- };
1069
- });
1070
- }
1071
- /**
1072
- * Theme toggle button component.
1073
- *
1074
- * @example
1075
- * import { ThemeToggle } from "@pyreon/zero/theme"
1076
- * <ThemeToggle />
1077
- */
1078
- function ThemeToggle(props) {
1079
- initTheme();
1080
- return /* @__PURE__ */ jsx("button", {
1081
- class: props.class,
1082
- style: props.style,
1083
- onClick: toggleTheme,
1084
- "aria-label": "Toggle theme",
1085
- title: "Toggle theme",
1086
- type: "button",
1087
- children: () => resolvedTheme() === "dark" ? /* @__PURE__ */ jsxs("svg", {
1088
- width: "18",
1089
- height: "18",
1090
- viewBox: "0 0 24 24",
1091
- fill: "none",
1092
- stroke: "currentColor",
1093
- "stroke-width": "2",
1094
- "stroke-linecap": "round",
1095
- "stroke-linejoin": "round",
1096
- "aria-hidden": "true",
1097
- children: [
1098
- /* @__PURE__ */ jsx("circle", {
1099
- cx: "12",
1100
- cy: "12",
1101
- r: "5"
1102
- }),
1103
- /* @__PURE__ */ jsx("line", {
1104
- x1: "12",
1105
- y1: "1",
1106
- x2: "12",
1107
- y2: "3"
1108
- }),
1109
- /* @__PURE__ */ jsx("line", {
1110
- x1: "12",
1111
- y1: "21",
1112
- x2: "12",
1113
- y2: "23"
1114
- }),
1115
- /* @__PURE__ */ jsx("line", {
1116
- x1: "4.22",
1117
- y1: "4.22",
1118
- x2: "5.64",
1119
- y2: "5.64"
1120
- }),
1121
- /* @__PURE__ */ jsx("line", {
1122
- x1: "18.36",
1123
- y1: "18.36",
1124
- x2: "19.78",
1125
- y2: "19.78"
1126
- }),
1127
- /* @__PURE__ */ jsx("line", {
1128
- x1: "1",
1129
- y1: "12",
1130
- x2: "3",
1131
- y2: "12"
1132
- }),
1133
- /* @__PURE__ */ jsx("line", {
1134
- x1: "21",
1135
- y1: "12",
1136
- x2: "23",
1137
- y2: "12"
1138
- }),
1139
- /* @__PURE__ */ jsx("line", {
1140
- x1: "4.22",
1141
- y1: "19.78",
1142
- x2: "5.64",
1143
- y2: "18.36"
1144
- }),
1145
- /* @__PURE__ */ jsx("line", {
1146
- x1: "18.36",
1147
- y1: "5.64",
1148
- x2: "19.78",
1149
- y2: "4.22"
1150
- })
1151
- ]
1152
- }) : /* @__PURE__ */ jsx("svg", {
1153
- width: "18",
1154
- height: "18",
1155
- viewBox: "0 0 24 24",
1156
- fill: "none",
1157
- stroke: "currentColor",
1158
- "stroke-width": "2",
1159
- "stroke-linecap": "round",
1160
- "stroke-linejoin": "round",
1161
- "aria-hidden": "true",
1162
- children: /* @__PURE__ */ jsx("path", { d: "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" })
1163
- })
1164
- });
1165
- }
1166
- /**
1167
- * Inline script to prevent flash of wrong theme.
1168
- * Include this in your index.html <head> BEFORE any stylesheets.
1169
- *
1170
- * @example
1171
- * // index.html
1172
- * <head>
1173
- * <script>{themeScript}<\/script>
1174
- * ...
1175
- * </head>
1176
- */
1177
- 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;document.querySelectorAll("[data-favicon-theme]").forEach(function(l){l.media=l.dataset.faviconTheme===r?"":"not all"})}catch(e){}})()`;
1178
-
1179
102
  //#endregion
1180
103
  //#region src/index.ts
1181
104
  function serverOnly(name, subpath) {