@ultimat3/render
Advanced tools
+10
-5
@@ -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 `<script>` 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 `<` 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. | |
+5
-5
| { | ||
| "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" | ||
| } | ||
| } |
+2
-2
@@ -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 | |
+11
-1
@@ -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))}"`, |
+27
-4
@@ -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('"', '"'); | ||
| } | ||
| /** | ||
| * 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; |
+4
-4
@@ -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, |
+3
-5
@@ -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
-40
@@ -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 { |
234167
1.78%4109
0.22%+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
Updated
Updated
Updated
Updated