@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.
- package/CHANGELOG.md +424 -0
- package/THIRD-PARTY-NOTICES.md +8311 -3318
- package/dist/{agentsMd-0l980yhL.js → agentsMd-BaLC10Na.js} +110 -82
- package/dist/agentsMd-DCY1RSs8.js +2 -0
- package/dist/apiBuild-CeUN55uk.js +2 -0
- package/dist/{apiBuild-CSFI8QGq.js → apiBuild-DTWp0S_q.js} +11 -5
- package/dist/bin.js +1 -1
- package/dist/build-D4ygSbnV.js +843 -0
- package/dist/{checkCommand-COmqc2cB.js → checkCommand-Dg1G7Gwd.js} +6 -6
- package/dist/{checkCommand-2SbqzukH.js → checkCommand-L7DTlpIF.js} +1 -1
- package/dist/{cloudCmd-QUXh-b5w.js → cloudCmd-DzKcSYuy.js} +1 -1
- package/dist/{codegen-VF479Cnb.js → codegen-DSLM8Su9.js} +1 -1
- package/dist/codegen-DjgxEOnD.js +2 -0
- package/dist/codegenCommand-CG_Vx4lc.js +41 -0
- package/dist/{codemodRunner-r7J9lIa7.js → codemodRunner-Cd4xkC6u.js} +109 -11
- package/dist/{commands-Cc_nV8WI.js → commands-6Kzi92Np.js} +96 -73
- package/dist/{dashboardCommand-C-gKvwqh.js → dashboardCommand-Cq1PWvI1.js} +5 -5
- package/dist/{dataCommand-BgpBHnlB.js → dataCommand-DYzW8vkv.js} +3 -3
- package/dist/{dbCommand-sHedr-NJ.js → dbCommand-B4NWZtGL.js} +278 -237
- package/dist/dbCommand-CSFWs9ev.js +2 -0
- package/dist/{dev-CRHoCEiy.js → dev-CmuvUKRq.js} +2903 -2306
- package/dist/{dev--A3nsxA3.js → dev-cKUiZZsB.js} +1 -1
- package/dist/{doctorCommand-DtfJ3FA6.js → doctorCommand-DCiFVMtZ.js} +101 -69
- package/dist/doctorCommand-J3qu4E0Y.js +2 -0
- package/dist/{dormancyCommand-Drn7o0No.js → dormancyCommand-w1TrmgYP.js} +1 -1
- package/dist/{embeddingsCommand-Z-jO1fWN.js → embeddingsCommand-CMgPyRTr.js} +1 -1
- package/dist/{envCommand-D4gCrrTZ.js → envCommand-Cyynmcfa.js} +8 -8
- package/dist/{evolveCommand-CMROeKeA.js → evolveCommand-BwvQ8dVH.js} +2 -2
- package/dist/fileConventions-l-RIXbx8.js +36 -0
- package/dist/{fileTaxonomy-DvDUV9wq.js → fileTaxonomy-CbyMQYx_.js} +42 -42
- package/dist/fontPipeline-LxIHa1vo.js +2 -0
- package/dist/fontPipeline-Tsh8kZfA.js +152 -0
- package/dist/{frameworkTableAssembly-w-XnLa3q.js → frameworkTableAssembly-D7LJuALW.js} +5 -5
- package/dist/frameworkTableAssembly-IPD1pUnZ.js +2 -0
- package/dist/imagePipeline-B_GVJgm6.js +2 -0
- package/dist/imagePipeline-CBZmjT4i.js +127 -0
- package/dist/index.js +2 -2
- package/dist/{infoCommand-DXM868o_.js → infoCommand-DlYlUPqs.js} +1 -1
- package/dist/{inspect-CtL_xTbu.js → inspect-Bd8-9wsi.js} +1 -1
- package/dist/inspect-CuoDInfZ.js +2 -0
- package/dist/{inspectGateHint-BjnFubmH.js → inspectGateHint-4LxkNtrz.js} +1 -1
- package/dist/manifestBuild-C4-J1-m_.js +2 -0
- package/dist/{manifestBuild-CuU1VrSm.js → manifestBuild-Cqgsx2bM.js} +1 -1
- package/dist/{metaCommands-C6RFmF1r.js → metaCommands-x7RCi2AF.js} +9 -3
- package/dist/{migrate-D0F-eTlK.js → migrate-BK_Bbx-_.js} +2 -2
- package/dist/mobileCommand-D9O6iq3D.js +428 -0
- package/dist/mobileCommand-DAum7tsG.js +2 -0
- package/dist/{pageConvention-CzUiSbtU.js → pageConvention-CMpfDN6r.js} +1 -1
- package/dist/{privacyCommand-DGdopOI6.js → privacyCommand-BCa2OoZG.js} +1 -1
- package/dist/{probeCommand-C9gazU0H.js → probeCommand-_C0YU207.js} +83 -24
- package/dist/{projectScaffold-EzlErR4E.js → projectScaffold-CJfP-xbT.js} +1 -1
- package/dist/{projectScaffold-B4dmTlwT.js → projectScaffold-CSN0OzBV.js} +2 -2
- package/dist/renderModeScan-43yQ2opo.js +147 -0
- package/dist/{renderProfile-Ck32Fzxr.js → renderProfile-DvrhVJHa.js} +2 -2
- package/dist/{runtimeTrace-BPQyCmC5.js → runtimeTrace-CGWx1Q6l.js} +1 -1
- package/dist/{sdkgen-Se88ifTd.js → sdkgen-CDGHQUFj.js} +1 -1
- package/dist/serveCommand-BiPe8BJm.js +2 -0
- package/dist/{serveCommand-DkP3OT0W.js → serveCommand-Bje09q1v.js} +889 -825
- package/dist/serveEntry.js +1 -1
- package/dist/start-B0bnJgxI.js +3 -0
- package/dist/{start-jw89Xbqy.js → start-Clz-1BHB.js} +633 -455
- package/dist/startEntry.js +1 -1
- package/dist/{staticCommand-BwNEDlSU.js → staticCommand-ey0kYmOT.js} +1 -1
- package/dist/{subcommandNames-DpYs3DXr.js → subcommandNames-CDzfEtKV.js} +3 -3
- package/dist/{templates-BR-fb4SP.js → templates-BTWZkJJT.js} +41 -9
- package/dist/{test-f3amja6a.js → test-D_kW4KMj.js} +1 -1
- package/dist/updateCommand-CIoVDKnj.js +2 -0
- package/dist/{updateCommand-BMk2e4ky.js → updateCommand-CRJlAOaM.js} +139 -115
- package/dist/{webDev-BgWL9gKV.js → webDev-DSI9SOhs.js} +1598 -1028
- package/dist/webDev-DlvZO30c.js +2 -0
- package/dist/{webhooksCommand-CoIO3jbj.js → webhooksCommand-BvzXNHji.js} +1 -1
- package/dist/workspaceDeps-RKEkX92S.js +45 -0
- package/package.json +60 -19
- package/templates/AGENTS.core.md +2 -0
- package/templates/AGENTS.md +8 -4
- package/templates/agent-docs/_index.md +6 -4
- package/templates/agent-docs/_manifest.json +21 -5
- package/templates/agent-docs/ai.md +6 -6
- package/templates/agent-docs/authentication.md +73 -1
- package/templates/agent-docs/cli.md +6 -3
- package/templates/agent-docs/configuration.md +17 -0
- package/templates/agent-docs/data.md +522 -29
- package/templates/agent-docs/database/advancedqueries.md +8 -8
- package/templates/agent-docs/database/columntypes.md +2 -2
- package/templates/agent-docs/database/migrations.md +1 -1
- package/templates/agent-docs/database/querying.md +1 -1
- package/templates/agent-docs/database/schema.md +1 -1
- package/templates/agent-docs/database/seedsdialects.md +65 -3
- package/templates/agent-docs/database/transactions.md +3 -3
- package/templates/agent-docs/deployment.md +8 -0
- package/templates/agent-docs/internationalization.md +4 -2
- package/templates/agent-docs/introduction.md +32 -1
- package/templates/agent-docs/local-first-mobile.md +226 -30
- package/templates/agent-docs/observability.md +5 -1
- package/templates/agent-docs/plugins/atlassian.md +2 -2
- package/templates/agent-docs/plugins/audit.md +2 -2
- package/templates/agent-docs/plugins/auth.md +1 -1
- package/templates/agent-docs/plugins/billing.md +1 -1
- package/templates/agent-docs/plugins/cdc-out.md +8 -3
- package/templates/agent-docs/plugins/comments.md +164 -0
- package/templates/agent-docs/plugins/notifications.md +47 -4
- package/templates/agent-docs/plugins/presence.md +45 -3
- package/templates/agent-docs/plugins/prometheus.md +3 -1
- package/templates/agent-docs/plugins/queue.md +172 -0
- package/templates/agent-docs/plugins.md +17 -13
- package/templates/agent-docs/reference.md +35 -4
- package/templates/agent-docs/routing.md +585 -7
- package/templates/agent-docs/scheduling.md +1 -1
- package/templates/agent-docs/schema-driven-ui.md +226 -4
- package/templates/agent-docs/security.md +3 -3
- package/templates/agent-docs/templates/appshells.md +36 -4
- package/templates/agent-docs/whats-new.md +134 -66
- package/templates/apps/api-ai/package.json +6 -6
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/package.json +8 -8
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/package.json +9 -9
- package/templates/apps/api-collab/README.md +3 -3
- package/templates/apps/api-collab/app.config.ts +1 -1
- package/templates/apps/api-collab/database/schema.ts +12 -8
- package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +1 -1
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +1 -1
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +1 -1
- package/templates/apps/api-collab/package.json +8 -8
- package/templates/apps/api-collab/template.json +1 -1
- package/templates/apps/api-collab/tests/documents.setBody.test.ts +10 -2
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-row-history/package.json +8 -8
- package/templates/apps/api-saas/package.json +11 -10
- package/templates/apps/api-saas-starter/package.json +10 -10
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/app.config.ts +26 -2
- package/templates/apps/changelog/content/releases/{0.1.0.mdx → v0-1-0.mdx} +0 -1
- package/templates/apps/changelog/content/releases/{0.2.0.mdx → v0-2-0.mdx} +0 -1
- package/templates/apps/changelog/package.json +9 -8
- package/templates/apps/changelog/src/collections/releases.collection.ts +48 -0
- package/templates/apps/changelog/src/globals.d.ts +1 -1
- package/templates/apps/changelog/src/locales/de.ts +1 -1
- package/templates/apps/changelog/src/locales/en.ts +1 -1
- package/templates/apps/changelog/src/pages/[locale]/[slug]/page.tsx +7 -4
- package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +13 -41
- package/templates/apps/changelog/src/pages/[slug]/page.test.tsx +26 -51
- package/templates/apps/changelog/src/pages/[slug]/page.tsx +21 -19
- package/templates/apps/changelog/src/pages/page.test.tsx +14 -37
- package/templates/apps/changelog/src/pages/page.tsx +18 -12
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +9 -9
- package/templates/apps/frontend-auth/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-cms/package.json +9 -9
- package/templates/apps/frontend-collab/README.md +43 -24
- package/templates/apps/frontend-collab/app.config.ts +3 -3
- package/templates/apps/frontend-collab/package.json +14 -10
- package/templates/apps/frontend-collab/src/locales/de.ts +1 -2
- package/templates/apps/frontend-collab/src/locales/en.ts +1 -2
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +72 -76
- package/templates/apps/frontend-collab/src/pages/page.tsx +45 -22
- package/templates/apps/frontend-collab/template.json +2 -2
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/content/docs/de/guides/first-page.md +4 -0
- package/templates/apps/frontend-docs/content/docs/de/intro/getting-started.md +4 -0
- package/templates/apps/frontend-docs/content/docs/en/guides/first-page.md +4 -0
- package/templates/apps/frontend-docs/content/docs/en/intro/getting-started.md +4 -0
- package/templates/apps/frontend-docs/package.json +9 -6
- package/templates/apps/frontend-docs/src/collections/docs.collection.ts +21 -0
- package/templates/apps/frontend-docs/src/locales/de.ts +0 -5
- package/templates/apps/frontend-docs/src/locales/en.ts +0 -5
- package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug]/page.tsx +17 -9
- package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +10 -5
- package/templates/apps/frontend-docs/src/pages/[locale]/page.tsx +8 -0
- package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.test.tsx +53 -15
- package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.tsx +27 -33
- package/templates/apps/frontend-docs/src/pages/page.test.tsx +17 -3
- package/templates/apps/frontend-docs/src/pages/page.tsx +16 -12
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/README.md +48 -0
- package/templates/apps/frontend-landing/app.config.ts +28 -0
- package/templates/apps/frontend-landing/package.json +7 -6
- package/templates/apps/frontend-landing/src/assets/hero.jpg +0 -0
- package/templates/apps/frontend-landing/src/fonts/Geist-Variable.woff2 +0 -0
- package/templates/apps/frontend-landing/src/fonts/LICENSE-Geist.txt +92 -0
- package/templates/apps/frontend-landing/src/globals.css +15 -0
- package/templates/apps/frontend-landing/src/globals.d.ts +17 -0
- package/templates/apps/frontend-landing/src/locales/de.ts +3 -2
- package/templates/apps/frontend-landing/src/locales/en.ts +5 -2
- package/templates/apps/frontend-landing/src/pages/page.test.tsx +79 -0
- package/templates/apps/frontend-landing/src/pages/page.tsx +26 -3
- package/templates/apps/frontend-landing/template.json +2 -2
- package/templates/apps/frontend-portal/package.json +8 -8
- package/templates/apps/frontend-saas/package.json +8 -8
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/content/posts/cms-to-ssg.md +12 -0
- package/templates/apps/frontend-static-blog/content/posts/hello-static.md +12 -0
- package/templates/apps/frontend-static-blog/content/posts/islands-not-hydration.md +14 -0
- package/templates/apps/frontend-static-blog/package.json +9 -6
- package/templates/apps/frontend-static-blog/src/collections/posts.collection.ts +27 -0
- package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug]/page.tsx +7 -4
- package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +7 -1
- package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.test.tsx +19 -9
- package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.tsx +19 -21
- package/templates/apps/frontend-static-blog/src/pages/page.test.tsx +18 -5
- package/templates/apps/frontend-static-blog/src/pages/page.tsx +21 -14
- package/templates/apps/frontend-status/package.json +8 -8
- package/templates/apps/mobile-app/package.json +12 -11
- package/templates/apps/mobile-app/src/lib/deeplinks.ts +29 -17
- package/templates/apps/mobile-app/tests/deeplinks.test.ts +16 -0
- package/dist/agentsMd-SDDSkyl4.js +0 -2
- package/dist/apiBuild-BYBpL7Pz.js +0 -2
- package/dist/build-CPgcMQug.js +0 -793
- package/dist/codegen-CctkDO-1.js +0 -2
- package/dist/codegenCommand-DCdG2JN-.js +0 -137
- package/dist/dbCommand-DNb6yeOG.js +0 -2
- package/dist/doctorCommand-CqoWA2p5.js +0 -2
- package/dist/fileConventions-DOqD3lPS.js +0 -34
- package/dist/frameworkTableAssembly-C6ETawPR.js +0 -2
- package/dist/inspect-CuGDYES0.js +0 -2
- package/dist/manifestBuild-CPjhvM62.js +0 -2
- package/dist/renderModeScan-CcH2X1_D.js +0 -120
- package/dist/serveCommand-DLc-BznW.js +0 -2
- package/dist/start-DfL3fOiN.js +0 -3
- package/dist/updateCommand-5gFVfK5q.js +0 -2
- package/dist/webDev-CZbTsDcH.js +0 -2
- package/templates/apps/changelog/scripts/generate-rss.mjs +0 -38
- package/templates/apps/changelog/src/lib/releases.ts +0 -21
- 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-
|
|
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
|
|
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.
|
|
618
|
-
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
|