@ultimat3/render 1.0.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/LICENSE +21 -0
- package/README.md +149 -0
- package/package.json +37 -0
- package/src/errors.ts +167 -0
- package/src/head-seo.ts +46 -0
- package/src/head.ts +137 -0
- package/src/hydrate.ts +121 -0
- package/src/index.ts +170 -0
- package/src/islands.ts +171 -0
- package/src/modes.ts +208 -0
- package/src/registry.ts +313 -0
- package/src/render-isr.ts +290 -0
- package/src/render-spa.ts +74 -0
- package/src/render-ssr.ts +60 -0
- package/src/render-static.ts +170 -0
- package/src/render-stream.ts +149 -0
- package/src/route.ts +158 -0
- package/src/router-client.ts +225 -0
- package/src/surfaces.ts +248 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 developerz.ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# 🖼 @ultimat3/render
|
|
2
|
+
|
|
3
|
+
The `route` primitive and the five render modes.
|
|
4
|
+
|
|
5
|
+
| Mode | Behavior | Use |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| `static` | built once, served as a file | marketing, docs |
|
|
8
|
+
| `isr` | static + background regen on tag/TTL | catalogs, profiles |
|
|
9
|
+
| `ssr` | per-request full render | fresh SEO pages |
|
|
10
|
+
| `stream` | static shell flushed instantly, holes streamed | **default for app pages** |
|
|
11
|
+
| `spa` | shell only, client fetches | dashboards behind auth |
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
export const config = defineRoute({
|
|
15
|
+
render: 'isr', // static | isr | ssr | stream | spa
|
|
16
|
+
revalidate: { tags: [tag.post] },
|
|
17
|
+
prerender: () => db.posts.slugs(),
|
|
18
|
+
offline: 'precache', // precache | runtime | network-only
|
|
19
|
+
hydrate: 'visible', // idle | visible | interaction | never
|
|
20
|
+
budget: { js: '40kb', lcp: 2000 },
|
|
21
|
+
meta: ({ post }) => ({ title: post.title, description: post.excerpt,
|
|
22
|
+
og: { image: post.cover }, ld: ld.Article(post) }),
|
|
23
|
+
});
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## `offline`, `hydrate` and `meta` are required by the type
|
|
27
|
+
|
|
28
|
+
Not by a lint rule, not by a doc — by `RouteDefinition`. Axiom 3 lives in the type system:
|
|
29
|
+
a route that forgets its offline strategy or its `<head>` does not compile. `defineRoute`
|
|
30
|
+
re-checks the same three at runtime (`X_ROUTE_OFFLINE_MISSING`, `X_ROUTE_META_MISSING`) for
|
|
31
|
+
JS callers and generators.
|
|
32
|
+
|
|
33
|
+
## `defineRoute` returns a descriptor, not the object you passed
|
|
34
|
+
|
|
35
|
+
Two fields come back narrower than they went in, so nothing downstream branches on shape:
|
|
36
|
+
|
|
37
|
+
| Field | The declaration accepts | The descriptor always is |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `meta` | `(data) => RouteMeta \| Promise<RouteMeta>` | `(data) => Promise<RouteMeta>` |
|
|
40
|
+
| `budget` | omitted, or a `RouteBudget` | a `RouteBudget` — `{}` when undeclared |
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
const meta = await config.meta({ post }); // always. sync or async declaration, one call
|
|
44
|
+
const js = config.budget.js ?? null; // never config.budget?.js
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
No author is forced to write `async`, and a `meta` that throws synchronously comes back as
|
|
48
|
+
a rejection, so one `catch` covers both. The budget's *fields* stay optional:
|
|
49
|
+
`budget.js === undefined` still means "declared no JS budget", which is exactly what fails
|
|
50
|
+
a hydrating `site/` route below.
|
|
51
|
+
|
|
52
|
+
## Mode invariants, checked at registration
|
|
53
|
+
|
|
54
|
+
| Mode | Invariant | Error if violated |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `static` | no per-request state — no `policy`, no `revalidate` | `X_ROUTE_MODE_INVALID` |
|
|
57
|
+
| `isr` | needs a trigger: `revalidate.tags` or `revalidate.ttl` | `X_ROUTE_MODE_INVALID` |
|
|
58
|
+
| `ssr` | cannot be prerendered | `X_ROUTE_MODE_INVALID` |
|
|
59
|
+
| `stream` | at least one `<Suspense>` boundary | `X_ROUTE_MODE_INVALID` |
|
|
60
|
+
| `spa` | requires a `policy` (authed dashboards only) | `X_ROUTE_MODE_INVALID` |
|
|
61
|
+
|
|
62
|
+
Plus surface rules: `site/` allows `static | isr | ssr`, `app/` allows `stream | spa | ssr`,
|
|
63
|
+
`api/` renders nothing, and a `site/` route that opts into hydration without a `budget.js`
|
|
64
|
+
is a build error.
|
|
65
|
+
|
|
66
|
+
## The route table is the single source of route truth
|
|
67
|
+
|
|
68
|
+
`registry.ts` maps file paths to URLs and `describeRoutes()` projects the table into a
|
|
69
|
+
sorted, JSON-safe descriptor list. Every downstream generator reads that one table.
|
|
70
|
+
|
|
71
|
+
| File | URL |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `site/page.tsx` | `/` |
|
|
74
|
+
| `site/pricing/page.tsx` | `/pricing` |
|
|
75
|
+
| `site/(marketing)/about/page.tsx` | `/about` |
|
|
76
|
+
| `site/blog/[slug]/page.tsx` | `/blog/:slug` |
|
|
77
|
+
| `site/docs/[...path]/page.tsx` | `/docs/*path` |
|
|
78
|
+
| `app/dashboard/page.tsx` | `/dashboard` |
|
|
79
|
+
| `api/posts/route.ts` | `/api/posts` |
|
|
80
|
+
|
|
81
|
+
The URL is the **directory** path under the surface; the filename names the kind of file, never a
|
|
82
|
+
URL segment. One spelling per surface — `page.tsx` under `site/` and `app/`, `route.ts` under
|
|
83
|
+
`api/` — and `registerRoute` refuses anything else with `X_ROUTE_FILE_INVALID`. `index.tsx` is not
|
|
84
|
+
a page. Two spellings would make "is this file a route?" undecidable for the module scan, the
|
|
85
|
+
boundary walk, `sw.js` and the author reading the folder; one spelling also co-locates
|
|
86
|
+
`page.tsx` + `page.module.scss` + `page.test.ts`, and gives `[slug]/` its own stylesheet.
|
|
87
|
+
|
|
88
|
+
Consumers: `x.manifest.json`, the `/_x` routes panel, `sitemap.xml`, `sw.js`
|
|
89
|
+
(`@ultimat3/pwa` takes descriptors as data — tier 4 packages never import each other).
|
|
90
|
+
|
|
91
|
+
## `site/` cannot import `app/` — build error, resolved transitively
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
X_SURFACE_BOUNDARY: site/ imported app/
|
|
95
|
+
cause: site/pricing/page.tsx → shared/ui/button.tsx → app/charts/sparkline.tsx
|
|
96
|
+
fix: x fix boundary site/pricing/page.tsx (or move sparkline out of shared/ui)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The failure this prevents, in order:
|
|
100
|
+
|
|
101
|
+
1. Someone puts `<Button>` in `shared/ui/` — correct, both surfaces need buttons.
|
|
102
|
+
2. A dashboard needs a button with an inline trend line, so `<Sparkline>` joins the same
|
|
103
|
+
file and pulls in the charting library.
|
|
104
|
+
3. `site/pricing` imports `<Button>`.
|
|
105
|
+
4. The highest-intent page in the product now ships chart.js. Nothing broke, nothing
|
|
106
|
+
warned, LCP regressed 900ms, discovered a quarter later by a Lighthouse audit nobody
|
|
107
|
+
scheduled.
|
|
108
|
+
|
|
109
|
+
The import that costs you is three hops from the file anyone reviewed, so
|
|
110
|
+
`checkSurfaceBoundary()` walks the whole value-import graph. `import type` edges are erased
|
|
111
|
+
at build time and therefore never carry the boundary. `shared/` is a leaf; `app/ → api/` is
|
|
112
|
+
types-only.
|
|
113
|
+
|
|
114
|
+
## Public API
|
|
115
|
+
|
|
116
|
+
| Export | Owns |
|
|
117
|
+
|---|---|
|
|
118
|
+
| `defineRoute` | the `route` primitive |
|
|
119
|
+
| `MODE_SPECS`, `assertModeShape`, `assertModeInvariants` | the mode invariant table |
|
|
120
|
+
| `registerRoute`, `describeRoutes`, `matchRoute`, `routePathFromFile` | the route table |
|
|
121
|
+
| `checkSurfaceBoundary`, `assertSurfaceBoundary`, `surfaceOf` | the hard boundary |
|
|
122
|
+
| `renderStatic`, `enumeratePrerender` | build-time render, content hashing |
|
|
123
|
+
| `createIsrController`, `invalidateAndRevalidate` | SWR + single-flight + tag triggers |
|
|
124
|
+
| `renderSsr`, `streamResult`, `renderSpa` | the per-request modes |
|
|
125
|
+
| `emitIslandAttributes`, `hydrateRuntime` | the four hydration strategies |
|
|
126
|
+
| `graphFor`, `checkBudget`, `assertBudget` | two bundle graphs, per-route budgets |
|
|
127
|
+
| `createRouter` | the vendored client router |
|
|
128
|
+
| `mergeHead`, `renderHead`, `themeScript` | `<head>` merge + the one inlined script |
|
|
129
|
+
|
|
130
|
+
## Notes
|
|
131
|
+
|
|
132
|
+
- **ISR** serves stale instantly, regenerates single-flight (a burst renders once), and
|
|
133
|
+
registers each rendered page in `@ultimat3/cache`'s invalidation graph as an
|
|
134
|
+
`isr-route` dependent of its `revalidate.tags`. So
|
|
135
|
+
`action({ cache: { invalidates: [tag.post] } })` reaches ISR in the same hop as memo,
|
|
136
|
+
LRU, Redis and the CDN, and regenerates exactly the dependent pages — nobody lists
|
|
137
|
+
pages by hand, so nobody forgets one. `controller.attach()` installs it as the
|
|
138
|
+
framework's `Revalidator`.
|
|
139
|
+
- **`stream`** flushes the shell first, then reveals holes in completion order with a
|
|
140
|
+
~200-byte inline script. Solid's compiled templates and signals mean the shell costs zero
|
|
141
|
+
hydration work, so streaming buys TTFB *and* TBT here, not just TTFB.
|
|
142
|
+
- **`hydrate: 'interaction'`** replays the event that woke the island; without replay the
|
|
143
|
+
first click on a cold island is silently lost.
|
|
144
|
+
- **`hydrate: 'never'`** emits no attributes beyond the marker and no runtime — the `site/`
|
|
145
|
+
0kb default is mechanical, not aspirational.
|
|
146
|
+
- **The client router is vendored** rather than depending on a moving SolidStart alpha. It
|
|
147
|
+
imports no `solid-js`: reactive primitives and the DOM host are injected.
|
|
148
|
+
- `@ultimat3/http`'s `html()` / `stream()` turn a `RenderResult` into a `Response`; render
|
|
149
|
+
never constructs one.
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ultimat3/render",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/developerz-ai/ultimate.git",
|
|
10
|
+
"directory": "packages/render"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public",
|
|
14
|
+
"provenance": true
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"!src/**/*.test.ts",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.3.0"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
30
|
+
"test": "bun test"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@ultimat3/cache": "1.0.0",
|
|
34
|
+
"@ultimat3/core": "1.0.0",
|
|
35
|
+
"@ultimat3/seo": "1.0.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render's error codes. Every route failure is a stable code + cause + exact fix
|
|
3
|
+
* (axiom 4), identical in the terminal, the browser overlay and `--json`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
7
|
+
|
|
8
|
+
export const RENDER_ERROR_CODES = [
|
|
9
|
+
'X_ROUTE_MODE_INVALID',
|
|
10
|
+
'X_ROUTE_OFFLINE_MISSING',
|
|
11
|
+
'X_ROUTE_META_MISSING',
|
|
12
|
+
'X_ROUTE_UNNORMALIZED',
|
|
13
|
+
'X_ROUTE_DUPLICATE',
|
|
14
|
+
'X_ROUTE_FILE_INVALID',
|
|
15
|
+
'X_SURFACE_BOUNDARY',
|
|
16
|
+
'X_BUDGET_EXCEEDED',
|
|
17
|
+
'X_PRERENDER_FAILED',
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export type RenderErrorCode = (typeof RENDER_ERROR_CODES)[number];
|
|
21
|
+
|
|
22
|
+
export const RENDER_ERROR_TITLES: Readonly<Record<RenderErrorCode, string>> = {
|
|
23
|
+
X_ROUTE_MODE_INVALID: 'render mode not allowed on this surface',
|
|
24
|
+
X_ROUTE_OFFLINE_MISSING: "the route's offline strategy is missing or contradictory",
|
|
25
|
+
X_ROUTE_META_MISSING: 'required metadata missing',
|
|
26
|
+
X_ROUTE_UNNORMALIZED: 'a route was registered without defineRoute',
|
|
27
|
+
X_ROUTE_DUPLICATE: 'two route files resolve to one URL',
|
|
28
|
+
X_ROUTE_FILE_INVALID: 'a route file is not named for its surface',
|
|
29
|
+
X_SURFACE_BOUNDARY: 'a surface imported across the hard boundary',
|
|
30
|
+
X_BUDGET_EXCEEDED: 'a route blew its JS or LCP budget',
|
|
31
|
+
X_PRERENDER_FAILED: 'a prerendered path threw during build',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Titles must be registered for `format()` to render the contract's first line. Every code above is
|
|
35
|
+
// owned here and none is borrowed, so the call is unconditional: a second package claiming one has
|
|
36
|
+
// to fail as X_ERROR_CODE_DUPLICATE, not quietly keep whichever title was registered first.
|
|
37
|
+
registerErrorCodes(
|
|
38
|
+
Object.fromEntries(Object.entries(RENDER_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const docsFor = (code: RenderErrorCode): string => `https://ultimate.dev/errors/${code}`;
|
|
42
|
+
|
|
43
|
+
/** A render mode's invariant was violated at registration (see `modes.ts`). */
|
|
44
|
+
export class RouteModeInvalidError extends UltimateError {
|
|
45
|
+
static readonly code = 'X_ROUTE_MODE_INVALID' as const;
|
|
46
|
+
constructor(cause: string, fix: string) {
|
|
47
|
+
super({
|
|
48
|
+
code: RouteModeInvalidError.code,
|
|
49
|
+
cause,
|
|
50
|
+
fix,
|
|
51
|
+
docs: docsFor(RouteModeInvalidError.code),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `offline` is required by the type; this catches JS callers that bypass it. */
|
|
57
|
+
export class RouteOfflineMissingError extends UltimateError {
|
|
58
|
+
static readonly code = 'X_ROUTE_OFFLINE_MISSING' as const;
|
|
59
|
+
constructor(cause: string, fix: string) {
|
|
60
|
+
super({
|
|
61
|
+
code: RouteOfflineMissingError.code,
|
|
62
|
+
cause,
|
|
63
|
+
fix,
|
|
64
|
+
docs: docsFor(RouteOfflineMissingError.code),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `meta` is required by the type; this catches JS callers that bypass it. */
|
|
70
|
+
export class RouteMetaMissingError extends UltimateError {
|
|
71
|
+
static readonly code = 'X_ROUTE_META_MISSING' as const;
|
|
72
|
+
constructor(cause: string, fix: string) {
|
|
73
|
+
super({
|
|
74
|
+
code: RouteMetaMissingError.code,
|
|
75
|
+
cause,
|
|
76
|
+
fix,
|
|
77
|
+
docs: docsFor(RouteMetaMissingError.code),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The route table holds descriptors, never declarations. `defineRoute` is the one normalizer, so
|
|
84
|
+
* a raw declaration reaching the registry means `budget` and `meta` are whatever the author wrote
|
|
85
|
+
* — and every reader downstream (`describeRoutes`, `sw.js`, the sitemap) assumes they are not.
|
|
86
|
+
*/
|
|
87
|
+
export class RouteUnnormalizedError extends UltimateError {
|
|
88
|
+
static readonly code = 'X_ROUTE_UNNORMALIZED' as const;
|
|
89
|
+
constructor(cause: string, fix: string) {
|
|
90
|
+
super({
|
|
91
|
+
code: RouteUnnormalizedError.code,
|
|
92
|
+
cause,
|
|
93
|
+
fix,
|
|
94
|
+
docs: docsFor(RouteUnnormalizedError.code),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Two files claim the same URL — the route table must stay a function of the path. */
|
|
100
|
+
export class RouteDuplicateError extends UltimateError {
|
|
101
|
+
static readonly code = 'X_ROUTE_DUPLICATE' as const;
|
|
102
|
+
constructor(cause: string, fix: string) {
|
|
103
|
+
super({
|
|
104
|
+
code: RouteDuplicateError.code,
|
|
105
|
+
cause,
|
|
106
|
+
fix,
|
|
107
|
+
docs: docsFor(RouteDuplicateError.code),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A route file is not named for its surface. One spelling per surface — `page.tsx` under `site/`
|
|
114
|
+
* and `app/`, `route.ts` under `api/` — because the URL is the *directory* path, and a second
|
|
115
|
+
* spelling makes "is this file a route?" undecidable for every reader that has to answer it:
|
|
116
|
+
* the module scan, the boundary walk, `sw.js`, and the author looking at the folder.
|
|
117
|
+
*/
|
|
118
|
+
export class RouteFileInvalidError extends UltimateError {
|
|
119
|
+
static readonly code = 'X_ROUTE_FILE_INVALID' as const;
|
|
120
|
+
constructor(cause: string, fix: string) {
|
|
121
|
+
super({
|
|
122
|
+
code: RouteFileInvalidError.code,
|
|
123
|
+
cause,
|
|
124
|
+
fix,
|
|
125
|
+
docs: docsFor(RouteFileInvalidError.code),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A surface imported across the hard boundary (`site/` → `app/`, `shared/` → anything). */
|
|
131
|
+
export class SurfaceBoundaryError extends UltimateError {
|
|
132
|
+
static readonly code = 'X_SURFACE_BOUNDARY' as const;
|
|
133
|
+
constructor(cause: string, fix: string) {
|
|
134
|
+
super({
|
|
135
|
+
code: SurfaceBoundaryError.code,
|
|
136
|
+
cause,
|
|
137
|
+
fix,
|
|
138
|
+
docs: docsFor(SurfaceBoundaryError.code),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A route's measured JS/LCP exceeded its declared `budget`. */
|
|
144
|
+
export class BudgetExceededError extends UltimateError {
|
|
145
|
+
static readonly code = 'X_BUDGET_EXCEEDED' as const;
|
|
146
|
+
constructor(cause: string, fix: string) {
|
|
147
|
+
super({
|
|
148
|
+
code: BudgetExceededError.code,
|
|
149
|
+
cause,
|
|
150
|
+
fix,
|
|
151
|
+
docs: docsFor(BudgetExceededError.code),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** `prerender()` threw, returned a non-enumeration, or produced unusable params. */
|
|
157
|
+
export class PrerenderFailedError extends UltimateError {
|
|
158
|
+
static readonly code = 'X_PRERENDER_FAILED' as const;
|
|
159
|
+
constructor(cause: string, fix: string) {
|
|
160
|
+
super({
|
|
161
|
+
code: PrerenderFailedError.code,
|
|
162
|
+
cause,
|
|
163
|
+
fix,
|
|
164
|
+
docs: docsFor(PrerenderFailedError.code),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
package/src/head-seo.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Single responsibility: the one adapter between `@ultimat3/seo`'s tag vocabulary and this
|
|
2
|
+
// package's `HeadRenderers` seam. `head.ts` stays injection-only so it is testable without a
|
|
3
|
+
// catalog; every caller that renders a real document — `x dev`, `x build`, the server entry —
|
|
4
|
+
// binds the seam here instead of writing a fourth private converter.
|
|
5
|
+
|
|
6
|
+
import type { RenderMetaOptions, RouteMeta, HeadTag as SeoHeadTag } from '@ultimat3/seo';
|
|
7
|
+
import { renderMeta } from '@ultimat3/seo';
|
|
8
|
+
import type { HeadRenderers, HeadTag } from './head';
|
|
9
|
+
|
|
10
|
+
/** Attributes that identify a tag for dedupe, read in this order. */
|
|
11
|
+
const IDENTITY: Readonly<Record<SeoHeadTag['tag'], readonly string[]>> = {
|
|
12
|
+
title: [],
|
|
13
|
+
meta: ['name', 'property', 'http-equiv', 'charset', 'media'],
|
|
14
|
+
link: ['rel', 'hreflang', 'sizes'],
|
|
15
|
+
script: ['type'],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `<meta name="description">` → `meta:description`. Scripts also carry their position: a page
|
|
20
|
+
* with three JSON-LD nodes emits three tags with identical attributes, and keying them alike
|
|
21
|
+
* would collapse the graph down to its last node.
|
|
22
|
+
*/
|
|
23
|
+
export function headTagKey(tag: SeoHeadTag, index: number): string {
|
|
24
|
+
const identity = IDENTITY[tag.tag]
|
|
25
|
+
.map((name) => tag.attrs[name])
|
|
26
|
+
.filter((value): value is string => value !== undefined);
|
|
27
|
+
return [tag.tag, ...identity, ...(tag.tag === 'script' ? [String(index)] : [])].join(':');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function toHeadTag(tag: SeoHeadTag, index: number): HeadTag {
|
|
31
|
+
return {
|
|
32
|
+
kind: tag.tag,
|
|
33
|
+
key: headTagKey(tag, index),
|
|
34
|
+
attrs: tag.attrs,
|
|
35
|
+
...(tag.text === undefined ? {} : { content: tag.text }),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `HeadRenderers` backed by `@ultimat3/seo`. There is no `renderLd` half on purpose:
|
|
41
|
+
* `renderMeta` already emits `meta.ld` as JSON-LD scripts, and a second source for the same
|
|
42
|
+
* tags is exactly how a document ends up with two copies of its graph.
|
|
43
|
+
*/
|
|
44
|
+
export function seoRenderers(options: RenderMetaOptions = {}): HeadRenderers {
|
|
45
|
+
return { renderMeta: (meta: RouteMeta) => renderMeta(meta, options).map(toHeadTag) };
|
|
46
|
+
}
|
package/src/head.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<head>` management. Merges `@ultimat3/seo`'s output with per-route overrides, deduped
|
|
3
|
+
* by key so the last writer wins deterministically, and owns the one inlined script the
|
|
4
|
+
* framework allows on a 0kb `site/` route: the theme flip.
|
|
5
|
+
*
|
|
6
|
+
* The seo renderers are injected rather than imported so render stays testable without a
|
|
7
|
+
* catalog and so the tag vocabulary keeps exactly one owner (`@ultimat3/seo`).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { RouteMeta } from '@ultimat3/seo';
|
|
11
|
+
import { BudgetExceededError } from './errors';
|
|
12
|
+
|
|
13
|
+
export type HeadTagKind = 'title' | 'base' | 'meta' | 'link' | 'script' | 'style';
|
|
14
|
+
|
|
15
|
+
export interface HeadTag {
|
|
16
|
+
readonly kind: HeadTagKind;
|
|
17
|
+
/** Dedupe key. `title`, `meta:description`, `link:canonical`, `script:theme`. */
|
|
18
|
+
readonly key: string;
|
|
19
|
+
readonly attrs?: Readonly<Record<string, string | boolean>>;
|
|
20
|
+
/** Text content for `title`, `script`, `style`. */
|
|
21
|
+
readonly content?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type MetaRenderer = (meta: RouteMeta) => readonly HeadTag[];
|
|
25
|
+
export type LdRenderer = (meta: RouteMeta) => string | null;
|
|
26
|
+
|
|
27
|
+
export interface HeadRenderers {
|
|
28
|
+
/** `@ultimat3/seo`'s `renderMeta`, adapted to `HeadTag[]`. */
|
|
29
|
+
readonly renderMeta: MetaRenderer;
|
|
30
|
+
/** `@ultimat3/seo`'s `renderLd`, returning the JSON-LD body or null. */
|
|
31
|
+
readonly renderLd?: LdRenderer;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const KIND_ORDER: readonly HeadTagKind[] = ['base', 'title', 'meta', 'link', 'style', 'script'];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Later sources win per key. Order is by kind, then by first-seen position within the
|
|
38
|
+
* kind — stable output matters because the shell's bytes are content-hashed.
|
|
39
|
+
*/
|
|
40
|
+
export function mergeHead(...sources: readonly (readonly HeadTag[])[]): readonly HeadTag[] {
|
|
41
|
+
const byKey = new Map<string, HeadTag>();
|
|
42
|
+
const order: string[] = [];
|
|
43
|
+
for (const source of sources) {
|
|
44
|
+
for (const tag of source) {
|
|
45
|
+
if (!byKey.has(tag.key)) order.push(tag.key);
|
|
46
|
+
byKey.set(tag.key, tag);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const merged = order
|
|
50
|
+
.map((key) => byKey.get(key))
|
|
51
|
+
.filter((tag): tag is HeadTag => tag !== undefined);
|
|
52
|
+
return merged.sort((a, b) => KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Build the head for a route from its `meta` output plus explicit overrides. */
|
|
56
|
+
export function headFromMeta(
|
|
57
|
+
meta: RouteMeta,
|
|
58
|
+
renderers: HeadRenderers,
|
|
59
|
+
overrides: readonly HeadTag[] = [],
|
|
60
|
+
): readonly HeadTag[] {
|
|
61
|
+
const seoTags = renderers.renderMeta(meta);
|
|
62
|
+
const ld = renderers.renderLd?.(meta) ?? null;
|
|
63
|
+
const ldTags: readonly HeadTag[] =
|
|
64
|
+
ld === null
|
|
65
|
+
? []
|
|
66
|
+
: [
|
|
67
|
+
{
|
|
68
|
+
kind: 'script',
|
|
69
|
+
key: 'script:ld+json',
|
|
70
|
+
attrs: { type: 'application/ld+json' },
|
|
71
|
+
content: ld,
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
return mergeHead(seoTags, ldTags, overrides);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const VOID_KINDS = new Set<HeadTagKind>(['meta', 'link', 'base']);
|
|
78
|
+
|
|
79
|
+
export function renderHead(tags: readonly HeadTag[]): string {
|
|
80
|
+
return tags.map(renderTag).join('');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function renderTag(tag: HeadTag): string {
|
|
84
|
+
const attrs = Object.entries(tag.attrs ?? {})
|
|
85
|
+
.map(([name, value]) =>
|
|
86
|
+
value === true ? ` ${name}` : ` ${name}="${escapeAttr(String(value))}"`,
|
|
87
|
+
)
|
|
88
|
+
.join('');
|
|
89
|
+
if (VOID_KINDS.has(tag.kind)) return `<${tag.kind}${attrs}>`;
|
|
90
|
+
const raw = tag.content ?? '';
|
|
91
|
+
const isRaw = tag.kind === 'script' || tag.kind === 'style';
|
|
92
|
+
return `<${tag.kind}${attrs}>${isRaw ? raw : escapeText(raw)}</${tag.kind}>`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function escapeAttr(value: string): string {
|
|
96
|
+
return value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function escapeText(value: string): string {
|
|
100
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ThemeScriptOptions {
|
|
104
|
+
/** Attribute the tokens key off. Never a class, never a raw colour. */
|
|
105
|
+
readonly attribute?: string;
|
|
106
|
+
readonly storageKey?: string;
|
|
107
|
+
/** Hard cap; a theme script that grows past this is no longer "one inlined script". */
|
|
108
|
+
readonly maxBytes?: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const THEME_SCRIPT_MAX_BYTES = 512;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The injection point for the no-flash theme flip. It sets a semantic attribute and
|
|
115
|
+
* nothing else — every colour is a token, so the whole scheme swap is one attribute.
|
|
116
|
+
* Returns a `HeadTag` so it participates in dedupe like any other tag.
|
|
117
|
+
*/
|
|
118
|
+
export function themeScript(options: ThemeScriptOptions = {}): HeadTag {
|
|
119
|
+
const attribute = options.attribute ?? 'data-theme';
|
|
120
|
+
const storageKey = options.storageKey ?? 'x-theme';
|
|
121
|
+
const source =
|
|
122
|
+
`try{var t=localStorage.getItem("${storageKey}")||` +
|
|
123
|
+
`(matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light");` +
|
|
124
|
+
`document.documentElement.setAttribute("${attribute}",t)}catch(e){}`;
|
|
125
|
+
|
|
126
|
+
const bytes = new TextEncoder().encode(source).byteLength;
|
|
127
|
+
const cap = options.maxBytes ?? THEME_SCRIPT_MAX_BYTES;
|
|
128
|
+
if (bytes > cap) {
|
|
129
|
+
throw new BudgetExceededError(
|
|
130
|
+
`the inlined theme script is ${bytes}b, over its ${cap}b cap — the only script a ` +
|
|
131
|
+
'0kb site/ route is allowed to ship',
|
|
132
|
+
'shrink the theme script, or raise maxBytes deliberately in the head config',
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { kind: 'script', key: 'script:theme', content: source };
|
|
137
|
+
}
|
package/src/hydrate.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four hydration strategies, emitted as a per-island directive. `never` emits no
|
|
3
|
+
* script tag and no runtime at all — that is what keeps the `site/` baseline at 0kb.
|
|
4
|
+
* `interaction` replays the event that triggered the load, so a click during the fetch is
|
|
5
|
+
* answered instead of swallowed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { HydrateStrategy } from './route';
|
|
9
|
+
|
|
10
|
+
export interface IslandDirective {
|
|
11
|
+
readonly islandId: string;
|
|
12
|
+
readonly strategy: HydrateStrategy;
|
|
13
|
+
/** Build-id-immutable module URL for this island's chunk. */
|
|
14
|
+
readonly entry: string;
|
|
15
|
+
/** Serialized props; must be JSON. */
|
|
16
|
+
readonly props?: Readonly<Record<string, unknown>>;
|
|
17
|
+
/** Events replayed for `interaction`. */
|
|
18
|
+
readonly events?: readonly string[];
|
|
19
|
+
/** `rootMargin` for `visible`. */
|
|
20
|
+
readonly rootMargin?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_REPLAY_EVENTS = ['click', 'input', 'change', 'submit', 'keydown'] as const;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The island's markup wrapper. `never` gets attributes only, so the HTML is inert and the
|
|
27
|
+
* runtime below is never emitted for that island.
|
|
28
|
+
*/
|
|
29
|
+
export function emitIslandAttributes(directive: IslandDirective): string {
|
|
30
|
+
const attrs = [`data-x-island="${directive.islandId}"`, `data-x-hydrate="${directive.strategy}"`];
|
|
31
|
+
if (directive.strategy !== 'never') {
|
|
32
|
+
attrs.push(`data-x-entry="${directive.entry}"`);
|
|
33
|
+
if (directive.rootMargin !== undefined) {
|
|
34
|
+
attrs.push(`data-x-margin="${directive.rootMargin}"`);
|
|
35
|
+
}
|
|
36
|
+
if (directive.events !== undefined && directive.events.length > 0) {
|
|
37
|
+
attrs.push(`data-x-events="${directive.events.join(' ')}"`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return attrs.join(' ');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Props travel as a typed JSON script tag, never as an attribute (quoting hazards). */
|
|
44
|
+
export function emitIslandProps(directive: IslandDirective): string {
|
|
45
|
+
if (directive.props === undefined || directive.strategy === 'never') return '';
|
|
46
|
+
return (
|
|
47
|
+
`<script type="application/json" data-x-props="${directive.islandId}">` +
|
|
48
|
+
`${JSON.stringify(directive.props).replace(/</g, '\\u003c')}</script>`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Strategies that need runtime support. `never` is absent by construction. */
|
|
53
|
+
export function requiredStrategies(
|
|
54
|
+
directives: readonly IslandDirective[],
|
|
55
|
+
): ReadonlySet<Exclude<HydrateStrategy, 'never'>> {
|
|
56
|
+
const set = new Set<Exclude<HydrateStrategy, 'never'>>();
|
|
57
|
+
for (const directive of directives) {
|
|
58
|
+
if (directive.strategy !== 'never') set.add(directive.strategy);
|
|
59
|
+
}
|
|
60
|
+
return set;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const RUNTIME_PRELUDE = `
|
|
64
|
+
var Q={};
|
|
65
|
+
function boot(el){var e=el.getAttribute('data-x-entry');
|
|
66
|
+
if(!e||el.__x)return Promise.resolve();el.__x=1;
|
|
67
|
+
var p=document.querySelector('script[data-x-props="'+el.getAttribute('data-x-island')+'"]');
|
|
68
|
+
var props=p?JSON.parse(p.textContent||'{}'):{};
|
|
69
|
+
return import(e).then(function(m){return m.mount(el,props)})}
|
|
70
|
+
function each(s,f){Array.prototype.forEach.call(document.querySelectorAll(s),f)}
|
|
71
|
+
`.trim();
|
|
72
|
+
|
|
73
|
+
const RUNTIME_IDLE = `
|
|
74
|
+
each('[data-x-hydrate="idle"]',function(el){
|
|
75
|
+
var go=function(){boot(el)};
|
|
76
|
+
if('requestIdleCallback'in window)requestIdleCallback(go,{timeout:2000});else setTimeout(go,1)})
|
|
77
|
+
`.trim();
|
|
78
|
+
|
|
79
|
+
const RUNTIME_VISIBLE = `
|
|
80
|
+
each('[data-x-hydrate="visible"]',function(el){
|
|
81
|
+
var io=new IntersectionObserver(function(es){es.forEach(function(en){
|
|
82
|
+
if(en.isIntersecting){io.disconnect();boot(el)}})},{rootMargin:el.getAttribute('data-x-margin')||'200px'});
|
|
83
|
+
io.observe(el)})
|
|
84
|
+
`.trim();
|
|
85
|
+
|
|
86
|
+
// Event replay: the listener is registered before the chunk exists, records the event that
|
|
87
|
+
// woke the island, and re-dispatches it once mounted. Without this, the first click on a
|
|
88
|
+
// cold island is silently lost — the failure users read as "the button does nothing".
|
|
89
|
+
const RUNTIME_INTERACTION = `
|
|
90
|
+
each('[data-x-hydrate="interaction"]',function(el){
|
|
91
|
+
var evs=(el.getAttribute('data-x-events')||'click').split(' ');
|
|
92
|
+
var q=[],done=false;
|
|
93
|
+
var on=function(ev){if(done)return;q.push(ev);
|
|
94
|
+
boot(el).then(function(){done=true;evs.forEach(function(n){el.removeEventListener(n,on,true)});
|
|
95
|
+
q.forEach(function(ev){var c=new ev.constructor(ev.type,ev);ev.target.dispatchEvent(c)});q=[]})};
|
|
96
|
+
evs.forEach(function(n){el.addEventListener(n,on,true)})})
|
|
97
|
+
`.trim();
|
|
98
|
+
|
|
99
|
+
const RUNTIME_PARTS: Readonly<Record<Exclude<HydrateStrategy, 'never'>, string>> = {
|
|
100
|
+
idle: RUNTIME_IDLE,
|
|
101
|
+
visible: RUNTIME_VISIBLE,
|
|
102
|
+
interaction: RUNTIME_INTERACTION,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Emit only the runtime the page's islands actually use. A page of `never` islands gets
|
|
107
|
+
* the empty string — an unused strategy must not cost a byte.
|
|
108
|
+
*/
|
|
109
|
+
export function hydrateRuntime(directives: readonly IslandDirective[]): string {
|
|
110
|
+
const needed = requiredStrategies(directives);
|
|
111
|
+
if (needed.size === 0) return '';
|
|
112
|
+
const parts = (['idle', 'visible', 'interaction'] as const)
|
|
113
|
+
.filter((strategy) => needed.has(strategy))
|
|
114
|
+
.map((strategy) => RUNTIME_PARTS[strategy]);
|
|
115
|
+
return `<script type="module">${RUNTIME_PRELUDE}\n${parts.join('\n')}</script>`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Rough emitted size of the hydration runtime for a page, for the budget check. */
|
|
119
|
+
export function hydrateRuntimeBytes(directives: readonly IslandDirective[]): number {
|
|
120
|
+
return new TextEncoder().encode(hydrateRuntime(directives)).byteLength;
|
|
121
|
+
}
|