@ultimat3/render 23.0.0 → 25.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 +18 -11
- package/README.md +38 -15
- package/package.json +8 -8
- package/src/asset.ts +5 -2
- package/src/client-path-style-tag.ts +0 -3
- package/src/client-scope-tag.ts +0 -3
- package/src/client-sync-tags.ts +0 -3
- package/src/code-unit-order.ts +1 -1
- package/src/css-modules.ts +59 -11
- package/src/duration.ts +10 -2
- package/src/errors.ts +3 -3
- package/src/head.ts +12 -20
- package/src/html.ts +10 -22
- package/src/hydrate.ts +34 -13
- package/src/index.ts +12 -25
- package/src/island-collector.ts +13 -5
- package/src/island-hold.ts +56 -0
- package/src/island-props.ts +1 -1
- package/src/island.ts +6 -2
- package/src/modes.ts +14 -3
- package/src/module-loader.ts +19 -14
- package/src/navigation-dom.ts +28 -6
- package/src/navigation-fetch.ts +12 -11
- package/src/navigation-rules.ts +37 -27
- package/src/navigation-scroll.ts +51 -0
- package/src/navigation-swap.ts +10 -2
- package/src/navigation-tabs.ts +58 -0
- package/src/navigation.ts +44 -63
- package/src/registry.ts +37 -46
- package/src/render-html.ts +8 -6
- package/src/render-isr.ts +131 -69
- package/src/render-ssr.ts +2 -1
- package/src/render-static.ts +21 -7
- package/src/render-stream.ts +7 -7
- package/src/route-pattern.ts +67 -0
- package/src/route.ts +13 -19
- package/src/server.ts +2 -1
- package/src/static-path.ts +15 -2
- package/src/stylesheet-path.ts +34 -0
- package/src/type-pins.tsx +4 -3
package/CLAUDE.md
CHANGED
|
@@ -29,12 +29,12 @@ axiom 6). Never `cli` (upward).
|
|
|
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`. |
|
|
32
|
-
| A loader's own STATUS | `withStatus(status, data)` (`route-status.ts`) — the ONE way a page answers 404/410/503 while rendering its own component in the app's shell. The status rides on the data by IDENTITY in a `WeakMap`; `routeStatusOf(data)` is the total reader (200 by default). A 3xx is `X_ROUTE_STATUS_INVALID`. A 4xx/5xx is `robots.index = false` by construction in `defineRoute`'s `meta` wrapper. The `Response` status is minted by `@ultimat3/cli`'s `
|
|
32
|
+
| A loader's own STATUS | `withStatus(status, data)` (`route-status.ts`) — the ONE way a page answers 404/410/503 while rendering its own component in the app's shell. The status rides on the data by IDENTITY in a `WeakMap`; `routeStatusOf(data)` is the total reader (200 by default). A 3xx is `X_ROUTE_STATUS_INVALID`. A 4xx/5xx is `robots.index = false` by construction in `defineRoute`'s `meta` wrapper. The `Response` status is minted by `@ultimat3/cli`'s `runtime-render.ts`. |
|
|
33
33
|
| Type claims | `type-pins.tsx`, never a `.test.ts` — `tsconfig.json` excludes tests. `.tsx` so the island-as-JSX claim is checked against the JSX an author writes. |
|
|
34
34
|
| Descriptor `meta` / `load` | always `(x) => Promise<…>`. Authors may declare either sync; consumers never branch. |
|
|
35
35
|
| Descriptor `budget` | always an object, `{}` when undeclared. `budget.js === undefined` is the site/ hydration failure. |
|
|
36
|
-
| `RouteBudget` keys | **`js`
|
|
37
|
-
| No `describe()` on a route | `
|
|
36
|
+
| `RouteBudget` keys | **`js` only, PROJECTED as `budgetJs`** — `lcp` went with `budgetLcp`, measured by nothing. `assertModeInvariants` refuses any other key that arrives by a cast (`X_ROUTE_MODE_INVALID`); `type-pins.tsx` derives the allowed set from `RouteDescriptor`'s `budget*` keys, so an unprojected key is a build error; `scripts/declaration-readers.ts` finds the class across every declaration. |
|
|
37
|
+
| No `describe()` on a route | `describePages()` is the one route list. |
|
|
38
38
|
| Mode invariants | `modes.ts` only; never inline a mode check in a render-\* file. **Four modes, and every one renders the route's component.** A fifth mode has to name the function that renders it. |
|
|
39
39
|
| Island declaration | `island({ src })` — a **specifier**, never an import, so a `static` page's graph cannot grow the island's dependencies. Never add an overload that takes a component. |
|
|
40
40
|
| Island filename | `*.island.tsx`, `ISLAND_EXTENSION` — one spelling. Never widen it. |
|
|
@@ -51,19 +51,20 @@ axiom 6). Never `cli` (upward).
|
|
|
51
51
|
| Island collection | per render, `renderToHtml(tree, { islands })`. Never module-global and never on an ambient context. |
|
|
52
52
|
| A byte count in a message | `formatBytes` from `@ultimat3/core`, never a local one. |
|
|
53
53
|
| Island bytes | **not this package's**: `@ultimat3/cli`'s `packages/cli/src/budgets.ts` measures the EMITTED document and is the gate. What stays here is the budget grammar (`parseByteBudget`) and `defaultIslandBudget`. |
|
|
54
|
-
| The hydration runtime's CSP | `HYDRATE_RUNTIME_BODIES` — every body `hydrateRuntime` can emit (seven subsets). It is inline in every document with an island and `@ultimat3/http`'s `script-src` is `'self' 'wasm-unsafe-eval'`, so `@ultimat3/cli`'s `script-csp.ts` hashes this list at boot. `runtimeBody` is the one place the served and hashed text are one string. **Uncovered**: `render-stream.ts`'s per-hole `<script>$X("id")</script>` (unreachable today; the first real hole needs a nonce). |
|
|
54
|
+
| The hydration runtime's CSP | `HYDRATE_RUNTIME_BODIES` — every body `hydrateRuntime` can emit (seven subsets, each with and without the hold part — fourteen). It is inline in every document with an island and `@ultimat3/http`'s `script-src` is `'self' 'wasm-unsafe-eval'`, so `@ultimat3/cli`'s `script-csp.ts` hashes this list at boot. `runtimeBody` is the one place the served and hashed text are one string. **Uncovered**: `render-stream.ts`'s per-hole `<script>$X("id")</script>` (unreachable today; the first real hole needs a nonce). |
|
|
55
55
|
| Island markup | the props `<script>` is emitted INSIDE the wrapper by `render-html.ts`; an assembler only adds `hydrateRuntime(directives)`. |
|
|
56
56
|
| Island boot | `el.__x` holds the boot PROMISE, never a boolean. |
|
|
57
|
-
| Island mount markers | `data-x-mounted=""` when `mount()` resolved, `data-x-failed="<message>"` when it rejected — set by the runtime, never by `emitIslandAttributes`. The rejection handler RETHROWS. Prelude sizes `As of 2026-
|
|
57
|
+
| Island mount markers | `data-x-mounted=""` when `mount()` resolved, `data-x-failed="<message>"` when it rejected — set by the runtime, never by `emitIslandAttributes`. The rejection handler RETHROWS. Prelude sizes `As of 2026-10-05`: `idle` 1,843, `visible` 886, `interaction` 1,728 B (what `DEFAULT_ISLAND_JS_BYTES` derives from). |
|
|
58
58
|
| A runtime that calls `boot` | TERMINATES the chain: `idle`/`visible` end in `.catch(hush)`, `interaction` passes `off` as the rejection arm. `hydrate-runtime.test.ts` reds on an unhandled rejection. |
|
|
59
59
|
| `idle` replays too | `idle` and `interaction` share ONE `catchUp(el)` in `hydrate.ts` (capture listeners for `data-x-events`, default `click`; a queue; `aim`; one flush). A caught event wakes an `idle` island early; each event replays once. `hydrate-replay.test.ts`. |
|
|
60
60
|
| Where `interaction` replays | `aim(el, ev)`, never `ev.target` — `mount` usually clears the root, detaching the pressed node. Kept → original target; replaced → `path(el, target)`'s structural walk (same tag, same place), then `document.elementFromPoint(ev.clientX, ev.clientY)` (unless `ev.detail === 0`), then the island root. A hit outside the island falls back to the root. `hydrate-replay.test.ts`. |
|
|
61
|
+
| A held island | `hold` on the collector (the host decides — the CLI holds realtime islands on a page-boot document); `island-hold.ts` owns the attribute, the inline `visibility:hidden`, the cap `ISLAND_HOLD_MS` and the runtime part. Emitted right after the prelude; booted at once, revealed TOGETHER on both mount arms, at the cap, or by CSS (`ISLAND_HOLD_REVEAL`, keyframes in `@ultimat3/ui`'s `global.scss`) — never hidden for good. `catchUp`'s `on` lets go after a mount it did not flush. `island-hold.test.ts`, `island-hold-replay.test.ts`. |
|
|
61
62
|
| `idle`'s deadline | `IDLE_HYDRATE_TIMEOUT_MS`, interpolated INTO the runtime string and exported for `x shot`'s settle. |
|
|
62
63
|
| Route truth | `registry.ts`. Never a second route list and never a second matcher — `@ultimat3/http`'s trie (`stages.ts`) serves requests. `routeFor` is an exact-path `Map` lookup. |
|
|
63
64
|
| Route filename | `page.tsx` under `site/`/`app/`, `route.ts` under `api/` — `ROUTE_FILENAME`. Anything else is `X_ROUTE_FILE_INVALID`. |
|
|
64
|
-
| Mounted routes | `registerMountedRoutes(mount, routes)` (`mounted-routes.ts`) — routes a PACKAGE mounts with no surface file (the admin's screens). In `
|
|
65
|
+
| Mounted routes | `registerMountedRoutes(mount, routes)` (`mounted-routes.ts`) — routes a PACKAGE mounts with no surface file (the admin's screens). In `describePages()` with `mount: { by, permissions }`, never in `routeEntries()`: described, not rendered here. Re-declaring a `key` replaces its set; a path a file route also claims is `X_ROUTE_DUPLICATE` in both orders. `RouteDescriptor.mount` is absent on a file route. |
|
|
65
66
|
| Registry input | descriptors only. `registerRoute` refuses a raw declaration with `X_ROUTE_UNNORMALIZED`; it fills in only the island budget. |
|
|
66
|
-
| Descriptors | `
|
|
67
|
+
| Descriptors | `describePages()` stays JSON-safe, sorted by path, deterministic. |
|
|
67
68
|
| Every string order | `byCodeUnit` (`code-unit-order.ts`), never `localeCompare` (ICU default locale and collation version vary by machine). |
|
|
68
69
|
| Boundary | `surfaces.ts` throws; it never warns. Type-only edges are not violations. |
|
|
69
70
|
| Stream cancellation | the source has a `cancel()` and `write` is guarded on it; a disconnect aborts `StreamHole.resolve(signal)`. |
|
|
@@ -71,8 +72,12 @@ axiom 6). Never `cli` (upward).
|
|
|
71
72
|
| "Is this a TTL?" has one reader | `parseTtlMs` in `duration.ts`, below `modes.ts` and `render-isr.ts`. |
|
|
72
73
|
| A build-time frame reads a throw with `renderThrowable` | `render-html.ts`, `render-static.ts`, `css-modules.ts`, `module-loader.ts`, `route-data.ts`. `bun run error-render` cannot see a value laundered through a local helper. |
|
|
73
74
|
| `X_ROUTE_LOAD_FAILED` computes its pathname BEFORE the try | `pathnameOf` never throws on a relative `ctx.url`. |
|
|
74
|
-
| The ISR key | `isrKey(url, locale)` — pathname, the negotiated locale in the reserved `__x_locale` parameter, then the sorted query. The locale is REQUIRED. The time zone is deliberately not a dimension. `toResult` emits `vary: accept-language
|
|
75
|
+
| The ISR key | `isrKey(url, locale)` — pathname, the negotiated locale in the reserved `__x_locale` parameter, then the sorted query. The locale is REQUIRED. The time zone is deliberately not a dimension. `toResult` emits `vary: accept-language`, and `surrogate-key`/`cache-tag` from `@ultimat3/cache`'s `surrogateKeys()` over the route's `revalidateTags` — kept only while the response stays shared: http's `applyCacheHeaders` strips both on a rewrite to `private`/`no-store`. `registry.ts`'s `assertPurgeableTags` screens the tags at registration (`X_ROUTE_MODE_INVALID`); the rest of the shared key is `@ultimat3/http`'s `cache-headers` stage. The one shipped emitter of purge keys; file and mounted routes are both screened, so `toResult` never refuses on the request path |
|
|
75
76
|
| A bust that lands MID-render | fenced with `@ultimat3/cache`'s `sampleFence({ key, tags })`, taken before `render()` and asked before `store.set`. `registerPath` runs BEFORE the render. |
|
|
77
|
+
| ISR regeneration | `@ultimat3/core`'s `singleFlight`, `deadlineMs` = `regenerateDeadlineMs` (`DEFAULT_ISR_REGENERATE_DEADLINE_MS`, 30 s; `finiteCount` ≥ 1, no opt-out), `schedule` injectable. Past it the PATH is free; the hung render runs on, and `latest` (by identity) drops its result once a newer run started. `render-isr-flight.test.ts`, on a hand-fired scheduler. |
|
|
78
|
+
| Which route a stored ISR path is | exact path first, else the MOST SPECIFIC pattern: `route-pattern.ts`'s `compilePattern` (never a second compiler), sorted on `CompiledPattern.specificity`. Never table order: `describePages()` sorts `*` before `:`. |
|
|
79
|
+
| `CompiledPattern.specificity` | the ONE ordering rule, read by ISR, `@ultimat3/cli`'s sitemap extras and `@ultimat3/admin`'s `adminRouteMatch`: `@ultimat3/core`'s `routeRank(path)` — the request trie's order as one integer, shared with `@ultimat3/pwa`'s worker rule order. Never a local rank. |
|
|
80
|
+
| `compilePattern`'s regex | a `*catch-all` owns its separator, `(?:/(.*))?`, so the bare prefix `static-path.ts` writes for an empty rest (`/docs`) matches; a literal character matches raw OR percent-encoded, either hex case — a store key is `URL.pathname`, and `precios-españa` arrives as `precios-espa%C3%B1a`. `route-pattern.test.ts`. |
|
|
76
81
|
| Marking a page stale | `IsrStore.markStale(path)`, in place — never `set({ ...entry, stale: true })` (`set` means "just generated" and orders eviction). |
|
|
77
82
|
| ISR store bound | `memoryIsrStore()` caps at `DEFAULT_ISR_MAX_ENTRIES` (1,000), least recently generated first. |
|
|
78
83
|
| A `RenderResult.status` | `finiteStatus(subject, status)` (`finite-status.ts`), 200–599 whole, at every site that takes one from a caller; `isRenderStatus` is the TOTAL read of a stored `IsrEntry.status`. The name carries `finite` so `bun run finite-bounds` recognises it. |
|
|
@@ -93,9 +98,10 @@ axiom 6). Never `cli` (upward).
|
|
|
93
98
|
| Client sync tags | `client-sync-tags.ts` — `ultimate-sync`, `ultimate-build`, `ultimate-sync-worker`. Principal-free, on every document. |
|
|
94
99
|
| Client scope tag | `client-scope-tag.ts` — `<meta name="ultimate-scope">`, read by core's `pageClient()`. `documentCarriesScope(headers)` is the one rule: only a `private` document carries it; absent means "not rendered for anyone". The literal is duplicated in core until core exports a constant. |
|
|
95
100
|
| `<head>` baseline | `documentBaseline()` in `head.ts` — charset, viewport, `color-scheme` — merged FIRST so a route can override any of them. |
|
|
96
|
-
| Escaping | `html.ts` only — including `render-stream.ts`'s `holeMarker` and `revealChunk` (`JSON.stringify`), and `head.ts`'s `themeScript`. `
|
|
97
|
-
| Script and style CONTENT | never raw: `
|
|
101
|
+
| Escaping | `html.ts` only — including `render-stream.ts`'s `holeMarker`, its default `errorFallback` and `revealChunk` (`JSON.stringify`), and `head.ts`'s `themeScript`. `escapeHtml` — text and attributes alike, five characters — is `@ultimat3/core`'s, imported by `html.ts` (never re-exported); `bun run flight-copies` refuses a second table (`X_HELPER_COPY`). |
|
|
102
|
+
| Script and style CONTENT | never raw: `escapeHtml`, `escapeRawTextContent` (`</` → `<\/`, `<!--` → `<\!--`), or `escapeJsonContent` for a `type` ending in `json`. Never HTML-escape a script body. |
|
|
98
103
|
| Which export is the page | `route-component.ts`: `Page` → a single `…Page` → a single capitalised function. |
|
|
104
|
+
| CSS-module scoping | `scopeClasses` rewrites class selectors in qualified-rule PRELUDES only (`inPreludes`) — never a declaration (`local(Inter.Regular)`), a comment or an at-rule prelude; a class is a whole CSS identifier, escapes included, and the map key is its unescaped name. The suffix hashes the basename + the COMPILED CSS, so two modules with one source and different partials never share a class name. |
|
|
99
105
|
| Stylesheets | compiled by `css-modules.ts`, grouped per surface, served by the CLI as one content-hashed file per surface (`@ultimat3/cli`'s `style-bundle.ts`) from `stylesFor`. `sass` is this package's only third-party dependency. Each compile goes through `sass-cache.ts`: `.x/cache/sass/` under cwd, a hit only while every file the compile read hashes the same; `setSassCacheDir(null)` turns it off, `setSassCacheDir(dir)` moves it (the image's prebuilt store), `sassCompilations()` counts the misses a boot reports. |
|
|
100
106
|
| CSS order | `stylesFor` sorts **globals before modules** (`isGlobalStylesheet`). `shared/` is carried by both graphs. |
|
|
101
107
|
| A package's sheets | carried by BOTH surfaces unless the package claims its directory: `claimStylesheets(import.meta.dir, 'app')` makes every sheet below it that surface's alone, still ordered with the library. `@ultimat3/admin` claims `app` — a `site/` document carried its dashboard CSS (axiom 6). A claim re-classifies sheets already registered and survives `clearStylesheets()`. |
|
|
@@ -104,9 +110,10 @@ axiom 6). Never `cli` (upward).
|
|
|
104
110
|
| An island's `mount` | may return `() => void`, its disposer; the runtime never calls it, `@ultimat3/testing`'s `mountIsland` does. |
|
|
105
111
|
| `<head>` binding | `head.ts` stays injection-only; `head-seo.ts` is the ONE binding of `HeadRenderers` to `@ultimat3/seo`. |
|
|
106
112
|
| Client navigation | `navigation*.ts` — soft navigation over server-rendered documents, NOT a mode: the router renders nothing, it swaps the document the route already rendered. Rules are pure (`navigation-rules.ts`: `linkVerdict`, `formVerdict`, `responseVerdict`, `mayPrefetch`) and unit-tested; the DOM half (`navigation-swap.ts`, `navigation.ts`) is proven in Chrome by `packages/cli/e2e/client-navigation-*.e2e.test.ts`. `./navigation` (`navigation-entry.ts`) is the script the CLI builds; importing it starts it, and a document without `ultimate-navigation` is never intercepted. |
|
|
113
|
+
| Router ↔ the page's lifecycle | `navigation-scroll.ts` owns `scrollRestoration = 'manual'` and RESTORES the entry's saved offset at start, before its first save (a reload, or Back after a full load); `navigation-tabs.ts` owns the `BroadcastChannel`, closed on `pagehide` (it blocks the back/forward cache), reopened and the cache emptied on a persisted `pageshow`. A sheet `loadStylesheets` appends is `owned` at once, so an aborted navigation's sheets are retired by the next swap. A view transition is tracked by identity: an older one finishing never clears the running one. |
|
|
107
114
|
| Router ↔ islands | ONE bootstrap: the swap re-runs the inline hydration runtime, whose `each` visits a root once per tab (`el.__v`). Never add a second mount path for the router. Dispose = call what `el.__x` resolved to. |
|
|
108
115
|
| Router head diff | only elements a SERVER document put in `<head>` (`documentHead`'s set) may be removed; a script-added one is the tab's. Scripts and stylesheets are never diffed. |
|
|
109
|
-
| Router: nothing is sent twice | the router follows no redirect (`redirect: 'manual'`; the server hands it over as `204` + `x-ultimate-location`), never re-submits a POST (a failure is `ultimate:navigation-error`), and hands a non-page answer to the browser from the bytes it already has. Its cache never answers with a non-2xx, a hand-over, or a `no-store` page older than 5 s, and is emptied by any POST, `onClientWrite`, `onRescope` and a `BroadcastChannel` message from another tab. |
|
|
116
|
+
| Router: nothing is sent twice | the router follows no redirect (`redirect: 'manual'`; the server hands it over as `204` + `x-ultimate-location`), never re-submits a POST (a failure is `ultimate:navigation-error`), and hands a 2xx non-page answer to the browser from the bytes it already has. A 4xx/5xx non-page answer (an offline worker's empty 503) is never handed over (it became a `blob:` URL): a POST fails, a GET is loaded by the browser — the ONE deliberate re-send, as there is nothing to show (`responseVerdict`). Its cache never answers with a non-2xx, a hand-over, or a `no-store` page older than 5 s, and is emptied by any POST (when sent AND when it settles), `onClientWrite`, `onRescope` and a `BroadcastChannel` message from another tab. |
|
|
110
117
|
|
|
111
118
|
Cross-package: `@ultimat3/pwa` consumes route descriptors as **data**, never by import.
|
|
112
119
|
Keep `RouteDescriptor` additive — removing a field breaks `sw.js` generation.
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ export const config = defineRoute({
|
|
|
16
16
|
prerender: () => db.posts.slugs(),
|
|
17
17
|
offline: 'precache', // precache | runtime | network-only
|
|
18
18
|
hydrate: 'visible', // idle | visible | interaction | never
|
|
19
|
-
budget: { js: '40kb'
|
|
19
|
+
budget: { js: '40kb' },
|
|
20
20
|
load: ({ params }) => db.posts.bySlug(params.slug), // once per render
|
|
21
21
|
meta: ({ data, url }) => ({ title: data.title, description: data.excerpt,
|
|
22
22
|
og: { image: data.cover }, canonical: url,
|
|
@@ -131,7 +131,7 @@ registered under is `X_ROUTE_POST_INVALID` at boot, as is `post` on a `static` p
|
|
|
131
131
|
| Mode | Invariant | Error if violated |
|
|
132
132
|
|---|---|---|
|
|
133
133
|
| `static` | no per-request state — no `policy`, no `revalidate` | `X_ROUTE_MODE_INVALID` |
|
|
134
|
-
| `isr` | needs a trigger: `revalidate.tags` or `revalidate.ttl`; **no `policy`** — one cached document per URL cannot answer two actors | `X_ROUTE_MODE_INVALID` |
|
|
134
|
+
| `isr` | needs a trigger: `revalidate.tags` or `revalidate.ttl`; **no `policy`** — one cached document per URL cannot answer two actors; every `revalidate.tags` entry must be a purgeable CDN key (no whitespace, no comma) | `X_ROUTE_MODE_INVALID` |
|
|
135
135
|
| `ssr` | cannot be prerendered | `X_ROUTE_MODE_INVALID` |
|
|
136
136
|
| any but `ssr` | declares no `cache` — `static`/`isr` headers are the mode, a `stream` is always `private, no-store` | `X_ROUTE_MODE_INVALID` |
|
|
137
137
|
| gated (`policy`) | its `cache` is never `public`/`immutable` — one actor's document in a shared cache | `X_ROUTE_MODE_INVALID` |
|
|
@@ -143,7 +143,7 @@ is a build error.
|
|
|
143
143
|
|
|
144
144
|
## The route table is the single source of route truth
|
|
145
145
|
|
|
146
|
-
`registry.ts` maps file paths to URLs and `
|
|
146
|
+
`registry.ts` maps file paths to URLs and `describePages()` projects the table into a
|
|
147
147
|
sorted, JSON-safe descriptor list. Every downstream generator reads that one table.
|
|
148
148
|
|
|
149
149
|
| File | URL |
|
|
@@ -152,7 +152,7 @@ sorted, JSON-safe descriptor list. Every downstream generator reads that one tab
|
|
|
152
152
|
| `site/pricing/page.tsx` | `/pricing` |
|
|
153
153
|
| `site/(marketing)/about/page.tsx` | `/about` |
|
|
154
154
|
| `site/blog/[slug]/page.tsx` | `/blog/:slug` |
|
|
155
|
-
| `site/docs/[...path]/page.tsx` | `/docs/*path` |
|
|
155
|
+
| `site/docs/[...path]/page.tsx` | `/docs/*path` — `compilePattern` matches the bare `/docs` too, the path the static build writes for an empty rest |
|
|
156
156
|
| `app/dashboard/page.tsx` | `/dashboard` |
|
|
157
157
|
| `api/posts/route.ts` | `/api/posts` |
|
|
158
158
|
|
|
@@ -333,7 +333,7 @@ first frame — an id, a count, and the URL of the read that answers the rest:
|
|
|
333
333
|
|
|
334
334
|
```ts
|
|
335
335
|
// page.tsx — the props are the id and the endpoint, never the rows
|
|
336
|
-
import {
|
|
336
|
+
import { queryPath } from '@ultimat3/core';
|
|
337
337
|
import { island } from '@ultimat3/render';
|
|
338
338
|
|
|
339
339
|
const Dispatch = island({
|
|
@@ -342,7 +342,7 @@ const Dispatch = island({
|
|
|
342
342
|
});
|
|
343
343
|
|
|
344
344
|
export const DispatchFor = (host: { readonly id: string }) =>
|
|
345
|
-
Dispatch({ hostId: host.id, models: [], modelsEndpoint: `${
|
|
345
|
+
Dispatch({ hostId: host.id, models: [], modelsEndpoint: `${queryPath('modelList')}?limit=100` });
|
|
346
346
|
```
|
|
347
347
|
|
|
348
348
|
```tsx
|
|
@@ -370,7 +370,7 @@ export function mount(el: HTMLElement, props: DispatchProps): void {
|
|
|
370
370
|
```
|
|
371
371
|
|
|
372
372
|
`models: []` keeps the island's first frame honest (an empty picker, not a missing one) and
|
|
373
|
-
`
|
|
373
|
+
`queryPath` is the same derivation the typed `client()` uses, so the URL cannot drift from the
|
|
374
374
|
route. The `fix:` line of an over-cap `X_ISLAND_PROPS_INVALID` names exactly this edit, with the
|
|
375
375
|
heaviest prop's own name in it.
|
|
376
376
|
|
|
@@ -385,7 +385,7 @@ by the request that carries it.
|
|
|
385
385
|
### It counts against the route's budget
|
|
386
386
|
|
|
387
387
|
```ts
|
|
388
|
-
const collector =
|
|
388
|
+
const collector = islandCollector({ file, hydrate: config.hydrate, resolve });
|
|
389
389
|
const html = await renderToHtml(page, { islands: collector });
|
|
390
390
|
document.body += hydrateRuntime(collector.directives); // the one thing left to remember
|
|
391
391
|
```
|
|
@@ -433,6 +433,14 @@ never flushed into an island that did not mount. `ISLAND_MOUNTED_ATTRIBUTE` and
|
|
|
433
433
|
from, exported for the same reason: anything waiting for hydration has to wait at least this long,
|
|
434
434
|
and a second copy of the number is a settle that shoots early and calls a healthy page broken.
|
|
435
435
|
|
|
436
|
+
**A held island** (`data-x-hold`, `As of 2026-10`) is one whose server markup must not paint before
|
|
437
|
+
it mounts — an offline reload's cached count under a queued write (#506). The collector's
|
|
438
|
+
`hold: (src) => boolean` names them (the host's call: `@ultimat3/cli` holds realtime islands on a
|
|
439
|
+
document that carries the page boot); the wrapper is `visibility:hidden` from its first byte, the
|
|
440
|
+
runtime boots it at once whatever the strategy, and reveals it when `mount()` settles either way or
|
|
441
|
+
at `ISLAND_HOLD_MS` (3000), whichever comes first — and at the same cap by CSS alone (`ISLAND_HOLD_REVEAL`, keyframes in `@ultimat3/ui`'s `global.scss`), so a page with no script still shows the markup. Only a page holding an island pays for that part
|
|
442
|
+
of the runtime, and `HYDRATE_RUNTIME_BODIES` carries both variants.
|
|
443
|
+
|
|
436
444
|
## Two entry points
|
|
437
445
|
|
|
438
446
|
**Split 2026-08-22, and every claim in this section holds `As of 2026-08`.**
|
|
@@ -486,14 +494,14 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
486
494
|
|---|---|
|
|
487
495
|
| `defineRoute` | the `route` primitive |
|
|
488
496
|
| `withStatus`, `routeStatusOf` | the status a loader answers, carried on its data; 200 when nothing asked |
|
|
489
|
-
| `island`, `
|
|
497
|
+
| `island`, `islandCollector` | one interactive component on a static page |
|
|
490
498
|
| `asset`, `setAssetResolver`, `assetPathProblem`, `AssetPath` | `asset('assets/x.avif')` → the content-hashed URL of a public site file; the table is the CLI's ([Static Assets](../../wiki/Static-Assets.md)) |
|
|
491
499
|
| `MODE_SPECS`, `assertModeShape`, `assertModeInvariants` | the mode invariant table |
|
|
492
|
-
| `registerRoute`, `
|
|
493
|
-
| `registerMountedRoutes`, `RouteMount`, `RouteMountInput`, `MountedRouteInput` | routes a package mounts with no surface file: listed by `
|
|
500
|
+
| `registerRoute`, `describePages`, `routeFor`, `routePathFromFile` | the route table |
|
|
501
|
+
| `registerMountedRoutes`, `RouteMount`, `RouteMountInput`, `MountedRouteInput` | routes a package mounts with no surface file: listed by `describePages()` with `mount: { by, permissions }`, absent from `routeEntries()` |
|
|
494
502
|
| `checkSurfaceBoundary`, `assertSurfaceBoundary`, `surfaceOf` | the hard boundary |
|
|
495
503
|
| `renderStatic`†, `enumeratePrerender`† | build-time render, content hashing |
|
|
496
|
-
| `
|
|
504
|
+
| `isrController`†, `invalidateAndRevalidate`† | SWR + single-flight + tag triggers |
|
|
497
505
|
| `renderSsr`†, `streamResult`† | the per-request modes |
|
|
498
506
|
| `renderToHtml`†, `renderComponent`†, `stylesFor`† | the server JSX writer and the surface's css |
|
|
499
507
|
| `claimStylesheets`† | a package claims its own directory's stylesheets for ONE surface, so the other surface's documents never carry them |
|
|
@@ -501,8 +509,9 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
501
509
|
| `setSassCacheDir`†, `sassCompilations`† | where the Sass compile cache lives (`.x/cache/sass` under cwd; a container's is the image's prebuilt store) and how many compiles this process ran instead of reading one |
|
|
502
510
|
| `emitIslandAttributes`, `hydrateRuntime`, `HYDRATE_RUNTIME_BODIES` | the four hydration strategies, and every body the runtime script can hold — a host hashes that list into `script-src`, because the runtime is emitted inline and no `render: 'static'` file can receive a nonce |
|
|
503
511
|
| `ISLAND_MOUNTED_ATTRIBUTE`, `ISLAND_FAILED_ATTRIBUTE`, `IDLE_HYDRATE_TIMEOUT_MS` | what hydration looks like from outside the page |
|
|
512
|
+
| `ISLAND_HOLD_ATTRIBUTE`, `ISLAND_HOLD_MS`, `ISLAND_HOLD_REVEAL` | a held island's marker, and the cap after which it shows its server markup regardless |
|
|
504
513
|
| `parseByteBudget`, `defaultIslandBudget` | the `'40kb'` budget grammar, and the ceiling a declared island earns |
|
|
505
|
-
| `mergeHead`, `renderHead`, `themeScript` | `<head>` merge + the one inlined script |
|
|
514
|
+
| `mergeHead`, `renderHead`, `themeScript` | `<head>` merge + the one inlined script; it stamps `data-theme` and, beside it, the fallback itself as `data-theme-default` for `@ultimat3/ui`'s `clearTheme`/`watchOsTheme` |
|
|
506
515
|
| `clientPathStyleTags`, `CLIENT_PATH_STYLE_META` | `<meta name="ultimate-path-style">` — the action path style the server serves, for the browser's typed client. No tag for `'resource'`, the default |
|
|
507
516
|
|
|
508
517
|
## Notes
|
|
@@ -516,7 +525,21 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
516
525
|
framework's `Revalidator`, and the function it returns releases **both** halves — the
|
|
517
526
|
dependents and the revalidator slot, the latter only while it is still this controller's.
|
|
518
527
|
The default store (`memoryIsrStore`) is capped at `DEFAULT_ISR_MAX_ENTRIES` (1,000) pages,
|
|
519
|
-
least recently generated evicted first.
|
|
528
|
+
least recently generated evicted first. One regeneration holds its page for at most
|
|
529
|
+
`DEFAULT_ISR_REGENERATE_DEADLINE_MS` (30 s; `isrController({ regenerateDeadlineMs })`),
|
|
530
|
+
so a render that never settles no longer pins the page for the life of the process. A stored
|
|
531
|
+
path takes its TTL and tags from the most specific matching route (static > `:param` >
|
|
532
|
+
`*catch-all`, per segment), never the first in table order.
|
|
533
|
+
- **A tag-revalidated ISR document carries its purge keys while it is shared-cacheable**,
|
|
534
|
+
`As of 2026-10-02` — `@ultimat3/http`'s `cache-headers` stage rewrites the response to `private`
|
|
535
|
+
for a signed-in visitor and strips both headers: `Surrogate-Key`
|
|
536
|
+
(space-joined) and `Cache-Tag` (comma-joined) from `@ultimat3/cache`'s `surrogateKeys()` over
|
|
537
|
+
the route's `revalidate.tags` — `revalidate: { tags: [tag.post] }` answers `post e:post` — on a
|
|
538
|
+
miss, a hit and a served-stale response alike. That is what lets the same `invalidates` purge
|
|
539
|
+
the document at the CDN; before it, the edge held every ISR page for its whole `s-maxage`. A
|
|
540
|
+
TTL-only route carries neither header. A tag a CDN would split (whitespace, a comma) is refused
|
|
541
|
+
when the route registers — `X_ROUTE_MODE_INVALID`, naming the file — for file routes and
|
|
542
|
+
mounted routes both.
|
|
520
543
|
- **`stream`** flushes the shell first, then reveals holes in completion order with a
|
|
521
544
|
~200-byte inline script. A client that disconnects mid-stream cancels it: `StreamHole.resolve`
|
|
522
545
|
is handed an `AbortSignal` so the work stops, and nothing more is enqueued. Solid's compiled templates and signals mean the shell costs zero
|
|
@@ -531,7 +554,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
531
554
|
- **`hydrate: 'never'`** emits no attributes beyond the marker and no runtime — the `site/`
|
|
532
555
|
0kb default is mechanical, not aspirational. A page that renders an island anyway is
|
|
533
556
|
`X_ISLAND_NOT_HYDRATED`, not a silently dead button.
|
|
534
|
-
- **A gated page is `ssr`**, never a client-rendered shell. `spa` and `
|
|
557
|
+
- **A gated page is `ssr`**, never a client-rendered shell. `spa` and `httpRouter` were
|
|
535
558
|
deleted `As of 2026-08-20`: `renderSpa` never read the route's component and no build ever produced the
|
|
536
559
|
`chunks` it preloaded, so every `spa` route served an empty `<div id="x-root">`, and the router
|
|
537
560
|
that shell would have needed had no caller in the framework or in either tracked app. A page
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/render",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.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",
|
|
@@ -32,18 +32,18 @@
|
|
|
32
32
|
"LICENSE"
|
|
33
33
|
],
|
|
34
34
|
"engines": {
|
|
35
|
-
"bun": ">=1.4.
|
|
35
|
+
"bun": ">=1.4.2"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
39
39
|
"test": "bun test"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@ultimat3/cache": "
|
|
43
|
-
"@ultimat3/core": "
|
|
44
|
-
"@ultimat3/http": "
|
|
45
|
-
"@ultimat3/i18n": "
|
|
46
|
-
"@ultimat3/seo": "
|
|
47
|
-
"sass": "1.
|
|
42
|
+
"@ultimat3/cache": "25.0.0",
|
|
43
|
+
"@ultimat3/core": "25.0.0",
|
|
44
|
+
"@ultimat3/http": "25.0.0",
|
|
45
|
+
"@ultimat3/i18n": "25.0.0",
|
|
46
|
+
"@ultimat3/seo": "25.0.0",
|
|
47
|
+
"sass": "1.105.1"
|
|
48
48
|
}
|
|
49
49
|
}
|
package/src/asset.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* `island({ src })` does. The table is the CLI's (`site-assets.ts`); this file holds only the seam.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { renderCauseValue } from '@ultimat3/core';
|
|
9
10
|
import { AssetMissingError } from './errors';
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -48,6 +49,8 @@ const EXTENSIONS: ReadonlySet<string> = new Set(ASSET_EXTENSIONS);
|
|
|
48
49
|
* refused here, before any disk is asked.
|
|
49
50
|
*/
|
|
50
51
|
export function assetPathProblem(path: string): string | undefined {
|
|
52
|
+
// Typed, but a JS caller or a Sass `asset()` can still hand over anything: a refusal, not a crash.
|
|
53
|
+
if (typeof path !== 'string') return `${renderCauseValue(path)} is not a path`;
|
|
51
54
|
if (path.includes('\\')) return `"${path}" contains a backslash`;
|
|
52
55
|
const segments = path.split('/');
|
|
53
56
|
if (segments[0] !== ASSET_DIR || segments.length < 2) {
|
|
@@ -94,14 +97,14 @@ export function asset(path: AssetPath): string {
|
|
|
94
97
|
const problem = assetPathProblem(path);
|
|
95
98
|
if (problem !== undefined) {
|
|
96
99
|
throw new AssetMissingError(
|
|
97
|
-
`asset(${
|
|
100
|
+
`asset(${renderCauseValue(path)}): ${problem}`,
|
|
98
101
|
`name a file under apps/web/site/${ASSET_DIR}/ with one of: ${ASSET_EXTENSIONS.join(', ')}`,
|
|
99
102
|
);
|
|
100
103
|
}
|
|
101
104
|
const resolver = holder[RESOLVER_SLOT];
|
|
102
105
|
if (resolver === undefined) {
|
|
103
106
|
throw new AssetMissingError(
|
|
104
|
-
`asset(${
|
|
107
|
+
`asset(${renderCauseValue(path)}) ran with no site asset table installed — in a browser (an island), or in a test that never loaded the app`,
|
|
105
108
|
'call asset() in the page and pass the URL to the island as a prop; a unit test installs a table with setAssetResolver((path) => "/" + path)',
|
|
106
109
|
);
|
|
107
110
|
}
|
|
@@ -9,9 +9,6 @@ import type { ActionPathStyle } from '@ultimat3/core';
|
|
|
9
9
|
import { CLIENT_PATH_STYLE_META } from '@ultimat3/core';
|
|
10
10
|
import type { HeadTag } from './head';
|
|
11
11
|
|
|
12
|
-
/** The `name` core's reader matches — core's constant, so the writer and the reader are one literal. */
|
|
13
|
-
export { CLIENT_PATH_STYLE_META };
|
|
14
|
-
|
|
15
12
|
/** `style` is `@ultimat3/action`'s `actionPathStyle()`: what `defineApi` declared, read per render. */
|
|
16
13
|
export function clientPathStyleTags(style: ActionPathStyle): readonly HeadTag[] {
|
|
17
14
|
if (style === 'resource') return [];
|
package/src/client-scope-tag.ts
CHANGED
|
@@ -9,9 +9,6 @@
|
|
|
9
9
|
import { CLIENT_PERSIST_META, CLIENT_SCOPE_META } from '@ultimat3/core';
|
|
10
10
|
import type { HeadTag } from './head';
|
|
11
11
|
|
|
12
|
-
/** The `name` core's reader matches — core's constant, so the writer and the reader are one literal. */
|
|
13
|
-
export { CLIENT_PERSIST_META, CLIENT_SCOPE_META };
|
|
14
|
-
|
|
15
12
|
/** `scope` is opaque (`@ultimat3/auth`'s `clientScopeOf`), and `''` is the anonymous page. */
|
|
16
13
|
export function clientScopeTag(scope: string): HeadTag {
|
|
17
14
|
return {
|
package/src/client-sync-tags.ts
CHANGED
|
@@ -7,9 +7,6 @@
|
|
|
7
7
|
import { CLIENT_BUILD_META, CLIENT_SYNC_META, CLIENT_SYNC_WORKER_META } from '@ultimat3/core';
|
|
8
8
|
import type { HeadTag } from './head';
|
|
9
9
|
|
|
10
|
-
/** Core's names (`page-meta.ts`), so the writer here and every reader share one literal each. */
|
|
11
|
-
export { CLIENT_BUILD_META, CLIENT_SYNC_META, CLIENT_SYNC_WORKER_META };
|
|
12
|
-
|
|
13
10
|
export interface ClientSyncHead {
|
|
14
11
|
readonly syncUrl: string;
|
|
15
12
|
readonly buildId: string;
|
package/src/code-unit-order.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* the runtime's ICU **default locale** and **collation version**: `'/A'` sorts after `'/a'` on one
|
|
11
11
|
* machine and before it on the next, and `'/zoo'` before `'/ärzte'` under `sv-SE` but after it
|
|
12
12
|
* under `en-US`, for the same input. Everything this package orders is either diffed across
|
|
13
|
-
* deploys (`
|
|
13
|
+
* deploys (`describePages()` feeds `x.manifest.json`, the sitemap and `sw.js`'s rule table) or is
|
|
14
14
|
* a choice a build makes (`pageComponentOf`'s fallback), so a machine-dependent order is a no-op
|
|
15
15
|
* deploy that reads as a change — or a different page rendered per host. Same rule
|
|
16
16
|
* `@ultimat3/pwa`'s `precache.ts` and `service-worker.ts` state for the artifact they emit.
|
package/src/css-modules.ts
CHANGED
|
@@ -78,10 +78,21 @@ const partialCandidates = (target: string): readonly string[] => {
|
|
|
78
78
|
];
|
|
79
79
|
};
|
|
80
80
|
|
|
81
|
-
/** Strings
|
|
82
|
-
const PROTECTED = /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|url\([^)]*\)
|
|
83
|
-
/**
|
|
84
|
-
const
|
|
81
|
+
/** Strings, `url()` payloads and comments may contain a `.` that is not a class selector. */
|
|
82
|
+
const PROTECTED = /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|url\([^)]*\)|\/\*[\s\S]*?\*\//g;
|
|
83
|
+
/** One CSS escape: up to six hex digits and the whitespace that ends them, or any other char. */
|
|
84
|
+
const ESCAPE = String.raw`\\[0-9a-fA-F]{1,6}[ \t\n]?|\\[^\n\r\f0-9a-fA-F]`;
|
|
85
|
+
const NAME_START = String.raw`(?:[A-Za-z_\u0080-\uffff]|${ESCAPE})`;
|
|
86
|
+
const NAME_CHAR = String.raw`(?:[\w\-\u0080-\uffff]|${ESCAPE})`;
|
|
87
|
+
/**
|
|
88
|
+
* A class selector: a dot, then a whole CSS identifier — escapes included, so `.w-1\.5` is ONE
|
|
89
|
+
* class, where `[\w-]*` split it into a scoped `w-1` and a stray `.5`. `0.5rem` cannot match: `5`
|
|
90
|
+
* starts no identifier.
|
|
91
|
+
*/
|
|
92
|
+
const CLASS_SELECTOR = new RegExp(
|
|
93
|
+
String.raw`\.(-?${NAME_START}${NAME_CHAR}*|--${NAME_CHAR}*)`,
|
|
94
|
+
'g',
|
|
95
|
+
);
|
|
85
96
|
/**
|
|
86
97
|
* The mask delimiter. NUL is the one byte CSS cannot contain, so the restore pass cannot mistake
|
|
87
98
|
* a real declaration for a placeholder — a bare numeric marker would collide with `flex:1 1 0`.
|
|
@@ -140,6 +151,11 @@ export function scopeClasses(
|
|
|
140
151
|
suffix: string,
|
|
141
152
|
): { readonly css: string; readonly classes: Record<string, string> } {
|
|
142
153
|
const classes: Record<string, string> = {};
|
|
154
|
+
const scopeSelector = (selector: string): string =>
|
|
155
|
+
selector.replace(CLASS_SELECTOR, (_match, name: string) => {
|
|
156
|
+
classes[unescapeName(name)] = `${unescapeName(name)}_${suffix}`;
|
|
157
|
+
return `.${name}_${suffix}`;
|
|
158
|
+
});
|
|
143
159
|
const literals: string[] = [];
|
|
144
160
|
const mask = (match: string): string => {
|
|
145
161
|
literals.push(match);
|
|
@@ -148,11 +164,7 @@ export function scopeClasses(
|
|
|
148
164
|
// Strings and `url()` first, so a `:global(` inside a `content:` string is never unwrapped;
|
|
149
165
|
// then each `:global()` payload joins the same mask and comes back unscoped.
|
|
150
166
|
const masked = unwrapGlobal(css.replace(PROTECTED, mask), mask);
|
|
151
|
-
const scoped = masked
|
|
152
|
-
const local = `${name}_${suffix}`;
|
|
153
|
-
classes[name] = local;
|
|
154
|
-
return `.${local}`;
|
|
155
|
-
});
|
|
167
|
+
const scoped = inPreludes(masked, scopeSelector);
|
|
156
168
|
// Recursive: a `:global()` payload is masked AFTER the strings inside it were, so its literal
|
|
157
169
|
// holds their placeholders — one pass restored `html[data-theme='light']` as
|
|
158
170
|
// `html[data-theme=\0 0 \0]`, a selector that matched nothing. A literal only ever holds
|
|
@@ -167,6 +179,39 @@ export function scopeClasses(
|
|
|
167
179
|
return { css: restore(scoped), classes };
|
|
168
180
|
}
|
|
169
181
|
|
|
182
|
+
/**
|
|
183
|
+
* `rewrite` applied to every qualified rule's prelude — the text before a `{` that is not an
|
|
184
|
+
* at-rule's — and nothing else. A declaration ends at `;` or `}` and never reaches it: a font's
|
|
185
|
+
* `local(Inter.Regular)` is not a class. The strings, comments and `url()`s are masked by then, so
|
|
186
|
+
* no brace in this text is quoted.
|
|
187
|
+
*/
|
|
188
|
+
function inPreludes(css: string, rewrite: (selector: string) => string): string {
|
|
189
|
+
let out = '';
|
|
190
|
+
let start = 0;
|
|
191
|
+
for (let at = 0; at < css.length; at += 1) {
|
|
192
|
+
const char = css[at];
|
|
193
|
+
if (char !== '{' && char !== '}' && char !== ';') continue;
|
|
194
|
+
const segment = css.slice(start, at);
|
|
195
|
+
out += char === '{' && !segment.trimStart().startsWith('@') ? rewrite(segment) : segment;
|
|
196
|
+
out += char;
|
|
197
|
+
start = at + 1;
|
|
198
|
+
}
|
|
199
|
+
return out + css.slice(start);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A class name as `class=""` spells it: every CSS escape resolved, a bad code point as U+FFFD. */
|
|
203
|
+
function unescapeName(name: string): string {
|
|
204
|
+
return name.replace(
|
|
205
|
+
/\\([0-9a-fA-F]{1,6})[ \t\n]?|\\(.)/g,
|
|
206
|
+
(_match, hex?: string, char?: string) => {
|
|
207
|
+
if (hex === undefined) return char ?? '';
|
|
208
|
+
const point = Number.parseInt(hex, 16);
|
|
209
|
+
const valid = point > 0 && point <= 0x10ffff && (point < 0xd800 || point > 0xdfff);
|
|
210
|
+
return String.fromCodePoint(valid ? point : 0xfffd);
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
170
215
|
/** The fix line for a stylesheet that names tokens `@ultimat3/ui/tokens` does not export. */
|
|
171
216
|
const TOKEN_FIX =
|
|
172
217
|
"@ultimat3/ui/tokens exports functions and mixins — space(4), radius(md), role('surface-raised'), " +
|
|
@@ -312,7 +357,10 @@ export function compileStylesheet(file: string, source: string): CompiledStylesh
|
|
|
312
357
|
}
|
|
313
358
|
if (!isCssModule(file)) return { css, classes: {}, dependencies };
|
|
314
359
|
// Content-addressed, not path-addressed: a checkout at a different absolute path must produce
|
|
315
|
-
// byte-identical CSS, which a hash over the absolute filename would not.
|
|
316
|
-
|
|
360
|
+
// byte-identical CSS, which a hash over the absolute filename would not. The COMPILED CSS, not
|
|
361
|
+
// the source: two `page.module.scss` with one source and different `@use`d partials hashed
|
|
362
|
+
// alike, emitted one class name with two bodies, and the later sheet won on both pages. Split on
|
|
363
|
+
// both separators: on Windows Bun hands the path over as `D:\…`, and `/` alone kept all of it.
|
|
364
|
+
const scoped = scopeClasses(css, contentHash(`${file.split(/[\\/]/).pop() ?? file} ${css}`));
|
|
317
365
|
return { css: scoped.css, classes: scoped.classes, dependencies };
|
|
318
366
|
}
|
package/src/duration.ts
CHANGED
|
@@ -18,11 +18,19 @@ const DURATION_UNITS: ReadonlyMap<string, number> = new Map([
|
|
|
18
18
|
/** `'5m'` → 300000. Numbers pass through as milliseconds. */
|
|
19
19
|
export function parseTtlMs(ttl: string | number | null | undefined): number | null {
|
|
20
20
|
if (ttl === null || ttl === undefined) return null;
|
|
21
|
-
if (typeof ttl === 'number') return
|
|
21
|
+
if (typeof ttl === 'number') return positiveMs(ttl);
|
|
22
|
+
// Typed away, reachable from JS: `modes.ts` asks this of an unvalidated `revalidate.ttl`.
|
|
23
|
+
if (typeof ttl !== 'string') return null;
|
|
22
24
|
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(ttl.trim());
|
|
23
25
|
const amount = match?.[1];
|
|
24
26
|
const unit = match?.[2];
|
|
25
27
|
if (amount === undefined || unit === undefined) return null;
|
|
26
28
|
const factor = DURATION_UNITS.get(unit);
|
|
27
|
-
return factor === undefined ? null : Number(amount) * factor;
|
|
29
|
+
return factor === undefined ? null : positiveMs(Number(amount) * factor);
|
|
28
30
|
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Both arms end here, so `'0s'` and `0` cannot disagree: a zero-length TTL registered, and
|
|
34
|
+
* `entryTtlMs` then read the stored `0` as tag-only — a page declared to expire never did.
|
|
35
|
+
*/
|
|
36
|
+
const positiveMs = (ms: number): number | null => (Number.isFinite(ms) && ms > 0 ? ms : null);
|
package/src/errors.ts
CHANGED
|
@@ -43,7 +43,7 @@ export const RENDER_ERROR_TITLES: Readonly<Record<RenderErrorCode, string>> = {
|
|
|
43
43
|
X_ROUTE_LOAD_FAILED: "a route's load threw while resolving its data",
|
|
44
44
|
X_ROUTE_STATUS_INVALID: 'a route answered a status a rendered page cannot carry',
|
|
45
45
|
X_SURFACE_BOUNDARY: 'a surface imported across the hard boundary',
|
|
46
|
-
X_BUDGET_EXCEEDED: 'a route blew its JS
|
|
46
|
+
X_BUDGET_EXCEEDED: 'a route blew its JS budget',
|
|
47
47
|
X_PRERENDER_FAILED: 'a prerendered path threw during build',
|
|
48
48
|
X_ISLAND_INVALID: 'an island declaration cannot become a client entry',
|
|
49
49
|
X_ISLAND_PROPS_INVALID: 'an island was passed props it cannot carry to the browser',
|
|
@@ -109,7 +109,7 @@ export class RouteMetaMissingError extends UltimateError {
|
|
|
109
109
|
/**
|
|
110
110
|
* The route table holds descriptors, never declarations. `defineRoute` is the one normalizer, so
|
|
111
111
|
* a raw declaration reaching the registry means `budget` and `meta` are whatever the author wrote
|
|
112
|
-
* — and every reader downstream (`
|
|
112
|
+
* — and every reader downstream (`describePages`, `sw.js`, the sitemap) assumes they are not.
|
|
113
113
|
*/
|
|
114
114
|
export class RouteUnnormalizedError extends UltimateError {
|
|
115
115
|
static readonly code = 'X_ROUTE_UNNORMALIZED' as const;
|
|
@@ -163,7 +163,7 @@ export class SurfaceBoundaryError extends UltimateError {
|
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
/** A route's measured JS
|
|
166
|
+
/** A route's measured JS exceeded its declared `budget`. */
|
|
167
167
|
export class BudgetExceededError extends UltimateError {
|
|
168
168
|
static readonly code = 'X_BUDGET_EXCEEDED' as const;
|
|
169
169
|
constructor(cause: string, fix: string) {
|
package/src/head.ts
CHANGED
|
@@ -7,17 +7,12 @@
|
|
|
7
7
|
* catalog and so the tag vocabulary keeps exactly one owner (`@ultimat3/seo`).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { finiteCount } from '@ultimat3/core';
|
|
10
|
+
import { escapeHtml, finiteCount, THEME_STORAGE_KEY } from '@ultimat3/core';
|
|
11
11
|
import type { RouteMeta } from '@ultimat3/seo';
|
|
12
12
|
import { BudgetExceededError } from './errors';
|
|
13
|
-
// `html.ts`
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
escapeJsonContent,
|
|
17
|
-
escapeRawTextContent,
|
|
18
|
-
escapeText,
|
|
19
|
-
isAttributeName,
|
|
20
|
-
} from './html';
|
|
13
|
+
// The raw-text escapers are `html.ts`'s and the text/attribute table is core's `escapeHtml` — a
|
|
14
|
+
// second one is how a character ends up missing.
|
|
15
|
+
import { escapeJsonContent, escapeRawTextContent, isAttributeName } from './html';
|
|
21
16
|
|
|
22
17
|
export type HeadTagKind = 'title' | 'base' | 'meta' | 'link' | 'script' | 'style';
|
|
23
18
|
|
|
@@ -125,7 +120,7 @@ function renderTag(tag: HeadTag): string {
|
|
|
125
120
|
const attrs = Object.entries(tag.attrs ?? {})
|
|
126
121
|
.filter(([name]) => isAttributeName(name))
|
|
127
122
|
.map(([name, value]) =>
|
|
128
|
-
value === true ? ` ${name}` : ` ${name}="${
|
|
123
|
+
value === true ? ` ${name}` : ` ${name}="${escapeHtml(String(value))}"`,
|
|
129
124
|
)
|
|
130
125
|
.join('');
|
|
131
126
|
if (VOID_KINDS.has(tag.kind)) return `<${tag.kind}${attrs}>`;
|
|
@@ -141,7 +136,7 @@ function renderTag(tag: HeadTag): string {
|
|
|
141
136
|
* reaching `meta.ld` — a title, a product name, a bio — able to close the element.
|
|
142
137
|
*/
|
|
143
138
|
function contentOf(tag: HeadTag, raw: string): string {
|
|
144
|
-
if (tag.kind !== 'script' && tag.kind !== 'style') return
|
|
139
|
+
if (tag.kind !== 'script' && tag.kind !== 'style') return escapeHtml(raw);
|
|
145
140
|
return carriesJson(tag) ? escapeJsonContent(raw) : escapeRawTextContent(raw);
|
|
146
141
|
}
|
|
147
142
|
|
|
@@ -178,14 +173,6 @@ export interface ThemeScriptOptions {
|
|
|
178
173
|
|
|
179
174
|
export const THEME_SCRIPT_MAX_BYTES = 512;
|
|
180
175
|
|
|
181
|
-
/**
|
|
182
|
-
* The storage key `@ultimat3/ui`'s `ThemeToggle` reads (`THEME_STORAGE_KEY` there). One literal on
|
|
183
|
-
* each side, pinned equal by a test in `@ultimat3/cli`: render sits below ui in the tier table and
|
|
184
|
-
* cannot import it, and the two keys disagreeing was exactly the bug — the boot stamped one key,
|
|
185
|
-
* the toggle wrote another, and a visitor's choice never survived a reload.
|
|
186
|
-
*/
|
|
187
|
-
export const THEME_STORAGE_KEY = 'ultimate.theme';
|
|
188
|
-
|
|
189
176
|
/**
|
|
190
177
|
* The script's text alone, so the CSP hash and the tag come from one string: a policy hashed from
|
|
191
178
|
* a restatement admits a script that is not the one the document carries.
|
|
@@ -204,10 +191,15 @@ export function themeScriptBody(options: ThemeScriptOptions = {}): string {
|
|
|
204
191
|
// string in a `<script>` body, where one `"` ends the string and the rest is code the page runs.
|
|
205
192
|
// Author-supplied today — the same status every `emitIslandAttributes` value had before it was
|
|
206
193
|
// routed through `html.ts`. The element's own raw-text rule is applied by `renderTag` below.
|
|
194
|
+
// The fallback itself goes on the document too, OUTSIDE the `try`: a storage read that throws
|
|
195
|
+
// still leaves the app's own opinion where `@ultimat3/ui`'s `clearTheme` / `watchOsTheme` read
|
|
196
|
+
// it, so a fixed `theme.defaultMode` is never overridden by an OS flip or a "system" pick.
|
|
207
197
|
return (
|
|
198
|
+
`var d=document.documentElement;` +
|
|
199
|
+
`d.setAttribute(${JSON.stringify(`${attribute}-default`)},${JSON.stringify(fallback)});` +
|
|
208
200
|
`try{var s=localStorage.getItem(${JSON.stringify(storageKey)}),` +
|
|
209
201
|
`t=s==="light"||s==="dark"?s:${otherwise};` +
|
|
210
|
-
`
|
|
202
|
+
`d.setAttribute(${JSON.stringify(attribute)},t)}catch(e){}`
|
|
211
203
|
);
|
|
212
204
|
}
|
|
213
205
|
|