New:Socket for Asana Is Now Available.Learn more
Get Started

@ultimat3/render

Package Overview
Dependencies
Maintainers
1
Versions
26
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ultimat3/render - npm Package Compare versions

Comparing version
7.0.0
to
8.0.0
+10
-5
CLAUDE.md

@@ -12,4 +12,7 @@ # @ultimat3/render — boundary

Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cache`, `seo`,
`entity`, `policy`, `http`, `action`, `query`. **Never** `pwa`, `mcp`, `ai`, `manifest`
(sideways), never `ui`/`cli` (upward).
`entity`, `policy`, `http`, `action`, `query`. **Never** `pwa`, `mcp`, `ai`, `manifest`, `ui`
— all tier 4, so **sideways**, and an undeclared sideways edge is a build error. `ui` moved 5 → 4
in 2026-08 and is held level with this package deliberately (`FLOOR_ABOVE` in
`scripts/lib/tiers.ts`), so `render → ui` stays refused; this package sits above its own floor of 2
for the mirror-image reason, keeping `render → pwa` refused. Never `cli` (upward).

@@ -39,4 +42,6 @@ | Rule | Detail |

| An attribute alias is a `Map` | never a record — an object lookup walks the prototype chain, so `<div {...row} />` with a column named `toString` resolved the alias to a FUNCTION and `attribute.toLowerCase()` threw a bare `TypeError`: no code, no fix, the whole page 500s off a `load()` result. Same reason `MODE_SPECS[config.render]` in `modes.ts` is guarded by `Object.hasOwn`, where `render: 'constructor'` returned a frozen descriptor for a mode nothing implements. |
| An attribute NAME is validated too | `ATTRIBUTE_NAME` in `html.ts` — `/^[A-Za-z_:][-A-Za-z0-9_:.]*$/`, refused as `null`. A name is emitted VERBATIM before the `=` and is escaped nowhere, so `{ 'q onmouseover=alert(1) r': 'ok' }` shipped a live event handler out of an object KEY. The handler check on the line under it folds case for the same reason: it was `name.startsWith('on')` while the two checks below it lowercased, so `ONERROR="alert(1)"` went out on the wire. Both are reachable by `<div {...row} />` over a JSON body or a JSONB column. `head.ts`'s `renderTag` is the package's OTHER attribute sink and shares the predicate (`isAttributeName`) — it emitted `<meta name="q" r onmouseover=alert(1) s="ok">` from an `attrs` key. One predicate, never two. |
| Which attributes take a URL | `URL_BEARING_ATTRIBUTES` in `html.ts` — core's four (`href`, `src`, `action`, `formaction`) plus `data`, `poster`, `ping` and `xlink:href`, each of which a browser FOLLOWS. `srcdoc` is refused outright: its value is entity-decoded and THEN parsed as HTML, so `escapeAttribute`'s `&lt;script&gt;` becomes a live `<script>` on this origin — escaping cannot make markup inert, so the attribute is never emitted, the same way `innerHTML` stays the one explicit escape hatch. |
| Island collection | per render, passed as `renderToHtml(tree, { islands })`. Never module-global and never on an ambient context — two concurrent requests would bill one page for the other's JS, and `assertNoPerRequestState` refuses a live context under `static` anyway. |
| A byte count in a message | `formatBytes` from `@ultimat3/core`, never a local one. This package's copy stopped at `kb`, so a 5 MB route read `5120kb` in `X_BUDGET_EXCEEDED` while `@ultimat3/pwa`'s own copy said `5mb` for the same bytes — two halves of one build disagreeing about the size of one artifact. Still on this barrel, because `@ultimat3/cli`'s budget reporter reads it beside the route table. |
| Island bytes | `routeJsBytes` unions `entry.islands` with the rendered directives' `moduleId`s. Reading either alone is a budget that counts the runtime and not the chunk. |

@@ -47,3 +52,3 @@ | Island markup | the props `<script>` is emitted INSIDE the wrapper by `render-html.ts`, so a document assembler has exactly one thing left to remember: `hydrateRuntime(directives)`. |

| `idle`'s deadline | `IDLE_HYDRATE_TIMEOUT_MS`, interpolated INTO the runtime string. Exported because a second reader has to agree — `x shot` waits before it photographs, and a settle shorter than this deadline reports an unhydrated page for one that hydrates perfectly. A constant the emitted string restates instead of reading is worse than no constant. |
| Route truth | `registry.ts`. Never keep a second route list anywhere. |
| Route truth | `registry.ts`. Never keep a second route list anywhere, and never a second *matcher*: this package's `matchRoute` was deleted in 2026-08 with zero consumers, because `@ultimat3/http`'s trie (`stages.ts`) is the one that serves requests and two matchers with different precedence rules is two answers to "which route is this?". `routeFor` is an exact-path `Map` lookup, not a pattern matcher. |
| Route filename | `page.tsx` under `site/`/`app/`, `route.ts` under `api/` — `ROUTE_FILENAME`, one per surface. The URL is the directory path. Anything else is `X_ROUTE_FILE_INVALID`; never widen the table to accept a second spelling. |

@@ -71,3 +76,3 @@ | Registry input | descriptors only. `registerRoute` refuses a raw declaration with `X_ROUTE_UNNORMALIZED` — `defineRoute` is the one normalizer of everything the declaration alone decides, and every reader downstream assumes it ran. The registry fills in exactly one value on top: the island budget, which needs the surface, which is a fact of the file path only the route table reads. |

| `<head>` baseline | `documentBaseline()` in `head.ts` — charset, viewport, `color-scheme` — merged FIRST so a route can still override any of them. Absent until `As of 2026-08`, and the missing `viewport` is why every deployed app rendered zoomed-out on a phone whatever its CSS said. |
| Escaping | `html.ts` only, and that now includes `render-stream.ts` (`holeMarker`'s attribute, and `revealChunk`'s `$X(...)` argument via `JSON.stringify` — an id containing `")` closed the call and ran the rest) and `head.ts`'s `themeScript` (`storageKey`/`attribute` as JS string LITERALS). All author-controlled today — the identical status `emitIslandAttributes` had before the last sweep. `render-spa.ts` was the third entry here and went with the mode. A second escaper is how one of them ends up missing a character, and a missing character in an attribute is an injection. `head.ts` and `hydrate.ts` each had a private copy; both now import — `escapeAttribute` for every attribute value (`emitIslandAttributes` interpolated all five of its own raw until 2026-08, while this row already claimed otherwise) and `escapeJsonContent` for a JSON script body. |
| Escaping | `html.ts` only, and that now includes `render-stream.ts` (`holeMarker`'s attribute, and `revealChunk`'s `$X(...)` argument via `JSON.stringify` — an id containing `")` closed the call and ran the rest) and `head.ts`'s `themeScript` (`storageKey`/`attribute` as JS string LITERALS). All author-controlled today — the identical status `emitIslandAttributes` had before the last sweep. `render-spa.ts` was the third entry here and went with the mode. A second escaper is how one of them ends up missing a character, and a missing character in an attribute is an injection. `escapeAttribute` itself is `@ultimat3/seo`'s (tier 1), re-exported by `html.ts` rather than reimplemented — the copy that lived here was the second escaper this row forbids, and `pwa/CLAUDE.md` already named seo's as the one. `head.ts` and `hydrate.ts` each had a private copy; both now import — `escapeAttribute` for every attribute value (`emitIslandAttributes` interpolated all five of its own raw until 2026-08, while this row already claimed otherwise) and `escapeJsonContent` for a JSON script body. |
| Script and style CONTENT | never emitted raw. Three rules, one choice: HTML text (`escapeText`), raw text for code (`escapeRawTextContent`: `</` → `<\/`, `<!--` → `<\!--`), and the total JSON rule for a `type` ending in `json` (`escapeJsonContent`: `<`, `>`, `&`, U+2028/9 → `\uXXXX`, still valid JSON). `meta.ld` is built from route data, and it was emitted VERBATIM until `As of 2026-08` — a title could close the element. Never HTML-escape a script body: a character reference is not decoded there, so `&lt;` corrupts the code AND leaves the hole. |

@@ -77,3 +82,3 @@ | Which export is the page | `route-component.ts`, one precedence: `Page` → a single `…Page` → a single capitalised function. Never a per-generator name table. |

| CSS order | `stylesFor` sorts **globals before modules** (`isGlobalStylesheet`), never plain insertion order — the reset styles bare elements at the lowest specificity there is, so whichever page loaded first must not decide who wins a tie. `shared/` is carried by both graphs, like a package sheet: it is where an app's own global layer lives, and filtering it out is what made every deployed app render token-less. |
| The global layer | this package may not import `@ultimat3/ui` (tier 5, upward), so the app's source graph carries it: one `shared/global.scss` that `@use`s `@ultimat3/ui/global.scss`, side-effect-imported by `shared/global.ts`. One file, because each stylesheet is its own Sass compilation — a token file `@use`d per module duplicates its `:root` block per module. `x verify` fails with `X_STYLES_GLOBAL_MISSING` when a surface's document defines none. |
| The global layer | this package may not import `@ultimat3/ui` (tier 4, the same tier — sideways, not upward: `ui` moved 5 → 4 in 2026-08 and `render → ui` stays forbidden because a same-tier edge has to be declared in `scripts/lib/tiers.ts`, and this one deliberately is not — the static bundle graph may not reach the design system, axiom 6), so the app's source graph carries it: one `shared/global.scss` that `@use`s `@ultimat3/ui/global.scss`, side-effect-imported by `shared/global.ts`. One file, because each stylesheet is its own Sass compilation — a token file `@use`d per module duplicates its `:root` block per module. `x verify` fails with `X_STYLES_GLOBAL_MISSING` when a surface's document defines none. |
| Colours | tokens and `data-theme` only. No hex in `head.ts` or any emitted script. |

@@ -80,0 +85,0 @@ | `<head>` binding | `head.ts` stays injection-only (testable with no catalog); `head-seo.ts` is the ONE binding of `HeadRenderers` to `@ultimat3/seo`. A caller writing its own converter is the drift this file prevents. |

{
"name": "@ultimat3/render",
"version": "7.0.0",
"version": "8.0.0",
"description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",

@@ -38,8 +38,8 @@ "license": "MIT",

"dependencies": {
"@ultimat3/cache": "7.0.0",
"@ultimat3/core": "7.0.0",
"@ultimat3/i18n": "7.0.0",
"@ultimat3/seo": "7.0.0",
"@ultimat3/cache": "8.0.0",
"@ultimat3/core": "8.0.0",
"@ultimat3/i18n": "8.0.0",
"@ultimat3/seo": "8.0.0",
"sass": "1.102.0"
}
}

@@ -22,3 +22,3 @@ # 🖼 @ultimat3/render

meta: ({ data, url }) => ({ title: data.title, description: data.excerpt,
og: { image: data.cover }, alternates: { canonical: url },
og: { image: data.cover }, canonical: url,
ld: ld.Article(data) }),

@@ -332,3 +332,3 @@ });

| `MODE_SPECS`, `assertModeShape`, `assertModeInvariants` | the mode invariant table |
| `registerRoute`, `describeRoutes`, `matchRoute`, `routePathFromFile` | the route table |
| `registerRoute`, `describeRoutes`, `routeFor`, `routePathFromFile` | the route table |
| `checkSurfaceBoundary`, `assertSurfaceBoundary`, `surfaceOf` | the hard boundary |

@@ -335,0 +335,0 @@ | `renderStatic`, `enumeratePrerender` | build-time render, content hashing |

@@ -13,3 +13,9 @@ /**

// `html.ts` is this package's one escaper — a second one is how a character ends up missing.
import { escapeAttribute, escapeJsonContent, escapeRawTextContent, escapeText } from './html';
import {
escapeAttribute,
escapeJsonContent,
escapeRawTextContent,
escapeText,
isAttributeName,
} from './html';

@@ -116,3 +122,7 @@ export type HeadTagKind = 'title' | 'base' | 'meta' | 'link' | 'script' | 'style';

function renderTag(tag: HeadTag): string {
// The NAME is emitted verbatim before the `=` and is escaped nowhere, so a key carrying a space
// carries a whole second attribute with it — an app spreading a row into `attrs` put a live
// event handler in `<head>`. Same predicate `attributePair` uses, never a second copy.
const attrs = Object.entries(tag.attrs ?? {})
.filter(([name]) => isAttributeName(name))
.map(([name, value]) =>

@@ -119,0 +129,0 @@ value === true ? ` ${name}` : ` ${name}="${escapeAttribute(String(value))}"`,

@@ -8,2 +8,3 @@ /**

import { safeUrl, URL_ATTRIBUTES } from '@ultimat3/core';
import { escapeAttribute } from '@ultimat3/seo';
import type { JsxProps } from './jsx';

@@ -33,5 +34,9 @@

export function escapeAttribute(value: string): string {
return escapeText(value).replaceAll('"', '&quot;');
}
/**
* Re-exported, never re-implemented: `@ultimat3/seo` (tier 1) owns the one attribute escaper, and
* the copy that lived here was a second place for a character to go missing. It stays reachable
* from `./html` so this module remains the single import site every render-* file already uses —
* one implementation, one place to look.
*/
export { escapeAttribute };

@@ -120,2 +125,16 @@ /**

/**
* The only shape that can be written between `<div` and `>` without ending the attribute. A name
* is not escaped anywhere — it is emitted verbatim before the `=` — so a key carrying a space
* carries a whole second attribute with it: `{ 'x onmouseover=alert(1) y': 'ok' }` emitted a live
* event handler out of an object KEY, which is `<div {...row} />` over a JSON body or a JSONB
* column. Narrower than the HTML spec's name production on purpose: everything an app actually
* writes (`data-*`, `aria-*`, `xlink:href`, a `__proto__`-named column) matches, and the refusal
* is `null` — the same "emit nothing" this function already answers for a refused URL.
*/
const ATTRIBUTE_NAME = /^[A-Za-z_:][-A-Za-z0-9_:.]*$/;
/** Exported for `head.ts`, the package's other attribute sink. One predicate, never two. */
export const isAttributeName = (name: string): boolean => ATTRIBUTE_NAME.test(name);
const cssProperty = (name: string): string =>

@@ -143,3 +162,7 @@ name.startsWith('--') ? name : name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);

if (typeof value === 'function') return null;
if (name.startsWith('on') && name.length > 2) return null;
if (!isAttributeName(name)) return null;
// Folded, because HTML attribute names are case-insensitive and the two checks below already
// fold: `ONERROR="alert(1)"` went out on the wire off a spread row for as long as this line was
// `name.startsWith('on')`.
if (name.toLowerCase().startsWith('on') && name.length > 2) return null;

@@ -146,0 +169,0 @@ const attribute = ATTRIBUTE_ALIASES.get(name) ?? name;

@@ -20,3 +20,6 @@ /** Public API of `@ultimat3/render`: the `route` primitive, the five modes, the table. */

export type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core';
export { HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from '@ultimat3/core';
// `formatBytes` moved to `@ultimat3/core` (one formatter, with the `mb` branch this package's copy
// never had); still named here because `@ultimat3/cli`'s budget reporter reads it beside the route
// table it prints against.
export { formatBytes, HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from '@ultimat3/core';
export type { CompiledStylesheet } from './css-modules';

@@ -90,3 +93,2 @@ export { compileStylesheet, isCssModule, isGlobalStylesheet, scopeClasses } from './css-modules';

checkBudgets,
formatBytes,
graphFor,

@@ -121,3 +123,2 @@ parseByteBudget,

RouteEntry,
RouteMatch,
} from './registry';

@@ -128,3 +129,2 @@ export {

describeRoutes,
matchRoute,
ROUTE_FILENAME,

@@ -131,0 +131,0 @@ registerRoute,

@@ -9,2 +9,5 @@ /**

import type { HydrateStrategy } from '@ultimat3/core';
// One formatter, in `@ultimat3/core`: the copy that lived here had no `mb` branch, so a 5 MB route
// read `5120kb` in `X_BUDGET_EXCEEDED` while `@ultimat3/pwa` said `5mb` for the same bytes.
import { formatBytes } from '@ultimat3/core';
import { BudgetExceededError } from './errors';

@@ -59,7 +62,2 @@ import type { IslandDirective } from './hydrate';

export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes}b`;
return `${Math.round((bytes / 1024) * 10) / 10}kb`;
}
export interface RouteBytes {

@@ -66,0 +64,0 @@ readonly total: number;

@@ -16,3 +16,3 @@ /**

import { assertModeInvariants, defaultIslandBudget } from './modes';
import type { RouteConfig, RouteData, RouteParams } from './route';
import type { RouteConfig, RouteData } from './route';
import { isRouteConfig, tagKeys } from './route';

@@ -159,2 +159,10 @@ import type { RouteComponent } from './route-component';

const target = inPlace ? `${dir}/${expected}` : `${stem}/${expected}`;
// Plain `mv`, never `git mv`. `x new --no-git` scaffolds a tree with no repository, and there the
// shipped `git mv` answered `fatal: not a git repository` — a fix line that fails is worse than
// no fix line, because the reader debugs git instead of moving the file. `mv` works in both
// cases: git detects the rename at `git add` time, so the only thing given up is a nicety, and
// the instruction is the same one either way. `-n` keeps the one guarantee `git mv` did carry:
// an author who adds the correct `site/pricing/page.tsx` and leaves `site/pricing.tsx` behind is
// reported against the stale file, and `target` is then the GOOD file — a clobber deletes a
// working route, and this fix line is pasted unread.
throw new RouteFileInvalidError(

@@ -164,4 +172,4 @@ `${file} is a route on the ${surface} surface, so it must be named ${expected}: the URL is the ` +

inPlace
? `git mv -- ${shellQuote(file)} ${shellQuote(target)}`
: `mkdir -p -- ${shellQuote(stem)} && git mv -- ${shellQuote(file)} ${shellQuote(target)}`,
? `mv -n -- ${shellQuote(file)} ${shellQuote(target)}`
: `mkdir -p -- ${shellQuote(stem)} && mv -n -- ${shellQuote(file)} ${shellQuote(target)}`,
);

@@ -340,42 +348,10 @@ }

export interface RouteMatch {
readonly entry: RouteEntry;
readonly params: RouteParams;
}
/** Most specific pattern wins: static segments > dynamic > catch-all. */
export function matchRoute(pathname: string): RouteMatch | null {
const candidates = routeEntries()
.slice()
.sort((a, b) => b.pattern.specificity - a.pattern.specificity);
for (const entry of candidates) {
const match = entry.pattern.regex.exec(pathname);
if (match === null) continue;
const params: Record<string, string> = {};
let undecodable = false;
entry.pattern.keys.forEach((key, index) => {
const value = match[index + 1];
if (value === undefined) return;
const decoded = decodeSegment(value);
if (decoded === undefined) undecodable = true;
else params[key] = decoded;
});
// A segment that will not decode fails only the branch that would have decoded it, exactly as
// `@ultimat3/http`'s router already answers: a literal route matching the same text still wins,
// and a pathname nothing else claims is the 404 it always was.
if (undecodable) continue;
return { entry, params };
}
return null;
}
/**
* `undefined` for a malformed percent-escape. A pathname is whatever the client typed, and
* `decodeURIComponent('%zz')` throws a bare `URIError` — no code, no fix line — which escaped
* `matchRoute` as a 500 and an error-monitor page for somebody's typo.
* `decodeURIComponent('%zz')` throws a bare `URIError` — no code, no fix line — where a router
* already has an answer for "this branch does not match".
*
* Still exported after `router-client.ts` went with `createRouter`: it is the one answer to "is
* this segment decodable?" on this side of the wire, and a second copy of it is how one of the two
* ends up throwing where the other 404s.
* Still exported after this package's own `matchRoute` was deleted for `@ultimat3/http`'s trie
* (`stages.ts`): it is the one answer to "is this segment decodable?" on this side of the wire,
* and a second copy of it is how one of the two ends up throwing where the other 404s.
*/

@@ -382,0 +358,0 @@ export function decodeSegment(value: string): string | undefined {