@ultimat3/render 22.5.1 → 22.6.1
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/CLAUDE.md +1 -1
- package/README.md +9 -0
- package/package.json +6 -6
- package/src/errors.ts +17 -0
- package/src/index.ts +1 -0
- package/src/route.ts +34 -3
package/CLAUDE.md
CHANGED
|
@@ -25,7 +25,7 @@ axiom 6). Never `cli` (upward).
|
|
|
25
25
|
|---|---|
|
|
26
26
|
| `offline`, `meta` | required by `RouteDefinition`. Never make them optional. |
|
|
27
27
|
| `hydrate` | optional, **derived from `island()`** — `'interaction'` when the module declared one, `'never'` when not. Declaring it still wins and is the only way to reach `idle` / `visible`. Never give an island its own strategy: `RouteDescriptor.hydrate` is read by `sw.js`, the web manifest and `x routes`. |
|
|
28
|
-
| `defineRoute` shape | exactly the contract's
|
|
28
|
+
| `defineRoute` shape | exactly the contract's 11 keys (`cache`, `ssr` only, is the tenth; `post`, an action's export name bound to `POST` at the page URL, the eleventh — never on `static`, `X_ROUTE_POST_INVALID`; the CLI mounts it and refuses an unregistered name at boot). New route *metadata* goes inside `meta`. |
|
|
29
29
|
| `load` | optional, and the ONE server-side data seam. Resolved once per render by `routeDataFor()` and handed to **both** `meta` and the page component. Absent `load`, the context IS the data (`{ params, url }`). |
|
|
30
30
|
| `load` is required when the context cannot supply the data | `LoadRequirement<TData>` in `defineRoute`'s parameter. `RouteContext` is a type ALIAS on purpose — only an alias carries the implicit index signature that makes it a `RouteData`. |
|
|
31
31
|
| A loader's own error | rethrown only when `isUltimateError` says so (core's brand), never a `code` property and never `instanceof UltimateError` — a tier-0 error is branded, not a subclass. Everything else is `X_ROUTE_LOAD_FAILED`. |
|
package/README.md
CHANGED
|
@@ -117,6 +117,15 @@ through `cacheControl()`, and its `vary` joins the defaults. `As of 2026-09-24`
|
|
|
117
117
|
`RouteCache`; `@ultimat3/http` is a dependency of this package for that type and for
|
|
118
118
|
`cacheControl` in the server half.
|
|
119
119
|
|
|
120
|
+
## `post` — the page's URL also answers `POST`
|
|
121
|
+
|
|
122
|
+
`defineRoute({ render: 'ssr', …, post: 'unsubscribeOnboarding' })` binds `POST <this page's path>`
|
|
123
|
+
to the named action: its policy, idempotency and redirect, with the URL's query merged over the
|
|
124
|
+
posted fields (query wins). An RFC 8058 one-click `POST /correos/baja?t=…` with
|
|
125
|
+
`List-Unsubscribe=One-Click` reaches the action as `{ t, 'List-Unsubscribe': 'One-Click' }`, while
|
|
126
|
+
`GET` stays the confirm page. `@ultimat3/cli` mounts it in both boots; a name no action is
|
|
127
|
+
registered under is `X_ROUTE_POST_INVALID` at boot, as is `post` on a `static` page.
|
|
128
|
+
|
|
120
129
|
## Mode invariants, checked at registration
|
|
121
130
|
|
|
122
131
|
| Mode | Invariant | Error if violated |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/render",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.6.1",
|
|
4
4
|
"description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -36,11 +36,11 @@
|
|
|
36
36
|
"test": "bun test"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@ultimat3/cache": "22.
|
|
40
|
-
"@ultimat3/core": "22.
|
|
41
|
-
"@ultimat3/http": "22.
|
|
42
|
-
"@ultimat3/i18n": "22.
|
|
43
|
-
"@ultimat3/seo": "22.
|
|
39
|
+
"@ultimat3/cache": "22.6.1",
|
|
40
|
+
"@ultimat3/core": "22.6.1",
|
|
41
|
+
"@ultimat3/http": "22.6.1",
|
|
42
|
+
"@ultimat3/i18n": "22.6.1",
|
|
43
|
+
"@ultimat3/seo": "22.6.1",
|
|
44
44
|
"sass": "1.104.0"
|
|
45
45
|
}
|
|
46
46
|
}
|
package/src/errors.ts
CHANGED
|
@@ -26,6 +26,7 @@ export const RENDER_ERROR_CODES = [
|
|
|
26
26
|
// about render's own output. `x verify` is only the surface that reports it.
|
|
27
27
|
'X_STYLES_GLOBAL_MISSING',
|
|
28
28
|
'X_ASSET_MISSING',
|
|
29
|
+
'X_ROUTE_POST_INVALID',
|
|
29
30
|
] as const;
|
|
30
31
|
|
|
31
32
|
export type RenderErrorCode = (typeof RENDER_ERROR_CODES)[number];
|
|
@@ -49,6 +50,7 @@ export const RENDER_ERROR_TITLES: Readonly<Record<RenderErrorCode, string>> = {
|
|
|
49
50
|
X_STYLES_GLOBAL_MISSING:
|
|
50
51
|
'a surface renders documents whose CSS defines no :root custom properties',
|
|
51
52
|
X_ASSET_MISSING: 'a page names a site asset the app does not have',
|
|
53
|
+
X_ROUTE_POST_INVALID: "a page route's post binding cannot be mounted",
|
|
52
54
|
};
|
|
53
55
|
|
|
54
56
|
// Titles must be registered for `format()` to render the contract's first line. Every code above is
|
|
@@ -183,6 +185,21 @@ export class PrerenderFailedError extends UltimateError {
|
|
|
183
185
|
}
|
|
184
186
|
|
|
185
187
|
/** `load` is optional, so this catches a value that is present and not callable. */
|
|
188
|
+
/**
|
|
189
|
+
* `defineRoute({ post })` that cannot be mounted: not an export name, on a page no process
|
|
190
|
+
* answers (`static`, `spa`), or — at boot — naming no registered action.
|
|
191
|
+
*/
|
|
192
|
+
export class RoutePostInvalidError extends UltimateError {
|
|
193
|
+
static readonly code = 'X_ROUTE_POST_INVALID' as const;
|
|
194
|
+
constructor(cause: string) {
|
|
195
|
+
super({
|
|
196
|
+
code: RoutePostInvalidError.code,
|
|
197
|
+
cause,
|
|
198
|
+
fix: "defineRoute({ render: 'ssr', ..., post: '<actionExportName>' }) naming an action the app registers — x actions list --json prints them",
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
186
203
|
export class RouteLoadInvalidError extends UltimateError {
|
|
187
204
|
static readonly code = 'X_ROUTE_LOAD_INVALID' as const;
|
|
188
205
|
constructor(cause: string, fix: string) {
|
package/src/index.ts
CHANGED
package/src/route.ts
CHANGED
|
@@ -15,11 +15,16 @@
|
|
|
15
15
|
import type { CacheTag } from '@ultimat3/cache';
|
|
16
16
|
import { serializeTags } from '@ultimat3/cache';
|
|
17
17
|
import type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core';
|
|
18
|
-
import { OFFLINE_STRATEGIES } from '@ultimat3/core';
|
|
18
|
+
import { OFFLINE_STRATEGIES, renderCauseValue } from '@ultimat3/core';
|
|
19
19
|
import type { CacheHint } from '@ultimat3/http';
|
|
20
20
|
import type { Translator } from '@ultimat3/i18n';
|
|
21
21
|
import type { RouteMeta } from '@ultimat3/seo';
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
RouteLoadInvalidError,
|
|
24
|
+
RouteMetaMissingError,
|
|
25
|
+
RouteOfflineMissingError,
|
|
26
|
+
RoutePostInvalidError,
|
|
27
|
+
} from './errors';
|
|
23
28
|
import type { IslandSpec } from './island';
|
|
24
29
|
import { drainDeclaredIslands } from './island';
|
|
25
30
|
import { assertModeShape } from './modes';
|
|
@@ -188,7 +193,7 @@ export type LoadRequirement<TData> = RouteContext extends TData
|
|
|
188
193
|
*/
|
|
189
194
|
export type RouteCache = 'no-store' | Omit<CacheHint, 'tags'>;
|
|
190
195
|
|
|
191
|
-
/** The input shape of `defineRoute` — exactly the contract's
|
|
196
|
+
/** The input shape of `defineRoute` — exactly the contract's eleven keys, nothing else. */
|
|
192
197
|
export interface RouteDefinition<TData = RouteData> {
|
|
193
198
|
readonly render: RenderMode;
|
|
194
199
|
readonly revalidate?: RevalidateConfig;
|
|
@@ -210,6 +215,15 @@ export interface RouteDefinition<TData = RouteData> {
|
|
|
210
215
|
readonly meta: RouteMetaFn<TData>;
|
|
211
216
|
readonly policy?: RouteGuard;
|
|
212
217
|
readonly cache?: RouteCache;
|
|
218
|
+
/**
|
|
219
|
+
* Answer `POST` to THIS page's URL with the named action — its export name, as registered — with
|
|
220
|
+
* the URL's query merged over the posted fields. What an RFC 8058 one-click unsubscribe needs:
|
|
221
|
+
* the mail client POSTs `List-Unsubscribe=One-Click` to the very URL the footer links, and the
|
|
222
|
+
* action (its policy decides who may; `allow()` for an anonymous token link) does the work.
|
|
223
|
+
* The page itself stays the `GET`, and a form on it can post to its own URL with no JavaScript.
|
|
224
|
+
* Never on `static`: a file on disk has no process to answer a `POST`.
|
|
225
|
+
*/
|
|
226
|
+
readonly post?: string;
|
|
213
227
|
}
|
|
214
228
|
|
|
215
229
|
/**
|
|
@@ -289,6 +303,8 @@ export function defineRoute<TData = RouteData>(
|
|
|
289
303
|
);
|
|
290
304
|
}
|
|
291
305
|
|
|
306
|
+
if (def.post !== undefined) assertPostBinding(def.post, def.render);
|
|
307
|
+
|
|
292
308
|
const declaredMeta = def.meta;
|
|
293
309
|
const declaredLoad = def.load;
|
|
294
310
|
// Drained unconditionally, even when `hydrate` is stated: the list must not survive into the
|
|
@@ -321,12 +337,27 @@ export function defineRoute<TData = RouteData>(
|
|
|
321
337
|
...(def.prerender ? { prerender: def.prerender } : {}),
|
|
322
338
|
...(def.policy ? { policy: def.policy } : {}),
|
|
323
339
|
...(def.cache === undefined ? {} : { cache: def.cache }),
|
|
340
|
+
...(def.post === undefined ? {} : { post: def.post }),
|
|
324
341
|
};
|
|
325
342
|
|
|
326
343
|
assertModeShape(config);
|
|
327
344
|
return Object.freeze(config);
|
|
328
345
|
}
|
|
329
346
|
|
|
347
|
+
/** An export name, on a mode a process answers. The action's EXISTENCE is checked at boot. */
|
|
348
|
+
function assertPostBinding(post: unknown, render: unknown): void {
|
|
349
|
+
if (typeof post !== 'string' || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(post)) {
|
|
350
|
+
throw new RoutePostInvalidError(
|
|
351
|
+
`post: ${renderCauseValue(post)} is not an action's export name`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
if (render === 'static') {
|
|
355
|
+
throw new RoutePostInvalidError(
|
|
356
|
+
`post: '${post}' sits on a render: 'static' page, a file on disk with no process to answer a POST`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
330
361
|
/** Only `index` is decided here; `follow` and the rest stay the author's. */
|
|
331
362
|
function noindexOnError(meta: RouteMeta, status: number): RouteMeta {
|
|
332
363
|
if (!isErrorStatus(status)) return meta;
|