@voltro/cli 0.13.0 → 0.14.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 +285 -0
- package/dist/{apiBuild-B6_ehbem.js → apiBuild-370ITNRk.js} +2 -2
- package/dist/apiBuild-DatWP5hh.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/{commands-DYOuGlEC.js → commands-D-teL_TM.js} +3709 -2789
- package/dist/dbCommand-B3CeMU3u.js +2 -0
- package/dist/{dbCommand-DIOJQwsq.js → dbCommand-Dmi-vHfA.js} +2 -1
- package/dist/{dev-Fb9BmavX.js → dev-CKyW_LAv.js} +1620 -1509
- package/dist/{dev-xdijqojc.js → dev-CPKbckaN.js} +1 -1
- package/dist/fileConventions-COg0ZytD.js +9 -0
- package/dist/{frameworkTableAssembly-CYlrafoK.js → frameworkTableAssembly-DCLhpHsF.js} +164 -163
- package/dist/frameworkTableAssembly-DYzb2elg.js +2 -0
- package/dist/index.js +1 -1
- package/dist/{inspectMetrics-DOiiII34.js → inspectMetrics-CyVMeofr.js} +955 -945
- package/dist/{serveCommand-CqYxzBZR.js → serveCommand-CU3Sw4ZM.js} +410 -396
- package/dist/serveEntry.js +2 -2
- package/dist/{start-CjG_0xhR.js → start-D8ATvTzo.js} +246 -246
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.core.md +77 -3
- package/templates/AGENTS.md +77 -3
- package/templates/agent-docs/_manifest.json +2 -2
- package/templates/agent-docs/authentication.md +5 -2
- package/templates/agent-docs/cli.md +36 -2
- package/templates/agent-docs/data.md +193 -1
- package/templates/agent-docs/database/hosting.md +2 -2
- package/templates/agent-docs/database/migrations.md +56 -3
- package/templates/agent-docs/database/overview.md +11 -0
- package/templates/agent-docs/database/seedsdialects.md +53 -1
- package/templates/agent-docs/internationalization.md +3 -3
- package/templates/agent-docs/introduction.md +133 -2
- package/templates/agent-docs/plugins.md +2 -1
- package/templates/agent-docs/reference.md +4 -4
- package/templates/agent-docs/routing.md +48 -43
- package/templates/agent-docs/schema-driven-ui.md +12 -2
- package/templates/agent-docs/templates/apibackends.md +1 -8
- package/templates/agent-docs/templates/appshells.md +24 -24
- package/templates/agent-docs/testing.md +16 -1
- package/templates/agent-docs/whats-new.md +16 -0
- package/templates/apps/api-ai/database/schema.ts +0 -2
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/database/schema.ts +0 -3
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/database/schema.ts +0 -1
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/database/schema.ts +0 -3
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/database/schema.ts +0 -3
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-storage/database/schema.ts +0 -3
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-data-advanced/database/authors.entity.ts +0 -2
- package/templates/apps/api-data-advanced/database/books.entity.ts +0 -1
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/database/schema.ts +0 -3
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/database/schema.ts +0 -1
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/database/schema.ts +0 -1
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/database/schema.ts +0 -3
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/database/schema.ts +0 -2
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/database/schema.ts +0 -1
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/database/schema.ts +0 -1
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/database/schema.ts +0 -2
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/database/schema.ts +0 -3
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/database/schema.ts +0 -1
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-search/database/schema.ts +0 -1
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-versioning/database/schema.ts +0 -1
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/database/schema.ts +0 -1
- 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].tsx → [slug].page.tsx} +2 -2
- package/templates/apps/changelog/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +2 -2
- package/templates/apps/changelog/src/pages/{[slug].test.tsx → [slug].page.test.tsx} +1 -1
- package/templates/apps/changelog/src/pages/{index.test.tsx → index.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)/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-admin/src/pages/(marketing)/{login.test.tsx → login.page.test.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.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-app/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-app/src/pages/{schema-ui.test.tsx → schema-ui.page.test.tsx} +1 -1
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-blank/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-contact/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/frontend-contact/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.test.tsx → login.page.test.tsx} +1 -1
- package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.test.tsx → settings.page.test.tsx} +1 -1
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-docs/src/pages/[locale]/docs/{[...slug].tsx → [...slug].page.tsx} +3 -3
- package/templates/apps/frontend-docs/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +2 -2
- package/templates/apps/frontend-docs/src/pages/docs/{[...slug].test.tsx → [...slug].page.test.tsx} +1 -1
- package/templates/apps/frontend-docs/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-i18n/src/pages/[locale]/{about.tsx → about.page.tsx} +2 -2
- package/templates/apps/frontend-i18n/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/frontend-i18n/src/pages/[locale]/mirrors.test.tsx +2 -2
- package/templates/apps/frontend-i18n/src/pages/{about.test.tsx → about.page.test.tsx} +1 -1
- package/templates/apps/frontend-i18n/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-landing/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/frontend-landing/src/pages/[locale]/mirrors.test.tsx +1 -1
- package/templates/apps/frontend-landing/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-spa/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr/src/pages/{feed-swr.test.tsx → feed-swr.page.test.tsx} +1 -1
- package/templates/apps/frontend-ssr/src/pages/{feed.test.tsx → feed.page.test.tsx} +1 -1
- package/templates/apps/frontend-ssr/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-ssr-api/src/pages/{index.test.tsx → index.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].tsx → [slug].page.tsx} +2 -2
- package/templates/apps/frontend-static-blog/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
- package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +2 -2
- package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].test.tsx → [slug].page.test.tsx} +1 -1
- package/templates/apps/frontend-static-blog/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
- package/dist/apiBuild-Bzpb383i.js +0 -2
- package/dist/dbCommand-ADD4mrTI.js +0 -2
- package/dist/frameworkTableAssembly-XvKO7wQt.js +0 -2
- /package/templates/apps/changelog/src/pages/{[slug].tsx → [slug].page.tsx} +0 -0
- /package/templates/apps/changelog/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-admin/src/pages/(marketing)/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-admin/src/pages/(marketing)/{login.tsx → login.page.tsx} +0 -0
- /package/templates/apps/frontend-admin/src/pages/admin/{[entity].tsx → [entity].page.tsx} +0 -0
- /package/templates/apps/frontend-admin/src/pages/admin/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-app/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-app/src/pages/{schema-ui.tsx → schema-ui.page.tsx} +0 -0
- /package/templates/apps/frontend-blank/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-contact/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.tsx → login.page.tsx} +0 -0
- /package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.tsx → settings.page.tsx} +0 -0
- /package/templates/apps/frontend-docs/src/pages/docs/{[...slug].tsx → [...slug].page.tsx} +0 -0
- /package/templates/apps/frontend-docs/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-i18n/src/pages/{about.tsx → about.page.tsx} +0 -0
- /package/templates/apps/frontend-i18n/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-landing/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-spa/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-ssr/src/pages/{feed-swr.tsx → feed-swr.page.tsx} +0 -0
- /package/templates/apps/frontend-ssr/src/pages/{feed.tsx → feed.page.tsx} +0 -0
- /package/templates/apps/frontend-ssr/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-ssr-api/src/pages/{index.tsx → index.page.tsx} +0 -0
- /package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].tsx → [slug].page.tsx} +0 -0
- /package/templates/apps/frontend-static-blog/src/pages/{index.tsx → index.page.tsx} +0 -0
|
@@ -11,7 +11,9 @@
|
|
|
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
|
+
|
|
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.
|
|
15
17
|
|
|
16
18
|
This section covers everything about how URLs map to React + how Voltro decides when to render, what to ship to the browser, and how to navigate between pages.
|
|
17
19
|
|
|
@@ -23,18 +25,18 @@ src/pages/
|
|
|
23
25
|
├── error.tsx # error boundary
|
|
24
26
|
├── loading.tsx # pending UI
|
|
25
27
|
├── not-found.tsx # 404 fallback
|
|
26
|
-
├── index.tsx # /
|
|
27
|
-
├── about.tsx # /about
|
|
28
|
+
├── index.page.tsx # /
|
|
29
|
+
├── about.page.tsx # /about
|
|
28
30
|
├── (marketing)/ # route group — no URL segment
|
|
29
31
|
│ ├── layout.tsx # marketing-scoped layout
|
|
30
|
-
│ └── pricing.tsx # /pricing
|
|
32
|
+
│ └── pricing.page.tsx # /pricing
|
|
31
33
|
├── users/
|
|
32
34
|
│ ├── layout.tsx # users-scoped layout
|
|
33
35
|
│ ├── error.tsx # users-scoped error boundary
|
|
34
|
-
│ ├── [id].tsx # /users/:id
|
|
35
|
-
│ └── index.tsx # /users
|
|
36
|
+
│ ├── [id].page.tsx # /users/:id
|
|
37
|
+
│ └── index.page.tsx # /users
|
|
36
38
|
└── docs/
|
|
37
|
-
└── [...slug].tsx # /docs/<anything> (catch-all)
|
|
39
|
+
└── [...slug].page.tsx # /docs/<anything> (catch-all)
|
|
38
40
|
```
|
|
39
41
|
|
|
40
42
|
That's the whole router. No `<Route>`, no `<Switch>`, no `useRoutes`.
|
|
@@ -50,7 +52,7 @@ That's the whole router. No `<Route>`, no `<Switch>`, no `useRoutes`.
|
|
|
50
52
|
|
|
51
53
|
## Architecture in one paragraph
|
|
52
54
|
|
|
53
|
-
The framework generates a `.framework/app.tsx` on every boot that imports each `*.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.
|
|
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.
|
|
54
56
|
|
|
55
57
|
You don't write any of this. The CLI regenerates it on every save in dev; the build pipeline freezes it for production.
|
|
56
58
|
|
|
@@ -123,7 +125,7 @@ A **page** is any `*.tsx` file under `src/pages/` that's not a special file (`la
|
|
|
123
125
|
## A static page
|
|
124
126
|
|
|
125
127
|
```tsx
|
|
126
|
-
// src/pages/about.tsx → /about
|
|
128
|
+
// src/pages/about.page.tsx → /about
|
|
127
129
|
import type { ReactNode } from 'react'
|
|
128
130
|
|
|
129
131
|
export default function About(): ReactNode {
|
|
@@ -143,9 +145,9 @@ That's it. Save the file, the CLI's discovery sees it on next save, the page is
|
|
|
143
145
|
`index.tsx` maps to the directory's URL:
|
|
144
146
|
|
|
145
147
|
```text
|
|
146
|
-
src/pages/index.tsx → /
|
|
147
|
-
src/pages/users/index.tsx → /users
|
|
148
|
-
src/pages/admin/index.tsx → /admin
|
|
148
|
+
src/pages/index.page.tsx → /
|
|
149
|
+
src/pages/users/index.page.tsx → /users
|
|
150
|
+
src/pages/admin/index.page.tsx → /admin
|
|
149
151
|
```
|
|
150
152
|
|
|
151
153
|
## Dynamic segments
|
|
@@ -153,7 +155,7 @@ src/pages/admin/index.tsx → /admin
|
|
|
153
155
|
Brackets in the filename are dynamic. The captured value comes through `useParams<T>()`:
|
|
154
156
|
|
|
155
157
|
```tsx
|
|
156
|
-
// src/pages/users/[id].tsx → /users/:id
|
|
158
|
+
// src/pages/users/[id].page.tsx → /users/:id
|
|
157
159
|
import { useParams } from '@voltro/web'
|
|
158
160
|
|
|
159
161
|
export default function User() {
|
|
@@ -165,7 +167,7 @@ export default function User() {
|
|
|
165
167
|
Multiple dynamic segments in one path:
|
|
166
168
|
|
|
167
169
|
```text
|
|
168
|
-
src/pages/orgs/[orgId]/projects/[projectId].tsx
|
|
170
|
+
src/pages/orgs/[orgId]/projects/[projectId].page.tsx
|
|
169
171
|
// → /orgs/:orgId/projects/:projectId
|
|
170
172
|
|
|
171
173
|
const { orgId, projectId } = useParams<{ orgId: string; projectId: string }>()
|
|
@@ -176,7 +178,7 @@ const { orgId, projectId } = useParams<{ orgId: string; projectId: string }>()
|
|
|
176
178
|
`[...name]` captures one OR more URL segments as a single param value (joined by `/`):
|
|
177
179
|
|
|
178
180
|
```tsx
|
|
179
|
-
// src/pages/docs/[...slug].tsx → /docs/<anything>
|
|
181
|
+
// src/pages/docs/[...slug].page.tsx → /docs/<anything>
|
|
180
182
|
const { slug } = useParams<{ slug: string }>()
|
|
181
183
|
// /docs/intro/getting-started → slug = "intro/getting-started"
|
|
182
184
|
```
|
|
@@ -184,7 +186,7 @@ const { slug } = useParams<{ slug: string }>()
|
|
|
184
186
|
**Optional** catch-all (matches the base URL too):
|
|
185
187
|
|
|
186
188
|
```tsx
|
|
187
|
-
// src/pages/docs/[[...slug]].tsx
|
|
189
|
+
// src/pages/docs/[[...slug]].page.tsx
|
|
188
190
|
// /docs → slug = ""
|
|
189
191
|
// /docs/foo → slug = "foo"
|
|
190
192
|
// /docs/foo/bar → slug = "foo/bar"
|
|
@@ -199,10 +201,10 @@ When multiple files could match (static, dynamic, catch-all), priority is:
|
|
|
199
201
|
3. Optional catch-all (`[[...slug]]`) wins over required catch-all (`[...slug]`) — the optional form scores as more specific, so it matches first.
|
|
200
202
|
|
|
201
203
|
```text
|
|
202
|
-
src/pages/users/index.tsx # /users → wins for /users
|
|
203
|
-
src/pages/users/[id].tsx # /users/:id → wins for /users/42
|
|
204
|
-
src/pages/users/new.tsx # /users/new → wins (static beats dynamic)
|
|
205
|
-
src/pages/[...rest].tsx # everything else
|
|
204
|
+
src/pages/users/index.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
|
|
206
208
|
```
|
|
207
209
|
|
|
208
210
|
## Query strings
|
|
@@ -238,20 +240,23 @@ export default function SearchPage() {
|
|
|
238
240
|
|
|
239
241
|
Parse the query string explicitly via `useServerRequest()` on the server and `window.location.search` on the client, as shown above.
|
|
240
242
|
|
|
241
|
-
##
|
|
243
|
+
## Co-locating components, hooks and tests
|
|
242
244
|
|
|
243
|
-
|
|
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:
|
|
244
246
|
|
|
245
247
|
```text
|
|
246
248
|
src/pages/
|
|
247
|
-
├── _components/
|
|
248
|
-
│ └── UserCard.tsx # NOT a route — import from siblings
|
|
249
249
|
├── users/
|
|
250
|
-
│ ├──
|
|
251
|
-
│
|
|
250
|
+
│ ├── index.page.tsx # → /users
|
|
251
|
+
│ ├── index.page.test.tsx # its test
|
|
252
|
+
│ ├── [id].page.tsx # → /users/:id
|
|
253
|
+
│ ├── UserCard.tsx # a component — no URL
|
|
254
|
+
│ └── useFilters.ts # a hook — no URL
|
|
252
255
|
```
|
|
253
256
|
|
|
254
|
-
|
|
257
|
+
No naming trick is needed to keep something out of the router: the absence of the suffix already does it. A `_`-prefixed directory has **no special meaning** — it is neither required nor recognised.
|
|
258
|
+
|
|
259
|
+
> Before this convention, every `.tsx` under `src/pages/` became a route, so a co-located component silently got a URL. That route rendered nothing and nobody visited it in dev; the failure surfaced at the first production build. If you are upgrading, `voltro update` renames your pages for you.
|
|
255
260
|
|
|
256
261
|
## Trailing slashes
|
|
257
262
|
|
|
@@ -314,11 +319,11 @@ Conventions:
|
|
|
314
319
|
```text
|
|
315
320
|
src/pages/
|
|
316
321
|
├── layout.tsx # outer (every page)
|
|
317
|
-
├── about.tsx # /about → wrapped in outer layout
|
|
322
|
+
├── about.page.tsx # /about → wrapped in outer layout
|
|
318
323
|
└── dashboard/
|
|
319
324
|
├── layout.tsx # nested (only /dashboard/*)
|
|
320
|
-
├── index.tsx # /dashboard
|
|
321
|
-
└── settings.tsx # /dashboard/settings
|
|
325
|
+
├── index.page.tsx # /dashboard
|
|
326
|
+
└── settings.page.tsx # /dashboard/settings
|
|
322
327
|
```
|
|
323
328
|
|
|
324
329
|
For `/dashboard/settings`, the React tree is:
|
|
@@ -341,13 +346,13 @@ A directory in `(parentheses)` does NOT contribute a URL segment, but its layout
|
|
|
341
346
|
src/pages/
|
|
342
347
|
├── (marketing)/
|
|
343
348
|
│ ├── layout.tsx # marketing-scoped chrome
|
|
344
|
-
│ ├── index.tsx # /
|
|
345
|
-
│ ├── pricing.tsx # /pricing
|
|
346
|
-
│ └── about.tsx # /about
|
|
349
|
+
│ ├── index.page.tsx # /
|
|
350
|
+
│ ├── pricing.page.tsx # /pricing
|
|
351
|
+
│ └── about.page.tsx # /about
|
|
347
352
|
└── (app)/
|
|
348
353
|
├── layout.tsx # authenticated app chrome
|
|
349
|
-
├── dashboard.tsx # /dashboard
|
|
350
|
-
└── settings.tsx # /settings
|
|
354
|
+
├── dashboard.page.tsx # /dashboard
|
|
355
|
+
└── settings.page.tsx # /settings
|
|
351
356
|
```
|
|
352
357
|
|
|
353
358
|
Marketing pages get one layout; authenticated app pages get another; the URLs stay flat.
|
|
@@ -491,7 +496,7 @@ Those four are the **complete** set. An unrecognised value is a hard error namin
|
|
|
491
496
|
## static (SSG)
|
|
492
497
|
|
|
493
498
|
```tsx
|
|
494
|
-
// src/pages/about.tsx
|
|
499
|
+
// src/pages/about.page.tsx
|
|
495
500
|
export const renderMode = 'static' as const
|
|
496
501
|
```
|
|
497
502
|
|
|
@@ -504,7 +509,7 @@ At `voltro build`:
|
|
|
504
509
|
For dynamic patterns, export `getStaticPaths` to enumerate every URL to pre-render:
|
|
505
510
|
|
|
506
511
|
```tsx
|
|
507
|
-
// src/pages/blog/[slug].tsx
|
|
512
|
+
// src/pages/blog/[slug].page.tsx
|
|
508
513
|
export const renderMode = 'static' as const
|
|
509
514
|
|
|
510
515
|
export const getStaticPaths = async () => [
|
|
@@ -729,7 +734,7 @@ Both are static module exports — the framework discovers them, the build pipel
|
|
|
729
734
|
## A loader
|
|
730
735
|
|
|
731
736
|
```tsx
|
|
732
|
-
// src/pages/notes/[id].tsx
|
|
737
|
+
// src/pages/notes/[id].page.tsx
|
|
733
738
|
import { useLoaderData } from '@voltro/web'
|
|
734
739
|
|
|
735
740
|
interface Note {
|
|
@@ -839,7 +844,7 @@ splits the loader's result into data that blocks the shell and data that
|
|
|
839
844
|
soon as the promise settles.
|
|
840
845
|
|
|
841
846
|
```tsx
|
|
842
|
-
// src/pages/dashboard.tsx
|
|
847
|
+
// src/pages/dashboard.page.tsx
|
|
843
848
|
import { Await, defer, useLoaderData } from '@voltro/web'
|
|
844
849
|
|
|
845
850
|
export const renderMode = 'ssr' as const // required — see below
|
|
@@ -959,7 +964,7 @@ Use `signal` for any fetch that could outlive the request — pass it to `fetch(
|
|
|
959
964
|
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:
|
|
960
965
|
|
|
961
966
|
```tsx
|
|
962
|
-
// src/pages/notes/[id].tsx
|
|
967
|
+
// src/pages/notes/[id].page.tsx
|
|
963
968
|
import { useSubscription } from '@voltro/client'
|
|
964
969
|
import { useLoaderData, type PageMeta } from '@voltro/web'
|
|
965
970
|
|
|
@@ -1131,7 +1136,7 @@ export const meta = ({ locale }: { locale: string }): PageMeta => {
|
|
|
1131
1136
|
### Authenticated dashboard with cookie-driven loader
|
|
1132
1137
|
|
|
1133
1138
|
```tsx
|
|
1134
|
-
// src/pages/dashboard/index.tsx
|
|
1139
|
+
// src/pages/dashboard/index.page.tsx
|
|
1135
1140
|
import { useLoaderData } from '@voltro/web'
|
|
1136
1141
|
|
|
1137
1142
|
export const renderMode = 'ssr' as const
|
|
@@ -1157,7 +1162,7 @@ export default function Dashboard() {
|
|
|
1157
1162
|
`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):
|
|
1158
1163
|
|
|
1159
1164
|
```tsx
|
|
1160
|
-
// src/pages/blog/[slug].tsx
|
|
1165
|
+
// src/pages/blog/[slug].page.tsx
|
|
1161
1166
|
import { listPostSlugs, type Post } from '../../content/posts'
|
|
1162
1167
|
|
|
1163
1168
|
export const renderMode = 'static' as const
|
|
@@ -1558,7 +1563,7 @@ export default island(LikeButton, { name: 'LikeButton', hydrate: 'visible' })
|
|
|
1558
1563
|
Use it in a page:
|
|
1559
1564
|
|
|
1560
1565
|
```tsx
|
|
1561
|
-
// src/pages/blog/[slug].tsx
|
|
1566
|
+
// src/pages/blog/[slug].page.tsx
|
|
1562
1567
|
import LikeButton from '../../components/LikeButton.island'
|
|
1563
1568
|
|
|
1564
1569
|
export const renderMode = 'static' as const
|
|
@@ -1093,16 +1093,26 @@ readable in a single place.
|
|
|
1093
1093
|
```tsx
|
|
1094
1094
|
import { defineTracking, useTracking } from '@voltro/client'
|
|
1095
1095
|
|
|
1096
|
-
|
|
1096
|
+
interface CheckoutButtonProps {
|
|
1097
|
+
readonly plan: 'free' | 'pro'
|
|
1098
|
+
readonly onClick: () => void
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const spec = defineTracking<CheckoutButtonProps>('CheckoutButton', {
|
|
1097
1102
|
onMount: 'checkout.viewed',
|
|
1098
1103
|
onClick: (props) => ({ event: 'checkout.started', plan: props.plan }),
|
|
1099
1104
|
})
|
|
1100
1105
|
|
|
1101
|
-
const CheckoutButton = (props) => {
|
|
1106
|
+
const CheckoutButton = (props: CheckoutButtonProps) => {
|
|
1102
1107
|
const tracked = useTracking(spec, props, sink)
|
|
1103
1108
|
return <button {...tracked}>Checkout</button>
|
|
1104
1109
|
}
|
|
1105
1110
|
```
|
|
1111
|
+
**Name the props.** The type parameter is what lets a payload builder read
|
|
1112
|
+
`props.plan` instead of indexing into `unknown` and casting — and a cast is the
|
|
1113
|
+
last thing that belongs in the one file that states what leaves the browser. It
|
|
1114
|
+
defaults to an untyped bag, so a loose catalogue still compiles; `useTracking`
|
|
1115
|
+
then returns the props type, which is what makes `{...tracked}` typecheck at all.
|
|
1106
1116
|
|
|
1107
1117
|
`useTracking(spec, props, sink)` returns a **copy of your props** with every
|
|
1108
1118
|
callback the map names wrapped, so invoking `onClick` fires its event and then
|
|
@@ -137,7 +137,6 @@ export const notes = table('notes', {
|
|
|
137
137
|
id: id({ prefix: 'note' }), title: text(), body: text(), done: boolean().default(false),
|
|
138
138
|
})
|
|
139
139
|
.with(tenant()) // pulls audit() transitively → tenantId + createdAt/updatedAt/createdBy/updatedBy
|
|
140
|
-
.reactive() // subscriptions get a fresh snapshot/delta on every write to notes
|
|
141
140
|
|
|
142
141
|
export type Note = InferRow<typeof notes>
|
|
143
142
|
export const database = databaseHandle({ actors, tenants, notes })
|
|
@@ -588,13 +587,12 @@ export const orders = table('orders', {
|
|
|
588
587
|
amountCents: integer(),
|
|
589
588
|
})
|
|
590
589
|
.with(tenant()) // pulls audit() transitively → tenantId + createdAt/updatedAt/createdBy/updatedBy
|
|
591
|
-
.reactive() // subscriber + live subscriptions wake on every write to orders
|
|
592
590
|
|
|
593
591
|
export type Order = InferRow<typeof orders>
|
|
594
592
|
export const database = databaseHandle({ actors, tenants, orders })
|
|
595
593
|
```
|
|
596
594
|
|
|
597
|
-
`tenant()` makes the table tenant-scoped: the runtime AND-merges `eq('tenantId', subject.tenantId)` into every subscription so cross-tenant reads can't leak.
|
|
595
|
+
`tenant()` makes the table tenant-scoped: the runtime AND-merges `eq('tenantId', subject.tenantId)` into every subscription so cross-tenant reads can't leak. Every table is in the matcher engine by default, so the `orderChanges` subscriber and any live query fire on every mutation write. See [multi-tenancy](/docs/multi-tenancy/overview).
|
|
598
596
|
|
|
599
597
|
## Place — mutation that emits a domain event
|
|
600
598
|
|
|
@@ -1292,7 +1290,6 @@ export const docs = table('docs', {
|
|
|
1292
1290
|
}),
|
|
1293
1291
|
)
|
|
1294
1292
|
// Opt into the matcher engine so subscriptions over `docs` re-fire on write.
|
|
1295
|
-
.reactive()
|
|
1296
1293
|
|
|
1297
1294
|
export type Doc = InferRow<typeof docs>
|
|
1298
1295
|
|
|
@@ -1459,7 +1456,6 @@ export const authors = table('authors', {
|
|
|
1459
1456
|
})
|
|
1460
1457
|
// tenant() pulls audit() transitively → tenantId + createdAt/updatedAt/createdBy/updatedBy
|
|
1461
1458
|
.with(tenant())
|
|
1462
|
-
.reactive()
|
|
1463
1459
|
```
|
|
1464
1460
|
|
|
1465
1461
|
```ts
|
|
@@ -1588,7 +1584,6 @@ export const books = table('books', {
|
|
|
1588
1584
|
weights: { title: 'A', summary: 'B' },
|
|
1589
1585
|
})
|
|
1590
1586
|
.with(tenant())
|
|
1591
|
-
.reactive()
|
|
1592
1587
|
```
|
|
1593
1588
|
|
|
1594
1589
|
The query executor compiles the FTS predicate via `.matching('bookSearch', q)` — resolving the index's covered columns + config from the declaration on the table. No tenant filter is added by hand; `books` carries `tenant()`, so the runtime AND-merges `eq('tenantId', subject.tenantId)`:
|
|
@@ -2935,7 +2930,6 @@ export const users = table('users', {
|
|
|
2935
2930
|
// adds deactivatedAt + deactivatedBy (→ actors); pulls audit() transitively.
|
|
2936
2931
|
// NO defaultWhere — a deactivated user still shows up in queries.
|
|
2937
2932
|
.with(deactivation())
|
|
2938
|
-
.reactive()
|
|
2939
2933
|
```
|
|
2940
2934
|
|
|
2941
2935
|
Deactivate with a normal update — `deactivatedAt` is just a column:
|
|
@@ -3122,7 +3116,6 @@ export const syncedEvents = table('synced_events', {
|
|
|
3122
3116
|
sequence: integer(),
|
|
3123
3117
|
})
|
|
3124
3118
|
.with(tenant()) // tenantId + audit columns, auto-stamped from the subject
|
|
3125
|
-
.reactive() // events.list subscription wakes on every insert
|
|
3126
3119
|
|
|
3127
3120
|
export type SyncedEvent = InferRow<typeof syncedEvents>
|
|
3128
3121
|
export const database = databaseHandle({ actors, tenants, syncedEvents })
|
|
@@ -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/index.tsx` is plain JSX with the two static-render exports:
|
|
42
|
+
`src/pages/index.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/index.tsx</code> to make it yours.</p>
|
|
62
|
+
<p>Edit <code>src/pages/index.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/index.tsx
|
|
83
|
+
// src/pages/index.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].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`**.
|
|
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].tsx` is the whole engine. It declares:
|
|
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/index.tsx — a pure client-side SPA (a bill splitter)
|
|
421
|
+
// src/pages/index.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/index.tsx
|
|
606
|
+
// src/pages/index.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.tsx
|
|
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.tsx
|
|
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].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.
|
|
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].tsx
|
|
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/index.tsx
|
|
975
|
+
// src/pages/index.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/index.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:
|
|
1109
|
+
`src/pages/index.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/index.tsx
|
|
1112
|
+
// src/pages/index.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
|
-
└── index.tsx # the reactive page — useSubscription + useMutation
|
|
1361
|
+
└── index.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/index.tsx
|
|
1393
|
+
// src/pages/index.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/index.tsx` to match your descriptors. The hook shapes are identical for any api.
|
|
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/index.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]/index.tsx — the entire file
|
|
1536
|
+
// src/pages/[locale]/index.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/index.tsx
|
|
1553
|
+
// src/pages/index.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.tsx` AND a one-line mirror `src/pages/[locale]/foo.tsx`. Without the mirror, only the default-locale URL is emitted — `/de/foo` 404s.
|
|
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/index.tsx
|
|
1762
|
+
// src/pages/dashboard/index.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/index.tsx
|
|
1887
|
+
// src/pages/index.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].tsx (abridged)
|
|
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].tsx` with a hand-built page for any entity that needs more than CRUD.
|
|
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
|
|
|
@@ -23,7 +23,7 @@ Everything in it is in-memory and synchronous-friendly — no docker, no running
|
|
|
23
23
|
|
|
24
24
|
| Export | Use |
|
|
25
25
|
|---|---|
|
|
26
|
-
| `makeTestContext(options?)` | The request `ctx` a handler/tool sees at runtime — subject-scoped `ctx.store`, `clock`, `email`, optional `ai`. → [Unit testing](/docs/testing/unit-testing) |
|
|
26
|
+
| `makeTestContext(options?)` | The request `ctx` a handler/tool sees at runtime — subject-scoped `ctx.store`, `clock`, `email`, `webhooks`, optional `ai`. → [Unit testing](/docs/testing/unit-testing) |
|
|
27
27
|
| `mockStore(seed)` | Seed rows: `makeTestContext({ store: mockStore({ todos: [...] }) })`. |
|
|
28
28
|
| `MockClock` / `MockEmail` / `MockLLM` | Deterministic clock, captured email, queued LLM responses. |
|
|
29
29
|
| `makeVoltroTestClient(config)` | The frontend harness — render a component against mocked `useSubscription` / `useMutation`, from the `@voltro/testing/client` subpath. → [Component testing](/docs/testing/component-testing) |
|
|
@@ -51,6 +51,8 @@ voltro e2e # boot api + web siblings, run tests, tear down
|
|
|
51
51
|
|
|
52
52
|
`voltro test` is a thin wrapper over vitest — it runs the app's test files with the framework's config. Everything `@voltro/testing` exports is plain TypeScript you import inside those files; there's no special test runner.
|
|
53
53
|
|
|
54
|
+
`e2e/` is left alone: those specs belong to `voltro e2e`, which drives them through tsx against a booted api + web. They define no vitest suite, so collecting them would report "No test suite found" — a red run for an app laid out exactly as the framework asks. Pass your own `--exclude` and it wins outright.
|
|
55
|
+
|
|
54
56
|
## What NOT to do
|
|
55
57
|
|
|
56
58
|
- **Don't spin up Postgres for a handler test.** `makeTestContext`'s in-memory store enforces the same mixin behaviour — tenant scoping, soft-delete, audit — so a unit test catches the same class of bug far faster. Save a real database for [dialect parity](/docs/testing/dialect-parity) and e2e.
|
|
@@ -480,6 +482,19 @@ ctx.email.lastTo('a@b.com') // the most recent email to that address, or unde
|
|
|
480
482
|
ctx.email.clear() // reset between cases
|
|
481
483
|
```
|
|
482
484
|
|
|
485
|
+
### `ctx.webhooks` — `MockWebhooks`
|
|
486
|
+
|
|
487
|
+
Records outgoing webhook emissions. Every context derived from this one — the transaction context `invoke` builds for a mutation, a `withSubject` / `withTenant` block — shares the same recorder, so what you assert on IS what the handler emitted.
|
|
488
|
+
|
|
489
|
+
```ts
|
|
490
|
+
ctx.webhooks.emitted // EmittedWebhook[] — every emit, in order
|
|
491
|
+
ctx.webhooks.last('todo.created') // the most recent emission of that event
|
|
492
|
+
ctx.webhooks.payloadsFor('todo.created') // just the payloads, oldest first
|
|
493
|
+
ctx.webhooks.clear() // reset between cases
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
It records; it does not deliver, sign, or consult subscriptions. `emit` reports `{ delivered: 0 }` because no targets are subscribed, and saying so is more honest than a number a test might assert against.
|
|
497
|
+
|
|
483
498
|
### `ctx.llm` — `MockLLM`
|
|
484
499
|
|
|
485
500
|
A queue of canned model responses plus a record of every call. Each `MockResponse` is one of `{ text }`, `{ toolCall: { name, input } }`, or `{ error: { code, message? } }`.
|
|
@@ -76,6 +76,22 @@ BREAKING entries name a codemod; run `voltro update` to apply it.
|
|
|
76
76
|
A dev container running as root with the host workspace bind-mounted wrote `apps/api/.env.local` as `root:root 0600` INTO THE SHARED WORKSPACE. On the host, everything that loads env then died with EACCES — vitest, `voltro doctor`, the editor — and the developer could not even read the file, while the next container boot recreated it. Container-with-bind-mount is the ordinary dev shape, not an edge case.
|
|
77
77
|
|
|
78
78
|
`0600` stays (the file holds a real signing key), because loosening it to `0644` would make that key readable by every account on the machine for the far more common single-user case. Ownership was the wrong variable, so that is the one corrected: the mint chowns the file to whoever owns the directory, which root can do — exactly the case that needs it — and reports loudly when it cannot. A plain EACCES while loading an env file now names the owning uid, the mode and the current uid, because that pair IS the diagnosis and none of it appears in node's message.
|
|
79
|
+
- **@voltro/cli** — Framework-generated output is handed to the workspace's owner, not left owned by whoever the process happens to be.
|
|
80
|
+
|
|
81
|
+
The previous release fixed this for the minted `.env.local`. The report that followed showed the scope was wrong: it is EVERY directory the framework generates. A dev pod running as root with the host monorepo bind-mounted leaves `.framework/` and `app.graph.observed.*` as `root:root` inside the developer's own tree, and on the host:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
voltro build . → EACCES: permission denied, open '…/apps/display/.framework/index.html'
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
That is the harder failure. `.env.local` broke env loading; this breaks the production build of every web app outright, with no workaround short of chown-ing by hand after each pod boot. One team could only verify their frontends through test suites and live requests against the running pods.
|
|
88
|
+
|
|
89
|
+
`voltro dev` and `voltro build` now hand their generated output — `.framework`, `.env.local`, every `*.generated.*` — to the uid that owns the app root, and say so loudly when they cannot. A no-op on every ordinary run and in any container started with `--user <uid>:<gid>`: when the process already owns the root it returns without touching the tree. Only generated state is claimed; the framework never chowns a file a human wrote.
|
|
90
|
+
- **@voltro/cli** — The observed app-graph no longer restarts the dev server.
|
|
91
|
+
|
|
92
|
+
`app.graph.observed.json` was written into the watched app root every 10 seconds, and the supervisor's watcher fired on each write. A downstream pod measured two restarts before every boot over 2000 log lines — the rule, not an outlier — and paid a ~46 s boot three times per save.
|
|
93
|
+
|
|
94
|
+
The watcher excludes `<name>.generated.<ext>`, a substring rule chosen precisely because a per-extension whitelist had already let a generated file slip twice. This file slipped it a third time by not carrying the segment at all. It is now `app.graph.observed.generated.json`, which matches the convention instead of adding a fourth special case to a list that has drifted three times; a stale un-suffixed file from an older dev server is removed on boot so it cannot keep triggering restarts.
|
|
79
95
|
- **@voltro/cli** — Four tooling fixes, all from downstream reports:
|
|
80
96
|
|
|
81
97
|
- **`voltro check --offline` crashed on any app that declares a workflow.** It built workflow entries as `{ name }` behind an `as never` while `InspectWorkflowEntry` is keyed by `tag`, so the manifest's sort read `undefined` and threw — surfacing as "could not assemble the graph from source" rather than the type error underneath. The cast is what let the two shapes disagree. - **`voltro check --offline` reported plugin tables as `dangling-source`.** It collected only the app's own `*.entity.ts` tables, so a query reading `_voltro_storage_refs` was an `error` — which sets the exit code, failing the CI gate the offline mode exists for. It now uses the same `assembleFrameworkTables` the migrator does. - **`voltro test` now derives `resolve.alias` from the app's tsconfig `paths`.** An app mapping `@/* → ./src/*` could not test any module importing through it (`Cannot find package '@/locales/en'`), and the workaround was a local `vitest.config.ts` restating what tsconfig already said. - **The `raw-fetch` doctor rule follows the import graph.** Keyed on filename conventions it caught 9 of 39 outbound calls on the reporting app; the other 30 were in `lib/*.ts` helpers only server code imports. A file reachable from a server-convention file and from nothing else is server code; one a page also imports is not, and stays unflagged.
|