@voltro/cli 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +297 -0
- package/bin/voltro.mjs +6 -1
- package/dist/{apiBuild-BJtiqoOu.js → apiBuild-CPDTJHkH.js} +2 -2
- package/dist/apiBuild-D1UBJ4TM.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/{commands-8A5RU3MW.js → commands-laJDMj2m.js} +2679 -2444
- package/dist/dbCommand-C3R5LrBZ.js +2 -0
- package/dist/{dbCommand-OPMtO0sX.js → dbCommand-DJSxnjPt.js} +2 -2
- package/dist/{dev-DGjEvvkV.js → dev-BOFxC21E.js} +1076 -1013
- package/dist/{dev-HdG7JdSk.js → dev-DOK0w6ZW.js} +1 -1
- package/dist/{fileConventions-Cu0tM-Bl.js → fileConventions-CBNPZVOV.js} +1 -1
- package/dist/frameworkTableAssembly-DRJrAVr1.js +2 -0
- package/dist/{frameworkTableAssembly-BA2bAmYN.js → frameworkTableAssembly-sg41s14F.js} +1 -1
- package/dist/index.js +1 -1
- package/dist/{inspectMetrics-CYDSO3za.js → inspectMetrics-D1DmLeJs.js} +491 -478
- package/dist/{serveCommand-C3lq3mFd.js → serveCommand-CxcxHc9Y.js} +322 -322
- package/dist/serveEntry.js +2 -2
- package/dist/{start-BtgatJN3.js → start-Clvz4IJb.js} +379 -379
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.core.md +62 -9
- package/templates/AGENTS.md +63 -10
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/authentication.md +48 -0
- package/templates/agent-docs/cli.md +11 -7
- package/templates/agent-docs/database/migrations.md +3 -3
- package/templates/agent-docs/database/querying.md +6 -2
- package/templates/agent-docs/internationalization.md +3 -3
- package/templates/agent-docs/introduction.md +17 -3
- package/templates/agent-docs/reference.md +4 -4
- package/templates/agent-docs/routing.md +42 -40
- package/templates/agent-docs/schema-driven-ui.md +6 -3
- package/templates/agent-docs/templates/appshells.md +24 -24
- package/templates/agent-docs/whats-new.md +170 -65
- package/templates/apps/api-ai/package.json +7 -7
- 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-storage/package.json +8 -8
- 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-saas/package.json +11 -11
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/changelog/src/pages/[locale]/{[slug].page.tsx → [slug]/page.tsx} +4 -4
- package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +2 -2
- package/templates/apps/changelog/src/pages/[locale]/{index.page.tsx → page.tsx} +2 -2
- package/templates/apps/changelog/src/pages/{[slug].page.test.tsx → [slug]/page.test.tsx} +1 -1
- package/templates/apps/changelog/src/pages/{[slug].page.tsx → [slug]/page.tsx} +2 -2
- package/templates/apps/changelog/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-admin/src/pages/(marketing)/{login.page.test.tsx → login/page.test.tsx} +2 -2
- package/templates/apps/frontend-admin/src/pages/(marketing)/{login.page.tsx → login/page.tsx} +2 -2
- package/templates/apps/frontend-admin/src/pages/(marketing)/{index.page.test.tsx → page.test.tsx} +1 -1
- package/templates/apps/frontend-admin/src/pages/admin/{[entity].page.tsx → [entity]/page.tsx} +1 -1
- package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +1 -1
- package/templates/apps/frontend-admin/src/pages/admin/{index.page.test.tsx → page.test.tsx} +1 -1
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-app/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
- package/templates/apps/frontend-app/src/pages/{schema-ui.page.test.tsx → schema-ui/page.test.tsx} +2 -2
- package/templates/apps/frontend-app/src/pages/{schema-ui.page.tsx → schema-ui/page.tsx} +1 -1
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-blank/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-contact/src/pages/[locale]/{index.page.tsx → page.tsx} +2 -2
- package/templates/apps/frontend-contact/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.page.test.tsx → login/page.test.tsx} +2 -2
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.page.tsx → login/page.tsx} +2 -2
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.page.test.tsx → page.test.tsx} +1 -1
- package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.page.test.tsx → page.test.tsx} +1 -1
- package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.page.test.tsx → settings/page.test.tsx} +2 -2
- package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.page.tsx → settings/page.tsx} +2 -2
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-docs/src/pages/[locale]/docs/{[...slug].page.tsx → [...slug]/page.tsx} +4 -4
- package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +2 -2
- package/templates/apps/frontend-docs/src/pages/[locale]/{index.page.tsx → page.tsx} +2 -2
- package/templates/apps/frontend-docs/src/pages/docs/{[...slug].page.test.tsx → [...slug]/page.test.tsx} +3 -3
- package/templates/apps/frontend-docs/src/pages/docs/{[...slug].page.tsx → [...slug]/page.tsx} +1 -1
- package/templates/apps/frontend-docs/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-i18n/src/pages/[locale]/{about.page.tsx → about/page.tsx} +3 -3
- package/templates/apps/frontend-i18n/src/pages/[locale]/mirrors.test.tsx +2 -2
- package/templates/apps/frontend-i18n/src/pages/[locale]/{index.page.tsx → page.tsx} +2 -2
- package/templates/apps/frontend-i18n/src/pages/{about.page.test.tsx → about/page.test.tsx} +3 -3
- package/templates/apps/frontend-i18n/src/pages/{about.page.tsx → about/page.tsx} +1 -1
- package/templates/apps/frontend-i18n/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-landing/src/pages/[locale]/mirrors.test.tsx +1 -1
- package/templates/apps/frontend-landing/src/pages/[locale]/{index.page.tsx → page.tsx} +2 -2
- package/templates/apps/frontend-landing/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-spa/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr/src/pages/{feed.page.test.tsx → feed/page.test.tsx} +2 -2
- package/templates/apps/frontend-ssr/src/pages/{feed.page.tsx → feed/page.tsx} +1 -1
- package/templates/apps/frontend-ssr/src/pages/{feed-swr.page.test.tsx → feed-swr/page.test.tsx} +2 -2
- package/templates/apps/frontend-ssr/src/pages/{feed-swr.page.tsx → feed-swr/page.tsx} +1 -1
- package/templates/apps/frontend-ssr/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-ssr-api/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/{[slug].page.tsx → [slug]/page.tsx} +4 -4
- package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +2 -2
- package/templates/apps/frontend-static-blog/src/pages/[locale]/{index.page.tsx → page.tsx} +2 -2
- package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].page.test.tsx → [slug]/page.test.tsx} +3 -3
- package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].page.tsx → [slug]/page.tsx} +3 -3
- package/templates/apps/frontend-static-blog/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
- package/dist/apiBuild-BOczx9-m.js +0 -2
- package/dist/dbCommand-DNQ6l-su.js +0 -2
- package/dist/frameworkTableAssembly-D1zJSgOU.js +0 -2
- /package/templates/apps/changelog/src/pages/{index.page.tsx → page.tsx} +0 -0
- /package/templates/apps/frontend-admin/src/pages/(marketing)/{index.page.tsx → page.tsx} +0 -0
- /package/templates/apps/frontend-admin/src/pages/admin/{index.page.tsx → page.tsx} +0 -0
- /package/templates/apps/frontend-app/src/pages/{index.page.tsx → page.tsx} +0 -0
- /package/templates/apps/frontend-blank/src/pages/{index.page.tsx → page.tsx} +0 -0
- /package/templates/apps/frontend-contact/src/pages/{index.page.tsx → page.tsx} +0 -0
- /package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.page.tsx → page.tsx} +0 -0
- /package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.page.tsx → page.tsx} +0 -0
- /package/templates/apps/frontend-docs/src/pages/{index.page.tsx → page.tsx} +0 -0
- /package/templates/apps/frontend-i18n/src/pages/{index.page.tsx → page.tsx} +0 -0
- /package/templates/apps/frontend-landing/src/pages/{index.page.tsx → page.tsx} +0 -0
- /package/templates/apps/frontend-spa/src/pages/{index.page.tsx → page.tsx} +0 -0
- /package/templates/apps/frontend-ssr/src/pages/{index.page.tsx → page.tsx} +0 -0
- /package/templates/apps/frontend-ssr-api/src/pages/{index.page.tsx → page.tsx} +0 -0
- /package/templates/apps/frontend-static-blog/src/pages/{index.page.tsx → page.tsx} +0 -0
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
_Voltro's file-based router — pages, layouts, render modes, loaders, navigation, islands. The web side of the framework._
|
|
13
13
|
|
|
14
|
-
The web side of a Voltro app uses **file-based routing**: drop a
|
|
14
|
+
The web side of a Voltro app uses **file-based routing**: drop a `page.tsx` file under `src/pages/`, the CLI discovers it on every boot + save, and the file becomes a route. No router config, no manual `<Route>` declarations, no codegen step.
|
|
15
15
|
|
|
16
16
|
The suffix is what makes a file a route — the directory tree only decides *which URL*. Anything else under `src/pages/` (components, hooks, tests) is ordinary code and gets no URL, so it can live beside the page that uses it.
|
|
17
17
|
|
|
@@ -25,18 +25,18 @@ src/pages/
|
|
|
25
25
|
├── error.tsx # error boundary
|
|
26
26
|
├── loading.tsx # pending UI
|
|
27
27
|
├── not-found.tsx # 404 fallback
|
|
28
|
-
├──
|
|
29
|
-
├── about
|
|
28
|
+
├── page.tsx # /
|
|
29
|
+
├── about/page.tsx # /about
|
|
30
30
|
├── (marketing)/ # route group — no URL segment
|
|
31
31
|
│ ├── layout.tsx # marketing-scoped layout
|
|
32
|
-
│ └── pricing
|
|
32
|
+
│ └── pricing/page.tsx # /pricing
|
|
33
33
|
├── users/
|
|
34
34
|
│ ├── layout.tsx # users-scoped layout
|
|
35
35
|
│ ├── error.tsx # users-scoped error boundary
|
|
36
|
-
│ ├── [id]
|
|
37
|
-
│ └──
|
|
36
|
+
│ ├── [id]/page.tsx # /users/:id
|
|
37
|
+
│ └── page.tsx # /users
|
|
38
38
|
└── docs/
|
|
39
|
-
└── [...slug]
|
|
39
|
+
└── [...slug]/page.tsx # /docs/<anything> (catch-all)
|
|
40
40
|
```
|
|
41
41
|
|
|
42
42
|
That's the whole router. No `<Route>`, no `<Switch>`, no `useRoutes`.
|
|
@@ -52,7 +52,7 @@ That's the whole router. No `<Route>`, no `<Switch>`, no `useRoutes`.
|
|
|
52
52
|
|
|
53
53
|
## Architecture in one paragraph
|
|
54
54
|
|
|
55
|
-
The framework generates a `.framework/app.tsx` on every boot that imports each
|
|
55
|
+
The framework generates a `.framework/app.tsx` on every boot that imports each `page.tsx` file under `src/pages/` (excluding `node_modules`), wraps them in their layout chains, and produces a `<Router routes={…} />` element. A `mount(App, { group })` call in `.framework/main.tsx` mounts it via `react-dom/client`'s `createRoot` (or `hydrateRoot` for SSR pages). The router watches `window.location` + intercepts `<Link>` clicks for client-side nav.
|
|
56
56
|
|
|
57
57
|
You don't write any of this. The CLI regenerates it on every save in dev; the build pipeline freezes it for production.
|
|
58
58
|
|
|
@@ -125,7 +125,7 @@ A **page** is any `*.tsx` file under `src/pages/` that's not a special file (`la
|
|
|
125
125
|
## A static page
|
|
126
126
|
|
|
127
127
|
```tsx
|
|
128
|
-
// src/pages/about
|
|
128
|
+
// src/pages/about/page.tsx → /about
|
|
129
129
|
import type { ReactNode } from 'react'
|
|
130
130
|
|
|
131
131
|
export default function About(): ReactNode {
|
|
@@ -145,9 +145,9 @@ That's it. Save the file, the CLI's discovery sees it on next save, the page is
|
|
|
145
145
|
`index.tsx` maps to the directory's URL:
|
|
146
146
|
|
|
147
147
|
```text
|
|
148
|
-
src/pages/
|
|
149
|
-
src/pages/users/
|
|
150
|
-
src/pages/admin/
|
|
148
|
+
src/pages/page.tsx → /
|
|
149
|
+
src/pages/users/page.tsx → /users
|
|
150
|
+
src/pages/admin/page.tsx → /admin
|
|
151
151
|
```
|
|
152
152
|
|
|
153
153
|
## Dynamic segments
|
|
@@ -155,7 +155,7 @@ src/pages/admin/index.page.tsx → /admin
|
|
|
155
155
|
Brackets in the filename are dynamic. The captured value comes through `useParams<T>()`:
|
|
156
156
|
|
|
157
157
|
```tsx
|
|
158
|
-
// src/pages/users/[id]
|
|
158
|
+
// src/pages/users/[id]/page.tsx → /users/:id
|
|
159
159
|
import { useParams } from '@voltro/web'
|
|
160
160
|
|
|
161
161
|
export default function User() {
|
|
@@ -167,7 +167,7 @@ export default function User() {
|
|
|
167
167
|
Multiple dynamic segments in one path:
|
|
168
168
|
|
|
169
169
|
```text
|
|
170
|
-
src/pages/orgs/[orgId]/projects/[projectId]
|
|
170
|
+
src/pages/orgs/[orgId]/projects/[projectId]/page.tsx
|
|
171
171
|
// → /orgs/:orgId/projects/:projectId
|
|
172
172
|
|
|
173
173
|
const { orgId, projectId } = useParams<{ orgId: string; projectId: string }>()
|
|
@@ -178,7 +178,7 @@ const { orgId, projectId } = useParams<{ orgId: string; projectId: string }>()
|
|
|
178
178
|
`[...name]` captures one OR more URL segments as a single param value (joined by `/`):
|
|
179
179
|
|
|
180
180
|
```tsx
|
|
181
|
-
// src/pages/docs/[...slug]
|
|
181
|
+
// src/pages/docs/[...slug]/page.tsx → /docs/<anything>
|
|
182
182
|
const { slug } = useParams<{ slug: string }>()
|
|
183
183
|
// /docs/intro/getting-started → slug = "intro/getting-started"
|
|
184
184
|
```
|
|
@@ -186,7 +186,7 @@ const { slug } = useParams<{ slug: string }>()
|
|
|
186
186
|
**Optional** catch-all (matches the base URL too):
|
|
187
187
|
|
|
188
188
|
```tsx
|
|
189
|
-
// src/pages/docs/[[...slug]]
|
|
189
|
+
// src/pages/docs/[[...slug]]/page.tsx
|
|
190
190
|
// /docs → slug = ""
|
|
191
191
|
// /docs/foo → slug = "foo"
|
|
192
192
|
// /docs/foo/bar → slug = "foo/bar"
|
|
@@ -201,10 +201,10 @@ When multiple files could match (static, dynamic, catch-all), priority is:
|
|
|
201
201
|
3. Optional catch-all (`[[...slug]]`) wins over required catch-all (`[...slug]`) — the optional form scores as more specific, so it matches first.
|
|
202
202
|
|
|
203
203
|
```text
|
|
204
|
-
src/pages/users/
|
|
205
|
-
src/pages/users/[id]
|
|
206
|
-
src/pages/users/new
|
|
207
|
-
src/pages/[...rest]
|
|
204
|
+
src/pages/users/page.tsx # /users → wins for /users
|
|
205
|
+
src/pages/users/[id]/page.tsx # /users/:id → wins for /users/42
|
|
206
|
+
src/pages/users/new/page.tsx # /users/new → wins (static beats dynamic)
|
|
207
|
+
src/pages/[...rest]/page.tsx # everything else
|
|
208
208
|
```
|
|
209
209
|
|
|
210
210
|
## Query strings
|
|
@@ -242,14 +242,14 @@ Parse the query string explicitly via `useServerRequest()` on the server and `wi
|
|
|
242
242
|
|
|
243
243
|
## Co-locating components, hooks and tests
|
|
244
244
|
|
|
245
|
-
Only
|
|
245
|
+
Only `page.tsx` is a route. Everything else under `src/pages/` is ordinary code and may sit next to the page that uses it:
|
|
246
246
|
|
|
247
247
|
```text
|
|
248
248
|
src/pages/
|
|
249
249
|
├── users/
|
|
250
|
-
│ ├──
|
|
251
|
-
│ ├──
|
|
252
|
-
│ ├── [id]
|
|
250
|
+
│ ├── page.tsx # → /users
|
|
251
|
+
│ ├── page.test.tsx # its test
|
|
252
|
+
│ ├── [id]/page.tsx # → /users/:id
|
|
253
253
|
│ ├── UserCard.tsx # a component — no URL
|
|
254
254
|
│ └── useFilters.ts # a hook — no URL
|
|
255
255
|
```
|
|
@@ -262,6 +262,8 @@ No naming trick is needed to keep something out of the router: the absence of th
|
|
|
262
262
|
|
|
263
263
|
The canonical form is **no trailing slash** — always link with `<Link to="/about">`, not `<Link to="/about/">`.
|
|
264
264
|
|
|
265
|
+
`Link` forwards every prop it does not consume itself to the underlying `<a>`, `ref` included — so it drops straight into a polymorphic slot (`<Button component={Link} to={url}>`) without a wrapper.
|
|
266
|
+
|
|
265
267
|
The framework does NOT emit a trailing-slash redirect on its own. If you need `/about/` → `/about` normalisation (for SEO), configure a 301 redirect at your reverse proxy.
|
|
266
268
|
|
|
267
269
|
## What pages CAN'T do
|
|
@@ -319,11 +321,11 @@ Conventions:
|
|
|
319
321
|
```text
|
|
320
322
|
src/pages/
|
|
321
323
|
├── layout.tsx # outer (every page)
|
|
322
|
-
├── about
|
|
324
|
+
├── about/page.tsx # /about → wrapped in outer layout
|
|
323
325
|
└── dashboard/
|
|
324
326
|
├── layout.tsx # nested (only /dashboard/*)
|
|
325
|
-
├──
|
|
326
|
-
└── settings
|
|
327
|
+
├── page.tsx # /dashboard
|
|
328
|
+
└── settings/page.tsx # /dashboard/settings
|
|
327
329
|
```
|
|
328
330
|
|
|
329
331
|
For `/dashboard/settings`, the React tree is:
|
|
@@ -346,13 +348,13 @@ A directory in `(parentheses)` does NOT contribute a URL segment, but its layout
|
|
|
346
348
|
src/pages/
|
|
347
349
|
├── (marketing)/
|
|
348
350
|
│ ├── layout.tsx # marketing-scoped chrome
|
|
349
|
-
│ ├──
|
|
350
|
-
│ ├── pricing
|
|
351
|
-
│ └── about
|
|
351
|
+
│ ├── page.tsx # /
|
|
352
|
+
│ ├── pricing/page.tsx # /pricing
|
|
353
|
+
│ └── about/page.tsx # /about
|
|
352
354
|
└── (app)/
|
|
353
355
|
├── layout.tsx # authenticated app chrome
|
|
354
|
-
├── dashboard
|
|
355
|
-
└── settings
|
|
356
|
+
├── dashboard/page.tsx # /dashboard
|
|
357
|
+
└── settings/page.tsx # /settings
|
|
356
358
|
```
|
|
357
359
|
|
|
358
360
|
Marketing pages get one layout; authenticated app pages get another; the URLs stay flat.
|
|
@@ -496,7 +498,7 @@ Those four are the **complete** set. An unrecognised value is a hard error namin
|
|
|
496
498
|
## static (SSG)
|
|
497
499
|
|
|
498
500
|
```tsx
|
|
499
|
-
// src/pages/about
|
|
501
|
+
// src/pages/about/page.tsx
|
|
500
502
|
export const renderMode = 'static' as const
|
|
501
503
|
```
|
|
502
504
|
|
|
@@ -509,7 +511,7 @@ At `voltro build`:
|
|
|
509
511
|
For dynamic patterns, export `getStaticPaths` to enumerate every URL to pre-render:
|
|
510
512
|
|
|
511
513
|
```tsx
|
|
512
|
-
// src/pages/blog/[slug]
|
|
514
|
+
// src/pages/blog/[slug]/page.tsx
|
|
513
515
|
export const renderMode = 'static' as const
|
|
514
516
|
|
|
515
517
|
export const getStaticPaths = async () => [
|
|
@@ -734,7 +736,7 @@ Both are static module exports — the framework discovers them, the build pipel
|
|
|
734
736
|
## A loader
|
|
735
737
|
|
|
736
738
|
```tsx
|
|
737
|
-
// src/pages/notes/[id]
|
|
739
|
+
// src/pages/notes/[id]/page.tsx
|
|
738
740
|
import { useLoaderData } from '@voltro/web'
|
|
739
741
|
|
|
740
742
|
interface Note {
|
|
@@ -844,7 +846,7 @@ splits the loader's result into data that blocks the shell and data that
|
|
|
844
846
|
soon as the promise settles.
|
|
845
847
|
|
|
846
848
|
```tsx
|
|
847
|
-
// src/pages/dashboard
|
|
849
|
+
// src/pages/dashboard/page.tsx
|
|
848
850
|
import { Await, defer, useLoaderData } from '@voltro/web'
|
|
849
851
|
|
|
850
852
|
export const renderMode = 'ssr' as const // required — see below
|
|
@@ -964,7 +966,7 @@ Use `signal` for any fetch that could outlive the request — pass it to `fetch(
|
|
|
964
966
|
Instead of hand-rolling a `fetch(INTERNAL_API/...)`, a server-side loader can call the backend rpc directly through `ctx.query` — the same query tags the client subscribes to, resolved to their initial snapshot:
|
|
965
967
|
|
|
966
968
|
```tsx
|
|
967
|
-
// src/pages/notes/[id]
|
|
969
|
+
// src/pages/notes/[id]/page.tsx
|
|
968
970
|
import { useSubscription } from '@voltro/client'
|
|
969
971
|
import { useLoaderData, type PageMeta } from '@voltro/web'
|
|
970
972
|
|
|
@@ -1136,7 +1138,7 @@ export const meta = ({ locale }: { locale: string }): PageMeta => {
|
|
|
1136
1138
|
### Authenticated dashboard with cookie-driven loader
|
|
1137
1139
|
|
|
1138
1140
|
```tsx
|
|
1139
|
-
// src/pages/dashboard/
|
|
1141
|
+
// src/pages/dashboard/page.tsx
|
|
1140
1142
|
import { useLoaderData } from '@voltro/web'
|
|
1141
1143
|
|
|
1142
1144
|
export const renderMode = 'ssr' as const
|
|
@@ -1162,7 +1164,7 @@ export default function Dashboard() {
|
|
|
1162
1164
|
`getStaticPaths` has no framework store — it reads its own content source (a CMS client, the filesystem, an API). The loader runs server-side and fetches via `query` (the backend rpc, resolved to its first snapshot):
|
|
1163
1165
|
|
|
1164
1166
|
```tsx
|
|
1165
|
-
// src/pages/blog/[slug]
|
|
1167
|
+
// src/pages/blog/[slug]/page.tsx
|
|
1166
1168
|
import { listPostSlugs, type Post } from '../../content/posts'
|
|
1167
1169
|
|
|
1168
1170
|
export const renderMode = 'static' as const
|
|
@@ -1563,7 +1565,7 @@ export default island(LikeButton, { name: 'LikeButton', hydrate: 'visible' })
|
|
|
1563
1565
|
Use it in a page:
|
|
1564
1566
|
|
|
1565
1567
|
```tsx
|
|
1566
|
-
// src/pages/blog/[slug]
|
|
1568
|
+
// src/pages/blog/[slug]/page.tsx
|
|
1567
1569
|
import LikeButton from '../../components/LikeButton.island'
|
|
1568
1570
|
|
|
1569
1571
|
export const renderMode = 'static' as const
|
|
@@ -903,9 +903,12 @@ that affordance read-only rather than binding to a tag that does not resolve. Th
|
|
|
903
903
|
[`useCan`](/docs/ui/client-utilities/use-can); map them to your app's real RBAC
|
|
904
904
|
scopes.
|
|
905
905
|
|
|
906
|
-
The
|
|
907
|
-
|
|
908
|
-
|
|
906
|
+
The manifest GET is bearer-gated wherever it runs — `/_voltro/inspect/*` is
|
|
907
|
+
fail-closed, so no configured `VOLTRO_INSPECT_TOKEN` means `401`, not "everyone".
|
|
908
|
+
Under `voltro dev` that is handled for you (the dev server mints a token and its
|
|
909
|
+
proxy attaches it server-side). An admin UI pointed at a deployed api has to
|
|
910
|
+
supply the token itself — a deployment concern, not something this hook
|
|
911
|
+
handles.
|
|
909
912
|
|
|
910
913
|
|
|
911
914
|
|
|
@@ -39,7 +39,7 @@ apps/acme/web/ # dir named by the app, not the template
|
|
|
39
39
|
|
|
40
40
|
## The page
|
|
41
41
|
|
|
42
|
-
`src/pages/
|
|
42
|
+
`src/pages/page.tsx` is plain JSX with the two static-render exports:
|
|
43
43
|
|
|
44
44
|
```tsx
|
|
45
45
|
export const renderMode = 'static' as const // pre-render at build time
|
|
@@ -59,7 +59,7 @@ export default function Index() {
|
|
|
59
59
|
</section>
|
|
60
60
|
<section>
|
|
61
61
|
<h2>Ready to start?</h2>
|
|
62
|
-
<p>Edit <code>src/pages/
|
|
62
|
+
<p>Edit <code>src/pages/page.tsx</code> to make it yours.</p>
|
|
63
63
|
</section>
|
|
64
64
|
</main>
|
|
65
65
|
)
|
|
@@ -80,7 +80,7 @@ export default island(SignupForm, { name: 'SignupForm', hydrate: 'visible' })
|
|
|
80
80
|
```
|
|
81
81
|
|
|
82
82
|
```tsx
|
|
83
|
-
// src/pages/
|
|
83
|
+
// src/pages/page.tsx
|
|
84
84
|
export const interactive = 'islands' as const // was 'none'
|
|
85
85
|
import SignupForm from '../components/SignupForm.island'
|
|
86
86
|
// … render <SignupForm /> somewhere in the page …
|
|
@@ -185,7 +185,7 @@ If your final product is going to be a:
|
|
|
185
185
|
|
|
186
186
|
_A documentation site with a catch-all docs router and build-time static pre-rendering of every page via getStaticPaths._
|
|
187
187
|
|
|
188
|
-
A documentation-site starter. `src/pages/docs/[...slug]
|
|
188
|
+
A documentation-site starter. `src/pages/docs/[...slug]/page.tsx` is a catch-all router; `getStaticPaths` enumerates every doc URL at build time so `voltro build` pre-renders them all into a static site you can deploy anywhere. Template id: **`frontend-docs`**.
|
|
189
189
|
|
|
190
190
|
The starter ships a tiny in-file `DOCS` array as stub content. Replace it with a real loader (a markdown reader, a CMS query, a database fetch) — the page component's contract stays the same.
|
|
191
191
|
|
|
@@ -215,7 +215,7 @@ apps/acme/docs/ # dir named by the app, not the template
|
|
|
215
215
|
|
|
216
216
|
## The catch-all renderer
|
|
217
217
|
|
|
218
|
-
`src/pages/docs/[...slug]
|
|
218
|
+
`src/pages/docs/[...slug]/page.tsx` is the whole engine. It declares:
|
|
219
219
|
|
|
220
220
|
```tsx
|
|
221
221
|
export const renderMode = 'static' as const // pre-render at build
|
|
@@ -418,7 +418,7 @@ No rpc client, no loader, no `apis:` entry, no `@voltro/ui-shadcn` dependency. T
|
|
|
418
418
|
The page exports `renderMode = 'spa'`, which tells `voltro build` not to pre-render it: the client renders it on load. Use this when the whole page is interactive AND its state lives in the browser — an SSR'd first paint would just be discarded on hydration, so rendering it on the server buys nothing.
|
|
419
419
|
|
|
420
420
|
```tsx
|
|
421
|
-
// src/pages/
|
|
421
|
+
// src/pages/page.tsx — a pure client-side SPA (a bill splitter)
|
|
422
422
|
import type { ReactNode } from 'react'
|
|
423
423
|
import { useEffect, useState } from 'react'
|
|
424
424
|
|
|
@@ -603,7 +603,7 @@ The full reference for these modes lives in the [render modes guide](/docs/routi
|
|
|
603
603
|
`index.tsx` exports `renderMode = 'ssr'`. Its loader runs SERVER-SIDE on EVERY request under `voltro start` — the timestamp and nonce change on each refresh, and the loader can read the incoming request to personalise the HTML before it's sent. There is no caching: the response carries `x-voltro-rendered-by: ssr`.
|
|
604
604
|
|
|
605
605
|
```tsx
|
|
606
|
-
// src/pages/
|
|
606
|
+
// src/pages/page.tsx
|
|
607
607
|
import type { ReactNode } from 'react'
|
|
608
608
|
import { useLoaderData, useServerRequest, type LoaderFn, type PageMeta } from '@voltro/web'
|
|
609
609
|
|
|
@@ -646,7 +646,7 @@ Use SSR for anything that varies per request: a logged-in dashboard, a page that
|
|
|
646
646
|
`feed.tsx` takes the SAME render path as SSR, but `voltro start` CACHES the HTML and serves the cached copy to every request within the `revalidate` window. After the window expires the next request re-renders and replaces the cache — cache hits return in well under a millisecond. `tenantAware: true` folds the `x-tenant` request header into the cache key, so tenant A's render is never served to tenant B:
|
|
647
647
|
|
|
648
648
|
```tsx
|
|
649
|
-
// src/pages/feed
|
|
649
|
+
// src/pages/feed/page.tsx
|
|
650
650
|
import type { ReactNode } from 'react'
|
|
651
651
|
import { useLoaderData, type LoaderFn, type PageMeta } from '@voltro/web'
|
|
652
652
|
|
|
@@ -685,7 +685,7 @@ The loader's `headers` is populated server-side (lowercased keys) — that's how
|
|
|
685
685
|
`feed-swr.tsx` is the same ISR page plus a `staleWhileRevalidate` window. When `revalidate` expires, instead of blocking the next visitor on a fresh render, the cache serves the STALE HTML immediately AND kicks off a background re-render. The visitor never waits; the cache catches up out-of-band. Beyond `revalidate + staleWhileRevalidate` the next request blocks on a fresh render, like a cold MISS:
|
|
686
686
|
|
|
687
687
|
```tsx
|
|
688
|
-
// src/pages/feed-swr
|
|
688
|
+
// src/pages/feed-swr/page.tsx
|
|
689
689
|
import type { ReactNode } from 'react'
|
|
690
690
|
import { useLoaderData, type LoaderFn, type PageMeta } from '@voltro/web'
|
|
691
691
|
|
|
@@ -878,10 +878,10 @@ export const posts: ReadonlyArray<Post> = [
|
|
|
878
878
|
|
|
879
879
|
## SSG dynamic routes — `getStaticPaths` + `loader` + per-post `meta`
|
|
880
880
|
|
|
881
|
-
A dynamic route like `src/pages/blog/[slug]
|
|
881
|
+
A dynamic route like `src/pages/blog/[slug]/page.tsx` matches infinitely many URLs. To pre-render it statically the build needs to know WHICH slugs exist — that is what `getStaticPaths` answers, by mapping over the content source. For each enumerated `params` the build runs the `loader` (server-side, at build time) and renders one HTML file. The `meta` export is a FUNCTION of the loader data, so each post gets its own correct `<title>` / `<description>` baked into the HTML — exactly what you want for SEO and social cards.
|
|
882
882
|
|
|
883
883
|
```tsx
|
|
884
|
-
// src/pages/blog/[slug]
|
|
884
|
+
// src/pages/blog/[slug]/page.tsx
|
|
885
885
|
import type { ReactNode } from 'react'
|
|
886
886
|
import { useLoaderData, notFound, type LoaderFn, type PageMeta } from '@voltro/web'
|
|
887
887
|
import { posts, type Post } from '../../content/posts'
|
|
@@ -972,7 +972,7 @@ export default island(ReadingProgress, { name: 'ReadingProgress', hydrate: 'load
|
|
|
972
972
|
The index at `/` is pure content: a list of posts with no interactivity. It declares `interactive: 'none'`, which makes the framework strip EVERY `<script>` it would otherwise emit — the HTML ships with no JS bundle at all. It reads the same content source the `[slug]` page + `getStaticPaths` use:
|
|
973
973
|
|
|
974
974
|
```tsx
|
|
975
|
-
// src/pages/
|
|
975
|
+
// src/pages/page.tsx
|
|
976
976
|
import type { ReactNode } from 'react'
|
|
977
977
|
import type { PageMeta } from '@voltro/web'
|
|
978
978
|
import { posts } from '../content/posts'
|
|
@@ -1106,10 +1106,10 @@ The serverless function lives next to the web app but is a separate deploy artif
|
|
|
1106
1106
|
|
|
1107
1107
|
## The static page + the island form
|
|
1108
1108
|
|
|
1109
|
-
`src/pages/
|
|
1109
|
+
`src/pages/page.tsx` is `renderMode: 'static'` (pre-rendered, servable from a CDN) and `interactive: 'islands'` — so only the `<ContactForm>` island hydrates; the headline + copy ship as inert HTML with no React lifecycle:
|
|
1110
1110
|
|
|
1111
1111
|
```tsx
|
|
1112
|
-
// src/pages/
|
|
1112
|
+
// src/pages/page.tsx
|
|
1113
1113
|
import type { ReactNode } from 'react'
|
|
1114
1114
|
import type { PageMeta } from '@voltro/web'
|
|
1115
1115
|
import ContactForm from '../components/ContactForm.island'
|
|
@@ -1358,7 +1358,7 @@ apps/demo/web/
|
|
|
1358
1358
|
├── README.md
|
|
1359
1359
|
└── src/pages/
|
|
1360
1360
|
├── layout.tsx # root shell (header + <main>)
|
|
1361
|
-
└──
|
|
1361
|
+
└── page.tsx # the reactive page — useSubscription + useMutation
|
|
1362
1362
|
```
|
|
1363
1363
|
|
|
1364
1364
|
## The `apis` wiring
|
|
@@ -1390,7 +1390,7 @@ The `package` resolves the api workspace package; its port is auto-discovered fr
|
|
|
1390
1390
|
## The reactive loop
|
|
1391
1391
|
|
|
1392
1392
|
```tsx
|
|
1393
|
-
// src/pages/
|
|
1393
|
+
// src/pages/page.tsx
|
|
1394
1394
|
import { useMutation, useSubscription } from '@voltro/client'
|
|
1395
1395
|
|
|
1396
1396
|
const TENANT = 'acme'
|
|
@@ -1418,7 +1418,7 @@ The subscription builder also exposes `revision`, `emittedAt`, `error`, and `pen
|
|
|
1418
1418
|
|
|
1419
1419
|
## Pairing with a different api
|
|
1420
1420
|
|
|
1421
|
-
This page is wired to api-backend's `notes` domain. To point it at your own api, change `apis.app.package` in `app.config.ts`, then swap the rpc tags + the row type in `src/pages/
|
|
1421
|
+
This page is wired to api-backend's `notes` domain. To point it at your own api, change `apis.app.package` in `app.config.ts`, then swap the rpc tags + the row type in `src/pages/page.tsx` to match your descriptors. The hook shapes are identical for any api.
|
|
1422
1422
|
|
|
1423
1423
|
## When to use
|
|
1424
1424
|
|
|
@@ -1533,7 +1533,7 @@ export const withLocalePrefix = (path: string, locale: string): string =>
|
|
|
1533
1533
|
Each page has a one-line **mirror** under `src/pages/[locale]/`. The mirror re-exports the bare page verbatim (the component reads its locale from the URL via the layout's provider) and uses `getStaticPaths` to enumerate the *non-default* locales — which is what tells `voltro build` to emit the `/de/...` HTML. Without the mirror, only the default-locale URL is built.
|
|
1534
1534
|
|
|
1535
1535
|
```tsx
|
|
1536
|
-
// src/pages/[locale]/
|
|
1536
|
+
// src/pages/[locale]/page.tsx — the entire file
|
|
1537
1537
|
import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from '../../lib/locale'
|
|
1538
1538
|
|
|
1539
1539
|
export { default } from '../index'
|
|
@@ -1550,7 +1550,7 @@ export const getStaticPaths = async (): Promise<Array<{ params: { locale: string
|
|
|
1550
1550
|
The body of each page is localised automatically (the SSG pipeline wraps every variant in the right catalog's provider). The document head is NOT — unless you export `meta` as a **function of `{ locale }`**. The framework drives `locale` from `params.locale` on the `[locale]` route (and `defaultLocale` on the bare path), so each variant bakes the right `<title>` / `<meta description>` into its HTML — exactly what you want for SEO and social cards. A plain `meta` object would leave the head English on `/de`.
|
|
1551
1551
|
|
|
1552
1552
|
```tsx
|
|
1553
|
-
// src/pages/
|
|
1553
|
+
// src/pages/page.tsx
|
|
1554
1554
|
import { T, useT, useLocale } from '@voltro/i18n'
|
|
1555
1555
|
import { getCatalog } from '../lib/locale'
|
|
1556
1556
|
|
|
@@ -1662,7 +1662,7 @@ The `[locale]` mirrors pick it up automatically — `getStaticPaths` reads `SUPP
|
|
|
1662
1662
|
|
|
1663
1663
|
## Anti-patterns
|
|
1664
1664
|
|
|
1665
|
-
- **Forgetting the `[locale]` mirror for a new page.** Create `src/pages/foo
|
|
1665
|
+
- **Forgetting the `[locale]` mirror for a new page.** Create `src/pages/foo/page.tsx` AND a one-line mirror `src/pages/[locale]/foo/page.tsx`. Without the mirror, only the default-locale URL is emitted — `/de/foo` 404s.
|
|
1666
1666
|
- **Exporting `meta` as a plain object.** The body localises, but the `<title>` / `<meta description>` stay English on `/de`. Export `meta` as a function of `{ locale }` and read the catalog via `getCatalog(locale)`.
|
|
1667
1667
|
- **Skipping `defineLocale<typeof en>()` for a non-base catalog.** Plain `defineCatalog(...)` compiles but loses the parity check — the one feature that stops translations drifting. Always run non-base locales through `defineLocale<typeof en>()`.
|
|
1668
1668
|
- **Angle brackets in catalog strings.** react-intl's ICU parser treats `<tag>` as a rich-text element and fails (`FORMAT_ERROR`) if there's no matching close tag. Write "page titles", not "`<title>` tags", in a message.
|
|
@@ -1759,7 +1759,7 @@ export default function DashboardLayout({ children }) {
|
|
|
1759
1759
|
The gate must run **per request** — it reads the request's cookie and may redirect. A `static` page is pre-rendered once at build time with no per-visitor render, so it can't redirect per-visitor; in fact `voltro build` **rejects** a `RedirectError` thrown during static pre-render. So the authed pages declare `renderMode: 'ssr'`:
|
|
1760
1760
|
|
|
1761
1761
|
```tsx
|
|
1762
|
-
// src/pages/dashboard/
|
|
1762
|
+
// src/pages/dashboard/page.tsx
|
|
1763
1763
|
export const renderMode = 'ssr' as const
|
|
1764
1764
|
export const meta = { title: 'Overview' }
|
|
1765
1765
|
export default function Overview() { /* … */ }
|
|
@@ -1884,7 +1884,7 @@ export default {
|
|
|
1884
1884
|
`query` is present ONLY server-side (`ssr` / `isr`). It invokes the api's rpc directly over `POST /rpc`, **forwarding the request's session cookie** so the same Subject + tenant resolve as the WebSocket path. A streaming query is drained to its FIRST snapshot — here, the current notes for this tenant. `meta` reads the result, so the `<title>` reflects real data in the server-rendered HTML:
|
|
1885
1885
|
|
|
1886
1886
|
```tsx
|
|
1887
|
-
// src/pages/
|
|
1887
|
+
// src/pages/page.tsx
|
|
1888
1888
|
import { useLoaderData, type LoaderFn, type PageMeta } from '@voltro/web'
|
|
1889
1889
|
import { useSubscription } from '@voltro/client'
|
|
1890
1890
|
|
|
@@ -1994,7 +1994,7 @@ const entities = manifest ? deriveEntityAdmins(manifest) : []
|
|
|
1994
1994
|
## Per-entity CRUD
|
|
1995
1995
|
|
|
1996
1996
|
```tsx
|
|
1997
|
-
// src/pages/admin/[entity]
|
|
1997
|
+
// src/pages/admin/[entity]/page.tsx (abridged)
|
|
1998
1998
|
const canCreate = useCan(spec.createScope) // <table>:create
|
|
1999
1999
|
{spec.createTag && canCreate ? (
|
|
2000
2000
|
<AutoForm api="app" mutation={spec.createTag} submitLabel={`Add ${spec.table}`} />
|
|
@@ -2041,7 +2041,7 @@ apps/acme/web/
|
|
|
2041
2041
|
|
|
2042
2042
|
## Make it yours
|
|
2043
2043
|
|
|
2044
|
-
Everything under `src/pages/admin/` is yours. Swap the cookie gate (`lib/auth.ts`) + demo scopes (`lib/admin.ts`) for real auth; replace the generic `[entity]
|
|
2044
|
+
Everything under `src/pages/admin/` is yours. Swap the cookie gate (`lib/auth.ts`) + demo scopes (`lib/admin.ts`) for real auth; replace the generic `[entity]/page.tsx` with a hand-built page for any entity that needs more than CRUD.
|
|
2045
2045
|
|
|
2046
2046
|
## When to use frontend-admin vs. the other web templates
|
|
2047
2047
|
|