@pyreon/mcp 0.15.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -68,6 +68,21 @@ effect(() => {
68
68
  - Creating signals inside an effect — they re-create on every run; create once outside`,
69
69
  },
70
70
 
71
+ 'reactivity/renderEffect': {
72
+ signature: '(fn: () => void) => () => void',
73
+ example: `// Inside a custom DOM helper that updates a text node:
74
+ const node = document.createTextNode('')
75
+ const dispose = renderEffect(() => {
76
+ node.data = String(count())
77
+ })
78
+ // Re-runs only when count() changes; lighter than effect() but no
79
+ // onCleanup support, no scope auto-disposal, no error-handler routing.`,
80
+ notes: `DOM-specific effect with a lighter dependency tracking path — uses a local array for deps instead of the full \`EffectScope\` integration. Used internally by \`_bind\` / \`_tpl\` for compiled-template DOM updates. **Prefer \`effect()\` for general use**; reach for \`renderEffect()\` only when you're hand-writing DOM update logic and have measured the overhead difference. Returns a dispose function (not an \`Effect\` object — different shape from \`effect()\`). See also: effect, computed.`,
81
+ mistakes: `- Calling \`onCleanup()\` inside \`renderEffect()\` — not supported; only \`effect()\` collects cleanups. Use \`effect()\` if you need cleanup callbacks
82
+ - Expecting \`renderEffect()\` to auto-dispose with the surrounding scope — it does NOT register with \`EffectScope\`. Component-scoped DOM effects should use \`effect()\` so they tear down on unmount
83
+ - Reaching for \`renderEffect()\` as the default — \`effect()\` is the canonical primitive. The performance delta only matters in extreme hot paths (1000+ DOM nodes), never in component-level code`,
84
+ },
85
+
71
86
  'reactivity/batch': {
72
87
  signature: '(fn: () => void) => void',
73
88
  example: `const a = signal(1)
@@ -82,6 +97,18 @@ batch(() => {
82
97
  - Forgetting \`batch()\` when updating 3+ related signals — causes N intermediate re-renders`,
83
98
  },
84
99
 
100
+ 'reactivity/nextTick': {
101
+ signature: '() => Promise<void>',
102
+ example: `count.set(5)
103
+ // Effects haven't run yet (sync writes are queued)
104
+ await nextTick()
105
+ // Now everything is flushed — DOM reflects count = 5
106
+ expect(node.textContent).toBe('5')`,
107
+ notes: `Returns a promise that resolves after the next microtask. Use to await pending reactive updates — every signal write that happens before \`nextTick()\` is fully flushed (effects ran, computeds settled, DOM patched) by the time the promise resolves. Equivalent to Vue's \`nextTick\`. Useful in tests and in code that needs to read the post-update DOM state. See also: batch.`,
108
+ mistakes: `- Awaiting \`nextTick()\` inside a \`batch()\` callback — pointless; the batch flushes when the callback returns, not when the microtask drains. Move the await outside \`batch()\`
109
+ - Using \`nextTick()\` to defer work — it doesn't schedule anything; it just resolves on the next microtask. Use \`setTimeout\` / \`requestAnimationFrame\` for actual deferral`,
110
+ },
111
+
85
112
  'reactivity/onCleanup': {
86
113
  signature: '(fn: () => void) => void',
87
114
  example: `effect(() => {
@@ -101,7 +128,50 @@ batch(() => {
101
128
  })`,
102
129
  notes: 'Explicit reactive watcher — tracks `source` and fires `callback` when it changes. Unlike `effect()`, the callback receives both `next` and `prev` values and does NOT auto-track signals read inside the callback body. `source` is evaluated at setup time to establish tracking; reading browser globals there still fires SSR lint rules. Returns a dispose function. See also: effect, computed.',
103
130
  mistakes: `- Reading browser globals in the \`source\` function — it runs at setup time (not just in mounted context), so \`no-window-in-ssr\` fires on \`window.X\` there
104
- - Expecting signals read inside the \`callback\` to be tracked — only the \`source\` function establishes tracking; the callback is untracked`,
131
+ - Expecting signals read inside the \`callback\` to be tracked — only the \`source\` function establishes tracking; the callback is untracked
132
+ - Forgetting to return a cleanup function from the callback — \`watch\` honors a returned function as a cleanup that runs before each re-run AND on dispose. Useful for cancelling in-flight requests, clearing timers, or removing listeners attached on the previous run`,
133
+ },
134
+
135
+ 'reactivity/createSelector': {
136
+ signature: '<T>(source: () => T) => (value: T) => boolean',
137
+ example: `const selectedId = signal<string | null>(null)
138
+ const isSelected = createSelector(() => selectedId())
139
+
140
+ // In each row's render — O(1) selection updates regardless of N rows:
141
+ <For each={rows} by={r => r.id}>{row => (
142
+ <li class={() => (isSelected(row.id) ? 'selected' : '')}>
143
+ {row.label}
144
+ </li>
145
+ )}</For>`,
146
+ notes: `Create an O(1) equality selector — returns a reactive predicate that fires only when the previously-selected and newly-selected values' subscribers are affected. Unlike a plain \`() => source() === value\` (which re-evaluates for every row in a list), this only triggers TWO subscribers per source change (deselected + newly selected) regardless of list size. Critical for keyed-list selection patterns. See also: signal, computed.`,
147
+ mistakes: `- Using a plain \`() => source() === value\` in lists — every row subscribes to source; selecting a row notifies ALL N rows (O(N))
148
+ - Calling \`isSelected\` outside a reactive scope — returns the current value but doesn't subscribe
149
+ - Using \`createSelector\` for non-equality predicates — it's purpose-built for \`===\` matching; for ranges or filters, use \`computed()\``,
150
+ },
151
+
152
+ 'reactivity/cell': {
153
+ signature: '<T>(value: T) => Cell<T>',
154
+ example: `import { cell } from '@pyreon/reactivity'
155
+
156
+ // Create a cell:
157
+ const label = cell('Initial')
158
+
159
+ // Read (no tracking — read inside an effect does NOT subscribe):
160
+ label.peek() // 'Initial'
161
+
162
+ // Write:
163
+ label.set('Updated')
164
+ label.update(s => s + '!')
165
+
166
+ // Subscribe directly (returns disposer):
167
+ const dispose = label.subscribe(() => console.log(label.peek()))
168
+
169
+ // Fire-and-forget — no disposer (saves 1 closure allocation):
170
+ label.listen(() => console.log('changed'))`,
171
+ notes: `Lightweight reactive primitive — class-based alternative to \`signal()\`. **1 object allocation vs \`signal()\`'s ~6 closures**, single-listener fast path (no Set allocated when ≤1 subscriber), methods on prototype shared across instances. **NOT callable as a getter** — does not integrate with effect dependency tracking. Use when you need reactive state but plan to subscribe directly via \`.subscribe()\` / \`.listen()\`, NOT via \`effect()\`. Ideal for keyed-list row labels where the subscription lifetime equals the row's lifetime. See also: signal.`,
172
+ mistakes: `- Using \`label()\` to read — Cells are NOT callable. Use \`label.peek()\` to read
173
+ - Reading \`label.peek()\` inside \`effect()\` and expecting tracked re-runs — Cells don't integrate with effect tracking. Use \`signal()\` if you need automatic dependency tracking
174
+ - Using \`cell()\` for ALL reactive state — only switch from \`signal()\` when you've measured allocation pressure (1000+ instances) AND you don't need effect-based subscriptions`,
105
175
  },
106
176
 
107
177
  'reactivity/createStore': {
@@ -112,10 +182,91 @@ batch(() => {
112
182
  })
113
183
  store.todos[0].done = true // fine-grained — only 'done' subscribers fire
114
184
  store.todos.push({ text: 'Build app', done: false }) // array methods work`,
115
- notes: 'Create a deeply reactive proxy-based object. Mutations at any depth trigger fine-grained updates — `store.todos[0].done = true` only re-runs effects that read `store.todos[0].done`, not effects that read `store.todos.length` or other items. No immer, no spread-copy, no `produce()` — just mutate. Works with nested objects, arrays, Maps, and Sets. See also: signal.',
185
+ notes: 'Create a deeply reactive proxy-based object. Mutations at any depth trigger fine-grained updates — `store.todos[0].done = true` only re-runs effects that read `store.todos[0].done`, not effects that read `store.todos.length` or other items. No immer, no spread-copy, no `produce()` — just mutate. Works with nested plain objects and arrays. Built-in types with internal slots (`Map`, `Set`, `WeakMap`, `WeakSet`, `Date`, `RegExp`, `Promise`, `Error`) are returned raw and are NOT deeply reactive — they fail the Proxy internal-slot check on every method call. Replace the whole field (`store.users = new Map(store.users)`) to trigger reactivity for these. See also: signal.',
116
186
  mistakes: `- Replacing the entire store object — \`store = { ... }\` replaces the variable, not the proxy. Mutate properties instead: \`store.filter = "active"\`
117
187
  - Destructuring store properties at setup — \`const { filter } = store\` captures the value once, losing reactivity. Read \`store.filter\` inside reactive scopes
118
- - Using \`createStore\` for simple scalar state — use \`signal()\` for primitives; \`createStore\` adds proxy overhead that only pays off for nested objects`,
188
+ - Using \`createStore\` for simple scalar state — use \`signal()\` for primitives; \`createStore\` adds proxy overhead that only pays off for nested objects
189
+ - Expecting fine-grained reactivity inside Map/Set/Date/RegExp/Promise — these are returned raw because Proxy can't intercept methods that rely on internal slots. Mutating the raw instance (\`store.users.set(...)\`) does NOT notify subscribers. Replace the whole field (\`store.users = new Map(store.users)\`) to trigger reactivity`,
190
+ },
191
+
192
+ 'reactivity/createResource': {
193
+ signature: '<T, P>(source: () => P, fetcher: (param: P) => Promise<T>) => Resource<T>',
194
+ example: `const userId = signal(1)
195
+ const user = createResource(
196
+ () => userId(),
197
+ (id) => fetch(\`/api/users/\${id}\`).then(r => r.json()),
198
+ )
199
+ effect(() => {
200
+ if (user.loading()) return
201
+ if (user.error()) return console.error(user.error())
202
+ console.log(user.data())
203
+ })
204
+ userId.set(2) // auto-refetches
205
+ user.refetch() // explicit refetch with current source
206
+ user.dispose() // stop tracking, discard in-flight response`,
207
+ notes: 'Async data primitive. Auto-fetches whenever `source()` changes — `data`, `loading`, `error` are signals readable inside effects. Stale-response guarded via internal `requestId` (typing fast then slow does not flicker old data). `refetch()` re-runs the fetcher with the current source value. **`dispose()` MUST be called for resources created outside an `EffectScope`** — otherwise the source-tracking effect leaks for the lifetime of the program. See also: signal, effect, effectScope.',
208
+ mistakes: `- Forgetting \`dispose()\` for resources outside an EffectScope — the internal source-tracking effect runs forever, leaking memory and unbounded fetch calls on source changes
209
+ - Calling \`refetch()\` after \`dispose()\` — silently no-ops; check disposed state on your end if needed
210
+ - Reading \`data()\` without checking \`loading()\` / \`error()\` — undefined values flow through; gate the read on those signals
211
+ - Expecting an in-flight response to update the resource AFTER \`dispose()\` — the response is discarded by design (stale-id check), \`loading\` may stay frozen at its dispose-time value
212
+ - Reading signals INSIDE the fetcher and expecting tracked re-runs — only \`source()\` is tracked; signals read inside \`fetcher\` are read once per call without subscription`,
213
+ },
214
+
215
+ 'reactivity/reconcile': {
216
+ signature: '<T extends object>(source: T, target: T) => void',
217
+ example: `const state = createStore({ user: { name: 'Alice', age: 30 }, items: [] })
218
+
219
+ // API response arrives — pure replacement payload:
220
+ reconcile(
221
+ { user: { name: 'Alice', age: 31 }, items: [{ id: 1 }] },
222
+ state,
223
+ )
224
+ // → only state.user.age signal fires (name unchanged)
225
+ // → state.items[0] is newly created, length signal fires`,
226
+ notes: 'Surgically diff a new value into an existing `createStore` proxy. Walks both trees in parallel and only calls `.set()` on signals whose value actually changed — unchanged subtrees do NOT re-run their effects. Ideal for applying API responses to a long-lived store: only the truly-changed fields trigger updates, even if you receive a fully-replacement payload from the server. Arrays reconcile by index; excess elements are removed. See also: createStore, signal.',
227
+ mistakes: `- Passing a non-store as \`target\` — \`reconcile\` requires a \`createStore\` proxy; for plain objects, just assign
228
+ - Expecting reconciliation by key for arrays — arrays are reconciled BY INDEX. For keyed list reconciliation, use a Map keyed by id and reconcile each entry by key, OR replace the array reference (which \`<For>\` reconciles via \`by\`)
229
+ - Using \`reconcile\` inside an effect — it triggers writes; you'd cycle. Call it outside reactive scopes (e.g. in a query callback or event handler)`,
230
+ },
231
+
232
+ 'reactivity/isStore': {
233
+ signature: '(value: unknown) => boolean',
234
+ example: `const a = createStore({ x: 1 })
235
+ const b = { x: 1 }
236
+ isStore(a) // true
237
+ isStore(b) // false
238
+ isStore(null) // false (null-safe)`,
239
+ notes: 'Type guard — returns `true` if the value is a `createStore` proxy (recognized via an internal symbol marker). Use to differentiate reactive stores from plain objects in code that handles both shapes (e.g. helpers that conditionally `reconcile()` vs assign). See also: createStore, reconcile.',
240
+ mistakes: `- Using \`isStore\` to detect ANY proxy — it's specific to Pyreon's store proxies. Other proxies return \`false\`
241
+ - Calling on \`null\` / \`undefined\` and expecting a throw — null-safe; returns \`false\``,
242
+ },
243
+
244
+ 'reactivity/shallowReactive': {
245
+ signature: '<T extends object>(initial: T) => T',
246
+ example: `const store = shallowReactive({ user: { name: 'Alice' }, count: 0 })
247
+ effect(() => store.count) // tracks store.count
248
+ effect(() => store.user) // tracks store.user reference (not its contents)
249
+ store.user.name = 'Bob' // does NOT trigger any effect (nested mutation)
250
+ store.count = 5 // triggers count effect
251
+ store.user = { name: 'Bob' } // triggers user effect (reference replacement)`,
252
+ notes: 'Create a SHALLOW reactive store — only top-level mutations trigger updates. Nested objects are NOT auto-wrapped; reading a nested object returns the raw reference, and mutating it does NOT trigger any effect. Replacing the top-level reference DOES trigger reactivity. Use when nested data is immutable (frozen API responses), when you want explicit control over which subtrees are reactive, or when you need to store class instances/third-party objects without paying the deep-proxy overhead. Vue 3 parity. See also: createStore, markRaw.',
253
+ mistakes: `- Expecting nested mutations to trigger effects — they don't. Use \`createStore\` if you need deep reactivity, or replace the top-level reference (\`store.user = { ...store.user, name: 'Bob' }\`)
254
+ - Mixing shallow + deep on the same raw object — \`createStore(raw)\` and \`shallowReactive({ wrapper: raw })\` produce DIFFERENT proxies (separate caches). Pick one shape per data flow`,
255
+ },
256
+
257
+ 'reactivity/markRaw': {
258
+ signature: '<T extends object>(value: T) => T',
259
+ example: `import { markRaw, createStore } from '@pyreon/reactivity'
260
+
261
+ class Editor { /* ... */ }
262
+ const ed = markRaw(new Editor()) // skips proxy
263
+ const store = createStore({ editor: ed })
264
+ store.editor === ed // true — raw reference preserved
265
+ store.editor.someMethod() // works — class methods see real receiver`,
266
+ notes: `Mark an object as RAW — \`createStore\` and \`shallowReactive\` will return it unwrapped. Useful for class instances, third-party objects, DOM nodes, or any shape that shouldn't be deeply proxied (Vue 3 parity). Marking is one-way: there's no \`unmarkRaw\`. Mark BEFORE the object enters a store; marking after wrap doesn't unwrap an existing proxy. See also: createStore, shallowReactive.`,
267
+ mistakes: `- Marking an object AFTER it's been wrapped — the existing proxy is unaffected. Mark before the object enters any store
268
+ - Expecting \`markRaw(obj)\` to return a different object — it mutates \`obj\` and returns the SAME reference (with the marker symbol attached)
269
+ - Using markRaw on plain data objects to "skip" deep wrap — for that, use \`shallowReactive\`. markRaw is for class instances and externally-managed shapes`,
119
270
  },
120
271
 
121
272
  'reactivity/untrack': {
@@ -127,6 +278,132 @@ store.todos.push({ text: 'Build app', done: false }) // array methods work`,
127
278
  notes: `Execute a function reading signals WITHOUT subscribing to them. Alias for \`runUntracked\`. Use inside effects when you need to read a signal's current value as a one-shot snapshot without the effect re-running when that signal changes. See also: signal, effect.`,
128
279
  mistakes: '- Using `untrack` as the default — signals should be tracked by default; `untrack` is the escape hatch for specific optimization or loop-prevention cases',
129
280
  },
281
+
282
+ 'reactivity/effectScope': {
283
+ signature: '() => EffectScope',
284
+ example: `import { effectScope, signal, effect } from '@pyreon/reactivity'
285
+
286
+ const scope = effectScope()
287
+ const count = signal(0)
288
+
289
+ scope.runInScope(() => {
290
+ effect(() => console.log(count())) // tracked by scope
291
+ })
292
+
293
+ count.set(5) // logs 5
294
+ scope.stop() // tears down all effects in the scope
295
+ count.set(10) // no log — effect was disposed`,
296
+ notes: `Create an \`EffectScope\` — a container that auto-tracks effects/computeds created inside \`scope.runInScope(fn)\` and disposes them all at once via \`scope.stop()\`. \`@pyreon/core\`'s \`mountReactive\` uses this internally for component lifetime management. **Always use a scope for effects created outside a component's setup phase** (e.g. in event handlers, route loaders, or async-await chains) — without one, effects leak for the lifetime of the program. See also: effect, getCurrentScope, onScopeDispose.`,
297
+ mistakes: `- Forgetting \`scope.stop()\` — effects leak for the lifetime of the program; same shape as forgetting \`dispose()\` on a top-level \`effect()\`
298
+ - Creating effects outside \`runInScope(fn)\` and expecting them to be tracked — effects must run during the synchronous body of \`runInScope\` to register with the scope
299
+ - Stopping a scope that has pending updates — in-flight microtasks may still fire \`onUpdate\` hooks; design for idempotency or check \`isActive\` before writes`,
300
+ },
301
+
302
+ 'reactivity/onScopeDispose': {
303
+ signature: '(fn: () => void) => void',
304
+ example: `scope.runInScope(() => {
305
+ const ws = new WebSocket(url)
306
+ onScopeDispose(() => ws.close())
307
+ // ws.close() runs when scope.stop() is called
308
+ })`,
309
+ notes: 'Register a callback to run when the current `EffectScope` stops. Vue 3 parity. Captures the AMBIENT scope at registration time, so it must be called inside `scope.runInScope(fn)`. Calling outside any scope is a no-op (with a dev warning). Use for resource cleanup tied to scope lifetime — timers, listeners, external subscriptions. Equivalent to `getCurrentScope()?.add({ dispose: fn })` but without the boilerplate. See also: effectScope, getCurrentScope, onCleanup.',
310
+ mistakes: `- Calling outside any scope — silently no-ops in production, dev warns. The callback is dropped on the floor; verify with \`getCurrentScope()\` before calling if scope is uncertain
311
+ - Expecting the callback to run on EFFECT cleanup — \`onScopeDispose\` fires only on \`scope.stop()\`. For per-effect cleanup, use \`onCleanup()\` inside the effect body or return a cleanup function from it
312
+ - Using outside \`runInScope\` and inside an effect callback — the effect captures whatever scope was ambient when the effect SET UP, not when the registration runs. Effects re-run later may see a different ambient scope; register at setup, not in the body`,
313
+ },
314
+
315
+ 'reactivity/getCurrentScope': {
316
+ signature: '() => EffectScope | null',
317
+ example: `import { getCurrentScope } from '@pyreon/reactivity'
318
+
319
+ function myReactiveResource() {
320
+ const scope = getCurrentScope()
321
+ if (scope) {
322
+ // Inside a component — register cleanup with the component's scope
323
+ scope.add({ dispose: cleanup })
324
+ } else {
325
+ // Top-level / standalone — caller must call dispose() manually
326
+ console.warn('myReactiveResource: no active scope; remember to dispose')
327
+ }
328
+ }`,
329
+ notes: `Returns the currently active \`EffectScope\` (the one whose \`runInScope(fn)\` is on the stack), or \`null\` if no scope is active. Use to register cleanup with the surrounding scope, or to detect "am I inside a component lifetime?" — useful for library code that wants to register an effect with the consumer's scope rather than the global one. See also: effectScope, setCurrentScope.`,
330
+ mistakes: `- Calling \`getCurrentScope()\` outside any scope and expecting a default — returns \`null\`. Handle the no-scope case explicitly
331
+ - Using \`getCurrentScope()\` as a substitute for \`effectScope()\` — it returns the AMBIENT scope, not a fresh one`,
332
+ },
333
+
334
+ 'reactivity/setCurrentScope': {
335
+ signature: '(scope: EffectScope | null) => void',
336
+ example: `// Inside a custom render boundary that needs to swap scopes mid-flow:
337
+ const prev = getCurrentScope()
338
+ setCurrentScope(myScope)
339
+ try {
340
+ doWork()
341
+ } finally {
342
+ setCurrentScope(prev)
343
+ }
344
+ // Or — preferred:
345
+ myScope.runInScope(() => doWork())`,
346
+ notes: '**Low-level escape hatch** — directly set the ambient `EffectScope`. Use only when implementing scope-aware framework primitives (e.g. `mountReactive`, custom render boundaries). Most code should use `scope.runInScope(fn)` which sets and restores via try/finally. Pairing `setCurrentScope(s)` with a manual `setCurrentScope(prev)` is error-prone — `runInScope` is the safe form. See also: effectScope, getCurrentScope.',
347
+ mistakes: `- Forgetting to restore the previous scope — leaks effects to the wrong owner forever
348
+ - Using \`setCurrentScope\` instead of \`runInScope\` in user code — the safe API is \`runInScope\``,
349
+ },
350
+
351
+ 'reactivity/onSignalUpdate': {
352
+ signature: '(listener: (event: { signal, name, prev, next, stack, timestamp }) => void) => () => void',
353
+ example: `import { onSignalUpdate, signal } from '@pyreon/reactivity'
354
+
355
+ const dispose = onSignalUpdate(e => {
356
+ console.log(\`\${e.name ?? '(anonymous)'}: \${e.prev} → \${e.next}\`)
357
+ })
358
+ const count = signal(0, { name: 'count' })
359
+ count.set(5) // logs: count: 0 → 5
360
+ dispose() // remove listener`,
361
+ notes: 'Register a global trace listener that fires on every signal write. Returns a disposer. **Dev/debug only** — every signal write incurs the listener call. Use for time-travel debugging, recording reactive transcripts in tests, or building devtools panels. Multiple listeners are supported (each gets every event). See also: inspectSignal, why.',
362
+ mistakes: `- Leaving \`onSignalUpdate\` registered in production — fires on EVERY signal write, even hot-path internal ones. Always dispose when done
363
+ - Throwing inside the listener — corrupts the signal's notification flow (the listener fires after \`_v\` is updated but before subscribers are notified). Wrap your handler in try/catch
364
+ - Expecting the event to capture writes that occur via batch flushes — the event fires per \`set()\` call, regardless of batch state`,
365
+ },
366
+
367
+ 'reactivity/inspectSignal': {
368
+ signature: '<T>(sig: Signal<T>) => SignalDebugInfo<T>',
369
+ example: `const count = signal(0, { name: 'count' })
370
+ inspectSignal(count)
371
+ // Console group:
372
+ // 🔍 Signal "count"
373
+ // value: 0
374
+ // subscribers: 2`,
375
+ notes: 'Inspect a signal — pretty-prints its current value, name, and subscriber count to the console (in a `console.group`) and returns the debug info object. Useful for one-shot inspection while debugging; for continuous tracing use `onSignalUpdate`. See also: onSignalUpdate, why.',
376
+ mistakes: '- Calling `inspectSignal` in production — produces console noise. Gate calls behind `if (import.meta.env.DEV)` or `__DEV__`',
377
+ },
378
+
379
+ 'reactivity/why': {
380
+ signature: '() => void',
381
+ example: `why() // arm tracer
382
+ clickButton() // any signal writes here are captured
383
+ why() // disarm + dump transcript:
384
+ // [pyreon:why] "filter": "all" → "active" (12 subscribers)
385
+ // [pyreon:why] "scrollY": 0 → 240 (1 subscriber)`,
386
+ notes: 'Toggle a global "why-did-it-update?" tracer that logs every signal write between consecutive calls. Calling once arms the tracer; calling again disarms it and dumps the captured transcript. **Dev/debug only.** Useful for hunting "why did this effect just re-run?" — wrap a suspicious operation, call `why()` before and after, see exactly which signals changed. See also: onSignalUpdate, inspectSignal.',
387
+ mistakes: `- Calling \`why()\` once and forgetting to call it again — keeps tracing forever, leaks the listener, prints nothing until disarmed
388
+ - Using \`why()\` in production — pure dev tool`,
389
+ },
390
+
391
+ 'reactivity/setErrorHandler': {
392
+ signature: '(fn: (err: unknown) => void) => void',
393
+ example: `setErrorHandler(err => {
394
+ reportToSentry(err)
395
+ toast.error('Something went wrong')
396
+ })
397
+
398
+ effect(() => {
399
+ if (count() > 100) throw new Error('count too high')
400
+ })
401
+ count.set(101) // logs/reports via handler instead of crashing`,
402
+ notes: 'Register a global handler for unhandled errors thrown inside `effect()` / `computed()` / `renderEffect()`. Without a handler, errors are logged to `console.error` and the effect re-throws (potentially crashing the surrounding frame). With one, the framework calls your handler with the thrown value and continues. Use for telemetry / error-boundary integration. **One handler only — calling twice replaces the first.** See also: effect, renderEffect.',
403
+ mistakes: `- Calling \`setErrorHandler\` multiple times and expecting all to fire — the second call REPLACES the first. Compose multiple handlers manually if you need a chain
404
+ - Throwing inside the handler — the framework will swallow this too, but you lose visibility. Make handlers no-throw (try/catch internally if needed)
405
+ - Expecting the handler to receive errors from \`signal.set()\` writes — only effect-runtime errors are routed. Synchronous errors at write time bubble up normally`,
406
+ },
130
407
  // <gen-docs:api-reference:end @pyreon/reactivity>
131
408
 
132
409
  // ═══════════════════════════════════════════════════════════════════════════
@@ -475,10 +752,15 @@ return wrapCompatComponent(type)(props)`,
475
752
  },
476
753
 
477
754
  'core/ExtractProps': {
478
- signature: 'type ExtractProps<T> = T extends ComponentFn<infer P> ? P : T',
479
- example: `const Greet: ComponentFn<{ name: string }> = ({ name }) => <h1>{name}</h1>
480
- type Props = ExtractProps<typeof Greet> // { name: string }`,
481
- notes: `Extracts the props type from a \`ComponentFn\`. Passes through unchanged if \`T\` is not a \`ComponentFn\`. Useful for HOC patterns and typed wrappers that need to infer the wrapped component's prop interface. See also: HigherOrderComponent.`,
755
+ signature: 'type ExtractProps<T> = /* matches up to 4 overloads, unions the props */ T extends ComponentFn<infer P> ? P : T',
756
+ example: `function Iterator<T extends SimpleValue>(p: { data: T[]; valueName?: string }): VNodeChild
757
+ function Iterator<T extends ObjectValue>(p: { data: T[]; component: ComponentFn<T> }): VNodeChild
758
+ type Props = ExtractProps<typeof Iterator>
759
+ // → { data: SimpleValue[]; valueName?: string }
760
+ // | { data: ObjectValue[]; component: ComponentFn<ObjectValue> }`,
761
+ notes: 'Extracts the props type from a `ComponentFn`. Passes through unchanged if `T` is not a `ComponentFn`. **Multi-overload aware** — matches up to 4 call signatures and produces the UNION of their first-argument types. Critical for multi-overload primitives (Iterator, List, Element) whose loosest overload is last; without overload-aware extraction, HOC wrapping (`rocketstyle()`, `attrs()`) silently downgraded their public prop surface. Single-overload functions still work — the union of 4 copies of the same props type dedupes back to the single shape. See also: HigherOrderComponent.',
762
+ mistakes: `- Assuming \`ExtractProps<T>\` returns only the LAST overload — pre-fix it did, post-fix it returns the UNION of up to 4 overloads. Functions with more than 4 overloads still drop the extras.
763
+ - Using \`T extends (props: infer P) => any ? P : never\` directly in user code — that pattern captures only the LAST overload of a multi-overload function. Use \`ExtractProps<T>\` to get the full union.`,
482
764
  },
483
765
 
484
766
  'core/HigherOrderComponent': {
@@ -818,18 +1100,61 @@ export default createHandler({
818
1100
  },
819
1101
 
820
1102
  'server/island': {
821
- signature: 'island(loader: () => Promise<ComponentFn>, options: { name: string; hydrate?: HydrationStrategy }): ComponentFn',
822
- example: `const SearchBar = island(
823
- () => import("./SearchBar"),
824
- { name: "SearchBar", hydrate: "visible" }
1103
+ signature: 'island(loader: () => Promise<ComponentFn>, options: { name: string; hydrate?: HydrationStrategy; prefetch?: PrefetchStrategy }): ComponentFn',
1104
+ example: `// Visible-hydration paired with idle-prefetch — chunk arrives during
1105
+ // browser idle so by scroll-in, hydration is instant.
1106
+ const Comments = island(
1107
+ () => import("./Comments"),
1108
+ { name: "Comments", hydrate: "visible", prefetch: "idle" }
825
1109
  )
826
1110
 
827
- // Hydration strategies: "load" | "idle" | "visible" | "media" | "never"`,
828
- notes: 'Wrap a lazily-loaded component in a `<pyreon-island>` boundary with a hydration strategy. The rest of the page stays HTML-only; only the island fetches its JS bundle and hydrates. Strategies: `"load"` (immediate), `"idle"` (`requestIdleCallback`), `"visible"` (IntersectionObserver), `"media(query)"` (matchMedia), `"never"` (HTML-only, no JS). Props passed to islands are JSON-serialized — non-JSON values (functions, symbols, undefined, children) are stripped. See also: createHandler, hydrateIslands.',
1111
+ // Interaction-hydration perfect for modals / dropdowns / command palettes.
1112
+ const CommandPalette = island(
1113
+ () => import("./CommandPalette"),
1114
+ { name: "CommandPalette", hydrate: "interaction" }, // first focus/click/pointerenter/touchstart
1115
+ )
1116
+
1117
+ // Hydration strategies: "load" | "idle" | "visible" | "interaction" | "media" | "never"
1118
+ // Prefetch strategies: "none" (default) | "idle" | "visible"`,
1119
+ notes: 'Wrap a lazily-loaded component in a `<pyreon-island>` boundary with a hydration strategy. The rest of the page stays HTML-only; only the island fetches its JS bundle and hydrates. Strategies: `"load"` (immediate), `"idle"` (`requestIdleCallback`), `"visible"` (IntersectionObserver), `"interaction"` (first focus/click/pointerenter/touchstart — also `"interaction(<events>)"` for custom event lists; clicks are REPLAYED on the equivalent live element after hydration so the first click both wakes the island AND fires the action), `"media(query)"` (matchMedia), `"never"` (HTML-only, no JS). Props passed to islands are JSON-serialized — non-JSON values (functions, symbols, undefined, children) are stripped. Pair with `prefetch: "idle"` or `"visible"` to pre-warm the chunk BEFORE the hydration trigger fires — eliminates the blank-while-fetching flash on deferred-strategy islands. Prefetch is a no-op for `hydrate: "load"` (loader runs synchronously already) and `hydrate: "never"` (defeats the zero-JS strategy). See also: createHandler, hydrateIslands, hydrateIslandsAuto.',
829
1120
  mistakes: `- Passing function props (event handlers, callbacks) — silently stripped during JSON serialization, the island sees \`undefined\`
830
1121
  - Passing children to an island — stripped; islands cannot render arbitrary descendant trees from props
831
- - Forgetting to call \`hydrateIslands({ Name: () => import("./Path") })\` on the client — islands render as HTML and never hydrate
832
- - Using a duplicate \`name\` across two islands — the client-side registry collapses them, only one loader will fire`,
1122
+ - Forgetting to wire client-side hydration — under \`@pyreon/vite-plugin\` use \`hydrateIslandsAuto(registry)\` (the registry is auto-generated from \`island()\` calls); without a plugin use the manual \`hydrateIslands({ Name: () => import("./Path") })\`
1123
+ - Using a duplicate \`name\` across two islands — the client-side registry collapses them, only one loader will fire
1124
+ - Setting \`prefetch: "idle"\` on a \`hydrate: "load"\` island — load runs the loader synchronously, prefetch is redundant (silently suppressed; no \`data-prefetch\` attribute is emitted)
1125
+ - Setting any \`prefetch\` on a \`hydrate: "never"\` island — defeats the whole zero-JS point of \`never\` (silently suppressed)
1126
+ - Registering a \`hydrate: "never"\` island in \`hydrateIslands({ ... })\` — defeats the strategy by pulling the component module into the client bundle. The whole point of \`never\` is zero client JS. The runtime short-circuits never-strategy before the registry lookup so missing entries are silent (no \`data-island-error="no-loader"\`); the auto-registry omits never-strategy islands by design.
1127
+ - Using \`"interaction"\` for visible-on-load components — defeats the strategy. Use \`"load"\` for above-the-fold interactive content; reserve \`"interaction"\` for modals / dropdowns / command palettes that are interactive but only shown on user demand
1128
+ - Relying on focus/pointerenter to trigger the SAME action as click for \`"interaction"\` — only clicks are replayed post-hydration. Non-click events trigger hydration but no replay (focus can\'t be reliably re-dispatched once the user has tabbed past; pointerenter is passive)`,
1129
+ },
1130
+
1131
+ 'server/hydrateIslands': {
1132
+ signature: 'hydrateIslands(registry: Record<string, () => Promise<ComponentFn | { default: ComponentFn }>>): () => void',
1133
+ example: `import { hydrateIslands } from "@pyreon/server/client"
1134
+
1135
+ hydrateIslands({
1136
+ Counter: () => import("./Counter"),
1137
+ SearchBar: () => import("./SearchBar"),
1138
+ // hydrate: "never" islands are intentionally omitted —
1139
+ // registering them defeats the zero-JS contract.
1140
+ })`,
1141
+ notes: 'Client-side counterpart to `island()`. Walks every `<pyreon-island>` element on the page and schedules hydration per its `data-hydrate` strategy. Manual form: the user maintains the `Name → loader` mapping by hand (must match every `island()` `name` field). Returns a cleanup function that disconnects pending observers / listeners. Use `hydrateIslandsAuto()` under `@pyreon/vite-plugin` to skip the manual sync. Imported from `@pyreon/server/client`, NOT from `@pyreon/server` (server-only entry). See also: island, hydrateIslandsAuto.',
1142
+ mistakes: `- Registry key must match the \`island()\` \`name\` field exactly — typo / drift causes runtime \`data-island-error="no-loader"\`. Use \`hydrateIslandsAuto()\` to eliminate this manual sync.
1143
+ - Including a \`hydrate: "never"\` island in the registry — defeats the strategy by pulling its module into the client bundle. Skip never-islands; the runtime short-circuits silently for them.
1144
+ - Importing from \`@pyreon/server\` instead of \`@pyreon/server/client\` — the main entry is server-only and stubs/throws on client-side use.`,
1145
+ },
1146
+
1147
+ 'server/hydrateIslandsAuto': {
1148
+ signature: 'hydrateIslandsAuto(registry: AutoIslandRegistry): () => void',
1149
+ example: `// src/entry-client.ts
1150
+ import { hydrateIslandsAuto } from "@pyreon/server/client"
1151
+ import * as registry from "virtual:pyreon/islands-registry"
1152
+
1153
+ hydrateIslandsAuto(registry)`,
1154
+ notes: 'Auto-discovered counterpart to `hydrateIslands()`. Under `@pyreon/vite-plugin` (`pyreon({ islands: true })` is the default), the plugin pre-scans your source for `island()` declarations and emits a `virtual:pyreon/islands-registry` virtual module. The user imports it into `entry-client.ts` and passes it here. Eliminates the manual `Name → loader` sync that drives the #1 author foot-gun for islands. Never-strategy islands are omitted from the auto-registry by design — their components stay out of the client bundle. See also: hydrateIslands, island.',
1155
+ mistakes: `- Calling without the registry argument — the function takes the imported virtual module explicitly. The user-side \`import\` is what lets the plugin\'s \`resolveId\` hook run; importing from inside \`@pyreon/server/client\` would fail at build time because Rolldown\'s static-import analysis runs before plugin resolveId hooks for workspace sources.
1156
+ - Using under a non-Vite bundler — the virtual module only exists under \`@pyreon/vite-plugin\`. Fall back to manual \`hydrateIslands({ ... })\` for non-Vite consumers.
1157
+ - Setting \`pyreon({ islands: false })\` and still calling \`hydrateIslandsAuto()\` — the plugin emits a stub registry that throws at runtime with a clear error message. Either re-enable islands (the default) or use \`hydrateIslands({ ... })\` instead.`,
833
1158
  },
834
1159
 
835
1160
  'server/prerender': {
@@ -2348,6 +2673,18 @@ if (__DEV__) console.warn('hello')`,
2348
2673
 
2349
2674
  // <gen-docs:api-reference:start @pyreon/mcp>
2350
2675
 
2676
+ 'mcp/mcp_overview': {
2677
+ signature: 'tool: mcp_overview() → MarkdownTable',
2678
+ example: `mcp_overview()
2679
+ // → | Tool | When to use | Example |
2680
+ // |------|-------------|---------|
2681
+ // | mcp_overview | Returns a markdown table of every registered MCP tool... | mcp_overview() |
2682
+ // | get_api | Look up any Pyreon API by package and symbol... | get_api({ package: 'flow', symbol: 'createFlow' }) |
2683
+ // | ...`,
2684
+ notes: 'Returns a markdown table of every registered MCP tool with a one-sentence "when to use" description and a one-line example. Reads from this same manifest at runtime — single source of truth (the same data feeds `api-reference.ts`, `llms-full.txt`, and `docs/docs/mcp.md`). Intended as the first call for any AI agent connecting to the server: enumerates the surface so the agent can navigate by intent (e.g. "I need release notes" → `get_changelog`) rather than guessing tool names from `tools/list`. See also: get_api.',
2685
+ mistakes: '- Skipping this tool and calling `tools/list` instead — that returns names + parameter schemas but no "when to use" guidance, so an agent has to call multiple tools to figure out which one fits the task.',
2686
+ },
2687
+
2351
2688
  'mcp/get_browser_smoke_status': {
2352
2689
  signature: 'tool: get_browser_smoke_status — no args',
2353
2690
  example: `// Ask the MCP server:
@@ -2438,7 +2775,17 @@ get_changelog({ package: '@pyreon/router', since: '0.12.0' })`,
2438
2775
  signature: `tool: audit_test_environment({ minRisk?: 'high' | 'medium' | 'low'; limit?: number }) → AuditReport`,
2439
2776
  example: `audit_test_environment({ minRisk: 'medium', limit: 10 })
2440
2777
  // → grouped report with HIGH / MEDIUM / LOW sections`,
2441
- notes: `Scan every \`*.test.{ts,tsx}\` under \`packages/\` for the mock-vnode anti-pattern that caused PR #197\'s silent metadata drop. Files are classified HIGH / MEDIUM / LOW based on the balance of mock-vnode literals + helpers + helper-call sites vs real \`h()\` calls + \`@pyreon/core\` import. Three context-aware skips (helper-def vs binding discrimination, type-guard call-arg skip, template-string fixture mask) keep the false-positive rate low. Run before merging a new test file or after a framework change. See also: get_browser_smoke_status.`,
2778
+ notes: `Scan every \`*.test.{ts,tsx}\` under \`packages/\` for the mock-vnode anti-pattern that caused PR #197\'s silent metadata drop. Files are classified HIGH / MEDIUM / LOW based on the balance of mock-vnode literals + helpers + helper-call sites vs real \`h()\` calls + \`@pyreon/core\` import. Three context-aware skips (helper-def vs binding discrimination, type-guard call-arg skip, template-string fixture mask) keep the false-positive rate low. Run before merging a new test file or after a framework change. See also: get_browser_smoke_status, audit_islands.`,
2779
+ },
2780
+
2781
+ 'mcp/audit_islands': {
2782
+ signature: 'tool: audit_islands({ json?: boolean }) → IslandAuditReport',
2783
+ example: `audit_islands({})
2784
+ // → markdown-grouped report with one section per finding code
2785
+
2786
+ audit_islands({ json: true })
2787
+ // → machine-readable { root, findings: [...], summary: {...} }`,
2788
+ notes: `Project-wide cross-file islands audit (PR C of the islands DX roadmap). Walks \`packages/\` + \`examples/\` and runs five detectors that auto-registry can\'t reach (manual \`hydrateIslands({...})\` for non-Vite consumers / library authors) AND PR G\'s per-file \`island-never-with-registry-entry\` detector misses (it only catches the same-file shape): \`duplicate-name\`, \`never-with-registry-entry\`, \`registry-mismatch\`, \`nested-island\`, \`dead-island\`. Each finding ships with file path + line/column + actionable fix suggestion. Companion to the \`pyreon doctor --check-islands\` CLI flag (same scanner, same five detectors). Run before merging an island PR; CI gate by piping \`--json\` and grepping \`findings.length > 0\`. See also: audit_test_environment, get_anti_patterns.`,
2442
2789
  },
2443
2790
  // <gen-docs:api-reference:end @pyreon/mcp>
2444
2791
 
@@ -2966,4 +3313,448 @@ const tree = helper.getDocNode()`,
2966
3313
  notes: 'Explicit page boundary inside a `DocPage`. Forces the renderer to start a new page at this point in paginated outputs (PDF, DOCX). In flow outputs (HTML, Markdown), it renders as visible whitespace or is omitted entirely. Use for explicit pagination control beyond what `DocPage` boundaries already provide. See also: DocPage.',
2967
3314
  },
2968
3315
  // <gen-docs:api-reference:end @pyreon/document-primitives>
3316
+
3317
+ // <gen-docs:api-reference:start @pyreon/zero>
3318
+
3319
+ 'zero/zero': {
3320
+ signature: 'function zero(config?: ZeroConfig): Plugin[] // default export of @pyreon/zero/server',
3321
+ example: `import zero from '@pyreon/zero/server'
3322
+
3323
+ // SPA (default) — no special config needed
3324
+ plugins: [pyreon(), zero()]
3325
+
3326
+ // SSG with auto-detected paths + i18n + adapter
3327
+ plugins: [pyreon(), zero({
3328
+ mode: 'ssg',
3329
+ i18n: { locales: ['en','de','cs'], defaultLocale: 'en' },
3330
+ adapter: vercelAdapter(),
3331
+ })]
3332
+
3333
+ // Subpath deploy (e.g. served at /blog/)
3334
+ plugins: [pyreon(), zero({ base: '/blog/', mode: 'ssg' })]`,
3335
+ notes: `Top-level Vite plugin chain for @pyreon/zero. Single config object selects rendering mode (\`'ssr' | 'ssg' | 'isr' | 'spa'\`), subpath base (\`base: '/blog/'\`), SSG settings (paths, concurrency, onProgress, emit404, emitRedirects), i18n config (locales / defaultLocale / strategy), and deployment adapter. Returns \`Plugin[]\` because the SSG mode adds a companion \`ssgPlugin()\` automatically — Vite's plugins array natively flattens nested arrays so \`plugins: [pyreon(), zero()]\` works without spread. See also: I18nRoutingConfig, GetStaticPaths, Adapter, createISRHandler.`,
3336
+ mistakes: `- Setting \`base\` in BOTH \`vite.config.base\` AND \`zero({ base })\` and expecting them to merge — user's explicit \`vite.config.base\` overrides the plugin-returned base. Set base ONCE via \`zero({ base })\`; let it propagate to Vite + router automatically
3337
+ - Passing \`layout\` to \`createApp\` / \`startClient\` when fs-router already emits \`_layout.tsx\` as a parent route — double-mounts the layout. Drop the explicit option; \`_layout.tsx\` is the canonical layout registration
3338
+ - Mixing \`mode: 'ssg'\` with a runtime adapter that has no SSG branch (e.g. expecting \`nodeAdapter\` to write platform routing config under SSG) — node/bun/static adapters no-op for SSG; use vercel/cloudflare/netlify if you need platform routing emission
3339
+ - Configuring \`ssg.paths\` AND per-route \`getStaticPaths\` together for the same dynamic route — both produce the same path list and the SSG plugin renders each path TWICE (the second pass overwrites). Pick one: \`ssg.paths\` for top-down explicit lists, \`getStaticPaths\` for per-route enumerators
3340
+ - Forgetting that \`mode: 'ssg'\` returns \`Plugin[]\` (not a single Plugin) — any downstream test code that does \`plugins: [zeroPlugin().name]\` instead of \`plugins: zeroPlugin()\` breaks
3341
+ - Setting \`ssg.concurrency\` higher than the data layer's connection ceiling — loaders running concurrently overwhelm the upstream (db pool, external API rate limit). Default \`4\` is safe; raise after profiling, lower to \`1\` for serial-required loaders`,
3342
+ },
3343
+
3344
+ 'zero/I18nRoutingConfig': {
3345
+ signature: `interface I18nRoutingConfig { locales: string[]; defaultLocale: string; strategy?: 'prefix' | 'prefix-except-default' }`,
3346
+ example: `// Prefix-except-default (canonical SEO shape — default unprefixed)
3347
+ zero({ i18n: { locales: ['en','de','cs'], defaultLocale: 'en' } })
3348
+ // Emits: /about, /de/about, /cs/about
3349
+ // Default locale's index.html: dist/about/index.html (NOT dist/en/about/...)
3350
+
3351
+ // Prefix (every locale prefixed)
3352
+ zero({ i18n: { locales: ['en','de','cs'], defaultLocale: 'en', strategy: 'prefix' } })
3353
+ // Emits: /en/about, /de/about, /cs/about
3354
+ // NO unprefixed /about exists`,
3355
+ notes: `Configuration shape for \`zero({ i18n })\`. \`locales\` is the supported BCP-47 list (validated against path-traversal — \`..\`, \`/\`, backslash, \`.\`, leading-dot, NUL chars rejected). \`defaultLocale\` is the canonical / SEO-primary locale. \`strategy\` selects URL shape — \`'prefix-except-default'\` (default) keeps \`/about\` unprefixed for the default locale + emits \`/de/about\` etc. for non-defaults (best for SEO-on-default-locale apps); \`'prefix'\` prefixes every locale including default (\`/en/about\`, \`/de/about\`) for apps with no primary locale. See also: zero, expandRoutesForLocales, i18nRouting.`,
3356
+ mistakes: `- Configuring locale strings with \`.\`, \`..\`, \`/\`, backslash, or NUL — rejected by \`validateLocale\` (PR L2 guard). Common BCP-47 shapes pass: \`en\`, \`de-AT\`, \`en-US\`, \`zh-Hans\`, \`pt-BR\`
3357
+ - Expecting \`<RouterLink to='/posts/1'>\` rendered inside \`/de/posts\` to emit \`/de/posts/1\` automatically — RouterLinks emit LITERAL hrefs; cross-locale navigation falls through to the default-locale route. Locale-aware navigation is a separate API (not yet shipped)
3358
+ - Assuming the framework runtime-detects locale from URL prefix — it doesn't. The router matches \`/de/about\` to the duplicated route record; consumer code reads locale from URL parsing OR from \`i18nRouting()\` middleware (request-time Accept-Language detection)
3359
+ - Using \`prefix-except-default\` and then duplicating the root \`_layout.tsx\` per locale — \`expandRoutesForLocales\` deliberately SKIPS root-layout duplication under this strategy because the unprefixed root layout already wraps locale-prefixed children via hierarchical match. Under \`prefix\` strategy the skip does NOT apply (no unprefixed default to inherit from)
3360
+ - Single-locale \`locales: ['en']\` + \`prefix-except-default\` — short-circuits to a no-op (no other locales to prefix). Use \`prefix\` strategy if you want \`/en/about\` for SEO consistency with future multi-locale expansion
3361
+ - Hand-writing per-locale routes (\`src/routes/de/about.tsx\`) instead of letting \`expandRoutesForLocales\` duplicate from a single source file — the framework's duplication wires hierarchical layouts + loader-data hydration + hreflang sitemap clustering correctly; hand-written variants miss the cross-cuts`,
3362
+ },
3363
+
3364
+ 'zero/expandRoutesForLocales': {
3365
+ signature: 'function expandRoutesForLocales(routes: FileRoute[], config: I18nRoutingConfig): FileRoute[] // server-only',
3366
+ example: `import { expandRoutesForLocales } from '@pyreon/zero/server'
3367
+ import { parseFileRoutes, scanRouteFiles } from '@pyreon/zero/server'
3368
+
3369
+ const files = await scanRouteFiles('./src/routes')
3370
+ const baseRoutes = parseFileRoutes(files)
3371
+ const fileRoutes = expandRoutesForLocales(baseRoutes, {
3372
+ locales: ['en', 'de', 'cs'],
3373
+ defaultLocale: 'en',
3374
+ strategy: 'prefix-except-default',
3375
+ })
3376
+ // fileRoutes now contains: original routes + /de/* + /cs/* subtrees`,
3377
+ notes: 'Fans a flat route list into per-locale variants based on `I18nRoutingConfig`. Each non-default locale gets a full subtree duplicate — layouts, error boundaries, loading components, 404 pages, dynamic params (`[id]` → `:id`), catch-all routes (`[...slug]` → `:slug*`) all compose naturally with the locale prefix. Source `filePath` is preserved so the duplicated routes share the same component module; only `urlPath` / `dirPath` / `depth` change. `getStaticPaths` inherits across duplicates so dynamic-route × locale cross-products work automatically (3 IDs × 3 locales = 9 SSG outputs). Root-layout skip under `prefix-except-default` prevents double-mount. See also: I18nRoutingConfig, zero, parseFileRoutes.',
3378
+ mistakes: `- Calling this from CLIENT code — server-only export from \`@pyreon/zero/server\`. Importing from \`@pyreon/zero\` (the client entry) gives a clear server-only error stub
3379
+ - Expecting hand-written \`src/routes/de/about.tsx\` to compose with duplicated \`/de/about\` from \`/about\` — the helper does NOT detect collisions today; a user-defined route at \`/de/profile\` + locale \`de\` produces two records at the same urlPath (router matches first)
3380
+ - Modifying the returned \`FileRoute[]\` and expecting \`getStaticPaths\` inheritance to update — the duplicates carry frozen \`exports\` references at duplication time; later mutations don't propagate to the SSG enumerator
3381
+ - Setting \`strategy: 'prefix'\` and expecting \`/about\` (unprefixed) to ALSO render — under \`prefix\` every locale is prefixed; the default-locale unprefixed URL does NOT exist as a dist file. Use \`prefix-except-default\` if you need both
3382
+ - Passing user-controlled strings as locales without validation — the helper validates against path-traversal (\`..\`, \`/\`, backslash, \`.\`, NUL) but does NOT validate BCP-47 shape; an invalid locale silently produces oddly-shaped URLs`,
3383
+ },
3384
+
3385
+ 'zero/GetStaticPaths': {
3386
+ signature: 'type GetStaticPaths<TParams> = () => Array<{ params: TParams }> | Promise<Array<{ params: TParams }>>',
3387
+ example: `import type { GetStaticPaths } from '@pyreon/zero/server'
3388
+
3389
+ // src/routes/posts/[id].tsx
3390
+ export const getStaticPaths: GetStaticPaths<{ id: string }> = () =>
3391
+ POSTS.map((p) => ({ params: { id: String(p.id) } }))
3392
+
3393
+ // Async loader-driven enumeration
3394
+ export const getStaticPaths: GetStaticPaths<{ slug: string }> = async () => {
3395
+ const posts = await db.query('SELECT slug FROM posts WHERE published = true')
3396
+ return posts.map((p) => ({ params: { slug: p.slug } }))
3397
+ }`,
3398
+ notes: 'Per-route export type for dynamic-route enumeration at SSG build time (PR A of the SSG roadmap). Route files at `src/routes/posts/[id].tsx` export `getStaticPaths` returning the concrete param values; the SSG plugin expands the URL pattern (`/posts/:id` × `[1, 2, 3]` → `/posts/1`, `/posts/2`, `/posts/3`). Sync or async return; errors during enumeration land in `PrerenderResult.errors` without aborting other routes. Catch-all routes (`[...slug].tsx`) work via `{ params: { slug: "a/b" } }` → `/blog/a/b`. See also: zero, I18nRoutingConfig.',
3399
+ mistakes: `- Returning param values as numbers instead of strings (\`{ id: 1 }\` instead of \`{ id: '1' }\`) — URL segments are always strings; the type enforces this but a runtime cast (\`as any\`) silently produces wrong paths
3400
+ - Forgetting to handle the no-i18n vs i18n cardinality — with \`zero({ i18n })\` the cross-product is \`paths × locales\`; a 100-path enumerator with 3 locales produces 300 dist files. Pair with \`ssg.concurrency\` to avoid serial-render blowup
3401
+ - Throwing in \`getStaticPaths\` and expecting the build to abort — errors are CAPTURED into \`PrerenderResult.errors\` and the build continues for other routes. Check \`dist/_pyreon-ssg-errors.json\` after the build (PR G)
3402
+ - Mixing \`getStaticPaths\` and \`ssg.paths\` for the same dynamic route — both produce paths and the SSG plugin renders each twice
3403
+ - Reading external state in \`getStaticPaths\` without await — the function is async-aware; missing await produces "[object Promise]" segments in the URL`,
3404
+ },
3405
+
3406
+ 'zero/Adapter': {
3407
+ signature: 'interface Adapter { name: string; build?(options: AdapterBuildOptions): Promise<void>; revalidate?(path: string): Promise<AdapterRevalidateResult> }',
3408
+ example: `import { vercelAdapter, cloudflareAdapter, netlifyAdapter, staticAdapter } from '@pyreon/zero/server'
3409
+
3410
+ // Vercel — emits .vercel/output/config.json v3 STATIC variant
3411
+ plugins: [pyreon(), zero({ mode: 'ssg', adapter: vercelAdapter() })]
3412
+
3413
+ // Cloudflare — emits _routes.json (zero-function deploy)
3414
+ plugins: [pyreon(), zero({ mode: 'ssg', adapter: cloudflareAdapter() })]
3415
+
3416
+ // Netlify — emits netlify.toml with publish="." + cache headers
3417
+ plugins: [pyreon(), zero({ mode: 'ssg', adapter: netlifyAdapter() })]
3418
+
3419
+ // ISR revalidation webhook handler (Vercel-side)
3420
+ await vercelAdapter().revalidate?.('/posts/123')
3421
+ // → { regenerated: true } on success`,
3422
+ notes: `Deployment adapter contract. \`build()\` is auto-invoked by SSG's \`closeBundle\` AFTER the path render loop (PR J) and writes platform-specific routing config: Vercel emits \`.vercel/output/config.json\`; Cloudflare emits \`_routes.json\` with zero-function \`exclude: ['/*']\`; Netlify emits \`netlify.toml\` with \`publish = '.'\` + asset cache headers. \`revalidate(path)\` is the runtime hook for build-time ISR (PR I) — Vercel POSTs to a revalidation webhook, Cloudflare purges the edge cache, Netlify triggers a Build Hook. Static / node / bun adapters no-op for SSG. See also: zero, createISRHandler, vercelAdapter.`,
3423
+ mistakes: `- Calling \`adapter.revalidate(path)\` without the platform's env vars set (e.g. \`VERCEL_DEPLOYMENT_URL\` + \`VERCEL_REVALIDATE_TOKEN\`) — returns \`{ regenerated: false }\` with a dev-mode warning. The webhook is a no-op without credentials
3424
+ - Expecting \`nodeAdapter\` / \`bunAdapter\` to emit platform routing config under SSG — they no-op (no platform routing to configure). Use vercel/cloudflare/netlify if you need a routing config emitted
3425
+ - Setting \`mode: 'ssg'\` + \`adapter: vercelAdapter()\` and ALSO writing \`.vercel/output/config.json\` manually — the adapter overwrites it. Pick one source of truth
3426
+ - Calling adapter methods from CLIENT code — server-only. Import from \`@pyreon/zero/server\`
3427
+ - Forgetting that Netlify's revalidate triggers a FULL-SITE rebuild (Build Hook semantics) — Netlify doesn't expose per-page ISR. The \`path\` arg flows into \`trigger_title\` for audit logs but doesn't scope the rebuild`,
3428
+ },
3429
+
3430
+ 'zero/createISRHandler': {
3431
+ signature: 'function createISRHandler(options: { handler: Handler; cacheTtl?: number; ... }): Handler',
3432
+ example: `import { createISRHandler, createServer } from '@pyreon/zero/server'
3433
+
3434
+ // Wrap createServer's handler with ISR cache
3435
+ const ssrHandler = createServer({ routes })
3436
+ const isrHandler = createISRHandler({
3437
+ handler: ssrHandler,
3438
+ cacheTtl: 60_000, // serve cached HTML for 60s
3439
+ })
3440
+
3441
+ export default isrHandler`,
3442
+ notes: `Runtime ISR — on-demand SSR caching with TTL. Wraps an SSR handler so pages are rendered on the FIRST request, cached for \`cacheTtl\` ms (default 60s), and served stale until expiry. Distinct from build-time ISR (per-route \`revalidate\` export + \`Adapter.revalidate\`): runtime ISR caches at request time; build-time ISR triggers platform rebuilds. They can coexist: a \`mode: 'isr'\` app with per-route \`revalidate\` exports gets BOTH. See also: zero, Adapter.`,
3443
+ mistakes: `- Setting \`cacheTtl: 0\` and expecting "never cache" — pass-through is the explicit handler call (no \`createISRHandler\` wrapper). \`cacheTtl: 0\` is a degenerate state
3444
+ - Sharing the ISR handler across server instances without external cache — each server's in-memory cache diverges. For multi-instance deploys, swap to a shared cache layer (Redis adapter not built in; user-side concern)`,
3445
+ },
3446
+
3447
+ 'zero/vercelAdapter': {
3448
+ signature: 'function vercelAdapter(): Adapter',
3449
+ example: `plugins: [pyreon(), zero({ mode: 'ssg', adapter: vercelAdapter() })]`,
3450
+ notes: 'Vercel deployment adapter. SSG branch emits `.vercel/output/config.json` v3 STATIC variant (no functions, asset cache headers). Does NOT copy files into `.vercel/output/static/` — Vercel CLI auto-detects dist. ISR `revalidate(path)` POSTs to `<VERCEL_DEPLOYMENT_URL>/api/_pyreon-revalidate?path=…&secret=<token>`; user-side webhook validates secret + calls `res.revalidate()`. See also: Adapter, zero.',
3451
+ },
3452
+
3453
+ 'zero/cloudflareAdapter': {
3454
+ signature: 'function cloudflareAdapter(): Adapter',
3455
+ example: `plugins: [pyreon(), zero({ mode: 'ssg', adapter: cloudflareAdapter() })]`,
3456
+ notes: `Cloudflare Pages adapter. SSG branch emits \`_routes.json\` with \`{ version: 1, include: [], exclude: ['/*'] }\` — i.e. "every URL is static, never invoke a Pages Function" (zero-function deploy). Without this file Pages defaults to running the worker on every request, wasting paid-plan compute. ISR \`revalidate(path)\` POSTs to Cloudflare's zone purge_cache API. See also: Adapter, zero.`,
3457
+ },
3458
+
3459
+ 'zero/netlifyAdapter': {
3460
+ signature: 'function netlifyAdapter(): Adapter',
3461
+ example: `plugins: [pyreon(), zero({ mode: 'ssg', adapter: netlifyAdapter() })]`,
3462
+ notes: `Netlify adapter. SSG branch emits \`netlify.toml\` with \`publish = "."\` + \`Cache-Control\` headers for \`/assets/*\`. PR B's \`dist/_redirects\` covers loader-thrown redirects (Netlify reads the file natively). ISR \`revalidate(path)\` POSTs to a Build Hook URL with \`trigger_title=revalidate:<path>\` for audit-log traceability (Netlify queues a full-site partial rebuild — no per-page ISR API). See also: Adapter, zero.`,
3463
+ },
3464
+
3465
+ 'zero/seoPlugin': {
3466
+ signature: 'function seoPlugin(config: SeoPluginConfig): Plugin // server-only',
3467
+ example: `seoPlugin({
3468
+ sitemap: {
3469
+ baseUrl: 'https://example.com',
3470
+ useSsgPaths: true, // PR F — auto-detect SSG paths
3471
+ hreflang: true, // PR K — auto-detect i18n + emit cross-refs
3472
+ },
3473
+ robots: { sitemap: 'https://example.com/sitemap.xml' },
3474
+ })`,
3475
+ notes: `SEO plugin — emits \`sitemap.xml\`, \`robots.txt\`, JSON-LD, and hreflang cross-references. \`sitemap.useSsgPaths: true\` auto-detects from SSG output manifest (paths from \`getStaticPaths\` × locale variants flow in automatically). \`sitemap.hreflang: true\` auto-detects i18n config from the SSG manifest → clusters per-locale URLs into ONE \`<url>\` with \`<xhtml:link rel='alternate' hreflang>\` siblings + \`x-default\` entry. Falls back to fs-router walk when SSG manifest is absent. See also: aiPlugin, zero.`,
3476
+ mistakes: `- Setting \`useSsgPaths: true\` in non-SSG mode — silently falls back to fs-router walk (no SSG manifest to read). Same effect as omitting the flag
3477
+ - Setting \`hreflang: true\` without \`zero({ i18n })\` — emits a plain single-URL sitemap (no clustering). Configure i18n on zero() to activate hreflang
3478
+ - Expecting \`hreflang: I18nRoutingConfig\` (explicit form) to override the SSG manifest's i18n config — explicit wins, but typically the auto-detect is the right shape. Use explicit only if you want a different locale set in the sitemap than in routing`,
3479
+ },
3480
+
3481
+ 'zero/aiPlugin': {
3482
+ signature: 'function aiPlugin(config?: AiPluginConfig): Plugin // server-only',
3483
+ example: 'plugins: [pyreon(), zero(), seoPlugin({ ... }), aiPlugin()]',
3484
+ notes: `AI integration plugin — generates \`llms.txt\`, \`llms-full.txt\`, and JSON-LD inference metadata at build time. Designed for sites that want to be AI-readable (search engines, model trainers, agentic crawlers). The generated files are themselves Pyreon's on-publish artifacts; the plugin runs \`inferJsonLd\` per route to extract structured data from \`meta\` exports. See also: seoPlugin, zero.`,
3485
+ },
3486
+
3487
+ 'zero/i18nRouting': {
3488
+ signature: 'function i18nRouting(config: I18nRoutingConfig): Plugin // server-only',
3489
+ example: `import { i18nRouting } from '@pyreon/zero/server'
3490
+
3491
+ plugins: [pyreon(), zero({ i18n: { locales, defaultLocale } }), i18nRouting({ locales, defaultLocale })]
3492
+ // Same config object shape — accepts the i18n already passed to zero() if you keep one source of truth`,
3493
+ notes: 'Vite plugin for REQUEST-TIME locale detection — Accept-Language header, cookie, root-path redirect to detected locale. Orthogonal to BUILD-TIME route duplication (`expandRoutesForLocales`); both can be used together. The plugin sets a request-context locale that components read via `createLocaleContext`. See also: zero, I18nRoutingConfig, createLocaleContext.',
3494
+ mistakes: `- Confusing this plugin with route duplication — they're separate concerns. \`zero({ i18n })\` controls BUILD-TIME duplication; \`i18nRouting()\` plugin controls REQUEST-TIME detection
3495
+ - Using \`i18nRouting()\` under SSG mode without a server runtime — request-time middleware needs a live request handler. SSG only emits static files. Use \`mode: 'ssr'\` for request-time locale detection`,
3496
+ },
3497
+
3498
+ 'zero/validateEnv': {
3499
+ signature: 'function validateEnv<T>(schema: T, env?: ProcessEnv): ValidatedEnv<T> // server-only',
3500
+ example: `import { validateEnv, publicEnv, schema } from '@pyreon/zero/server'
3501
+
3502
+ const env = validateEnv({
3503
+ PORT: 3000,
3504
+ DEBUG: false,
3505
+ API_KEY: String, // required string
3506
+ API_URL: schema((v) => new URL(v)),
3507
+ })
3508
+ // env.PORT → number; env.API_KEY → string; env.API_URL → URL
3509
+
3510
+ const pub = publicEnv(env, ['API_URL']) // omit secrets`,
3511
+ notes: 'Env-variable validation with type coercion. Schema accepts primitives (`String`, `Number`, `Boolean`) for default coercion + `schema()` for custom parsers. `publicEnv()` returns a client-safe subset (no secrets). Catches missing-required-env errors at startup instead of mid-request runtime crashes. See also: zero.',
3512
+ },
3513
+
3514
+ 'zero/cspMiddleware': {
3515
+ signature: 'function cspMiddleware(config: { directives: CspDirectives }): Middleware // server-only',
3516
+ example: `import { cspMiddleware } from '@pyreon/zero/server'
3517
+
3518
+ plugins: [pyreon(), zero({
3519
+ middleware: [cspMiddleware({
3520
+ directives: {
3521
+ 'default-src': ["'self'"],
3522
+ 'script-src': ["'self'", "'nonce-{{nonce}}'"],
3523
+ },
3524
+ })],
3525
+ })]`,
3526
+ notes: `CSP (Content Security Policy) middleware — emits \`Content-Security-Policy\` header per request with configurable directives. Pair with \`useNonce()\` for inline scripts (nonce is generated per-request and embedded in CSP \`script-src 'nonce-XXX'\`). Server-only; SPA mode without a request handler can't emit per-request nonces. See also: useRequestLocals.`,
3527
+ },
3528
+
3529
+ 'zero/useRequestLocals': {
3530
+ signature: 'function useRequestLocals<T = unknown>(): T',
3531
+ example: `// middleware
3532
+ async function authMiddleware(ctx, next) {
3533
+ ctx.locals.user = await verifyToken(ctx.req.headers.get('authorization'))
3534
+ return next()
3535
+ }
3536
+
3537
+ // component
3538
+ const { user } = useRequestLocals<{ user: User | null }>()`,
3539
+ notes: 'Bridge middleware-attached request locals into the component tree. Middleware sets `ctx.locals.user = currentUser`; components call `useRequestLocals()` to read. Reactive context — locale-aware re-reads work inside `effect()` / JSX thunks. See also: cspMiddleware.',
3540
+ },
3541
+
3542
+ 'zero/Link': {
3543
+ signature: '<Link href={path} prefetch="hover" activeClass={cls}>{children}</Link>',
3544
+ example: `import { Link } from '@pyreon/zero/link'
3545
+
3546
+ <Link href="/about" prefetch="viewport" activeClass="nav-active">About</Link>
3547
+ <Link href="/external" external>External</Link> // target="_blank" rel="noopener noreferrer"`,
3548
+ notes: 'Default navigation link built on an `<a>` tag — client-side push via `router.push()`, hover/viewport prefetch, `aria-current="page"` on exact match, `activeClass` / `exactActiveClass` for nav-state styling. Built on `createLink` so consumers can swap the rendered element via `createLink(MyCustomLink)` without losing the prefetch + active-state behavior. See also: useLink, createLink, prefetchRoute.',
3549
+ mistakes: `- Using \`<a href={path} onClick={() => router.push(path)}>\` instead of \`<Link>\` — manual approach skips prefetch, active-state class merging, and the keyboard-modifier guard (Cmd+click should open new tab, not navigate in-place)
3550
+ - Setting \`prefetch="hover"\` (default) and expecting prefetch on mobile — mobile devices don't fire mouseenter; use \`prefetch="viewport"\` for IntersectionObserver-based prefetch (or accept that touchstart triggers prefetch too)
3551
+ - Passing \`class\` AND \`activeClass\` — both are MERGED via \`cx\` (not overridden); the user-provided \`class\` always applies, \`activeClass\` is appended when \`isActive()\` is true
3552
+ - \`<Link to={...}>\` — Link uses \`href\`, NOT \`to\` (RouterLink from \`@pyreon/router\` uses \`to\`; Link from \`@pyreon/zero/link\` uses \`href\` to match HTML anchor convention)
3553
+ - Expecting \`external: true\` to skip prefetch — \`external\` controls click handling (opens in new tab via \`target="_blank"\`), not prefetch. Use \`prefetch="none"\` if you want to skip prefetch for an internal link
3554
+ - Building a custom anchor wrapper from scratch instead of using \`createLink\` or \`useLink\` — the prefetch cache, keyboard-modifier guard, active-state class composition, and SSR-safe document.head injection are non-trivial`,
3555
+ },
3556
+
3557
+ 'zero/useLink': {
3558
+ signature: 'function useLink(props: LinkProps): UseLinkReturn',
3559
+ example: `import { useLink } from '@pyreon/zero/link'
3560
+
3561
+ function CardLink(props: LinkProps) {
3562
+ const link = useLink(props)
3563
+ return (
3564
+ <div
3565
+ ref={link.ref}
3566
+ class={() => \`card \${link.classes()}\`}
3567
+ onClick={link.handleClick}
3568
+ onMouseEnter={link.handleMouseEnter}
3569
+ onTouchStart={link.handleTouchStart}
3570
+ >
3571
+ {props.children}
3572
+ </div>
3573
+ )
3574
+ }`,
3575
+ notes: 'Composable that returns all link behavior — `{ ref, handleClick, handleMouseEnter, handleTouchStart, isActive, isExactActive, classes }`. Use when `createLink` is too opinionated (e.g. you need a `<button>` link, a card-shaped link, or want to compose with another framework primitive). Internals: hover/viewport prefetch via IntersectionObserver, keyboard-modifier guard (Cmd+click opens new tab), active/exact-active path matching, class-string composition. See also: Link, createLink, UseLinkReturn.',
3576
+ mistakes: `- Reading \`link.classes\` as a plain string — it's a \`() => string\` accessor. Call it inside reactive scopes (JSX expression thunks, \`class={link.classes}\`) so the active class updates on route change
3577
+ - Forgetting to wire \`link.ref\` to the root element under \`prefetch="viewport"\` — without the ref the IntersectionObserver has nothing to observe; viewport-based prefetch never fires
3578
+ - Calling \`link.handleClick(e)\` synchronously in the component body — handlers are meant to be JSX event props (\`onClick={link.handleClick}\`); synchronous invocation in the render body triggers \`router.push\` during render which the lint rule \`no-imperative-navigate-in-render\` flags
3579
+ - Mixing \`useLink\` + a router instance from a different \`RouterProvider\` — \`useLink\` reads the nearest router context; multi-router apps need explicit context boundaries
3580
+ - Treating \`useLink\` as setup-only (calling it conditionally inside an effect) — like all hooks, call it at the top of the component body. The ref / handlers are stable across re-renders
3581
+ - Forgetting that \`external: true\` bypasses the click handler entirely — \`useLink\` still returns handlers but \`handleClick\`'s body short-circuits when \`props.external\` is true; the wrapped element should let the native anchor \`target="_blank"\` semantics handle the rest`,
3582
+ },
3583
+
3584
+ 'zero/createLink': {
3585
+ signature: 'function createLink(Component: (p: LinkRenderProps) => any): (props: LinkProps) => any',
3586
+ example: `import { createLink } from '@pyreon/zero/link'
3587
+
3588
+ const ButtonLink = createLink((props) => (
3589
+ <button
3590
+ ref={props.ref}
3591
+ class={props.class}
3592
+ onClick={props.onClick}
3593
+ onMouseEnter={props.onMouseEnter}
3594
+ >
3595
+ {props.children}
3596
+ </button>
3597
+ ))
3598
+
3599
+ <ButtonLink href="/dashboard" activeClass="active">Dashboard</ButtonLink>`,
3600
+ notes: 'HOC that wraps any component with link behavior. The wrapped component receives `LinkRenderProps` with all handlers + state pre-wired (`href`, `ref`, `onClick`, `onMouseEnter`, `onTouchStart`, `isActive`, `isExactActive`, `class`, `target`, `rel`). Use this to build styled link variants (button-links, card-links, design-system anchors) without re-implementing the prefetch + active-state machine. See also: Link, useLink, LinkRenderProps.',
3601
+ mistakes: `- Not forwarding \`props.ref\` to the rendered element — the prefetch IntersectionObserver and active-state observer both need a real DOM ref to attach to
3602
+ - Calling the user-provided \`props.class\` as a function in JSX (\`class={props.class()}\`) — \`class\` is a string-or-accessor union; pass it directly (\`class={props.class}\`) and let the renderer call it if needed
3603
+ - Forgetting \`onTouchStart\` — mobile devices don't fire mouseenter; without \`onTouchStart\` mobile users get no prefetch benefit
3604
+ - Re-rendering the wrapped component on every router event — the HOC calls \`useLink\` ONCE per component instance, returns stable handlers, and the route signal is reactive at the granularity of \`isActive\` / \`classes\`. Don't memoize the wrapper output manually
3605
+ - Building separate wrappers for \`<button>\` vs \`<a>\` vs \`<div>\` instead of having ONE styled wrapper that accepts a \`tag\` prop — \`createLink\` only handles the link logic; the rendered tag choice is the consumer's structural decision
3606
+ - Expecting \`createLink\` to handle \`external: true\` semantics on a non-anchor component — \`target\` and \`rel\` are forwarded as RenderProps but \`<button target="_blank">\` does nothing; for external links rendered as buttons, the consumer must handle \`window.open()\` explicitly`,
3607
+ },
3608
+
3609
+ 'zero/prefetchRoute': {
3610
+ signature: 'function prefetchRoute(href: string): void',
3611
+ example: `import { prefetchRoute } from '@pyreon/zero/link'
3612
+
3613
+ // On user hovering a card, prefetch the linked route's chunk
3614
+ <Card onMouseEnter={() => prefetchRoute('/posts/' + post.id)}>...</Card>`,
3615
+ notes: `Imperatively prefetch a route's JS chunk by injecting \`<link rel="prefetch">\` + \`<link rel="modulepreload">\` into \`document.head\`. Deduplicates — calling twice with the same \`href\` is a no-op. Backed by an LRU cache (MAX 200 entries) that evicts oldest entries AND removes their DOM nodes to prevent head-bloat across long SPA sessions. See also: Link, useLink.`,
3616
+ },
3617
+
3618
+ 'zero/Image': {
3619
+ signature: '<Image src={url} alt={alt} width={w} height={h} priority={false} loading="lazy" placeholder={blurUrl} />',
3620
+ example: `import { Image } from '@pyreon/zero/image'
3621
+ import hero from './hero.jpg?optimize'
3622
+
3623
+ // With imagePlugin — spreads optimized srcset + formats + dimensions
3624
+ <Image {...hero} alt="Hero" priority />
3625
+
3626
+ // Manual
3627
+ <Image src="/hero.jpg" alt="Hero" width={1200} height={630} />
3628
+
3629
+ // Raw mode — skip all optimization wrappers (custom layout)
3630
+ <Image src="/bg.jpg" alt="" width={400} height={300} raw />`,
3631
+ notes: 'Default optimized image — lazy loading via IntersectionObserver, automatic width/height for CLS prevention, responsive srcset, multi-format via `<picture>`, blur-up placeholder, `fetchPriority="high"` for LCP images. Built on `createImage` so consumers can layer rocketstyle / custom wrappers on top via `createImage(MyStyledImage)` without losing the optimization pipeline. The `raw: true` escape hatch returns a bare `<img>` (no container, no lazy load, no aspect-ratio enforcement). See also: useImage, createImage, ImageProps, ImageRenderProps.',
3632
+ mistakes: `- Forgetting \`width\` + \`height\` — both are REQUIRED for CLS prevention. The \`aspect-ratio\` CSS is computed from these; omitting them produces layout shift when the image loads
3633
+ - Setting \`priority\` on below-the-fold images — \`priority\` disables lazy loading AND adds \`fetchPriority="high"\`. Reserve it for the LCP image only (typically the hero)
3634
+ - Setting \`loading="eager"\` AND \`priority\` — they're redundant; \`priority\` already implies eager. Pick one (\`priority\` is the LCP-marker; \`loading="eager"\` is the no-priority eager hint)
3635
+ - Using \`placeholder\` as a full-resolution image — it should be a tiny base64 data URI or a /placeholder.jpg (~1-2 KB). Large placeholders defeat the purpose by blocking initial paint
3636
+ - Spreading \`imagePlugin\` output (\`{...hero}\`) WITHOUT \`alt\` — \`alt\` is required for accessibility AND not auto-derived by the plugin. The TypeScript type enforces this
3637
+ - Wrapping \`<Image>\` in a \`<picture>\` manually for WebP/AVIF — \`formats\` already does this via \`imagePlugin\`. Manual \`<picture>\` defeats the optimization`,
3638
+ },
3639
+
3640
+ 'zero/useImage': {
3641
+ signature: 'function useImage(props: ImageProps): UseImageReturn',
3642
+ example: `import { useImage } from '@pyreon/zero/image'
3643
+
3644
+ function FigureImage(props: ImageProps) {
3645
+ const img = useImage(props)
3646
+ return (
3647
+ <figure ref={img.containerRef} style={img.containerStyle}>
3648
+ <img
3649
+ src={img.src}
3650
+ srcSet={img.srcSet}
3651
+ sizes={img.sizes}
3652
+ alt={props.alt}
3653
+ width={props.width}
3654
+ height={props.height}
3655
+ loading={img.loading}
3656
+ onLoad={img.handleLoad}
3657
+ style={img.imageStyle}
3658
+ />
3659
+ <figcaption>{props.alt}</figcaption>
3660
+ </figure>
3661
+ )
3662
+ }`,
3663
+ notes: `Composable that returns resolved image attributes + signals — \`{ containerRef, inView, loaded, src, srcSet, sizes, aspectRatio, containerStyle, imageStyle, placeholderStyle, loading, fetchPriority, handleLoad, formats, hasFormats }\`. Use for full control when \`createImage\`'s default \`<div><img/></div>\` structure is wrong (e.g. \`<figure>\` + \`<figcaption>\`, custom container layouts, overlay elements). Reactive accessors (\`src\`, \`srcSet\`, \`imageStyle\`, \`placeholderStyle\`) re-evaluate on \`inView()\` flip — wire them as JSX expressions for fine-grained updates. See also: Image, createImage, UseImageReturn.`,
3664
+ mistakes: `- Reading \`img.src\` as a plain string — it's a \`() => string\` accessor that returns empty string until \`inView()\` triggers. Pass it as a JSX attribute (\`src={img.src}\`) so the renderer wraps it in a reactive binding
3665
+ - Forgetting to wire \`img.containerRef\` — without the ref, IntersectionObserver has nothing to observe; lazy images never enter view, never load
3666
+ - Calling \`img.handleLoad()\` from your own code — \`handleLoad\` is the \`<img>\`'s \`onLoad\` handler. Wire it as \`onLoad={img.handleLoad}\`; calling it manually marks the image as loaded prematurely (placeholder fades out before the image arrives)
3667
+ - Spreading \`useImage\` return on the \`<img>\` directly (\`<img {...img}/>\`) — most fields aren't \`<img>\` attributes (\`containerRef\`, \`aspectRatio\`, \`imageStyle\`, \`placeholderStyle\`, \`hasFormats\`). Pick the fields you need
3668
+ - Ignoring \`img.hasFormats\` — if \`formats\` is set, you should render a \`<picture>\` with per-format \`<source>\` elements; \`img.srcSet()\` returns empty string under formats mode (the format-specific srcsets live on \`<source>\`)
3669
+ - Treating \`useImage\` as setup-only — like all Pyreon hooks, call it at the top of the component body. The container ref + signals are stable across re-renders`,
3670
+ },
3671
+
3672
+ 'zero/createImage': {
3673
+ signature: 'function createImage(Component: (p: ImageRenderProps) => any): (props: ImageProps) => any',
3674
+ example: `import { createImage } from '@pyreon/zero/image'
3675
+
3676
+ const FigureImage = createImage((props) => (
3677
+ <figure ref={props.containerRef} class={props.class} style={props.containerStyle}>
3678
+ {props.placeholder}
3679
+ {props.image}
3680
+ <figcaption>Caption</figcaption>
3681
+ </figure>
3682
+ ))
3683
+
3684
+ <FigureImage src="/hero.jpg" alt="Hero" width={1200} height={630} placeholder="/blur.jpg" />`,
3685
+ notes: 'HOC that wraps any component with image optimization. The wrapped component receives `ImageRenderProps` with pre-rendered `placeholder` JSX (null when no placeholder set) + pre-rendered `image` JSX (bare `<img>` OR `<picture>` tree depending on formats), the container ref, container styles, and class. Consumer composes those pieces with whatever wrapper element / extra layout (overlay, badge, caption). See also: Image, useImage, ImageRenderProps.',
3686
+ mistakes: `- Forgetting to render \`props.image\` — without it, the actual \`<img>\` never appears in the DOM. The HOC pre-renders the bare \`<img>\` or \`<picture>\` tree; the consumer just needs to place it
3687
+ - Conditionally rendering \`props.placeholder\` — it's already conditional (null when no \`placeholder\` prop set). Always render it; React/Pyreon ignore null children
3688
+ - Forwarding \`props.containerStyle\` to a child instead of the container — the styles (aspect-ratio, position: relative, overflow: hidden) MUST apply to the element holding \`props.containerRef\`. Otherwise CLS prevention breaks AND IntersectionObserver observes the wrong element
3689
+ - Building \`placeholder\` JSX from scratch — \`createImage\` already constructs the blur-up \`<img>\` with the right styles. Just render \`{props.placeholder}\`; don't reach into \`useImage().placeholderStyle()\` manually
3690
+ - Passing \`raw: true\` to a \`createImage\`-wrapped component — \`raw\` short-circuits BEFORE \`createImage\`'s wrapped component runs (returns bare \`<img>\`). The wrapped component never receives \`ImageRenderProps\` in raw mode. Documented as the no-optimization escape hatch
3691
+ - Re-implementing the \`<picture>\` switch — \`props.image\` already handles the formats branch. Wrapping \`props.image\` in another \`<picture>\` produces nested \`<picture>\` which browsers ignore (the outer wins)`,
3692
+ },
3693
+
3694
+ 'zero/Script': {
3695
+ signature: '<Script src={url} strategy="afterHydration" id={uniqueId} async={true} onLoad={cb} onError={cb} />',
3696
+ example: `import { Script } from '@pyreon/zero/script'
3697
+
3698
+ // Load analytics after page is interactive
3699
+ <Script src="https://analytics.example.com/script.js" strategy="onIdle" id="analytics" />
3700
+
3701
+ // Load chat widget when scrolled into view
3702
+ <Script src="/chat-widget.js" strategy="onViewport" />
3703
+
3704
+ // Inline script with deferred execution
3705
+ <Script strategy="afterHydration">{\`console.log("App hydrated!")\`}</Script>`,
3706
+ notes: 'Default optimized third-party script loader. Strategies: `beforeHydration` (in HTML already), `afterHydration` (inject on mount — default), `onIdle` (via `requestIdleCallback`), `onInteraction` (on first click/scroll/keydown/touchstart), `onViewport` (when sentinel enters viewport). Built on `createScript` — consumers can render loading indicators, retry buttons, or analytics-readiness gates via `createScript(MyCustom)` without re-implementing the strategy machine. Returns a 0×0 sentinel `<div>` for `onViewport` strategy, `null` otherwise. See also: useScript, createScript, ScriptProps, ScriptStrategy.',
3707
+ mistakes: `- Setting \`strategy="onInteraction"\` for analytics that needs first-paint metrics — by definition, onInteraction loads AFTER the first user interaction; first-paint metrics from such a script are useless. Use \`onIdle\` for analytics that needs LCP / FCP capture
3708
+ - Forgetting \`id\` for scripts that might mount in multiple places — without \`id\`, dedup doesn't fire and the script loads twice. Always provide \`id\` for analytics / tracking / third-party widgets
3709
+ - Mixing \`src\` + \`children\` — \`children\` is the inline script body; \`src\` is the URL. If BOTH are set, \`src\` wins and \`children\` is ignored (the dom script.src takes precedence). Use one or the other
3710
+ - \`strategy="beforeHydration"\` without actually putting the \`<script>\` in the HTML — beforeHydration is a NO-OP marker; the script must already exist in the SSR-emitted HTML. Use SSR \`<script>\` tag injection in your entry-server, not \`<Script>\`
3711
+ - Setting \`async={false}\` for non-critical scripts — \`async={false}\` blocks parser; reserve for scripts that MUST execute in order (rare for third-party). Default is true
3712
+ - Expecting \`onError\` to fire for inline scripts — only \`src\`-based scripts trigger onerror via the browser. Inline scripts (\`children\`) execute synchronously; runtime exceptions don't propagate to \`onError\``,
3713
+ },
3714
+
3715
+ 'zero/useScript': {
3716
+ signature: 'function useScript(props: ScriptProps): UseScriptReturn',
3717
+ example: `import { useScript } from '@pyreon/zero/script'
3718
+
3719
+ function TrackedScript(props: ScriptProps) {
3720
+ const s = useScript(props)
3721
+ return (
3722
+ <>
3723
+ {() => s.pending() && <Spinner />}
3724
+ {() => s.errored() && <button onClick={() => location.reload()}>Retry</button>}
3725
+ {s.needsSentinel && <div ref={s.sentinelRef} style="width:0;height:0" />}
3726
+ </>
3727
+ )
3728
+ }`,
3729
+ notes: 'Composable returning script load-state signals + sentinel ref — `{ sentinelRef, loaded, errored, pending, needsSentinel, load }`. Reactive signals (`loaded`, `errored`, `pending`) let consumers render loading indicators, retry buttons, or analytics-readiness gates without re-implementing the strategy machine. `needsSentinel` is true ONLY for `onViewport` strategy. `load()` is the imperative escape hatch (strategy normally triggers it; rarely needed). See also: Script, createScript, UseScriptReturn.',
3730
+ mistakes: `- Reading \`s.loaded\` / \`s.errored\` / \`s.pending\` as booleans — they're \`() => boolean\` accessors. Call them inside reactive scopes (JSX thunks, \`effect()\`) so the UI updates when state changes
3731
+ - Forgetting \`s.needsSentinel\` and always rendering a sentinel — non-onViewport strategies don't need one; rendering a div anyway is harmless but reads as wrong
3732
+ - Calling \`s.load()\` in the component body — the strategy already calls it (afterHydration runs it on mount, onInteraction on first interaction, etc.). Manual \`load()\` typically duplicates the request (unless \`id\` is set for dedup)
3733
+ - Wiring \`s.sentinelRef\` to a non-DOM element — IntersectionObserver needs a real Element. A \`null\` or detached ref means viewport-based load never fires
3734
+ - Expecting \`s.pending()\` to start true for \`afterHydration\` — it doesn't. \`afterHydration\` is the synchronous-load strategy; pending only starts true for \`onIdle\` / \`onInteraction\` / \`onViewport\` (where the load is deferred)
3735
+ - Using \`s.errored()\` to suppress retry-on-mount — \`errored\` is set when the script's onerror fires, NOT when a previous mount errored. Multi-mount apps need their own retry budget tracking`,
3736
+ },
3737
+
3738
+ 'zero/createScript': {
3739
+ signature: 'function createScript(Component: (p: ScriptRenderProps) => any): (props: ScriptProps) => any',
3740
+ example: `import { createScript } from '@pyreon/zero/script'
3741
+
3742
+ const StatusScript = createScript((props) => (
3743
+ <div>
3744
+ {() => props.pending() && <span>Loading analytics...</span>}
3745
+ {() => props.errored() && <span>Analytics failed to load</span>}
3746
+ {props.needsSentinel && <div ref={props.sentinelRef} style="width:0;height:0" />}
3747
+ </div>
3748
+ ))
3749
+
3750
+ <StatusScript src="/analytics.js" strategy="onIdle" id="analytics" />`,
3751
+ notes: 'HOC that wraps any component with script load behavior. The wrapped component receives `ScriptRenderProps` with the sentinel ref, load-state signals (`loaded`, `errored`, `pending`), and `needsSentinel` flag. Use this to render loading indicators, retry UI, or analytics-readiness gates around the script load lifecycle. See also: Script, useScript, ScriptRenderProps.',
3752
+ mistakes: `- Always rendering \`<div ref={props.sentinelRef} .../>\` regardless of \`needsSentinel\` — for non-onViewport strategies the ref is \`undefined\`. Gate the sentinel render on \`props.needsSentinel\`
3753
+ - Calling \`props.loaded()\` / \`props.errored()\` / \`props.pending()\` outside reactive scopes — they're accessors; outside JSX thunks they capture the value at setup time and never update
3754
+ - Forgetting that the wrapped component's render output doesn't affect script loading — the script load fires in \`useScript\`'s \`onMount\` regardless of what the wrapped component returns (null, div, fragment). The wrapper is purely a UI surface
3755
+ - Building a custom strategy machine in the wrapped component — the strategy is already resolved by \`useScript\`. The wrapped component just observes the resulting signals
3756
+ - Forwarding \`props.sentinelRef\` to multiple elements — \`useIntersectionObserver\` observes ONE element. Multi-ref forwarding produces undefined behavior (the last-attached element wins)
3757
+ - Expecting the wrapped component to fire \`onLoad\` / \`onError\` — those callbacks are on the \`ScriptProps\` (passed to the OUTER component), not on the wrapped component. The wrapped component reads \`props.loaded()\` / \`props.errored()\` signals to react to the same events`,
3758
+ },
3759
+ // <gen-docs:api-reference:end @pyreon/zero>
2969
3760
  }