@ultimat3/render 22.15.0 → 23.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 CHANGED
@@ -61,6 +61,7 @@ axiom 6). Never `cli` (upward).
61
61
  | `idle`'s deadline | `IDLE_HYDRATE_TIMEOUT_MS`, interpolated INTO the runtime string and exported for `x shot`'s settle. |
62
62
  | Route truth | `registry.ts`. Never a second route list and never a second matcher — `@ultimat3/http`'s trie (`stages.ts`) serves requests. `routeFor` is an exact-path `Map` lookup. |
63
63
  | Route filename | `page.tsx` under `site/`/`app/`, `route.ts` under `api/` — `ROUTE_FILENAME`. Anything else is `X_ROUTE_FILE_INVALID`. |
64
+ | Mounted routes | `registerMountedRoutes(mount, routes)` (`mounted-routes.ts`) — routes a PACKAGE mounts with no surface file (the admin's screens). In `describeRoutes()` with `mount: { by, permissions }`, never in `routeEntries()`: described, not rendered here. Re-declaring a `key` replaces its set; a path a file route also claims is `X_ROUTE_DUPLICATE` in both orders. `RouteDescriptor.mount` is absent on a file route. |
64
65
  | Registry input | descriptors only. `registerRoute` refuses a raw declaration with `X_ROUTE_UNNORMALIZED`; it fills in only the island budget. |
65
66
  | Descriptors | `describeRoutes()` stays JSON-safe, sorted by path, deterministic. |
66
67
  | Every string order | `byCodeUnit` (`code-unit-order.ts`), never `localeCompare` (ICU default locale and collation version vary by machine). |
@@ -88,14 +89,16 @@ axiom 6). Never `cli` (upward).
88
89
  | Solid | no `solid-js` import anywhere in this package; `type-pins.tsx` satisfies `JSX.Element` structurally. `jsx.ts` builds inert nodes. Islands are compiled by `@ultimat3/cli`'s `solid-loader.ts` with `babel-preset-solid`. |
89
90
  | Root element | `ROOT_ELEMENT_ID` (`render-html.ts`). |
90
91
  | The loaders | `module-loader.ts` installs them at **`server.ts`** module scope, once. `packages/cli/src/app-load.ts` imports `@ultimat3/render/server` for that side effect. |
92
+ | Path style tag | `client-path-style-tag.ts` — `<meta name="ultimate-path-style">`, read by core's `actionPath`. Principal-free, on every document; written ONLY for a non-default style, so a `'resource'` app's bytes do not move. `@ultimat3/cli`'s `headFor` hands it `@ultimat3/action`'s `actionPathStyle()` per render — this package has no edge to `action`. |
91
93
  | Client sync tags | `client-sync-tags.ts` — `ultimate-sync`, `ultimate-build`, `ultimate-sync-worker`. Principal-free, on every document. |
92
94
  | 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. |
93
95
  | `<head>` baseline | `documentBaseline()` in `head.ts` — charset, viewport, `color-scheme` — merged FIRST so a route can override any of them. |
94
96
  | Escaping | `html.ts` only — including `render-stream.ts`'s `holeMarker` and `revealChunk` (`JSON.stringify`), and `head.ts`'s `themeScript`. `escapeAttribute` is `@ultimat3/seo`'s, re-exported by `html.ts`. |
95
97
  | Script and style CONTENT | never raw: `escapeText`, `escapeRawTextContent` (`</` → `<\/`, `<!--` → `<\!--`), or `escapeJsonContent` for a `type` ending in `json`. Never HTML-escape a script body. |
96
98
  | Which export is the page | `route-component.ts`: `Page` → a single `…Page` → a single capitalised function. |
97
- | 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. |
99
+ | 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. |
98
100
  | CSS order | `stylesFor` sorts **globals before modules** (`isGlobalStylesheet`). `shared/` is carried by both graphs. |
101
+ | 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()`. |
99
102
  | The global layer | the app's `shared/global.scss` `@use`s `@ultimat3/ui/global.scss`, side-effect-imported by `shared/global.ts` (this package may not import `ui`). `x verify` fails with `X_STYLES_GLOBAL_MISSING` when a surface's document defines none. |
100
103
  | Colours | tokens and `data-theme` only. No hex in `head.ts` or any emitted script. |
101
104
  | An island's `mount` | may return `() => void`, its disposer; the runtime never calls it, `@ultimat3/testing`'s `mountIsland` does. |
package/README.md CHANGED
@@ -490,16 +490,20 @@ a job boundary the class is gone and the `code` is what survives — match on th
490
490
  | `asset`, `setAssetResolver`, `assetPathProblem`, `AssetPath` | `asset('assets/x.avif')` → the content-hashed URL of a public site file; the table is the CLI's ([Static Assets](../../wiki/Static-Assets.md)) |
491
491
  | `MODE_SPECS`, `assertModeShape`, `assertModeInvariants` | the mode invariant table |
492
492
  | `registerRoute`, `describeRoutes`, `routeFor`, `routePathFromFile` | the route table |
493
+ | `registerMountedRoutes`, `RouteMount`, `RouteMountInput`, `MountedRouteInput` | routes a package mounts with no surface file: listed by `describeRoutes()` with `mount: { by, permissions }`, absent from `routeEntries()` |
493
494
  | `checkSurfaceBoundary`, `assertSurfaceBoundary`, `surfaceOf` | the hard boundary |
494
495
  | `renderStatic`†, `enumeratePrerender`† | build-time render, content hashing |
495
496
  | `createIsrController`†, `invalidateAndRevalidate`† | SWR + single-flight + tag triggers |
496
497
  | `renderSsr`†, `streamResult`† | the per-request modes |
497
498
  | `renderToHtml`†, `renderComponent`†, `stylesFor`† | the server JSX writer and the surface's css |
499
+ | `claimStylesheets`† | a package claims its own directory's stylesheets for ONE surface, so the other surface's documents never carry them |
498
500
  | `installRenderLoader`†, `compileStylesheet`† | the `.tsx`/`.scss` loaders, installed on import |
501
+ | `setSassCacheDir`†, `sassCompilations`† | where the Sass compile cache lives (`.x/cache/sass` under cwd; a container's is the image's prebuilt store) and how many compiles this process ran instead of reading one |
499
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 |
500
503
  | `ISLAND_MOUNTED_ATTRIBUTE`, `ISLAND_FAILED_ATTRIBUTE`, `IDLE_HYDRATE_TIMEOUT_MS` | what hydration looks like from outside the page |
501
504
  | `parseByteBudget`, `defaultIslandBudget` | the `'40kb'` budget grammar, and the ceiling a declared island earns |
502
505
  | `mergeHead`, `renderHead`, `themeScript` | `<head>` merge + the one inlined script |
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 |
503
507
 
504
508
  ## Notes
505
509
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/render",
3
- "version": "22.15.0",
3
+ "version": "23.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",
@@ -26,6 +26,7 @@
26
26
  "files": [
27
27
  "src",
28
28
  "!src/**/*.test.ts",
29
+ "!src/**/*-fixture.ts",
29
30
  "CLAUDE.md",
30
31
  "README.md",
31
32
  "LICENSE"
@@ -38,11 +39,11 @@
38
39
  "test": "bun test"
39
40
  },
40
41
  "dependencies": {
41
- "@ultimat3/cache": "22.15.0",
42
- "@ultimat3/core": "22.15.0",
43
- "@ultimat3/http": "22.15.0",
44
- "@ultimat3/i18n": "22.15.0",
45
- "@ultimat3/seo": "22.15.0",
42
+ "@ultimat3/cache": "23.0.0",
43
+ "@ultimat3/core": "23.0.0",
44
+ "@ultimat3/http": "23.0.0",
45
+ "@ultimat3/i18n": "23.0.0",
46
+ "@ultimat3/seo": "23.0.0",
46
47
  "sass": "1.104.0"
47
48
  }
48
49
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * How this server turns an action's name into its URL, as a `<head>` tag:
3
+ * `<meta name="ultimate-path-style" content="readable">`, read by `@ultimat3/core`'s `actionPath`
4
+ * for every browser caller that names no style. Principal-free, so every document may carry it —
5
+ * and only a non-default style is written: an app that declared none renders the same bytes.
6
+ */
7
+
8
+ import type { ActionPathStyle } from '@ultimat3/core';
9
+ import { CLIENT_PATH_STYLE_META } from '@ultimat3/core';
10
+ import type { HeadTag } from './head';
11
+
12
+ /** The `name` core's reader matches — core's constant, so the writer and the reader are one literal. */
13
+ export { CLIENT_PATH_STYLE_META };
14
+
15
+ /** `style` is `@ultimat3/action`'s `actionPathStyle()`: what `defineApi` declared, read per render. */
16
+ export function clientPathStyleTags(style: ActionPathStyle): readonly HeadTag[] {
17
+ if (style === 'resource') return [];
18
+ return [
19
+ {
20
+ kind: 'meta',
21
+ key: `meta:${CLIENT_PATH_STYLE_META}`,
22
+ attrs: { name: CLIENT_PATH_STYLE_META, content: style },
23
+ },
24
+ ];
25
+ }
package/src/index.ts CHANGED
@@ -19,6 +19,8 @@ export { formatBytes, HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } fro
19
19
  /** `asset('assets/x.avif')` → the content-hashed URL the site asset surface serves it at. */
20
20
  export type { AssetExtension, AssetPath, AssetResolver } from './asset';
21
21
  export { ASSET_DIR, ASSET_EXTENSIONS, asset, assetPathProblem, setAssetResolver } from './asset';
22
+ /** The `<meta name="ultimate-path-style">` core's `actionPath` reads — a non-default style only. */
23
+ export { CLIENT_PATH_STYLE_META, clientPathStyleTags } from './client-path-style-tag';
22
24
  /** The `<meta name="ultimate-scope">` core's `pageClient()` reads, on private documents only. */
23
25
  export {
24
26
  CLIENT_PERSIST_META,
@@ -154,15 +156,19 @@ export type { ClientNavigationHead } from './navigation-tags';
154
156
  export { clientNavigationTags } from './navigation-tags';
155
157
  export type {
156
158
  CompiledPattern,
159
+ MountedRouteInput,
157
160
  RegisterRouteInput,
158
161
  RouteDescriptor,
159
162
  RouteEntry,
163
+ RouteMount,
164
+ RouteMountInput,
160
165
  } from './registry';
161
166
  export {
162
167
  clearRoutes,
163
168
  compilePattern,
164
169
  describeRoutes,
165
170
  ROUTE_FILENAME,
171
+ registerMountedRoutes,
166
172
  registerRoute,
167
173
  routeEntries,
168
174
  routeFor,
package/src/jsx.ts CHANGED
@@ -28,16 +28,37 @@ export function isJsxNode(value: unknown): value is JsxNode {
28
28
  * component reading `props.children` behaves the same whether its children came from the classic
29
29
  * factory's rest arguments or from an explicit `children` prop.
30
30
  */
31
+ export function h(type: string, props: JsxProps | null, ...children: readonly unknown[]): JsxNode;
32
+ /**
33
+ * A component, with the props IT declares. `JsxComponent` takes any record, so a component typed
34
+ * for its own props — an `island()`, a page's `Shell` — is not one under `strictFunctionTypes`,
35
+ * and a hand-written `h(Toggle, { label })` was refused where the `.tsx` loader's own call is not.
36
+ * The props are checked against the component here; `children` rides as the rest arguments.
37
+ */
38
+ export function h<Props extends object>(
39
+ type: (props: Props) => unknown,
40
+ props: Omit<Props, 'children'> | null,
41
+ ...children: readonly unknown[]
42
+ ): JsxNode;
31
43
  export function h(
32
44
  type: string | JsxComponent,
33
45
  props: JsxProps | null,
34
46
  ...children: readonly unknown[]
47
+ ): JsxNode;
48
+ export function h(
49
+ // `never` is the one parameter type every component's props satisfy.
50
+ type: string | ((props: never) => unknown),
51
+ props: object | null,
52
+ ...children: readonly unknown[]
35
53
  ): JsxNode {
36
- const base = props ?? {};
37
- if (children.length === 0) return { [JSX_NODE]: true, type, props: base };
54
+ // The overloads above are where a call is checked; a node carries the pair erased, which is all
55
+ // `render-html.ts` ever reads — it hands `props` back to the `type` it arrived with.
56
+ const erased = type as string | JsxComponent;
57
+ const base = (props ?? {}) as JsxProps;
58
+ if (children.length === 0) return { [JSX_NODE]: true, type: erased, props: base };
38
59
  return {
39
60
  [JSX_NODE]: true,
40
- type,
61
+ type: erased,
41
62
  props: { ...base, children: children.length === 1 ? children[0] : children },
42
63
  };
43
64
  }
@@ -76,6 +76,11 @@ export interface Stylesheet {
76
76
  readonly file: string;
77
77
  /** The surface that owns it, or `null` for a package stylesheet shared by both graphs. */
78
78
  readonly surface: Surface | null;
79
+ /**
80
+ * A PACKAGE's sheet whose surface came from `claimStylesheets`, not from where the file sits.
81
+ * It is carried by that one surface and still ordered with the library — see `stylesheetOrder`.
82
+ */
83
+ readonly claimed: boolean;
79
84
  /** A plain (non-module) stylesheet: the tokens and the reset, which the cascade needs first. */
80
85
  readonly global: boolean;
81
86
  /**
@@ -118,8 +123,46 @@ export function registeredStylesheets(): readonly Stylesheet[] {
118
123
  */
119
124
  let stylesheetRoot: string | undefined;
120
125
 
126
+ /**
127
+ * Directories a package claimed for ONE surface, longest first. A package's sheet has no surface
128
+ * of its own and rides both graphs — right for a design system, and the reason an app's static
129
+ * `site/` documents carried the admin's stylesheet for screens only `app/` serves (axiom 6).
130
+ */
131
+ const claims: { readonly dir: string; readonly surface: Surface }[] = [];
132
+
133
+ const claimedSurface = (path: string): Surface | undefined =>
134
+ claims.find((claim) => path.startsWith(`${claim.dir}/`))?.surface;
135
+
121
136
  const surfaceOfSheet = (path: string): Surface | null =>
122
- surfaceUnder(stylesheetRoot ?? process.cwd(), path);
137
+ claimedSurface(path) ?? surfaceUnder(stylesheetRoot ?? process.cwd(), path);
138
+
139
+ /** Every registered sheet, classified again; the revision moves only when an answer does. */
140
+ const reclassify = (): void => {
141
+ for (const [path, sheet] of stylesheets) {
142
+ const surface = surfaceOfSheet(path);
143
+ const claimed = claimedSurface(path) !== undefined;
144
+ if (surface === sheet.surface && claimed === sheet.claimed) continue;
145
+ stylesheets.set(path, { ...sheet, surface, claimed });
146
+ revision += 1;
147
+ }
148
+ };
149
+
150
+ /**
151
+ * A package says its own stylesheets belong to one surface: every sheet below `dir` is carried by
152
+ * documents of `surface` and by no other. Called by the package that OWNS the directory, with its
153
+ * own `import.meta.dir` — a sheet loads when its module is linked, which is before the claiming
154
+ * module's body runs, so a claim re-classifies what is already registered.
155
+ *
156
+ * A claim is a fact about a package's source, made once per process: `clearStylesheets()` leaves
157
+ * it standing, because the module that made it is cached and will not make it again.
158
+ */
159
+ export function claimStylesheets(dir: string, surface: Surface): void {
160
+ const claimed = resolve(dir);
161
+ if (claims.some((claim) => claim.dir === claimed && claim.surface === surface)) return;
162
+ claims.push({ dir: claimed, surface });
163
+ claims.sort((a, b) => b.dir.length - a.dir.length);
164
+ reclassify();
165
+ }
123
166
 
124
167
  /**
125
168
  * Names the app root the registry classifies against; `loadApp` calls it before importing a module.
@@ -132,12 +175,7 @@ const surfaceOfSheet = (path: string): Surface | null =>
132
175
  export function setStylesheetRoot(root: string | undefined): void {
133
176
  // Resolved: `loadApp('.')` is a legal call, and `.` is a prefix of no absolute path.
134
177
  stylesheetRoot = root === undefined ? undefined : resolve(root);
135
- for (const [path, sheet] of stylesheets) {
136
- const surface = surfaceOfSheet(path);
137
- if (surface === sheet.surface) continue;
138
- stylesheets.set(path, { ...sheet, surface });
139
- revision += 1;
140
- }
178
+ reclassify();
141
179
  }
142
180
 
143
181
  /** Test seam: the registry is process-global because the module cache it mirrors is too. */
@@ -180,7 +218,8 @@ export function stylesFor(surface: Surface | null): string {
180
218
  }
181
219
 
182
220
  const OWNER_RANK = (sheet: Stylesheet): number =>
183
- sheet.surface === null ? 0 : sheet.surface === 'shared' ? 1 : 2;
221
+ // A claimed sheet is a package's whichever surface carries it: library first.
222
+ sheet.surface === null || sheet.claimed ? 0 : sheet.surface === 'shared' ? 1 : 2;
184
223
 
185
224
  /** Relative to the app root, so a container at `/app` and a laptop order one app alike. */
186
225
  const orderPath = (sheet: Stylesheet): string => {
@@ -233,6 +272,7 @@ export function loadStylesheet(
233
272
  stylesheets.set(path, {
234
273
  file: path,
235
274
  surface: surfaceOfSheet(path),
275
+ claimed: claimedSurface(path) !== undefined,
236
276
  global: isGlobalStylesheet(path),
237
277
  island,
238
278
  css: compiled.css,
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The routes a package MOUNTS — screens no surface file declares, like the admin's — held beside
3
+ * the file routes so `registry.ts` can describe both in the one route list. Storage and the
4
+ * collision rule only: nothing here renders, matches or serves.
5
+ */
6
+
7
+ import { RouteDuplicateError } from './errors';
8
+ import type { RouteConfig } from './route';
9
+ import type { Surface } from './surfaces';
10
+
11
+ /** Who put a mounted route in the table, and what it requires. */
12
+ export interface RouteMount {
13
+ /** The call that declared it: `defineAdmin`. */
14
+ readonly by: string;
15
+ /** Every permission the route's own screen decides on, coarse gate first. */
16
+ readonly permissions: readonly string[];
17
+ }
18
+
19
+ /** The mount a set of routes belongs to. `key` is what a re-declaration replaces. */
20
+ export interface RouteMountInput {
21
+ readonly key: string;
22
+ readonly by: string;
23
+ /** What `RouteDescriptor.file` reads for these routes: the package, since no app file is. */
24
+ readonly file: string;
25
+ readonly surface: Exclude<Surface, 'shared'>;
26
+ }
27
+
28
+ export interface MountedRouteInput {
29
+ readonly path: string;
30
+ readonly config: RouteConfig;
31
+ readonly permissions: readonly string[];
32
+ }
33
+
34
+ export interface MountedRoute extends MountedRouteInput {
35
+ readonly mount: RouteMountInput;
36
+ }
37
+
38
+ const mounted = new Map<string, readonly MountedRoute[]>();
39
+
40
+ /** Every mounted route, in mount-declaration order. */
41
+ export function mountedRoutes(): readonly MountedRoute[] {
42
+ return [...mounted.values()].flat();
43
+ }
44
+
45
+ /** The mounted route claiming `path`, if one does. */
46
+ export function mountedAt(path: string): MountedRoute | undefined {
47
+ return mountedRoutes().find((route) => route.path === path);
48
+ }
49
+
50
+ /** Replaces the set under `mount.key`: a re-declaration is the same mount, saved again. */
51
+ export function setMountedRoutes(
52
+ mount: RouteMountInput,
53
+ declared: readonly MountedRouteInput[],
54
+ ): void {
55
+ mounted.set(
56
+ mount.key,
57
+ declared.map((route) => ({ ...route, mount })),
58
+ );
59
+ }
60
+
61
+ export function clearMountedRoutes(): void {
62
+ mounted.clear();
63
+ }
64
+
65
+ /** One URL, one claimant — a file and a mount on the same path is two servers for it. */
66
+ export const mountCollision = (
67
+ path: string,
68
+ file: string,
69
+ mount: RouteMountInput,
70
+ ): RouteDuplicateError =>
71
+ new RouteDuplicateError(
72
+ `${path} is claimed by both ${file} and ${mount.by}() — a route ${mount.file} mounts`,
73
+ `delete ${file}: ${mount.by}() already serves ${path}, and the route table is keyed by URL`,
74
+ );
package/src/registry.ts CHANGED
@@ -15,6 +15,14 @@ import {
15
15
  SurfaceBoundaryError,
16
16
  } from './errors';
17
17
  import { assertModeInvariants, defaultIslandBudget } from './modes';
18
+ import type { MountedRouteInput, RouteMount, RouteMountInput } from './mounted-routes';
19
+ import {
20
+ clearMountedRoutes,
21
+ mountCollision,
22
+ mountedAt,
23
+ mountedRoutes,
24
+ setMountedRoutes,
25
+ } from './mounted-routes';
18
26
  import type { RouteConfig, RouteData } from './route';
19
27
  import { isRouteConfig, tagKeys } from './route';
20
28
  import type { RouteComponent } from './route-component';
@@ -83,8 +91,15 @@ export interface RouteDescriptor {
83
91
  readonly islandSources: readonly string[];
84
92
  readonly budgetJs: string | null;
85
93
  readonly budgetLcp: number | null;
94
+ /**
95
+ * Present on a route NO surface file declares: who mounted it and every permission that gates
96
+ * it. Absent on a file route — so a reader never has to ask whether `file` is a path.
97
+ */
98
+ readonly mount?: RouteMount;
86
99
  }
87
100
 
101
+ export type { MountedRouteInput, RouteMount, RouteMountInput } from './mounted-routes';
102
+
88
103
  export interface CompiledPattern {
89
104
  readonly source: string;
90
105
  readonly regex: RegExp;
@@ -284,6 +299,8 @@ export function registerRoute<TData = RouteData>(
284
299
  `rename or delete one of them — the route table is keyed by URL`,
285
300
  );
286
301
  }
302
+ const claimed = mountedAt(path);
303
+ if (claimed !== undefined) throw mountCollision(path, input.file, claimed.mount);
287
304
 
288
305
  const entry: RouteEntry<TData> = {
289
306
  file: input.file,
@@ -329,8 +346,27 @@ function withIslandBudget<TData>(config: RouteConfig<TData>, surface: Surface):
329
346
  return Object.freeze(derived);
330
347
  }
331
348
 
349
+ /**
350
+ * Declare the routes a package MOUNTS — screens no surface file exists for, like the admin's — so
351
+ * the one route list carries them. Described, never rendered here: the mounting host serves them,
352
+ * and `routeEntries()` stays the pages a file declares. Re-declaring a `key` replaces its set,
353
+ * because `x dev` re-evaluates the module that declared it on every save.
354
+ */
355
+ export function registerMountedRoutes(
356
+ mount: RouteMountInput,
357
+ declared: readonly MountedRouteInput[],
358
+ ): void {
359
+ for (const route of declared) {
360
+ const file = routes.get(route.path)?.file;
361
+ if (file !== undefined) throw mountCollision(route.path, file, mount);
362
+ }
363
+ setMountedRoutes(mount, declared);
364
+ described = undefined;
365
+ }
366
+
332
367
  export function clearRoutes(): void {
333
368
  routes.clear();
369
+ clearMountedRoutes();
334
370
  described = undefined;
335
371
  }
336
372
 
@@ -361,26 +397,51 @@ export function describeRoutes(): readonly RouteDescriptor[] {
361
397
  }
362
398
 
363
399
  function buildDescriptors(): RouteDescriptor[] {
364
- return routeEntries().map((entry) => ({
365
- path: entry.path,
366
- file: entry.file,
367
- surface: entry.surface,
368
- mode: entry.config.render,
369
- offline: entry.config.offline,
370
- hydrate: entry.config.hydrate,
371
- revalidateTags: tagKeys(entry.config.revalidate?.tags),
372
- revalidateTtl: entry.config.revalidate?.ttl ?? null,
373
- prerenderable: entry.config.prerender !== undefined,
374
- dynamic: entry.pattern.keys.length > 0,
375
- hasPolicy: entry.config.policy !== undefined,
376
- personal: isPersonal(entry.config),
377
- islands: entry.islands,
378
- islandSources: entry.config.islands.map((spec) => spec.src),
379
- budgetJs: entry.config.budget.js ?? null,
380
- budgetLcp: entry.config.budget.lcp ?? null,
381
- }));
400
+ const files = routeEntries().map((entry) =>
401
+ descriptorOf(entry.path, entry.file, entry.surface, entry.config, entry.pattern.keys.length),
402
+ );
403
+ const mounts = mountedRoutes().map(
404
+ (route): RouteDescriptor => ({
405
+ ...descriptorOf(
406
+ route.path,
407
+ route.mount.file,
408
+ route.mount.surface,
409
+ route.config,
410
+ compilePattern(route.path).keys.length,
411
+ ),
412
+ mount: { by: route.mount.by, permissions: [...route.permissions] },
413
+ }),
414
+ );
415
+ return mounts.length === 0
416
+ ? files
417
+ : [...files, ...mounts].sort((a, b) => byCodeUnit(a.path, b.path));
382
418
  }
383
419
 
420
+ const descriptorOf = (
421
+ path: string,
422
+ file: string,
423
+ surface: Surface,
424
+ config: RouteConfig,
425
+ params: number,
426
+ ): RouteDescriptor => ({
427
+ path,
428
+ file,
429
+ surface,
430
+ mode: config.render,
431
+ offline: config.offline,
432
+ hydrate: config.hydrate,
433
+ revalidateTags: tagKeys(config.revalidate?.tags),
434
+ revalidateTtl: config.revalidate?.ttl ?? null,
435
+ prerenderable: config.prerender !== undefined,
436
+ dynamic: params > 0,
437
+ hasPolicy: config.policy !== undefined,
438
+ personal: isPersonal(config),
439
+ islands: config.islands.map((spec) => spec.moduleId),
440
+ islandSources: config.islands.map((spec) => spec.src),
441
+ budgetJs: config.budget.js ?? null,
442
+ budgetLcp: config.budget.lcp ?? null,
443
+ });
444
+
384
445
  /** See `RouteDescriptor.personal`. */
385
446
  function isPersonal(config: RouteConfig): boolean {
386
447
  if (config.policy !== undefined || config.render === 'stream') return true;
package/src/sass-cache.ts CHANGED
@@ -9,7 +9,12 @@ import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'no
9
9
  // why: Bun ships no path-join primitive.
10
10
  import { join } from 'node:path';
11
11
 
12
- /** Relative to the process's cwd — the app root for every `x` command. `.x/` is gitignored. */
12
+ /**
13
+ * Relative to the process's cwd — the app root for every `x` command. `.x/` is gitignored, and in
14
+ * a container it is the STATE directory a topology mounts a tmpfs over, so an image's own entries
15
+ * live elsewhere: the image build points this cache at its prebuilt store through
16
+ * `setSassCacheDir`, and the container's boot points it back at the same place.
17
+ */
13
18
  export const SASS_CACHE_DIR = join('.x', 'cache', 'sass');
14
19
 
15
20
  /** Bumped when the entry shape changes, so an old entry is a miss and never a misread. */
@@ -153,11 +158,25 @@ export function cachedSassCompile(
153
158
  assetsValid: AssetCheck = () => false,
154
159
  ): SassOutput {
155
160
  const dir = cacheDir();
156
- if (dir === null) return compile();
161
+ if (dir === null) return counted(compile);
157
162
  const file = join(dir, `${sha256(key)}.json`);
158
163
  const hit = readHit(file, assetsValid);
159
164
  if (hit !== undefined) return hit;
160
- const output = compile();
165
+ const output = counted(compile);
161
166
  store(dir, file, output);
162
167
  return output;
163
168
  }
169
+
170
+ let compilations = 0;
171
+
172
+ const counted = (compile: () => SassOutput): SassOutput => {
173
+ compilations += 1;
174
+ return compile();
175
+ };
176
+
177
+ /**
178
+ * How many times this process ran Sass rather than reading a stored result. A container booted
179
+ * from a prebuilt image answers 0; anything else is a boot that compiled what the image build
180
+ * should have, and the number is what its log line reports.
181
+ */
182
+ export const sassCompilations = (): number => compilations;
package/src/server.ts CHANGED
@@ -26,6 +26,7 @@ export {
26
26
  // ---- the two Bun loaders: `.tsx` → the server JSX factory, `.scss` → css + a class map ----------
27
27
  export type { Stylesheet } from './module-loader';
28
28
  export {
29
+ claimStylesheets,
29
30
  clearStylesheets,
30
31
  installRenderLoader,
31
32
  loadStylesheet,
@@ -76,3 +77,5 @@ export {
76
77
  revealChunk,
77
78
  streamResult,
78
79
  } from './render-stream';
80
+ // ---- the Sass compile cache: where it lives, and how often this process ran Sass instead --------
81
+ export { sassCompilations, setSassCacheDir } from './sass-cache';
@@ -1,91 +0,0 @@
1
- /**
2
- * The controller's test harness beside the fake DOM: a page with the links every test follows, a
3
- * tab whose `fetch` the test answers (every call recorded), an event dispatched the way a browser
4
- * does, and the router the test started — stopped after each by `stopRouter()`.
5
- */
6
- import { startNavigation } from './navigation';
7
- import {
8
- FakeDocument,
9
- type FakeElement,
10
- type FakeWindow,
11
- fakeWindow,
12
- h,
13
- htmlAnswer,
14
- routerHead,
15
- } from './navigation-dom-fixture';
16
-
17
- export type Handler = (init: RequestInit | undefined) => Response | Promise<Response>;
18
-
19
- export const page = (
20
- title: string,
21
- meta: { scope?: string; build?: string } = {},
22
- body: FakeElement[] = [],
23
- ) =>
24
- new FakeDocument(routerHead(title, [], meta), [
25
- h('main', {}, [
26
- h('h1', {}, title),
27
- h('a', { id: 'to-b', href: '/b' }, 'b'),
28
- h('a', { id: 'to-c', href: '/c' }, 'c'),
29
- h('a', { id: 'to-slow', href: '/slow' }, 'slow'),
30
- h('a', { id: 'plain', href: '/b', 'data-x-no-prefetch': '' }, 'plain'),
31
- ...body,
32
- ]),
33
- h('script', {}, `hydrate(${title})`),
34
- ]);
35
-
36
- /** The router the current test started; `stopRouter()` in an `afterEach` lets it go. */
37
- let router: ReturnType<typeof startNavigation>;
38
- export const currentRouter = (): ReturnType<typeof startNavigation> => router;
39
- export function stopRouter(): void {
40
- router?.stop();
41
- router = undefined;
42
- }
43
-
44
- /** A tab on `/a` whose `fetch` answers from `routes`; `calls` records `METHOD path purpose`. */
45
- export function tab(routes: Record<string, Handler>, doc = page('A')) {
46
- const calls: string[] = [];
47
- const fetch = (url: string, init?: RequestInit): Promise<Response> => {
48
- const u = new URL(url);
49
- const headers = (init?.headers ?? {}) as Record<string, string>;
50
- calls.push(
51
- `${init?.method ?? 'GET'} ${u.pathname}${u.search} ${headers['x-ultimate-navigation']}`,
52
- );
53
- const handler = routes[u.pathname];
54
- if (handler === undefined) return Promise.reject(new TypeError('network'));
55
- return Promise.resolve(handler(init));
56
- };
57
- const win = fakeWindow(doc, 'https://app.test/a', fetch);
58
- router = startNavigation(win as unknown as Window);
59
- return { win, doc, calls };
60
- }
61
-
62
- /** An event the way a browser dispatches it: cancelable, with its target and fields. */
63
- export function fire(
64
- on: EventTarget,
65
- type: string,
66
- target: unknown,
67
- fields: Record<string, unknown> = {},
68
- ): Event {
69
- const event = new Event(type, { cancelable: true, bubbles: true });
70
- Object.defineProperty(event, 'target', { value: target });
71
- for (const [key, value] of Object.entries({
72
- button: 0,
73
- metaKey: false,
74
- ctrlKey: false,
75
- shiftKey: false,
76
- altKey: false,
77
- ...fields,
78
- })) {
79
- Object.defineProperty(event, key, { value });
80
- }
81
- on.dispatchEvent(event);
82
- return event;
83
- }
84
-
85
- export const click = (win: FakeWindow, id: string, fields?: Record<string, unknown>) =>
86
- fire(win, 'click', win.document.getElementById(id), fields);
87
-
88
- export const answers = {
89
- b: () => htmlAnswer('page:B', page('B')),
90
- c: () => htmlAnswer('page:C', page('C')),
91
- };
@@ -1,424 +0,0 @@
1
- /**
2
- * Just enough DOM for the client router's unit tests, written for them the way `hydrate-replay`'s
3
- * fakes are: elements with attributes and children, the selectors the router uses (tag, `[a]`,
4
- * `[a="v"]`, `[a~="v"]`, `:not([src])`, comma lists), documents that adopt and swap bodies, a
5
- * window with history, location and timers, and a `DOMParser` that answers a fetched string with a
6
- * document the test built. What a browser DOES with a document is proven in Chrome
7
- * (`packages/cli/e2e/client-navigation-*.e2e.test.ts`); this is the branch coverage beside it.
8
- */
9
-
10
- type Attrs = Readonly<Record<string, string>>;
11
-
12
- /** One compound selector: a tag and attribute conditions, optionally excluding a `src`. */
13
- interface Simple {
14
- readonly tag: string;
15
- readonly conditions: readonly { name: string; op: string | undefined; value: string }[];
16
- readonly notSrc: boolean;
17
- }
18
-
19
- const parse = (selector: string): readonly Simple[] =>
20
- selector.split(',').map((part) => {
21
- const text = part.trim();
22
- const notSrc = text.includes(':not([src])');
23
- const clean = text.replace(':not([src])', '');
24
- const tag = /^[a-z0-9]*/i.exec(clean)?.[0] ?? '';
25
- const conditions = [...clean.matchAll(/\[([\w:-]+)(?:(~?=)"?([^"\]]*)"?)?\]/g)].map((m) => ({
26
- name: m[1] ?? '',
27
- op: m[2],
28
- value: m[3] ?? '',
29
- }));
30
- return { tag: tag.toUpperCase(), conditions, notSrc };
31
- });
32
-
33
- const matches = (el: FakeElement, selector: string): boolean =>
34
- parse(selector).some(
35
- (simple) =>
36
- (simple.tag === '' || simple.tag === el.tagName) &&
37
- !(simple.notSrc && el.hasAttribute('src')) &&
38
- simple.conditions.every(({ name, op, value }) => {
39
- const said = el.getAttribute(name);
40
- if (said === null) return false;
41
- if (op === undefined) return true;
42
- if (op === '~=') return said.split(/\s+/).includes(value);
43
- return said === value;
44
- }),
45
- );
46
-
47
- export class FakeElement {
48
- readonly tagName: string;
49
- readonly attrs = new Map<string, string>();
50
- children: FakeElement[] = [];
51
- parentElement: FakeElement | null = null;
52
- ownerDocument: FakeDocument;
53
- text = '';
54
- style: Record<string, string> = {};
55
- nonce = '';
56
- async = true;
57
- onload: (() => void) | null = null;
58
- onerror: (() => void) | null = null;
59
- __x?: Promise<unknown>;
60
- clicks = 0;
61
-
62
- constructor(
63
- doc: FakeDocument,
64
- tag: string,
65
- attrs: Attrs = {},
66
- content: readonly FakeElement[] | string = [],
67
- ) {
68
- this.ownerDocument = doc;
69
- this.tagName = tag.toUpperCase();
70
- for (const [name, value] of Object.entries(attrs)) this.attrs.set(name, value);
71
- if (typeof content === 'string') this.text = content;
72
- else for (const child of content) this.append(child);
73
- }
74
-
75
- get id(): string {
76
- return this.getAttribute('id') ?? '';
77
- }
78
- getAttribute(name: string): string | null {
79
- return this.attrs.get(name) ?? null;
80
- }
81
- setAttribute(name: string, value: string): void {
82
- this.attrs.set(name, String(value));
83
- }
84
- removeAttribute(name: string): void {
85
- this.attrs.delete(name);
86
- }
87
- hasAttribute(name: string): boolean {
88
- return this.attrs.has(name);
89
- }
90
- getAttributeNames(): string[] {
91
- return [...this.attrs.keys()];
92
- }
93
- get attributes(): { name: string; value: string }[] {
94
- return [...this.attrs].map(([name, value]) => ({ name, value }));
95
- }
96
- get textContent(): string {
97
- return this.text + this.children.map((child) => child.textContent).join('');
98
- }
99
- set textContent(value: string) {
100
- this.text = value;
101
- this.children = [];
102
- }
103
- get className(): string {
104
- return this.getAttribute('class') ?? '';
105
- }
106
- get outerHTML(): string {
107
- const attrs = [...this.attrs].map(([n, v]) => ` ${n}="${v}"`).join('');
108
- const tag = this.tagName.toLowerCase();
109
- return `<${tag}${attrs}>${this.text}${this.children.map((c) => c.outerHTML).join('')}</${tag}>`;
110
- }
111
- // The properties the router reads and writes on links, scripts and anchors.
112
- get href(): string {
113
- return new URL(this.getAttribute('href') ?? '', this.ownerDocument.baseURI).href;
114
- }
115
- set href(value: string) {
116
- this.setAttribute('href', value);
117
- }
118
- set rel(value: string) {
119
- this.setAttribute('rel', value);
120
- }
121
- get target(): string {
122
- return this.getAttribute('target') ?? '';
123
- }
124
- set download(value: string) {
125
- this.setAttribute('download', value);
126
- }
127
- get type(): string {
128
- return this.getAttribute('type') ?? '';
129
- }
130
-
131
- get connected(): boolean {
132
- let at: FakeElement | null = this;
133
- while (at.parentElement !== null) at = at.parentElement;
134
- return at === this.ownerDocument.documentElement;
135
- }
136
-
137
- /** A `src` script or a stylesheet link inserted into the live document "loads". */
138
- private loaded(): void {
139
- if (!this.connected) return;
140
- if (this.tagName === 'SCRIPT')
141
- this.ownerDocument.ran.push(this.getAttribute('src') ?? this.text);
142
- if (this.tagName === 'SCRIPT' || this.tagName === 'LINK') {
143
- const failed = (this.getAttribute('src') ?? this.getAttribute('href') ?? '').includes('fail');
144
- queueMicrotask(() => (failed ? this.onerror?.() : this.onload?.()));
145
- }
146
- }
147
-
148
- append(...nodes: FakeElement[]): void {
149
- for (const node of nodes) {
150
- node.remove();
151
- node.parentElement = this;
152
- node.adopt(this.ownerDocument);
153
- this.children.push(node);
154
- node.loaded();
155
- }
156
- }
157
- remove(): void {
158
- const parent = this.parentElement;
159
- if (parent === null) return;
160
- parent.children = parent.children.filter((child) => child !== this);
161
- this.parentElement = null;
162
- }
163
- replaceWith(node: FakeElement): void {
164
- const parent = this.parentElement;
165
- if (parent === null) return;
166
- node.remove();
167
- const index = parent.children.indexOf(this);
168
- parent.children.splice(index, 1, node);
169
- node.parentElement = parent;
170
- this.parentElement = null;
171
- node.adopt(parent.ownerDocument);
172
- node.loaded();
173
- }
174
- replaceChild(next: FakeElement, old: FakeElement): void {
175
- old.replaceWith(next);
176
- }
177
- adopt(doc: FakeDocument): void {
178
- this.ownerDocument = doc;
179
- for (const child of this.children) child.adopt(doc);
180
- }
181
- contains(node: FakeElement): boolean {
182
- for (let at: FakeElement | null = node; at !== null; at = at.parentElement) {
183
- if (at === this) return true;
184
- }
185
- return false;
186
- }
187
- private descendants(): FakeElement[] {
188
- return this.children.flatMap((child) => [child, ...child.descendants()]);
189
- }
190
- querySelectorAll(selector: string): FakeElement[] {
191
- return this.descendants().filter((el) => matches(el, selector));
192
- }
193
- querySelector(selector: string): FakeElement | null {
194
- return this.querySelectorAll(selector)[0] ?? null;
195
- }
196
- closest(selector: string): FakeElement | null {
197
- for (let at: FakeElement | null = this; at !== null; at = at.parentElement) {
198
- if (matches(at, selector)) return at;
199
- }
200
- return null;
201
- }
202
- focus(): void {
203
- this.ownerDocument.activeElement = this;
204
- }
205
- click(): void {
206
- this.clicks += 1;
207
- }
208
- }
209
-
210
- /** A form the router's `instanceof HTMLFormElement` accepts once installed as that global. */
211
- export class FakeForm extends FakeElement {
212
- fields: [string, string | File][] = [];
213
- }
214
-
215
- export class FakeDocument extends EventTarget {
216
- readonly documentElement: FakeElement;
217
- baseURI = 'https://app.test/';
218
- activeElement: FakeElement | null = null;
219
- /** Every script that "ran": its `src`, or its text for an inline one. */
220
- readonly ran: string[] = [];
221
- startViewTransition?: (update: () => void) => {
222
- updateCallbackDone: Promise<void>;
223
- ready: Promise<void>;
224
- finished: Promise<void>;
225
- };
226
-
227
- constructor(head: readonly FakeElement[] = [], body: readonly FakeElement[] = [], lang = 'en') {
228
- super();
229
- this.documentElement = new FakeElement(this, 'html', { lang });
230
- this.documentElement.append(new FakeElement(this, 'head'), new FakeElement(this, 'body'));
231
- this.head.append(...head.map((el) => this.take(el)));
232
- this.body.append(...body.map((el) => this.take(el)));
233
- }
234
- private take(el: FakeElement): FakeElement {
235
- el.adopt(this);
236
- return el;
237
- }
238
- get head(): FakeElement {
239
- return this.documentElement.children.find((c) => c.tagName === 'HEAD') as FakeElement;
240
- }
241
- get body(): FakeElement {
242
- return this.documentElement.children.find((c) => c.tagName === 'BODY') as FakeElement;
243
- }
244
- get title(): string {
245
- return this.head.querySelector('title')?.textContent ?? '';
246
- }
247
- set title(value: string) {
248
- const el = this.head.querySelector('title');
249
- if (el === null) this.head.append(new FakeElement(this, 'title', {}, value));
250
- else el.textContent = value;
251
- }
252
- createElement(tag: string): FakeElement {
253
- return new FakeElement(this, tag);
254
- }
255
- adoptNode(node: FakeElement): FakeElement {
256
- node.remove();
257
- node.adopt(this);
258
- return node;
259
- }
260
- /** What a test says is under the pointer, for a click the browser aimed at `<html>`. */
261
- underPointer: FakeElement | null = null;
262
- elementFromPoint(): FakeElement | null {
263
- return this.underPointer;
264
- }
265
- getElementById(id: string): FakeElement | null {
266
- return this.documentElement.querySelectorAll('[id]').find((el) => el.id === id) ?? null;
267
- }
268
- querySelector(selector: string): FakeElement | null {
269
- return this.documentElement.querySelector(selector);
270
- }
271
- querySelectorAll(selector: string): FakeElement[] {
272
- return this.documentElement.querySelectorAll(selector);
273
- }
274
- }
275
-
276
- /** `h('a', { href: '/b' }, 'b')` — builds an element owned by a scratch document until adopted. */
277
- const scratch = new FakeDocument();
278
- export const h = (
279
- tag: string,
280
- attrs: Attrs = {},
281
- content: readonly FakeElement[] | string = [],
282
- ): FakeElement =>
283
- tag === 'form'
284
- ? new FakeForm(scratch, tag, attrs, content)
285
- : new FakeElement(scratch, tag, attrs, content);
286
-
287
- /** The head an opted-in document carries, plus whatever the page adds. */
288
- export const routerHead = (
289
- title: string,
290
- extra: readonly FakeElement[] = [],
291
- meta: { surface?: string; build?: string; scope?: string } = {},
292
- ): FakeElement[] => [
293
- h('title', {}, title),
294
- h('meta', { name: 'ultimate-navigation', content: meta.surface ?? 'web:app' }),
295
- h('meta', { name: 'x-ultimate-build', content: meta.build ?? 'b1' }),
296
- ...(meta.scope === undefined ? [] : [h('meta', { name: 'ultimate-scope', content: meta.scope })]),
297
- ...extra,
298
- ];
299
-
300
- interface HistoryEntry {
301
- state: unknown;
302
- url: string;
303
- }
304
-
305
- /** A window: location, history (with a back/forward that fires `popstate`), timers, `fetch`. */
306
- export class FakeWindow extends EventTarget {
307
- document: FakeDocument;
308
- readonly assigned: string[] = [];
309
- readonly entries: HistoryEntry[];
310
- index = 0;
311
- scrollX = 0;
312
- scrollY = 0;
313
- reducedMotion = false;
314
- navigator: { connection?: { saveData?: boolean; effectiveType?: string } } = {};
315
- readonly DOMParser = true;
316
- readonly BroadcastChannel = true;
317
- fetch: (url: string, init?: RequestInit) => Promise<Response>;
318
- __xNavigation?: unknown;
319
-
320
- constructor(doc: FakeDocument, url: string, fetch: FakeWindow['fetch']) {
321
- super();
322
- this.document = doc;
323
- this.entries = [{ state: null, url }];
324
- this.fetch = fetch;
325
- }
326
-
327
- readonly location = {
328
- get href(): string {
329
- return self.entries[self.index]?.url ?? '';
330
- },
331
- assign: (url: string): void => {
332
- this.assigned.push(url);
333
- },
334
- };
335
-
336
- readonly history = {
337
- scrollRestoration: 'auto',
338
- get state(): unknown {
339
- return self.entries[self.index]?.state ?? null;
340
- },
341
- pushState: (state: unknown, _title: string, url?: string): void => {
342
- this.entries.splice(this.index + 1);
343
- this.entries.push({ state, url: url ?? this.location.href });
344
- this.index += 1;
345
- },
346
- replaceState: (state: unknown, _title: string, url?: string): void => {
347
- this.entries[this.index] = { state, url: url ?? this.location.href };
348
- },
349
- };
350
-
351
- /** Back or forward, as a browser does it: the entry moves, then `popstate` fires. */
352
- go(delta: number): void {
353
- this.index += delta;
354
- this.dispatchEvent(Object.assign(new Event('popstate'), { state: this.history.state }));
355
- }
356
-
357
- scrollTo(options: { left: number; top: number }): void {
358
- this.scrollX = options.left;
359
- this.scrollY = options.top;
360
- }
361
- matchMedia(): { matches: boolean } {
362
- return { matches: this.reducedMotion };
363
- }
364
- setTimeout(fn: () => void, ms?: number): number {
365
- return globalThis.setTimeout(fn, ms) as unknown as number;
366
- }
367
- clearTimeout(id: number | undefined): void {
368
- globalThis.clearTimeout(id);
369
- }
370
- }
371
-
372
- // `location` and `history` are object literals whose getters need the window; `self` is set per
373
- // instance by `fakeWindow`, one window per test.
374
- let self: FakeWindow;
375
-
376
- /** The window a test drives; installs the three globals the router reaches for by name. */
377
- export function fakeWindow(doc: FakeDocument, url: string, fetch: FakeWindow['fetch']): FakeWindow {
378
- self = new FakeWindow(doc, url, fetch);
379
- const globals = globalThis as unknown as Record<string, unknown>;
380
- globals['DOMParser'] = FakeParser;
381
- globals['HTMLFormElement'] = FakeForm;
382
- globals['FormData'] = FakeFormData;
383
- globals['HTMLElement'] = FakeElement;
384
- return self;
385
- }
386
-
387
- /** Documents a `DOMParser` answers, keyed by the HTML string a fake `fetch` returned. */
388
- export const parsed = new Map<string, FakeDocument>();
389
-
390
- class FakeParser {
391
- parseFromString(html: string): FakeDocument {
392
- const doc = parsed.get(html);
393
- if (doc === undefined) throw new TypeError(`no fake document registered for ${html}`);
394
- return doc;
395
- }
396
- }
397
-
398
- const RealFormData = globalThis.FormData;
399
- class FakeFormData extends RealFormData {
400
- constructor(form?: FakeForm, submitter?: FakeElement | null) {
401
- super();
402
- for (const [name, value] of form?.fields ?? []) this.append(name, value);
403
- const name = submitter?.getAttribute('name');
404
- if (name !== undefined && name !== null)
405
- this.append(name, submitter?.getAttribute('value') ?? '');
406
- }
407
- }
408
-
409
- /** A registered page: `answer('/b', doc)` is what the fake fetch returns for `/b`. */
410
- export function htmlAnswer(key: string, doc: FakeDocument, init: ResponseInit = {}): Response {
411
- parsed.set(key, doc);
412
- return new Response(key, {
413
- ...init,
414
- headers: {
415
- 'content-type': 'text/html; charset=utf-8',
416
- ...(init.headers as Record<string, string>),
417
- },
418
- });
419
- }
420
-
421
- /** Lets queued microtasks and a few macrotasks run — a swap awaits loads, transitions, scripts. */
422
- export const settle = async (ms = 5): Promise<void> => {
423
- for (let i = 0; i < 5; i += 1) await new Promise((resolve) => setTimeout(resolve, ms));
424
- };