@lesto/ui 0.1.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/render.tsx ADDED
@@ -0,0 +1,349 @@
1
+ /**
2
+ * tree -> React (+ optionally, the island hydration manifest).
3
+ *
4
+ * The renderer turns a validated JSON tree into a React element against the
5
+ * registry. It degrades safely: an unknown or malformed node renders nothing
6
+ * (and is reported), and a component whose own `render()` throws is contained
7
+ * here — at build time — rather than crashing the whole tree. The renderer
8
+ * itself NEVER throws.
9
+ *
10
+ * Why contain the throw eagerly instead of leaning on a React error boundary?
11
+ * Because boundaries only fire during client reconciliation — server rendering
12
+ * (`renderToStaticMarkup`) lets a throw propagate straight out. A try/catch
13
+ * around each component's render is the one mechanism that holds on both sides.
14
+ *
15
+ * Islands ride the SAME walk. Where a node's `type` names a *client* component,
16
+ * the server cannot run it — it emits a marked wrapper element carrying an
17
+ * optional server `fallback`, and (when building a page) records the mount in a
18
+ * manifest the browser later hydrates. `renderTree` keeps its exact shape and
19
+ * behavior: an island renders its static fallback and is invisible to callers
20
+ * that never asked for a manifest. `renderPage` is the additive door to the
21
+ * manifest.
22
+ *
23
+ * SCOPE (ADR 0011 Increment 2 — the path convergence): this whole `UiNode`/
24
+ * Registry walk is the DEMOTED niche — the AI-/DB-driven content tree, where a
25
+ * `type` is a JSON string a model emitted and an island is collected into one
26
+ * page-wide `#lesto-islands` manifest. The CANONICAL island path is `defineIsland`
27
+ * in a hand-authored `.page` (co-located mount scripts, `hydrateDocumentIslands`,
28
+ * the synthesized `@lesto/assets` client) — that path does NOT come through here.
29
+ * Both emit the byte-identical {@link IslandMount} (the shared `islandMount`
30
+ * author), so the one wire contract and the one set of island invariants serve
31
+ * both; only the EMISSION (page-wide array vs. co-located per island) differs.
32
+ */
33
+
34
+ import { createElement, Fragment } from "react";
35
+ import type { ComponentType, ReactElement, ReactNode } from "react";
36
+ import { renderToStaticMarkup, renderToString } from "react-dom/server";
37
+
38
+ import { ISLAND_ATTR } from "./island";
39
+ import type { ClientComponentDef, IslandMount } from "./island";
40
+ import { islandMount } from "./mount";
41
+ import { isNodeObject } from "./node";
42
+ import { validateProps } from "./props";
43
+ import type { Registry } from "./registry";
44
+
45
+ /**
46
+ * One thing the renderer couldn't render, located by `path`.
47
+ *
48
+ * `type` is the stable diagnostic kind (`invalid_node`, `unknown_component`,
49
+ * `invalid_props`, `render_threw`) — branch on it, never on the prose. `detail`
50
+ * is a human-readable note (the offending component name, the prop-validation
51
+ * message); `cause` carries the actual thrown value for a `render_threw` (the
52
+ * component's own `Error`, or the `UI_ISLAND_PROPS_NOT_SERIALIZABLE` an island's
53
+ * bad prop raised), so an operator gets the real stack rather than a bare
54
+ * "something threw". Both are OPTIONAL — a plain `invalid_node` needs neither —
55
+ * and mirror `validate.ts`'s `TreeError.detail`, so the render walk and the pure
56
+ * tree walk report the same diagnostic shape.
57
+ */
58
+ export interface RenderError {
59
+ path: string;
60
+ type: string;
61
+ detail?: string;
62
+ cause?: unknown;
63
+ }
64
+
65
+ /**
66
+ * The injectable server-render dialect: the two functions {@link renderPageMarkup}
67
+ * needs to serialize a built {@link Page} to body HTML.
68
+ *
69
+ * It is the SAME two-function surface `react-dom/server` exposes (`renderToString`
70
+ * + `renderToStaticMarkup`), narrowed to what this module calls — so the default
71
+ * is a thin pass-through and every existing caller is byte-for-byte unchanged.
72
+ *
73
+ * Why a seam at all? An `ssr: true` island ships its real server render into the
74
+ * shell for the client to `hydrateRoot`, and hydration only succeeds when the
75
+ * server- and client-emitted markup agree. React and Preact emit DIFFERENT
76
+ * hydration markup (notably how they delimit adjacent text segments), so a page
77
+ * whose client bundle is Preact (the opt-in `react`→`preact/compat` alias, ADR
78
+ * 0007) MUST be server-rendered by Preact too, or every `ssr: true` island
79
+ * mismatches on hydration. This seam lets the caller pick the dialect that matches
80
+ * its client; the default keeps the React dialect this engine has always emitted.
81
+ *
82
+ * It mirrors `hydrate.tsx`'s injectable `mount` seam: the thing that varies by
83
+ * runtime is injected, never reached for as a global, so both halves of the
84
+ * hydration contract are chosen by the same explicit decision.
85
+ *
86
+ * The `dialect` tag names which client this renderer's markup is meant to hydrate
87
+ * against (`"react"` / `"preact"`). It is the load-bearing half of ADR 0008's
88
+ * matched pair: the wiring that turns a single `ui.dialect` key into a client
89
+ * alias AND a server renderer compares this tag to the client dialect and refuses
90
+ * a mismatch (client Preact + server React) with a coded error, so the
91
+ * silently-mismatching hydration this seam exists to prevent can never be wired.
92
+ */
93
+ export interface ServerRenderer {
94
+ /** Which client this renderer's markup hydrates against — the matched-pair tag. */
95
+ dialect: "react" | "preact";
96
+ renderToString(node: ReactElement): string;
97
+ renderToStaticMarkup(node: ReactElement): string;
98
+ }
99
+
100
+ /**
101
+ * The default dialect: React's own `react-dom/server`. Statically imported (not
102
+ * lazy) because it is this engine's baseline renderer — the one the tests, the
103
+ * stream path, and the deploy all already use — so there is nothing to defer.
104
+ * Selecting a different dialect (e.g. {@link ./server-preact}) is the caller's
105
+ * explicit opt-in via {@link renderPageMarkup}'s `renderer` argument.
106
+ */
107
+ export const reactServerRenderer: ServerRenderer = {
108
+ dialect: "react",
109
+ renderToStaticMarkup,
110
+ renderToString,
111
+ };
112
+
113
+ /**
114
+ * The mutable scratch a single render walk threads through itself: the errors it
115
+ * accumulates and, only when a page is being built, the island manifest it fills.
116
+ */
117
+ interface Walk {
118
+ registry: Registry;
119
+ errors: RenderError[];
120
+ islands: IslandMount[] | undefined;
121
+ }
122
+
123
+ /** Build the React element for a tree, plus the list of nodes that degraded. */
124
+ export function renderTree(
125
+ registry: Registry,
126
+ tree: unknown,
127
+ ): { element: ReactElement | null; errors: RenderError[] } {
128
+ const walk: Walk = { registry, errors: [], islands: undefined };
129
+
130
+ const element = build(walk, tree, "$");
131
+
132
+ return { element, errors: walk.errors };
133
+ }
134
+
135
+ /** A fully built page: the HTML element tree plus the islands to hydrate. */
136
+ export interface Page {
137
+ element: ReactElement | null;
138
+ errors: RenderError[];
139
+ islands: IslandMount[];
140
+ }
141
+
142
+ /**
143
+ * Render a tree AND collect its island hydration manifest in one walk.
144
+ *
145
+ * This is `renderTree` plus the wire payload: every island in the tree yields an
146
+ * `IslandMount { id, component, props }` whose `id` matches the `data-lesto-island`
147
+ * attribute on its wrapper, so the client can pair DOM to data. Server-only and
148
+ * additive — existing `renderTree` callers are untouched.
149
+ */
150
+ export function renderPage(registry: Registry, tree: unknown): Page {
151
+ const islands: IslandMount[] = [];
152
+
153
+ const walk: Walk = { registry, errors: [], islands };
154
+
155
+ const element = build(walk, tree, "$");
156
+
157
+ return { element, errors: walk.errors, islands };
158
+ }
159
+
160
+ /**
161
+ * Serialize a built {@link Page} to its body HTML, choosing the React server
162
+ * renderer that the page's own hydration contract requires.
163
+ *
164
+ * This is where the framework's headline feature lives or dies. An `ssr: true`
165
+ * island ships its REAL server render into the shell so the client can
166
+ * `hydrateRoot` it — reuse the DOM, no re-render. But `hydrateRoot` matches the
167
+ * server tree to the client tree by walking *text-segment comment markers*
168
+ * (`<!-- -->`) that React emits between adjacent text children. `renderToString`
169
+ * emits those markers; `renderToStaticMarkup` deliberately STRIPS them (its
170
+ * markup is for documents that will never hydrate). Hydrating
171
+ * `renderToStaticMarkup` output therefore mismatches the instant a component
172
+ * renders two or more adjacent text segments under one parent (`'Hi, ', name`),
173
+ * firing `onRecoverableError` and forcing React to re-render the whole island —
174
+ * defeating `ssr: true` entirely. Single-text-child trees happen to survive,
175
+ * which is exactly the trap: the common shape (interpolated text) is the broken
176
+ * one.
177
+ *
178
+ * So the rule is mechanical, never a guess: **if any island in the manifest is
179
+ * `ssr: true`, the body MUST be rendered with `renderToString`** to keep the
180
+ * hydration markers the client needs. A page with no SSR islands (pure static,
181
+ * or only deferred `createRoot` islands whose shells are mounted fresh and never
182
+ * hydrated) uses `renderToStaticMarkup` — smaller, marker-free output, and the
183
+ * deferred shells are replaced wholesale so their markers are irrelevant.
184
+ *
185
+ * A page whose element degraded to `null` has no body to render.
186
+ *
187
+ * The `renderer` is the dialect seam. It DEFAULTS to {@link reactServerRenderer}
188
+ * (real `react-dom/server`), so every existing caller — estate's `document.ts`,
189
+ * the render/hydrate/stream tests — is byte-for-byte unchanged. A page whose
190
+ * client bundle is Preact passes the Preact adapter (`@lesto/ui/server`) so
191
+ * the server emits the same markup the Preact client will hydrate against; only
192
+ * then is an `ssr: true` island safe under the `preact/compat` alias.
193
+ */
194
+ export function renderPageMarkup(
195
+ page: Page,
196
+ renderer: ServerRenderer = reactServerRenderer,
197
+ ): string {
198
+ if (page.element === null) return "";
199
+
200
+ // The manifest is the single source of truth for which renderer is safe: it is
201
+ // the same data the client reads to decide hydrate-vs-mount, so server and
202
+ // client can never disagree about whether markers were emitted. WHICH dialect
203
+ // emits those markers is the `renderer`'s business; this rule (markers iff any
204
+ // ssr island) is identical across dialects.
205
+ const needsHydrationMarkers = page.islands.some((island) => island.ssr);
206
+
207
+ return needsHydrationMarkers
208
+ ? renderer.renderToString(page.element)
209
+ : renderer.renderToStaticMarkup(page.element);
210
+ }
211
+
212
+ /** Recursively build one node into a React element, collecting render errors. */
213
+ function build(walk: Walk, node: unknown, path: string): ReactElement | null {
214
+ // A bare string leaf becomes a text node, wrapped so the return type stays a
215
+ // uniform ReactElement (callers get ReactElement | null, never raw strings).
216
+ if (typeof node === "string") {
217
+ return createElement(Fragment, { key: path }, node);
218
+ }
219
+
220
+ // Malformed: not a string, not a node object. Render nothing, report it.
221
+ if (!isNodeObject(node)) {
222
+ walk.errors.push({ path, type: "invalid_node", detail: "node must be a string or an object" });
223
+
224
+ return null;
225
+ }
226
+
227
+ // An island short-circuits the server component path: the client owns it.
228
+ const client = walk.registry.getClient(node.type);
229
+
230
+ if (client !== undefined) {
231
+ return buildIsland(walk, client, node.props ?? {}, path);
232
+ }
233
+
234
+ const def = walk.registry.get(node.type);
235
+
236
+ // Unknown component: nothing vetted to render. Degrade to nothing.
237
+ if (def === undefined) {
238
+ walk.errors.push({ path, type: "unknown_component", detail: node.type });
239
+
240
+ return null;
241
+ }
242
+
243
+ // Validate props as the pure `validateTree` walk does, but here in the render
244
+ // path too: a missing-required or out-of-enum prop was previously DROPPED on
245
+ // the floor (only the parallel `validateTree` reported it), so a renderer-only
246
+ // caller got a silently-degraded component with no diagnostic. Surface each as
247
+ // an `invalid_props` (the same kind `validate.ts` emits), then render with the
248
+ // reconciled bag — the render still proceeds (defaults applied, the bad value
249
+ // dropped), it is just no longer silent.
250
+ const { props, errors: propErrors } = validateProps(def.props, node.props ?? {});
251
+
252
+ for (const detail of propErrors) {
253
+ walk.errors.push({ path, type: "invalid_props", detail });
254
+ }
255
+
256
+ const childNodes: ReactNode = (node.children ?? []).map((child, index) =>
257
+ build(walk, child, `${path}.children[${index}]`),
258
+ );
259
+
260
+ // Contain the component's own render: a throw becomes a reported diagnostic,
261
+ // never an exception that escapes the walk.
262
+ return safeRender(def.render, props, childNodes, path, walk.errors);
263
+ }
264
+
265
+ /**
266
+ * Build an island's server footprint: a marked wrapper element holding either the
267
+ * fallback (deferred island) or the component's real output (an `ssr` island),
268
+ * plus (when a page is being built) its manifest entry.
269
+ *
270
+ * Props are validated against the client schema and asserted serializable; a
271
+ * non-serializable prop is contained as a `render_threw` diagnostic carrying the
272
+ * raised `UiError` as its `cause`, exactly like a server component that throws,
273
+ * so the island degrades to nothing rather than crashing the surrounding page.
274
+ *
275
+ * The shell contents are the crux of the hydration contract. A deferred island
276
+ * (`ssr` falsy) holds only the fallback — the client mounts the live component
277
+ * fresh, so the two need not match. An `ssr` island holds the component's actual
278
+ * server render, which the client then `hydrateRoot`s — the markup MUST match, so
279
+ * the same `component` runs on both sides with the same wire props. The `ssr`
280
+ * flag rides into the manifest so the client picks the right mount without
281
+ * guessing.
282
+ */
283
+ function buildIsland(
284
+ walk: Walk,
285
+ client: ClientComponentDef,
286
+ rawProps: Record<string, unknown>,
287
+ path: string,
288
+ ): ReactElement | null {
289
+ try {
290
+ // The mount shape (validated + serializable props, ssr, the optional
291
+ // strategy/bind) is authored once in `islandMount`, shared with the `.page`
292
+ // path's `defineIsland` so the two emit byte-identical wire entries. Building
293
+ // it is the serialize guard `buildIsland` contains — a non-serializable prop
294
+ // throws here and the island is reported, never crashing the page render.
295
+ const { mount, props } = islandMount(client, rawProps, path);
296
+
297
+ // Only a page build cares about the manifest; `renderTree` leaves it absent.
298
+ walk.islands?.push(mount);
299
+
300
+ // An `ssr` island ships its REAL output (the markup the client will hydrate
301
+ // and find unchanged); a deferred island ships only its fallback placeholder.
302
+ //
303
+ // The ssr component is placed LAZILY (`createElement`, not an eager call):
304
+ // an island's `component` is a full React component that may use hooks, so it
305
+ // can only be run by React's renderer (during the caller's
306
+ // `renderToStaticMarkup`), never invoked as a plain function here. This keeps
307
+ // the renderer's invariant honest — *building* the element tree never throws;
308
+ // the island's own render runs when React renders the tree.
309
+ const contents: ReactNode = mount.ssr
310
+ ? createElement(client.component as ComponentType<Record<string, unknown>>, mount.props)
311
+ : (client.fallback?.(props) as ReactNode);
312
+
313
+ return createElement("div", { key: path, [ISLAND_ATTR]: path }, contents);
314
+ } catch (error) {
315
+ // Carry the actual throw (an island's `UI_ISLAND_PROPS_NOT_SERIALIZABLE`, or
316
+ // a coded `UI_ISLAND_SSR_DATA_UNRESOLVED`) as `cause` so an operator sees the
317
+ // real error, plus its message as `detail` — instead of a bare "threw".
318
+ walk.errors.push({ path, type: "render_threw", detail: errorDetail(error), cause: error });
319
+
320
+ return null;
321
+ }
322
+ }
323
+
324
+ /** A human-readable note for a thrown value: its `.message` if it has one, else its `String`. */
325
+ function errorDetail(error: unknown): string {
326
+ return error instanceof Error ? error.message : String(error);
327
+ }
328
+
329
+ /** Invoke a component's render, containing any throw as a reported error. */
330
+ function safeRender(
331
+ render: (props: Record<string, unknown>, children: ReactNode) => ReactElement,
332
+ props: Record<string, unknown>,
333
+ children: ReactNode,
334
+ path: string,
335
+ errors: RenderError[],
336
+ ): ReactElement | null {
337
+ try {
338
+ const element = render(props, children);
339
+
340
+ // Re-key so React can place sibling elements without a missing-key warning.
341
+ return createElement(Fragment, { key: path }, element);
342
+ } catch (error) {
343
+ // The component's own throw, carried (not swallowed): `cause` is the real
344
+ // error for an operator's stack, `detail` its message for a quick read.
345
+ errors.push({ path, type: "render_threw", detail: errorDetail(error), cause: error });
346
+
347
+ return null;
348
+ }
349
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Resource hints over React 19's native resource APIs.
3
+ *
4
+ * React 19 turns `preload`/`preinit`/`preconnect`/`prefetchDNS` (from `react-dom`)
5
+ * and document-metadata tags into real `<link>`/`<script>` elements during SSR —
6
+ * and it does so even under the framework's buffered `renderToStaticMarkup`
7
+ * render, hoisting them to the FRONT of the emitted markup. That is the whole
8
+ * Tier-0 win: the browser learns about the LCP image, fonts, and island bundles
9
+ * before it has finished parsing the body, with no streaming and no new transport.
10
+ *
11
+ * This module is a thin, Lesto-flavored seam over those primitives. Two reasons it
12
+ * exists rather than telling callers to import `react-dom` directly:
13
+ * - the `react-dom` resource functions are imperative side effects that only
14
+ * register a hint while a render is in flight; wrapping them keeps the import
15
+ * surface small and lets us inject a fake registrar in tests (no real render
16
+ * needed to prove we called the right thing with the right options);
17
+ * - the LCP-image and island-bundle conventions (`fetchpriority="high"`,
18
+ * `<link rel="modulepreload">`) are Lesto decisions, not React ones, and belong
19
+ * in one named place.
20
+ *
21
+ * The element-returning helpers (`lcpImage`, `modulePreload`) emit React elements
22
+ * the caller drops into the tree; React hoists the `<link>`s itself. The
23
+ * imperative helpers (`preload`/`preinit`/…) register a hint as a side effect and
24
+ * return nothing — call them from inside a component's render.
25
+ */
26
+
27
+ import { createElement } from "react";
28
+ import type { ReactElement } from "react";
29
+ import {
30
+ preconnect as reactPreconnect,
31
+ prefetchDNS as reactPrefetchDNS,
32
+ preinit as reactPreinit,
33
+ preinitModule as reactPreinitModule,
34
+ preload as reactPreload,
35
+ } from "react-dom";
36
+
37
+ /**
38
+ * The subset of React's resource API we lean on, named as one seam so a test can
39
+ * substitute a recorder and assert the exact calls without rendering. The shapes
40
+ * mirror `react-dom`'s own (intentionally loose option bags — React validates).
41
+ */
42
+ export interface ResourceRegistrar {
43
+ preload: (href: string, options: PreloadOptions) => void;
44
+ preinit: (href: string, options: PreinitOptions) => void;
45
+ preinitModule: (href: string, options?: PreinitModuleOptions) => void;
46
+ preconnect: (href: string, options?: PreconnectOptions) => void;
47
+ prefetchDNS: (href: string) => void;
48
+ }
49
+
50
+ /** Options for a `preload` hint — `as` is required; the rest mirror react-dom. */
51
+ export interface PreloadOptions {
52
+ as:
53
+ | "audio"
54
+ | "document"
55
+ | "embed"
56
+ | "fetch"
57
+ | "font"
58
+ | "image"
59
+ | "object"
60
+ | "script"
61
+ | "style"
62
+ | "track"
63
+ | "video"
64
+ | "worker";
65
+ crossOrigin?: "anonymous" | "use-credentials";
66
+ integrity?: string;
67
+ type?: string;
68
+ fetchPriority?: "high" | "low" | "auto";
69
+ imageSrcSet?: string;
70
+ imageSizes?: string;
71
+ }
72
+
73
+ /** Options for a `preinit` hint — load AND execute/apply a script or stylesheet. */
74
+ export interface PreinitOptions {
75
+ as: "script" | "style";
76
+ precedence?: string;
77
+ crossOrigin?: "anonymous" | "use-credentials";
78
+ integrity?: string;
79
+ fetchPriority?: "high" | "low" | "auto";
80
+ }
81
+
82
+ /** Options for a `preinitModule` hint — load AND execute an ES module. */
83
+ export interface PreinitModuleOptions {
84
+ as?: "script";
85
+ crossOrigin?: "anonymous" | "use-credentials";
86
+ integrity?: string;
87
+ }
88
+
89
+ /** Options for a `preconnect` hint — open the connection early. */
90
+ export interface PreconnectOptions {
91
+ crossOrigin?: "anonymous" | "use-credentials";
92
+ }
93
+
94
+ /** The real react-dom functions, the default registrar everywhere but tests. */
95
+ const reactRegistrar: ResourceRegistrar = {
96
+ preload: reactPreload,
97
+ preinit: reactPreinit,
98
+ preinitModule: reactPreinitModule,
99
+ preconnect: reactPreconnect,
100
+ prefetchDNS: reactPrefetchDNS,
101
+ };
102
+
103
+ /**
104
+ * Register a `preload` hint: fetch `href` early at the given priority, but do not
105
+ * execute it. The browser caches it for the moment the parser reaches the real
106
+ * reference. React hoists the resulting `<link rel="preload">` into the document.
107
+ */
108
+ export function preload(
109
+ href: string,
110
+ options: PreloadOptions,
111
+ registrar: ResourceRegistrar = reactRegistrar,
112
+ ): void {
113
+ registrar.preload(href, options);
114
+ }
115
+
116
+ /**
117
+ * Register a `preinit` hint: fetch AND execute/apply `href` early. Use it for a
118
+ * critical script or stylesheet whose effect the page needs as soon as possible.
119
+ */
120
+ export function preinit(
121
+ href: string,
122
+ options: PreinitOptions,
123
+ registrar: ResourceRegistrar = reactRegistrar,
124
+ ): void {
125
+ registrar.preinit(href, options);
126
+ }
127
+
128
+ /**
129
+ * Register a `preinitModule` hint: fetch AND execute an ES module early. This
130
+ * EXECUTES the module — for execute-free hint use {@link modulePreload} instead.
131
+ */
132
+ export function preinitModule(
133
+ href: string,
134
+ options?: PreinitModuleOptions,
135
+ registrar: ResourceRegistrar = reactRegistrar,
136
+ ): void {
137
+ // Forward `options` only when given: react-dom's signature makes it optional,
138
+ // and passing an explicit `undefined` under exactOptionalPropertyTypes is noise.
139
+ if (options === undefined) {
140
+ registrar.preinitModule(href);
141
+
142
+ return;
143
+ }
144
+
145
+ registrar.preinitModule(href, options);
146
+ }
147
+
148
+ /**
149
+ * Register a `preconnect` hint: open the TCP+TLS connection to `href`'s origin
150
+ * before the first request to it, shaving the handshake off the critical path.
151
+ */
152
+ export function preconnect(
153
+ href: string,
154
+ options?: PreconnectOptions,
155
+ registrar: ResourceRegistrar = reactRegistrar,
156
+ ): void {
157
+ if (options === undefined) {
158
+ registrar.preconnect(href);
159
+
160
+ return;
161
+ }
162
+
163
+ registrar.preconnect(href, options);
164
+ }
165
+
166
+ /**
167
+ * Register a `prefetchDNS` hint: resolve `href`'s DNS early. Cheaper than
168
+ * `preconnect` (no socket), for an origin you are likely — but not certain — to hit.
169
+ */
170
+ export function prefetchDNS(href: string, registrar: ResourceRegistrar = reactRegistrar): void {
171
+ registrar.prefetchDNS(href);
172
+ }
173
+
174
+ /** Optional attributes for {@link lcpImage} beyond the load-bearing ones. */
175
+ export interface LcpImageProps {
176
+ src: string;
177
+ alt: string;
178
+ width?: number;
179
+ height?: number;
180
+ className?: string;
181
+ sizes?: string;
182
+ srcSet?: string;
183
+ }
184
+
185
+ /**
186
+ * The hero image, marked as the Largest Contentful Paint candidate.
187
+ *
188
+ * `fetchpriority="high"` tells the browser to prioritize this fetch over the
189
+ * default-`low` images; React 19 *also* auto-emits a `<link rel="preload"
190
+ * as="image" fetchpriority="high">` from an `<img fetchPriority>` during SSR, so
191
+ * the hint reaches the browser in the document head before the body is parsed —
192
+ * the single highest-ROI LCP lever. We never lazy-load the LCP image (`loading`
193
+ * stays eager) for the same reason: deferring the most important paint is a
194
+ * regression dressed as an optimization.
195
+ */
196
+ export function lcpImage(props: LcpImageProps): ReactElement {
197
+ return createElement("img", {
198
+ src: props.src,
199
+ alt: props.alt,
200
+ // React 19 emits this in its DOM-property casing (`fetchPriority`), which the
201
+ // browser reads case-insensitively; it also auto-emits a `<link rel="preload"
202
+ // as="image">` from an `<img fetchPriority>`. Marking the LCP image is the one
203
+ // place this attribute earns its keep.
204
+ fetchPriority: "high",
205
+ // Spell the load behavior out: an eager LCP image is the point.
206
+ loading: "eager",
207
+ decoding: "async",
208
+ ...(props.width === undefined ? {} : { width: props.width }),
209
+ ...(props.height === undefined ? {} : { height: props.height }),
210
+ ...(props.className === undefined ? {} : { className: props.className }),
211
+ ...(props.sizes === undefined ? {} : { sizes: props.sizes }),
212
+ ...(props.srcSet === undefined ? {} : { srcSet: props.srcSet }),
213
+ });
214
+ }
215
+
216
+ /**
217
+ * A `<link rel="modulepreload">` for an island's client bundle.
218
+ *
219
+ * `modulepreload` fetches AND compiles a module graph WITHOUT executing it — the
220
+ * right hint for code that runs only after hydration. (Contrast `preinitModule`,
221
+ * which executes.) React hoists this `<link>` into the head, so the island's JS
222
+ * is parsed and ready the moment `hydrateIslands` reaches for it.
223
+ */
224
+ export function modulePreload(
225
+ href: string,
226
+ crossOrigin?: "anonymous" | "use-credentials",
227
+ ): ReactElement {
228
+ return createElement("link", {
229
+ rel: "modulepreload",
230
+ href,
231
+ ...(crossOrigin === undefined ? {} : { crossOrigin }),
232
+ });
233
+ }
package/src/route.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `route(pattern, params)` — build a typed URL from a known route pattern.
3
+ *
4
+ * The compile-time-CHECKED companion to `<Link>`: where `href` only autocompletes
5
+ * (a typo still compiles), `route` CONSTRAINS its pattern argument to the app's
6
+ * registered route patterns — a typo'd or stale pattern is a `tsc` error — and types
7
+ * `params` from the pattern's `:segments` via `@lesto/router`'s `PathParams`. It
8
+ * returns a `string` assignable to a `<Link href>`, so the type-safe dynamic link is:
9
+ *
10
+ * <Link href={route("/lab/gallery/:id", { id: listing.id })}>{listing.title}</Link>
11
+ * route("/lab/gallery") // a param-less pattern takes no second argument
12
+ *
13
+ * This is the higher-value half of typed routing (ADR/`docs/plans/dx-parity.md`,
14
+ * Workstream 1 Increment 2): `<Link href>` made navigation route-AWARE; `route` makes
15
+ * a dynamic link route-SAFE. With no route codegen the pattern is any `string` and the
16
+ * params are unconstrained — the unchanged escape hatch, nothing breaks.
17
+ *
18
+ * Authored as a plain pure function with native string ops, so it stays in
19
+ * `@lesto/ui`'s isomorphic, react-free core alongside `<Link>` (its sibling on the
20
+ * link-authoring surface).
21
+ */
22
+
23
+ import { UiError } from "./errors";
24
+ import type { KnownPatterns, ParamArgs } from "./routes";
25
+
26
+ /**
27
+ * The pattern argument: the app's known patterns when codegen has augmented
28
+ * `RegisteredRoutes`, else any `string` (unchanged). The `[KnownPatterns] extends
29
+ * [never]` tuple guards the empty case without distributing over the union.
30
+ */
31
+ type PatternArg = [KnownPatterns] extends [never] ? string : KnownPatterns;
32
+
33
+ /**
34
+ * Build a URL from a route pattern, substituting each `:name` segment from `params`
35
+ * (URL-encoded). The pattern is constrained to the app's {@link KnownPatterns} and the
36
+ * params are typed via {@link ParamArgs} from the pattern's `:segments` — so a typo or
37
+ * a missing/mistyped param is a compile error.
38
+ *
39
+ * The runtime missing-param guard (a coded {@link UiError}) is unreachable through the
40
+ * types, but an untyped JS caller could trip it — mirrors `@lesto/client`'s
41
+ * `applyParams`, the same `:param`-substitution this is the navigation twin of.
42
+ */
43
+ export function route<P extends PatternArg>(pattern: P, ...args: ParamArgs<P>): string {
44
+ const params = args[0] as Record<string, string | number> | undefined;
45
+
46
+ return pattern.replace(/:([A-Za-z0-9_]+)/g, (_match, name: string) => {
47
+ const value = params?.[name];
48
+
49
+ if (value === undefined) {
50
+ throw new UiError(
51
+ "UI_ROUTE_MISSING_PARAM",
52
+ `route "${pattern}" needs a value for ":${name}"`,
53
+ {
54
+ pattern,
55
+ param: name,
56
+ },
57
+ );
58
+ }
59
+
60
+ return encodeURIComponent(String(value));
61
+ });
62
+ }