@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.
@@ -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="${escapeAttribute(holeId(id))}">${fallback}</x-hole>`;
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 `escapeAttribute` — never a script — so a quote in it
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="${escapeAttribute(holeId(id))}">${html}</template>` +
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
- options.errorFallback ?? ((id) => `<div data-x-hole-error="${id}" hidden></div>`);
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
- 'x-ultimate-build': options.buildId,
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. **Two keys, and every one of them is PROJECTED** — a key
55
- * here that `RouteDescriptor` does not carry is a number an author writes and nothing can ever
56
- * compare against, which `type-pins.tsx` now makes a build error.
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` were three such keys and are gone in 12.0.0. `registerRoute` flattens a
59
- * budget to `budgetJs` + `budgetLcp`, and every reader downstream — `x verify`'s `budgets` step,
60
- * `x.manifest.json`, `x routes`, the dev dashboard — reads only those two, so `budget: { cls: 0.1 }`
61
- * was accepted, normalised, stored and ignored while the gate that exists to enforce budgets
62
- * reported green. Deleted rather than wired for `PwaConfig.installPrompt`'s reason: the measuring
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 tagKeys(tags: readonly CacheTag[] | undefined): readonly string[] {
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: ${JSON.stringify(def.offline)} is not a known strategy`,
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: ${JSON.stringify(def.load)} is not a function`,
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
- createIsrController,
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';
@@ -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 = Object.hasOwn(params, name) ? params[name] : undefined;
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 = Object.hasOwn(params, name) ? (params[name] ?? '') : '';
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`/`budgetLcp` on `RouteDescriptor`
114
- * give `'js' | 'lcp'`, and anything `RouteBudget` declares beyond that fails this line by name.
115
- * `budget.css`, `budget.cls` and `budget.tbt` were declared on the contract for four majors, were
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