@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.
- package/lib/api-routes-Ci0kVmM4.js +146 -0
- package/lib/client.js +7 -2
- package/lib/csp.js +19 -9
- package/lib/env.js +6 -6
- package/lib/font.js +3 -3
- package/lib/{fs-router-CQ7Zxeca.js → fs-router-MewHc5SB.js} +56 -24
- package/lib/i18n-routing.js +112 -1
- package/lib/image-plugin.js +4 -0
- package/lib/image.js +141 -108
- package/lib/index.js +253 -132
- package/lib/link.js +1 -49
- package/lib/og-image.js +5 -5
- package/lib/rolldown-runtime-CjeV3_4I.js +18 -0
- package/lib/script.js +115 -74
- package/lib/seo.js +186 -15
- package/lib/server.js +275 -1247
- package/lib/theme.js +1 -50
- package/lib/types/config.d.ts +275 -3
- package/lib/types/env.d.ts +2 -2
- package/lib/types/i18n-routing.d.ts +197 -6
- package/lib/types/image.d.ts +105 -5
- package/lib/types/index.d.ts +640 -178
- package/lib/types/link.d.ts +3 -3
- package/lib/types/script.d.ts +78 -6
- package/lib/types/seo.d.ts +128 -4
- package/lib/types/server.d.ts +603 -77
- package/lib/types/theme.d.ts +2 -2
- package/lib/vite-plugin-xjWZwudX.js +2454 -0
- package/package.json +16 -13
- package/src/adapters/bun.ts +20 -1
- package/src/adapters/cloudflare.ts +78 -1
- package/src/adapters/index.ts +25 -3
- package/src/adapters/netlify.ts +63 -1
- package/src/adapters/node.ts +25 -1
- package/src/adapters/static.ts +26 -1
- package/src/adapters/validate.ts +8 -1
- package/src/adapters/vercel.ts +76 -1
- package/src/adapters/warn-missing-env.ts +49 -0
- package/src/app.ts +35 -1
- package/src/client.ts +18 -0
- package/src/csp.ts +28 -12
- package/src/entry-server.ts +55 -5
- package/src/env.ts +7 -7
- package/src/font.ts +3 -3
- package/src/fs-router.ts +123 -4
- package/src/i18n-routing.ts +246 -12
- package/src/image.tsx +242 -91
- package/src/index.ts +4 -4
- package/src/isr.ts +24 -6
- package/src/manifest.ts +675 -0
- package/src/og-image.ts +5 -5
- package/src/script.tsx +159 -36
- package/src/seo.ts +346 -15
- package/src/server.ts +10 -2
- package/src/ssg-plugin.ts +1523 -0
- package/src/types.ts +329 -19
- package/src/vercel-revalidate-handler.ts +204 -0
- package/src/vite-plugin.ts +326 -68
- package/lib/actions.js.map +0 -1
- package/lib/ai.js.map +0 -1
- package/lib/api-routes.js.map +0 -1
- package/lib/cache.js.map +0 -1
- package/lib/client.js.map +0 -1
- package/lib/compression.js.map +0 -1
- package/lib/config.js.map +0 -1
- package/lib/cors.js.map +0 -1
- package/lib/csp.js.map +0 -1
- package/lib/env.js.map +0 -1
- package/lib/favicon.js.map +0 -1
- package/lib/font.js.map +0 -1
- package/lib/fs-router-3xzp-4Wj.js.map +0 -1
- package/lib/fs-router-CQ7Zxeca.js.map +0 -1
- package/lib/i18n-routing.js.map +0 -1
- package/lib/image-plugin.js.map +0 -1
- package/lib/image.js.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/link.js.map +0 -1
- package/lib/logger.js.map +0 -1
- package/lib/meta.js.map +0 -1
- package/lib/middleware.js.map +0 -1
- package/lib/og-image.js.map +0 -1
- package/lib/rate-limit.js.map +0 -1
- package/lib/script.js.map +0 -1
- package/lib/seo.js.map +0 -1
- package/lib/server.js.map +0 -1
- package/lib/testing.js.map +0 -1
- package/lib/theme.js.map +0 -1
- package/lib/types/actions.d.ts.map +0 -1
- package/lib/types/ai.d.ts.map +0 -1
- package/lib/types/api-routes.d.ts.map +0 -1
- package/lib/types/cache.d.ts.map +0 -1
- package/lib/types/client.d.ts.map +0 -1
- package/lib/types/compression.d.ts.map +0 -1
- package/lib/types/config.d.ts.map +0 -1
- package/lib/types/cors.d.ts.map +0 -1
- package/lib/types/csp.d.ts.map +0 -1
- package/lib/types/env.d.ts.map +0 -1
- package/lib/types/favicon.d.ts.map +0 -1
- package/lib/types/font.d.ts.map +0 -1
- package/lib/types/i18n-routing.d.ts.map +0 -1
- package/lib/types/image-plugin.d.ts.map +0 -1
- package/lib/types/image.d.ts.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
- package/lib/types/link.d.ts.map +0 -1
- package/lib/types/logger.d.ts.map +0 -1
- package/lib/types/meta.d.ts.map +0 -1
- package/lib/types/middleware.d.ts.map +0 -1
- package/lib/types/og-image.d.ts.map +0 -1
- package/lib/types/rate-limit.d.ts.map +0 -1
- package/lib/types/script.d.ts.map +0 -1
- package/lib/types/seo.d.ts.map +0 -1
- package/lib/types/server.d.ts.map +0 -1
- package/lib/types/testing.d.ts.map +0 -1
- package/lib/types/theme.d.ts.map +0 -1
package/src/i18n-routing.ts
CHANGED
|
@@ -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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// -
|
|
11
|
-
// -
|
|
12
|
-
//
|
|
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
|
-
//
|
|
16
|
-
//
|
|
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.
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
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
|
-
*
|
|
61
|
-
*
|
|
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
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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
|
-
*
|
|
70
|
-
*
|
|
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
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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.
|
|
155
|
-
alt=
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
+
))
|