@voltro/cli 0.52.0 → 0.54.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 (244) hide show
  1. package/CHANGELOG.md +424 -0
  2. package/THIRD-PARTY-NOTICES.md +8311 -3318
  3. package/dist/{agentsMd-0l980yhL.js → agentsMd-BaLC10Na.js} +110 -82
  4. package/dist/agentsMd-DCY1RSs8.js +2 -0
  5. package/dist/apiBuild-CeUN55uk.js +2 -0
  6. package/dist/{apiBuild-CSFI8QGq.js → apiBuild-DTWp0S_q.js} +11 -5
  7. package/dist/bin.js +1 -1
  8. package/dist/build-D4ygSbnV.js +843 -0
  9. package/dist/{checkCommand-COmqc2cB.js → checkCommand-Dg1G7Gwd.js} +6 -6
  10. package/dist/{checkCommand-2SbqzukH.js → checkCommand-L7DTlpIF.js} +1 -1
  11. package/dist/{cloudCmd-QUXh-b5w.js → cloudCmd-DzKcSYuy.js} +1 -1
  12. package/dist/{codegen-VF479Cnb.js → codegen-DSLM8Su9.js} +1 -1
  13. package/dist/codegen-DjgxEOnD.js +2 -0
  14. package/dist/codegenCommand-CG_Vx4lc.js +41 -0
  15. package/dist/{codemodRunner-r7J9lIa7.js → codemodRunner-Cd4xkC6u.js} +109 -11
  16. package/dist/{commands-Cc_nV8WI.js → commands-6Kzi92Np.js} +96 -73
  17. package/dist/{dashboardCommand-C-gKvwqh.js → dashboardCommand-Cq1PWvI1.js} +5 -5
  18. package/dist/{dataCommand-BgpBHnlB.js → dataCommand-DYzW8vkv.js} +3 -3
  19. package/dist/{dbCommand-sHedr-NJ.js → dbCommand-B4NWZtGL.js} +278 -237
  20. package/dist/dbCommand-CSFWs9ev.js +2 -0
  21. package/dist/{dev-CRHoCEiy.js → dev-CmuvUKRq.js} +2903 -2306
  22. package/dist/{dev--A3nsxA3.js → dev-cKUiZZsB.js} +1 -1
  23. package/dist/{doctorCommand-DtfJ3FA6.js → doctorCommand-DCiFVMtZ.js} +101 -69
  24. package/dist/doctorCommand-J3qu4E0Y.js +2 -0
  25. package/dist/{dormancyCommand-Drn7o0No.js → dormancyCommand-w1TrmgYP.js} +1 -1
  26. package/dist/{embeddingsCommand-Z-jO1fWN.js → embeddingsCommand-CMgPyRTr.js} +1 -1
  27. package/dist/{envCommand-D4gCrrTZ.js → envCommand-Cyynmcfa.js} +8 -8
  28. package/dist/{evolveCommand-CMROeKeA.js → evolveCommand-BwvQ8dVH.js} +2 -2
  29. package/dist/fileConventions-l-RIXbx8.js +36 -0
  30. package/dist/{fileTaxonomy-DvDUV9wq.js → fileTaxonomy-CbyMQYx_.js} +42 -42
  31. package/dist/fontPipeline-LxIHa1vo.js +2 -0
  32. package/dist/fontPipeline-Tsh8kZfA.js +152 -0
  33. package/dist/{frameworkTableAssembly-w-XnLa3q.js → frameworkTableAssembly-D7LJuALW.js} +5 -5
  34. package/dist/frameworkTableAssembly-IPD1pUnZ.js +2 -0
  35. package/dist/imagePipeline-B_GVJgm6.js +2 -0
  36. package/dist/imagePipeline-CBZmjT4i.js +127 -0
  37. package/dist/index.js +2 -2
  38. package/dist/{infoCommand-DXM868o_.js → infoCommand-DlYlUPqs.js} +1 -1
  39. package/dist/{inspect-CtL_xTbu.js → inspect-Bd8-9wsi.js} +1 -1
  40. package/dist/inspect-CuoDInfZ.js +2 -0
  41. package/dist/{inspectGateHint-BjnFubmH.js → inspectGateHint-4LxkNtrz.js} +1 -1
  42. package/dist/manifestBuild-C4-J1-m_.js +2 -0
  43. package/dist/{manifestBuild-CuU1VrSm.js → manifestBuild-Cqgsx2bM.js} +1 -1
  44. package/dist/{metaCommands-C6RFmF1r.js → metaCommands-x7RCi2AF.js} +9 -3
  45. package/dist/{migrate-D0F-eTlK.js → migrate-BK_Bbx-_.js} +2 -2
  46. package/dist/mobileCommand-D9O6iq3D.js +428 -0
  47. package/dist/mobileCommand-DAum7tsG.js +2 -0
  48. package/dist/{pageConvention-CzUiSbtU.js → pageConvention-CMpfDN6r.js} +1 -1
  49. package/dist/{privacyCommand-DGdopOI6.js → privacyCommand-BCa2OoZG.js} +1 -1
  50. package/dist/{probeCommand-C9gazU0H.js → probeCommand-_C0YU207.js} +83 -24
  51. package/dist/{projectScaffold-EzlErR4E.js → projectScaffold-CJfP-xbT.js} +1 -1
  52. package/dist/{projectScaffold-B4dmTlwT.js → projectScaffold-CSN0OzBV.js} +2 -2
  53. package/dist/renderModeScan-43yQ2opo.js +147 -0
  54. package/dist/{renderProfile-Ck32Fzxr.js → renderProfile-DvrhVJHa.js} +2 -2
  55. package/dist/{runtimeTrace-BPQyCmC5.js → runtimeTrace-CGWx1Q6l.js} +1 -1
  56. package/dist/{sdkgen-Se88ifTd.js → sdkgen-CDGHQUFj.js} +1 -1
  57. package/dist/serveCommand-BiPe8BJm.js +2 -0
  58. package/dist/{serveCommand-DkP3OT0W.js → serveCommand-Bje09q1v.js} +889 -825
  59. package/dist/serveEntry.js +1 -1
  60. package/dist/start-B0bnJgxI.js +3 -0
  61. package/dist/{start-jw89Xbqy.js → start-Clz-1BHB.js} +633 -455
  62. package/dist/startEntry.js +1 -1
  63. package/dist/{staticCommand-BwNEDlSU.js → staticCommand-ey0kYmOT.js} +1 -1
  64. package/dist/{subcommandNames-DpYs3DXr.js → subcommandNames-CDzfEtKV.js} +3 -3
  65. package/dist/{templates-BR-fb4SP.js → templates-BTWZkJJT.js} +41 -9
  66. package/dist/{test-f3amja6a.js → test-D_kW4KMj.js} +1 -1
  67. package/dist/updateCommand-CIoVDKnj.js +2 -0
  68. package/dist/{updateCommand-BMk2e4ky.js → updateCommand-CRJlAOaM.js} +139 -115
  69. package/dist/{webDev-BgWL9gKV.js → webDev-DSI9SOhs.js} +1598 -1028
  70. package/dist/webDev-DlvZO30c.js +2 -0
  71. package/dist/{webhooksCommand-CoIO3jbj.js → webhooksCommand-BvzXNHji.js} +1 -1
  72. package/dist/workspaceDeps-RKEkX92S.js +45 -0
  73. package/package.json +60 -19
  74. package/templates/AGENTS.core.md +2 -0
  75. package/templates/AGENTS.md +8 -4
  76. package/templates/agent-docs/_index.md +6 -4
  77. package/templates/agent-docs/_manifest.json +21 -5
  78. package/templates/agent-docs/ai.md +6 -6
  79. package/templates/agent-docs/authentication.md +73 -1
  80. package/templates/agent-docs/cli.md +6 -3
  81. package/templates/agent-docs/configuration.md +17 -0
  82. package/templates/agent-docs/data.md +522 -29
  83. package/templates/agent-docs/database/advancedqueries.md +8 -8
  84. package/templates/agent-docs/database/columntypes.md +2 -2
  85. package/templates/agent-docs/database/migrations.md +1 -1
  86. package/templates/agent-docs/database/querying.md +1 -1
  87. package/templates/agent-docs/database/schema.md +1 -1
  88. package/templates/agent-docs/database/seedsdialects.md +65 -3
  89. package/templates/agent-docs/database/transactions.md +3 -3
  90. package/templates/agent-docs/deployment.md +8 -0
  91. package/templates/agent-docs/internationalization.md +4 -2
  92. package/templates/agent-docs/introduction.md +32 -1
  93. package/templates/agent-docs/local-first-mobile.md +226 -30
  94. package/templates/agent-docs/observability.md +5 -1
  95. package/templates/agent-docs/plugins/atlassian.md +2 -2
  96. package/templates/agent-docs/plugins/audit.md +2 -2
  97. package/templates/agent-docs/plugins/auth.md +1 -1
  98. package/templates/agent-docs/plugins/billing.md +1 -1
  99. package/templates/agent-docs/plugins/cdc-out.md +8 -3
  100. package/templates/agent-docs/plugins/comments.md +164 -0
  101. package/templates/agent-docs/plugins/notifications.md +47 -4
  102. package/templates/agent-docs/plugins/presence.md +45 -3
  103. package/templates/agent-docs/plugins/prometheus.md +3 -1
  104. package/templates/agent-docs/plugins/queue.md +172 -0
  105. package/templates/agent-docs/plugins.md +17 -13
  106. package/templates/agent-docs/reference.md +35 -4
  107. package/templates/agent-docs/routing.md +585 -7
  108. package/templates/agent-docs/scheduling.md +1 -1
  109. package/templates/agent-docs/schema-driven-ui.md +226 -4
  110. package/templates/agent-docs/security.md +3 -3
  111. package/templates/agent-docs/templates/appshells.md +36 -4
  112. package/templates/agent-docs/whats-new.md +134 -66
  113. package/templates/apps/api-ai/package.json +6 -6
  114. package/templates/apps/api-auth/package.json +8 -8
  115. package/templates/apps/api-backend/package.json +7 -7
  116. package/templates/apps/api-backend-deactivation/package.json +7 -7
  117. package/templates/apps/api-backend-mail/package.json +8 -8
  118. package/templates/apps/api-backend-mariadb/package.json +9 -9
  119. package/templates/apps/api-backend-sqlite/package.json +8 -8
  120. package/templates/apps/api-backend-storage/package.json +8 -8
  121. package/templates/apps/api-cms/package.json +9 -9
  122. package/templates/apps/api-collab/README.md +3 -3
  123. package/templates/apps/api-collab/app.config.ts +1 -1
  124. package/templates/apps/api-collab/database/schema.ts +12 -8
  125. package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +1 -1
  126. package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +1 -1
  127. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +1 -1
  128. package/templates/apps/api-collab/package.json +8 -8
  129. package/templates/apps/api-collab/template.json +1 -1
  130. package/templates/apps/api-collab/tests/documents.setBody.test.ts +10 -2
  131. package/templates/apps/api-data-advanced/package.json +8 -8
  132. package/templates/apps/api-durable/package.json +8 -8
  133. package/templates/apps/api-feature-flags/package.json +9 -9
  134. package/templates/apps/api-governance/package.json +8 -8
  135. package/templates/apps/api-kv/package.json +8 -8
  136. package/templates/apps/api-moderation/package.json +8 -8
  137. package/templates/apps/api-observability/package.json +8 -8
  138. package/templates/apps/api-ratelimit/package.json +8 -8
  139. package/templates/apps/api-rbac/package.json +8 -8
  140. package/templates/apps/api-rest/package.json +7 -7
  141. package/templates/apps/api-row-history/package.json +8 -8
  142. package/templates/apps/api-saas/package.json +11 -10
  143. package/templates/apps/api-saas-starter/package.json +10 -10
  144. package/templates/apps/api-search/package.json +8 -8
  145. package/templates/apps/api-status/package.json +8 -8
  146. package/templates/apps/api-webhooks/package.json +9 -9
  147. package/templates/apps/changelog/app.config.ts +26 -2
  148. package/templates/apps/changelog/content/releases/{0.1.0.mdx → v0-1-0.mdx} +0 -1
  149. package/templates/apps/changelog/content/releases/{0.2.0.mdx → v0-2-0.mdx} +0 -1
  150. package/templates/apps/changelog/package.json +9 -8
  151. package/templates/apps/changelog/src/collections/releases.collection.ts +48 -0
  152. package/templates/apps/changelog/src/globals.d.ts +1 -1
  153. package/templates/apps/changelog/src/locales/de.ts +1 -1
  154. package/templates/apps/changelog/src/locales/en.ts +1 -1
  155. package/templates/apps/changelog/src/pages/[locale]/[slug]/page.tsx +7 -4
  156. package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +13 -41
  157. package/templates/apps/changelog/src/pages/[slug]/page.test.tsx +26 -51
  158. package/templates/apps/changelog/src/pages/[slug]/page.tsx +21 -19
  159. package/templates/apps/changelog/src/pages/page.test.tsx +14 -37
  160. package/templates/apps/changelog/src/pages/page.tsx +18 -12
  161. package/templates/apps/edge-functions/package.json +2 -2
  162. package/templates/apps/frontend-admin/package.json +8 -8
  163. package/templates/apps/frontend-app/package.json +9 -9
  164. package/templates/apps/frontend-auth/package.json +8 -8
  165. package/templates/apps/frontend-blank/package.json +7 -7
  166. package/templates/apps/frontend-cms/package.json +9 -9
  167. package/templates/apps/frontend-collab/README.md +43 -24
  168. package/templates/apps/frontend-collab/app.config.ts +3 -3
  169. package/templates/apps/frontend-collab/package.json +14 -10
  170. package/templates/apps/frontend-collab/src/locales/de.ts +1 -2
  171. package/templates/apps/frontend-collab/src/locales/en.ts +1 -2
  172. package/templates/apps/frontend-collab/src/pages/page.test.tsx +72 -76
  173. package/templates/apps/frontend-collab/src/pages/page.tsx +45 -22
  174. package/templates/apps/frontend-collab/template.json +2 -2
  175. package/templates/apps/frontend-contact/package.json +7 -7
  176. package/templates/apps/frontend-dashboard/package.json +7 -7
  177. package/templates/apps/frontend-docs/content/docs/de/guides/first-page.md +4 -0
  178. package/templates/apps/frontend-docs/content/docs/de/intro/getting-started.md +4 -0
  179. package/templates/apps/frontend-docs/content/docs/en/guides/first-page.md +4 -0
  180. package/templates/apps/frontend-docs/content/docs/en/intro/getting-started.md +4 -0
  181. package/templates/apps/frontend-docs/package.json +9 -6
  182. package/templates/apps/frontend-docs/src/collections/docs.collection.ts +21 -0
  183. package/templates/apps/frontend-docs/src/locales/de.ts +0 -5
  184. package/templates/apps/frontend-docs/src/locales/en.ts +0 -5
  185. package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug]/page.tsx +17 -9
  186. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +10 -5
  187. package/templates/apps/frontend-docs/src/pages/[locale]/page.tsx +8 -0
  188. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.test.tsx +53 -15
  189. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.tsx +27 -33
  190. package/templates/apps/frontend-docs/src/pages/page.test.tsx +17 -3
  191. package/templates/apps/frontend-docs/src/pages/page.tsx +16 -12
  192. package/templates/apps/frontend-i18n/package.json +6 -6
  193. package/templates/apps/frontend-landing/README.md +48 -0
  194. package/templates/apps/frontend-landing/app.config.ts +28 -0
  195. package/templates/apps/frontend-landing/package.json +7 -6
  196. package/templates/apps/frontend-landing/src/assets/hero.jpg +0 -0
  197. package/templates/apps/frontend-landing/src/fonts/Geist-Variable.woff2 +0 -0
  198. package/templates/apps/frontend-landing/src/fonts/LICENSE-Geist.txt +92 -0
  199. package/templates/apps/frontend-landing/src/globals.css +15 -0
  200. package/templates/apps/frontend-landing/src/globals.d.ts +17 -0
  201. package/templates/apps/frontend-landing/src/locales/de.ts +3 -2
  202. package/templates/apps/frontend-landing/src/locales/en.ts +5 -2
  203. package/templates/apps/frontend-landing/src/pages/page.test.tsx +79 -0
  204. package/templates/apps/frontend-landing/src/pages/page.tsx +26 -3
  205. package/templates/apps/frontend-landing/template.json +2 -2
  206. package/templates/apps/frontend-portal/package.json +8 -8
  207. package/templates/apps/frontend-saas/package.json +8 -8
  208. package/templates/apps/frontend-spa/package.json +7 -7
  209. package/templates/apps/frontend-ssr/package.json +7 -7
  210. package/templates/apps/frontend-ssr-api/package.json +8 -8
  211. package/templates/apps/frontend-static-blog/content/posts/cms-to-ssg.md +12 -0
  212. package/templates/apps/frontend-static-blog/content/posts/hello-static.md +12 -0
  213. package/templates/apps/frontend-static-blog/content/posts/islands-not-hydration.md +14 -0
  214. package/templates/apps/frontend-static-blog/package.json +9 -6
  215. package/templates/apps/frontend-static-blog/src/collections/posts.collection.ts +27 -0
  216. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug]/page.tsx +7 -4
  217. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +7 -1
  218. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.test.tsx +19 -9
  219. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.tsx +19 -21
  220. package/templates/apps/frontend-static-blog/src/pages/page.test.tsx +18 -5
  221. package/templates/apps/frontend-static-blog/src/pages/page.tsx +21 -14
  222. package/templates/apps/frontend-status/package.json +8 -8
  223. package/templates/apps/mobile-app/package.json +12 -11
  224. package/templates/apps/mobile-app/src/lib/deeplinks.ts +29 -17
  225. package/templates/apps/mobile-app/tests/deeplinks.test.ts +16 -0
  226. package/dist/agentsMd-SDDSkyl4.js +0 -2
  227. package/dist/apiBuild-BYBpL7Pz.js +0 -2
  228. package/dist/build-CPgcMQug.js +0 -793
  229. package/dist/codegen-CctkDO-1.js +0 -2
  230. package/dist/codegenCommand-DCdG2JN-.js +0 -137
  231. package/dist/dbCommand-DNb6yeOG.js +0 -2
  232. package/dist/doctorCommand-CqoWA2p5.js +0 -2
  233. package/dist/fileConventions-DOqD3lPS.js +0 -34
  234. package/dist/frameworkTableAssembly-C6ETawPR.js +0 -2
  235. package/dist/inspect-CuGDYES0.js +0 -2
  236. package/dist/manifestBuild-CPjhvM62.js +0 -2
  237. package/dist/renderModeScan-CcH2X1_D.js +0 -120
  238. package/dist/serveCommand-DLc-BznW.js +0 -2
  239. package/dist/start-DfL3fOiN.js +0 -3
  240. package/dist/updateCommand-5gFVfK5q.js +0 -2
  241. package/dist/webDev-CZbTsDcH.js +0 -2
  242. package/templates/apps/changelog/scripts/generate-rss.mjs +0 -38
  243. package/templates/apps/changelog/src/lib/releases.ts +0 -21
  244. package/templates/apps/frontend-static-blog/src/content/posts.ts +0 -64
@@ -245,6 +245,36 @@ export { searchParams } from '../../search/page'
245
245
 
246
246
  One schema, no drift — the mirror page decodes exactly what the original declares.
247
247
 
248
+ **Two scanners read this line, and they do not agree.** The isr refusal above is a
249
+ source scan, and so is the route-builder codegen that brands a route's URL with its
250
+ searchParams type — but they recognise different spellings, which is worth knowing
251
+ before you pick one:
252
+
253
+ | spelling on the mirror page | typed `withQuery` on the mirror route | `isr` + schema refused |
254
+ | --- | --- | --- |
255
+ | `export const searchParams = …` | yes | yes |
256
+ | `export { searchParams } from '../../search/page'` | **no** | yes |
257
+ | `export * from '../../search/page'` | **no** | **no** |
258
+ | `import { searchParams as base } …` + `export const searchParams = base` | yes | yes |
259
+
260
+ The middle two are the ones to watch. A clause re-export still decodes correctly at
261
+ runtime and is still refused on `isr` — but the route builder does not see it, so
262
+ `withQuery` on the mirror's URL falls back to untyped and nothing reports it. A star
263
+ re-export is seen by neither: the binding is on the module at runtime (`export *`
264
+ forwards every named export), so the page behaves as if it declared a schema while
265
+ the `isr` refusal never fires.
266
+
267
+ So: prefer the last row when you want the mirror route's links type-checked, and
268
+ never reach a schema through `export *` on an `isr` page.
269
+
270
+ ```tsx
271
+ // src/pages/[locale]/search/page.tsx — one schema, and both scanners see it
272
+ import { searchParams as base } from '../../search/page'
273
+
274
+ export const searchParams = base
275
+ export { default } from '../../search/page'
276
+ ```
277
+
248
278
  ### Routes without a schema
249
279
 
250
280
  `useSearchParams()` without an argument stays the raw `URLSearchParams` — nothing changes for a route that declares no schema:
@@ -521,7 +551,7 @@ export const renderMode = 'static' as const // 'static' | 'spa' | 'ssr' | 'isr
521
551
  | `ssr` | Every request | Never | Authenticated dashboards, search results, anything cookie-driven |
522
552
  | `isr` | First request after build, then on revalidate | Per-key in-memory or Postgres | News feeds, listings, dashboards that change but not per-user |
523
553
 
524
- Those four are the **complete** set. An unrecognised value is a hard error naming the page — see [What doesn't work](#what-doesnt-work).
554
+ Those four are the **complete** set. An unrecognised value is a hard error naming the page — see [What doesn't work](#what-doesn-t-work).
525
555
 
526
556
  ## static (SSG)
527
557
 
@@ -599,7 +629,7 @@ else — React's render loop still runs to completion in one pass, so a slow
599
629
  render is still a slow render. The lever that matters is `defer()`: it puts a
600
630
  real `<Suspense>` boundary in the tree, which is what lets the server return to
601
631
  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).
632
+ [Deferring slow data](/docs/routing/loaders-and-meta#deferring-slow-data-defer).
603
633
 
604
634
  Measured against a real server rendering a page with one 400ms deferred field:
605
635
  first body byte at 7ms, the deferred chunk at 408ms — and a probe firing every
@@ -614,8 +644,11 @@ Two consequences worth knowing:
614
644
  framework hands the URL to React instead, so it goes out `async` at the end
615
645
  of the shell and hydration starts immediately.
616
646
  - **`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.
647
+ artefact rather than a response. `defer()` is an error on those modes —
648
+ with ONE exception: an isr page that exports `ppr = true` (partial
649
+ prerendering, below) caches the shell and appends its deferred holes per
650
+ request. On `static` it stays an error even with `ppr` — a static file is
651
+ served by any dumb file host, which cannot append anything.
619
652
 
620
653
  Apps do not call the renderer directly. If you are building your own server on
621
654
  top of `@voltro/web/ssr`, `renderPageToStream` is the entry point — it takes the
@@ -671,6 +704,69 @@ the first visitor's data for everyone. This applies identically under
671
704
  silently serve shared HTML in production. A page whose loader needs the signed-in
672
705
  subject belongs on `renderMode: 'ssr'`.
673
706
 
707
+ ## Partial prerendering (ppr) — cached shell + per-request holes
708
+
709
+ An isr page can combine a **cached, anonymous shell** with **per-request
710
+ dynamic holes**: the shell comes straight out of the cache (or the miss
711
+ render), and the deferred fields stream in behind it on the SAME response —
712
+ personalised, never cached.
713
+
714
+ ```tsx
715
+ export const renderMode = 'isr' as const
716
+ export const revalidate = 60
717
+ export const ppr = true
718
+
719
+ export const loader = ({ headers }) => defer(
720
+ { title: 'Dashboard' }, // SHELL — cached, anonymous
721
+ { greeting: personalGreeting(headers) }, // HOLE — per request, never cached
722
+ )
723
+ ```
724
+
725
+ How it works, and what each half may do:
726
+
727
+ - **The shell render is fail-closed, not merely stripped.** Its loader context
728
+ and `useServerRequest()` snapshot THROW by name when a credential is read
729
+ (`cookie`, `authorization`, `x-voltro-*` headers; any cookie but
730
+ `voltro:locale`): the first request answers with an error naming the read
731
+ and the fix, instead of baking silently-empty subject data into an artefact
732
+ served to everyone. `x-tenant` and `accept-language` stay readable — they
733
+ are cache-key inputs.
734
+ - **A hole is an `async` function.** Its credential reads happen inside the
735
+ promise: on the shell pass they reject harmlessly into the `<Await>`
736
+ fallback; on the per-request hole pass they see the real request. A
737
+ credential read in the EAGER half (or synchronously while building the
738
+ hole promise) is the named error above — that is the fail-closed contract.
739
+ - **Holes reveal through hydration.** The shell carries the `<Await>`
740
+ fallbacks, the registry bootstrap and the deferred-id payload; each hole
741
+ value is appended as an inline settle script the moment its promise
742
+ resolves, and the hydrated `<Await>` renders it. `ppr` therefore requires
743
+ `interactive: 'full'` — `'none'` ships no JS to reveal anything and
744
+ `'islands'` never hydrates the page root; both are refused by name.
745
+ - **The eager half runs twice per request** (shell render on a cache miss,
746
+ hole pass always). That is the cost model on purpose: eager data is the
747
+ cheap, cacheable half; per-subject work belongs in the holes.
748
+ - **Client-side navigation** to a ppr page runs the loader in the browser —
749
+ holes resolve through the client defer path, same `<Await>` markup.
750
+ - **Invalidation is the shell's**: `revalidate`, `cacheInvalidatesOn` and
751
+ on-demand revalidation purge the SHELL entry; holes are never cached, so
752
+ there is nothing to invalidate.
753
+ - **Layout loaders cannot defer on a ppr page** (v1): holes live in the page
754
+ loader; a deferring layout is refused by name.
755
+ - **CSP nonces are refused on ppr exactly as on isr** — the cached shell
756
+ carries inline registry scripts that cannot be per-request-nonce'd. Use
757
+ `renderMode: 'ssr'` for nonce'd pages, or a hash-based policy.
758
+ - **Without JavaScript** (a text crawler, JS disabled) the hole fallbacks
759
+ stay visible — the shell is complete, correct HTML; only the holes remain
760
+ in their pending state. There is deliberately NO "buffer fully for
761
+ crawlers" mode: user-agent sniffing serves different documents to crawlers
762
+ and users, which is the cloaking failure class.
763
+ - **A client that disconnects mid-response** simply stops receiving settle
764
+ scripts; nothing corrupts, the loader's own work completes server-side.
765
+
766
+ The response carries `x-voltro-ppr: shell+holes` next to the usual
767
+ `x-voltro-cache` headers, and the server counts shell serves, hole passes and
768
+ hole latency (see [Observability](/docs/observability/overview)).
769
+
674
770
  ## Tenant-aware ISR
675
771
 
676
772
  For multi-tenant ISR (each tenant gets its own cache entry):
@@ -1474,6 +1570,55 @@ They're plain async functions. They can't call `useSubscription`, `useState`, et
1474
1570
 
1475
1571
  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
1572
 
1573
+ ## OG images from a template — `ogImage`
1574
+
1575
+ Declare the page's `og:image` as a satori JSX template and the framework
1576
+ produces the PNG: at BUILD time for `static` pages (hashed into
1577
+ `dist/assets/og/`, tags injected with the absolute `seo.siteUrl`), ON DEMAND
1578
+ for `ssr` pages over a signed route with a cache.
1579
+
1580
+ ```tsx
1581
+ export const ogImage = ({ params, loaderData, locale }: {
1582
+ params: Record<string, string>
1583
+ loaderData: unknown
1584
+ locale: string
1585
+ }) => ({
1586
+ type: 'div',
1587
+ props: {
1588
+ style: {
1589
+ display: 'flex', width: '100%', height: '100%',
1590
+ background: '#0b1220', color: '#fff', fontSize: 72, fontFamily: 'Inter',
1591
+ alignItems: 'center', justifyContent: 'center',
1592
+ },
1593
+ children: `My post ${params['slug'] ?? ''} (${locale})`,
1594
+ },
1595
+ })
1596
+ ```
1597
+
1598
+ `meta` wiring is automatic: `og:image`, `twitter:image` and `twitter:card`
1599
+ land in the head — unless your `meta` already sets `og:image`, which then
1600
+ wins (no duplicate tag for crawlers to pick at random).
1601
+
1602
+ **Preconditions, decided rather than improvised:**
1603
+
1604
+ - **A declared font is REQUIRED** ([Fonts](/docs/routing/fonts)) — satori
1605
+ cannot render text without a font buffer, and there is no bundled default
1606
+ (that would ship a license artifact). Missing font → a named build/boot
1607
+ error with the fix. The renderer uses the ORIGINAL un-subsetted files, so
1608
+ glyphs outside your declared subsets still render.
1609
+ - **`ssr` pages need `VOLTRO_OG_SECRET` in multi-replica deploys.** The
1610
+ render signs the on-demand URL (HMAC over route + params + tenant +
1611
+ locale; tampering answers 403) and a per-boot minted secret only verifies
1612
+ in the process that signed it — behind a load balancer set the env var
1613
+ (same value on every replica), or the deploy boot refuses, loudly. Images
1614
+ are cached in the same backend as the page cache; tenant and locale are
1615
+ part of the key wherever the template uses them.
1616
+ - **Emoji are not supported** — satori renders them only via a per-glyph CDN
1617
+ fetch, which collides with the no-external-requests posture. Use an image
1618
+ in the template instead: a `?image` import's `blurDataURL`/`src`, or any
1619
+ data-URI (`src: \`data:image/png;base64,…\``) inside an `img` element of
1620
+ the template — the standard avatar/logo card works that way.
1621
+
1477
1622
  ## Anti-patterns
1478
1623
 
1479
1624
  - **Calling `ctx.ai.generate(...)` in a loader without timeouts.** Loaders shouldn't take >2s. For slow data, render a Suspense fallback + `useSubscription` after hydration.
@@ -1947,7 +2092,7 @@ export const interactive = 'islands' as const
1947
2092
 
1948
2093
  With `interactive: 'islands'`, the page's HTML is server-rendered and its script tag points at the page's own entry. That entry registers the page's islands, scans for island markers, and hydrates each one on its own schedule — the page component itself never runs in the browser.
1949
2094
 
1950
- Looking for Astro's **"Server Islands"** — per-request-rendered holes in otherwise static pages? In Voltro that is planned as **partial prerendering (PPR)**, not part of islands mode.
2095
+ Looking for Astro's **"Server Islands"** — per-request-rendered holes in otherwise static pages? In Voltro that is [**partial prerendering (PPR)**](/docs/routing/render-modes#partial-prerendering-ppr-cached-shell-per-request-holes): `ppr = true` on an `isr` page, a separate mechanism from islands mode. The two do not combine — ppr reveals its holes through hydration, so it requires `interactive: 'full'`.
1951
2096
 
1952
2097
  ## When to use islands
1953
2098
 
@@ -2230,6 +2375,309 @@ the `@source` is misconfigured.
2230
2375
 
2231
2376
 
2232
2377
 
2378
+ ---
2379
+
2380
+ <!-- source: en/routing/assets.md -->
2381
+ ## Images & static assets
2382
+
2383
+ _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._
2384
+
2385
+ `<Image>` is the responsive image primitive; the `?image` import suffix is the
2386
+ build-time pipeline behind it. Together they replace next/image: variants and
2387
+ placeholders are produced at build time for static assets, on demand in dev.
2388
+
2389
+ ## The pipeline: `?image` imports
2390
+
2391
+ ```tsx
2392
+ import { Image } from '@voltro/web'
2393
+ import hero from '../assets/hero.jpg?image'
2394
+
2395
+ export default function Page(): React.ReactElement {
2396
+ return <Image src={hero} alt="Team photo" priority />
2397
+ }
2398
+ ```
2399
+
2400
+ The `?image` suffix turns the import into an optimized asset object instead of
2401
+ a URL string: the build encodes every ladder width up to the intrinsic width
2402
+ (640 … 3840, capped) as AVIF + WebP plus a same-family fallback (jpeg/png),
2403
+ hashes the variants into `dist/assets/`, reads the intrinsic `width`/`height`,
2404
+ and inlines a 16px blur placeholder as a data URI. `<Image>` renders it as a
2405
+ `<picture>` with one `<source>` per modern format; dimensions and blur are
2406
+ inferred — the CLS-required `width`/`height` props stop being hand-written for
2407
+ imported assets, and `placeholder="blur"` is the default (opt out with
2408
+ `placeholder="empty"`).
2409
+
2410
+ The suffix is an explicit opt-in **on purpose**: a bare image import keeps
2411
+ Vite's plain hashed-URL semantics, so existing `<img src={imported}>` and CSS
2412
+ references are untouched.
2413
+
2414
+ Add the ambient type once per app (`src/voltro-image.d.ts`):
2415
+
2416
+ ```ts
2417
+ declare module '*?image' {
2418
+ const asset: {
2419
+ readonly src: string
2420
+ readonly width: number
2421
+ readonly height: number
2422
+ readonly blurDataURL: string
2423
+ readonly srcSet: string
2424
+ readonly sources: ReadonlyArray<{ readonly type: string; readonly srcSet: string }>
2425
+ }
2426
+ export default asset
2427
+ }
2428
+ ```
2429
+
2430
+ ## Dev vs build vs start
2431
+
2432
+ - **`voltro build`** encodes variants into `dist/assets/` under content
2433
+ hashes. The transforms run through a persistent cache
2434
+ (`.framework/image-cache/`), so the second build re-encodes nothing — 500
2435
+ posts × 8 widths × 2 formats is a one-time cost.
2436
+ - **`voltro dev`** serves transforms on demand from
2437
+ `/_voltro/image/<assetId>` (same cache). The endpoint answers ONLY for
2438
+ assets registered by an actual `?image` import — a free path parameter
2439
+ would be a dev file-read surface.
2440
+ - **`voltro start`** serves build artifacts only. There is deliberately no
2441
+ production transform endpoint — no transform-DoS surface. This is a
2442
+ documented dev/prod divergence.
2443
+
2444
+ ## Configuration
2445
+
2446
+ ```ts
2447
+ // app.config.ts (web)
2448
+ export default {
2449
+ type: 'web' as const,
2450
+ name: 'MyApp',
2451
+ images: {
2452
+ formats: ['avif', 'webp'], // modern formats, in <source> order (default)
2453
+ quality: 75, // encode quality for every variant (default)
2454
+ },
2455
+ }
2456
+ ```
2457
+
2458
+ ## sharp — the native encoder
2459
+
2460
+ The pipeline runs on [sharp](https://sharp.pixelplumbing.com), shipped as an
2461
+ **optional dependency of @voltro/cli** — auto-available in every project,
2462
+ nothing to install. sharp ≥0.33 ships prebuilt binaries as `@img/*` platform
2463
+ packages with no install script, so pnpm 10's build-script approval gate does
2464
+ not apply.
2465
+
2466
+ If your installer **omits optional dependencies**, the platform prebuilds are
2467
+ dropped: sharp resolves but throws on load. The pipeline then serves original
2468
+ images with ONE loud warning naming the fix (`pnpm add -D sharp`, or reinstall
2469
+ without omitting optional deps), and `voltro doctor` distinguishes
2470
+ "not installed" from "installed but binary missing". Never a silent
2471
+ passthrough.
2472
+
2473
+ ## Limits (and the answer for each)
2474
+
2475
+ - **Dynamic `src`** — a URL from `loaderData` or CMS frontmatter cannot be
2476
+ seen at build time. Use the loader seam: `<Image src={url} loader={cdn}>`
2477
+ against your image CDN or the storage plugin's public-serve endpoint (which
2478
+ resizes on the fly). The `quality` prop flows into the loader for exactly
2479
+ this path; for `?image` assets quality is baked at build time from
2480
+ `images.quality`.
2481
+ - **Remote images** — same: loader seam, not the build pipeline.
2482
+ - **Markdown-content images** (a blog's relative references) — copied into
2483
+ `dist/assets/content-media/<hash>.<ext>` by the content pipeline and the
2484
+ `src` rewritten to that URL. A relative source resolves against the markdown
2485
+ file that references it, and one that does not exist FAILS the build naming
2486
+ the path — a page that renders while its image 404s is the outcome this
2487
+ replaces. Absolute (`/…`) and remote sources are left untouched. Build-time
2488
+ TRANSFORMATION (resize / format) stays a named non-goal here: a markdown
2489
+ reference carries no width and no `sizes` to derive one from.
2490
+
2491
+ ## `<Image>` without the pipeline
2492
+
2493
+ Everything from before still holds for plain string `src`: lazy loading +
2494
+ async decode by default, `priority` for the LCP image, required
2495
+ `width`/`height` (or `fill`) for CLS, `sizes`, and the pluggable
2496
+ `ImageLoader`/`ImageConfigProvider` seam. See the reference for the full prop
2497
+ table.
2498
+
2499
+
2500
+
2501
+ ---
2502
+
2503
+ <!-- source: en/routing/third-party-scripts.md -->
2504
+ ## Third-party scripts
2505
+
2506
+ _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._
2507
+
2508
+ `<Script>` loads third-party scripts declaratively instead of hand-rolled
2509
+ `useEffect` + `createElement('script')` blocks — with a decided answer for
2510
+ every mode the page can be in.
2511
+
2512
+ ```tsx
2513
+ import { Script } from '@voltro/web'
2514
+
2515
+ // Analytics after hydration (the default strategy):
2516
+ <Script
2517
+ src="https://eu.i.posthog.com/static/array.js"
2518
+ onLoad={() => {
2519
+ // The hand-written PostHog browser snippet — @voltro/plugin-posthog is a
2520
+ // SERVER-side track sink and ships no browser snippet; this is the
2521
+ // client half, wired the way PostHog's docs describe.
2522
+ const w = window as { posthog?: { init: (key: string, opts: { api_host: string }) => void } }
2523
+ w.posthog?.init('phc_your_project_key', { api_host: 'https://eu.i.posthog.com' })
2524
+ }}
2525
+ />
2526
+
2527
+ // GTM bootstrap — the inline variant (id is REQUIRED: it is the dedupe key):
2528
+ <Script id="gtm-init">{`window.dataLayer = window.dataLayer || []`}</Script>
2529
+
2530
+ // A chat widget nobody needs before the browser is idle:
2531
+ <Script src="https://widget.example.com/loader.js" strategy="lazyOnload" />
2532
+ ```
2533
+
2534
+ ## Strategies
2535
+
2536
+ - **`afterInteractive`** (default) — injected after this component mounts,
2537
+ i.e. after hydration. Never render-blocking; the shell head stays clean.
2538
+ - **`lazyOnload`** — waits for browser idle (`requestIdleCallback`, with a
2539
+ `setTimeout` fallback for Safari).
2540
+
2541
+ There is **no `beforeInteractive`**. The honest alternative for a
2542
+ must-run-first script (a consent manager) is a literal `<script>` tag in the
2543
+ shell head — a `<link rel="preload">` is *not* an answer: it fetches but never
2544
+ executes. And no `worker` strategy (the Partytown class is its own decision).
2545
+
2546
+ ## Dedupe + remount semantics
2547
+
2548
+ Scripts deduplicate **process-wide** by `src` (external) or `id` (inline) —
2549
+ the registry is global, so two `<Script>` tags for one widget produce one
2550
+ request and one execution, even across an islands page's separate bundle.
2551
+
2552
+ A script is **never unloaded**. Navigate away and back and the script does
2553
+ not re-execute and nothing is re-fetched — but `onLoad` **fires again**,
2554
+ answered from the registry (the classic next/script bug where a remounted
2555
+ component's `onLoad` never fires is pinned by test here). `onError` behaves
2556
+ the same for a failed load.
2557
+
2558
+ ## Behavior per `interactive` mode — decided, not accidental
2559
+
2560
+ - **`full`** — as described above.
2561
+ - **`none`** — zero-JS means zero: the app bundle never ships, so a
2562
+ `<Script>` can never inject. The build **warns by name** instead of
2563
+ silently doing nothing.
2564
+ - **`islands`** — outside an island nothing mounts, so a `<Script>` in the
2565
+ page's static part never fires; the build warns. Inside an island it runs
2566
+ when that island hydrates — a `visible` island's script loads when it
2567
+ scrolls into view, which is often exactly the lazy behavior you want.
2568
+
2569
+ ## CSP
2570
+
2571
+ `<Script>` injects client-side, so the per-request nonce your middleware
2572
+ mints ([CSP nonces](/docs/security/production-hardening)) is stamped into
2573
+ server-rendered tags — not into tags created in the browser. The component's
2574
+ answer:
2575
+
2576
+ - An explicit `nonce` prop always wins.
2577
+ - Otherwise the injector propagates the **document's own nonce** (read off an
2578
+ existing nonce'd script element). On an SSR page under a nonce'd CSP the
2579
+ injected tag therefore carries the request's nonce automatically.
2580
+ - On a **`static` page there is no per-request nonce path at all** — the
2581
+ documented options are `'strict-dynamic'` (scripts injected by an
2582
+ allowed/nonce'd bootstrap are permitted, which is exactly this shape) or a
2583
+ hash-based policy.
2584
+
2585
+ The inline variant is covered by the same rules — under a strict CSP an
2586
+ inline snippet needs the nonce or `'strict-dynamic'` like any other injected
2587
+ script.
2588
+
2589
+
2590
+
2591
+ ---
2592
+
2593
+ <!-- source: en/routing/fonts.md -->
2594
+ ## Fonts
2595
+
2596
+ _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._
2597
+
2598
+ Declare local font files once and the framework does the rest: content-hashed
2599
+ self-hosting, `@font-face` with `font-display`, a **size-adjusted fallback
2600
+ face** so the swap moves nothing, a `<link rel="preload">` in the shell head,
2601
+ and opt-in unicode-range subsetting.
2602
+
2603
+ ```ts
2604
+ // app.config.ts (web)
2605
+ export default {
2606
+ type: 'web' as const,
2607
+ name: 'MyApp',
2608
+ fonts: [{
2609
+ family: 'Inter',
2610
+ src: [
2611
+ { path: 'src/fonts/Inter-Variable.woff2', weight: '100 900' }, // variable range
2612
+ // …or discrete faces — Regular+Bold is any real project's minimum:
2613
+ // { path: 'src/fonts/Inter-400.woff2', weight: 400 },
2614
+ // { path: 'src/fonts/Inter-700.woff2', weight: 700 },
2615
+ // { path: 'src/fonts/Inter-Italic.woff2', weight: 400, style: 'italic' },
2616
+ ],
2617
+ display: 'swap',
2618
+ subsets: ['latin'],
2619
+ fallback: 'Arial',
2620
+ }],
2621
+ }
2622
+ ```
2623
+
2624
+ ```tsx
2625
+ import { localFont } from '@voltro/web'
2626
+
2627
+ const inter = localFont('Inter') // { variable: '--font-inter', fontFamily: 'var(--font-inter)' }
2628
+
2629
+ export default function Page(): React.ReactElement {
2630
+ return <main style={{ fontFamily: inter.fontFamily }}>…</main>
2631
+ }
2632
+ ```
2633
+
2634
+ The shell defines `--font-inter` as `'Inter', 'Inter Fallback', Arial,
2635
+ sans-serif` — use it from any CSS. Every render mode ships the same head tags
2636
+ (the CSS + preload are baked into the ONE generated shell that dev, static
2637
+ prerender, SSR streaming and `voltro start` all serve).
2638
+
2639
+ ## Why the fallback face matters (CLS)
2640
+
2641
+ Until the web font arrives, text renders in the fallback — and a fallback
2642
+ with different metrics reflows the page when the swap happens. The pipeline
2643
+ reads the font file's real metrics (fontkit) and emits an `'Inter Fallback'`
2644
+ face: `local('Arial')` with `size-adjust`, `ascent-override`,
2645
+ `descent-override` and `line-gap-override` computed by the capsize formula so
2646
+ the fallback occupies the SAME space. The swap becomes invisible; CLS ≈ 0.
2647
+
2648
+ ## Subsetting
2649
+
2650
+ `subsets: ['latin']` (and/or `'latin-ext'`) rewrites each face to just that
2651
+ unicode range via `subset-font`, declared with a matching `unicode-range` so
2652
+ the browser only downloads what the page's characters need. Measured in the
2653
+ framework's own e2e: the subset ships at well under half the source size.
2654
+
2655
+ ## GDPR — no font CDN, ever
2656
+
2657
+ Nothing here talks to Google Fonts (or any font host) at runtime — the files
2658
+ ship from YOUR origin, content-hashed and immutable. That is the compliance
2659
+ answer German courts made concrete (LG München, remote Google-Fonts
2660
+ embedding): the user's IP never reaches a font CDN because no request leaves
2661
+ your domain. The framework's e2e asserts exactly that — a full page load with
2662
+ zero foreign-host requests.
2663
+
2664
+ **Getting the files:** there is deliberately no Google-Fonts download helper
2665
+ (license terms differ per family — that step stays yours). The manual path:
2666
+ download the family from fonts.google.com (or the foundry), drop the
2667
+ `woff2`/`ttf` into `src/fonts/`, declare it. Done once, committed with the
2668
+ repo.
2669
+
2670
+ ## Tooling (optional, degrades loudly)
2671
+
2672
+ `fontkit` (metrics) and `subset-font` (subsetting) ship as optional
2673
+ dependencies of @voltro/cli — script-free, nothing to approve. If your
2674
+ installer omits optional dependencies: fonts still self-host with
2675
+ `@font-face` + preload, but the fallback metrics and subsets are skipped —
2676
+ with ONE named warning each, and `voltro doctor` reports which half is
2677
+ missing and the fix. Never a silent downgrade.
2678
+
2679
+
2680
+
2233
2681
  ---
2234
2682
 
2235
2683
  <!-- source: en/routing/middleware.md -->
@@ -2317,7 +2765,7 @@ Two boundaries, stated rather than implied:
2317
2765
 
2318
2766
  ## A per-request CSP nonce — `cspNonce`
2319
2767
 
2320
- Return `cspNonce` and the framework stamps `nonce="…"` onto every script tag of that render — the state script, the deferred registry, the shell's bundle tags, and React's own bootstrap/settle scripts (via React's nonce support). The POLICY header stays yours: set it via `responseHeaders`, with the same nonce.
2768
+ Return `cspNonce` and the framework stamps `nonce="…"` onto every script tag of that render — the state script, the deferred registry, the shell's bundle tags, the islands entry, and React's own bootstrap and Suspense scripts (via React's nonce support). The POLICY header stays yours: set it via `responseHeaders`, with the same nonce.
2321
2769
 
2322
2770
  ```ts
2323
2771
  import { randomBytes } from 'node:crypto'
@@ -2338,7 +2786,9 @@ export const csp = defineMiddleware({
2338
2786
  ```
2339
2787
 
2340
2788
  - **`isr` + `cspNonce` refuses the render, loudly.** A cached nonce is a lie the browser enforces — the second visitor gets HTML whose nonce the policy header no longer matches. The ways out: `ssr` for nonce'd pages, or a hash-based CSP for `isr`.
2341
- - The PPR variant of that question is open until partial prerendering exists; a component for client-injected script tags (a `Script` component) is planned.
2789
+ - **`ppr` is refused for the same reason.** A [partial-prerendered](/docs/routing/render-modes#partial-prerendering-ppr-cached-shell-per-request-holes) page serves a cached shell whose inline registry scripts cannot carry a per-request nonce, so `cspNonce` on a `ppr` page is refused by name — same ways out as `isr`.
2790
+ - **Client-injected script tags carry the nonce too.** [`<Script>`](/docs/routing/third-party-scripts) propagates the DOCUMENT's own nonce onto the tag it injects, so a nonce'd `ssr` page needs no explicit `nonce` prop.
2791
+ - **`defer()` does not yet compose with `cspNonce`.** The settle `<script>` each [`<Await>`](/docs/routing/loaders-and-meta) boundary emits inside the streamed body is part of the RENDERED TREE — it is not one React injects, so React's nonce support does not reach it, and it is not in the `<head>` the framework stamps. Under `script-src 'nonce-…'` the browser blocks it, the deferred value is never published to the client registry, and the boundary stays on its fallback after hydration while the server HTML looks correct. Until that is closed, pick one per route: `defer()`, or a nonce'd CSP.
2342
2792
 
2343
2793
  ## `match` — where it runs
2344
2794
 
@@ -2409,3 +2859,131 @@ The file is loaded **once per boot** — it is app code with a stable identity,
2409
2859
  **`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
2860
 
2411
2861
  It runs on both SSR boot paths, `voltro dev` and `voltro start`, with the cookies written on every response arm.
2862
+
2863
+
2864
+
2865
+ ---
2866
+
2867
+ <!-- source: en/routing/intercepting-routes.md -->
2868
+ ## Intercepting routes
2869
+
2870
+ _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._
2871
+
2872
+ An intercepting route is the **modal-with-URL** pattern: navigating from a
2873
+ gallery to a photo opens the photo as an overlay *above the still-mounted
2874
+ gallery* — the URL is the photo's, sharing/reloading it shows the standalone
2875
+ photo page, and Back closes the overlay with the gallery exactly as you left
2876
+ it (typed filters, scroll position, mounted state — nothing re-runs).
2877
+
2878
+ ## Declaring one
2879
+
2880
+ One export on the PAGE, no directory grammar:
2881
+
2882
+ ```tsx
2883
+ // src/pages/photos/[id]/page.tsx
2884
+ export const renderMode = 'ssr' as const
2885
+ export const intercept = { from: '/photos' }
2886
+
2887
+ export const loader = ({ params }) => fetchPhoto(params.id)
2888
+
2889
+ export default function PhotoDetail() {
2890
+ const photo = useLoaderData<Photo>()
2891
+ return <figure>…</figure>
2892
+ }
2893
+ ```
2894
+
2895
+ `from` names one or more ROUTE PATTERNS (`'/photos'`,
2896
+ `['/photos', '/albums/[id]']`). A soft navigation that arrives from one of
2897
+ them renders this page inside a native `<dialog>` overlay; a soft navigation
2898
+ from anywhere else — and every hard load — renders it standalone. That
2899
+ asymmetry is the feature: the same URL is a lightweight preview in context
2900
+ and a full page out of context.
2901
+
2902
+ ## The three paths, precisely
2903
+
2904
+ - **Soft navigation from a `from` route** — the overlay opens. The origin
2905
+ page stays MOUNTED: its state, subscriptions and scroll position are
2906
+ untouched (the router keeps rendering it as the background tree; nothing
2907
+ unmounts, no loader re-runs). Only the modal page's own loader runs —
2908
+ layout loaders do not (the overlay renders the page alone above the
2909
+ background's chrome), and its `Pending` shows *inside* the overlay, never
2910
+ as a full-screen swap.
2911
+ - **Hard load / reload** — standalone, always. The server knows nothing of
2912
+ interception; it renders the page as itself, with its own `meta`. A reload
2913
+ of an open modal deliberately IGNORES the overlay state that survives in
2914
+ `history.state` — the server rendered standalone and hydration must match
2915
+ it.
2916
+ - **Back** — closes the overlay (it is a real history entry). Focus returns
2917
+ to the element that opened it (native `<dialog>` semantics), the body
2918
+ scroll lock releases, and the background — which never went anywhere —
2919
+ needs no restore.
2920
+
2921
+ Nested modals stack: a modal that soft-navigates to another intercepting
2922
+ route (its `from` naming the modal's pattern) opens above it, and Back
2923
+ closes only the topmost.
2924
+
2925
+ ## Navigation blockers hold the Back gesture
2926
+
2927
+ `useBlocker` now guards **popstate** too. Back is a modal's primary close
2928
+ gesture, and before this it bypassed every blocker: the browser moves the
2929
+ URL first, so the router *reverts* the move (`history.go(-delta)`) when a
2930
+ blocker holds it and surfaces `retry`/`reset` as for any blocked navigation.
2931
+ ESC inside the overlay routes through the same path — a dirty form holds
2932
+ both. This is a behaviour CHANGE of a documented hook: a blocker that used
2933
+ to be silently skipped on Back now fires.
2934
+
2935
+ ## Two trees, two query strings
2936
+
2937
+ While an overlay is open the URL carries the MODAL's query. Each tree reads
2938
+ its own: `useSearchParams` in the background keeps decoding the background's
2939
+ query (an open modal cannot reset a filter), and `useSetSearchParams` writes
2940
+ to the calling tree's URL — a background setter never writes onto the
2941
+ modal's URL, and a modal setter (a `?zoom=` tweak) replaces without tearing
2942
+ down its own background.
2943
+
2944
+ ## What it is NOT
2945
+
2946
+ - **Parallel `@slot` routes are a declared non-goal.** Next.js pairs
2947
+ interception with independent slot navigation (`@team`/`@analytics`,
2948
+ per-slot `loading.tsx`/`default.tsx`). Here, dashboard split panes are
2949
+ COMPONENTS in a layout, not a routing concept — this page delivers the
2950
+ intercepting/modal half only.
2951
+ - **Islands / zero-JS pages don't intercept.** Interception is a client
2952
+ router behaviour; `interactive: 'islands'` pages have no SPA navigation
2953
+ and `'none'` ships no JS. Full-hydration pages only.
2954
+ - **Overlays do not View-Transition.** Route transitions apply to route
2955
+ swaps; an overlay opening is a layer change, not a page change (see
2956
+ [Navigation](/docs/routing/navigation)).
2957
+
2958
+ ## Chrome, focus, and styling
2959
+
2960
+ The framework renders the overlay as a native `<dialog>` opened with
2961
+ `showModal()` — focus trap, `::backdrop` and focus restoration come from the
2962
+ platform, and the body scroll is locked while open. It is deliberately
2963
+ unstyled: target `dialog[data-vweb-overlay]` (and `::backdrop`) from your
2964
+ CSS or a kit. The route announcer announces the modal's title on open, as it
2965
+ would any navigation.
2966
+
2967
+ ## Locale-prefixed apps
2968
+
2969
+ `from` matches route patterns literally, so a `[locale]` mirror declares its
2970
+ own: `/de/photos/[id]`'s page re-exports the base page and sets
2971
+ `intercept: { from: '/[locale]/photos' }`.
2972
+
2973
+ **Declares — not re-exports.** `intercept` is read off the page module at
2974
+ runtime, so any re-export forwards it, `export *` included. A mirror that
2975
+ forwards the base page's `intercept` therefore inherits `from: '/photos'`, and
2976
+ `from` is compared against the background's route PATTERN, which for a mirror is
2977
+ `/[locale]/photos`. The two never match, so the overlay silently never opens and
2978
+ the modal renders standalone — no error, no warning, just a page where a modal
2979
+ was expected. This is the opposite failure from the [searchParams
2980
+ re-export](/docs/routing/pages#mirror-routes-share-one-schema), which is silently
2981
+ *lost*; `intercept` is silently *inherited with the wrong pattern*.
2982
+
2983
+ ```tsx
2984
+ // src/pages/[locale]/photos/[id]/page.tsx
2985
+ export { default, meta } from '../../../photos/[id]/page'
2986
+
2987
+ // NOT re-exported: the base's `from` names the un-prefixed pattern.
2988
+ export const intercept = { from: '/[locale]/photos' }
2989
+ ```
@@ -214,7 +214,7 @@ The handler receives a `ScheduleContext` — the same `app` a mutation gets, plu
214
214
  > tenant-scoped** — a schedule runs as `system` with no tenant. Reads see every
215
215
  > tenant's rows, and a write to a `tenant()` table fails with
216
216
  > `TenantScopeViolation` unless you pass `tenantId` explicitly. See
217
- > [below](#a-schedule-runs-as-the-system-subject--no-tenant). This sentence is
217
+ > [below](#a-schedule-runs-as-the-system-subject-no-tenant). This sentence is
218
218
  > here rather than only further down because "same shape as a mutation" is what
219
219
  > sets the expectation that gets violated.
220
220