@pyreon/zero 0.15.0 → 0.18.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.
Files changed (52) hide show
  1. package/lib/{api-routes-DANluJic.js → api-routes-Ci0kVmM4.js} +2 -2
  2. package/lib/client.js +4 -1
  3. package/lib/env.js +6 -6
  4. package/lib/font.js +3 -3
  5. package/lib/{fs-router-ZebyutPa.js → fs-router-MewHc5SB.js} +25 -30
  6. package/lib/i18n-routing.js +112 -1
  7. package/lib/image.js +140 -58
  8. package/lib/index.js +252 -82
  9. package/lib/og-image.js +5 -5
  10. package/lib/rolldown-runtime-CjeV3_4I.js +18 -0
  11. package/lib/script.js +114 -25
  12. package/lib/seo.js +186 -15
  13. package/lib/server.js +274 -564
  14. package/lib/types/config.d.ts +307 -3
  15. package/lib/types/env.d.ts +2 -2
  16. package/lib/types/i18n-routing.d.ts +193 -2
  17. package/lib/types/image.d.ts +105 -5
  18. package/lib/types/index.d.ts +666 -182
  19. package/lib/types/script.d.ts +78 -6
  20. package/lib/types/seo.d.ts +128 -4
  21. package/lib/types/server.d.ts +607 -72
  22. package/lib/vite-plugin-y0NmCLJA.js +2476 -0
  23. package/package.json +11 -10
  24. package/src/adapters/bun.ts +20 -1
  25. package/src/adapters/cloudflare.ts +78 -1
  26. package/src/adapters/index.ts +25 -3
  27. package/src/adapters/netlify.ts +63 -1
  28. package/src/adapters/node.ts +25 -1
  29. package/src/adapters/static.ts +26 -1
  30. package/src/adapters/validate.ts +8 -1
  31. package/src/adapters/vercel.ts +76 -1
  32. package/src/adapters/warn-missing-env.ts +49 -0
  33. package/src/app.ts +14 -0
  34. package/src/client.ts +18 -0
  35. package/src/entry-server.ts +55 -5
  36. package/src/env.ts +7 -7
  37. package/src/font.ts +3 -3
  38. package/src/fs-router.ts +72 -3
  39. package/src/i18n-routing.ts +246 -12
  40. package/src/image.tsx +242 -91
  41. package/src/index.ts +4 -4
  42. package/src/isr.ts +24 -6
  43. package/src/manifest.ts +675 -0
  44. package/src/og-image.ts +5 -5
  45. package/src/script.tsx +159 -36
  46. package/src/seo.ts +346 -15
  47. package/src/server.ts +10 -2
  48. package/src/ssg-plugin.ts +1211 -54
  49. package/src/types.ts +333 -10
  50. package/src/vercel-revalidate-handler.ts +204 -0
  51. package/src/vite-plugin.ts +171 -41
  52. package/lib/vite-plugin-E4BHYvYW.js +0 -855
package/lib/index.js CHANGED
@@ -31,30 +31,34 @@ function useIntersectionObserver(getElement, onIntersect, rootMargin = "200px")
31
31
  //#endregion
32
32
  //#region src/image.tsx
33
33
  /**
34
- * Optimized image component with lazy loading, responsive images,
35
- * multi-format <picture> support, and blur-up placeholders.
34
+ * Composable that provides all image optimization behavior lazy loading,
35
+ * srcset/sizes resolution, format selection, blur-placeholder state,
36
+ * load tracking.
36
37
  *
37
- * @example
38
- * // With imagePlugin spread the import directly
39
- * import hero from "./hero.jpg?optimize"
40
- * <Image {...hero} alt="Hero" priority />
38
+ * Use this for full control when `createImage` is too opinionated about
39
+ * the surrounding markup (e.g. custom container layouts, non-`<div>`
40
+ * wrappers, additional overlay elements).
41
41
  *
42
42
  * @example
43
- * // Manual usage
44
- * <Image src="/hero.jpg" alt="Hero" width={1200} height={630} />
43
+ * function MyImage(props: ImageProps) {
44
+ * const img = useImage(props)
45
+ * return (
46
+ * <figure ref={img.containerRef} style={img.containerStyle}>
47
+ * <img
48
+ * src={img.src}
49
+ * srcSet={img.srcSet}
50
+ * sizes={img.sizes}
51
+ * alt={props.alt}
52
+ * loading={img.loading}
53
+ * onLoad={img.handleLoad}
54
+ * style={img.imageStyle}
55
+ * />
56
+ * <figcaption>{props.alt}</figcaption>
57
+ * </figure>
58
+ * )
59
+ * }
45
60
  */
46
- function Image(props) {
47
- if (props.raw) return /* @__PURE__ */ jsx("img", {
48
- src: props.src,
49
- alt: props.alt,
50
- width: props.width,
51
- height: props.height,
52
- class: props.class,
53
- style: props.style,
54
- decoding: props.decoding ?? "async",
55
- loading: props.loading ?? "lazy",
56
- fetchPriority: props.priority ? "high" : void 0
57
- });
61
+ function useImage(props) {
58
62
  const isEager = props.priority || props.loading === "eager";
59
63
  const loaded = signal(isEager);
60
64
  const inView = signal(isEager);
@@ -62,7 +66,7 @@ function Image(props) {
62
66
  const resolvedSrcset = typeof props.srcset === "string" ? props.srcset : props.srcset?.map((s) => `${s.src} ${s.width}w`).join(", ");
63
67
  const sizes = props.sizes ?? "100vw";
64
68
  const fit = props.fit ?? "cover";
65
- const hasFormats = props.formats && props.formats.length > 0;
69
+ const hasFormats = !!(props.formats && props.formats.length > 0);
66
70
  const aspectRatio = `${props.width} / ${props.height}`;
67
71
  if (!isEager) useIntersectionObserver(() => containerRef.current ?? void 0, () => inView.set(true));
68
72
  const containerStyle = [
@@ -73,53 +77,131 @@ function Image(props) {
73
77
  "width: 100%",
74
78
  props.style
75
79
  ].filter(Boolean).join("; ");
76
- const imgEl = /* @__PURE__ */ jsx("img", {
80
+ const imageStyle = () => [
81
+ "display: block",
82
+ "width: 100%",
83
+ "height: 100%",
84
+ `object-fit: ${fit}`,
85
+ "transition: opacity 0.3s ease",
86
+ props.placeholder && !loaded() ? "opacity: 0" : "opacity: 1"
87
+ ].join("; ");
88
+ const placeholderStyle = () => [
89
+ "position: absolute",
90
+ "inset: 0",
91
+ "width: 100%",
92
+ "height: 100%",
93
+ "object-fit: cover",
94
+ "filter: blur(20px)",
95
+ "transform: scale(1.1)",
96
+ "transition: opacity 0.4s ease",
97
+ loaded() ? "opacity: 0; pointer-events: none" : "opacity: 1"
98
+ ].join("; ");
99
+ return {
100
+ containerRef,
101
+ inView,
102
+ loaded,
77
103
  src: () => inView() ? props.src : "",
78
104
  srcSet: () => !hasFormats && inView() && resolvedSrcset ? resolvedSrcset : "",
79
105
  sizes: resolvedSrcset ? sizes : void 0,
80
- alt: props.alt,
81
- width: props.width,
82
- height: props.height,
106
+ aspectRatio,
107
+ containerStyle,
108
+ imageStyle,
109
+ placeholderStyle,
83
110
  loading: isEager ? "eager" : "lazy",
84
- decoding: props.decoding ?? "async",
85
111
  fetchPriority: props.priority ? "high" : void 0,
86
- onLoad: () => loaded.set(true),
87
- style: () => [
88
- "display: block",
89
- "width: 100%",
90
- "height: 100%",
91
- `object-fit: ${fit}`,
92
- "transition: opacity 0.3s ease",
93
- props.placeholder && !loaded() ? "opacity: 0" : "opacity: 1"
94
- ].join("; ")
95
- });
96
- return /* @__PURE__ */ jsxs("div", {
97
- ref: containerRef,
98
- class: props.class,
99
- style: containerStyle,
100
- children: [props.placeholder && /* @__PURE__ */ jsx("img", {
112
+ handleLoad: () => loaded.set(true),
113
+ formats: props.formats,
114
+ hasFormats
115
+ };
116
+ }
117
+ /**
118
+ * Higher-order component that wraps any component with image optimization.
119
+ *
120
+ * The wrapped component receives {@link ImageRenderProps} with the pre-rendered
121
+ * `image` JSX (bare `<img>` OR `<picture>` tree depending on formats), the
122
+ * pre-rendered `placeholder` JSX, and the container ref + styles. Consumers
123
+ * compose those pieces with whatever wrapper element / layout they want.
124
+ *
125
+ * @example
126
+ * // Custom figure-based image with caption
127
+ * const FigureImage = createImage((props) => (
128
+ * <figure ref={props.containerRef} class={props.class} style={props.containerStyle}>
129
+ * {props.placeholder}
130
+ * {props.image}
131
+ * <figcaption>Caption goes here</figcaption>
132
+ * </figure>
133
+ * ))
134
+ *
135
+ * // Usage — identical to default <Image>
136
+ * <FigureImage src="/hero.jpg" alt="Hero" width={1200} height={630} />
137
+ */
138
+ function createImage(Component) {
139
+ return function WrappedImage(props) {
140
+ if (props.raw) return /* @__PURE__ */ jsx("img", {
141
+ src: props.src,
142
+ alt: props.alt,
143
+ width: props.width,
144
+ height: props.height,
145
+ class: props.class,
146
+ style: props.style,
147
+ decoding: props.decoding ?? "async",
148
+ loading: props.loading ?? "lazy",
149
+ fetchPriority: props.priority ? "high" : void 0
150
+ });
151
+ const img = useImage(props);
152
+ const imgEl = /* @__PURE__ */ jsx("img", {
153
+ src: img.src,
154
+ srcSet: img.srcSet,
155
+ sizes: img.sizes,
156
+ alt: props.alt,
157
+ width: props.width,
158
+ height: props.height,
159
+ loading: img.loading,
160
+ decoding: props.decoding ?? "async",
161
+ fetchPriority: img.fetchPriority,
162
+ onLoad: img.handleLoad,
163
+ style: img.imageStyle
164
+ });
165
+ const placeholderEl = props.placeholder ? /* @__PURE__ */ jsx("img", {
101
166
  src: props.placeholder,
102
167
  alt: "",
103
168
  "aria-hidden": "true",
104
169
  loading: "eager",
105
- style: () => [
106
- "position: absolute",
107
- "inset: 0",
108
- "width: 100%",
109
- "height: 100%",
110
- "object-fit: cover",
111
- "filter: blur(20px)",
112
- "transform: scale(1.1)",
113
- "transition: opacity 0.4s ease",
114
- loaded() ? "opacity: 0; pointer-events: none" : "opacity: 1"
115
- ].join("; ")
116
- }), hasFormats ? /* @__PURE__ */ jsxs("picture", { children: [props.formats?.map((fmt) => /* @__PURE__ */ jsx("source", {
170
+ style: img.placeholderStyle
171
+ }) : null;
172
+ const imageEl = img.hasFormats ? /* @__PURE__ */ jsxs("picture", { children: [img.formats?.map((fmt) => /* @__PURE__ */ jsx("source", {
117
173
  type: fmt.type,
118
- srcSet: () => inView() ? fmt.srcset ?? "" : "",
119
- sizes
120
- })), imgEl] }) : imgEl]
121
- });
174
+ srcSet: () => img.inView() ? fmt.srcset ?? "" : "",
175
+ sizes: img.sizes
176
+ })), imgEl] }) : imgEl;
177
+ return /* @__PURE__ */ jsx(Component, {
178
+ containerRef: img.containerRef,
179
+ class: props.class,
180
+ containerStyle: img.containerStyle,
181
+ placeholder: placeholderEl,
182
+ image: imageEl
183
+ });
184
+ };
122
185
  }
186
+ /**
187
+ * Default optimized image component with lazy loading, responsive srcset,
188
+ * `<picture>` multi-format support, and blur-up placeholders.
189
+ *
190
+ * @example
191
+ * // With imagePlugin — spread the import directly
192
+ * import hero from "./hero.jpg?optimize"
193
+ * <Image {...hero} alt="Hero" priority />
194
+ *
195
+ * @example
196
+ * // Manual usage
197
+ * <Image src="/hero.jpg" alt="Hero" width={1200} height={630} />
198
+ */
199
+ const Image = createImage((props) => /* @__PURE__ */ jsxs("div", {
200
+ ref: props.containerRef,
201
+ class: props.class,
202
+ style: props.containerStyle,
203
+ children: [props.placeholder, props.image]
204
+ }));
123
205
 
124
206
  //#endregion
125
207
  //#region src/link.tsx
@@ -306,36 +388,67 @@ const Link = createLink((props) => /* @__PURE__ */ jsx("a", {
306
388
  //#endregion
307
389
  //#region src/script.tsx
308
390
  /**
309
- * Optimized script loading component.
391
+ * Composable that provides all script loading behavior — strategy state
392
+ * machine (afterHydration / onIdle / onInteraction / onViewport),
393
+ * deduplication, load/error tracking.
310
394
  *
311
- * @example
312
- * // Load analytics after page is interactive
313
- * <Script src="https://analytics.example.com/script.js" strategy="onIdle" />
395
+ * Returns reactive signals (`loaded`, `errored`, `pending`) so consumers
396
+ * can render loading indicators, retry buttons, or analytics-readiness
397
+ * gates without re-implementing the strategy machine.
314
398
  *
315
- * // Load chat widget when user scrolls
316
- * <Script src="/chat-widget.js" strategy="onViewport" />
317
- *
318
- * // Inline script with deferred execution
319
- * <Script strategy="afterHydration">
320
- * {`console.log("App hydrated!")`}
321
- * <\/Script>
399
+ * @example
400
+ * function MyScript(props: ScriptProps) {
401
+ * const s = useScript(props)
402
+ * return (
403
+ * <>
404
+ * {() => s.loaded() ? <Analytics /> : <Skeleton />}
405
+ * {() => s.needsSentinel && <div ref={s.sentinelRef} style="width:0;height:0" />}
406
+ * </>
407
+ * )
408
+ * }
322
409
  */
323
- function Script(props) {
410
+ function useScript(props) {
411
+ const strategy = props.strategy ?? "afterHydration";
412
+ const loaded = signal(false);
413
+ const errored = signal(false);
414
+ const pending = signal(strategy !== "beforeHydration" && strategy !== "afterHydration");
415
+ const sentinelRef = strategy === "onViewport" ? createRef() : void 0;
324
416
  function loadScript() {
325
417
  if (typeof document === "undefined") return;
326
- if (props.id && document.getElementById(props.id)) return;
418
+ if (props.id && document.getElementById(props.id)) {
419
+ loaded.set(true);
420
+ pending.set(false);
421
+ return;
422
+ }
327
423
  const script = document.createElement("script");
328
424
  if (props.src) script.src = props.src;
329
425
  if (props.id) script.id = props.id;
330
426
  script.async = props.async !== false;
331
- if (props.onLoad) script.onload = props.onLoad;
332
- if (props.onError) script.onerror = () => props.onError?.(/* @__PURE__ */ new Error(`Failed to load: ${props.src}`));
333
- if (props.children && !props.src) script.textContent = props.children;
427
+ script.onload = () => {
428
+ loaded.set(true);
429
+ pending.set(false);
430
+ props.onLoad?.();
431
+ };
432
+ script.onerror = () => {
433
+ errored.set(true);
434
+ pending.set(false);
435
+ props.onError?.(/* @__PURE__ */ new Error(`Failed to load: ${props.src}`));
436
+ };
437
+ if (props.children && !props.src) {
438
+ script.textContent = props.children;
439
+ setTimeout(() => {
440
+ loaded.set(true);
441
+ pending.set(false);
442
+ }, 0);
443
+ }
334
444
  document.head.appendChild(script);
335
445
  }
336
446
  onMount(() => {
337
- switch (props.strategy ?? "afterHydration") {
338
- case "beforeHydration": break;
447
+ switch (strategy) {
448
+ case "beforeHydration":
449
+ loaded.set(true);
450
+ pending.set(false);
451
+ break;
339
452
  case "afterHydration":
340
453
  loadScript();
341
454
  break;
@@ -366,15 +479,72 @@ function Script(props) {
366
479
  case "onViewport": break;
367
480
  }
368
481
  });
369
- const sentinelRef = createRef();
370
- const strategy = props.strategy ?? "afterHydration";
371
482
  if (strategy === "onViewport") useIntersectionObserver(() => sentinelRef.current ?? void 0, () => loadScript());
372
- if (strategy === "onViewport") return /* @__PURE__ */ jsx("div", {
373
- ref: sentinelRef,
483
+ return {
484
+ sentinelRef,
485
+ loaded,
486
+ errored,
487
+ pending,
488
+ needsSentinel: strategy === "onViewport",
489
+ load: loadScript
490
+ };
491
+ }
492
+ /**
493
+ * Higher-order component that wraps any component with script load behavior.
494
+ *
495
+ * The wrapped component receives {@link ScriptRenderProps} with the sentinel
496
+ * ref, load-state signals, and a `needsSentinel` flag. Use this when you want
497
+ * to render a loading indicator, retry button, or custom analytics-readiness
498
+ * gate around the script load.
499
+ *
500
+ * @example
501
+ * // Script with a loading indicator
502
+ * const TrackedScript = createScript((props) => (
503
+ * <>
504
+ * {() => props.pending() && <Spinner />}
505
+ * {() => props.errored() && <button onClick={() => location.reload()}>Retry</button>}
506
+ * {props.needsSentinel && <div ref={props.sentinelRef} style="width:0;height:0" />}
507
+ * </>
508
+ * ))
509
+ *
510
+ * <TrackedScript src="/analytics.js" strategy="onIdle" />
511
+ */
512
+ function createScript(Component) {
513
+ return function WrappedScript(props) {
514
+ const s = useScript(props);
515
+ return /* @__PURE__ */ jsx(Component, {
516
+ sentinelRef: s.sentinelRef,
517
+ needsSentinel: s.needsSentinel,
518
+ loaded: s.loaded,
519
+ errored: s.errored,
520
+ pending: s.pending
521
+ });
522
+ };
523
+ }
524
+ /**
525
+ * Default optimized script component. Renders a 0×0 sentinel `<div>` for the
526
+ * `onViewport` strategy (so IntersectionObserver has an element to observe),
527
+ * `null` for every other strategy.
528
+ *
529
+ * @example
530
+ * // Load analytics after page is interactive
531
+ * <Script src="https://analytics.example.com/script.js" strategy="onIdle" />
532
+ *
533
+ * // Load chat widget when user scrolls
534
+ * <Script src="/chat-widget.js" strategy="onViewport" />
535
+ *
536
+ * // Inline script with deferred execution
537
+ * <Script strategy="afterHydration">
538
+ * {`console.log("App hydrated!")`}
539
+ * <\/Script>
540
+ */
541
+ const Script = createScript((props) => {
542
+ if (!props.needsSentinel) return null;
543
+ return /* @__PURE__ */ jsx("div", {
544
+ ref: props.sentinelRef,
374
545
  style: "width:0;height:0;overflow:hidden"
375
546
  });
376
- return null;
377
- }
547
+ });
378
548
 
379
549
  //#endregion
380
550
  //#region src/i18n-routing.ts
@@ -948,5 +1118,5 @@ function aiPlugin(..._) {
948
1118
  }
949
1119
 
950
1120
  //#endregion
951
- export { Image, Link, Meta, Script, ThemeToggle, aiPlugin, buildLocalePath, buildMetaTags, createLink, createServer, defineConfig, extractLocaleFromPath, faviconPlugin, initTheme, ogImagePlugin, prefetchRoute, resolvedTheme, seoPlugin, setLocale, setSSRThemeDefault, setTheme, theme, themeScript, toggleTheme, useLink, useLocale, validateEnv };
1121
+ export { Image, Link, Meta, Script, ThemeToggle, aiPlugin, buildLocalePath, buildMetaTags, createImage, createLink, createScript, createServer, defineConfig, extractLocaleFromPath, faviconPlugin, initTheme, ogImagePlugin, prefetchRoute, resolvedTheme, seoPlugin, setLocale, setSSRThemeDefault, setTheme, theme, themeScript, toggleTheme, useImage, useLink, useLocale, useScript, validateEnv };
952
1122
  //# sourceMappingURL=index.js.map
package/lib/og-image.js CHANGED
@@ -71,14 +71,14 @@ function buildTextOverlaySvg(layers, width, height, locale) {
71
71
  const lines = [];
72
72
  let currentLine = "";
73
73
  const estimateWidth = (s) => {
74
- let width = 0;
74
+ let w = 0;
75
75
  for (let i = 0; i < s.length; i++) {
76
76
  const code = s.charCodeAt(i);
77
- if (code >= 12288 && code <= 40959) width += fontSize * 1;
78
- else if (code <= 126 && "iljft!|:;.,'".includes(s[i])) width += fontSize * .35;
79
- else width += fontSize * .55;
77
+ if (code >= 12288 && code <= 40959) w += fontSize * 1;
78
+ else if (code <= 126 && "iljft!|:;.,'".includes(s[i])) w += fontSize * .35;
79
+ else w += fontSize * .55;
80
80
  }
81
- return width;
81
+ return w;
82
82
  };
83
83
  for (const word of words) {
84
84
  const testLine = currentLine ? `${currentLine} ${word}` : word;
@@ -0,0 +1,18 @@
1
+ //#region \0rolldown/runtime.js
2
+ var __defProp = Object.defineProperty;
3
+ var __exportAll = (all, no_symbols) => {
4
+ let target = {};
5
+ for (var name in all) {
6
+ __defProp(target, name, {
7
+ get: all[name],
8
+ enumerable: true
9
+ });
10
+ }
11
+ if (!no_symbols) {
12
+ __defProp(target, Symbol.toStringTag, { value: "Module" });
13
+ }
14
+ return target;
15
+ };
16
+
17
+ //#endregion
18
+ export { __exportAll as t };
package/lib/script.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createRef, onMount, onUnmount } from "@pyreon/core";
2
+ import { signal } from "@pyreon/reactivity";
2
3
  import { jsx } from "@pyreon/core/jsx-runtime";
3
4
 
4
5
  //#region src/utils/use-intersection-observer.ts
@@ -28,36 +29,67 @@ function useIntersectionObserver(getElement, onIntersect, rootMargin = "200px")
28
29
  //#endregion
29
30
  //#region src/script.tsx
30
31
  /**
31
- * Optimized script loading component.
32
+ * Composable that provides all script loading behavior — strategy state
33
+ * machine (afterHydration / onIdle / onInteraction / onViewport),
34
+ * deduplication, load/error tracking.
32
35
  *
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" />
36
+ * Returns reactive signals (`loaded`, `errored`, `pending`) so consumers
37
+ * can render loading indicators, retry buttons, or analytics-readiness
38
+ * gates without re-implementing the strategy machine.
39
39
  *
40
- * // Inline script with deferred execution
41
- * <Script strategy="afterHydration">
42
- * {`console.log("App hydrated!")`}
43
- * <\/Script>
40
+ * @example
41
+ * function MyScript(props: ScriptProps) {
42
+ * const s = useScript(props)
43
+ * return (
44
+ * <>
45
+ * {() => s.loaded() ? <Analytics /> : <Skeleton />}
46
+ * {() => s.needsSentinel && <div ref={s.sentinelRef} style="width:0;height:0" />}
47
+ * </>
48
+ * )
49
+ * }
44
50
  */
45
- function Script(props) {
51
+ function useScript(props) {
52
+ const strategy = props.strategy ?? "afterHydration";
53
+ const loaded = signal(false);
54
+ const errored = signal(false);
55
+ const pending = signal(strategy !== "beforeHydration" && strategy !== "afterHydration");
56
+ const sentinelRef = strategy === "onViewport" ? createRef() : void 0;
46
57
  function loadScript() {
47
58
  if (typeof document === "undefined") return;
48
- if (props.id && document.getElementById(props.id)) return;
59
+ if (props.id && document.getElementById(props.id)) {
60
+ loaded.set(true);
61
+ pending.set(false);
62
+ return;
63
+ }
49
64
  const script = document.createElement("script");
50
65
  if (props.src) script.src = props.src;
51
66
  if (props.id) script.id = props.id;
52
67
  script.async = props.async !== false;
53
- if (props.onLoad) script.onload = props.onLoad;
54
- if (props.onError) script.onerror = () => props.onError?.(/* @__PURE__ */ new Error(`Failed to load: ${props.src}`));
55
- if (props.children && !props.src) script.textContent = props.children;
68
+ script.onload = () => {
69
+ loaded.set(true);
70
+ pending.set(false);
71
+ props.onLoad?.();
72
+ };
73
+ script.onerror = () => {
74
+ errored.set(true);
75
+ pending.set(false);
76
+ props.onError?.(/* @__PURE__ */ new Error(`Failed to load: ${props.src}`));
77
+ };
78
+ if (props.children && !props.src) {
79
+ script.textContent = props.children;
80
+ setTimeout(() => {
81
+ loaded.set(true);
82
+ pending.set(false);
83
+ }, 0);
84
+ }
56
85
  document.head.appendChild(script);
57
86
  }
58
87
  onMount(() => {
59
- switch (props.strategy ?? "afterHydration") {
60
- case "beforeHydration": break;
88
+ switch (strategy) {
89
+ case "beforeHydration":
90
+ loaded.set(true);
91
+ pending.set(false);
92
+ break;
61
93
  case "afterHydration":
62
94
  loadScript();
63
95
  break;
@@ -88,16 +120,73 @@ function Script(props) {
88
120
  case "onViewport": break;
89
121
  }
90
122
  });
91
- const sentinelRef = createRef();
92
- const strategy = props.strategy ?? "afterHydration";
93
123
  if (strategy === "onViewport") useIntersectionObserver(() => sentinelRef.current ?? void 0, () => loadScript());
94
- if (strategy === "onViewport") return /* @__PURE__ */ jsx("div", {
95
- ref: sentinelRef,
124
+ return {
125
+ sentinelRef,
126
+ loaded,
127
+ errored,
128
+ pending,
129
+ needsSentinel: strategy === "onViewport",
130
+ load: loadScript
131
+ };
132
+ }
133
+ /**
134
+ * Higher-order component that wraps any component with script load behavior.
135
+ *
136
+ * The wrapped component receives {@link ScriptRenderProps} with the sentinel
137
+ * ref, load-state signals, and a `needsSentinel` flag. Use this when you want
138
+ * to render a loading indicator, retry button, or custom analytics-readiness
139
+ * gate around the script load.
140
+ *
141
+ * @example
142
+ * // Script with a loading indicator
143
+ * const TrackedScript = createScript((props) => (
144
+ * <>
145
+ * {() => props.pending() && <Spinner />}
146
+ * {() => props.errored() && <button onClick={() => location.reload()}>Retry</button>}
147
+ * {props.needsSentinel && <div ref={props.sentinelRef} style="width:0;height:0" />}
148
+ * </>
149
+ * ))
150
+ *
151
+ * <TrackedScript src="/analytics.js" strategy="onIdle" />
152
+ */
153
+ function createScript(Component) {
154
+ return function WrappedScript(props) {
155
+ const s = useScript(props);
156
+ return /* @__PURE__ */ jsx(Component, {
157
+ sentinelRef: s.sentinelRef,
158
+ needsSentinel: s.needsSentinel,
159
+ loaded: s.loaded,
160
+ errored: s.errored,
161
+ pending: s.pending
162
+ });
163
+ };
164
+ }
165
+ /**
166
+ * Default optimized script component. Renders a 0×0 sentinel `<div>` for the
167
+ * `onViewport` strategy (so IntersectionObserver has an element to observe),
168
+ * `null` for every other strategy.
169
+ *
170
+ * @example
171
+ * // Load analytics after page is interactive
172
+ * <Script src="https://analytics.example.com/script.js" strategy="onIdle" />
173
+ *
174
+ * // Load chat widget when user scrolls
175
+ * <Script src="/chat-widget.js" strategy="onViewport" />
176
+ *
177
+ * // Inline script with deferred execution
178
+ * <Script strategy="afterHydration">
179
+ * {`console.log("App hydrated!")`}
180
+ * <\/Script>
181
+ */
182
+ const Script = createScript((props) => {
183
+ if (!props.needsSentinel) return null;
184
+ return /* @__PURE__ */ jsx("div", {
185
+ ref: props.sentinelRef,
96
186
  style: "width:0;height:0;overflow:hidden"
97
187
  });
98
- return null;
99
- }
188
+ });
100
189
 
101
190
  //#endregion
102
- export { Script };
191
+ export { Script, createScript, useScript };
103
192
  //# sourceMappingURL=script.js.map