@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.
Files changed (139) hide show
  1. package/CHANGELOG.md +297 -0
  2. package/bin/voltro.mjs +6 -1
  3. package/dist/{apiBuild-BJtiqoOu.js → apiBuild-CPDTJHkH.js} +2 -2
  4. package/dist/apiBuild-D1UBJ4TM.js +2 -0
  5. package/dist/bin.js +3 -3
  6. package/dist/{commands-8A5RU3MW.js → commands-laJDMj2m.js} +2679 -2444
  7. package/dist/dbCommand-C3R5LrBZ.js +2 -0
  8. package/dist/{dbCommand-OPMtO0sX.js → dbCommand-DJSxnjPt.js} +2 -2
  9. package/dist/{dev-DGjEvvkV.js → dev-BOFxC21E.js} +1076 -1013
  10. package/dist/{dev-HdG7JdSk.js → dev-DOK0w6ZW.js} +1 -1
  11. package/dist/{fileConventions-Cu0tM-Bl.js → fileConventions-CBNPZVOV.js} +1 -1
  12. package/dist/frameworkTableAssembly-DRJrAVr1.js +2 -0
  13. package/dist/{frameworkTableAssembly-BA2bAmYN.js → frameworkTableAssembly-sg41s14F.js} +1 -1
  14. package/dist/index.js +1 -1
  15. package/dist/{inspectMetrics-CYDSO3za.js → inspectMetrics-D1DmLeJs.js} +491 -478
  16. package/dist/{serveCommand-C3lq3mFd.js → serveCommand-CxcxHc9Y.js} +322 -322
  17. package/dist/serveEntry.js +2 -2
  18. package/dist/{start-BtgatJN3.js → start-Clvz4IJb.js} +379 -379
  19. package/dist/startEntry.js +2 -2
  20. package/package.json +17 -17
  21. package/templates/AGENTS.core.md +62 -9
  22. package/templates/AGENTS.md +63 -10
  23. package/templates/agent-docs/_index.md +1 -1
  24. package/templates/agent-docs/authentication.md +48 -0
  25. package/templates/agent-docs/cli.md +11 -7
  26. package/templates/agent-docs/database/migrations.md +3 -3
  27. package/templates/agent-docs/database/querying.md +6 -2
  28. package/templates/agent-docs/internationalization.md +3 -3
  29. package/templates/agent-docs/introduction.md +17 -3
  30. package/templates/agent-docs/reference.md +4 -4
  31. package/templates/agent-docs/routing.md +42 -40
  32. package/templates/agent-docs/schema-driven-ui.md +6 -3
  33. package/templates/agent-docs/templates/appshells.md +24 -24
  34. package/templates/agent-docs/whats-new.md +170 -65
  35. package/templates/apps/api-ai/package.json +7 -7
  36. package/templates/apps/api-auth/package.json +8 -8
  37. package/templates/apps/api-backend/package.json +7 -7
  38. package/templates/apps/api-backend-deactivation/package.json +7 -7
  39. package/templates/apps/api-backend-mail/package.json +8 -8
  40. package/templates/apps/api-backend-mariadb/package.json +9 -9
  41. package/templates/apps/api-backend-storage/package.json +8 -8
  42. package/templates/apps/api-data-advanced/package.json +8 -8
  43. package/templates/apps/api-durable/package.json +8 -8
  44. package/templates/apps/api-feature-flags/package.json +9 -9
  45. package/templates/apps/api-governance/package.json +8 -8
  46. package/templates/apps/api-kv/package.json +8 -8
  47. package/templates/apps/api-moderation/package.json +8 -8
  48. package/templates/apps/api-observability/package.json +8 -8
  49. package/templates/apps/api-ratelimit/package.json +8 -8
  50. package/templates/apps/api-rbac/package.json +8 -8
  51. package/templates/apps/api-rest/package.json +7 -7
  52. package/templates/apps/api-saas/package.json +11 -11
  53. package/templates/apps/api-search/package.json +8 -8
  54. package/templates/apps/api-versioning/package.json +8 -8
  55. package/templates/apps/api-webhooks/package.json +9 -9
  56. package/templates/apps/changelog/package.json +6 -6
  57. package/templates/apps/changelog/src/pages/[locale]/{[slug].page.tsx → [slug]/page.tsx} +4 -4
  58. package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +2 -2
  59. package/templates/apps/changelog/src/pages/[locale]/{index.page.tsx → page.tsx} +2 -2
  60. package/templates/apps/changelog/src/pages/{[slug].page.test.tsx → [slug]/page.test.tsx} +1 -1
  61. package/templates/apps/changelog/src/pages/{[slug].page.tsx → [slug]/page.tsx} +2 -2
  62. package/templates/apps/changelog/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
  63. package/templates/apps/edge-functions/package.json +2 -2
  64. package/templates/apps/frontend-admin/package.json +8 -8
  65. package/templates/apps/frontend-admin/src/pages/(marketing)/{login.page.test.tsx → login/page.test.tsx} +2 -2
  66. package/templates/apps/frontend-admin/src/pages/(marketing)/{login.page.tsx → login/page.tsx} +2 -2
  67. package/templates/apps/frontend-admin/src/pages/(marketing)/{index.page.test.tsx → page.test.tsx} +1 -1
  68. package/templates/apps/frontend-admin/src/pages/admin/{[entity].page.tsx → [entity]/page.tsx} +1 -1
  69. package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +1 -1
  70. package/templates/apps/frontend-admin/src/pages/admin/{index.page.test.tsx → page.test.tsx} +1 -1
  71. package/templates/apps/frontend-app/package.json +8 -8
  72. package/templates/apps/frontend-app/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
  73. package/templates/apps/frontend-app/src/pages/{schema-ui.page.test.tsx → schema-ui/page.test.tsx} +2 -2
  74. package/templates/apps/frontend-app/src/pages/{schema-ui.page.tsx → schema-ui/page.tsx} +1 -1
  75. package/templates/apps/frontend-blank/package.json +7 -7
  76. package/templates/apps/frontend-blank/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
  77. package/templates/apps/frontend-contact/package.json +7 -7
  78. package/templates/apps/frontend-contact/src/pages/[locale]/{index.page.tsx → page.tsx} +2 -2
  79. package/templates/apps/frontend-contact/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
  80. package/templates/apps/frontend-dashboard/package.json +7 -7
  81. package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.page.test.tsx → login/page.test.tsx} +2 -2
  82. package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.page.tsx → login/page.tsx} +2 -2
  83. package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.page.test.tsx → page.test.tsx} +1 -1
  84. package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.page.test.tsx → page.test.tsx} +1 -1
  85. package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.page.test.tsx → settings/page.test.tsx} +2 -2
  86. package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.page.tsx → settings/page.tsx} +2 -2
  87. package/templates/apps/frontend-docs/package.json +7 -7
  88. package/templates/apps/frontend-docs/src/pages/[locale]/docs/{[...slug].page.tsx → [...slug]/page.tsx} +4 -4
  89. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +2 -2
  90. package/templates/apps/frontend-docs/src/pages/[locale]/{index.page.tsx → page.tsx} +2 -2
  91. package/templates/apps/frontend-docs/src/pages/docs/{[...slug].page.test.tsx → [...slug]/page.test.tsx} +3 -3
  92. package/templates/apps/frontend-docs/src/pages/docs/{[...slug].page.tsx → [...slug]/page.tsx} +1 -1
  93. package/templates/apps/frontend-docs/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
  94. package/templates/apps/frontend-i18n/package.json +6 -6
  95. package/templates/apps/frontend-i18n/src/pages/[locale]/{about.page.tsx → about/page.tsx} +3 -3
  96. package/templates/apps/frontend-i18n/src/pages/[locale]/mirrors.test.tsx +2 -2
  97. package/templates/apps/frontend-i18n/src/pages/[locale]/{index.page.tsx → page.tsx} +2 -2
  98. package/templates/apps/frontend-i18n/src/pages/{about.page.test.tsx → about/page.test.tsx} +3 -3
  99. package/templates/apps/frontend-i18n/src/pages/{about.page.tsx → about/page.tsx} +1 -1
  100. package/templates/apps/frontend-i18n/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
  101. package/templates/apps/frontend-landing/package.json +7 -7
  102. package/templates/apps/frontend-landing/src/pages/[locale]/mirrors.test.tsx +1 -1
  103. package/templates/apps/frontend-landing/src/pages/[locale]/{index.page.tsx → page.tsx} +2 -2
  104. package/templates/apps/frontend-landing/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
  105. package/templates/apps/frontend-spa/package.json +7 -7
  106. package/templates/apps/frontend-spa/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
  107. package/templates/apps/frontend-ssr/package.json +7 -7
  108. package/templates/apps/frontend-ssr/src/pages/{feed.page.test.tsx → feed/page.test.tsx} +2 -2
  109. package/templates/apps/frontend-ssr/src/pages/{feed.page.tsx → feed/page.tsx} +1 -1
  110. package/templates/apps/frontend-ssr/src/pages/{feed-swr.page.test.tsx → feed-swr/page.test.tsx} +2 -2
  111. package/templates/apps/frontend-ssr/src/pages/{feed-swr.page.tsx → feed-swr/page.tsx} +1 -1
  112. package/templates/apps/frontend-ssr/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
  113. package/templates/apps/frontend-ssr-api/package.json +8 -8
  114. package/templates/apps/frontend-ssr-api/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
  115. package/templates/apps/frontend-static-blog/package.json +6 -6
  116. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/{[slug].page.tsx → [slug]/page.tsx} +4 -4
  117. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +2 -2
  118. package/templates/apps/frontend-static-blog/src/pages/[locale]/{index.page.tsx → page.tsx} +2 -2
  119. package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].page.test.tsx → [slug]/page.test.tsx} +3 -3
  120. package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].page.tsx → [slug]/page.tsx} +3 -3
  121. package/templates/apps/frontend-static-blog/src/pages/{index.page.test.tsx → page.test.tsx} +1 -1
  122. package/dist/apiBuild-BOczx9-m.js +0 -2
  123. package/dist/dbCommand-DNQ6l-su.js +0 -2
  124. package/dist/frameworkTableAssembly-D1zJSgOU.js +0 -2
  125. /package/templates/apps/changelog/src/pages/{index.page.tsx → page.tsx} +0 -0
  126. /package/templates/apps/frontend-admin/src/pages/(marketing)/{index.page.tsx → page.tsx} +0 -0
  127. /package/templates/apps/frontend-admin/src/pages/admin/{index.page.tsx → page.tsx} +0 -0
  128. /package/templates/apps/frontend-app/src/pages/{index.page.tsx → page.tsx} +0 -0
  129. /package/templates/apps/frontend-blank/src/pages/{index.page.tsx → page.tsx} +0 -0
  130. /package/templates/apps/frontend-contact/src/pages/{index.page.tsx → page.tsx} +0 -0
  131. /package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.page.tsx → page.tsx} +0 -0
  132. /package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.page.tsx → page.tsx} +0 -0
  133. /package/templates/apps/frontend-docs/src/pages/{index.page.tsx → page.tsx} +0 -0
  134. /package/templates/apps/frontend-i18n/src/pages/{index.page.tsx → page.tsx} +0 -0
  135. /package/templates/apps/frontend-landing/src/pages/{index.page.tsx → page.tsx} +0 -0
  136. /package/templates/apps/frontend-spa/src/pages/{index.page.tsx → page.tsx} +0 -0
  137. /package/templates/apps/frontend-ssr/src/pages/{index.page.tsx → page.tsx} +0 -0
  138. /package/templates/apps/frontend-ssr-api/src/pages/{index.page.tsx → page.tsx} +0 -0
  139. /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 `*.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.
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
- ├── index.page.tsx # /
29
- ├── about.page.tsx # /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.page.tsx # /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].page.tsx # /users/:id
37
- │ └── index.page.tsx # /users
36
+ │ ├── [id]/page.tsx # /users/:id
37
+ │ └── page.tsx # /users
38
38
  └── docs/
39
- └── [...slug].page.tsx # /docs/<anything> (catch-all)
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 `*.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.
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.page.tsx → /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/index.page.tsx → /
149
- src/pages/users/index.page.tsx → /users
150
- src/pages/admin/index.page.tsx → /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].page.tsx → /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].page.tsx
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].page.tsx → /docs/<anything>
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]].page.tsx
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/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
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 `*.page.tsx` is a route. Everything else under `src/pages/` is ordinary code and may sit next to the page that uses it:
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
- │ ├── index.page.tsx # → /users
251
- │ ├── index.page.test.tsx # its test
252
- │ ├── [id].page.tsx # → /users/: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.page.tsx # /about → wrapped in outer layout
324
+ ├── about/page.tsx # /about → wrapped in outer layout
323
325
  └── dashboard/
324
326
  ├── layout.tsx # nested (only /dashboard/*)
325
- ├── index.page.tsx # /dashboard
326
- └── settings.page.tsx # /dashboard/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
- │ ├── index.page.tsx # /
350
- │ ├── pricing.page.tsx # /pricing
351
- │ └── about.page.tsx # /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.page.tsx # /dashboard
355
- └── settings.page.tsx # /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.page.tsx
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].page.tsx
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].page.tsx
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.page.tsx
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].page.tsx
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/index.page.tsx
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].page.tsx
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].page.tsx
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 inspect surface is open in dev. When a deploy sets an inspect token the
907
- manifest GET is bearer-gated, so an admin UI pointed at a locked-down api has to
908
- supply that token a deployment concern, not something this hook handles.
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/index.page.tsx` is plain JSX with the two static-render exports:
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/index.page.tsx</code> to make it yours.</p>
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/index.page.tsx
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].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`**.
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].page.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.page.tsx — a pure client-side SPA (a bill splitter)
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/index.page.tsx
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.page.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.page.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].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.
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].page.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.page.tsx
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/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:
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/index.page.tsx
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
- └── index.page.tsx # the reactive page — useSubscription + useMutation
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/index.page.tsx
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/index.page.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/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.page.tsx — the entire file
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/index.page.tsx
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.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.
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.page.tsx
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/index.page.tsx
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].page.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].page.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