@ultimat3/render 7.0.0 → 9.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/CLAUDE.md +25 -6
- package/README.md +31 -5
- package/package.json +8 -7
- package/src/head.ts +11 -1
- package/src/html.ts +27 -4
- package/src/index.ts +9 -67
- package/src/islands.ts +3 -5
- package/src/registry.ts +16 -40
- package/src/server.ts +72 -0
package/CLAUDE.md
CHANGED
|
@@ -4,13 +4,30 @@ Owns: the `route` primitive, the four render modes, the route table, the surface
|
|
|
4
4
|
islands + budgets, hydration directives, `<head>` merge, **the server JSX runtime and the two Bun
|
|
5
5
|
loaders that make an app's `.tsx` and `.scss` runnable**.
|
|
6
6
|
|
|
7
|
+
**Two entry points, disjoint, split 2026-08-22** — the same `"."` / `"./server"` shape
|
|
8
|
+
`@ultimat3/realtime` took, for the same reason. `"."` (`index.ts`) is the CLIENT half and BUNDLES
|
|
9
|
+
for the browser; `"./server"` (`server.ts`) is the build-time half — `css-modules`,
|
|
10
|
+
`module-loader`, `render-html`, `render-isr`, `render-ssr`, `render-static`, `render-stream` — and
|
|
11
|
+
does not. `css-modules.ts` imports `fileURLToPath`/`pathToFileURL` from `node:url`, which Bun's
|
|
12
|
+
browser polyfill exports NEITHER of, so the single barrel was not a fat bundle, it was a
|
|
13
|
+
`bun build --target=browser` that FAILED at link time on any app entry that reached this package.
|
|
14
|
+
Measured: no `sideEffects` value repairs it (`false`, `[]`, an array naming only `errors.ts` — all
|
|
15
|
+
fail identically), which is where this split differs from realtime's, where the array alone was
|
|
16
|
+
enough. Only not importing the module does. Never re-export a name from both barrels: disjointness
|
|
17
|
+
is what makes "which half does this live in" a mechanical fact, and `index.test.ts` asserts both
|
|
18
|
+
the empty name intersection AND that `index.ts`'s transitive runtime import graph reaches none of
|
|
19
|
+
the seven modules above. `scripts/browser-barrel.test.ts` holds the end property, both directions.
|
|
20
|
+
|
|
7
21
|
`island()` is a **factory over the route's own `hydrate`**, not a ninth primitive and not a
|
|
8
22
|
second render mode — the same rule `llm()` and `backfill()` follow. It adds no key to
|
|
9
23
|
`defineRoute`.
|
|
10
24
|
|
|
11
25
|
Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cache`, `seo`,
|
|
12
|
-
`entity`, `policy`, `http`, `action`, `query`. **Never** `pwa`, `mcp`, `ai`, `manifest`
|
|
13
|
-
|
|
26
|
+
`entity`, `policy`, `http`, `action`, `query`. **Never** `pwa`, `mcp`, `ai`, `manifest`, `ui`
|
|
27
|
+
— all tier 4, so **sideways**, and an undeclared sideways edge is a build error. `ui` moved 5 → 4
|
|
28
|
+
in 2026-08 and is held level with this package deliberately (`FLOOR_ABOVE` in
|
|
29
|
+
`scripts/lib/tiers.ts`), so `render → ui` stays refused; this package sits above its own floor of 2
|
|
30
|
+
for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upward).
|
|
14
31
|
|
|
15
32
|
| Rule | Detail |
|
|
16
33
|
|---|---|
|
|
@@ -35,14 +52,16 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
|
|
|
35
52
|
| Island props | declared, JSON-safe, under `ISLAND_PROPS_MAX_BYTES` — `island-props.ts` is the one gate. A structural walk, never a `JSON.stringify` round trip: stringify drops a function and an `undefined` silently, which is the footgun rather than the check. |
|
|
36
53
|
| A prop lands via `Object.defineProperty` | never `out[key] = v`. For exactly one name — `__proto__`, which `JSON.parse` mints as a real OWN key off any request body — the assignment runs `Object.prototype`'s setter: the prop was DROPPED from the browser payload (the footgun the walk exists to prevent), the record handed back as `IslandProps` carried a prototype built from request data, so a later `bag.row.isAdmin` on the SERVER read attacker-chosen values, and `ISLAND_PROPS_MAX_BYTES` under-counted because `JSON.stringify` could not see it. Same shape `@ultimat3/mcp`'s `validate-args.ts` uses for the same class. |
|
|
37
54
|
| An attribute alias is a `Map` | never a record — an object lookup walks the prototype chain, so `<div {...row} />` with a column named `toString` resolved the alias to a FUNCTION and `attribute.toLowerCase()` threw a bare `TypeError`: no code, no fix, the whole page 500s off a `load()` result. Same reason `MODE_SPECS[config.render]` in `modes.ts` is guarded by `Object.hasOwn`, where `render: 'constructor'` returned a frozen descriptor for a mode nothing implements. |
|
|
55
|
+
| An attribute NAME is validated too | `ATTRIBUTE_NAME` in `html.ts` — `/^[A-Za-z_:][-A-Za-z0-9_:.]*$/`, refused as `null`. A name is emitted VERBATIM before the `=` and is escaped nowhere, so `{ 'q onmouseover=alert(1) r': 'ok' }` shipped a live event handler out of an object KEY. The handler check on the line under it folds case for the same reason: it was `name.startsWith('on')` while the two checks below it lowercased, so `ONERROR="alert(1)"` went out on the wire. Both are reachable by `<div {...row} />` over a JSON body or a JSONB column. `head.ts`'s `renderTag` is the package's OTHER attribute sink and shares the predicate (`isAttributeName`) — it emitted `<meta name="q" r onmouseover=alert(1) s="ok">` from an `attrs` key. One predicate, never two. |
|
|
38
56
|
| Which attributes take a URL | `URL_BEARING_ATTRIBUTES` in `html.ts` — core's four (`href`, `src`, `action`, `formaction`) plus `data`, `poster`, `ping` and `xlink:href`, each of which a browser FOLLOWS. `srcdoc` is refused outright: its value is entity-decoded and THEN parsed as HTML, so `escapeAttribute`'s `<script>` becomes a live `<script>` on this origin — escaping cannot make markup inert, so the attribute is never emitted, the same way `innerHTML` stays the one explicit escape hatch. |
|
|
39
57
|
| Island collection | per render, passed as `renderToHtml(tree, { islands })`. Never module-global and never on an ambient context — two concurrent requests would bill one page for the other's JS, and `assertNoPerRequestState` refuses a live context under `static` anyway. |
|
|
58
|
+
| A byte count in a message | `formatBytes` from `@ultimat3/core`, never a local one. This package's copy stopped at `kb`, so a 5 MB route read `5120kb` in `X_BUDGET_EXCEEDED` while `@ultimat3/pwa`'s own copy said `5mb` for the same bytes — two halves of one build disagreeing about the size of one artifact. Still on this barrel, because `@ultimat3/cli`'s budget reporter reads it beside the route table. |
|
|
40
59
|
| Island bytes | `routeJsBytes` unions `entry.islands` with the rendered directives' `moduleId`s. Reading either alone is a budget that counts the runtime and not the chunk. |
|
|
41
60
|
| Island markup | the props `<script>` is emitted INSIDE the wrapper by `render-html.ts`, so a document assembler has exactly one thing left to remember: `hydrateRuntime(directives)`. |
|
|
42
61
|
| Island boot | `el.__x` holds the boot PROMISE, never a boolean. As a flag, a second interaction while the chunk was still loading got a resolved promise back and the replay queue flushed into an island that had not mounted — the events went nowhere and the listeners were already removed. |
|
|
43
62
|
| Island mount markers | `data-x-mounted=""` when `mount()` RESOLVED, `data-x-failed="<message>"` when it rejected — set by the runtime, never by `emitIslandAttributes`, because the server does not know the answer. `el.__x` alone cannot carry this: it is assigned when `import()` is CALLED, so a chunk still downloading and one whose `mount()` threw were the same observable, and telling those apart is what `x shot` gates on. Two attributes rather than two values of one, so `[data-x-mounted]` never counts a failure as a success. The rejection handler RETHROWS — swallowing it resolves `el.__x` and the interaction runtime flushes its replay queue into an island that never mounted, which is the row above reintroduced one layer out. Costs 129 B of shared prelude: `idle` 744, `visible` 816, `interaction` 1,010 (`As of 2026-08-21`, the numbers `DEFAULT_ISLAND_JS_BYTES` is derived from). |
|
|
44
63
|
| `idle`'s deadline | `IDLE_HYDRATE_TIMEOUT_MS`, interpolated INTO the runtime string. Exported because a second reader has to agree — `x shot` waits before it photographs, and a settle shorter than this deadline reports an unhydrated page for one that hydrates perfectly. A constant the emitted string restates instead of reading is worse than no constant. |
|
|
45
|
-
| Route truth | `registry.ts`. Never keep a second route list anywhere. |
|
|
64
|
+
| Route truth | `registry.ts`. Never keep a second route list anywhere, and never a second *matcher*: this package's `matchRoute` was deleted in 2026-08 with zero consumers, because `@ultimat3/http`'s trie (`stages.ts`) is the one that serves requests and two matchers with different precedence rules is two answers to "which route is this?". `routeFor` is an exact-path `Map` lookup, not a pattern matcher. |
|
|
46
65
|
| Route filename | `page.tsx` under `site/`/`app/`, `route.ts` under `api/` — `ROUTE_FILENAME`, one per surface. The URL is the directory path. Anything else is `X_ROUTE_FILE_INVALID`; never widen the table to accept a second spelling. |
|
|
47
66
|
| Registry input | descriptors only. `registerRoute` refuses a raw declaration with `X_ROUTE_UNNORMALIZED` — `defineRoute` is the one normalizer of everything the declaration alone decides, and every reader downstream assumes it ran. The registry fills in exactly one value on top: the island budget, which needs the surface, which is a fact of the file path only the route table reads. |
|
|
48
67
|
| Descriptors | `describeRoutes()` must stay JSON-safe, sorted by path, deterministic. |
|
|
@@ -63,14 +82,14 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
|
|
|
63
82
|
| Responses | return `RenderResult`. `@ultimat3/http` builds the `Response`. |
|
|
64
83
|
| Solid | no `solid-js` import anywhere in this package — `type-pins.tsx` satisfies its `JSX.Element` structurally, through `jsxImportSource`, and never names it. The JSX factory in `jsx.ts` builds inert nodes — it is not a Solid renderer and must never become one. The client half runs in an island chunk, which `@ultimat3/cli`'s `solid-loader.ts` compiles with `babel-preset-solid`: Solid's reactivity is a COMPILE-time contract, so nothing this package could inject would substitute for it. `router-client.ts` was the one file built on that premise ("inject primitives") and it never had a caller. |
|
|
65
84
|
| Root element | `ROOT_ELEMENT_ID` (`render-html.ts`), the id every document's body wraps its component in. It was `SPA_ROOT_ID` in `render-spa.ts`, naming a mode that never used it and that no longer exists. |
|
|
66
|
-
| The loaders | `module-loader.ts` installs them at
|
|
85
|
+
| The loaders | `module-loader.ts` installs them at **`server.ts`** module scope, once — `index.ts` until the barrel split, and it cannot be there again: the client barrel would carry `sass` and `node:url`. A plugin only affects modules loaded after it, so a second install point is a page that renders in one entry point and not another. Anything that loads an app's `.tsx` reaches `@ultimat3/render/server` first, which is why `packages/cli/src/app-load.ts` imports it for the side effect and nothing else. |
|
|
67
86
|
| `<head>` baseline | `documentBaseline()` in `head.ts` — charset, viewport, `color-scheme` — merged FIRST so a route can still override any of them. Absent until `As of 2026-08`, and the missing `viewport` is why every deployed app rendered zoomed-out on a phone whatever its CSS said. |
|
|
68
|
-
| Escaping | `html.ts` only, and that now includes `render-stream.ts` (`holeMarker`'s attribute, and `revealChunk`'s `$X(...)` argument via `JSON.stringify` — an id containing `")` closed the call and ran the rest) and `head.ts`'s `themeScript` (`storageKey`/`attribute` as JS string LITERALS). All author-controlled today — the identical status `emitIslandAttributes` had before the last sweep. `render-spa.ts` was the third entry here and went with the mode. A second escaper is how one of them ends up missing a character, and a missing character in an attribute is an injection. `head.ts` and `hydrate.ts` each had a private copy; both now import — `escapeAttribute` for every attribute value (`emitIslandAttributes` interpolated all five of its own raw until 2026-08, while this row already claimed otherwise) and `escapeJsonContent` for a JSON script body. |
|
|
87
|
+
| Escaping | `html.ts` only, and that now includes `render-stream.ts` (`holeMarker`'s attribute, and `revealChunk`'s `$X(...)` argument via `JSON.stringify` — an id containing `")` closed the call and ran the rest) and `head.ts`'s `themeScript` (`storageKey`/`attribute` as JS string LITERALS). All author-controlled today — the identical status `emitIslandAttributes` had before the last sweep. `render-spa.ts` was the third entry here and went with the mode. A second escaper is how one of them ends up missing a character, and a missing character in an attribute is an injection. `escapeAttribute` itself is `@ultimat3/seo`'s (tier 1), re-exported by `html.ts` rather than reimplemented — the copy that lived here was the second escaper this row forbids, and `pwa/CLAUDE.md` already named seo's as the one. `head.ts` and `hydrate.ts` each had a private copy; both now import — `escapeAttribute` for every attribute value (`emitIslandAttributes` interpolated all five of its own raw until 2026-08, while this row already claimed otherwise) and `escapeJsonContent` for a JSON script body. |
|
|
69
88
|
| Script and style CONTENT | never emitted raw. Three rules, one choice: HTML text (`escapeText`), raw text for code (`escapeRawTextContent`: `</` → `<\/`, `<!--` → `<\!--`), and the total JSON rule for a `type` ending in `json` (`escapeJsonContent`: `<`, `>`, `&`, U+2028/9 → `\uXXXX`, still valid JSON). `meta.ld` is built from route data, and it was emitted VERBATIM until `As of 2026-08` — a title could close the element. Never HTML-escape a script body: a character reference is not decoded there, so `<` corrupts the code AND leaves the hole. |
|
|
70
89
|
| Which export is the page | `route-component.ts`, one precedence: `Page` → a single `…Page` → a single capitalised function. Never a per-generator name table. |
|
|
71
90
|
| Stylesheets | compiled by `css-modules.ts` and served **inlined** per surface. `sass` is this package's only third-party dependency and its only reason to exist here. |
|
|
72
91
|
| CSS order | `stylesFor` sorts **globals before modules** (`isGlobalStylesheet`), never plain insertion order — the reset styles bare elements at the lowest specificity there is, so whichever page loaded first must not decide who wins a tie. `shared/` is carried by both graphs, like a package sheet: it is where an app's own global layer lives, and filtering it out is what made every deployed app render token-less. |
|
|
73
|
-
| The global layer | this package may not import `@ultimat3/ui` (tier 5,
|
|
92
|
+
| The global layer | this package may not import `@ultimat3/ui` (tier 4, the same tier — sideways, not upward: `ui` moved 5 → 4 in 2026-08 and `render → ui` stays forbidden because a same-tier edge has to be declared in `scripts/lib/tiers.ts`, and this one deliberately is not — the static bundle graph may not reach the design system, axiom 6), so the app's source graph carries it: one `shared/global.scss` that `@use`s `@ultimat3/ui/global.scss`, side-effect-imported by `shared/global.ts`. One file, because each stylesheet is its own Sass compilation — a token file `@use`d per module duplicates its `:root` block per module. `x verify` fails with `X_STYLES_GLOBAL_MISSING` when a surface's document defines none. |
|
|
74
93
|
| Colours | tokens and `data-theme` only. No hex in `head.ts` or any emitted script. |
|
|
75
94
|
| `<head>` binding | `head.ts` stays injection-only (testable with no catalog); `head-seo.ts` is the ONE binding of `HeadRenderers` to `@ultimat3/seo`. A caller writing its own converter is the drift this file prevents. |
|
|
76
95
|
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ export const config = defineRoute({
|
|
|
19
19
|
budget: { js: '40kb', lcp: 2000 },
|
|
20
20
|
load: ({ params }) => db.posts.bySlug(params.slug), // once per render
|
|
21
21
|
meta: ({ data, url }) => ({ title: data.title, description: data.excerpt,
|
|
22
|
-
og: { image: data.cover },
|
|
22
|
+
og: { image: data.cover }, canonical: url,
|
|
23
23
|
ld: ld.Article(data) }),
|
|
24
24
|
});
|
|
25
25
|
|
|
@@ -321,18 +321,44 @@ never flushed into an island that did not mount. `ISLAND_MOUNTED_ATTRIBUTE` and
|
|
|
321
321
|
from, exported for the same reason: anything waiting for hydration has to wait at least this long,
|
|
322
322
|
and a second copy of the number is a settle that shoots early and calls a healthy page broken.
|
|
323
323
|
|
|
324
|
+
## Two entry points
|
|
325
|
+
|
|
326
|
+
**Split 2026-08-22, and every claim in this section holds `As of 2026-08`.**
|
|
327
|
+
|
|
328
|
+
`@ultimat3/render` is the **client** half — the `route` primitive, the JSX factory, islands,
|
|
329
|
+
hydration, `<head>`, the route table. It bundles for the browser, and
|
|
330
|
+
`scripts/browser-barrel.test.ts` builds it that way and asserts it.
|
|
331
|
+
|
|
332
|
+
`@ultimat3/render/server` is the **build-time** half — the `.tsx`/`.scss` Bun loaders and the
|
|
333
|
+
render pipeline. It imports `sass` and `node:url`, so it never reaches a browser bundle.
|
|
334
|
+
|
|
335
|
+
The two are **disjoint**: no name is on both, and a file needing both imports both. That is the
|
|
336
|
+
price of the split and it is the point of it — a single barrel could not be bundled for the
|
|
337
|
+
browser at all, because `node:url`'s browser polyfill exports neither `fileURLToPath` nor
|
|
338
|
+
`pathToFileURL` and the build fails at link time. No `sideEffects` value fixes that (measured:
|
|
339
|
+
`false`, `[]` and an array naming only `errors.ts` all fail identically) — only not importing it
|
|
340
|
+
does.
|
|
341
|
+
|
|
342
|
+
**Importing `@ultimat3/render/server` installs the `.tsx`/`.scss` loaders**, once, as a module
|
|
343
|
+
side effect. Anything that loads an app's source — `x dev`, `x build`, `server.ts`, a test that
|
|
344
|
+
`await import()`s a `page.tsx` — reaches it before the module it loads.
|
|
345
|
+
|
|
324
346
|
## Public API
|
|
325
347
|
|
|
348
|
+
`†` marks a name on `@ultimat3/render/server`.
|
|
349
|
+
|
|
326
350
|
| Export | Owns |
|
|
327
351
|
|---|---|
|
|
328
352
|
| `defineRoute` | the `route` primitive |
|
|
329
353
|
| `island`, `createIslandCollector` | one interactive component on a static page |
|
|
330
354
|
| `MODE_SPECS`, `assertModeShape`, `assertModeInvariants` | the mode invariant table |
|
|
331
|
-
| `registerRoute`, `describeRoutes`, `
|
|
355
|
+
| `registerRoute`, `describeRoutes`, `routeFor`, `routePathFromFile` | the route table |
|
|
332
356
|
| `checkSurfaceBoundary`, `assertSurfaceBoundary`, `surfaceOf` | the hard boundary |
|
|
333
|
-
| `renderStatic
|
|
334
|
-
| `createIsrController
|
|
335
|
-
| `renderSsr
|
|
357
|
+
| `renderStatic`†, `enumeratePrerender`† | build-time render, content hashing |
|
|
358
|
+
| `createIsrController`†, `invalidateAndRevalidate`† | SWR + single-flight + tag triggers |
|
|
359
|
+
| `renderSsr`†, `streamResult`† | the per-request modes |
|
|
360
|
+
| `renderToHtml`†, `renderComponent`†, `stylesFor`† | the server JSX writer and the surface's css |
|
|
361
|
+
| `installRenderLoader`†, `compileStylesheet`† | the `.tsx`/`.scss` loaders, installed on import |
|
|
336
362
|
| `emitIslandAttributes`, `hydrateRuntime` | the four hydration strategies |
|
|
337
363
|
| `ISLAND_MOUNTED_ATTRIBUTE`, `ISLAND_FAILED_ATTRIBUTE`, `IDLE_HYDRATE_TIMEOUT_MS` | what hydration looks like from outside the page |
|
|
338
364
|
| `graphFor`, `checkBudget`, `assertBudget` | two bundle graphs, per-route budgets |
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/render",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "9.0.0",
|
|
4
4
|
"description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": [
|
|
8
8
|
"./src/errors.ts",
|
|
9
|
-
"./src/
|
|
9
|
+
"./src/server.ts"
|
|
10
10
|
],
|
|
11
11
|
"repository": {
|
|
12
12
|
"type": "git",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"provenance": true
|
|
19
19
|
},
|
|
20
20
|
"exports": {
|
|
21
|
-
".": "./src/index.ts"
|
|
21
|
+
".": "./src/index.ts",
|
|
22
|
+
"./server": "./src/server.ts"
|
|
22
23
|
},
|
|
23
24
|
"files": [
|
|
24
25
|
"src",
|
|
@@ -35,10 +36,10 @@
|
|
|
35
36
|
"test": "bun test"
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
38
|
-
"@ultimat3/cache": "
|
|
39
|
-
"@ultimat3/core": "
|
|
40
|
-
"@ultimat3/i18n": "
|
|
41
|
-
"@ultimat3/seo": "
|
|
39
|
+
"@ultimat3/cache": "9.0.0",
|
|
40
|
+
"@ultimat3/core": "9.0.0",
|
|
41
|
+
"@ultimat3/i18n": "9.0.0",
|
|
42
|
+
"@ultimat3/seo": "9.0.0",
|
|
42
43
|
"sass": "1.102.0"
|
|
43
44
|
}
|
|
44
45
|
}
|
package/src/head.ts
CHANGED
|
@@ -10,7 +10,13 @@
|
|
|
10
10
|
import type { RouteMeta } from '@ultimat3/seo';
|
|
11
11
|
import { BudgetExceededError } from './errors';
|
|
12
12
|
// `html.ts` is this package's one escaper — a second one is how a character ends up missing.
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
escapeAttribute,
|
|
15
|
+
escapeJsonContent,
|
|
16
|
+
escapeRawTextContent,
|
|
17
|
+
escapeText,
|
|
18
|
+
isAttributeName,
|
|
19
|
+
} from './html';
|
|
14
20
|
|
|
15
21
|
export type HeadTagKind = 'title' | 'base' | 'meta' | 'link' | 'script' | 'style';
|
|
16
22
|
|
|
@@ -112,7 +118,11 @@ export function renderHead(tags: readonly HeadTag[]): string {
|
|
|
112
118
|
}
|
|
113
119
|
|
|
114
120
|
function renderTag(tag: HeadTag): string {
|
|
121
|
+
// The NAME is emitted verbatim before the `=` and is escaped nowhere, so a key carrying a space
|
|
122
|
+
// carries a whole second attribute with it — an app spreading a row into `attrs` put a live
|
|
123
|
+
// event handler in `<head>`. Same predicate `attributePair` uses, never a second copy.
|
|
115
124
|
const attrs = Object.entries(tag.attrs ?? {})
|
|
125
|
+
.filter(([name]) => isAttributeName(name))
|
|
116
126
|
.map(([name, value]) =>
|
|
117
127
|
value === true ? ` ${name}` : ` ${name}="${escapeAttribute(String(value))}"`,
|
|
118
128
|
)
|
package/src/html.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { safeUrl, URL_ATTRIBUTES } from '@ultimat3/core';
|
|
8
|
+
import { escapeAttribute } from '@ultimat3/seo';
|
|
8
9
|
import type { JsxProps } from './jsx';
|
|
9
10
|
|
|
10
11
|
/** Elements that never carry children, so the writer must not emit a closing tag. */
|
|
@@ -29,9 +30,13 @@ export function escapeText(value: string): string {
|
|
|
29
30
|
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Re-exported, never re-implemented: `@ultimat3/seo` (tier 1) owns the one attribute escaper, and
|
|
35
|
+
* the copy that lived here was a second place for a character to go missing. It stays reachable
|
|
36
|
+
* from `./html` so this module remains the single import site every render-* file already uses —
|
|
37
|
+
* one implementation, one place to look.
|
|
38
|
+
*/
|
|
39
|
+
export { escapeAttribute };
|
|
35
40
|
|
|
36
41
|
/**
|
|
37
42
|
* `<script>` and `<style>` hold RAW TEXT: a character reference is not decoded inside them, so
|
|
@@ -115,6 +120,20 @@ const URL_BEARING_ATTRIBUTES: ReadonlySet<string> = new Set([
|
|
|
115
120
|
*/
|
|
116
121
|
const REFUSED_ATTRIBUTES: ReadonlySet<string> = new Set(['srcdoc']);
|
|
117
122
|
|
|
123
|
+
/**
|
|
124
|
+
* The only shape that can be written between `<div` and `>` without ending the attribute. A name
|
|
125
|
+
* is not escaped anywhere — it is emitted verbatim before the `=` — so a key carrying a space
|
|
126
|
+
* carries a whole second attribute with it: `{ 'x onmouseover=alert(1) y': 'ok' }` emitted a live
|
|
127
|
+
* event handler out of an object KEY, which is `<div {...row} />` over a JSON body or a JSONB
|
|
128
|
+
* column. Narrower than the HTML spec's name production on purpose: everything an app actually
|
|
129
|
+
* writes (`data-*`, `aria-*`, `xlink:href`, a `__proto__`-named column) matches, and the refusal
|
|
130
|
+
* is `null` — the same "emit nothing" this function already answers for a refused URL.
|
|
131
|
+
*/
|
|
132
|
+
const ATTRIBUTE_NAME = /^[A-Za-z_:][-A-Za-z0-9_:.]*$/;
|
|
133
|
+
|
|
134
|
+
/** Exported for `head.ts`, the package's other attribute sink. One predicate, never two. */
|
|
135
|
+
export const isAttributeName = (name: string): boolean => ATTRIBUTE_NAME.test(name);
|
|
136
|
+
|
|
118
137
|
const cssProperty = (name: string): string =>
|
|
119
138
|
name.startsWith('--') ? name : name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
120
139
|
|
|
@@ -137,7 +156,11 @@ export function attributePair(name: string, value: unknown): string | null {
|
|
|
137
156
|
if (NON_ATTRIBUTES.has(name)) return null;
|
|
138
157
|
if (value === undefined || value === null || value === false) return null;
|
|
139
158
|
if (typeof value === 'function') return null;
|
|
140
|
-
if (
|
|
159
|
+
if (!isAttributeName(name)) return null;
|
|
160
|
+
// Folded, because HTML attribute names are case-insensitive and the two checks below already
|
|
161
|
+
// fold: `ONERROR="alert(1)"` went out on the wire off a spread row for as long as this line was
|
|
162
|
+
// `name.startsWith('on')`.
|
|
163
|
+
if (name.toLowerCase().startsWith('on') && name.length > 2) return null;
|
|
141
164
|
|
|
142
165
|
const attribute = ATTRIBUTE_ALIASES.get(name) ?? name;
|
|
143
166
|
if (REFUSED_ATTRIBUTES.has(attribute.toLowerCase())) return null;
|
package/src/index.ts
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
// every consumer that will ever load a `.tsx` route or a `.scss` module imports this package first
|
|
7
|
-
// (an app's route file imports `defineRoute` from here before it imports anything else it owns).
|
|
8
|
-
// Any later hook — `x dev`, `x build`, `server.ts` — would each have to remember, which is four
|
|
9
|
-
// places one fact can be wrong instead of none.
|
|
10
|
-
installRenderLoader();
|
|
1
|
+
/**
|
|
2
|
+
* The CLIENT half of `@ultimat3/render` — the `route` primitive, the JSX factory, islands, and the
|
|
3
|
+
* tables describing them — kept disjoint from `@ultimat3/render/server` because everything here
|
|
4
|
+
* must bundle for a browser, which the loaders cannot (axiom 6).
|
|
5
|
+
*/
|
|
11
6
|
|
|
12
7
|
/**
|
|
13
8
|
* The route vocabulary is declared once, at tier 0 (`@ultimat3/core`), and re-exported here
|
|
@@ -17,9 +12,10 @@ installRenderLoader();
|
|
|
17
12
|
* second declaration, which is what makes re-exporting safe where copying was not.
|
|
18
13
|
*/
|
|
19
14
|
export type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core';
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
15
|
+
// `formatBytes` moved to `@ultimat3/core` (one formatter, with the `mb` branch this package's copy
|
|
16
|
+
// never had); still named here because `@ultimat3/cli`'s budget reporter reads it beside the route
|
|
17
|
+
// table it prints against.
|
|
18
|
+
export { formatBytes, HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from '@ultimat3/core';
|
|
23
19
|
export { parseTtlMs } from './duration';
|
|
24
20
|
export type { RenderErrorCode } from './errors';
|
|
25
21
|
export {
|
|
@@ -86,7 +82,6 @@ export {
|
|
|
86
82
|
assertBudget,
|
|
87
83
|
checkBudget,
|
|
88
84
|
checkBudgets,
|
|
89
|
-
formatBytes,
|
|
90
85
|
graphFor,
|
|
91
86
|
parseByteBudget,
|
|
92
87
|
routeJsBytes,
|
|
@@ -102,27 +97,16 @@ export {
|
|
|
102
97
|
defaultIslandBudget,
|
|
103
98
|
MODE_SPECS,
|
|
104
99
|
} from './modes';
|
|
105
|
-
export type { Stylesheet } from './module-loader';
|
|
106
|
-
export {
|
|
107
|
-
clearStylesheets,
|
|
108
|
-
installRenderLoader,
|
|
109
|
-
loadStylesheet,
|
|
110
|
-
registeredStylesheets,
|
|
111
|
-
stylesFor,
|
|
112
|
-
transformTsx,
|
|
113
|
-
} from './module-loader';
|
|
114
100
|
export type {
|
|
115
101
|
CompiledPattern,
|
|
116
102
|
RegisterRouteInput,
|
|
117
103
|
RouteDescriptor,
|
|
118
104
|
RouteEntry,
|
|
119
|
-
RouteMatch,
|
|
120
105
|
} from './registry';
|
|
121
106
|
export {
|
|
122
107
|
clearRoutes,
|
|
123
108
|
compilePattern,
|
|
124
109
|
describeRoutes,
|
|
125
|
-
matchRoute,
|
|
126
110
|
ROUTE_FILENAME,
|
|
127
111
|
registerRoute,
|
|
128
112
|
routeCount,
|
|
@@ -130,48 +114,6 @@ export {
|
|
|
130
114
|
routeFor,
|
|
131
115
|
routePathFromFile,
|
|
132
116
|
} from './registry';
|
|
133
|
-
export type { RenderHtmlOptions } from './render-html';
|
|
134
|
-
export { ROOT_ELEMENT_ID, renderComponent, renderToHtml } from './render-html';
|
|
135
|
-
export type {
|
|
136
|
-
IsrController,
|
|
137
|
-
IsrControllerOptions,
|
|
138
|
-
IsrEntry,
|
|
139
|
-
IsrRenderFn,
|
|
140
|
-
IsrServeResult,
|
|
141
|
-
IsrState,
|
|
142
|
-
IsrStore,
|
|
143
|
-
MemoryIsrStoreOptions,
|
|
144
|
-
} from './render-isr';
|
|
145
|
-
export {
|
|
146
|
-
createIsrController,
|
|
147
|
-
DEFAULT_ISR_MAX_ENTRIES,
|
|
148
|
-
invalidateAndRevalidate,
|
|
149
|
-
isrKey,
|
|
150
|
-
memoryIsrStore,
|
|
151
|
-
} from './render-isr';
|
|
152
|
-
export type { SsrOptions, SsrRenderFn, SsrRenderInput } from './render-ssr';
|
|
153
|
-
export { renderSsr, ssrHeaders } from './render-ssr';
|
|
154
|
-
export type { StaticArtifact, StaticBuildOptions, StaticRenderFn } from './render-static';
|
|
155
|
-
export {
|
|
156
|
-
assertNoPerRequestState,
|
|
157
|
-
contentHash,
|
|
158
|
-
enumeratePrerender,
|
|
159
|
-
fillPath,
|
|
160
|
-
renderStatic,
|
|
161
|
-
staticHeaders,
|
|
162
|
-
staticResult,
|
|
163
|
-
} from './render-static';
|
|
164
|
-
export type { StreamHole, StreamOptions, StreamPlan } from './render-stream';
|
|
165
|
-
export {
|
|
166
|
-
collectStream,
|
|
167
|
-
DEFAULT_HOLE_TIMEOUT_MS,
|
|
168
|
-
holeId,
|
|
169
|
-
holeMarker,
|
|
170
|
-
REVEAL_SCRIPT,
|
|
171
|
-
renderStreamHtml,
|
|
172
|
-
revealChunk,
|
|
173
|
-
streamResult,
|
|
174
|
-
} from './render-stream';
|
|
175
117
|
export type {
|
|
176
118
|
LoadRequirement,
|
|
177
119
|
PrerenderFn,
|
package/src/islands.ts
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { HydrateStrategy } from '@ultimat3/core';
|
|
9
|
+
// One formatter, in `@ultimat3/core`: the copy that lived here had no `mb` branch, so a 5 MB route
|
|
10
|
+
// read `5120kb` in `X_BUDGET_EXCEEDED` while `@ultimat3/pwa` said `5mb` for the same bytes.
|
|
11
|
+
import { formatBytes } from '@ultimat3/core';
|
|
9
12
|
import { BudgetExceededError } from './errors';
|
|
10
13
|
import type { IslandDirective } from './hydrate';
|
|
11
14
|
import { hydrateRuntimeBytes } from './hydrate';
|
|
@@ -55,11 +58,6 @@ export function parseByteBudget(budget: string | undefined): number | null {
|
|
|
55
58
|
return factor === undefined ? null : Math.round(Number(amount) * factor);
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
export function formatBytes(bytes: number): string {
|
|
59
|
-
if (bytes < 1024) return `${bytes}b`;
|
|
60
|
-
return `${Math.round((bytes / 1024) * 10) / 10}kb`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
61
|
export interface RouteBytes {
|
|
64
62
|
readonly total: number;
|
|
65
63
|
readonly baseline: number;
|
package/src/registry.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
SurfaceBoundaryError,
|
|
14
14
|
} from './errors';
|
|
15
15
|
import { assertModeInvariants, defaultIslandBudget } from './modes';
|
|
16
|
-
import type { RouteConfig, RouteData
|
|
16
|
+
import type { RouteConfig, RouteData } from './route';
|
|
17
17
|
import { isRouteConfig, tagKeys } from './route';
|
|
18
18
|
import type { RouteComponent } from './route-component';
|
|
19
19
|
import type { Surface } from './surfaces';
|
|
@@ -155,12 +155,20 @@ function assertRouteFilename(file: string, surface: Surface, basename: string |
|
|
|
155
155
|
const dir = stem.slice(0, stem.lastIndexOf('/'));
|
|
156
156
|
const inPlace = DIRECTORY_STEMS.has(stem.slice(dir.length + 1));
|
|
157
157
|
const target = inPlace ? `${dir}/${expected}` : `${stem}/${expected}`;
|
|
158
|
+
// Plain `mv`, never `git mv`. `x new --no-git` scaffolds a tree with no repository, and there the
|
|
159
|
+
// shipped `git mv` answered `fatal: not a git repository` — a fix line that fails is worse than
|
|
160
|
+
// no fix line, because the reader debugs git instead of moving the file. `mv` works in both
|
|
161
|
+
// cases: git detects the rename at `git add` time, so the only thing given up is a nicety, and
|
|
162
|
+
// the instruction is the same one either way. `-n` keeps the one guarantee `git mv` did carry:
|
|
163
|
+
// an author who adds the correct `site/pricing/page.tsx` and leaves `site/pricing.tsx` behind is
|
|
164
|
+
// reported against the stale file, and `target` is then the GOOD file — a clobber deletes a
|
|
165
|
+
// working route, and this fix line is pasted unread.
|
|
158
166
|
throw new RouteFileInvalidError(
|
|
159
167
|
`${file} is a route on the ${surface} surface, so it must be named ${expected}: the URL is the ` +
|
|
160
168
|
'directory path and the filename names the kind of file',
|
|
161
169
|
inPlace
|
|
162
|
-
? `
|
|
163
|
-
: `mkdir -p -- ${shellQuote(stem)} &&
|
|
170
|
+
? `mv -n -- ${shellQuote(file)} ${shellQuote(target)}`
|
|
171
|
+
: `mkdir -p -- ${shellQuote(stem)} && mv -n -- ${shellQuote(file)} ${shellQuote(target)}`,
|
|
164
172
|
);
|
|
165
173
|
}
|
|
166
174
|
|
|
@@ -334,46 +342,14 @@ export function describeRoutes(): readonly RouteDescriptor[] {
|
|
|
334
342
|
}));
|
|
335
343
|
}
|
|
336
344
|
|
|
337
|
-
export interface RouteMatch {
|
|
338
|
-
readonly entry: RouteEntry;
|
|
339
|
-
readonly params: RouteParams;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/** Most specific pattern wins: static segments > dynamic > catch-all. */
|
|
343
|
-
export function matchRoute(pathname: string): RouteMatch | null {
|
|
344
|
-
const candidates = routeEntries()
|
|
345
|
-
.slice()
|
|
346
|
-
.sort((a, b) => b.pattern.specificity - a.pattern.specificity);
|
|
347
|
-
|
|
348
|
-
for (const entry of candidates) {
|
|
349
|
-
const match = entry.pattern.regex.exec(pathname);
|
|
350
|
-
if (match === null) continue;
|
|
351
|
-
const params: Record<string, string> = {};
|
|
352
|
-
let undecodable = false;
|
|
353
|
-
entry.pattern.keys.forEach((key, index) => {
|
|
354
|
-
const value = match[index + 1];
|
|
355
|
-
if (value === undefined) return;
|
|
356
|
-
const decoded = decodeSegment(value);
|
|
357
|
-
if (decoded === undefined) undecodable = true;
|
|
358
|
-
else params[key] = decoded;
|
|
359
|
-
});
|
|
360
|
-
// A segment that will not decode fails only the branch that would have decoded it, exactly as
|
|
361
|
-
// `@ultimat3/http`'s router already answers: a literal route matching the same text still wins,
|
|
362
|
-
// and a pathname nothing else claims is the 404 it always was.
|
|
363
|
-
if (undecodable) continue;
|
|
364
|
-
return { entry, params };
|
|
365
|
-
}
|
|
366
|
-
return null;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
345
|
/**
|
|
370
346
|
* `undefined` for a malformed percent-escape. A pathname is whatever the client typed, and
|
|
371
|
-
* `decodeURIComponent('%zz')` throws a bare `URIError` — no code, no fix line —
|
|
372
|
-
*
|
|
347
|
+
* `decodeURIComponent('%zz')` throws a bare `URIError` — no code, no fix line — where a router
|
|
348
|
+
* already has an answer for "this branch does not match".
|
|
373
349
|
*
|
|
374
|
-
* Still exported after
|
|
375
|
-
* this segment decodable?" on this side of the wire,
|
|
376
|
-
* ends up throwing where the other 404s.
|
|
350
|
+
* Still exported after this package's own `matchRoute` was deleted for `@ultimat3/http`'s trie
|
|
351
|
+
* (`stages.ts`): it is the one answer to "is this segment decodable?" on this side of the wire,
|
|
352
|
+
* and a second copy of it is how one of the two ends up throwing where the other 404s.
|
|
377
353
|
*/
|
|
378
354
|
export function decodeSegment(value: string): string | undefined {
|
|
379
355
|
try {
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The BUILD-TIME half of `@ultimat3/render` — the loaders and the route → bytes pipeline — split
|
|
3
|
+
* off because `css-modules.ts` imports `node:url`, whose browser polyfill exports neither name it
|
|
4
|
+
* asks for: one barrel carrying both halves could not be bundled for a browser at all (axiom 6).
|
|
5
|
+
* Disjoint from `@ultimat3/render` by construction, which `server.test.ts` checks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { installRenderLoader } from './module-loader';
|
|
9
|
+
|
|
10
|
+
// A side effect on import, deliberately, and the reason this barrel is named in `sideEffects`: a
|
|
11
|
+
// Bun plugin only transforms modules loaded AFTER it, so the install has to happen before any
|
|
12
|
+
// `.tsx` route or `.scss` module is imported. It lives on THIS barrel rather than on
|
|
13
|
+
// `@ultimat3/render` because the loader is build-time code — a browser bundle that reached it
|
|
14
|
+
// would carry `sass` and `node:fs`, which is the defect this split closes.
|
|
15
|
+
installRenderLoader();
|
|
16
|
+
|
|
17
|
+
// ---- scss → css, and the scoped class map every `import styles from` receives -------------------
|
|
18
|
+
export type { CompiledStylesheet } from './css-modules';
|
|
19
|
+
export { compileStylesheet, isCssModule, isGlobalStylesheet, scopeClasses } from './css-modules';
|
|
20
|
+
// ---- the two Bun loaders: `.tsx` → the server JSX factory, `.scss` → css + a class map ----------
|
|
21
|
+
export type { Stylesheet } from './module-loader';
|
|
22
|
+
export {
|
|
23
|
+
clearStylesheets,
|
|
24
|
+
installRenderLoader,
|
|
25
|
+
loadStylesheet,
|
|
26
|
+
registeredStylesheets,
|
|
27
|
+
stylesFor,
|
|
28
|
+
transformTsx,
|
|
29
|
+
} from './module-loader';
|
|
30
|
+
// ---- the render pipeline: one entry point per mode ----------------------------------------------
|
|
31
|
+
export type { RenderHtmlOptions } from './render-html';
|
|
32
|
+
export { ROOT_ELEMENT_ID, renderComponent, renderToHtml } from './render-html';
|
|
33
|
+
export type {
|
|
34
|
+
IsrController,
|
|
35
|
+
IsrControllerOptions,
|
|
36
|
+
IsrEntry,
|
|
37
|
+
IsrRenderFn,
|
|
38
|
+
IsrServeResult,
|
|
39
|
+
IsrState,
|
|
40
|
+
IsrStore,
|
|
41
|
+
MemoryIsrStoreOptions,
|
|
42
|
+
} from './render-isr';
|
|
43
|
+
export {
|
|
44
|
+
createIsrController,
|
|
45
|
+
DEFAULT_ISR_MAX_ENTRIES,
|
|
46
|
+
invalidateAndRevalidate,
|
|
47
|
+
isrKey,
|
|
48
|
+
memoryIsrStore,
|
|
49
|
+
} from './render-isr';
|
|
50
|
+
export type { SsrOptions, SsrRenderFn, SsrRenderInput } from './render-ssr';
|
|
51
|
+
export { renderSsr, ssrHeaders } from './render-ssr';
|
|
52
|
+
export type { StaticArtifact, StaticBuildOptions, StaticRenderFn } from './render-static';
|
|
53
|
+
export {
|
|
54
|
+
assertNoPerRequestState,
|
|
55
|
+
contentHash,
|
|
56
|
+
enumeratePrerender,
|
|
57
|
+
fillPath,
|
|
58
|
+
renderStatic,
|
|
59
|
+
staticHeaders,
|
|
60
|
+
staticResult,
|
|
61
|
+
} from './render-static';
|
|
62
|
+
export type { StreamHole, StreamOptions, StreamPlan } from './render-stream';
|
|
63
|
+
export {
|
|
64
|
+
collectStream,
|
|
65
|
+
DEFAULT_HOLE_TIMEOUT_MS,
|
|
66
|
+
holeId,
|
|
67
|
+
holeMarker,
|
|
68
|
+
REVEAL_SCRIPT,
|
|
69
|
+
renderStreamHtml,
|
|
70
|
+
revealChunk,
|
|
71
|
+
streamResult,
|
|
72
|
+
} from './render-stream';
|