@ultimat3/render 1.2.0 → 3.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/src/head.ts CHANGED
@@ -9,6 +9,8 @@
9
9
 
10
10
  import type { RouteMeta } from '@ultimat3/seo';
11
11
  import { BudgetExceededError } from './errors';
12
+ // `html.ts` is this package's one escaper — a second one is how a character ends up missing.
13
+ import { escapeAttribute, escapeJsonContent, escapeRawTextContent, escapeText } from './html';
12
14
 
13
15
  export type HeadTagKind = 'title' | 'base' | 'meta' | 'link' | 'script' | 'style';
14
16
 
@@ -27,7 +29,13 @@ export type LdRenderer = (meta: RouteMeta) => string | null;
27
29
  export interface HeadRenderers {
28
30
  /** `@ultimat3/seo`'s `renderMeta`, adapted to `HeadTag[]`. */
29
31
  readonly renderMeta: MetaRenderer;
30
- /** `@ultimat3/seo`'s `renderLd`, returning the JSON-LD body or null. */
32
+ /**
33
+ * A host's own JSON-LD body, or null. Deliberately unbound by `seoRenderers()` — `renderMeta`
34
+ * already emits `meta.ld` as one script per node, and a second source for the same tags is how
35
+ * a document ends up with two copies of its graph. It never was `@ultimat3/seo`'s `renderLd`
36
+ * either: that took nodes and returned a `HeadTag`, so it could not satisfy this signature. It
37
+ * was deleted in 1.3.0 for being that second source.
38
+ */
31
39
  readonly renderLd?: LdRenderer;
32
40
  }
33
41
 
@@ -53,12 +61,35 @@ export function mergeHead(...sources: readonly (readonly HeadTag[])[]): readonly
53
61
  }
54
62
 
55
63
  /** Build the head for a route from its `meta` output plus explicit overrides. */
64
+ /**
65
+ * The tags every HTML document needs and no route should have to declare. Merged FIRST, so a
66
+ * route that sets one of these keys still wins.
67
+ *
68
+ * Absent until now, and the omission was not cosmetic: with no `viewport`, a phone lays the page
69
+ * out at ~980px and scales it down, so every deployed Ultimate app rendered zoomed-out on mobile
70
+ * whatever its CSS said. `color-scheme` is the other half of the token layer's dark mode — without
71
+ * it the browser paints its own form controls and scrollbars light under a dark page.
72
+ */
73
+ export const documentBaseline = (): readonly HeadTag[] => [
74
+ { kind: 'meta', key: 'meta:charset', attrs: { charset: 'utf-8' } },
75
+ {
76
+ kind: 'meta',
77
+ key: 'meta:viewport',
78
+ attrs: { name: 'viewport', content: 'width=device-width, initial-scale=1' },
79
+ },
80
+ {
81
+ kind: 'meta',
82
+ key: 'meta:color-scheme',
83
+ attrs: { name: 'color-scheme', content: 'light dark' },
84
+ },
85
+ ];
86
+
56
87
  export function headFromMeta(
57
88
  meta: RouteMeta,
58
89
  renderers: HeadRenderers,
59
90
  overrides: readonly HeadTag[] = [],
60
91
  ): readonly HeadTag[] {
61
- const seoTags = renderers.renderMeta(meta);
92
+ const seoTags = [...documentBaseline(), ...renderers.renderMeta(meta)];
62
93
  const ld = renderers.renderLd?.(meta) ?? null;
63
94
  const ldTags: readonly HeadTag[] =
64
95
  ld === null
@@ -83,21 +114,40 @@ export function renderHead(tags: readonly HeadTag[]): string {
83
114
  function renderTag(tag: HeadTag): string {
84
115
  const attrs = Object.entries(tag.attrs ?? {})
85
116
  .map(([name, value]) =>
86
- value === true ? ` ${name}` : ` ${name}="${escapeAttr(String(value))}"`,
117
+ value === true ? ` ${name}` : ` ${name}="${escapeAttribute(String(value))}"`,
87
118
  )
88
119
  .join('');
89
120
  if (VOID_KINDS.has(tag.kind)) return `<${tag.kind}${attrs}>`;
90
121
  const raw = tag.content ?? '';
91
- const isRaw = tag.kind === 'script' || tag.kind === 'style';
92
- return `<${tag.kind}${attrs}>${isRaw ? raw : escapeText(raw)}</${tag.kind}>`;
122
+ return `<${tag.kind}${attrs}>${contentOf(tag, raw)}</${tag.kind}>`;
93
123
  }
94
124
 
95
- function escapeAttr(value: string): string {
96
- return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
125
+ /**
126
+ * Three contexts, three rules, and the one that was missing was the one attacker text reaches.
127
+ * `script`/`style` are raw text (`escapeRawTextContent`); a JSON-carrying script is data, so it
128
+ * takes the total JSON rule; everything else is HTML text, where a character reference IS decoded.
129
+ * `content` was emitted VERBATIM for script and style until `As of 2026-08`, which made any string
130
+ * reaching `meta.ld` — a title, a product name, a bio — able to close the element.
131
+ */
132
+ function contentOf(tag: HeadTag, raw: string): string {
133
+ if (tag.kind !== 'script' && tag.kind !== 'style') return escapeText(raw);
134
+ return carriesJson(tag) ? escapeJsonContent(raw) : escapeRawTextContent(raw);
97
135
  }
98
136
 
99
- function escapeText(value: string): string {
100
- return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
137
+ /**
138
+ * `application/ld+json`, `application/json`, any `…+json`: the body is data, not code.
139
+ *
140
+ * The MIME parameter is cut before the suffix test, because a real document writes
141
+ * `type="application/ld+json; charset=utf-8"` and that does not end in `json` — so the block built
142
+ * from route data, which is the path attacker text takes, silently took the raw-text escaper
143
+ * instead of the total one. `</` is escaped either way, so this was a weakened boundary rather than
144
+ * a break-out; the JSON rule is total on purpose, and a `charset` is not a reason to leave it.
145
+ */
146
+ function carriesJson(tag: HeadTag): boolean {
147
+ const declared = tag.attrs?.['type'];
148
+ if (typeof declared !== 'string') return false;
149
+ const [type = ''] = declared.split(';');
150
+ return type.trim().toLowerCase().endsWith('json');
101
151
  }
102
152
 
103
153
  export interface ThemeScriptOptions {
package/src/html.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * HTML text and attribute serialization for the server renderer. Escaping lives here and only
3
+ * here: a second escaper is how one of them ends up missing a character, and a missing character
4
+ * in an attribute is an injection.
5
+ */
6
+
7
+ import { safeUrl, URL_ATTRIBUTES } from '@ultimat3/core';
8
+ import type { JsxProps } from './jsx';
9
+
10
+ /** Elements that never carry children, so the writer must not emit a closing tag. */
11
+ export const VOID_ELEMENTS: ReadonlySet<string> = new Set([
12
+ 'area',
13
+ 'base',
14
+ 'br',
15
+ 'col',
16
+ 'embed',
17
+ 'hr',
18
+ 'img',
19
+ 'input',
20
+ 'link',
21
+ 'meta',
22
+ 'param',
23
+ 'source',
24
+ 'track',
25
+ 'wbr',
26
+ ]);
27
+
28
+ export function escapeText(value: string): string {
29
+ return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
30
+ }
31
+
32
+ export function escapeAttribute(value: string): string {
33
+ return escapeText(value).replaceAll('"', '&quot;');
34
+ }
35
+
36
+ /**
37
+ * `<script>` and `<style>` hold RAW TEXT: a character reference is not decoded inside them, so
38
+ * `escapeText` there would ship `&lt;` to a JS or CSS parser and corrupt the code without closing
39
+ * the hole. What actually ends the element is `</` followed by its tag name, and — inside a script
40
+ * only — `<!--` switches the tokenizer into the escaped state where the element's own `</script>`
41
+ * no longer closes it and the rest of the document becomes script text.
42
+ *
43
+ * So the two sequences are made unwritable instead. `\/` and `\!` are the identity escape in a JS
44
+ * string, a JS regex, a CSS string and a CSS url(), which is where a `</` in authored code lives;
45
+ * outside a string neither `</` nor `<!--` is valid code in either language.
46
+ */
47
+ export function escapeRawTextContent(value: string): string {
48
+ return value.replaceAll('</', '<\\/').replaceAll('<!--', '<\\!--');
49
+ }
50
+
51
+ /**
52
+ * The same element, when its content is JSON rather than code — `application/ld+json`, which is
53
+ * built from route data and is therefore the path attacker text takes. JSON gets the total rule:
54
+ * `<` is the same character to `JSON.parse`, so nothing survives that could spell `</script`
55
+ * or `<!--`, and the body stays byte-for-byte valid JSON.
56
+ *
57
+ * Safe by construction because `<`, `>`, `&`, U+2028 and U+2029 can only occur INSIDE a JSON
58
+ * string — no JSON structural token contains one — so every replacement lands where `\u` means
59
+ * an escape. U+2028/U+2029 are legal in a JSON string and illegal in a JS one, and this content
60
+ * is read back by both.
61
+ */
62
+ export function escapeJsonContent(json: string): string {
63
+ return json
64
+ .replaceAll('<', '\\u003c')
65
+ .replaceAll('>', '\\u003e')
66
+ .replaceAll('&', '\\u0026')
67
+ .replaceAll('\u2028', '\\u2028')
68
+ .replaceAll('\u2029', '\\u2029');
69
+ }
70
+
71
+ /**
72
+ * JSX prop name → attribute name. Solid authors write the HTML spelling (`class`, `for`), but the
73
+ * React spellings compile too, and an author who writes one and gets no attribute has a bug with
74
+ * no error message.
75
+ */
76
+ const ATTRIBUTE_ALIASES: Readonly<Record<string, string>> = {
77
+ className: 'class',
78
+ htmlFor: 'for',
79
+ };
80
+
81
+ /** Props the tree consumes rather than emits. `innerHTML` is emitted as content, not an attribute. */
82
+ const NON_ATTRIBUTES: ReadonlySet<string> = new Set([
83
+ 'children',
84
+ 'ref',
85
+ 'key',
86
+ 'innerHTML',
87
+ 'textContent',
88
+ ]);
89
+
90
+ const cssProperty = (name: string): string =>
91
+ name.startsWith('--') ? name : name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
92
+
93
+ /** `{ marginTop: '1rem' }` → `margin-top:1rem`. A string style passes through untouched. */
94
+ export function styleValue(value: unknown): string | null {
95
+ if (typeof value === 'string') return value;
96
+ if (typeof value !== 'object' || value === null) return null;
97
+ const parts = Object.entries(value as Record<string, unknown>)
98
+ .filter(([, item]) => item !== undefined && item !== null && item !== false)
99
+ .map(([name, item]) => `${cssProperty(name)}:${String(item)}`);
100
+ return parts.length === 0 ? null : parts.join(';');
101
+ }
102
+
103
+ /**
104
+ * One prop → one attribute string, or `null` when it emits nothing. Event handlers are dropped
105
+ * rather than stringified: the server has no listeners, and `onclick="function(){…}"` would ship
106
+ * a broken inline handler that looks like it works.
107
+ */
108
+ export function attributePair(name: string, value: unknown): string | null {
109
+ if (NON_ATTRIBUTES.has(name)) return null;
110
+ if (value === undefined || value === null || value === false) return null;
111
+ if (typeof value === 'function') return null;
112
+ if (name.startsWith('on') && name.length > 2) return null;
113
+
114
+ const attribute = ATTRIBUTE_ALIASES[name] ?? name;
115
+ if (value === true) return attribute;
116
+ if (attribute === 'style') {
117
+ const style = styleValue(value);
118
+ return style === null ? null : `style="${escapeAttribute(style)}"`;
119
+ }
120
+ const text = String(value);
121
+ // Escaping makes a value inert inside the quotes; it cannot make a SCHEME inert, because
122
+ // `href="javascript:alert(1)"` never leaves them. One choke point for both, here, because this
123
+ // module is the single place injection is prevented and an href off a database row is the shape
124
+ // every app writes. A refused URL emits no attribute at all — an anchor with no `href` is inert
125
+ // and still renders its text, where a blanked one is a live link nobody checked.
126
+ if (URL_ATTRIBUTES.includes(attribute.toLowerCase())) {
127
+ const url = safeUrl(text, attribute.toLowerCase());
128
+ if (url === null) return null;
129
+ return `${attribute}="${escapeAttribute(url)}"`;
130
+ }
131
+ return `${attribute}="${escapeAttribute(text)}"`;
132
+ }
133
+
134
+ export function renderAttributes(props: JsxProps): string {
135
+ const parts: string[] = [];
136
+ for (const [name, value] of Object.entries(props)) {
137
+ const pair = attributePair(name, value);
138
+ if (pair !== null) parts.push(pair);
139
+ }
140
+ return parts.length === 0 ? '' : ` ${parts.join(' ')}`;
141
+ }
package/src/hydrate.ts CHANGED
@@ -5,10 +5,17 @@
5
5
  * answered instead of swallowed.
6
6
  */
7
7
 
8
+ import { escapeJsonContent } from './html';
8
9
  import type { HydrateStrategy } from './route';
9
10
 
10
11
  export interface IslandDirective {
12
+ /** Unique per INSTANCE: two of the same island on a page need two prop bags to find. */
11
13
  readonly islandId: string;
14
+ /**
15
+ * The client entry this instance came from — the unit a bundle is measured in and a budget
16
+ * counts. Optional only because it arrived after `islandId`; `island()` always sets it.
17
+ */
18
+ readonly moduleId?: string;
12
19
  readonly strategy: HydrateStrategy;
13
20
  /** Build-id-immutable module URL for this island's chunk. */
14
21
  readonly entry: string;
@@ -40,12 +47,16 @@ export function emitIslandAttributes(directive: IslandDirective): string {
40
47
  return attrs.join(' ');
41
48
  }
42
49
 
43
- /** Props travel as a typed JSON script tag, never as an attribute (quoting hazards). */
50
+ /**
51
+ * Props travel as a typed JSON script tag, never as an attribute (quoting hazards). The body is
52
+ * escaped by `html.ts`'s one JSON escaper — this file had its own partial copy, and a second
53
+ * escaper is how one of them ends up missing a character.
54
+ */
44
55
  export function emitIslandProps(directive: IslandDirective): string {
45
56
  if (directive.props === undefined || directive.strategy === 'never') return '';
46
57
  return (
47
58
  `<script type="application/json" data-x-props="${directive.islandId}">` +
48
- `${JSON.stringify(directive.props).replace(/</g, '\\u003c')}</script>`
59
+ `${escapeJsonContent(JSON.stringify(directive.props))}</script>`
49
60
  );
50
61
  }
51
62
 
@@ -60,13 +71,18 @@ export function requiredStrategies(
60
71
  return set;
61
72
  }
62
73
 
74
+ // `el.__x` holds the boot PROMISE, not a boolean flag: it is still "already booting" for the
75
+ // once-only guard, and a second caller now waits for the same mount instead of being handed a
76
+ // resolved promise. As a flag, a second click during the chunk's load short-circuited to
77
+ // `Promise.resolve()`, and the interaction runtime flushed its replay queue into an island that
78
+ // had not mounted — the events went to nothing and the listeners were already removed.
63
79
  const RUNTIME_PRELUDE = `
64
80
  var Q={};
65
81
  function boot(el){var e=el.getAttribute('data-x-entry');
66
- if(!e||el.__x)return Promise.resolve();el.__x=1;
82
+ if(!e)return Promise.resolve();if(el.__x)return el.__x;
67
83
  var p=document.querySelector('script[data-x-props="'+el.getAttribute('data-x-island')+'"]');
68
84
  var props=p?JSON.parse(p.textContent||'{}'):{};
69
- return import(e).then(function(m){return m.mount(el,props)})}
85
+ return el.__x=import(e).then(function(m){return m.mount(el,props)})}
70
86
  function each(s,f){Array.prototype.forEach.call(document.querySelectorAll(s),f)}
71
87
  `.trim();
72
88
 
package/src/index.ts CHANGED
@@ -1,13 +1,29 @@
1
1
  /** Public API of `@ultimat3/render`: the `route` primitive, the five modes, the table. */
2
2
 
3
+ import { installRenderLoader } from './module-loader';
4
+
5
+ // A side effect on import, deliberately: a Bun plugin only transforms modules loaded AFTER it, and
6
+ // every consumer that will ever load a `.tsx` route or a `.scss` module imports this package first
7
+ // (an app's route file imports `defineRoute` from here before it imports anything else it owns).
8
+ // Any later hook — `x dev`, `x build`, `server.ts` — would each have to remember, which is four
9
+ // places one fact can be wrong instead of none.
10
+ installRenderLoader();
11
+
12
+ export type { CompiledStylesheet } from './css-modules';
13
+ export { compileStylesheet, isCssModule, isGlobalStylesheet, scopeClasses } from './css-modules';
3
14
  export type { RenderErrorCode } from './errors';
4
15
  export {
5
16
  BudgetExceededError,
17
+ IslandInvalidError,
18
+ IslandNotHydratedError,
19
+ IslandPropsInvalidError,
6
20
  PrerenderFailedError,
7
21
  RENDER_ERROR_CODES,
8
22
  RENDER_ERROR_TITLES,
9
23
  RouteDuplicateError,
10
24
  RouteFileInvalidError,
25
+ RouteLoadFailedError,
26
+ RouteLoadInvalidError,
11
27
  RouteMetaMissingError,
12
28
  RouteModeInvalidError,
13
29
  RouteOfflineMissingError,
@@ -22,6 +38,7 @@ export type {
22
38
  ThemeScriptOptions,
23
39
  } from './head';
24
40
  export {
41
+ documentBaseline,
25
42
  headFromMeta,
26
43
  mergeHead,
27
44
  renderHead,
@@ -38,6 +55,19 @@ export {
38
55
  hydrateRuntimeBytes,
39
56
  requiredStrategies,
40
57
  } from './hydrate';
58
+ export type { IslandComponent, IslandDeclaration, IslandNode, IslandSpec } from './island';
59
+ export {
60
+ ISLAND_EXTENSION,
61
+ ISLAND_NODE,
62
+ isEmittableSpecifier,
63
+ isIslandNode,
64
+ island,
65
+ islandModuleId,
66
+ } from './island';
67
+ export type { IslandCollector, IslandCollectorInput } from './island-collector';
68
+ export { createIslandCollector, islandModuleIds } from './island-collector';
69
+ export type { IslandProps, JsonValue } from './island-props';
70
+ export { checkIslandProps, ISLAND_PROPS_MAX_BYTES } from './island-props';
41
71
  export type { BudgetReport, BundleGraph, GraphName, Island, RouteBytes } from './islands';
42
72
  export {
43
73
  assertBudget,
@@ -48,14 +78,27 @@ export {
48
78
  parseByteBudget,
49
79
  routeJsBytes,
50
80
  } from './islands';
81
+ export type { JsxComponent, JsxNode, JsxProps } from './jsx';
82
+ export { Fragment, h, isJsxNode, JSX_NODE } from './jsx';
51
83
  export type { ModeCheckContext, ModeSpec, RouteShape } from './modes';
52
84
  export {
53
85
  assertModeInvariants,
54
86
  assertModeShape,
87
+ DEFAULT_ISLAND_JS_BYTES,
55
88
  defaultHydrate,
89
+ defaultIslandBudget,
56
90
  MODE_SPECS,
57
91
  RENDER_MODES,
58
92
  } from './modes';
93
+ export type { Stylesheet } from './module-loader';
94
+ export {
95
+ clearStylesheets,
96
+ installRenderLoader,
97
+ loadStylesheet,
98
+ registeredStylesheets,
99
+ stylesFor,
100
+ transformTsx,
101
+ } from './module-loader';
59
102
  export type {
60
103
  CompiledPattern,
61
104
  RegisterRouteInput,
@@ -75,6 +118,8 @@ export {
75
118
  routeFor,
76
119
  routePathFromFile,
77
120
  } from './registry';
121
+ export type { RenderHtmlOptions } from './render-html';
122
+ export { renderComponent, renderToHtml } from './render-html';
78
123
  export type {
79
124
  IsrController,
80
125
  IsrControllerOptions,
@@ -83,10 +128,11 @@ export type {
83
128
  IsrServeResult,
84
129
  IsrState,
85
130
  IsrStore,
131
+ MemoryIsrStoreOptions,
86
132
  } from './render-isr';
87
-
88
133
  export {
89
134
  createIsrController,
135
+ DEFAULT_ISR_MAX_ENTRIES,
90
136
  invalidateAndRevalidate,
91
137
  memoryIsrStore,
92
138
  parseTtlMs,
@@ -108,6 +154,7 @@ export {
108
154
  export type { StreamHole, StreamOptions, StreamPlan } from './render-stream';
109
155
  export {
110
156
  collectStream,
157
+ DEFAULT_HOLE_TIMEOUT_MS,
111
158
  holeId,
112
159
  holeMarker,
113
160
  REVEAL_SCRIPT,
@@ -117,6 +164,7 @@ export {
117
164
  } from './render-stream';
118
165
  export type {
119
166
  HydrateStrategy,
167
+ LoadRequirement,
120
168
  OfflineStrategy,
121
169
  PrerenderFn,
122
170
  RenderMode,
@@ -124,20 +172,28 @@ export type {
124
172
  RevalidateConfig,
125
173
  RouteBudget,
126
174
  RouteConfig,
175
+ RouteContext,
127
176
  RouteData,
128
177
  RouteDefinition,
129
178
  RouteGuard,
179
+ RouteLoadAsyncFn,
180
+ RouteLoadFn,
130
181
  RouteMetaAsyncFn,
182
+ RouteMetaContext,
131
183
  RouteMetaFn,
132
184
  RouteParams,
133
185
  } from './route';
134
186
  export {
187
+ DEFAULT_ISLAND_HYDRATE,
135
188
  defineRoute,
136
189
  HYDRATE_STRATEGIES,
137
190
  isRouteConfig,
138
191
  OFFLINE_STRATEGIES,
139
192
  tagKeys,
140
193
  } from './route';
194
+ export type { RouteComponent } from './route-component';
195
+ export { pageComponentOf } from './route-component';
196
+ export { metaContextFor, routeDataFor } from './route-data';
141
197
  export type {
142
198
  NavigateOptions,
143
199
  NavigationGuard,
@@ -0,0 +1,148 @@
1
+ /**
2
+ * What one render pulled in. The collector is where an island's timing, its budget and its
3
+ * failure to have either come from the ROUTE rather than from the island — so `hydrate` stays the
4
+ * one place a route says it ships JavaScript, and the directives stay a per-render fact.
5
+ */
6
+
7
+ import { IslandInvalidError, IslandNotHydratedError } from './errors';
8
+ import type { IslandDirective } from './hydrate';
9
+ import { DEFAULT_REPLAY_EVENTS } from './hydrate';
10
+ import type { IslandSpec } from './island';
11
+ import { isEmittableSpecifier, islandNeverDrained } from './island';
12
+ import type { IslandProps } from './island-props';
13
+ import { checkIslandProps } from './island-props';
14
+ import type { JsxProps } from './jsx';
15
+ import type { HydrateStrategy } from './route';
16
+
17
+ /** The distinct client entries a rendered page pulled in — one per module, however many instances. */
18
+ export function islandModuleIds(directives: readonly IslandDirective[]): readonly string[] {
19
+ const ids = new Set<string>();
20
+ for (const directive of directives) ids.add(directive.moduleId ?? directive.islandId);
21
+ return [...ids];
22
+ }
23
+
24
+ export interface IslandCollectorInput {
25
+ /** The route file, so every failure names the file an author has to open. */
26
+ readonly file: string;
27
+ /** The route's `hydrate`. The island inherits it; it never declares one of its own. */
28
+ readonly hydrate: HydrateStrategy;
29
+ /** Specifier → built chunk URL. Identity in dev and in tests; the build supplies the real one. */
30
+ readonly resolve?: (src: string) => string;
31
+ }
32
+
33
+ /**
34
+ * Collects what a single render pulled in. Per render, never module-global: two requests render
35
+ * different params and a shared collector would bill one page for the other's islands.
36
+ */
37
+ export interface IslandCollector {
38
+ readonly file: string;
39
+ readonly hydrate: HydrateStrategy;
40
+ readonly directives: readonly IslandDirective[];
41
+ /** Called by the tree walker once per island instance. Returns what the markup is built from. */
42
+ record(spec: IslandSpec, props: JsxProps): IslandDirective;
43
+ }
44
+
45
+ export function createIslandCollector(input: IslandCollectorInput): IslandCollector {
46
+ const directives: IslandDirective[] = [];
47
+ const entries = new Map<string, string>();
48
+ const resolve = input.resolve ?? ((src: string) => src);
49
+ const strategy = input.hydrate;
50
+
51
+ return {
52
+ file: input.file,
53
+ hydrate: strategy,
54
+ get directives(): readonly IslandDirective[] {
55
+ return directives;
56
+ },
57
+ record(spec: IslandSpec, props: JsxProps): IslandDirective {
58
+ assertHydrates(strategy, spec, input.file);
59
+
60
+ const entry = resolve(spec.src);
61
+ assertEntry(entry, spec, input.file);
62
+ const claimed = entries.get(spec.moduleId);
63
+ if (claimed !== undefined && claimed !== entry) {
64
+ throw new IslandInvalidError(
65
+ `two islands on ${input.file} both resolve to the id ${spec.moduleId} (${claimed} and ` +
66
+ `${entry}), so their props would be handed to whichever the browser found first`,
67
+ `rename one of the modules — the id is its filename, so two islands need two names`,
68
+ );
69
+ }
70
+ entries.set(spec.moduleId, entry);
71
+
72
+ const bag = checkIslandProps(props, spec.propKeys, input.file, spec.moduleId);
73
+ const instance = directives.filter((d) => d.moduleId === spec.moduleId).length + 1;
74
+ const directive = buildDirective(spec, strategy, entry, `${spec.moduleId}-${instance}`, bag);
75
+ directives.push(directive);
76
+ return directive;
77
+ },
78
+ };
79
+ }
80
+
81
+ function buildDirective(
82
+ spec: IslandSpec,
83
+ strategy: HydrateStrategy,
84
+ entry: string,
85
+ islandId: string,
86
+ props: IslandProps,
87
+ ): IslandDirective {
88
+ // Replay defaults to every event a shell can receive: losing the first keystroke in a search box
89
+ // is the same failure as losing the first click, and only the declaration knows which shell it is.
90
+ const events = strategy === 'interaction' ? (spec.events ?? DEFAULT_REPLAY_EVENTS) : spec.events;
91
+ return {
92
+ islandId,
93
+ moduleId: spec.moduleId,
94
+ strategy,
95
+ entry,
96
+ ...(Object.keys(props).length === 0 ? {} : { props }),
97
+ ...(events === undefined ? {} : { events }),
98
+ ...(spec.rootMargin === undefined ? {} : { rootMargin: spec.rootMargin }),
99
+ };
100
+ }
101
+
102
+ /**
103
+ * An island on a route that ships no JS is a button that does nothing — and it is also how the
104
+ * budget stops meaning anything, because `hydrate: 'never'` is what excuses a `site/` route from
105
+ * declaring `budget.js` at all.
106
+ *
107
+ * Still reachable with `hydrate` derived, and for exactly two reasons — so the `fix:` names ONE.
108
+ * `islandNeverDrained` is what tells them apart: a spec still pending at render time was declared
109
+ * where no `defineRoute` could see it, and a spec already drained means an author wrote `'never'`.
110
+ * Offering both edits would make half the instruction wrong for every reader, and leave working
111
+ * out which half is theirs as the reader's job — which is the opposite of axiom 4.
112
+ */
113
+ function assertHydrates(strategy: HydrateStrategy, spec: IslandSpec, file: string): void {
114
+ if (strategy !== 'never') return;
115
+ // Only on the failure path: the happy path returned above and never touches the list.
116
+ const undrained = islandNeverDrained(spec);
117
+ const cause = undrained
118
+ ? `no defineRoute in that module drained the ${spec.moduleId} declaration and the route ` +
119
+ "derived hydrate: 'never'"
120
+ : `the route declares hydrate: 'never'`;
121
+ throw new IslandNotHydratedError(
122
+ `${file} renders the ${spec.moduleId} island but ${cause}, so the browser would receive its ` +
123
+ 'markup and never the JavaScript that makes it do anything',
124
+ undrained
125
+ ? `move the island() call for ${spec.moduleId} above defineRoute in ${file} — a page that ` +
126
+ 'declares an island hydrates on its own'
127
+ : `remove hydrate: 'never' from ${file} — a page that declares an island hydrates on its own`,
128
+ );
129
+ }
130
+
131
+ function assertEntry(entry: string, spec: IslandSpec, file: string): void {
132
+ if (isEmittableSpecifier(entry)) return;
133
+ throw new IslandInvalidError(
134
+ `the resolver returned ${JSON.stringify(entry)} for the ${spec.moduleId} island in ${file}, ` +
135
+ 'which cannot be emitted as a module URL',
136
+ 'fix the resolve() passed to createIslandCollector — it must return a plain URL path',
137
+ );
138
+ }
139
+
140
+ /** Thrown from the walker when an island is rendered with nothing to boot it. */
141
+ export function islandWithoutCollector(spec: IslandSpec): IslandNotHydratedError {
142
+ return new IslandNotHydratedError(
143
+ `the ${spec.moduleId} island was rendered outside a render that collects islands, so no ` +
144
+ 'hydration runtime is emitted and its chunk is never requested',
145
+ 'render the page through renderToHtml(tree, { islands: createIslandCollector({ file, ' +
146
+ 'hydrate }) }) so the island is counted, booted and budgeted',
147
+ );
148
+ }