@pyreon/zero 0.24.5 → 0.24.6

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 (54) hide show
  1. package/package.json +10 -39
  2. package/src/actions.ts +0 -196
  3. package/src/adapters/bun.ts +0 -114
  4. package/src/adapters/cloudflare.ts +0 -166
  5. package/src/adapters/index.ts +0 -61
  6. package/src/adapters/netlify.ts +0 -154
  7. package/src/adapters/node.ts +0 -163
  8. package/src/adapters/static.ts +0 -42
  9. package/src/adapters/validate.ts +0 -23
  10. package/src/adapters/vercel.ts +0 -182
  11. package/src/adapters/warn-missing-env.ts +0 -49
  12. package/src/ai.ts +0 -623
  13. package/src/api-routes.ts +0 -219
  14. package/src/app.ts +0 -92
  15. package/src/cache.ts +0 -136
  16. package/src/client.ts +0 -143
  17. package/src/compression.ts +0 -116
  18. package/src/config.ts +0 -35
  19. package/src/cors.ts +0 -94
  20. package/src/csp.ts +0 -226
  21. package/src/entry-server.ts +0 -224
  22. package/src/env.ts +0 -344
  23. package/src/error-overlay.ts +0 -118
  24. package/src/favicon.ts +0 -841
  25. package/src/font.ts +0 -511
  26. package/src/fs-router.ts +0 -1519
  27. package/src/i18n-routing.ts +0 -533
  28. package/src/icon.tsx +0 -182
  29. package/src/icons-plugin.ts +0 -296
  30. package/src/image-plugin.ts +0 -751
  31. package/src/image-types.ts +0 -60
  32. package/src/image.tsx +0 -340
  33. package/src/index.ts +0 -92
  34. package/src/isr.ts +0 -394
  35. package/src/link.tsx +0 -304
  36. package/src/logger.ts +0 -144
  37. package/src/manifest.ts +0 -787
  38. package/src/meta.tsx +0 -354
  39. package/src/middleware.ts +0 -65
  40. package/src/not-found.ts +0 -44
  41. package/src/og-image.ts +0 -378
  42. package/src/rate-limit.ts +0 -140
  43. package/src/script.tsx +0 -260
  44. package/src/seo.ts +0 -617
  45. package/src/server.ts +0 -89
  46. package/src/sharp.d.ts +0 -22
  47. package/src/ssg-plugin.ts +0 -1582
  48. package/src/testing.ts +0 -146
  49. package/src/theme.tsx +0 -257
  50. package/src/types.ts +0 -624
  51. package/src/utils/use-intersection-observer.ts +0 -36
  52. package/src/utils/with-headers.ts +0 -13
  53. package/src/vercel-revalidate-handler.ts +0 -204
  54. package/src/vite-plugin.ts +0 -848
package/src/isr.ts DELETED
@@ -1,394 +0,0 @@
1
- import type { ISRConfig } from './types'
2
-
3
- // Dev-mode counter sink — see packages/internals/perf-harness for contract.
4
- // Zero cost in prod (gate folds to `false`, optional-chain short-circuits).
5
- const __DEV__ = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
6
- const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
7
-
8
- // ─── ISR Cache ───────────────────────────────────────────────────────────────
9
-
10
- /** Serialized SSR response cached by the ISR layer (one per cache key). */
11
- export interface ISRCacheEntry {
12
- html: string
13
- headers: Record<string, string>
14
- timestamp: number
15
- }
16
-
17
- /**
18
- * Pluggable backing store for the ISR cache. The default in-memory
19
- * implementation (`createMemoryStore`) is per-process — fine for
20
- * single-instance deploys, but multi-instance / horizontally-scaled
21
- * apps need a SHARED store (Redis, Vercel KV, Cloudflare KV, Upstash,
22
- * etc.) so revalidation in one instance is visible to all instances.
23
- *
24
- * The interface accepts BOTH sync and async returns: the in-memory
25
- * default stays cheap (sync `Map` ops, no `Promise` allocation per
26
- * request), while external stores return promises naturally. The
27
- * handler awaits the result either way (`await` on a non-promise just
28
- * returns the value).
29
- *
30
- * `get` is responsible for any LRU bookkeeping — external stores
31
- * typically rely on their own TTL/eviction policy and can `return
32
- * this.map.get(key)` directly; the in-memory store does a
33
- * delete + re-insert to keep the Map's insertion-order LRU correct.
34
- *
35
- * `delete` is optional. It's only used when a future on-demand
36
- * revalidation pathway needs to evict a specific key (the current
37
- * stale-while-revalidate flow does not call it). External stores that
38
- * don't support per-key invalidation can omit it.
39
- */
40
- export interface ISRStore<E = ISRCacheEntry> {
41
- get(key: string): Promise<E | undefined> | E | undefined
42
- set(key: string, entry: E): Promise<void> | void
43
- delete?(key: string): Promise<void> | void
44
- /**
45
- * Drop EVERY entry. Optional — external stores (Redis with TTL-only,
46
- * Vercel KV with per-key invalidation, etc.) may not support a
47
- * blanket purge. When omitted, `ISRHandler.revalidateAll()` throws a
48
- * clear error pointing at the missing method. The default
49
- * `createMemoryStore` implements it.
50
- */
51
- clear?(): Promise<void> | void
52
- }
53
-
54
- /**
55
- * The default in-memory ISR store: a `Map` with insertion-order LRU
56
- * eviction, capped at `maxEntries` (default `1000`). Drop in as
57
- * `config.store` if you want to tweak the cap or wrap the store with
58
- * instrumentation; pass a different `ISRStore` impl for Redis / KV /
59
- * etc. backings.
60
- *
61
- * `get` does the LRU bump (re-inserts the touched entry at the
62
- * newest position) so hot paths survive eviction even when the cap is
63
- * small. Without that, `Map.get(...)` wouldn't update ordering and
64
- * frequently-read entries could be evicted by occasional writes.
65
- */
66
- export function createMemoryStore<E = ISRCacheEntry>(opts: {
67
- maxEntries?: number
68
- } = {}): ISRStore<E> {
69
- const cache = new Map<string, E>()
70
- const maxEntries = Math.max(1, opts.maxEntries ?? 1000)
71
- return {
72
- get(key) {
73
- const entry = cache.get(key)
74
- if (entry !== undefined) {
75
- cache.delete(key)
76
- cache.set(key, entry)
77
- }
78
- return entry
79
- },
80
- set(key, entry) {
81
- if (cache.has(key)) cache.delete(key)
82
- cache.set(key, entry)
83
- while (cache.size > maxEntries) {
84
- const oldest = cache.keys().next().value
85
- if (oldest === undefined) break
86
- cache.delete(oldest)
87
- }
88
- },
89
- delete(key) {
90
- cache.delete(key)
91
- },
92
- clear() {
93
- cache.clear()
94
- },
95
- }
96
- }
97
-
98
- // Internal alias for the default-shape entry — keeps function bodies tidy
99
- // while staying source-compatible with the public `ISRCacheEntry` export.
100
- type CacheEntry = ISRCacheEntry
101
-
102
- /**
103
- * ISR handler with stale-while-revalidate semantics.
104
- *
105
- * Wraps an SSR handler and caches responses per URL path. Serves stale
106
- * content immediately while revalidating in the background. The cache
107
- * backing is **pluggable** via `config.store` (default:
108
- * `createMemoryStore({ maxEntries: 1000 })`) — pass a custom `ISRStore`
109
- * implementation backed by Redis / Vercel KV / Cloudflare KV / Upstash
110
- * etc. to share state across horizontally-scaled instances. Without an
111
- * external store, each instance has its own per-process cache (which
112
- * is fine for single-instance deploys; behaviour-identical to the
113
- * pre-pluggable-store implementation).
114
- *
115
- * Default in-memory store: bounded by `config.maxEntries` (default
116
- * `1000`) with insertion-order LRU eviction. Without the cap, unbounded
117
- * URL spaces like `/user/:id` would grow cache memory without limit
118
- * over the server's lifetime — a real leak in long-running deployments.
119
- * `config.maxEntries` is ignored when a custom `config.store` is supplied
120
- * (the custom store owns its own eviction policy).
121
- */
122
- /**
123
- * The fetch handler `createISRHandler` returns is also a callable
124
- * carrying imperative invalidation methods. Webhooks, CMS notifications,
125
- * admin endpoints etc. call these methods to drop one or all cached
126
- * entries on demand — strictly more responsive than waiting for the
127
- * TTL-based stale-while-revalidate cycle.
128
- *
129
- * The shape is `(req) => Promise<Response>` PLUS the methods, so
130
- * existing consumers (`Bun.serve({ fetch: handler })`) keep working
131
- * byte-identically.
132
- */
133
- export interface ISRHandler {
134
- (req: Request): Promise<Response>
135
- /**
136
- * Drop the cache entry for a single path (or `cacheKey`-derived key).
137
- * The next request for that key will MISS and re-render fresh. Returns
138
- * `{ dropped: true }` if an entry was found and deleted, `{ dropped: false }`
139
- * if no entry existed.
140
- *
141
- * Useful for webhook-driven invalidation: a CMS notifies that a post
142
- * was updated, the webhook handler calls `isrHandler.revalidateNow('/posts/123')`,
143
- * and the very next visitor gets the fresh content — no stale window.
144
- *
145
- * Idempotent. Safe to call against keys that don't exist (returns
146
- * `dropped: false` cleanly).
147
- */
148
- revalidateNow(key: string): Promise<{ dropped: boolean }>
149
- /**
150
- * Drop ALL cached entries. Useful for "purge cache" admin actions or
151
- * deploy-completion hooks that want a clean slate.
152
- *
153
- * The default in-memory store supports this via repeated `delete`
154
- * calls under the hood; custom stores that omit `delete` (Redis with
155
- * TTL-only, etc.) throw a clear error pointing at the missing method
156
- * so the caller knows their store implementation can't honor the call.
157
- */
158
- revalidateAll(): Promise<void>
159
- }
160
-
161
- export function createISRHandler(
162
- handler: (req: Request) => Promise<Response>,
163
- config: ISRConfig,
164
- ): ISRHandler {
165
- // Pluggable backing store. Default keeps the prior in-memory LRU
166
- // behaviour byte-identical (same `maxEntries` default, same Map +
167
- // delete+re-insert LRU bump) so existing callers see no behavioural
168
- // change.
169
- const store: ISRStore<CacheEntry>
170
- = config.store
171
- ?? createMemoryStore<CacheEntry>(
172
- config.maxEntries !== undefined ? { maxEntries: config.maxEntries } : {},
173
- )
174
- const revalidating = new Set<string>()
175
- const revalidateMs = config.revalidate * 1000
176
- // Bounded background-revalidation timeout. Without it, a handler that
177
- // hangs forever leaves its key permanently in `revalidating` (the
178
- // `finally` that clears it never runs), so EVERY later request for
179
- // that key short-circuits the `revalidating.has(key)` guard and the
180
- // entry stays stale for the rest of the process lifetime — it can
181
- // never recover. 30s default matches the Suspense streaming timeout;
182
- // overridable via ISRConfig.revalidateTimeoutMs.
183
- const REVALIDATE_TIMEOUT_MS = Math.max(1, config.revalidateTimeoutMs ?? 30_000)
184
-
185
- // Only 2xx, cookie-free responses may be cached. Caching a transient
186
- // 5xx/3xx/404 and replaying it as a 200 for the whole revalidate
187
- // window is a self-inflicted outage / cache-poisoning bug. Caching a
188
- // `Set-Cookie` response and replaying it to every visitor leaks one
189
- // user's session/CSRF cookie cross-user — not covered by the
190
- // documented "ISR-without-cacheKey is for non-personalized pages"
191
- // caveat (that caveat is about key variance, not header stripping).
192
- function isCacheable(res: Response): boolean {
193
- return res.status >= 200 && res.status < 300 && !res.headers.has('set-cookie')
194
- }
195
- // M1.1 — cache-key derivation. Default keys by pathname only (the
196
- // pre-M1 behaviour). User-supplied `cacheKey` opts in to varying
197
- // by cookies / query / headers — required for auth-gated pages.
198
- // See `ISRConfig.cacheKey` JSDoc for the auth-incompatibility caveat.
199
- const deriveKey: (req: Request, url: URL) => string
200
- = typeof config.cacheKey === 'function'
201
- ? (req, _url) => (config.cacheKey as (r: Request) => string)(req)
202
- : (_req, url) => url.pathname
203
-
204
- async function revalidate(url: URL, originalReq: Request) {
205
- // Re-derive key from the ORIGINAL request so cookies / headers /
206
- // query that varied the cache entry are preserved across revalidation.
207
- // Without this, a user-supplied `cacheKey` that reads cookies would
208
- // re-render against a no-cookie request and stomp the cached entry
209
- // with the wrong-user content.
210
- const key = deriveKey(originalReq, url)
211
- if (revalidating.has(key)) return
212
- revalidating.add(key)
213
-
214
- // Hold the timer id outside try/Promise.race so the finally block
215
- // can `clearTimeout` it on the SUCCESS path. Pre-fix the rejection
216
- // setTimeout was left pending until REVALIDATE_TIMEOUT_MS (default
217
- // 30s) every time `handler(req)` won the race — i.e. every
218
- // successful revalidation. Under sustained traffic this accumulates
219
- // hundreds of pending timer closures + rejection callbacks before
220
- // they self-clear.
221
- let timeoutId: ReturnType<typeof setTimeout> | undefined
222
- try {
223
- // Default: forward the original request shape (headers + method)
224
- // so the re-render sees the same auth context as the user's read.
225
- // Opt-in `revalidateRequest` hook lets auth-gated callers scope
226
- // the revalidation explicitly — e.g. return `null` to skip
227
- // revalidation for authenticated entries (stale stays stale until
228
- // the next live request), or return an anonymous Request for
229
- // non-personalized entries.
230
- let req: Request
231
- if (typeof config.revalidateRequest === 'function') {
232
- const custom = config.revalidateRequest(originalReq)
233
- if (custom === null) {
234
- revalidating.delete(key)
235
- return
236
- }
237
- req = custom
238
- } else {
239
- req = new Request(url.href, {
240
- method: 'GET',
241
- headers: originalReq.headers,
242
- })
243
- }
244
- // Bound the revalidation so a hung handler can't pin `key` in
245
- // `revalidating` forever (which would freeze the entry stale).
246
- const res = await Promise.race([
247
- handler(req),
248
- new Promise<never>((_, reject) => {
249
- timeoutId = setTimeout(
250
- () => reject(new Error('[Pyreon ISR] revalidation timeout')),
251
- REVALIDATE_TIMEOUT_MS,
252
- )
253
- }),
254
- ])
255
- // Never overwrite a good stale entry with a bad re-render
256
- // (5xx/3xx) or poison it with a Set-Cookie response.
257
- if (isCacheable(res)) {
258
- const html = await res.text()
259
- const headers: Record<string, string> = {}
260
- res.headers.forEach((v, k) => {
261
- headers[k] = v
262
- })
263
- await store.set(key, { html, headers, timestamp: Date.now() })
264
- }
265
- } catch {
266
- // Revalidation failed / timed out — stale cache entry remains valid
267
- } finally {
268
- if (timeoutId !== undefined) {
269
- clearTimeout(timeoutId)
270
- // Leak-class I diagnostic — emit one per cleared timer. Healthy
271
- // count = revalidation-attempts (every attempt should clear its
272
- // timer in finally). A LOWER count than revalidations means
273
- // clearTimeout failed to fire — the orphan-timer leak is back.
274
- if (__DEV__) _countSink.__pyreon_count__?.('isr.revalidate.timerClear')
275
- }
276
- revalidating.delete(key)
277
- }
278
- }
279
-
280
- const fetch = async (req: Request): Promise<Response> => {
281
- // Only cache GET requests
282
- if (req.method !== 'GET') {
283
- return handler(req)
284
- }
285
-
286
- const url = new URL(req.url)
287
- const key = deriveKey(req, url)
288
- // The store's `get` is responsible for any LRU bookkeeping — the
289
- // default in-memory store does delete + re-insert so hot paths
290
- // survive eviction. External stores (Redis/KV) rely on their own
291
- // TTL/eviction policy and typically just read directly.
292
- const entry = await store.get(key)
293
-
294
- if (entry) {
295
- const age = Date.now() - entry.timestamp
296
-
297
- if (age > revalidateMs) {
298
- // Stale — serve cached but revalidate in background
299
- revalidate(url, req)
300
- }
301
-
302
- return new Response(entry.html, {
303
- status: 200,
304
- headers: {
305
- ...entry.headers,
306
- 'content-type': 'text/html; charset=utf-8',
307
- 'x-isr-cache': age > revalidateMs ? 'STALE' : 'HIT',
308
- 'x-isr-age': String(Math.round(age / 1000)),
309
- },
310
- })
311
- }
312
-
313
- // Cache miss — render. Only cache (and only normalize to a 200
314
- // text/html response) when the render is actually cacheable; a
315
- // transient error / redirect / Set-Cookie response is passed
316
- // through verbatim with its ORIGINAL status + headers and is NOT
317
- // stored, so it can't be replayed as a 200 to later visitors.
318
- const res = await handler(req)
319
- const html = await res.text()
320
- const headers: Record<string, string> = {}
321
- res.headers.forEach((v, k) => {
322
- headers[k] = v
323
- })
324
-
325
- if (!isCacheable(res)) {
326
- return new Response(html, {
327
- status: res.status,
328
- statusText: res.statusText,
329
- headers: { ...headers, 'x-isr-cache': 'BYPASS' },
330
- })
331
- }
332
-
333
- await store.set(key, { html, headers, timestamp: Date.now() })
334
-
335
- return new Response(html, {
336
- status: 200,
337
- headers: {
338
- ...headers,
339
- 'content-type': 'text/html; charset=utf-8',
340
- 'x-isr-cache': 'MISS',
341
- },
342
- })
343
- }
344
-
345
- // Attach the imperative invalidation methods. The result is a
346
- // callable (still works with `Bun.serve({ fetch: handler })`) plus
347
- // `.revalidateNow(key)` and `.revalidateAll()` for webhook-driven
348
- // cache busting.
349
- const isrHandler = fetch as ISRHandler
350
-
351
- isrHandler.revalidateNow = async (key: string) => {
352
- // Get-then-delete so we can return an accurate `dropped` flag. The
353
- // store's `delete` doesn't return whether anything existed, so the
354
- // precheck is the only way to distinguish "actually dropped" from
355
- // "no-op against missing key". For the default in-memory store the
356
- // get is O(1); external stores pay one extra round-trip (acceptable
357
- // for an invalidation API that fires on CMS webhooks, not on the
358
- // hot path).
359
- //
360
- // `dropped: true` is only returned when (a) the entry existed AND
361
- // (b) the store actually supported delete. A store without
362
- // `delete?` returns `dropped: false` even if the entry existed —
363
- // the caller's intent (make this key MISS next time) wasn't
364
- // honored, so the honest answer is "no". Such stores rely on TTL
365
- // for eviction.
366
- const existed = (await store.get(key)) !== undefined
367
- let dropped = false
368
- if (existed && store.delete) {
369
- await store.delete(key)
370
- dropped = true
371
- }
372
- // Always clear the in-flight revalidation flag so the next request
373
- // re-renders fresh rather than short-circuiting on the
374
- // `revalidating.has(key)` guard — regardless of whether we
375
- // physically dropped the entry.
376
- revalidating.delete(key)
377
- return { dropped }
378
- }
379
-
380
- isrHandler.revalidateAll = async () => {
381
- if (!store.clear) {
382
- throw new Error(
383
- '[Pyreon ISR] revalidateAll() called against a store that does not implement `clear()`. The default in-memory store supports this; external stores (Redis/KV/etc.) must opt in by implementing `ISRStore.clear()`.',
384
- )
385
- }
386
- await store.clear()
387
- // Drop every in-flight revalidation flag — fresh requests should
388
- // re-render rather than waiting on stale resolve callbacks pointing
389
- // at entries we just purged.
390
- revalidating.clear()
391
- }
392
-
393
- return isrHandler
394
- }
package/src/link.tsx DELETED
@@ -1,304 +0,0 @@
1
- import { createRef } from '@pyreon/core'
2
- import { useRouter } from '@pyreon/router'
3
- import { useIntersectionObserver } from './utils/use-intersection-observer'
4
-
5
- // ─── Link component with prefetching ────────────────────────────────────────
6
- //
7
- // Provides client-side navigation, prefetching, and active state tracking.
8
- // Three levels of API:
9
- //
10
- // 1. useLink(props) — composable returning handlers, state, and ref callback
11
- // 2. createLink(Comp) — HOC wrapping any component with link behavior
12
- // 3. Link — default <a>-based link (built on createLink)
13
-
14
- export interface LinkProps {
15
- /** Target URL path. */
16
- href: string
17
- /** Link content. */
18
- children?: any
19
- /** CSS class name. */
20
- class?: string
21
- /** Class applied when this link matches the current route. */
22
- activeClass?: string
23
- /** Class applied when this link exactly matches the current route. */
24
- exactActiveClass?: string
25
- /** Prefetch strategy. Default: "hover" */
26
- prefetch?: 'hover' | 'viewport' | 'none'
27
- /** Open in new tab. */
28
- external?: boolean
29
- /** Inline styles. */
30
- style?: string
31
- /** ARIA label. */
32
- 'aria-label'?: string
33
- /** Additional click handler — called before navigation. Call e.preventDefault() to cancel. */
34
- onClick?: ((e: MouseEvent) => void) | undefined
35
- }
36
-
37
- /** Props passed to a custom component via createLink. */
38
- export interface LinkRenderProps {
39
- href: string
40
- ref: import('@pyreon/core').Ref<HTMLAnchorElement>
41
- onClick: (e: MouseEvent) => void
42
- onMouseEnter: () => void
43
- onTouchStart: () => void
44
- isActive: () => boolean
45
- isExactActive: () => boolean
46
- /** Reactive class string — pass directly to element for auto-updates on route change. */
47
- class: (() => string) | string | undefined
48
- style?: string
49
- target?: string
50
- rel?: string
51
- 'aria-label'?: string
52
- children?: any
53
- }
54
-
55
- /** Return type of useLink. */
56
- export interface UseLinkReturn {
57
- /** Ref object — attach to the root element for viewport-based prefetch. */
58
- ref: import('@pyreon/core').Ref<HTMLAnchorElement>
59
- /** Click handler — performs client-side navigation. */
60
- handleClick: (e: MouseEvent) => void
61
- /** Mouse enter handler — triggers hover prefetch. */
62
- handleMouseEnter: () => void
63
- /** Touch start handler — triggers prefetch on mobile. */
64
- handleTouchStart: () => void
65
- /** Whether the link partially matches the current route. */
66
- isActive: () => boolean
67
- /** Whether the link exactly matches the current route. */
68
- isExactActive: () => boolean
69
- /** Resolved class string including active classes. */
70
- classes: () => string
71
- }
72
-
73
- const MAX_PREFETCH_CACHE = 200
74
- // Maps href → list of <link> elements injected into <head>. When the
75
- // cache evicts an href (FIFO at MAX_PREFETCH_CACHE), the matching <link>
76
- // elements must be removed too — otherwise head bloats unboundedly
77
- // across long SPA sessions (every Link interaction added 2 <link> nodes
78
- // with no cleanup).
79
- const prefetched = new Map<string, Element[]>()
80
-
81
- function doPrefetch(href: string) {
82
- // Prefetch only fires from browser-mounted Link interactions (hover /
83
- // click intent). Explicit guard documents the SSR-safety contract.
84
- if (typeof document === 'undefined') return
85
- if (prefetched.has(href)) return
86
- // Evict oldest entries when cache is full — AND remove their DOM nodes.
87
- if (prefetched.size >= MAX_PREFETCH_CACHE) {
88
- const firstEntry = prefetched.entries().next().value
89
- if (firstEntry) {
90
- const [oldestHref, oldestLinks] = firstEntry
91
- for (const link of oldestLinks) link.remove()
92
- prefetched.delete(oldestHref)
93
- }
94
- }
95
-
96
- const injected: Element[] = []
97
- const docLink = document.createElement('link')
98
- docLink.rel = 'prefetch'
99
- docLink.href = href
100
- docLink.as = 'document'
101
- document.head.appendChild(docLink)
102
- injected.push(docLink)
103
-
104
- try {
105
- const chunkHint = document.createElement('link')
106
- chunkHint.rel = 'modulepreload'
107
- chunkHint.href = href
108
- document.head.appendChild(chunkHint)
109
- injected.push(chunkHint)
110
- } catch {
111
- // modulepreload is a hint, not critical
112
- }
113
-
114
- prefetched.set(href, injected)
115
- }
116
-
117
- /**
118
- * Prefetch a route's JS chunk by injecting `<link rel="prefetch">` into the
119
- * document head. Deduplicates — calling with the same href twice is a no-op.
120
- *
121
- * @example
122
- * prefetchRoute('/about')
123
- * prefetchRoute('/dashboard')
124
- */
125
- export function prefetchRoute(href: string): void {
126
- doPrefetch(href)
127
- }
128
-
129
- /**
130
- * Composable that provides all link behavior — navigation, prefetching,
131
- * active state, and viewport observation.
132
- *
133
- * Use this for full control when `createLink` is too opinionated.
134
- *
135
- * @example
136
- * function MyLink(props: LinkProps) {
137
- * const link = useLink(props)
138
- * return (
139
- * <button ref={link.ref} class={link.classes()} onClick={link.handleClick}>
140
- * {props.children}
141
- * </button>
142
- * )
143
- * }
144
- */
145
- export function useLink(props: LinkProps): UseLinkReturn {
146
- const router = useRouter()
147
- const elementRef = createRef<HTMLAnchorElement>()
148
- const strategy = props.prefetch ?? 'hover'
149
-
150
- function handleClick(e: MouseEvent) {
151
- // Call user's onClick first — they may call e.preventDefault()
152
- if (props.onClick) {
153
- ;(props.onClick as (e: MouseEvent) => void)(e)
154
- }
155
-
156
- if (
157
- e.defaultPrevented ||
158
- e.button !== 0 ||
159
- e.metaKey ||
160
- e.ctrlKey ||
161
- e.shiftKey ||
162
- e.altKey ||
163
- props.external ||
164
- !props.href
165
- ) {
166
- return
167
- }
168
- e.preventDefault()
169
- router.push(props.href)
170
- }
171
-
172
- function handleMouseEnter() {
173
- if (strategy === 'hover') {
174
- doPrefetch(props.href)
175
- }
176
- }
177
-
178
- function handleTouchStart() {
179
- if (strategy === 'hover' || strategy === 'viewport') {
180
- doPrefetch(props.href)
181
- }
182
- }
183
-
184
- if (strategy === 'viewport') {
185
- useIntersectionObserver(
186
- () => elementRef.current ?? undefined,
187
- () => doPrefetch(props.href),
188
- )
189
- }
190
-
191
- const isActive = () => {
192
- const currentPath = router.currentRoute()?.path
193
- if (!currentPath || !props.href) return false
194
- if (props.href === '/') return currentPath === '/'
195
- return currentPath.startsWith(props.href)
196
- }
197
-
198
- const isExactActive = () => {
199
- const currentPath = router.currentRoute()?.path
200
- if (!currentPath) return false
201
- return currentPath === props.href
202
- }
203
-
204
- const classes = () => {
205
- const cls: string[] = []
206
- if (props.class) cls.push(props.class)
207
- if (props.activeClass && isActive()) cls.push(props.activeClass)
208
- if (props.exactActiveClass && isExactActive()) cls.push(props.exactActiveClass)
209
- return cls.join(' ')
210
- }
211
-
212
- return {
213
- ref: elementRef,
214
- handleClick,
215
- handleMouseEnter,
216
- handleTouchStart,
217
- isActive,
218
- isExactActive,
219
- classes,
220
- }
221
- }
222
-
223
- /**
224
- * Higher-order component that wraps any component with link behavior.
225
- *
226
- * The wrapped component receives {@link LinkRenderProps} with all handlers,
227
- * active state, and accessibility attributes pre-wired.
228
- *
229
- * @example
230
- * // Custom button link
231
- * const ButtonLink = createLink((props) => (
232
- * <button
233
- * ref={props.ref}
234
- * class={props.class}
235
- * onClick={props.onClick}
236
- * onMouseEnter={props.onMouseEnter}
237
- * >
238
- * {props.children}
239
- * </button>
240
- * ))
241
- *
242
- * // Custom styled component
243
- * const CardLink = createLink((props) => (
244
- * <div
245
- * ref={props.ref}
246
- * class={`card ${props.isActive() ? "card--active" : ""}`}
247
- * onClick={props.onClick}
248
- * onMouseEnter={props.onMouseEnter}
249
- * >
250
- * {props.children}
251
- * </div>
252
- * ))
253
- *
254
- * // Usage
255
- * <ButtonLink href="/about">About</ButtonLink>
256
- * <CardLink href="/posts" prefetch="viewport">Posts</CardLink>
257
- */
258
- export function createLink(Component: (props: LinkRenderProps) => any): (props: LinkProps) => any {
259
- return function WrappedLink(props: LinkProps) {
260
- const link = useLink(props)
261
-
262
- return (
263
- <Component
264
- href={props.href}
265
- ref={link.ref}
266
- onClick={link.handleClick}
267
- onMouseEnter={link.handleMouseEnter}
268
- onTouchStart={link.handleTouchStart}
269
- isActive={link.isActive}
270
- isExactActive={link.isExactActive}
271
- class={link.classes}
272
- {...(props.style ? { style: props.style } : {})}
273
- {...(props.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
274
- {...(props['aria-label'] ? { 'aria-label': props['aria-label'] } : {})}
275
- children={props.children}
276
- />
277
- )
278
- }
279
- }
280
-
281
- /**
282
- * Default navigation link built on an `<a>` tag.
283
- *
284
- * @example
285
- * <Link href="/about" prefetch="viewport">About</Link>
286
- * <Link href="/posts" activeClass="nav-active">Posts</Link>
287
- */
288
- export const Link = createLink((props: LinkRenderProps) => (
289
- <a
290
- ref={props.ref}
291
- href={props.href}
292
- {...(props.class ? { class: props.class } : {})}
293
- {...(props.style ? { style: props.style } : {})}
294
- {...(props.target ? { target: props.target } : {})}
295
- {...(props.rel ? { rel: props.rel } : {})}
296
- {...(props['aria-label'] ? { 'aria-label': props['aria-label'] } : {})}
297
- {...(props.isExactActive() ? { 'aria-current': 'page' as const } : {})}
298
- onClick={props.onClick}
299
- onMouseEnter={props.onMouseEnter}
300
- onTouchStart={props.onTouchStart}
301
- >
302
- {props.children}
303
- </a>
304
- ))