@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/src/image.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { VNodeChild } from '@pyreon/core'
1
+ import type { Ref, VNodeChild } from '@pyreon/core'
2
2
  import { createRef } from '@pyreon/core'
3
3
  import { signal } from '@pyreon/reactivity'
4
4
  import type { FormatSource } from './image-plugin'
@@ -13,6 +13,12 @@ import { useIntersectionObserver } from './utils/use-intersection-observer'
13
13
  // - Multi-format support via <picture> (WebP/AVIF with fallback)
14
14
  // - Blur-up placeholder while loading
15
15
  // - Priority loading for above-the-fold images
16
+ //
17
+ // Three levels of API (mirrors @pyreon/zero/link):
18
+ //
19
+ // 1. useImage(props) — composable returning resolved attributes + signals
20
+ // 2. createImage(Comp) — HOC wrapping any component with image optimization
21
+ // 3. Image — default <div><img/></div> wrapper (built on createImage)
16
22
 
17
23
  export interface ImageProps {
18
24
  /** Image source URL. */
@@ -47,6 +53,9 @@ export interface ImageProps {
47
53
  * Raw mode — renders a plain `<img>` without the container div,
48
54
  * aspect-ratio, max-width, or lazy loading wrapper.
49
55
  * Use when the Image is inside a custom layout (absolute positioning, etc.).
56
+ *
57
+ * Note: `raw` skips the three-layer API entirely. `useImage` / `createImage`
58
+ * do not apply when `raw: true` — the component returns a bare `<img>`.
50
59
  */
51
60
  raw?: boolean
52
61
  }
@@ -56,37 +65,83 @@ export interface ImageSource {
56
65
  width: number
57
66
  }
58
67
 
68
+ /** Return type of {@link useImage}. */
69
+ export interface UseImageReturn {
70
+ /** Ref — attach to the container element for IntersectionObserver. */
71
+ containerRef: Ref<HTMLElement>
72
+ /** Whether the image has entered the viewport (and started loading). */
73
+ inView: () => boolean
74
+ /** Whether the `<img>` onLoad has fired. */
75
+ loaded: () => boolean
76
+ /** Resolved `src` accessor — empty string until inView, then `props.src`. */
77
+ src: () => string
78
+ /** Resolved srcSet accessor — empty until inView; empty when `formats` is set (srcset moves to `<source>` elements). */
79
+ srcSet: () => string
80
+ /** `sizes` attribute or undefined when no srcset. */
81
+ sizes: string | undefined
82
+ /** `aspect-ratio` CSS value (`"${width} / ${height}"`). */
83
+ aspectRatio: string
84
+ /** Resolved CSS for the container — position + overflow + aspect-ratio + max-width + caller's `style`. */
85
+ containerStyle: string
86
+ /** Resolved CSS accessor for the `<img>` — fit + transition + opacity (placeholder fade). */
87
+ imageStyle: () => string
88
+ /** Resolved CSS accessor for the placeholder `<img>` (only meaningful when `placeholder` is set). */
89
+ placeholderStyle: () => string
90
+ /** `loading` attribute — eager when priority/eager, else lazy. */
91
+ loading: 'lazy' | 'eager'
92
+ /** `fetchPriority` — 'high' when priority, else undefined. */
93
+ fetchPriority: 'high' | undefined
94
+ /** onLoad handler — sets the loaded signal. Wire into the rendered `<img>`. */
95
+ handleLoad: () => void
96
+ /** Resolved per-format <source> descriptors (or undefined when no formats). */
97
+ formats: FormatSource[] | undefined
98
+ /** Whether `formats` is non-empty (i.e. consumer should render a `<picture>` wrapper). */
99
+ hasFormats: boolean
100
+ }
101
+
102
+ /** Props passed to a custom component via {@link createImage}. */
103
+ export interface ImageRenderProps {
104
+ /** Container ref. */
105
+ containerRef: Ref<HTMLElement>
106
+ /** CSS class for the container. */
107
+ class: string | undefined
108
+ /** Resolved container `style` string. */
109
+ containerStyle: string
110
+ /** Pre-rendered placeholder `<img>` (or `null` when `placeholder` is unset). */
111
+ placeholder: VNodeChild
112
+ /** Pre-rendered image — either a bare `<img>` or a `<picture>` tree when `formats` is set. */
113
+ image: VNodeChild
114
+ }
115
+
59
116
  /**
60
- * Optimized image component with lazy loading, responsive images,
61
- * multi-format <picture> support, and blur-up placeholders.
117
+ * Composable that provides all image optimization behavior lazy loading,
118
+ * srcset/sizes resolution, format selection, blur-placeholder state,
119
+ * load tracking.
62
120
  *
63
- * @example
64
- * // With imagePlugin spread the import directly
65
- * import hero from "./hero.jpg?optimize"
66
- * <Image {...hero} alt="Hero" priority />
121
+ * Use this for full control when `createImage` is too opinionated about
122
+ * the surrounding markup (e.g. custom container layouts, non-`<div>`
123
+ * wrappers, additional overlay elements).
67
124
  *
68
125
  * @example
69
- * // Manual usage
70
- * <Image src="/hero.jpg" alt="Hero" width={1200} height={630} />
126
+ * function MyImage(props: ImageProps) {
127
+ * const img = useImage(props)
128
+ * return (
129
+ * <figure ref={img.containerRef} style={img.containerStyle}>
130
+ * <img
131
+ * src={img.src}
132
+ * srcSet={img.srcSet}
133
+ * sizes={img.sizes}
134
+ * alt={props.alt}
135
+ * loading={img.loading}
136
+ * onLoad={img.handleLoad}
137
+ * style={img.imageStyle}
138
+ * />
139
+ * <figcaption>{props.alt}</figcaption>
140
+ * </figure>
141
+ * )
142
+ * }
71
143
  */
72
- export function Image(props: ImageProps): VNodeChild {
73
- // Raw mode: plain <img> without container, lazy loading, or layout constraints
74
- if (props.raw) {
75
- return (
76
- <img
77
- src={props.src}
78
- alt={props.alt}
79
- width={props.width}
80
- height={props.height}
81
- class={props.class}
82
- style={props.style}
83
- decoding={props.decoding ?? 'async'}
84
- loading={props.loading ?? 'lazy'}
85
- fetchPriority={props.priority ? 'high' : undefined}
86
- />
87
- ) as any
88
- }
89
-
144
+ export function useImage(props: ImageProps): UseImageReturn {
90
145
  const isEager = props.priority || props.loading === 'eager'
91
146
  const loaded = signal(isEager)
92
147
  const inView = signal(isEager)
@@ -100,7 +155,7 @@ export function Image(props: ImageProps): VNodeChild {
100
155
 
101
156
  const sizes = props.sizes ?? '100vw'
102
157
  const fit = props.fit ?? 'cover'
103
- const hasFormats = props.formats && props.formats.length > 0
158
+ const hasFormats = !!(props.formats && props.formats.length > 0)
104
159
  const aspectRatio = `${props.width} / ${props.height}`
105
160
 
106
161
  if (!isEager) {
@@ -110,7 +165,6 @@ export function Image(props: ImageProps): VNodeChild {
110
165
  )
111
166
  }
112
167
 
113
- // Static styles (don't depend on signals)
114
168
  const containerStyle = [
115
169
  'position: relative',
116
170
  'overflow: hidden',
@@ -122,68 +176,165 @@ export function Image(props: ImageProps): VNodeChild {
122
176
  .filter(Boolean)
123
177
  .join('; ')
124
178
 
125
- const imgEl = (
126
- <img
127
- src={() => (inView() ? props.src : '')}
128
- srcSet={() => (!hasFormats && inView() && resolvedSrcset ? resolvedSrcset : '')}
129
- sizes={resolvedSrcset ? sizes : undefined}
130
- alt={props.alt}
131
- width={props.width}
132
- height={props.height}
133
- loading={isEager ? 'eager' : 'lazy'}
134
- decoding={props.decoding ?? 'async'}
135
- fetchPriority={props.priority ? 'high' : undefined}
136
- onLoad={() => loaded.set(true)}
137
- style={() =>
138
- [
139
- 'display: block',
140
- 'width: 100%',
141
- 'height: 100%',
142
- `object-fit: ${fit}`,
143
- 'transition: opacity 0.3s ease',
144
- props.placeholder && !loaded() ? 'opacity: 0' : 'opacity: 1',
145
- ].join('; ')
146
- }
147
- />
148
- )
149
-
150
- return (
151
- <div ref={containerRef} class={props.class} style={containerStyle}>
152
- {props.placeholder && (
179
+ const imageStyle = () =>
180
+ [
181
+ 'display: block',
182
+ 'width: 100%',
183
+ 'height: 100%',
184
+ `object-fit: ${fit}`,
185
+ 'transition: opacity 0.3s ease',
186
+ props.placeholder && !loaded() ? 'opacity: 0' : 'opacity: 1',
187
+ ].join('; ')
188
+
189
+ const placeholderStyle = () =>
190
+ [
191
+ 'position: absolute',
192
+ 'inset: 0',
193
+ 'width: 100%',
194
+ 'height: 100%',
195
+ 'object-fit: cover',
196
+ 'filter: blur(20px)',
197
+ 'transform: scale(1.1)',
198
+ 'transition: opacity 0.4s ease',
199
+ loaded() ? 'opacity: 0; pointer-events: none' : 'opacity: 1',
200
+ ].join('; ')
201
+
202
+ return {
203
+ containerRef,
204
+ inView,
205
+ loaded,
206
+ src: () => (inView() ? props.src : ''),
207
+ srcSet: () => (!hasFormats && inView() && resolvedSrcset ? resolvedSrcset : ''),
208
+ sizes: resolvedSrcset ? sizes : undefined,
209
+ aspectRatio,
210
+ containerStyle,
211
+ imageStyle,
212
+ placeholderStyle,
213
+ loading: isEager ? 'eager' : 'lazy',
214
+ fetchPriority: props.priority ? 'high' : undefined,
215
+ handleLoad: () => loaded.set(true),
216
+ formats: props.formats,
217
+ hasFormats,
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Higher-order component that wraps any component with image optimization.
223
+ *
224
+ * The wrapped component receives {@link ImageRenderProps} with the pre-rendered
225
+ * `image` JSX (bare `<img>` OR `<picture>` tree depending on formats), the
226
+ * pre-rendered `placeholder` JSX, and the container ref + styles. Consumers
227
+ * compose those pieces with whatever wrapper element / layout they want.
228
+ *
229
+ * @example
230
+ * // Custom figure-based image with caption
231
+ * const FigureImage = createImage((props) => (
232
+ * <figure ref={props.containerRef} class={props.class} style={props.containerStyle}>
233
+ * {props.placeholder}
234
+ * {props.image}
235
+ * <figcaption>Caption goes here</figcaption>
236
+ * </figure>
237
+ * ))
238
+ *
239
+ * // Usage — identical to default <Image>
240
+ * <FigureImage src="/hero.jpg" alt="Hero" width={1200} height={630} />
241
+ */
242
+ export function createImage(
243
+ Component: (p: ImageRenderProps) => any,
244
+ ): (props: ImageProps) => any {
245
+ return function WrappedImage(props: ImageProps) {
246
+ // `raw` mode short-circuits — returns a bare <img> with no optimization
247
+ // wrapper, no container, no createImage composition. Documented as the
248
+ // no-optimization escape hatch.
249
+ if (props.raw) {
250
+ return (
153
251
  <img
154
- src={props.placeholder}
155
- alt=""
156
- aria-hidden="true"
157
- loading="eager"
158
- style={() =>
159
- [
160
- 'position: absolute',
161
- 'inset: 0',
162
- 'width: 100%',
163
- 'height: 100%',
164
- 'object-fit: cover',
165
- 'filter: blur(20px)',
166
- 'transform: scale(1.1)',
167
- 'transition: opacity 0.4s ease',
168
- loaded() ? 'opacity: 0; pointer-events: none' : 'opacity: 1',
169
- ].join('; ')
170
- }
252
+ src={props.src}
253
+ alt={props.alt}
254
+ width={props.width}
255
+ height={props.height}
256
+ class={props.class}
257
+ style={props.style}
258
+ decoding={props.decoding ?? 'async'}
259
+ loading={props.loading ?? 'lazy'}
260
+ fetchPriority={props.priority ? 'high' : undefined}
171
261
  />
172
- )}
173
- {hasFormats ? (
174
- <picture>
175
- {props.formats?.map((fmt) => (
176
- <source
177
- type={fmt.type}
178
- srcSet={() => (inView() ? (fmt.srcset ?? '') : '')}
179
- sizes={sizes}
180
- />
181
- ))}
182
- {imgEl}
183
- </picture>
184
- ) : (
185
- imgEl
186
- )}
187
- </div>
188
- )
262
+ )
263
+ }
264
+
265
+ const img = useImage(props)
266
+
267
+ const imgEl = (
268
+ <img
269
+ src={img.src}
270
+ srcSet={img.srcSet}
271
+ sizes={img.sizes}
272
+ alt={props.alt}
273
+ width={props.width}
274
+ height={props.height}
275
+ loading={img.loading}
276
+ decoding={props.decoding ?? 'async'}
277
+ fetchPriority={img.fetchPriority}
278
+ onLoad={img.handleLoad}
279
+ style={img.imageStyle}
280
+ />
281
+ )
282
+
283
+ const placeholderEl = props.placeholder
284
+ ? (
285
+ <img
286
+ src={props.placeholder}
287
+ alt=""
288
+ aria-hidden="true"
289
+ loading="eager"
290
+ style={img.placeholderStyle}
291
+ />
292
+ )
293
+ : null
294
+
295
+ const imageEl = img.hasFormats
296
+ ? (
297
+ <picture>
298
+ {img.formats?.map((fmt) => (
299
+ <source
300
+ type={fmt.type}
301
+ srcSet={() => (img.inView() ? (fmt.srcset ?? '') : '')}
302
+ sizes={img.sizes}
303
+ />
304
+ ))}
305
+ {imgEl}
306
+ </picture>
307
+ )
308
+ : imgEl
309
+
310
+ return (
311
+ <Component
312
+ containerRef={img.containerRef}
313
+ class={props.class}
314
+ containerStyle={img.containerStyle}
315
+ placeholder={placeholderEl}
316
+ image={imageEl}
317
+ />
318
+ )
319
+ }
189
320
  }
321
+
322
+ /**
323
+ * Default optimized image component with lazy loading, responsive srcset,
324
+ * `<picture>` multi-format support, and blur-up placeholders.
325
+ *
326
+ * @example
327
+ * // With imagePlugin — spread the import directly
328
+ * import hero from "./hero.jpg?optimize"
329
+ * <Image {...hero} alt="Hero" priority />
330
+ *
331
+ * @example
332
+ * // Manual usage
333
+ * <Image src="/hero.jpg" alt="Hero" width={1200} height={630} />
334
+ */
335
+ export const Image: (props: ImageProps) => any = createImage((props) => (
336
+ <div ref={props.containerRef} class={props.class} style={props.containerStyle}>
337
+ {props.placeholder}
338
+ {props.image}
339
+ </div>
340
+ ))
package/src/index.ts CHANGED
@@ -13,12 +13,12 @@
13
13
 
14
14
  // ─── Components (browser-safe) ──────────────────────────────────────────────
15
15
 
16
- export type { ImageProps, ImageSource } from "./image";
17
- export { Image } from "./image";
16
+ export type { ImageProps, ImageRenderProps, ImageSource, UseImageReturn } from "./image";
17
+ export { createImage, Image, useImage } from "./image";
18
18
  export type { LinkProps, LinkRenderProps, UseLinkReturn } from "./link";
19
19
  export { createLink, Link, prefetchRoute, useLink } from "./link";
20
- export type { ScriptProps, ScriptStrategy } from "./script";
21
- export { Script } from "./script";
20
+ export type { ScriptProps, ScriptRenderProps, ScriptStrategy, UseScriptReturn } from "./script";
21
+ export { createScript, Script, useScript } from "./script";
22
22
  export type { MetaProps } from "./meta";
23
23
  export { buildMetaTags, Meta } from "./meta";
24
24
 
package/src/isr.ts CHANGED
@@ -29,6 +29,14 @@ export function createISRHandler(
29
29
  const revalidating = new Set<string>()
30
30
  const revalidateMs = config.revalidate * 1000
31
31
  const maxEntries = Math.max(1, config.maxEntries ?? 1000)
32
+ // M1.1 — cache-key derivation. Default keys by pathname only (the
33
+ // pre-M1 behaviour). User-supplied `cacheKey` opts in to varying
34
+ // by cookies / query / headers — required for auth-gated pages.
35
+ // See `ISRConfig.cacheKey` JSDoc for the auth-incompatibility caveat.
36
+ const deriveKey: (req: Request, url: URL) => string
37
+ = typeof config.cacheKey === 'function'
38
+ ? (req, _url) => (config.cacheKey as (r: Request) => string)(req)
39
+ : (_req, url) => url.pathname
32
40
 
33
41
  function set(key: string, entry: CacheEntry): void {
34
42
  // LRU: re-inserting moves the key to the newest position. Then if we're
@@ -51,13 +59,23 @@ export function createISRHandler(
51
59
  return entry
52
60
  }
53
61
 
54
- async function revalidate(url: URL) {
55
- const key = url.pathname
62
+ async function revalidate(url: URL, originalReq: Request) {
63
+ // Re-derive key from the ORIGINAL request so cookies / headers /
64
+ // query that varied the cache entry are preserved across revalidation.
65
+ // Without this, a user-supplied `cacheKey` that reads cookies would
66
+ // re-render against a no-cookie request and stomp the cached entry
67
+ // with the wrong-user content.
68
+ const key = deriveKey(originalReq, url)
56
69
  if (revalidating.has(key)) return
57
70
  revalidating.add(key)
58
71
 
59
72
  try {
60
- const req = new Request(url.href, { method: 'GET' })
73
+ // Forward the original request shape (headers + method) so the
74
+ // re-render sees the same auth context as the user's read.
75
+ const req = new Request(url.href, {
76
+ method: 'GET',
77
+ headers: originalReq.headers,
78
+ })
61
79
  const res = await handler(req)
62
80
  const html = await res.text()
63
81
  const headers: Record<string, string> = {}
@@ -80,7 +98,7 @@ export function createISRHandler(
80
98
  }
81
99
 
82
100
  const url = new URL(req.url)
83
- const key = url.pathname
101
+ const key = deriveKey(req, url)
84
102
  // `touch` moves the entry to the newest LRU position on read so
85
103
  // hot paths survive eviction even when the cap is small. `get`
86
104
  // wouldn't update ordering.
@@ -91,7 +109,7 @@ export function createISRHandler(
91
109
 
92
110
  if (age > revalidateMs) {
93
111
  // Stale — serve cached but revalidate in background
94
- revalidate(url)
112
+ revalidate(url, req)
95
113
  }
96
114
 
97
115
  return new Response(entry.html, {
@@ -113,7 +131,7 @@ export function createISRHandler(
113
131
  headers[k] = v
114
132
  })
115
133
 
116
- cache.set(key, { html, headers, timestamp: Date.now() })
134
+ set(key, { html, headers, timestamp: Date.now() })
117
135
 
118
136
  return new Response(html, {
119
137
  status: 200,