@ultimat3/render 23.0.0 → 24.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 +11 -5
- package/README.md +19 -5
- package/package.json +6 -6
- package/src/asset.ts +5 -2
- package/src/css-modules.ts +58 -11
- package/src/duration.ts +10 -2
- package/src/errors.ts +2 -2
- package/src/head.ts +9 -10
- package/src/html.ts +13 -19
- package/src/hydrate.ts +3 -3
- package/src/index.ts +2 -2
- package/src/island-collector.ts +2 -1
- package/src/island.ts +1 -1
- package/src/modes.ts +14 -3
- package/src/navigation-dom.ts +28 -6
- package/src/navigation-rules.ts +22 -6
- 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 +25 -53
- package/src/registry.ts +30 -39
- package/src/render-html.ts +4 -4
- package/src/render-isr.ts +127 -65
- package/src/render-static.ts +20 -6
- package/src/render-stream.ts +6 -5
- package/src/route-pattern.ts +67 -0
- package/src/route.ts +10 -18
- package/src/server.ts +1 -0
- package/src/static-path.ts +15 -2
- package/src/type-pins.tsx +4 -3
package/CLAUDE.md
CHANGED
|
@@ -33,7 +33,7 @@ axiom 6). Never `cli` (upward).
|
|
|
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`
|
|
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
37
|
| No `describe()` on a route | `describeRoutes()` 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. |
|
|
@@ -71,8 +71,12 @@ axiom 6). Never `cli` (upward).
|
|
|
71
71
|
| "Is this a TTL?" has one reader | `parseTtlMs` in `duration.ts`, below `modes.ts` and `render-isr.ts`. |
|
|
72
72
|
| 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
73
|
| `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
|
|
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`, 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
75
|
| 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. |
|
|
76
|
+
| ISR regeneration | `@ultimat3/core`'s `createSingleFlight`, `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. |
|
|
77
|
+
| 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: `describeRoutes()` sorts `*` before `:`. |
|
|
78
|
+
| `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. |
|
|
79
|
+
| `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
80
|
| Marking a page stale | `IsrStore.markStale(path)`, in place — never `set({ ...entry, stale: true })` (`set` means "just generated" and orders eviction). |
|
|
77
81
|
| ISR store bound | `memoryIsrStore()` caps at `DEFAULT_ISR_MAX_ENTRIES` (1,000), least recently generated first. |
|
|
78
82
|
| 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 +97,10 @@ axiom 6). Never `cli` (upward).
|
|
|
93
97
|
| Client sync tags | `client-sync-tags.ts` — `ultimate-sync`, `ultimate-build`, `ultimate-sync-worker`. Principal-free, on every document. |
|
|
94
98
|
| 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
99
|
| `<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: `
|
|
100
|
+
| 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, re-exported by `html.ts`; `bun run flight-copies` refuses a second table (`X_HELPER_COPY`). |
|
|
101
|
+
| Script and style CONTENT | never raw: `escapeHtml`, `escapeRawTextContent` (`</` → `<\/`, `<!--` → `<\!--`), or `escapeJsonContent` for a `type` ending in `json`. Never HTML-escape a script body. |
|
|
98
102
|
| Which export is the page | `route-component.ts`: `Page` → a single `…Page` → a single capitalised function. |
|
|
103
|
+
| 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
104
|
| 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
105
|
| CSS order | `stylesFor` sorts **globals before modules** (`isGlobalStylesheet`). `shared/` is carried by both graphs. |
|
|
101
106
|
| 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 +109,10 @@ axiom 6). Never `cli` (upward).
|
|
|
104
109
|
| An island's `mount` | may return `() => void`, its disposer; the runtime never calls it, `@ultimat3/testing`'s `mountIsland` does. |
|
|
105
110
|
| `<head>` binding | `head.ts` stays injection-only; `head-seo.ts` is the ONE binding of `HeadRenderers` to `@ultimat3/seo`. |
|
|
106
111
|
| 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. |
|
|
112
|
+
| 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
113
|
| 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
114
|
| 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. |
|
|
115
|
+
| 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 (when sent AND when it settles), `onClientWrite`, `onRescope` and a `BroadcastChannel` message from another tab. |
|
|
110
116
|
|
|
111
117
|
Cross-package: `@ultimat3/pwa` consumes route descriptors as **data**, never by import.
|
|
112
118
|
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` |
|
|
@@ -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
|
|
|
@@ -502,7 +502,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
502
502
|
| `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
503
|
| `ISLAND_MOUNTED_ATTRIBUTE`, `ISLAND_FAILED_ATTRIBUTE`, `IDLE_HYDRATE_TIMEOUT_MS` | what hydration looks like from outside the page |
|
|
504
504
|
| `parseByteBudget`, `defaultIslandBudget` | the `'40kb'` budget grammar, and the ceiling a declared island earns |
|
|
505
|
-
| `mergeHead`, `renderHead`, `themeScript` | `<head>` merge + the one inlined script |
|
|
505
|
+
| `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
506
|
| `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
507
|
|
|
508
508
|
## Notes
|
|
@@ -516,7 +516,21 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
516
516
|
framework's `Revalidator`, and the function it returns releases **both** halves — the
|
|
517
517
|
dependents and the revalidator slot, the latter only while it is still this controller's.
|
|
518
518
|
The default store (`memoryIsrStore`) is capped at `DEFAULT_ISR_MAX_ENTRIES` (1,000) pages,
|
|
519
|
-
least recently generated evicted first.
|
|
519
|
+
least recently generated evicted first. One regeneration holds its page for at most
|
|
520
|
+
`DEFAULT_ISR_REGENERATE_DEADLINE_MS` (30 s; `createIsrController({ regenerateDeadlineMs })`),
|
|
521
|
+
so a render that never settles no longer pins the page for the life of the process. A stored
|
|
522
|
+
path takes its TTL and tags from the most specific matching route (static > `:param` >
|
|
523
|
+
`*catch-all`, per segment), never the first in table order.
|
|
524
|
+
- **A tag-revalidated ISR document carries its purge keys while it is shared-cacheable**,
|
|
525
|
+
`As of 2026-10-02` — `@ultimat3/http`'s `cache-headers` stage rewrites the response to `private`
|
|
526
|
+
for a signed-in visitor and strips both headers: `Surrogate-Key`
|
|
527
|
+
(space-joined) and `Cache-Tag` (comma-joined) from `@ultimat3/cache`'s `surrogateKeys()` over
|
|
528
|
+
the route's `revalidate.tags` — `revalidate: { tags: [tag.post] }` answers `post e:post` — on a
|
|
529
|
+
miss, a hit and a served-stale response alike. That is what lets the same `invalidates` purge
|
|
530
|
+
the document at the CDN; before it, the edge held every ISR page for its whole `s-maxage`. A
|
|
531
|
+
TTL-only route carries neither header. A tag a CDN would split (whitespace, a comma) is refused
|
|
532
|
+
when the route registers — `X_ROUTE_MODE_INVALID`, naming the file — for file routes and
|
|
533
|
+
mounted routes both.
|
|
520
534
|
- **`stream`** flushes the shell first, then reveals holes in completion order with a
|
|
521
535
|
~200-byte inline script. A client that disconnects mid-stream cancels it: `StreamHole.resolve`
|
|
522
536
|
is handed an `AbortSignal` so the work stops, and nothing more is enqueued. Solid's compiled templates and signals mean the shell costs zero
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/render",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "24.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",
|
|
@@ -39,11 +39,11 @@
|
|
|
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": "
|
|
42
|
+
"@ultimat3/cache": "24.0.0",
|
|
43
|
+
"@ultimat3/core": "24.0.0",
|
|
44
|
+
"@ultimat3/http": "24.0.0",
|
|
45
|
+
"@ultimat3/i18n": "24.0.0",
|
|
46
|
+
"@ultimat3/seo": "24.0.0",
|
|
47
47
|
"sass": "1.104.0"
|
|
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
|
}
|
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,9 @@ 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.
|
|
363
|
+
const scoped = scopeClasses(css, contentHash(`${file.split('/').pop() ?? file} ${css}`));
|
|
317
364
|
return { css: scoped.css, classes: scoped.classes, dependencies };
|
|
318
365
|
}
|
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',
|
|
@@ -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
|
@@ -11,13 +11,7 @@ import { finiteCount } from '@ultimat3/core';
|
|
|
11
11
|
import type { RouteMeta } from '@ultimat3/seo';
|
|
12
12
|
import { BudgetExceededError } from './errors';
|
|
13
13
|
// `html.ts` is this package's one escaper — a second one is how a character ends up missing.
|
|
14
|
-
import {
|
|
15
|
-
escapeAttribute,
|
|
16
|
-
escapeJsonContent,
|
|
17
|
-
escapeRawTextContent,
|
|
18
|
-
escapeText,
|
|
19
|
-
isAttributeName,
|
|
20
|
-
} from './html';
|
|
14
|
+
import { escapeHtml, escapeJsonContent, escapeRawTextContent, isAttributeName } from './html';
|
|
21
15
|
|
|
22
16
|
export type HeadTagKind = 'title' | 'base' | 'meta' | 'link' | 'script' | 'style';
|
|
23
17
|
|
|
@@ -125,7 +119,7 @@ function renderTag(tag: HeadTag): string {
|
|
|
125
119
|
const attrs = Object.entries(tag.attrs ?? {})
|
|
126
120
|
.filter(([name]) => isAttributeName(name))
|
|
127
121
|
.map(([name, value]) =>
|
|
128
|
-
value === true ? ` ${name}` : ` ${name}="${
|
|
122
|
+
value === true ? ` ${name}` : ` ${name}="${escapeHtml(String(value))}"`,
|
|
129
123
|
)
|
|
130
124
|
.join('');
|
|
131
125
|
if (VOID_KINDS.has(tag.kind)) return `<${tag.kind}${attrs}>`;
|
|
@@ -141,7 +135,7 @@ function renderTag(tag: HeadTag): string {
|
|
|
141
135
|
* reaching `meta.ld` — a title, a product name, a bio — able to close the element.
|
|
142
136
|
*/
|
|
143
137
|
function contentOf(tag: HeadTag, raw: string): string {
|
|
144
|
-
if (tag.kind !== 'script' && tag.kind !== 'style') return
|
|
138
|
+
if (tag.kind !== 'script' && tag.kind !== 'style') return escapeHtml(raw);
|
|
145
139
|
return carriesJson(tag) ? escapeJsonContent(raw) : escapeRawTextContent(raw);
|
|
146
140
|
}
|
|
147
141
|
|
|
@@ -204,10 +198,15 @@ export function themeScriptBody(options: ThemeScriptOptions = {}): string {
|
|
|
204
198
|
// string in a `<script>` body, where one `"` ends the string and the rest is code the page runs.
|
|
205
199
|
// Author-supplied today — the same status every `emitIslandAttributes` value had before it was
|
|
206
200
|
// routed through `html.ts`. The element's own raw-text rule is applied by `renderTag` below.
|
|
201
|
+
// The fallback itself goes on the document too, OUTSIDE the `try`: a storage read that throws
|
|
202
|
+
// still leaves the app's own opinion where `@ultimat3/ui`'s `clearTheme` / `watchOsTheme` read
|
|
203
|
+
// it, so a fixed `theme.defaultMode` is never overridden by an OS flip or a "system" pick.
|
|
207
204
|
return (
|
|
205
|
+
`var d=document.documentElement;` +
|
|
206
|
+
`d.setAttribute(${JSON.stringify(`${attribute}-default`)},${JSON.stringify(fallback)});` +
|
|
208
207
|
`try{var s=localStorage.getItem(${JSON.stringify(storageKey)}),` +
|
|
209
208
|
`t=s==="light"||s==="dark"?s:${otherwise};` +
|
|
210
|
-
`
|
|
209
|
+
`d.setAttribute(${JSON.stringify(attribute)},t)}catch(e){}`
|
|
211
210
|
);
|
|
212
211
|
}
|
|
213
212
|
|
package/src/html.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HTML text and attribute serialization for the server renderer.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* HTML text and attribute serialization for the server renderer. The character table is
|
|
3
|
+
* `@ultimat3/core`'s `escapeHtml`, reached from here and nowhere else in this package: a second
|
|
4
|
+
* escaper is how one of them ends up missing a character, and a missing character in an attribute
|
|
5
|
+
* is an injection.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
|
-
import { safeUrl, URL_ATTRIBUTES } from '@ultimat3/core';
|
|
8
|
-
import { escapeAttribute } from '@ultimat3/seo';
|
|
8
|
+
import { escapeHtml, safeUrl, URL_ATTRIBUTES } from '@ultimat3/core';
|
|
9
9
|
import type { JsxProps } from './jsx';
|
|
10
10
|
|
|
11
11
|
/** Elements that never carry children, so the writer must not emit a closing tag. */
|
|
@@ -26,21 +26,15 @@ export const VOID_ELEMENTS: ReadonlySet<string> = new Set([
|
|
|
26
26
|
'wbr',
|
|
27
27
|
]);
|
|
28
28
|
|
|
29
|
-
export function escapeText(value: string): string {
|
|
30
|
-
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
31
|
-
}
|
|
32
|
-
|
|
33
29
|
/**
|
|
34
|
-
* Re-exported, never re-implemented:
|
|
35
|
-
*
|
|
36
|
-
* from `./html` so this module remains the single import site every render-* file already uses —
|
|
37
|
-
* one implementation, one place to look.
|
|
30
|
+
* Re-exported, never re-implemented: one set of five characters for text AND attributes, so a
|
|
31
|
+
* value is inert wherever it lands. Every render-* file escapes through this import.
|
|
38
32
|
*/
|
|
39
|
-
export {
|
|
33
|
+
export { escapeHtml };
|
|
40
34
|
|
|
41
35
|
/**
|
|
42
36
|
* `<script>` and `<style>` hold RAW TEXT: a character reference is not decoded inside them, so
|
|
43
|
-
* `
|
|
37
|
+
* `escapeHtml` there would ship `<` to a JS or CSS parser and corrupt the code without closing
|
|
44
38
|
* the hole. What actually ends the element is `</` followed by its tag name, and — inside a script
|
|
45
39
|
* only — `<!--` switches the tokenizer into the escaped state where the element's own `</script>`
|
|
46
40
|
* no longer closes it and the rest of the document becomes script text.
|
|
@@ -113,7 +107,7 @@ const URL_BEARING_ATTRIBUTES: ReadonlySet<string> = new Set([
|
|
|
113
107
|
|
|
114
108
|
/**
|
|
115
109
|
* Attributes that carry MARKUP, not text or a URL, and are therefore never emitted. `srcdoc` is
|
|
116
|
-
* entity-DECODED and then parsed as HTML, so
|
|
110
|
+
* entity-DECODED and then parsed as HTML, so an escaped `<script>` becomes a live
|
|
117
111
|
* `<script>` inside the iframe — on this origin, with this session's cookie. Escaping cannot make
|
|
118
112
|
* it inert, so the attribute is refused instead, the same way `innerHTML` above stays the one
|
|
119
113
|
* explicit escape hatch rather than a prop anyone can spread in from a row.
|
|
@@ -167,7 +161,7 @@ export function attributePair(name: string, value: unknown): string | null {
|
|
|
167
161
|
if (value === true) return attribute;
|
|
168
162
|
if (attribute === 'style') {
|
|
169
163
|
const style = styleValue(value);
|
|
170
|
-
return style === null ? null : `style="${
|
|
164
|
+
return style === null ? null : `style="${escapeHtml(style)}"`;
|
|
171
165
|
}
|
|
172
166
|
const text = String(value);
|
|
173
167
|
// Escaping makes a value inert inside the quotes; it cannot make a SCHEME inert, because
|
|
@@ -178,9 +172,9 @@ export function attributePair(name: string, value: unknown): string | null {
|
|
|
178
172
|
if (URL_BEARING_ATTRIBUTES.has(attribute.toLowerCase())) {
|
|
179
173
|
const url = safeUrl(text, attribute.toLowerCase());
|
|
180
174
|
if (url === null) return null;
|
|
181
|
-
return `${attribute}="${
|
|
175
|
+
return `${attribute}="${escapeHtml(url)}"`;
|
|
182
176
|
}
|
|
183
|
-
return `${attribute}="${
|
|
177
|
+
return `${attribute}="${escapeHtml(text)}"`;
|
|
184
178
|
}
|
|
185
179
|
|
|
186
180
|
export function renderAttributes(props: JsxProps): string {
|
package/src/hydrate.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { HydrateStrategy } from '@ultimat3/core';
|
|
9
9
|
import { HYDRATE_STRATEGIES } from '@ultimat3/core';
|
|
10
|
-
import {
|
|
10
|
+
import { escapeHtml, escapeJsonContent } from './html';
|
|
11
11
|
|
|
12
12
|
export interface IslandDirective {
|
|
13
13
|
/** Unique per INSTANCE: two of the same island on a page need two prop bags to find. */
|
|
@@ -61,7 +61,7 @@ export function emitIslandAttributes(directive: IslandDirective): string {
|
|
|
61
61
|
// and the rest of the string is markup. Author-controlled today — which is why it costs nothing
|
|
62
62
|
// to route through the ONE escaper now, rather than after a build id or a prop-derived margin
|
|
63
63
|
// starts carrying something the author did not type.
|
|
64
|
-
const attr = (name: string, value: string): string => `${name}="${
|
|
64
|
+
const attr = (name: string, value: string): string => `${name}="${escapeHtml(value)}"`;
|
|
65
65
|
const attrs = [
|
|
66
66
|
attr('data-x-island', directive.islandId),
|
|
67
67
|
attr('data-x-hydrate', directive.strategy),
|
|
@@ -89,7 +89,7 @@ export function emitIslandProps(directive: IslandDirective): string {
|
|
|
89
89
|
// The id goes through the SAME escaper every other attribute in this file does. Safe today —
|
|
90
90
|
// `islandModuleId` reduces to `[a-z0-9-]` — which is exactly why it costs nothing to route it
|
|
91
91
|
// now, rather than after an id starts being derived from something an author did not type.
|
|
92
|
-
`<script type="application/json" data-x-props="${
|
|
92
|
+
`<script type="application/json" data-x-props="${escapeHtml(directive.islandId)}">` +
|
|
93
93
|
`${escapeJsonContent(JSON.stringify(directive.props))}</script>`
|
|
94
94
|
);
|
|
95
95
|
}
|
package/src/index.ts
CHANGED
|
@@ -155,7 +155,6 @@ export {
|
|
|
155
155
|
export type { ClientNavigationHead } from './navigation-tags';
|
|
156
156
|
export { clientNavigationTags } from './navigation-tags';
|
|
157
157
|
export type {
|
|
158
|
-
CompiledPattern,
|
|
159
158
|
MountedRouteInput,
|
|
160
159
|
RegisterRouteInput,
|
|
161
160
|
RouteDescriptor,
|
|
@@ -165,7 +164,6 @@ export type {
|
|
|
165
164
|
} from './registry';
|
|
166
165
|
export {
|
|
167
166
|
clearRoutes,
|
|
168
|
-
compilePattern,
|
|
169
167
|
describeRoutes,
|
|
170
168
|
ROUTE_FILENAME,
|
|
171
169
|
registerMountedRoutes,
|
|
@@ -205,6 +203,8 @@ export {
|
|
|
205
203
|
export type { RouteComponent } from './route-component';
|
|
206
204
|
export { pageComponentOf } from './route-component';
|
|
207
205
|
export { metaContextFor, routeDataFor } from './route-data';
|
|
206
|
+
export type { CompiledPattern } from './route-pattern';
|
|
207
|
+
export { compilePattern } from './route-pattern';
|
|
208
208
|
export { routeStatusOf, withStatus } from './route-status';
|
|
209
209
|
export type { SpeculationRules } from './speculation-rules';
|
|
210
210
|
export {
|
package/src/island-collector.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { HydrateStrategy } from '@ultimat3/core';
|
|
8
|
+
import { renderCauseValue } from '@ultimat3/core';
|
|
8
9
|
import { IslandInvalidError, IslandNotHydratedError } from './errors';
|
|
9
10
|
import type { IslandDirective } from './hydrate';
|
|
10
11
|
import { DEFAULT_REPLAY_EVENTS } from './hydrate';
|
|
@@ -131,7 +132,7 @@ function assertHydrates(strategy: HydrateStrategy, spec: IslandSpec, file: strin
|
|
|
131
132
|
function assertEntry(entry: string, spec: IslandSpec, file: string): void {
|
|
132
133
|
if (isEmittableSpecifier(entry)) return;
|
|
133
134
|
throw new IslandInvalidError(
|
|
134
|
-
`the resolver returned ${
|
|
135
|
+
`the resolver returned ${renderCauseValue(entry)} for the ${spec.moduleId} island in ${file}, ` +
|
|
135
136
|
'which cannot be emitted as a module URL',
|
|
136
137
|
'fix the resolve() passed to createIslandCollector — it must return a plain URL path',
|
|
137
138
|
);
|
package/src/island.ts
CHANGED
|
@@ -25,7 +25,7 @@ const UNSAFE_SPECIFIER = /["'`<>\s\\]/;
|
|
|
25
25
|
|
|
26
26
|
/** The same test the resolver's output has to pass: one rule, applied at both ends of the seam. */
|
|
27
27
|
export function isEmittableSpecifier(value: string): boolean {
|
|
28
|
-
return value.length > 0 && !UNSAFE_SPECIFIER.test(value);
|
|
28
|
+
return typeof value === 'string' && value.length > 0 && !UNSAFE_SPECIFIER.test(value);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export interface IslandDeclaration<TKeys extends readonly string[] = readonly string[]> {
|
package/src/modes.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { HydrateStrategy, RenderMode } from '@ultimat3/core';
|
|
9
|
-
import { HYDRATE_STRATEGIES, RENDER_MODES } from '@ultimat3/core';
|
|
9
|
+
import { HYDRATE_STRATEGIES, RENDER_MODES, renderCauseValue } from '@ultimat3/core';
|
|
10
10
|
import { parseTtlMs } from './duration';
|
|
11
11
|
import { RouteModeInvalidError } from './errors';
|
|
12
12
|
import type { RouteConfig } from './route';
|
|
@@ -84,14 +84,14 @@ export function assertModeShape(config: RouteShape): void {
|
|
|
84
84
|
: undefined;
|
|
85
85
|
if (spec === undefined) {
|
|
86
86
|
throw new RouteModeInvalidError(
|
|
87
|
-
`render: ${
|
|
87
|
+
`render: ${renderCauseValue(config.render)} is not a render mode`,
|
|
88
88
|
`use one of ${RENDER_MODES.join(' | ')}`,
|
|
89
89
|
);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
if (!HYDRATE_STRATEGIES.includes(config.hydrate)) {
|
|
93
93
|
throw new RouteModeInvalidError(
|
|
94
|
-
`hydrate: ${
|
|
94
|
+
`hydrate: ${renderCauseValue(config.hydrate)} is not a hydration strategy`,
|
|
95
95
|
`use one of ${HYDRATE_STRATEGIES.join(' | ')}`,
|
|
96
96
|
);
|
|
97
97
|
}
|
|
@@ -200,6 +200,17 @@ export function assertModeInvariants(config: RouteShape, ctx: ModeCheckContext):
|
|
|
200
200
|
);
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
// `js` is the one budget anything measures. `lcp`, `css`, `cls` and `tbt` were each accepted,
|
|
204
|
+
// stored and compared by nothing that had a measurement — a false green — so a key the type no
|
|
205
|
+
// longer declares, arriving by a cast or from plain JS, is refused rather than carried.
|
|
206
|
+
for (const key of Object.keys(config.budget)) {
|
|
207
|
+
if (key === 'js') continue;
|
|
208
|
+
throw new RouteModeInvalidError(
|
|
209
|
+
`${ctx.file} declares budget.${key}, which nothing in the framework measures — only budget.js is weighed (x verify's budgets step). In ${ctx.file}, delete ${key}: the whole budget is the js key`,
|
|
210
|
+
"budget: { js: '10kb' }",
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
203
214
|
// `budget` is always on the descriptor; `budget.js` is the field that stays optional,
|
|
204
215
|
// and its absence is the failure — site/ is 0kb until a route says otherwise, in bytes.
|
|
205
216
|
if (ctx.surface === 'site' && config.hydrate !== 'never' && config.budget.js === undefined) {
|
package/src/navigation-dom.ts
CHANGED
|
@@ -39,6 +39,28 @@ export function anchorOf(target: EventTarget | null): HTMLAnchorElement | HTMLAr
|
|
|
39
39
|
export const fieldText = (value: unknown): string =>
|
|
40
40
|
value instanceof File ? value.name : String(value);
|
|
41
41
|
|
|
42
|
+
/** Every lone CR, lone LF and CRLF in `text`, as CRLF. */
|
|
43
|
+
const crlf = (text: string): string => text.replace(/\r\n|\r|\n/g, '\r\n');
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The fields as a GET query or a urlencoded body carries them — names and values with their line
|
|
47
|
+
* breaks normalised to CRLF, as the browser's own submit does (HTML's "convert to a list of
|
|
48
|
+
* name-value pairs"). A `<textarea>`'s value holds bare LFs; sent as they were, the router's
|
|
49
|
+
* submit and a native one delivered two different bodies for one form. A multipart body needs
|
|
50
|
+
* none of this: `fetch` encodes a `FormData` with the same normalisation.
|
|
51
|
+
*/
|
|
52
|
+
export const formPairs = (fields: FormData): readonly (readonly [string, string])[] =>
|
|
53
|
+
[...fields].map(([name, value]: [string, unknown]) => [crlf(name), crlf(fieldText(value))]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A form's action, resolved against the document — or the raw text when it does not resolve, which
|
|
57
|
+
* `formVerdict` leaves to the browser (`'unparsable'`) rather than throwing from a submit handler.
|
|
58
|
+
*/
|
|
59
|
+
export function formAction(raw: string, fallback: string, base: string): string {
|
|
60
|
+
const action = raw || fallback;
|
|
61
|
+
return URL.canParse(action, base) ? new URL(action, base).href : action;
|
|
62
|
+
}
|
|
63
|
+
|
|
42
64
|
/** The submitted fields, the submitter's own name/value included, as the browser would send. */
|
|
43
65
|
export function formFields(form: HTMLFormElement, submitter: HTMLElement | null): FormData {
|
|
44
66
|
try {
|
|
@@ -59,7 +81,7 @@ const hush = (): void => undefined;
|
|
|
59
81
|
export async function transition(
|
|
60
82
|
win: Window,
|
|
61
83
|
apply: () => void,
|
|
62
|
-
track?: (running: RunningTransition
|
|
84
|
+
track?: (running: RunningTransition, finished: boolean) => void,
|
|
63
85
|
): Promise<void> {
|
|
64
86
|
const doc = win.document as Document & {
|
|
65
87
|
startViewTransition?: (update: () => void) => RunningTransition;
|
|
@@ -70,12 +92,12 @@ export async function transition(
|
|
|
70
92
|
return;
|
|
71
93
|
}
|
|
72
94
|
const running = doc.startViewTransition(apply);
|
|
73
|
-
track?.(running);
|
|
95
|
+
track?.(running, false);
|
|
74
96
|
running.ready.catch(hush);
|
|
75
|
-
running
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
);
|
|
97
|
+
// The transition itself, never "whichever is running": an older one finishing after a newer one
|
|
98
|
+
// started must not clear the newer one (the caller compares by identity).
|
|
99
|
+
const done = (): void => track?.(running, true);
|
|
100
|
+
running.finished.then(done, done);
|
|
79
101
|
await running.updateCallbackDone;
|
|
80
102
|
}
|
|
81
103
|
|