@voltro/cli 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/THIRD-PARTY-NOTICES.md +8311 -3318
  3. package/dist/{apiBuild-CSFI8QGq.js → apiBuild-CaPfoWku.js} +11 -5
  4. package/dist/apiBuild-DHtLXYx9.js +2 -0
  5. package/dist/bin.js +1 -1
  6. package/dist/build-D-OnvNMf.js +843 -0
  7. package/dist/{checkCommand-COmqc2cB.js → checkCommand-C5elt0tW.js} +6 -6
  8. package/dist/{checkCommand-2SbqzukH.js → checkCommand-D2ZduVlh.js} +1 -1
  9. package/dist/codegen-BWpt3VgF.js +2 -0
  10. package/dist/{codegen-VF479Cnb.js → codegen-FEk8AZHb.js} +2 -2
  11. package/dist/{codegenCommand-DCdG2JN-.js → codegenCommand-BOiWQ5hz.js} +4 -4
  12. package/dist/{codemodRunner-r7J9lIa7.js → codemodRunner-BjtB2lq6.js} +109 -11
  13. package/dist/{commands-Cc_nV8WI.js → commands-DyxAmhP0.js} +30 -30
  14. package/dist/{dashboardCommand-C-gKvwqh.js → dashboardCommand-BdKTyT13.js} +5 -5
  15. package/dist/{dataCommand-BgpBHnlB.js → dataCommand-Bab9X7s8.js} +2 -2
  16. package/dist/{dbCommand-sHedr-NJ.js → dbCommand-06O2finM.js} +277 -236
  17. package/dist/dbCommand-B1EXBC6f.js +2 -0
  18. package/dist/{dev-CRHoCEiy.js → dev-C6LGF4iY.js} +3006 -2426
  19. package/dist/{dev--A3nsxA3.js → dev-GjJWAYo2.js} +1 -1
  20. package/dist/doctorCommand-B0hX0tdz.js +2 -0
  21. package/dist/{doctorCommand-DtfJ3FA6.js → doctorCommand-etMkflRc.js} +101 -69
  22. package/dist/{dormancyCommand-Drn7o0No.js → dormancyCommand-UwZ1AZzB.js} +1 -1
  23. package/dist/{embeddingsCommand-Z-jO1fWN.js → embeddingsCommand-C70zWHwo.js} +1 -1
  24. package/dist/{envCommand-D4gCrrTZ.js → envCommand-dSyKvRkM.js} +18 -18
  25. package/dist/{evolveCommand-CMROeKeA.js → evolveCommand-CG0_ebO5.js} +2 -2
  26. package/dist/fileConventions-DASGEmj-.js +35 -0
  27. package/dist/{fileTaxonomy-DvDUV9wq.js → fileTaxonomy-B7uxipWS.js} +55 -55
  28. package/dist/fontPipeline-LxIHa1vo.js +2 -0
  29. package/dist/fontPipeline-Tsh8kZfA.js +152 -0
  30. package/dist/frameworkTableAssembly-C_7Z-rMs.js +2 -0
  31. package/dist/{frameworkTableAssembly-w-XnLa3q.js → frameworkTableAssembly-DKx3ba3S.js} +5 -5
  32. package/dist/imagePipeline-B_GVJgm6.js +2 -0
  33. package/dist/imagePipeline-CBZmjT4i.js +127 -0
  34. package/dist/index.js +1 -1
  35. package/dist/{infoCommand-DXM868o_.js → infoCommand-_53iOc_j.js} +1 -1
  36. package/dist/{inspect-CtL_xTbu.js → inspect-Bd8-9wsi.js} +1 -1
  37. package/dist/inspect-CuoDInfZ.js +2 -0
  38. package/dist/{inspectGateHint-BjnFubmH.js → inspectGateHint-4LxkNtrz.js} +1 -1
  39. package/dist/manifestBuild-C4-J1-m_.js +2 -0
  40. package/dist/{manifestBuild-CuU1VrSm.js → manifestBuild-Cqgsx2bM.js} +1 -1
  41. package/dist/{metaCommands-C6RFmF1r.js → metaCommands-Cn2oboG4.js} +8 -2
  42. package/dist/{migrate-D0F-eTlK.js → migrate-Cko9rswM.js} +2 -2
  43. package/dist/{pageConvention-CzUiSbtU.js → pageConvention-C938S8oC.js} +1 -1
  44. package/dist/{privacyCommand-DGdopOI6.js → privacyCommand-DWTQMC6R.js} +2 -2
  45. package/dist/{probeCommand-C9gazU0H.js → probeCommand-DkGGLknv.js} +83 -24
  46. package/dist/{renderModeScan-CcH2X1_D.js → renderModeScan-CUbOeOAg.js} +3 -1
  47. package/dist/{renderProfile-Ck32Fzxr.js → renderProfile-CskIgAfn.js} +2 -2
  48. package/dist/{runtimeTrace-BPQyCmC5.js → runtimeTrace-c0APJz7E.js} +1 -1
  49. package/dist/{sdkgen-Se88ifTd.js → sdkgen-BiQCgIEr.js} +1 -1
  50. package/dist/{serveCommand-DkP3OT0W.js → serveCommand-CueKQgzl.js} +869 -805
  51. package/dist/serveCommand-DsnrVN3U.js +2 -0
  52. package/dist/serveEntry.js +1 -1
  53. package/dist/start-BJzZLbt8.js +3 -0
  54. package/dist/start-ekPan8BT.js +1510 -0
  55. package/dist/startEntry.js +1 -1
  56. package/dist/{staticCommand-BwNEDlSU.js → staticCommand-xlSL-IWk.js} +1 -1
  57. package/dist/{test-f3amja6a.js → test-BWPQcRoB.js} +1 -1
  58. package/dist/updateCommand-Bqql_rsQ.js +2 -0
  59. package/dist/{updateCommand-BMk2e4ky.js → updateCommand-C_8I8Rzo.js} +139 -115
  60. package/dist/webDev-C7jWJ5dX.js +2 -0
  61. package/dist/{webDev-BgWL9gKV.js → webDev-oczpugbx.js} +1455 -922
  62. package/dist/{webhooksCommand-CoIO3jbj.js → webhooksCommand-4SVPDjKg.js} +1 -1
  63. package/package.json +60 -19
  64. package/templates/AGENTS.md +5 -3
  65. package/templates/agent-docs/_index.md +5 -3
  66. package/templates/agent-docs/_manifest.json +20 -4
  67. package/templates/agent-docs/ai.md +2 -2
  68. package/templates/agent-docs/authentication.md +1 -1
  69. package/templates/agent-docs/cli.md +2 -2
  70. package/templates/agent-docs/configuration.md +17 -0
  71. package/templates/agent-docs/data.md +470 -26
  72. package/templates/agent-docs/database/advancedqueries.md +7 -7
  73. package/templates/agent-docs/database/columntypes.md +2 -2
  74. package/templates/agent-docs/database/querying.md +1 -1
  75. package/templates/agent-docs/database/schema.md +1 -1
  76. package/templates/agent-docs/database/seedsdialects.md +1 -1
  77. package/templates/agent-docs/database/transactions.md +3 -3
  78. package/templates/agent-docs/deployment.md +8 -0
  79. package/templates/agent-docs/internationalization.md +2 -2
  80. package/templates/agent-docs/introduction.md +7 -1
  81. package/templates/agent-docs/local-first-mobile.md +102 -4
  82. package/templates/agent-docs/observability.md +2 -0
  83. package/templates/agent-docs/plugins/auth.md +1 -1
  84. package/templates/agent-docs/plugins/comments.md +142 -0
  85. package/templates/agent-docs/plugins/notifications.md +47 -4
  86. package/templates/agent-docs/plugins/presence.md +16 -3
  87. package/templates/agent-docs/plugins/prometheus.md +1 -1
  88. package/templates/agent-docs/plugins/queue.md +129 -0
  89. package/templates/agent-docs/plugins.md +11 -7
  90. package/templates/agent-docs/reference.md +16 -4
  91. package/templates/agent-docs/routing.md +527 -3
  92. package/templates/agent-docs/schema-driven-ui.md +214 -3
  93. package/templates/agent-docs/security.md +3 -3
  94. package/templates/agent-docs/whats-new.md +160 -66
  95. package/templates/apps/api-ai/package.json +6 -6
  96. package/templates/apps/api-auth/package.json +8 -8
  97. package/templates/apps/api-backend/package.json +7 -7
  98. package/templates/apps/api-backend-deactivation/package.json +7 -7
  99. package/templates/apps/api-backend-mail/package.json +8 -8
  100. package/templates/apps/api-backend-mariadb/package.json +9 -9
  101. package/templates/apps/api-backend-sqlite/package.json +8 -8
  102. package/templates/apps/api-backend-storage/package.json +8 -8
  103. package/templates/apps/api-cms/package.json +9 -9
  104. package/templates/apps/api-collab/package.json +8 -8
  105. package/templates/apps/api-data-advanced/package.json +8 -8
  106. package/templates/apps/api-durable/package.json +8 -8
  107. package/templates/apps/api-feature-flags/package.json +9 -9
  108. package/templates/apps/api-governance/package.json +8 -8
  109. package/templates/apps/api-kv/package.json +8 -8
  110. package/templates/apps/api-moderation/package.json +8 -8
  111. package/templates/apps/api-observability/package.json +8 -8
  112. package/templates/apps/api-ratelimit/package.json +8 -8
  113. package/templates/apps/api-rbac/package.json +8 -8
  114. package/templates/apps/api-rest/package.json +7 -7
  115. package/templates/apps/api-row-history/package.json +8 -8
  116. package/templates/apps/api-saas/package.json +10 -10
  117. package/templates/apps/api-saas-starter/package.json +10 -10
  118. package/templates/apps/api-search/package.json +8 -8
  119. package/templates/apps/api-status/package.json +8 -8
  120. package/templates/apps/api-webhooks/package.json +9 -9
  121. package/templates/apps/changelog/app.config.ts +26 -2
  122. package/templates/apps/changelog/content/releases/{0.1.0.mdx → v0-1-0.mdx} +0 -1
  123. package/templates/apps/changelog/content/releases/{0.2.0.mdx → v0-2-0.mdx} +0 -1
  124. package/templates/apps/changelog/package.json +8 -8
  125. package/templates/apps/changelog/src/collections/releases.collection.ts +48 -0
  126. package/templates/apps/changelog/src/globals.d.ts +1 -1
  127. package/templates/apps/changelog/src/locales/de.ts +1 -1
  128. package/templates/apps/changelog/src/locales/en.ts +1 -1
  129. package/templates/apps/changelog/src/pages/[locale]/[slug]/page.tsx +7 -4
  130. package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +13 -41
  131. package/templates/apps/changelog/src/pages/[slug]/page.test.tsx +26 -51
  132. package/templates/apps/changelog/src/pages/[slug]/page.tsx +21 -19
  133. package/templates/apps/changelog/src/pages/page.test.tsx +14 -37
  134. package/templates/apps/changelog/src/pages/page.tsx +18 -12
  135. package/templates/apps/edge-functions/package.json +2 -2
  136. package/templates/apps/frontend-admin/package.json +8 -8
  137. package/templates/apps/frontend-app/package.json +9 -9
  138. package/templates/apps/frontend-auth/package.json +8 -8
  139. package/templates/apps/frontend-blank/package.json +7 -7
  140. package/templates/apps/frontend-cms/package.json +9 -9
  141. package/templates/apps/frontend-collab/package.json +10 -10
  142. package/templates/apps/frontend-contact/package.json +7 -7
  143. package/templates/apps/frontend-dashboard/package.json +7 -7
  144. package/templates/apps/frontend-docs/content/docs/de/guides/first-page.md +4 -0
  145. package/templates/apps/frontend-docs/content/docs/de/intro/getting-started.md +4 -0
  146. package/templates/apps/frontend-docs/content/docs/en/guides/first-page.md +4 -0
  147. package/templates/apps/frontend-docs/content/docs/en/intro/getting-started.md +4 -0
  148. package/templates/apps/frontend-docs/package.json +8 -6
  149. package/templates/apps/frontend-docs/src/collections/docs.collection.ts +21 -0
  150. package/templates/apps/frontend-docs/src/locales/de.ts +0 -5
  151. package/templates/apps/frontend-docs/src/locales/en.ts +0 -5
  152. package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug]/page.tsx +17 -9
  153. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +10 -5
  154. package/templates/apps/frontend-docs/src/pages/[locale]/page.tsx +8 -0
  155. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.test.tsx +53 -15
  156. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.tsx +27 -33
  157. package/templates/apps/frontend-docs/src/pages/page.test.tsx +17 -3
  158. package/templates/apps/frontend-docs/src/pages/page.tsx +16 -12
  159. package/templates/apps/frontend-i18n/package.json +6 -6
  160. package/templates/apps/frontend-landing/package.json +6 -6
  161. package/templates/apps/frontend-portal/package.json +8 -8
  162. package/templates/apps/frontend-saas/package.json +8 -8
  163. package/templates/apps/frontend-spa/package.json +7 -7
  164. package/templates/apps/frontend-ssr/package.json +7 -7
  165. package/templates/apps/frontend-ssr-api/package.json +8 -8
  166. package/templates/apps/frontend-static-blog/content/posts/cms-to-ssg.md +12 -0
  167. package/templates/apps/frontend-static-blog/content/posts/hello-static.md +12 -0
  168. package/templates/apps/frontend-static-blog/content/posts/islands-not-hydration.md +14 -0
  169. package/templates/apps/frontend-static-blog/package.json +8 -6
  170. package/templates/apps/frontend-static-blog/src/collections/posts.collection.ts +27 -0
  171. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug]/page.tsx +7 -4
  172. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +7 -1
  173. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.test.tsx +19 -9
  174. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.tsx +19 -21
  175. package/templates/apps/frontend-static-blog/src/pages/page.test.tsx +18 -5
  176. package/templates/apps/frontend-static-blog/src/pages/page.tsx +21 -14
  177. package/templates/apps/frontend-status/package.json +8 -8
  178. package/templates/apps/mobile-app/package.json +4 -4
  179. package/dist/apiBuild-BYBpL7Pz.js +0 -2
  180. package/dist/build-CPgcMQug.js +0 -793
  181. package/dist/codegen-CctkDO-1.js +0 -2
  182. package/dist/dbCommand-DNb6yeOG.js +0 -2
  183. package/dist/doctorCommand-CqoWA2p5.js +0 -2
  184. package/dist/fileConventions-DOqD3lPS.js +0 -34
  185. package/dist/frameworkTableAssembly-C6ETawPR.js +0 -2
  186. package/dist/inspect-CuGDYES0.js +0 -2
  187. package/dist/manifestBuild-CPjhvM62.js +0 -2
  188. package/dist/serveCommand-DLc-BznW.js +0 -2
  189. package/dist/start-DfL3fOiN.js +0 -3
  190. package/dist/start-jw89Xbqy.js +0 -1339
  191. package/dist/updateCommand-5gFVfK5q.js +0 -2
  192. package/dist/webDev-CZbTsDcH.js +0 -2
  193. package/templates/apps/changelog/scripts/generate-rss.mjs +0 -38
  194. package/templates/apps/changelog/src/lib/releases.ts +0 -21
  195. package/templates/apps/frontend-static-blog/src/content/posts.ts +0 -64
@@ -599,7 +599,7 @@ else — React's render loop still runs to completion in one pass, so a slow
599
599
  render is still a slow render. The lever that matters is `defer()`: it puts a
600
600
  real `<Suspense>` boundary in the tree, which is what lets the server return to
601
601
  the event loop while a slow value is still pending. See
602
- [Deferring slow data](/docs/routing/loaders-and-meta#deferring-slow-data-defer--await).
602
+ [Deferring slow data](/docs/routing/loaders-and-meta#deferring-slow-data-defer).
603
603
 
604
604
  Measured against a real server rendering a page with one 400ms deferred field:
605
605
  first body byte at 7ms, the deferred chunk at 408ms — and a probe firing every
@@ -614,8 +614,11 @@ Two consequences worth knowing:
614
614
  framework hands the URL to React instead, so it goes out `async` at the end
615
615
  of the shell and hydration starts immediately.
616
616
  - **`isr` and `static` are still buffered**, because both produce a stored
617
- artefact rather than a response. That is also why `defer()` is an error on
618
- those modes.
617
+ artefact rather than a response. `defer()` is an error on those modes —
618
+ with ONE exception: an isr page that exports `ppr = true` (partial
619
+ prerendering, below) caches the shell and appends its deferred holes per
620
+ request. On `static` it stays an error even with `ppr` — a static file is
621
+ served by any dumb file host, which cannot append anything.
619
622
 
620
623
  Apps do not call the renderer directly. If you are building your own server on
621
624
  top of `@voltro/web/ssr`, `renderPageToStream` is the entry point — it takes the
@@ -671,6 +674,69 @@ the first visitor's data for everyone. This applies identically under
671
674
  silently serve shared HTML in production. A page whose loader needs the signed-in
672
675
  subject belongs on `renderMode: 'ssr'`.
673
676
 
677
+ ## Partial prerendering (ppr) — cached shell + per-request holes
678
+
679
+ An isr page can combine a **cached, anonymous shell** with **per-request
680
+ dynamic holes**: the shell comes straight out of the cache (or the miss
681
+ render), and the deferred fields stream in behind it on the SAME response —
682
+ personalised, never cached.
683
+
684
+ ```tsx
685
+ export const renderMode = 'isr' as const
686
+ export const revalidate = 60
687
+ export const ppr = true
688
+
689
+ export const loader = ({ headers }) => defer(
690
+ { title: 'Dashboard' }, // SHELL — cached, anonymous
691
+ { greeting: personalGreeting(headers) }, // HOLE — per request, never cached
692
+ )
693
+ ```
694
+
695
+ How it works, and what each half may do:
696
+
697
+ - **The shell render is fail-closed, not merely stripped.** Its loader context
698
+ and `useServerRequest()` snapshot THROW by name when a credential is read
699
+ (`cookie`, `authorization`, `x-voltro-*` headers; any cookie but
700
+ `voltro:locale`): the first request answers with an error naming the read
701
+ and the fix, instead of baking silently-empty subject data into an artefact
702
+ served to everyone. `x-tenant` and `accept-language` stay readable — they
703
+ are cache-key inputs.
704
+ - **A hole is an `async` function.** Its credential reads happen inside the
705
+ promise: on the shell pass they reject harmlessly into the `<Await>`
706
+ fallback; on the per-request hole pass they see the real request. A
707
+ credential read in the EAGER half (or synchronously while building the
708
+ hole promise) is the named error above — that is the fail-closed contract.
709
+ - **Holes reveal through hydration.** The shell carries the `<Await>`
710
+ fallbacks, the registry bootstrap and the deferred-id payload; each hole
711
+ value is appended as an inline settle script the moment its promise
712
+ resolves, and the hydrated `<Await>` renders it. `ppr` therefore requires
713
+ `interactive: 'full'` — `'none'` ships no JS to reveal anything and
714
+ `'islands'` never hydrates the page root; both are refused by name.
715
+ - **The eager half runs twice per request** (shell render on a cache miss,
716
+ hole pass always). That is the cost model on purpose: eager data is the
717
+ cheap, cacheable half; per-subject work belongs in the holes.
718
+ - **Client-side navigation** to a ppr page runs the loader in the browser —
719
+ holes resolve through the client defer path, same `<Await>` markup.
720
+ - **Invalidation is the shell's**: `revalidate`, `cacheInvalidatesOn` and
721
+ on-demand revalidation purge the SHELL entry; holes are never cached, so
722
+ there is nothing to invalidate.
723
+ - **Layout loaders cannot defer on a ppr page** (v1): holes live in the page
724
+ loader; a deferring layout is refused by name.
725
+ - **CSP nonces are refused on ppr exactly as on isr** — the cached shell
726
+ carries inline registry scripts that cannot be per-request-nonce'd. Use
727
+ `renderMode: 'ssr'` for nonce'd pages, or a hash-based policy.
728
+ - **Without JavaScript** (a text crawler, JS disabled) the hole fallbacks
729
+ stay visible — the shell is complete, correct HTML; only the holes remain
730
+ in their pending state. There is deliberately NO "buffer fully for
731
+ crawlers" mode: user-agent sniffing serves different documents to crawlers
732
+ and users, which is the cloaking failure class.
733
+ - **A client that disconnects mid-response** simply stops receiving settle
734
+ scripts; nothing corrupts, the loader's own work completes server-side.
735
+
736
+ The response carries `x-voltro-ppr: shell+holes` next to the usual
737
+ `x-voltro-cache` headers, and the server counts shell serves, hole passes and
738
+ hole latency (see [Observability](/docs/observability/overview)).
739
+
674
740
  ## Tenant-aware ISR
675
741
 
676
742
  For multi-tenant ISR (each tenant gets its own cache entry):
@@ -1474,6 +1540,55 @@ They're plain async functions. They can't call `useSubscription`, `useState`, et
1474
1540
 
1475
1541
  If you need a reactive query (live updates), use `useSubscription` in the component AFTER hydration; for the initial render's data, use the loader.
1476
1542
 
1543
+ ## OG images from a template — `ogImage`
1544
+
1545
+ Declare the page's `og:image` as a satori JSX template and the framework
1546
+ produces the PNG: at BUILD time for `static` pages (hashed into
1547
+ `dist/assets/og/`, tags injected with the absolute `seo.siteUrl`), ON DEMAND
1548
+ for `ssr` pages over a signed route with a cache.
1549
+
1550
+ ```tsx
1551
+ export const ogImage = ({ params, loaderData, locale }: {
1552
+ params: Record<string, string>
1553
+ loaderData: unknown
1554
+ locale: string
1555
+ }) => ({
1556
+ type: 'div',
1557
+ props: {
1558
+ style: {
1559
+ display: 'flex', width: '100%', height: '100%',
1560
+ background: '#0b1220', color: '#fff', fontSize: 72, fontFamily: 'Inter',
1561
+ alignItems: 'center', justifyContent: 'center',
1562
+ },
1563
+ children: `My post ${params['slug'] ?? ''} (${locale})`,
1564
+ },
1565
+ })
1566
+ ```
1567
+
1568
+ `meta` wiring is automatic: `og:image`, `twitter:image` and `twitter:card`
1569
+ land in the head — unless your `meta` already sets `og:image`, which then
1570
+ wins (no duplicate tag for crawlers to pick at random).
1571
+
1572
+ **Preconditions, decided rather than improvised:**
1573
+
1574
+ - **A declared font is REQUIRED** ([Fonts](/docs/routing/fonts)) — satori
1575
+ cannot render text without a font buffer, and there is no bundled default
1576
+ (that would ship a license artifact). Missing font → a named build/boot
1577
+ error with the fix. The renderer uses the ORIGINAL un-subsetted files, so
1578
+ glyphs outside your declared subsets still render.
1579
+ - **`ssr` pages need `VOLTRO_OG_SECRET` in multi-replica deploys.** The
1580
+ render signs the on-demand URL (HMAC over route + params + tenant +
1581
+ locale; tampering answers 403) and a per-boot minted secret only verifies
1582
+ in the process that signed it — behind a load balancer set the env var
1583
+ (same value on every replica), or the deploy boot refuses, loudly. Images
1584
+ are cached in the same backend as the page cache; tenant and locale are
1585
+ part of the key wherever the template uses them.
1586
+ - **Emoji are not supported** — satori renders them only via a per-glyph CDN
1587
+ fetch, which collides with the no-external-requests posture. Use an image
1588
+ in the template instead: a `?image` import's `blurDataURL`/`src`, or any
1589
+ data-URI (`src: \`data:image/png;base64,…\``) inside an `img` element of
1590
+ the template — the standard avatar/logo card works that way.
1591
+
1477
1592
  ## Anti-patterns
1478
1593
 
1479
1594
  - **Calling `ctx.ai.generate(...)` in a loader without timeouts.** Loaders shouldn't take >2s. For slow data, render a Suspense fallback + `useSubscription` after hydration.
@@ -2230,6 +2345,304 @@ the `@source` is misconfigured.
2230
2345
 
2231
2346
 
2232
2347
 
2348
+ ---
2349
+
2350
+ <!-- source: en/routing/assets.md -->
2351
+ ## Images & static assets
2352
+
2353
+ _The build-time image pipeline — ?image imports become srcSet variants (AVIF/WebP + fallback) with inferred dimensions and a blur placeholder; the <Image> primitive, the CDN loader seam, and the sharp setup._
2354
+
2355
+ `<Image>` is the responsive image primitive; the `?image` import suffix is the
2356
+ build-time pipeline behind it. Together they replace next/image: variants and
2357
+ placeholders are produced at build time for static assets, on demand in dev.
2358
+
2359
+ ## The pipeline: `?image` imports
2360
+
2361
+ ```tsx
2362
+ import { Image } from '@voltro/web'
2363
+ import hero from '../assets/hero.jpg?image'
2364
+
2365
+ export default function Page(): React.ReactElement {
2366
+ return <Image src={hero} alt="Team photo" priority />
2367
+ }
2368
+ ```
2369
+
2370
+ The `?image` suffix turns the import into an optimized asset object instead of
2371
+ a URL string: the build encodes every ladder width up to the intrinsic width
2372
+ (640 … 3840, capped) as AVIF + WebP plus a same-family fallback (jpeg/png),
2373
+ hashes the variants into `dist/assets/`, reads the intrinsic `width`/`height`,
2374
+ and inlines a 16px blur placeholder as a data URI. `<Image>` renders it as a
2375
+ `<picture>` with one `<source>` per modern format; dimensions and blur are
2376
+ inferred — the CLS-required `width`/`height` props stop being hand-written for
2377
+ imported assets, and `placeholder="blur"` is the default (opt out with
2378
+ `placeholder="empty"`).
2379
+
2380
+ The suffix is an explicit opt-in **on purpose**: a bare image import keeps
2381
+ Vite's plain hashed-URL semantics, so existing `<img src={imported}>` and CSS
2382
+ references are untouched.
2383
+
2384
+ Add the ambient type once per app (`src/voltro-image.d.ts`):
2385
+
2386
+ ```ts
2387
+ declare module '*?image' {
2388
+ const asset: {
2389
+ readonly src: string
2390
+ readonly width: number
2391
+ readonly height: number
2392
+ readonly blurDataURL: string
2393
+ readonly srcSet: string
2394
+ readonly sources: ReadonlyArray<{ readonly type: string; readonly srcSet: string }>
2395
+ }
2396
+ export default asset
2397
+ }
2398
+ ```
2399
+
2400
+ ## Dev vs build vs start
2401
+
2402
+ - **`voltro build`** encodes variants into `dist/assets/` under content
2403
+ hashes. The transforms run through a persistent cache
2404
+ (`.framework/image-cache/`), so the second build re-encodes nothing — 500
2405
+ posts × 8 widths × 2 formats is a one-time cost.
2406
+ - **`voltro dev`** serves transforms on demand from
2407
+ `/_voltro/image/<assetId>` (same cache). The endpoint answers ONLY for
2408
+ assets registered by an actual `?image` import — a free path parameter
2409
+ would be a dev file-read surface.
2410
+ - **`voltro start`** serves build artifacts only. There is deliberately no
2411
+ production transform endpoint — no transform-DoS surface. This is a
2412
+ documented dev/prod divergence.
2413
+
2414
+ ## Configuration
2415
+
2416
+ ```ts
2417
+ // app.config.ts (web)
2418
+ export default {
2419
+ type: 'web' as const,
2420
+ name: 'MyApp',
2421
+ images: {
2422
+ formats: ['avif', 'webp'], // modern formats, in <source> order (default)
2423
+ quality: 75, // encode quality for every variant (default)
2424
+ },
2425
+ }
2426
+ ```
2427
+
2428
+ ## sharp — the native encoder
2429
+
2430
+ The pipeline runs on [sharp](https://sharp.pixelplumbing.com), shipped as an
2431
+ **optional dependency of @voltro/cli** — auto-available in every project,
2432
+ nothing to install. sharp ≥0.33 ships prebuilt binaries as `@img/*` platform
2433
+ packages with no install script, so pnpm 10's build-script approval gate does
2434
+ not apply.
2435
+
2436
+ If your installer **omits optional dependencies**, the platform prebuilds are
2437
+ dropped: sharp resolves but throws on load. The pipeline then serves original
2438
+ images with ONE loud warning naming the fix (`pnpm add -D sharp`, or reinstall
2439
+ without omitting optional deps), and `voltro doctor` distinguishes
2440
+ "not installed" from "installed but binary missing". Never a silent
2441
+ passthrough.
2442
+
2443
+ ## Limits (and the answer for each)
2444
+
2445
+ - **Dynamic `src`** — a URL from `loaderData` or CMS frontmatter cannot be
2446
+ seen at build time. Use the loader seam: `<Image src={url} loader={cdn}>`
2447
+ against your image CDN or the storage plugin's public-serve endpoint (which
2448
+ resizes on the fly). The `quality` prop flows into the loader for exactly
2449
+ this path; for `?image` assets quality is baked at build time from
2450
+ `images.quality`.
2451
+ - **Remote images** — same: loader seam, not the build pipeline.
2452
+ - **Markdown-content images** (a blog's relative references) — copied +
2453
+ hashed by the content pipeline; build-time transformation for those is a
2454
+ named non-goal for now.
2455
+
2456
+ ## `<Image>` without the pipeline
2457
+
2458
+ Everything from before still holds for plain string `src`: lazy loading +
2459
+ async decode by default, `priority` for the LCP image, required
2460
+ `width`/`height` (or `fill`) for CLS, `sizes`, and the pluggable
2461
+ `ImageLoader`/`ImageConfigProvider` seam. See the reference for the full prop
2462
+ table.
2463
+
2464
+
2465
+
2466
+ ---
2467
+
2468
+ <!-- source: en/routing/third-party-scripts.md -->
2469
+ ## Third-party scripts
2470
+
2471
+ _The <Script> component — declared loading strategies (afterInteractive, lazyOnload), process-wide dedupe, remount-safe onLoad, behavior per interactive mode, and the CSP story for client-injected tags._
2472
+
2473
+ `<Script>` loads third-party scripts declaratively instead of hand-rolled
2474
+ `useEffect` + `createElement('script')` blocks — with a decided answer for
2475
+ every mode the page can be in.
2476
+
2477
+ ```tsx
2478
+ import { Script } from '@voltro/web'
2479
+
2480
+ // Analytics after hydration (the default strategy):
2481
+ <Script
2482
+ src="https://eu.i.posthog.com/static/array.js"
2483
+ onLoad={() => {
2484
+ // The hand-written PostHog browser snippet — @voltro/plugin-posthog is a
2485
+ // SERVER-side track sink and ships no browser snippet; this is the
2486
+ // client half, wired the way PostHog's docs describe.
2487
+ const w = window as { posthog?: { init: (key: string, opts: { api_host: string }) => void } }
2488
+ w.posthog?.init('phc_your_project_key', { api_host: 'https://eu.i.posthog.com' })
2489
+ }}
2490
+ />
2491
+
2492
+ // GTM bootstrap — the inline variant (id is REQUIRED: it is the dedupe key):
2493
+ <Script id="gtm-init">{`window.dataLayer = window.dataLayer || []`}</Script>
2494
+
2495
+ // A chat widget nobody needs before the browser is idle:
2496
+ <Script src="https://widget.example.com/loader.js" strategy="lazyOnload" />
2497
+ ```
2498
+
2499
+ ## Strategies
2500
+
2501
+ - **`afterInteractive`** (default) — injected after this component mounts,
2502
+ i.e. after hydration. Never render-blocking; the shell head stays clean.
2503
+ - **`lazyOnload`** — waits for browser idle (`requestIdleCallback`, with a
2504
+ `setTimeout` fallback for Safari).
2505
+
2506
+ There is **no `beforeInteractive`**. The honest alternative for a
2507
+ must-run-first script (a consent manager) is a literal `<script>` tag in the
2508
+ shell head — a `<link rel="preload">` is *not* an answer: it fetches but never
2509
+ executes. And no `worker` strategy (the Partytown class is its own decision).
2510
+
2511
+ ## Dedupe + remount semantics
2512
+
2513
+ Scripts deduplicate **process-wide** by `src` (external) or `id` (inline) —
2514
+ the registry is global, so two `<Script>` tags for one widget produce one
2515
+ request and one execution, even across an islands page's separate bundle.
2516
+
2517
+ A script is **never unloaded**. Navigate away and back and the script does
2518
+ not re-execute and nothing is re-fetched — but `onLoad` **fires again**,
2519
+ answered from the registry (the classic next/script bug where a remounted
2520
+ component's `onLoad` never fires is pinned by test here). `onError` behaves
2521
+ the same for a failed load.
2522
+
2523
+ ## Behavior per `interactive` mode — decided, not accidental
2524
+
2525
+ - **`full`** — as described above.
2526
+ - **`none`** — zero-JS means zero: the app bundle never ships, so a
2527
+ `<Script>` can never inject. The build **warns by name** instead of
2528
+ silently doing nothing.
2529
+ - **`islands`** — outside an island nothing mounts, so a `<Script>` in the
2530
+ page's static part never fires; the build warns. Inside an island it runs
2531
+ when that island hydrates — a `visible` island's script loads when it
2532
+ scrolls into view, which is often exactly the lazy behavior you want.
2533
+
2534
+ ## CSP
2535
+
2536
+ `<Script>` injects client-side, so the per-request nonce your middleware
2537
+ mints ([CSP nonces](/docs/security/production-hardening)) is stamped into
2538
+ server-rendered tags — not into tags created in the browser. The component's
2539
+ answer:
2540
+
2541
+ - An explicit `nonce` prop always wins.
2542
+ - Otherwise the injector propagates the **document's own nonce** (read off an
2543
+ existing nonce'd script element). On an SSR page under a nonce'd CSP the
2544
+ injected tag therefore carries the request's nonce automatically.
2545
+ - On a **`static` page there is no per-request nonce path at all** — the
2546
+ documented options are `'strict-dynamic'` (scripts injected by an
2547
+ allowed/nonce'd bootstrap are permitted, which is exactly this shape) or a
2548
+ hash-based policy.
2549
+
2550
+ The inline variant is covered by the same rules — under a strict CSP an
2551
+ inline snippet needs the nonce or `'strict-dynamic'` like any other injected
2552
+ script.
2553
+
2554
+
2555
+
2556
+ ---
2557
+
2558
+ <!-- source: en/routing/fonts.md -->
2559
+ ## Fonts
2560
+
2561
+ _Self-hosted local fonts — declared once in app.config.ts, delivered as hashed woff2 with @font-face, a size-adjusted fallback face (the CLS guard), a preload link and opt-in subsetting. No font CDN request ever leaves the browser._
2562
+
2563
+ Declare local font files once and the framework does the rest: content-hashed
2564
+ self-hosting, `@font-face` with `font-display`, a **size-adjusted fallback
2565
+ face** so the swap moves nothing, a `<link rel="preload">` in the shell head,
2566
+ and opt-in unicode-range subsetting.
2567
+
2568
+ ```ts
2569
+ // app.config.ts (web)
2570
+ export default {
2571
+ type: 'web' as const,
2572
+ name: 'MyApp',
2573
+ fonts: [{
2574
+ family: 'Inter',
2575
+ src: [
2576
+ { path: 'src/fonts/Inter-Variable.woff2', weight: '100 900' }, // variable range
2577
+ // …or discrete faces — Regular+Bold is any real project's minimum:
2578
+ // { path: 'src/fonts/Inter-400.woff2', weight: 400 },
2579
+ // { path: 'src/fonts/Inter-700.woff2', weight: 700 },
2580
+ // { path: 'src/fonts/Inter-Italic.woff2', weight: 400, style: 'italic' },
2581
+ ],
2582
+ display: 'swap',
2583
+ subsets: ['latin'],
2584
+ fallback: 'Arial',
2585
+ }],
2586
+ }
2587
+ ```
2588
+
2589
+ ```tsx
2590
+ import { localFont } from '@voltro/web'
2591
+
2592
+ const inter = localFont('Inter') // { variable: '--font-inter', fontFamily: 'var(--font-inter)' }
2593
+
2594
+ export default function Page(): React.ReactElement {
2595
+ return <main style={{ fontFamily: inter.fontFamily }}>…</main>
2596
+ }
2597
+ ```
2598
+
2599
+ The shell defines `--font-inter` as `'Inter', 'Inter Fallback', Arial,
2600
+ sans-serif` — use it from any CSS. Every render mode ships the same head tags
2601
+ (the CSS + preload are baked into the ONE generated shell that dev, static
2602
+ prerender, SSR streaming and `voltro start` all serve).
2603
+
2604
+ ## Why the fallback face matters (CLS)
2605
+
2606
+ Until the web font arrives, text renders in the fallback — and a fallback
2607
+ with different metrics reflows the page when the swap happens. The pipeline
2608
+ reads the font file's real metrics (fontkit) and emits an `'Inter Fallback'`
2609
+ face: `local('Arial')` with `size-adjust`, `ascent-override`,
2610
+ `descent-override` and `line-gap-override` computed by the capsize formula so
2611
+ the fallback occupies the SAME space. The swap becomes invisible; CLS ≈ 0.
2612
+
2613
+ ## Subsetting
2614
+
2615
+ `subsets: ['latin']` (and/or `'latin-ext'`) rewrites each face to just that
2616
+ unicode range via `subset-font`, declared with a matching `unicode-range` so
2617
+ the browser only downloads what the page's characters need. Measured in the
2618
+ framework's own e2e: the subset ships at well under half the source size.
2619
+
2620
+ ## GDPR — no font CDN, ever
2621
+
2622
+ Nothing here talks to Google Fonts (or any font host) at runtime — the files
2623
+ ship from YOUR origin, content-hashed and immutable. That is the compliance
2624
+ answer German courts made concrete (LG München, remote Google-Fonts
2625
+ embedding): the user's IP never reaches a font CDN because no request leaves
2626
+ your domain. The framework's e2e asserts exactly that — a full page load with
2627
+ zero foreign-host requests.
2628
+
2629
+ **Getting the files:** there is deliberately no Google-Fonts download helper
2630
+ (license terms differ per family — that step stays yours). The manual path:
2631
+ download the family from fonts.google.com (or the foundry), drop the
2632
+ `woff2`/`ttf` into `src/fonts/`, declare it. Done once, committed with the
2633
+ repo.
2634
+
2635
+ ## Tooling (optional, degrades loudly)
2636
+
2637
+ `fontkit` (metrics) and `subset-font` (subsetting) ship as optional
2638
+ dependencies of @voltro/cli — script-free, nothing to approve. If your
2639
+ installer omits optional dependencies: fonts still self-host with
2640
+ `@font-face` + preload, but the fallback metrics and subsets are skipped —
2641
+ with ONE named warning each, and `voltro doctor` reports which half is
2642
+ missing and the fix. Never a silent downgrade.
2643
+
2644
+
2645
+
2233
2646
  ---
2234
2647
 
2235
2648
  <!-- source: en/routing/middleware.md -->
@@ -2409,3 +2822,114 @@ The file is loaded **once per boot** — it is app code with a stable identity,
2409
2822
  **`voltro dev` therefore RESTARTS when you edit it**, the same way a hard-restart field in `app.config.ts` does, and says so in the log. Once-per-boot is documented, and it is still the rule most easily forgotten — everything else in a dev server hot-reloads, so a sabotaged middleware that changes nothing reads as a hook that was never wired.
2410
2823
 
2411
2824
  It runs on both SSR boot paths, `voltro dev` and `voltro start`, with the cookies written on every response arm.
2825
+
2826
+
2827
+
2828
+ ---
2829
+
2830
+ <!-- source: en/routing/intercepting-routes.md -->
2831
+ ## Intercepting routes
2832
+
2833
+ _Modal-with-URL — a page that opens as an overlay above the still-mounted origin on soft navigation, renders standalone on a hard load, and closes on Back; declared with one page export, no directory grammar._
2834
+
2835
+ An intercepting route is the **modal-with-URL** pattern: navigating from a
2836
+ gallery to a photo opens the photo as an overlay *above the still-mounted
2837
+ gallery* — the URL is the photo's, sharing/reloading it shows the standalone
2838
+ photo page, and Back closes the overlay with the gallery exactly as you left
2839
+ it (typed filters, scroll position, mounted state — nothing re-runs).
2840
+
2841
+ ## Declaring one
2842
+
2843
+ One export on the PAGE, no directory grammar:
2844
+
2845
+ ```tsx
2846
+ // src/pages/photos/[id]/page.tsx
2847
+ export const renderMode = 'ssr' as const
2848
+ export const intercept = { from: '/photos' }
2849
+
2850
+ export const loader = ({ params }) => fetchPhoto(params.id)
2851
+
2852
+ export default function PhotoDetail() {
2853
+ const photo = useLoaderData<Photo>()
2854
+ return <figure>…</figure>
2855
+ }
2856
+ ```
2857
+
2858
+ `from` names one or more ROUTE PATTERNS (`'/photos'`,
2859
+ `['/photos', '/albums/[id]']`). A soft navigation that arrives from one of
2860
+ them renders this page inside a native `<dialog>` overlay; a soft navigation
2861
+ from anywhere else — and every hard load — renders it standalone. That
2862
+ asymmetry is the feature: the same URL is a lightweight preview in context
2863
+ and a full page out of context.
2864
+
2865
+ ## The three paths, precisely
2866
+
2867
+ - **Soft navigation from a `from` route** — the overlay opens. The origin
2868
+ page stays MOUNTED: its state, subscriptions and scroll position are
2869
+ untouched (the router keeps rendering it as the background tree; nothing
2870
+ unmounts, no loader re-runs). Only the modal page's own loader runs —
2871
+ layout loaders do not (the overlay renders the page alone above the
2872
+ background's chrome), and its `Pending` shows *inside* the overlay, never
2873
+ as a full-screen swap.
2874
+ - **Hard load / reload** — standalone, always. The server knows nothing of
2875
+ interception; it renders the page as itself, with its own `meta`. A reload
2876
+ of an open modal deliberately IGNORES the overlay state that survives in
2877
+ `history.state` — the server rendered standalone and hydration must match
2878
+ it.
2879
+ - **Back** — closes the overlay (it is a real history entry). Focus returns
2880
+ to the element that opened it (native `<dialog>` semantics), the body
2881
+ scroll lock releases, and the background — which never went anywhere —
2882
+ needs no restore.
2883
+
2884
+ Nested modals stack: a modal that soft-navigates to another intercepting
2885
+ route (its `from` naming the modal's pattern) opens above it, and Back
2886
+ closes only the topmost.
2887
+
2888
+ ## Navigation blockers hold the Back gesture
2889
+
2890
+ `useBlocker` now guards **popstate** too. Back is a modal's primary close
2891
+ gesture, and before this it bypassed every blocker: the browser moves the
2892
+ URL first, so the router *reverts* the move (`history.go(-delta)`) when a
2893
+ blocker holds it and surfaces `retry`/`reset` as for any blocked navigation.
2894
+ ESC inside the overlay routes through the same path — a dirty form holds
2895
+ both. This is a behaviour CHANGE of a documented hook: a blocker that used
2896
+ to be silently skipped on Back now fires.
2897
+
2898
+ ## Two trees, two query strings
2899
+
2900
+ While an overlay is open the URL carries the MODAL's query. Each tree reads
2901
+ its own: `useSearchParams` in the background keeps decoding the background's
2902
+ query (an open modal cannot reset a filter), and `useSetSearchParams` writes
2903
+ to the calling tree's URL — a background setter never writes onto the
2904
+ modal's URL, and a modal setter (a `?zoom=` tweak) replaces without tearing
2905
+ down its own background.
2906
+
2907
+ ## What it is NOT
2908
+
2909
+ - **Parallel `@slot` routes are a declared non-goal.** Next.js pairs
2910
+ interception with independent slot navigation (`@team`/`@analytics`,
2911
+ per-slot `loading.tsx`/`default.tsx`). Here, dashboard split panes are
2912
+ COMPONENTS in a layout, not a routing concept — this page delivers the
2913
+ intercepting/modal half only.
2914
+ - **Islands / zero-JS pages don't intercept.** Interception is a client
2915
+ router behaviour; `interactive: 'islands'` pages have no SPA navigation
2916
+ and `'none'` ships no JS. Full-hydration pages only.
2917
+ - **Overlays do not View-Transition.** Route transitions apply to route
2918
+ swaps; an overlay opening is a layer change, not a page change (see
2919
+ [Navigation](/docs/routing/navigation)).
2920
+
2921
+ ## Chrome, focus, and styling
2922
+
2923
+ The framework renders the overlay as a native `<dialog>` opened with
2924
+ `showModal()` — focus trap, `::backdrop` and focus restoration come from the
2925
+ platform, and the body scroll is locked while open. It is deliberately
2926
+ unstyled: target `dialog[data-vweb-overlay]` (and `::backdrop`) from your
2927
+ CSS or a kit. The route announcer announces the modal's title on open, as it
2928
+ would any navigation.
2929
+
2930
+ ## Locale-prefixed apps
2931
+
2932
+ `from` matches route patterns literally, so a `[locale]` mirror declares its
2933
+ own: `/de/photos/[id]`'s page re-exports the base page and sets
2934
+ `intercept: { from: '/[locale]/photos' }` — same pattern as the documented
2935
+ searchParams schema re-export.