@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/src/render-stream.ts
CHANGED
|
@@ -11,9 +11,8 @@
|
|
|
11
11
|
* contains no interactive island costs literally zero JS.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { finiteCount, logger, renderThrowable } from '@ultimat3/core';
|
|
14
|
+
import { BUILD_ID_HEADER, escapeHtml, finiteCount, logger, renderThrowable } from '@ultimat3/core';
|
|
15
15
|
import { finiteStatus } from './finite-status';
|
|
16
|
-
import { escapeAttribute } from './html';
|
|
17
16
|
import type { RenderResult } from './route';
|
|
18
17
|
import { REVEAL_BODY, REVEAL_CALL } from './stream-scripts';
|
|
19
18
|
|
|
@@ -54,7 +53,7 @@ export function holeId(id: string): string {
|
|
|
54
53
|
* from a param. `fallback` is already-rendered HTML and stays verbatim.
|
|
55
54
|
*/
|
|
56
55
|
export function holeMarker(id: string, fallback: string): string {
|
|
57
|
-
return `<x-hole id="${
|
|
56
|
+
return `<x-hole id="${escapeHtml(holeId(id))}">${fallback}</x-hole>`;
|
|
58
57
|
}
|
|
59
58
|
|
|
60
59
|
/**
|
|
@@ -64,10 +63,10 @@ export function holeMarker(id: string, fallback: string): string {
|
|
|
64
63
|
export const REVEAL_SCRIPT = `<script>${REVEAL_BODY}</script>`;
|
|
65
64
|
|
|
66
65
|
export function revealChunk(id: string, html: string): string {
|
|
67
|
-
// The id reaches markup ONLY through `
|
|
66
|
+
// The id reaches markup ONLY through `escapeHtml` — never a script — so a quote in it
|
|
68
67
|
// cannot close anything, and no per-hole body exists for a CSP to fail to list.
|
|
69
68
|
return (
|
|
70
|
-
`<template data-x-hole="${
|
|
69
|
+
`<template data-x-hole="${escapeHtml(holeId(id))}">${html}</template>` +
|
|
71
70
|
`<script>${REVEAL_CALL}</script>`
|
|
72
71
|
);
|
|
73
72
|
}
|
|
@@ -117,7 +116,8 @@ export function renderStreamHtml(
|
|
|
117
116
|
const encoder = new TextEncoder();
|
|
118
117
|
const tail = plan.tail ?? '</body></html>';
|
|
119
118
|
const errorFallback =
|
|
120
|
-
|
|
119
|
+
// Escaped as `holeMarker` escapes the same id: it lands inside an attribute either way.
|
|
120
|
+
options.errorFallback ?? ((id) => `<div data-x-hole-error="${escapeHtml(id)}" hidden></div>`);
|
|
121
121
|
// A deadline is the bound a non-finite value does not disable but MOVES: `setTimeout(fn, NaN)`
|
|
122
122
|
// is `setTimeout(fn, 0)`, so every hole would miss a deadline nobody set and the document would
|
|
123
123
|
// be all fallbacks. `null` is the declared opt-out; there is no spelling that means "immediately".
|
|
@@ -224,7 +224,7 @@ export function streamResult(plan: StreamPlan, options: StreamOptions, status =
|
|
|
224
224
|
// Proxies that buffer defeat the entire mode.
|
|
225
225
|
'x-accel-buffering': 'no',
|
|
226
226
|
'transfer-encoding': 'chunked',
|
|
227
|
-
|
|
227
|
+
[BUILD_ID_HEADER]: options.buildId,
|
|
228
228
|
},
|
|
229
229
|
body: renderStreamHtml(plan, options),
|
|
230
230
|
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A route pattern (`/docs/:id`, `/docs/*path`) as the regex a pathname is tested with, and the
|
|
3
|
+
* order two matching patterns are ranked in. Split from `registry.ts`, which registers routes; this
|
|
4
|
+
* file only reads a pattern string.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { routeRank } from '@ultimat3/core';
|
|
8
|
+
|
|
9
|
+
export interface CompiledPattern {
|
|
10
|
+
readonly source: string;
|
|
11
|
+
readonly regex: RegExp;
|
|
12
|
+
readonly keys: readonly string[];
|
|
13
|
+
/**
|
|
14
|
+
* Higher wins when two patterns match the same pathname — `@ultimat3/core`'s `routeRank`, the
|
|
15
|
+
* ONE ordering rule: ISR, the sitemap, the admin and `@ultimat3/pwa`'s worker all sort on it.
|
|
16
|
+
*/
|
|
17
|
+
readonly specificity: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A regex metacharacter, matched as itself. */
|
|
21
|
+
const escapeRegex = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A literal segment as a pathname may spell it: each character raw OR percent-encoded, either hex
|
|
25
|
+
* case. A route file is named in Unicode (`precios-españa`) and `URL.pathname` encodes it
|
|
26
|
+
* (`precios-espa%C3%B1a`), so a literal-only pattern never matched the key it was asked about.
|
|
27
|
+
*/
|
|
28
|
+
function literalPattern(segment: string): string {
|
|
29
|
+
let out = '';
|
|
30
|
+
for (const char of segment) {
|
|
31
|
+
const encoded = [...new TextEncoder().encode(char)]
|
|
32
|
+
.map((byte) => {
|
|
33
|
+
const hex = byte.toString(16).toUpperCase().padStart(2, '0');
|
|
34
|
+
const digit = (d: string): string => (/[A-F]/.test(d) ? `[${d}${d.toLowerCase()}]` : d);
|
|
35
|
+
return `%${digit(hex[0] ?? '0')}${digit(hex[1] ?? '0')}`;
|
|
36
|
+
})
|
|
37
|
+
.join('');
|
|
38
|
+
out += `(?:${escapeRegex(char)}|${encoded})`;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function compilePattern(path: string): CompiledPattern {
|
|
44
|
+
const keys: string[] = [];
|
|
45
|
+
const segments = path.split('/').filter((s) => s.length > 0);
|
|
46
|
+
|
|
47
|
+
// Each part carries its own leading `/`, so a catch-all can own its separator: an empty rest is
|
|
48
|
+
// the bare prefix (`/docs`), which is the path `static-path.ts` writes for it.
|
|
49
|
+
const parts = segments.map((segment) => {
|
|
50
|
+
if (segment.startsWith('*')) {
|
|
51
|
+
keys.push(segment.slice(1));
|
|
52
|
+
return '(?:/(.*))?';
|
|
53
|
+
}
|
|
54
|
+
if (segment.startsWith(':')) {
|
|
55
|
+
keys.push(segment.slice(1));
|
|
56
|
+
return '/([^/]+)';
|
|
57
|
+
}
|
|
58
|
+
return `/${literalPattern(segment)}`;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
source: path,
|
|
63
|
+
regex: new RegExp(`^${parts.join('')}/?$`),
|
|
64
|
+
keys,
|
|
65
|
+
specificity: routeRank(path),
|
|
66
|
+
};
|
|
67
|
+
}
|
package/src/route.ts
CHANGED
|
@@ -51,17 +51,15 @@ export interface RevalidateConfig {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
|
-
* What a route promises to stay under. **
|
|
55
|
-
*
|
|
56
|
-
*
|
|
54
|
+
* What a route promises to stay under. **One key, and it is MEASURED** — a key here that nothing
|
|
55
|
+
* weighs is a number an author writes and nothing can ever compare against. `type-pins.tsx` makes
|
|
56
|
+
* an unprojected key a build error, and `assertModeInvariants` refuses one that arrives anyway.
|
|
57
57
|
*
|
|
58
|
-
* `css`, `cls` and `tbt`
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* half does not exist either, and a knob that fails silently in the permissive direction is the
|
|
64
|
-
* framework's most repeated defect.
|
|
58
|
+
* `css`, `cls` and `tbt` went in 12.0.0 and `lcp` after them: each was accepted, stored and
|
|
59
|
+
* ignored while the gate that exists to enforce budgets reported green, because nothing in the
|
|
60
|
+
* framework observes a paint — the build emits static HTML. Deleted rather than wired for
|
|
61
|
+
* `PwaConfig.installPrompt`'s reason: the measuring half does not exist, and a knob that fails
|
|
62
|
+
* silently in the permissive direction is the framework's most repeated defect.
|
|
65
63
|
*/
|
|
66
64
|
export interface RouteBudget {
|
|
67
65
|
/**
|
|
@@ -72,12 +70,6 @@ export interface RouteBudget {
|
|
|
72
70
|
* `As of 2026-09-05`; `packages/cli/src/budgets.ts`'s `measureDocumentJs` is the one reader.
|
|
73
71
|
*/
|
|
74
72
|
readonly js?: string;
|
|
75
|
-
/**
|
|
76
|
-
* Milliseconds. PUBLISHED, not enforced: it reaches `x.manifest.json` and `x routes`, and no
|
|
77
|
-
* step of the gate compares it — nothing in the framework produces an `lcp` measurement, which
|
|
78
|
-
* `packages/cli/src/templates/route.ts` states at the one place an author would write one.
|
|
79
|
-
*/
|
|
80
|
-
readonly lcp?: number;
|
|
81
73
|
}
|
|
82
74
|
|
|
83
75
|
/**
|
|
@@ -273,8 +265,10 @@ export interface RenderResult {
|
|
|
273
265
|
/**
|
|
274
266
|
* Route descriptors carry tags in `@ultimat3/cache`'s wire form (`post`, `post:123`), the
|
|
275
267
|
* same strings every cache tier speaks. Render never invents a key convention of its own.
|
|
268
|
+
* Declaration order is kept — unlike `@ultimat3/cache`'s `tagKeys`, the sorted identity form —
|
|
269
|
+
* so the name says which list this is, and it stays out of the public barrel.
|
|
276
270
|
*/
|
|
277
|
-
export function
|
|
271
|
+
export function routeTagKeys(tags: readonly CacheTag[] | undefined): readonly string[] {
|
|
278
272
|
return tags === undefined ? [] : serializeTags(tags);
|
|
279
273
|
}
|
|
280
274
|
|
|
@@ -299,7 +293,7 @@ export function defineRoute<TData = RouteData>(
|
|
|
299
293
|
}
|
|
300
294
|
if (!OFFLINE_STRATEGIES.includes(def.offline)) {
|
|
301
295
|
throw new RouteOfflineMissingError(
|
|
302
|
-
`offline: ${
|
|
296
|
+
`offline: ${renderCauseValue(def.offline)} is not a known strategy`,
|
|
303
297
|
`use one of ${OFFLINE_STRATEGIES.join(' | ')}`,
|
|
304
298
|
);
|
|
305
299
|
}
|
|
@@ -312,7 +306,7 @@ export function defineRoute<TData = RouteData>(
|
|
|
312
306
|
|
|
313
307
|
if (def.load !== undefined && typeof def.load !== 'function') {
|
|
314
308
|
throw new RouteLoadInvalidError(
|
|
315
|
-
`load: ${
|
|
309
|
+
`load: ${renderCauseValue(def.load)} is not a function`,
|
|
316
310
|
'make load a function of ({ params, url }) returning the page data, or remove it',
|
|
317
311
|
);
|
|
318
312
|
}
|
package/src/server.ts
CHANGED
|
@@ -47,9 +47,10 @@ export type {
|
|
|
47
47
|
IsrServeResult,
|
|
48
48
|
} from './render-isr';
|
|
49
49
|
export {
|
|
50
|
-
|
|
50
|
+
DEFAULT_ISR_REGENERATE_DEADLINE_MS,
|
|
51
51
|
ISR_LOCALE_PARAM,
|
|
52
52
|
invalidateAndRevalidate,
|
|
53
|
+
isrController,
|
|
53
54
|
isrKey,
|
|
54
55
|
} from './render-isr';
|
|
55
56
|
export type { IsrEntry, IsrState, IsrStore, MemoryIsrStoreOptions } from './render-isr-store';
|
package/src/static-path.ts
CHANGED
|
@@ -35,6 +35,19 @@ const checked = (pattern: string, param: string, segment: string): string => {
|
|
|
35
35
|
return segment;
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* A param as text, `undefined` when absent. Typed `string` but filled from app data at runtime:
|
|
40
|
+
* `{ id: 7 }` reached `.split()` and `for…of` as a number and left the build as a bare `TypeError`.
|
|
41
|
+
*/
|
|
42
|
+
const paramText = (pattern: string, name: string, params: RouteParams): string | undefined => {
|
|
43
|
+
if (typeof params !== 'object' || params === null) {
|
|
44
|
+
return refuse(pattern, name, params, 'is missing — the params are not an object');
|
|
45
|
+
}
|
|
46
|
+
const value: unknown = Object.hasOwn(params, name) ? params[name] : undefined;
|
|
47
|
+
if (value === undefined || typeof value === 'string') return value;
|
|
48
|
+
return refuse(pattern, name, value, 'is not text — a path segment is a string');
|
|
49
|
+
};
|
|
50
|
+
|
|
38
51
|
/**
|
|
39
52
|
* The raw (decoded) segments, validated. `:name` must be present and one segment; `*name` may span
|
|
40
53
|
* several (`a/b`) and may be absent, but none of its parts may be a dot segment or unsafe.
|
|
@@ -45,7 +58,7 @@ export function filledSegments(pattern: string, params: RouteParams): readonly s
|
|
|
45
58
|
if (segment === '') continue;
|
|
46
59
|
if (segment.startsWith(':')) {
|
|
47
60
|
const name = segment.slice(1);
|
|
48
|
-
const value =
|
|
61
|
+
const value = paramText(pattern, name, params);
|
|
49
62
|
if (value === undefined || value === '') {
|
|
50
63
|
return refuse(
|
|
51
64
|
pattern,
|
|
@@ -59,7 +72,7 @@ export function filledSegments(pattern: string, params: RouteParams): readonly s
|
|
|
59
72
|
}
|
|
60
73
|
if (segment.startsWith('*')) {
|
|
61
74
|
const name = segment.slice(1);
|
|
62
|
-
const value =
|
|
75
|
+
const value = paramText(pattern, name, params) ?? '';
|
|
63
76
|
for (const part of value.split('/')) if (part !== '') out.push(checked(pattern, name, part));
|
|
64
77
|
continue;
|
|
65
78
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// One responsibility: the ONE spelling of a filesystem path the stylesheet registry compares — `/`
|
|
2
|
+
// separators, `..` folded, a Windows drive letter upper-cased — so "is this sheet below that
|
|
3
|
+
// directory" means the same on Windows, where Bun hands every path over as `D:\app\…`, as on POSIX.
|
|
4
|
+
|
|
5
|
+
// why: Bun ships no path API, and a relative root must be resolved against the working directory.
|
|
6
|
+
import { posix, resolve } from 'node:path';
|
|
7
|
+
|
|
8
|
+
/** POSIX-absolute (`/srv/app`), drive-absolute (`D:\app`, `d:/app`) or UNC (`\\host\share`). */
|
|
9
|
+
const ABSOLUTE = /^(?:[A-Za-z]:)?[\\/]/;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Comparable, never displayed: the registry keeps every sheet under the path it was handed and only
|
|
13
|
+
* COMPARES through this. An absolute path is not re-resolved, because `resolve('/app')` on Windows
|
|
14
|
+
* is `D:\app` — a drive the sheets a caller handed as `/app/…` never carry.
|
|
15
|
+
*/
|
|
16
|
+
export function comparablePath(path: string): string {
|
|
17
|
+
const absolute = ABSOLUTE.test(path) ? path : resolve(path);
|
|
18
|
+
const slashed = posix.normalize(absolute.replaceAll('\\', '/'));
|
|
19
|
+
const drive = slashed.replace(/^([a-z]):/, (_, letter: string) => `${letter.toUpperCase()}:`);
|
|
20
|
+
// A trailing separator is dropped so `${dir}/` is one prefix — except the filesystem root itself.
|
|
21
|
+
const trimmed = drive.replace(/\/+$/, '');
|
|
22
|
+
return trimmed === '' || trimmed.endsWith(':') ? `${trimmed}/` : trimmed;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `path` sits below `dir` — both already `comparablePath`. A sibling sharing a prefix does not. */
|
|
26
|
+
export function isPathBelow(path: string, dir: string): boolean {
|
|
27
|
+
return path.startsWith(dir.endsWith('/') ? dir : `${dir}/`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `path` relative to `dir` when it sits below it, else `path` — both already `comparablePath`. */
|
|
31
|
+
export function pathBelow(path: string, dir: string): string {
|
|
32
|
+
if (!isPathBelow(path, dir)) return path;
|
|
33
|
+
return path.slice(dir.endsWith('/') ? dir.length : dir.length + 1);
|
|
34
|
+
}
|
package/src/type-pins.tsx
CHANGED
|
@@ -110,9 +110,10 @@ export function _IslandIsAJsxComponent(): unknown {
|
|
|
110
110
|
/**
|
|
111
111
|
* Every key a route may declare a BUDGET for is a key the descriptor projects.
|
|
112
112
|
*
|
|
113
|
-
* Both sides are derived, so neither can move alone: `budgetJs
|
|
114
|
-
*
|
|
115
|
-
* `budget.css`, `budget.cls` and
|
|
113
|
+
* Both sides are derived, so neither can move alone: `budgetJs` on `RouteDescriptor` gives `'js'`,
|
|
114
|
+
* and anything `RouteBudget` declares beyond that fails this line by name. `budget.lcp` left the
|
|
115
|
+
* contract with its descriptor field, measured by nothing. `budget.css`, `budget.cls` and
|
|
116
|
+
* `budget.tbt` were declared on the contract for four majors, were
|
|
116
117
|
* flattened away by `registerRoute`, and reached no reader anywhere — so an author writing
|
|
117
118
|
* `budget: { cls: 0.1 }` was ignored in silence while `x verify`'s `budgets` step, the one thing
|
|
118
119
|
* that exists to enforce a budget, reported green. `scripts/declaration-readers.ts` is the rule
|