@pyreon/zero 0.14.0 → 0.16.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 (114) hide show
  1. package/lib/api-routes-Ci0kVmM4.js +146 -0
  2. package/lib/client.js +7 -2
  3. package/lib/csp.js +19 -9
  4. package/lib/env.js +6 -6
  5. package/lib/font.js +3 -3
  6. package/lib/{fs-router-CQ7Zxeca.js → fs-router-MewHc5SB.js} +56 -24
  7. package/lib/i18n-routing.js +112 -1
  8. package/lib/image-plugin.js +4 -0
  9. package/lib/image.js +141 -108
  10. package/lib/index.js +253 -132
  11. package/lib/link.js +1 -49
  12. package/lib/og-image.js +5 -5
  13. package/lib/rolldown-runtime-CjeV3_4I.js +18 -0
  14. package/lib/script.js +115 -74
  15. package/lib/seo.js +186 -15
  16. package/lib/server.js +275 -1247
  17. package/lib/theme.js +1 -50
  18. package/lib/types/config.d.ts +275 -3
  19. package/lib/types/env.d.ts +2 -2
  20. package/lib/types/i18n-routing.d.ts +197 -6
  21. package/lib/types/image.d.ts +105 -5
  22. package/lib/types/index.d.ts +640 -178
  23. package/lib/types/link.d.ts +3 -3
  24. package/lib/types/script.d.ts +78 -6
  25. package/lib/types/seo.d.ts +128 -4
  26. package/lib/types/server.d.ts +603 -77
  27. package/lib/types/theme.d.ts +2 -2
  28. package/lib/vite-plugin-xjWZwudX.js +2454 -0
  29. package/package.json +16 -13
  30. package/src/adapters/bun.ts +20 -1
  31. package/src/adapters/cloudflare.ts +78 -1
  32. package/src/adapters/index.ts +25 -3
  33. package/src/adapters/netlify.ts +63 -1
  34. package/src/adapters/node.ts +25 -1
  35. package/src/adapters/static.ts +26 -1
  36. package/src/adapters/validate.ts +8 -1
  37. package/src/adapters/vercel.ts +76 -1
  38. package/src/adapters/warn-missing-env.ts +49 -0
  39. package/src/app.ts +35 -1
  40. package/src/client.ts +18 -0
  41. package/src/csp.ts +28 -12
  42. package/src/entry-server.ts +55 -5
  43. package/src/env.ts +7 -7
  44. package/src/font.ts +3 -3
  45. package/src/fs-router.ts +123 -4
  46. package/src/i18n-routing.ts +246 -12
  47. package/src/image.tsx +242 -91
  48. package/src/index.ts +4 -4
  49. package/src/isr.ts +24 -6
  50. package/src/manifest.ts +675 -0
  51. package/src/og-image.ts +5 -5
  52. package/src/script.tsx +159 -36
  53. package/src/seo.ts +346 -15
  54. package/src/server.ts +10 -2
  55. package/src/ssg-plugin.ts +1523 -0
  56. package/src/types.ts +329 -19
  57. package/src/vercel-revalidate-handler.ts +204 -0
  58. package/src/vite-plugin.ts +326 -68
  59. package/lib/actions.js.map +0 -1
  60. package/lib/ai.js.map +0 -1
  61. package/lib/api-routes.js.map +0 -1
  62. package/lib/cache.js.map +0 -1
  63. package/lib/client.js.map +0 -1
  64. package/lib/compression.js.map +0 -1
  65. package/lib/config.js.map +0 -1
  66. package/lib/cors.js.map +0 -1
  67. package/lib/csp.js.map +0 -1
  68. package/lib/env.js.map +0 -1
  69. package/lib/favicon.js.map +0 -1
  70. package/lib/font.js.map +0 -1
  71. package/lib/fs-router-3xzp-4Wj.js.map +0 -1
  72. package/lib/fs-router-CQ7Zxeca.js.map +0 -1
  73. package/lib/i18n-routing.js.map +0 -1
  74. package/lib/image-plugin.js.map +0 -1
  75. package/lib/image.js.map +0 -1
  76. package/lib/index.js.map +0 -1
  77. package/lib/link.js.map +0 -1
  78. package/lib/logger.js.map +0 -1
  79. package/lib/meta.js.map +0 -1
  80. package/lib/middleware.js.map +0 -1
  81. package/lib/og-image.js.map +0 -1
  82. package/lib/rate-limit.js.map +0 -1
  83. package/lib/script.js.map +0 -1
  84. package/lib/seo.js.map +0 -1
  85. package/lib/server.js.map +0 -1
  86. package/lib/testing.js.map +0 -1
  87. package/lib/theme.js.map +0 -1
  88. package/lib/types/actions.d.ts.map +0 -1
  89. package/lib/types/ai.d.ts.map +0 -1
  90. package/lib/types/api-routes.d.ts.map +0 -1
  91. package/lib/types/cache.d.ts.map +0 -1
  92. package/lib/types/client.d.ts.map +0 -1
  93. package/lib/types/compression.d.ts.map +0 -1
  94. package/lib/types/config.d.ts.map +0 -1
  95. package/lib/types/cors.d.ts.map +0 -1
  96. package/lib/types/csp.d.ts.map +0 -1
  97. package/lib/types/env.d.ts.map +0 -1
  98. package/lib/types/favicon.d.ts.map +0 -1
  99. package/lib/types/font.d.ts.map +0 -1
  100. package/lib/types/i18n-routing.d.ts.map +0 -1
  101. package/lib/types/image-plugin.d.ts.map +0 -1
  102. package/lib/types/image.d.ts.map +0 -1
  103. package/lib/types/index.d.ts.map +0 -1
  104. package/lib/types/link.d.ts.map +0 -1
  105. package/lib/types/logger.d.ts.map +0 -1
  106. package/lib/types/meta.d.ts.map +0 -1
  107. package/lib/types/middleware.d.ts.map +0 -1
  108. package/lib/types/og-image.d.ts.map +0 -1
  109. package/lib/types/rate-limit.d.ts.map +0 -1
  110. package/lib/types/script.d.ts.map +0 -1
  111. package/lib/types/seo.d.ts.map +0 -1
  112. package/lib/types/server.d.ts.map +0 -1
  113. package/lib/types/testing.d.ts.map +0 -1
  114. package/lib/types/theme.d.ts.map +0 -1
@@ -1,19 +1,39 @@
1
1
  import { createContext } from '@pyreon/core'
2
2
  import { signal } from '@pyreon/reactivity'
3
3
  import type { Plugin } from 'vite'
4
+ import type { FileRoute } from './types'
4
5
 
5
6
  // ─── Localized routing ─────────────────────────────────────────────────────
6
7
  //
7
- // Adds locale-prefixed routes to Zero's file-system router:
8
- // - /about /en/about, /de/about, /cs/about
9
- // - / → /en, /de, /cs (or default locale without prefix)
10
- // - Automatic locale detection from Accept-Language header
11
- // - Redirect to preferred locale
12
- // - hreflang link generation
8
+ // Adds locale-prefixed routes to Zero's file-system router (PR H of the SSG
9
+ // roadmap). Two complementary halves:
10
+ //
11
+ // 1. **Build-time route duplication** `expandRoutesForLocales(routes, config)`
12
+ // fans every `FileRoute` into per-locale variants according to the
13
+ // configured `strategy`. Called from `vite-plugin.ts`'s virtual-routes
14
+ // load AND `ssg-plugin.ts`'s pre-render path expansion. Wired via the
15
+ // `i18n?: I18nRoutingConfig` field on `ZeroConfig`.
16
+ //
17
+ // 2. **Request-time locale detection** — the `i18nRouting()` Vite plugin
18
+ // below attaches a middleware that reads `Accept-Language` / cookies,
19
+ // sets the `localeSignal` for `useLocale()`, and redirects root
20
+ // requests to the detected locale. Independent from (1) — `i18nRouting()`
21
+ // only handles middleware; route duplication happens via
22
+ // `expandRoutesForLocales` regardless of whether this plugin is mounted.
23
+ //
24
+ // Examples (with `locales: ["en","de","cs"]`, `defaultLocale: "en"`):
25
+ // - `prefix-except-default` (default): `/about` (en, unprefixed) +
26
+ // `/de/about`, `/cs/about`. Best for SEO-on-default-locale apps.
27
+ // - `prefix`: `/en/about`, `/de/about`, `/cs/about`. Every URL
28
+ // self-identifies its locale.
13
29
  //
14
30
  // Usage:
15
- // import { i18nRouting } from "@pyreon/zero"
16
- // export default { plugins: [Pyreon], defaultLocale: "en" })] }
31
+ // // zero.config.ts
32
+ // import { defineConfig, i18nRouting } from "@pyreon/zero"
33
+ // export default defineConfig({
34
+ // i18n: { locales: ["en","de","cs"], defaultLocale: "en" },
35
+ // plugins: [i18nRouting({ locales: ["en","de","cs"], defaultLocale: "en" })],
36
+ // })
17
37
 
18
38
  export interface I18nRoutingConfig {
19
39
  /** Supported locales. e.g. ["en", "de", "cs"] */
@@ -108,6 +128,218 @@ export function buildLocalePath(
108
128
  return `/${locale}${clean}`
109
129
  }
110
130
 
131
+ /**
132
+ * Fan a `FileRoute[]` into per-locale duplicates so the file-system router
133
+ * knows about every localized URL pattern at build time. PR H — was the
134
+ * missing half of the i18n story before this PR (the `i18nRouting()` Vite
135
+ * plugin only handled request-time locale detection; routes themselves
136
+ * were never duplicated, so static-host SSG outputs and SSR matching had
137
+ * no `/de/about` / `/cs/about` records to render against).
138
+ *
139
+ * Strategy semantics:
140
+ *
141
+ * - **`prefix-except-default`** (default): the default locale's routes
142
+ * keep their original `urlPath` unchanged (`/about` stays `/about`); all
143
+ * non-default locales get a prefix (`/de/about`, `/cs/about`). Best for
144
+ * SEO-on-default-locale apps — search engines see canonical URLs at
145
+ * `/about` while non-default speakers get explicit prefixes.
146
+ *
147
+ * - **`prefix`**: every locale gets its own prefix, including the default
148
+ * (`/en/about`, `/de/about`, `/cs/about`). Root `/` becomes `/en` /
149
+ * `/de` / `/cs`. Better when no locale is "primary" — every URL
150
+ * self-identifies its locale.
151
+ *
152
+ * Layouts, error boundaries, loading components, and 404 pages duplicate
153
+ * along with their pages — same source file (same `filePath`), new
154
+ * locale-prefixed `urlPath` / `dirPath` / `depth`. The route tree built
155
+ * from the expanded array therefore has one fully-formed subtree per
156
+ * locale, so layout matching, dynamic params (`[id]` → `:id`), and
157
+ * catch-all routes (`[...slug]` → `:slug*`) all compose naturally with
158
+ * the locale prefix — no special cases.
159
+ *
160
+ * `getStaticPaths` composition (for SSG): each duplicate route inherits
161
+ * the same `exports.getStaticPaths`. The SSG plugin's `expandUrlPattern`
162
+ * step then expands `/blog/[slug]` × `[en, de]` × `getStaticPaths()
163
+ * → ['a', 'b']` into `/blog/a`, `/blog/b`, `/de/blog/a`, `/de/blog/b`
164
+ * (or all six prefixed forms under `'prefix'` strategy). Cardinality
165
+ * compounds, which is by design — `ssg.concurrency` (PR D) limits
166
+ * in-flight renders independent of route count.
167
+ *
168
+ * No-op when `config.locales` is empty or contains only the default
169
+ * locale (prefix-except-default strategy with no other locales) — returns
170
+ * the input array unchanged. Always return a fresh array on duplication
171
+ * so callers don't accidentally mutate cached input.
172
+ *
173
+ * Reference: the helper is called from `vite-plugin.ts`'s virtual route
174
+ * module load AND `ssg-plugin.ts`'s pre-render path expansion. Tested in
175
+ * isolation — duplication is a pure transform on FileRoute[] with no
176
+ * filesystem or network side effects.
177
+ */
178
+ export function expandRoutesForLocales(
179
+ routes: FileRoute[],
180
+ config: I18nRoutingConfig,
181
+ ): FileRoute[] {
182
+ const strategy = config.strategy ?? 'prefix-except-default'
183
+ const { locales, defaultLocale } = config
184
+
185
+ // Cheap no-op guards. Empty `locales` would otherwise produce an empty
186
+ // route array, killing the app silently.
187
+ if (locales.length === 0) return routes
188
+
189
+ // PR L2 — Validate every locale string before they reach the filesystem.
190
+ // The locales drive both URL pattern emission (`/${locale}/...`) AND
191
+ // filesystem writes (`mkdir(dist/${locale})` in ssg-plugin.ts's per-
192
+ // locale 404 emit). User-supplied input with `/`, `..`, `\`, NUL, or
193
+ // leading dots could write outside dist OR produce broken URLs.
194
+ // Validate at the single entry point so every downstream consumer
195
+ // (vite-plugin's virtual-routes load AND ssg-plugin's path expansion)
196
+ // benefits from one check.
197
+ //
198
+ // Reject:
199
+ // - empty string (kills the app silently with no usable URLs)
200
+ // - leading/trailing whitespace (URL-malformed)
201
+ // - `/` or `\` (path traversal AND structurally invalid as a URL
202
+ // segment — `/de/sub/about` would split into nested directories)
203
+ // - `..` or `.` whole-string (path traversal)
204
+ // - NUL char (system-call boundary breaks)
205
+ // - leading `.` (hidden directory; macOS/Linux dotfile pattern that
206
+ // would create `dist/.locale/` invisible to most ls outputs)
207
+ //
208
+ // Runs AFTER the empty-locales no-op guard so apps temporarily
209
+ // toggling to `i18n: { locales: [], ... }` (mid-migration shape)
210
+ // don't trip on an unused defaultLocale.
211
+ for (const locale of locales) validateLocale(locale)
212
+ validateLocale(defaultLocale)
213
+ if (
214
+ strategy === 'prefix-except-default'
215
+ && locales.length === 1
216
+ && locales[0] === defaultLocale
217
+ ) {
218
+ return routes
219
+ }
220
+
221
+ const expanded: FileRoute[] = []
222
+ for (const route of routes) {
223
+ for (const locale of locales) {
224
+ // For prefix-except-default, the default locale uses the ORIGINAL
225
+ // urlPath / dirPath / depth — no prefix applied.
226
+ if (strategy === 'prefix-except-default' && locale === defaultLocale) {
227
+ expanded.push(route)
228
+ continue
229
+ }
230
+
231
+ // PR H follow-up: skip duplication of ROOT-level layouts under
232
+ // `prefix-except-default`. The unprefixed default-locale root
233
+ // `_layout.tsx` (urlPath `/`) is the parent of the matched chain
234
+ // for EVERY path, including locale-prefixed ones — the route
235
+ // tree's hierarchical matching wraps `/de/about` under `/_layout`
236
+ // automatically. Producing a duplicate `/de/_layout` would cause
237
+ // the matcher to nest BOTH layouts (`/_layout` → `/de/_layout` →
238
+ // page), mounting the layout component twice and rendering two
239
+ // navbars / two PyreonUI providers.
240
+ //
241
+ // Non-root layouts (e.g. `/dashboard/_layout` at urlPath
242
+ // `/dashboard`) MUST still be duplicated — `/de/dashboard/users`
243
+ // is NOT a child of the unprefixed `/dashboard/_layout` (the
244
+ // path patterns don't match), so the de-prefixed dashboard needs
245
+ // its own `_layout`.
246
+ //
247
+ // Under `prefix` strategy this skip does NOT apply: there is no
248
+ // unprefixed default to inherit from, so every locale needs its
249
+ // own root layout (`/en/_layout`, `/de/_layout`, …).
250
+ if (
251
+ strategy === 'prefix-except-default'
252
+ && route.isLayout
253
+ && route.urlPath === '/'
254
+ ) {
255
+ continue
256
+ }
257
+
258
+ const newUrlPath = prefixUrlPath(route.urlPath, locale)
259
+ // dirPath needs the locale segment too so the route-tree builder
260
+ // groups localized siblings correctly. Original empty `dirPath`
261
+ // (root-level routes) becomes the bare locale.
262
+ const newDirPath = route.dirPath === '' ? locale : `${locale}/${route.dirPath}`
263
+ // Recompute depth from the new urlPath. Layouts at the root (depth
264
+ // 0) become depth 1 under their locale prefix; nested routes shift
265
+ // up by 1.
266
+ const newDepth = newUrlPath === '/' ? 0 : newUrlPath.split('/').filter(Boolean).length
267
+
268
+ expanded.push({
269
+ ...route,
270
+ urlPath: newUrlPath,
271
+ dirPath: newDirPath,
272
+ depth: newDepth,
273
+ })
274
+ }
275
+ }
276
+ return expanded
277
+ }
278
+
279
+ /**
280
+ * Prepend `/locale` to a URL pattern. Handles three shapes:
281
+ * `/` → `/de`
282
+ * `/about` → `/de/about`
283
+ * `/users/:id` / `/blog/:slug*` → `/de/users/:id` / `/de/blog/:slug*`
284
+ *
285
+ * Internal helper to `expandRoutesForLocales`; not exported because the
286
+ * public surface for path-building is `buildLocalePath` (which strips
287
+ * existing locale prefixes — different semantics).
288
+ */
289
+ function prefixUrlPath(urlPath: string, locale: string): string {
290
+ if (urlPath === '/') return `/${locale}`
291
+ return `/${locale}${urlPath}`
292
+ }
293
+
294
+ /**
295
+ * Validate a locale string (PR L2).
296
+ *
297
+ * The locale drives both URL pattern emission AND filesystem writes
298
+ * (see `expandRoutesForLocales` for full rationale). Reject input that
299
+ * would either:
300
+ * - break path-traversal boundaries (`..`, `/`, `\`)
301
+ * - produce invalid URL segments (whitespace, NUL)
302
+ * - create hidden-file artifacts (`.` leading)
303
+ * - silently kill the app (empty string)
304
+ *
305
+ * Throws with an actionable `[Pyreon]` error message. Called per-locale
306
+ * by `expandRoutesForLocales` after the empty-locales no-op guard.
307
+ *
308
+ * @internal — exported for unit testing.
309
+ */
310
+ export function validateLocale(locale: string): void {
311
+ if (typeof locale !== 'string' || locale === '') {
312
+ throw new Error(
313
+ `[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Locales must be non-empty strings (e.g. "en", "de", "en-US").`,
314
+ )
315
+ }
316
+ if (locale.trim() !== locale) {
317
+ throw new Error(
318
+ `[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Leading or trailing whitespace not allowed.`,
319
+ )
320
+ }
321
+ if (locale.includes('/') || locale.includes('\\')) {
322
+ throw new Error(
323
+ `[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Path separators ("/", "\\\\") not allowed — they would break URL emission and could write outside the dist directory.`,
324
+ )
325
+ }
326
+ if (locale === '..' || locale === '.') {
327
+ throw new Error(
328
+ `[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Path-traversal segments not allowed.`,
329
+ )
330
+ }
331
+ if (locale.startsWith('.')) {
332
+ throw new Error(
333
+ `[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Leading dot not allowed — it would create a hidden-file directory (\`dist/.${locale.slice(1)}/\`) invisible to most file listings.`,
334
+ )
335
+ }
336
+ if (locale.includes('\0')) {
337
+ throw new Error(
338
+ `[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. NUL characters not allowed.`,
339
+ )
340
+ }
341
+ }
342
+
111
343
  /**
112
344
  * Create a LocaleContext for use in components and loaders.
113
345
  */
@@ -176,10 +408,12 @@ export function i18nRouting(config: I18nRoutingConfig): Plugin {
176
408
  return {
177
409
  name: 'pyreon-zero-i18n-routing',
178
410
 
179
- // Route duplication is NOT handled here. The fs-router's `scanRouteFiles`
180
- // consumes the i18n config to duplicate routes per locale at build time.
181
- // This plugin only provides: (1) the server middleware for locale detection
182
- // and (2) the runtime hooks (useLocale, setLocale) for client-side use.
411
+ // Route duplication is NOT handled here. It happens in
412
+ // `vite-plugin.ts` and `ssg-plugin.ts` via `expandRoutesForLocales`,
413
+ // gated by the `i18n` field on `ZeroConfig`. This plugin only
414
+ // provides: (1) the dev server middleware for locale detection
415
+ // (Accept-Language, cookies, root redirect) and (2) the runtime
416
+ // hooks (useLocale, setLocale) for client-side use.
183
417
  configResolved() {},
184
418
 
185
419
  configureServer(server) {
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
+ ))