@warlock.js/web 5.5.0 → 5.7.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 +53 -5
- package/esm/build/contribution.d.mts +2 -2
- package/esm/build/contribution.mjs +1 -1
- package/esm/build/contribution.mjs.map +1 -1
- package/esm/build/discover-pages.mjs +130 -208
- package/esm/build/discover-pages.mjs.map +1 -1
- package/esm/build/generate-pages-barrel.mjs +7 -5
- package/esm/build/generate-pages-barrel.mjs.map +1 -1
- package/esm/build/page-routes-manifest.mjs +1 -1
- package/esm/build/public-files.mjs +30 -2
- package/esm/build/public-files.mjs.map +1 -1
- package/esm/build/read-metadata-keys.mjs +225 -0
- package/esm/build/read-metadata-keys.mjs.map +1 -0
- package/esm/build/read-route-exports.mjs.map +1 -1
- package/esm/client/build-hydrated-tree.mjs +1 -1
- package/esm/client/build-hydrated-tree.mjs.map +1 -1
- package/esm/client/navigation/document-metadata.mjs +108 -0
- package/esm/client/navigation/document-metadata.mjs.map +1 -0
- package/esm/client/navigation/fetch-page-data.mjs +3 -10
- package/esm/client/navigation/fetch-page-data.mjs.map +1 -1
- package/esm/client/navigation/navigation-root.mjs +24 -104
- package/esm/client/navigation/navigation-root.mjs.map +1 -1
- package/esm/client/navigation/scroll-to-fragment.mjs.map +1 -1
- package/esm/client/navigation/use-query-string.d.mts +35 -0
- package/esm/client/navigation/use-query-string.mjs +68 -0
- package/esm/client/navigation/use-query-string.mjs.map +1 -0
- package/esm/client/runtime/index.d.mts +1 -1
- package/esm/client/runtime/index.mjs +1 -1
- package/esm/client/runtime/manifest.mjs.map +1 -1
- package/esm/client/runtime/matcher.mjs.map +1 -1
- package/esm/client/runtime/types.d.mts +1 -1
- package/esm/components/link.mjs.map +1 -1
- package/esm/core/src/router/normalize-route-path.mjs +33 -0
- package/esm/core/src/router/normalize-route-path.mjs.map +1 -0
- package/esm/{hydration → entry}/index.mjs +1 -1
- package/esm/entry/index.mjs.map +1 -0
- package/esm/hydration-payload.mjs +19 -10
- package/esm/hydration-payload.mjs.map +1 -1
- package/esm/index.d.mts +3 -2
- package/esm/index.mjs +3 -2
- package/esm/loaders.d.mts +13 -1
- package/esm/localization.mjs.map +1 -1
- package/esm/metadata.d.mts +4 -4
- package/esm/metadata.mjs.map +1 -1
- package/esm/node_modules/.pnpm/@mongez_concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs +33 -0
- package/esm/node_modules/.pnpm/@mongez_concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs.map +1 -0
- package/esm/{runtime/register-modules.d.mts → register-modules.d.mts} +1 -1
- package/esm/{runtime/register-modules.mjs → register-modules.mjs} +1 -1
- package/esm/register-modules.mjs.map +1 -0
- package/esm/route.d.mts +9 -0
- package/esm/routing/duplicate-route-path.mjs +22 -0
- package/esm/routing/duplicate-route-path.mjs.map +1 -0
- package/esm/routing/layout-level.mjs +50 -0
- package/esm/routing/layout-level.mjs.map +1 -0
- package/esm/routing/layout-policy.mjs.map +1 -1
- package/esm/routing/query-string.d.mts +23 -1
- package/esm/routing/query-string.mjs +23 -1
- package/esm/routing/query-string.mjs.map +1 -1
- package/esm/routing/route-identity.mjs +27 -2
- package/esm/routing/route-identity.mjs.map +1 -1
- package/esm/routing/route-table.mjs.map +1 -1
- package/esm/routing/router-events.d.mts +5 -4
- package/esm/routing/router-events.mjs.map +1 -1
- package/esm/server/create-page-route-handler.d.mts +2 -2
- package/esm/server/create-page-route-handler.mjs +18 -12
- package/esm/server/create-page-route-handler.mjs.map +1 -1
- package/esm/server/{dev-server.mjs → dev-error-transport.mjs} +2 -2
- package/esm/server/dev-error-transport.mjs.map +1 -0
- package/esm/server/execute-page-request.mjs +91 -35
- package/esm/server/execute-page-request.mjs.map +1 -1
- package/esm/server/execute-page-request.types.d.mts +21 -1
- package/esm/server/fold-layout-loaders.mjs +36 -0
- package/esm/server/fold-layout-loaders.mjs.map +1 -0
- package/esm/server/hydration-client-url.mjs +3 -3
- package/esm/server/hydration-client-url.mjs.map +1 -1
- package/esm/server/index.d.mts +4 -3
- package/esm/server/index.mjs +6 -6
- package/esm/server/install-page-routes-from-manifest.d.mts +10 -1
- package/esm/server/install-page-routes-from-manifest.mjs +61 -58
- package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
- package/esm/server/install-page-routes.d.mts +24 -26
- package/esm/server/install-page-routes.mjs +149 -60
- package/esm/server/install-page-routes.mjs.map +1 -1
- package/esm/server/install-production-page-routes.mjs.map +1 -1
- package/esm/server/match-page-route.mjs +6 -10
- package/esm/server/match-page-route.mjs.map +1 -1
- package/esm/server/not-found-page.d.mts +1 -0
- package/esm/server/not-found-page.mjs +8 -1
- package/esm/server/not-found-page.mjs.map +1 -1
- package/esm/server/page-file-change.mjs +1 -1
- package/esm/server/page-file-change.mjs.map +1 -1
- package/esm/server/page-module-shapes.d.mts +24 -0
- package/esm/server/page-route-reload.mjs +4 -3
- package/esm/server/page-route-reload.mjs.map +1 -1
- package/esm/server/register-production-public-files.mjs +26 -2
- package/esm/server/register-production-public-files.mjs.map +1 -1
- package/esm/server/render-page.d.mts +13 -49
- package/esm/server/render-page.mjs +14 -55
- package/esm/server/render-page.mjs.map +1 -1
- package/esm/server/resolve-page-metadata.mjs.map +1 -1
- package/esm/server/resolve-route-validation-input.mjs +11 -0
- package/esm/server/resolve-route-validation-input.mjs.map +1 -0
- package/esm/server/resolve-validation-data.mjs +1 -1
- package/esm/server/resolve-validation-data.mjs.map +1 -1
- package/esm/server/settle-page-response.mjs +5 -3
- package/esm/server/settle-page-response.mjs.map +1 -1
- package/esm/server/stylesheet-urls.mjs +78 -19
- package/esm/server/stylesheet-urls.mjs.map +1 -1
- package/esm/server/unregistered-pages.mjs +2 -1
- package/esm/server/unregistered-pages.mjs.map +1 -1
- package/esm/server/web-connector-factory.mjs +1 -1
- package/esm/server/web-connector-factory.mjs.map +1 -1
- package/esm/server/web-connector.mjs +25 -322
- package/esm/server/web-connector.mjs.map +1 -1
- package/esm/shared/module-key.mjs +15 -0
- package/esm/shared/module-key.mjs.map +1 -0
- package/esm/shared/to-posix.mjs +13 -0
- package/esm/shared/to-posix.mjs.map +1 -0
- package/esm/shared.mjs +24 -0
- package/esm/shared.mjs.map +1 -1
- package/esm/validation.d.mts +12 -1
- package/esm/vite/build-client.mjs +1 -1
- package/esm/vite/build-client.mjs.map +1 -1
- package/esm/vite/dev-server-config.mjs +240 -0
- package/esm/vite/dev-server-config.mjs.map +1 -0
- package/esm/vite/gate-a-resolve.d.mts +5 -4
- package/esm/vite/gate-a-resolve.mjs +32 -71
- package/esm/vite/gate-a-resolve.mjs.map +1 -1
- package/esm/vite/gate-b-secrets.mjs.map +1 -1
- package/esm/vite/gate-c-verify.d.mts +1 -1
- package/esm/vite/gate-c-verify.mjs +1 -1
- package/esm/vite/gate-c-verify.mjs.map +1 -1
- package/esm/vite/hydration-entries.mjs +6 -8
- package/esm/vite/hydration-entries.mjs.map +1 -1
- package/esm/vite/index.d.mts +1 -1
- package/esm/vite/index.mjs +4 -110
- package/esm/vite/index.mjs.map +1 -1
- package/esm/vite/page-registry-plugin.mjs +9 -26
- package/esm/vite/page-registry-plugin.mjs.map +1 -1
- package/esm/vite/projection.mjs.map +1 -1
- package/esm/vite/react-refresh-preamble.mjs +90 -0
- package/esm/vite/react-refresh-preamble.mjs.map +1 -0
- package/esm/vite/ssr-client-view.mjs +135 -0
- package/esm/vite/ssr-client-view.mjs.map +1 -0
- package/llms-full.txt +564 -482
- package/llms.txt +2 -2
- package/package.json +6 -6
- package/skills/create-a-page/SKILL.md +354 -295
- package/skills/navigate-on-the-client/SKILL.md +212 -189
- package/esm/hydration/index.mjs.map +0 -1
- package/esm/runtime/register-modules.mjs.map +0 -1
- package/esm/server/dev-server.mjs.map +0 -1
- /package/esm/{hydration → entry}/index.d.mts +0 -0
|
@@ -1,295 +1,354 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: create-a-page
|
|
3
|
-
description: 'Create an SSR React page under `src/web/**`, with either a literal `route` or a filesystem-derived one, an explicit public-cache opt-in, a default component, an optional typed `loader`, page `metadata`, the `error.page.tsx` boundary, and the universal `register()` hook. Triggers: `*.page.tsx`, `route`, `route.cache`, `maxAge`, `PageLoader`, `PageProps`, `PageMetadata`, `error.page.tsx`, `register`, `[...slug]`; "create a page", "cache a public page", "add an SSR route", "make a React page", "type page loader data", "add an error boundary", "catch-all route", "page renders blank 200", "page has no default export"; typical import `import type { PageLoader, PageProps } from "@warlock.js/web"`. Skip: root document shell — `@warlock.js/web/write-the-root/SKILL.md`; layout wrappers and prefixes — `@warlock.js/web/use-layouts/SKILL.md`; loader lifecycle and `shared` — `@warlock.js/web/load-page-data/SKILL.md`; competing frameworks `next`, `remix`, `react-router` file routes.'
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Warlock — create a page
|
|
7
|
-
|
|
8
|
-
A page is any `*.page.tsx` beneath `src/web/` — the page root. Its URL is either a declared `route` or one derived from its own location; its default export renders React.
|
|
9
|
-
|
|
10
|
-
## The shape
|
|
11
|
-
|
|
12
|
-
```tsx title="src/web/products/product-details.page.tsx"
|
|
13
|
-
import type { PageLoader, PageMetadata, PageProps } from "@warlock.js/web";
|
|
14
|
-
|
|
15
|
-
export const route = {
|
|
16
|
-
path: "/products/:id",
|
|
17
|
-
name: "products.details",
|
|
18
|
-
cache: { public: true, maxAge: 60 },
|
|
19
|
-
} as const;
|
|
20
|
-
|
|
21
|
-
export const loader = (async ({ request }) => {
|
|
22
|
-
const id = request.input("id");
|
|
23
|
-
|
|
24
|
-
return {
|
|
25
|
-
product: {
|
|
26
|
-
id,
|
|
27
|
-
name: `Product ${id}`,
|
|
28
|
-
},
|
|
29
|
-
};
|
|
30
|
-
}) satisfies PageLoader<undefined, typeof route>;
|
|
31
|
-
|
|
32
|
-
export const metadata: PageMetadata<typeof loader> = ({ data }) => ({
|
|
33
|
-
title: data.product.name,
|
|
34
|
-
description: `Details for ${data.product.name}`,
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
export default function ProductDetailsPage({ data }: PageProps<typeof loader>) {
|
|
38
|
-
return (
|
|
39
|
-
<main>
|
|
40
|
-
<h1>{data.product.name}</h1>
|
|
41
|
-
<p>Product id: {data.product.id}</p>
|
|
42
|
-
</main>
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
Use `satisfies PageLoader`, not `: PageLoader`. `satisfies` checks the context contract while retaining the loader's concrete return type, which is how `PageProps<typeof loader>` knows the shape of `data`.
|
|
48
|
-
|
|
49
|
-
## The minimum page
|
|
50
|
-
|
|
51
|
-
```tsx title="src/web/contact.page.tsx"
|
|
52
|
-
export const route = "/contact";
|
|
53
|
-
|
|
54
|
-
export default function ContactPage() {
|
|
55
|
-
return (
|
|
56
|
-
<main>
|
|
57
|
-
<h1>Contact</h1>
|
|
58
|
-
<a href="mailto:support@example.com">support@example.com</a>
|
|
59
|
-
</main>
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
`route` is optional. A page that omits it derives both its path and its name from where the file sits beneath `src/web` ([filesystem routing](#filesystem-routing), below). A page that declares `route` uses that instead — an explicit `route` always wins over the derived one, for both the path and (when it sets `name`) the name.
|
|
65
|
-
|
|
66
|
-
### The default export is required
|
|
67
|
-
|
|
68
|
-
`route` is optional; the default export is not. A `*.page.tsx` that exports only named bindings is a **hard discovery/build failure naming the file**:
|
|
69
|
-
|
|
70
|
-
```
|
|
71
|
-
The page "src/web/contact.page.tsx" has no runtime default export. Every `*.page.tsx`
|
|
72
|
-
file must default-export the React component it renders. For example:
|
|
73
|
-
`export default function Page() { return <main />; }`
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
Two details worth knowing:
|
|
77
|
-
|
|
78
|
-
- **`export { Page as default }` satisfies the rule** — the check is for a runtime default binding, not for the `export default` keyword form specifically.
|
|
79
|
-
- **`export default interface Page {}` does not.** A type-only default is erased at compile time, so there is no component at runtime; it is treated as missing.
|
|
80
|
-
|
|
81
|
-
A file that cannot be parsed reports separately — `Cannot inspect the default export of "<file>": the file could not be parsed (…). Fix the syntax error and discovery will continue.` — so a syntax error never masquerades as a missing export.
|
|
82
|
-
|
|
83
|
-
## Route declarations
|
|
84
|
-
|
|
85
|
-
Use either a bare path or a literal object:
|
|
86
|
-
|
|
87
|
-
```ts
|
|
88
|
-
export const route = "/products";
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
```ts
|
|
92
|
-
export const route = {
|
|
93
|
-
path: "/products/:id",
|
|
94
|
-
name: "products.details",
|
|
95
|
-
cache: { public: true, maxAge: 60 },
|
|
96
|
-
} as const;
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
Prefer an explicit stable `name` for links. Without one, Warlock derives a name from the declared path — a global root page gets `index`, another global page gets its dotted path — the same derivation [filesystem routing](#filesystem-routing) uses when there is no `route` at all.
|
|
100
|
-
|
|
101
|
-
Every segment of a page's URL is written down somewhere: `route.path` (or the derived filesystem path), prefixed by the literal `prefix` exports of the positional layouts above it ([use-layouts](../use-layouts/SKILL.md)). Where the file sits always decides which layouts are above it, and — only when `route` is absent — the path segments too.
|
|
102
|
-
|
|
103
|
-
The build reads `route` without executing application code. Declare it directly with `export const` and literal strings. Variables, function calls, computed object keys, spreads, and `export { route }` are refused.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
##
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
The
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
-
|
|
294
|
-
-
|
|
295
|
-
-
|
|
1
|
+
---
|
|
2
|
+
name: create-a-page
|
|
3
|
+
description: 'Create an SSR React page under `src/web/**`, with either a literal `route` or a filesystem-derived one, an explicit public-cache opt-in, a `validation` schema and `middleware` guards, a default component, an optional typed `loader`, page `metadata`, the `error.page.tsx` boundary, and the universal `register()` hook. Triggers: `*.page.tsx`, `route`, `route.cache`, `maxAge`, `validation`, `middleware`, `route.validate`, `route.middleware`, `PageLoader`, `PageProps`, `PageMetadata`, `error.page.tsx`, `register`, `[...slug]`; "create a page", "cache a public page", "add an SSR route", "make a React page", "type page loader data", "add an error boundary", "catch-all route", "page renders blank 200", "page has no default export", "validate route params and query", "page-level middleware"; typical import `import type { PageLoader, PageProps } from "@warlock.js/web"`. Skip: root document shell — `@warlock.js/web/write-the-root/SKILL.md`; layout wrappers and prefixes — `@warlock.js/web/use-layouts/SKILL.md`; loader lifecycle and `shared` — `@warlock.js/web/load-page-data/SKILL.md`; competing frameworks `next`, `remix`, `react-router` file routes.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Warlock — create a page
|
|
7
|
+
|
|
8
|
+
A page is any `*.page.tsx` beneath `src/web/` — the page root. Its URL is either a declared `route` or one derived from its own location; its default export renders React.
|
|
9
|
+
|
|
10
|
+
## The shape
|
|
11
|
+
|
|
12
|
+
```tsx title="src/web/products/product-details.page.tsx"
|
|
13
|
+
import type { PageLoader, PageMetadata, PageProps } from "@warlock.js/web";
|
|
14
|
+
|
|
15
|
+
export const route = {
|
|
16
|
+
path: "/products/:id",
|
|
17
|
+
name: "products.details",
|
|
18
|
+
cache: { public: true, maxAge: 60 },
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export const loader = (async ({ request }) => {
|
|
22
|
+
const id = request.input("id");
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
product: {
|
|
26
|
+
id,
|
|
27
|
+
name: `Product ${id}`,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}) satisfies PageLoader<undefined, typeof route>;
|
|
31
|
+
|
|
32
|
+
export const metadata: PageMetadata<typeof loader> = ({ data }) => ({
|
|
33
|
+
title: data.product.name,
|
|
34
|
+
description: `Details for ${data.product.name}`,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export default function ProductDetailsPage({ data }: PageProps<typeof loader>) {
|
|
38
|
+
return (
|
|
39
|
+
<main>
|
|
40
|
+
<h1>{data.product.name}</h1>
|
|
41
|
+
<p>Product id: {data.product.id}</p>
|
|
42
|
+
</main>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Use `satisfies PageLoader`, not `: PageLoader`. `satisfies` checks the context contract while retaining the loader's concrete return type, which is how `PageProps<typeof loader>` knows the shape of `data`.
|
|
48
|
+
|
|
49
|
+
## The minimum page
|
|
50
|
+
|
|
51
|
+
```tsx title="src/web/contact.page.tsx"
|
|
52
|
+
export const route = "/contact";
|
|
53
|
+
|
|
54
|
+
export default function ContactPage() {
|
|
55
|
+
return (
|
|
56
|
+
<main>
|
|
57
|
+
<h1>Contact</h1>
|
|
58
|
+
<a href="mailto:support@example.com">support@example.com</a>
|
|
59
|
+
</main>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`route` is optional. A page that omits it derives both its path and its name from where the file sits beneath `src/web` ([filesystem routing](#filesystem-routing), below). A page that declares `route` uses that instead — an explicit `route` always wins over the derived one, for both the path and (when it sets `name`) the name.
|
|
65
|
+
|
|
66
|
+
### The default export is required
|
|
67
|
+
|
|
68
|
+
`route` is optional; the default export is not. A `*.page.tsx` that exports only named bindings is a **hard discovery/build failure naming the file**:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
The page "src/web/contact.page.tsx" has no runtime default export. Every `*.page.tsx`
|
|
72
|
+
file must default-export the React component it renders. For example:
|
|
73
|
+
`export default function Page() { return <main />; }`
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Two details worth knowing:
|
|
77
|
+
|
|
78
|
+
- **`export { Page as default }` satisfies the rule** — the check is for a runtime default binding, not for the `export default` keyword form specifically.
|
|
79
|
+
- **`export default interface Page {}` does not.** A type-only default is erased at compile time, so there is no component at runtime; it is treated as missing.
|
|
80
|
+
|
|
81
|
+
A file that cannot be parsed reports separately — `Cannot inspect the default export of "<file>": the file could not be parsed (…). Fix the syntax error and discovery will continue.` — so a syntax error never masquerades as a missing export.
|
|
82
|
+
|
|
83
|
+
## Route declarations
|
|
84
|
+
|
|
85
|
+
Use either a bare path or a literal object:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
export const route = "/products";
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
export const route = {
|
|
93
|
+
path: "/products/:id",
|
|
94
|
+
name: "products.details",
|
|
95
|
+
cache: { public: true, maxAge: 60 },
|
|
96
|
+
} as const;
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Prefer an explicit stable `name` for links. Without one, Warlock derives a name from the declared path — a global root page gets `index`, another global page gets its dotted path — the same derivation [filesystem routing](#filesystem-routing) uses when there is no `route` at all.
|
|
100
|
+
|
|
101
|
+
Every segment of a page's URL is written down somewhere: `route.path` (or the derived filesystem path), prefixed by the literal `prefix` exports of the positional layouts above it ([use-layouts](../use-layouts/SKILL.md)). Where the file sits always decides which layouts are above it, and — only when `route` is absent — the path segments too.
|
|
102
|
+
|
|
103
|
+
The build reads `route` without executing application code. Declare it directly with `export const` and literal strings. Variables, function calls, computed object keys, spreads, and `export { route }` are refused.
|
|
104
|
+
|
|
105
|
+
### Validate the page's input — the `validation` export
|
|
106
|
+
|
|
107
|
+
Declare `validation` as its own top-level export: a [Seal](https://www.npmjs.com/package/@warlock.js/seal) schema per source, `params` and `query` kept as two separate keys — never merged into one bag, so a `:id` path segment and a `?id=` query key can never collide or silently shadow one another:
|
|
108
|
+
|
|
109
|
+
> **Withdrawn after 5.6.0: `route.validate`.** A page that still declares it does not silently lose its validation — the app **refuses to boot** and names the file. Move the schema to the `validation` export shown below; the shape and the 400 are unchanged.
|
|
110
|
+
|
|
111
|
+
```tsx title="src/web/products/product-details.page.tsx"
|
|
112
|
+
import { v } from "@warlock.js/seal";
|
|
113
|
+
import type { PageLoader, PageProps } from "@warlock.js/web";
|
|
114
|
+
|
|
115
|
+
export const route = {
|
|
116
|
+
path: "/products/:id",
|
|
117
|
+
name: "products.details",
|
|
118
|
+
} as const;
|
|
119
|
+
|
|
120
|
+
export const validation = {
|
|
121
|
+
params: v.object({ id: v.string().minLength(2) }),
|
|
122
|
+
query: v.object({ tab: v.string().optional() }),
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const loader = (async ({ request }) => {
|
|
126
|
+
const { params, query } = request.validated();
|
|
127
|
+
|
|
128
|
+
return { id: params.id, tab: query.tab };
|
|
129
|
+
}) satisfies PageLoader<undefined, typeof route>;
|
|
130
|
+
|
|
131
|
+
export default function ProductDetailsPage({ data }: PageProps<typeof loader>) {
|
|
132
|
+
return <h1>Product {data.id}</h1>;
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`request.validated()` types `params` and `query` from the schema — never a flattened merge of the two. There is exactly ONE validation surface on a page; see [load-page-data](../load-page-data/SKILL.md) for how the validated data reaches the loader.
|
|
137
|
+
|
|
138
|
+
Rejected input never reaches the loader. It renders the application's `error.page.tsx` boundary at status 400 carrying the failure — a page is a document, not an API endpoint, so invalid input never gets a raw JSON body. The same 400 travels the same way over the `_loader` client-navigation wire.
|
|
139
|
+
|
|
140
|
+
### `middleware` — a page's own guard, run last
|
|
141
|
+
|
|
142
|
+
Declare `middleware` as its own top-level export: an array of `(ctx) => unknown | Promise<unknown>` guards on the page itself, alongside any layout `middleware` above it ([use-layouts](../use-layouts/SKILL.md)). Ordering is fixed pipeline-wide: every layout on the chain runs outermost-first, and the page's own `middleware` runs LAST, closest to the loader — **a layout's auth gate can never be bypassed by a page declaring its own middleware.**
|
|
143
|
+
|
|
144
|
+
> **Withdrawn after 5.6.0: `route.middleware`.** As with `route.validate`, a page still declaring it **refuses to boot** rather than quietly running without its guards — which for an auth guard is the difference between a broken deploy and an open door.
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
export const route = {
|
|
148
|
+
path: "/account",
|
|
149
|
+
} as const;
|
|
150
|
+
|
|
151
|
+
export const middleware = [
|
|
152
|
+
async ({ request, response }) => {
|
|
153
|
+
if (!request.header("authorization")) {
|
|
154
|
+
response.setStatusCode(401);
|
|
155
|
+
|
|
156
|
+
return { error: "Unauthorized" };
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Returning anything other than `undefined` from a middleware short-circuits the request with that value, exactly as an app or layout middleware does.
|
|
163
|
+
|
|
164
|
+
## Page caching
|
|
165
|
+
|
|
166
|
+
Page documents and their `x-warlock-data` representations are `no-store` by
|
|
167
|
+
default. Opt a public page into shared caching on its route:
|
|
168
|
+
|
|
169
|
+
```tsx
|
|
170
|
+
export const route = {
|
|
171
|
+
path: "/products",
|
|
172
|
+
name: "products.index",
|
|
173
|
+
cache: { public: true, maxAge: 60 },
|
|
174
|
+
} as const;
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`maxAge` is seconds. Both keys are required: `cache: { public: true }` is a
|
|
178
|
+
boot-time `InvalidPageCacheOptInError`, because the framework will not invent
|
|
179
|
+
a freshness window. Remove `cache` entirely to keep the safe `no-store`
|
|
180
|
+
default.
|
|
181
|
+
|
|
182
|
+
The declaration is an opt-in, not an override of request safety. A response
|
|
183
|
+
that sets or clears a cookie, or a request that used authenticated state,
|
|
184
|
+
emits `Cache-Control: private, no-store`. If Warlock cannot determine whether
|
|
185
|
+
the request used authenticated state, it revokes the opt-in and emits
|
|
186
|
+
`Cache-Control: no-store`. Only a provably unauthenticated request with no
|
|
187
|
+
`Set-Cookie` can emit `public, max-age=<maxAge>`.
|
|
188
|
+
|
|
189
|
+
This decision is applied once after loaders finish, to both representations.
|
|
190
|
+
Setting `Cache-Control` manually in a loader cannot bypass the floor.
|
|
191
|
+
|
|
192
|
+
## Filesystem routing
|
|
193
|
+
|
|
194
|
+
Omit `route` and the URL comes from the page's own path beneath `src/web`:
|
|
195
|
+
|
|
196
|
+
- Every directory contributes a segment, in order — `src/web/products/featured.page.tsx` derives `/products/featured`.
|
|
197
|
+
- A `(group)` directory — parentheses, not braces — contributes nothing to the URL, only to organization: `src/web/(marketing)/pricing.page.tsx` derives `/pricing`. Bracket syntax inside a group name is refused at boot because it can never contribute a dynamic segment; use `(marketing)/[id]/page.page.tsx`, not `(marketing[id])/page.page.tsx`.
|
|
198
|
+
- `index.page.tsx` claims its own directory rather than adding a segment: `src/web/products/index.page.tsx` derives `/products`. This is the ONLY filename with special meaning — `home.page.tsx` is not magic and derives `/home`.
|
|
199
|
+
- `[id]` becomes `:id`: `src/web/products/[id].page.tsx` derives `/products/:id`.
|
|
200
|
+
- A layout `prefix` on the page's ancestry composes in front of the derived path exactly as it does for an explicit `route.path` ([use-layouts](../use-layouts/SKILL.md)).
|
|
201
|
+
|
|
202
|
+
Two pages that derive (or declare) the same effective path is a build error naming both files.
|
|
203
|
+
|
|
204
|
+
### Catch-all segments are refused
|
|
205
|
+
|
|
206
|
+
There is no catch-all / rest-parameter form in filesystem routing. Only
|
|
207
|
+
`[name]` — a plain identifier in square brackets — is recognized as a
|
|
208
|
+
dynamic segment. `src/web/docs/[...slug].page.tsx` raises
|
|
209
|
+
`PageFileSegmentNotSupportedError` at boot and names both the page file and
|
|
210
|
+
the rejected segment.
|
|
211
|
+
|
|
212
|
+
Until a catch-all exists, use the terminal wildcard with an explicit `route`:
|
|
213
|
+
|
|
214
|
+
```tsx
|
|
215
|
+
export const route = { path: "/docs/*", name: "docs.catchAll" } as const;
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Page-route grammar
|
|
219
|
+
|
|
220
|
+
Page routes deliberately accept less than API routes:
|
|
221
|
+
|
|
222
|
+
- Supported: `/`, static segments, whole-segment params such as `/products/:id`, the exact wildcard `*`, and a terminal wildcard such as `/docs/*`.
|
|
223
|
+
- Not supported: regex params, optional params, multiple params in one segment, params mixed with text, doubled or trailing slashes, non-terminal wildcards, and catch-all/rest segments (`[...slug]`).
|
|
224
|
+
|
|
225
|
+
Write two pages for an optional segment. Validate a constrained parameter in the page instead of putting a regex in its path.
|
|
226
|
+
|
|
227
|
+
An unsupported declared path is a boot-time
|
|
228
|
+
`PageRoutePathNotSupportedError`, never a literal or normalized route.
|
|
229
|
+
Examples that fail include `/users/:id?`, `/users/:id(\\d+)`,
|
|
230
|
+
`/near/:lat-:lng`, `/a//b`, and `/users/`.
|
|
231
|
+
|
|
232
|
+
## Metadata
|
|
233
|
+
|
|
234
|
+
`metadata` may be a static object or a function of the resolved loader data and readonly `shared` payload:
|
|
235
|
+
|
|
236
|
+
```tsx
|
|
237
|
+
import type { PageMetadata } from "@warlock.js/web";
|
|
238
|
+
|
|
239
|
+
export const metadata: PageMetadata = {
|
|
240
|
+
title: "Products",
|
|
241
|
+
description: "Browse the product catalogue",
|
|
242
|
+
robots: "index,follow",
|
|
243
|
+
openGraph: {
|
|
244
|
+
type: "website",
|
|
245
|
+
image: "/images/catalogue-card.png",
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Supported fields are `title`, `description`, `keywords`, `canonical`, `robots`, `openGraph`, and `twitter`. Function metadata runs after a successful loader. If a loader fails, Warlock uses error metadata instead of calling the page function with missing data.
|
|
251
|
+
|
|
252
|
+
## The error boundary — `error.page.tsx`
|
|
253
|
+
|
|
254
|
+
`error.page.tsx` anywhere beneath `src/web` is the application's one error boundary — exactly two page filenames are special in Warlock, this and `404.page.tsx`. A second `error.page.tsx` is a build error naming both files. Like `404.page.tsx`, it declares no `route` — it has no URL of its own and is reached only when something throws.
|
|
255
|
+
|
|
256
|
+
```tsx title="src/web/error.page.tsx"
|
|
257
|
+
import type { ErrorPageProps } from "@warlock.js/web";
|
|
258
|
+
|
|
259
|
+
export default function ErrorPage({ error, status }: ErrorPageProps) {
|
|
260
|
+
return (
|
|
261
|
+
<main>
|
|
262
|
+
<h1>Something went wrong</h1>
|
|
263
|
+
<p>Status: {status}</p>
|
|
264
|
+
</main>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
During SSR the component receives the real thrown value in `error`. After hydration it receives the JSON-safe `{ name, message, stack? }` shape instead — the original value does not survive the wire. `robots: noindex` is a framework default on this path that an app-supplied `metadata` cannot remove.
|
|
270
|
+
|
|
271
|
+
Every unhandled error response is also forced to
|
|
272
|
+
`Cache-Control: private, no-store` at the framework's shared error funnel.
|
|
273
|
+
This includes page and API-route failures.
|
|
274
|
+
|
|
275
|
+
If the failure happens before any page module could even load — a module-load or `register()` throw — there is no trustworthy server composition left to hydrate against, so Warlock renders a plain framework fallback (your `error.page.tsx` if it can still be loaded, otherwise a minimal built-in boundary) with no hydration script at all rather than risk hydrating client code against markup nothing can vouch for.
|
|
276
|
+
|
|
277
|
+
## The `register()` hook
|
|
278
|
+
|
|
279
|
+
`root.tsx`, `layout.tsx`, and `*.page.tsx` may each export `register`: a synchronous, no-argument, side-effect hook that runs once per module namespace instance, on both the server and the browser, before that module's middleware or loader. Unlike `route`/`middleware`/`validation`/`loader`/`metadata`, it is not stripped from the client — it is meant to run on both sides.
|
|
280
|
+
|
|
281
|
+
```tsx
|
|
282
|
+
export function register() {
|
|
283
|
+
// one-time setup for this module namespace; must not return a Promise
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Returning a Promise (or anything thenable) throws — `register()` must finish before the module is usable.
|
|
288
|
+
|
|
289
|
+
## The client boundary
|
|
290
|
+
|
|
291
|
+
The browser boundary is decided by the import graph, not by the file's location. A `*.page.tsx` is universal:
|
|
292
|
+
|
|
293
|
+
- `route`, `middleware`, `validation`, `loader`, and `metadata` are stripped from the client projection.
|
|
294
|
+
- The default component and any other surviving exports form the client graph.
|
|
295
|
+
- An import used only by a stripped server export is removed with it.
|
|
296
|
+
- An import also used by the component survives and therefore must be browser-safe.
|
|
297
|
+
|
|
298
|
+
Keep server-only repository and service reads inside `loader`. Do not read them from module-scope initializers or the default component.
|
|
299
|
+
|
|
300
|
+
### `.client` does not isolate SSR
|
|
301
|
+
|
|
302
|
+
A `.client.tsx` suffix is a naming convention, not an SSR-isolation boundary. A
|
|
303
|
+
module statically imported by a page, layout, root, or any of their imports is
|
|
304
|
+
still evaluated by the server. Top-level browser globals such as `window`
|
|
305
|
+
therefore crash SSR boot. Warlock does not ship a client-only component
|
|
306
|
+
primitive; code that requires browser globals at module scope cannot be part of
|
|
307
|
+
the SSR page graph.
|
|
308
|
+
|
|
309
|
+
### Static assets use `public/`
|
|
310
|
+
|
|
311
|
+
The production server build does not support imported non-stylesheet assets. An
|
|
312
|
+
import such as `import logo from "./logo.svg"` works under Vite in development
|
|
313
|
+
but `warlock build` refuses it rather than emit a server URL that disagrees with
|
|
314
|
+
the client bundle. Put the file in the application's `public/` directory and
|
|
315
|
+
reference it by root URL: `public/logo.svg` is `/logo.svg`. Stylesheet imports
|
|
316
|
+
are the exception and remain supported.
|
|
317
|
+
|
|
318
|
+
## Editing a page in development
|
|
319
|
+
|
|
320
|
+
`warlock dev` decides Fast Refresh vs. a full reload by comparing the module's _skeleton_ — its source with every component body masked out — across the edit. Everything outside a component body is part of the skeleton: imports, module-level declarations, and all server exports (`route`, `middleware`, `validation`, `loader`, `metadata`). The skeleton moving, with or without a simultaneous JSX change, forces a full reload; the skeleton holding still defers to Fast Refresh.
|
|
321
|
+
|
|
322
|
+
- **A JSX-only edit hot-updates.** The skeleton is unchanged, so Vite's Fast Refresh applies the projected client code with no reload and no lost component state.
|
|
323
|
+
- **A `metadata`-only edit reloads the document.** `metadata` sits outside the skeleton's masked region, so the edit moves it. Warlock sends a full reload, which re-runs SSR and rebuilds `<head>`. Component state is lost — that is the price of seeing the new `<title>` without touching the browser.
|
|
324
|
+
- **Any module-level change reloads, not just `metadata`.** An edited import, a module-level declaration, or an edit confined to `route`, `middleware`, `validation`, or `loader` all move the skeleton the same way and take the same full-reload path.
|
|
325
|
+
- **A helper function used only by the JSX still reloads if it is declared at module level.** The rule does not try to prove which half of a shared declaration the edit was "really" for — it over-approximates deliberately, because a false reload only costs component state, while a missed one ships a stale `<head>` and calls it a hot update.
|
|
326
|
+
- Creating, deleting, or renaming a page file, or editing its `route` export, is page-GRAPH churn, not a skeleton edit — see below, not Fast Refresh.
|
|
327
|
+
|
|
328
|
+
## Route-table changes in development
|
|
329
|
+
|
|
330
|
+
Creating a page, deleting one, or editing its `route` export's path is a different kind of dev edit from the skeleton comparison above — it changes which URLs exist, not just how one already-registered URL renders. `warlock dev` re-registers the affected route(s) in the live route table, atomically and with no dev-server restart, so the new file (or new path) is reachable on the very next request with no manual restart.
|
|
331
|
+
|
|
332
|
+
## Gotchas
|
|
333
|
+
|
|
334
|
+
- **A page with no `route` is not unreachable.** It derives a real URL from its file location — see [Filesystem routing](#filesystem-routing).
|
|
335
|
+
- **A page with no default export IS refused.** Named exports alone fail the build naming the file, instead of serving a blank 200.
|
|
336
|
+
- **`.client.tsx` does not prevent SSR evaluation.** It is a naming convention, not a client-only component primitive.
|
|
337
|
+
- **Imported static assets do not build.** Put them in `public/` and reference their root URL; CSS imports remain supported.
|
|
338
|
+
- **`[...slug]` is not a catch-all.** It fails boot with
|
|
339
|
+
`PageFileSegmentNotSupportedError`; use an explicit terminal `*` route
|
|
340
|
+
instead — see [Catch-all segments are refused](#catch-all-segments-are-refused).
|
|
341
|
+
- **`process.env` is refused in the client/universal graph, `PUBLIC_` prefix included.** Read env values in a loader and return them as page data; see [`load-page-data/SKILL.md`](../load-page-data/SKILL.md).
|
|
342
|
+
- **Keep `route` literal.** A computed route cannot be discovered without executing app code and is refused.
|
|
343
|
+
- **Do not annotate the loader with `: PageLoader`.** That erases the return type `PageProps` needs.
|
|
344
|
+
- **Components receive data, not HTTP objects.** `request` and `response` belong to loaders; the component also renders in the browser.
|
|
345
|
+
- **A default component is synchronous.** Fetch in the loader, then render its result.
|
|
346
|
+
- **There are no server actions.** Mutations remain ordinary API requests; call `refresh()` after a successful mutation.
|
|
347
|
+
- **Do not import the hydration entry.** `esm/hydration/index.mjs` is a framework build input, not a consumer API. The public low-level runtime subpath is `@warlock.js/web/client/runtime`.
|
|
348
|
+
|
|
349
|
+
## See also
|
|
350
|
+
|
|
351
|
+
- [`load-page-data/SKILL.md`](../load-page-data/SKILL.md) — validation, loader context, response short-circuits, and `shared`.
|
|
352
|
+
- [`use-layouts/SKILL.md`](../use-layouts/SKILL.md) — positional layouts, prefixes, and persistence.
|
|
353
|
+
- [`navigate-on-the-client/SKILL.md`](../navigate-on-the-client/SKILL.md) — `Link`, `href`, navigation, and `refresh()`.
|
|
354
|
+
- [`serve-styles/SKILL.md`](../serve-styles/SKILL.md) — CSS imports in page and root modules.
|