@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/index.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * @lesto/ui — the AI-native UI rendering engine's ISOMORPHIC core.
3
+ *
4
+ * const registry = new Registry()
5
+ * .define({ name: "Box", props: {}, children: true, render: (_p, kids) => <div>{kids}</div> });
6
+ *
7
+ * const schema = treeJsonSchema(registry); // constrain the model's output
8
+ * const catalog = componentCatalog(registry); // describe it in the prompt
9
+ *
10
+ * const tree = { type: "Box", children: ["hello"] }; // the AI emits plain JSON
11
+ *
12
+ * const { valid, errors } = validateTree(registry, tree); // pure, React-free
13
+ *
14
+ * This barrel is deliberately isomorphic: NOTHING here imports `react-dom/server`,
15
+ * so a client bundle that pulls `@lesto/ui` (for `Registry`, `defineIsland`, the
16
+ * island/data tokens) never drags React's ~60 KB server renderer into the
17
+ * browser. The server-render surface — `renderPage`/`renderPageMarkup`/`renderTree`,
18
+ * the streaming renderers, and the React/Preact server dialects — lives behind the
19
+ * `@lesto/ui/server` subpath; the browser-only hydration runtime behind
20
+ * `@lesto/ui/client`. Mirrors `react-dom`'s own server/client split (ADR 0008).
21
+ */
22
+
23
+ export { Registry } from "./registry";
24
+
25
+ export { validateProps } from "./props";
26
+
27
+ export { componentCatalog, treeJsonSchema } from "./schema";
28
+
29
+ export { validateTree } from "./validate";
30
+ export type { TreeError } from "./validate";
31
+
32
+ // The server-render surface (`renderPage`/`renderPageMarkup`/`renderTree`, the
33
+ // streaming renderers, the React/Preact `ServerRenderer` dialects) is NOT
34
+ // re-exported here — it imports `react-dom/server` and so lives behind the
35
+ // `@lesto/ui/server` subpath, keeping this barrel client-safe.
36
+
37
+ export { assertClientDef, island, ISLAND_ATTR, ISLAND_MOUNT_ATTR } from "./island";
38
+ export type { ClientComponentDef, HydrationStrategy, IslandMount } from "./island";
39
+
40
+ // Self-describing islands for the `.page` path (ADR 0011) — THE CANONICAL island
41
+ // authoring path. A component that emits its own shell + co-located mount script
42
+ // + data primer, so islands need no page-wide manifest walk. Every `.page`/
43
+ // `lesto()` app (estate, blog) authors islands this way; its client half is
44
+ // `hydrateDocumentIslands` (the `@lesto/ui/client` subpath), and `@lesto/assets`
45
+ // synthesizes the client entry from a one-`defineIsland`-per-file `app/islands/`
46
+ // convention. The Registry/`island()`/`renderPage`/`hydrateIslands` array form
47
+ // below is now the DEMOTED niche (ADR 0011 Increment 2): it serves the AI-/DB-
48
+ // driven `UiNode` content tree, where a `type` is a JSON string a model emitted
49
+ // and the page is a walked manifest — NOT a hand-authored React `.page`.
50
+ export { defineIsland } from "./define-island";
51
+ export type { IslandComponent, IslandDef } from "./define-island";
52
+
53
+ export { islandMount } from "./mount";
54
+
55
+ // Island data sources (ADR 0010): declared data, framework-owned delivery.
56
+ // `defineDataSource` is the isomorphic token; `dataSourceHref`/`DATA_ROUTE_PREFIX`
57
+ // + `dataPrimerScript` are the STATIC-tier server delivery seams (the client half
58
+ // lives in `@lesto/ui/client`'s hydration runtime).
59
+ export { DATA_ROUTE_PREFIX, dataPrimerScript, dataSourceHref, defineDataSource } from "./data";
60
+ export type { DataSource, DataSourceScope, IslandBind } from "./data";
61
+
62
+ // The render-time source resolver (ADR 0012): the DYNAMIC-tier delivery that runs
63
+ // loaders during the render and inlines the values — feeding the canonical
64
+ // `ssr: true` island's server markup. Server-only (it wraps the page tree in a
65
+ // React context the `.page` renderer provides); NOT in the client barrel.
66
+ export { createSourceResolver, IslandDataContext, IslandDataProvider } from "./data-resolve";
67
+ export type { SourceResolver } from "./data-resolve";
68
+
69
+ // Client data hooks (the first step of the reactive data layer, ADR 0027):
70
+ // `useQuery`/`useMutation` over one shared cache giving in-flight dedupe +
71
+ // explicit-by-key invalidation — NOT yet the schema-inferred invalidation a
72
+ // later phase may add. Decoupled from
73
+ // `@lesto/client` (the caller passes the fetcher/mutationFn thunk), so they stay
74
+ // in this isomorphic core; the fetch is kicked from an effect, so SSR never fetches.
75
+ export {
76
+ defaultQueryClient,
77
+ QueryClient,
78
+ serializeQueryKey,
79
+ useMutation,
80
+ useQuery,
81
+ } from "./data-client";
82
+ export type {
83
+ MutationOptions,
84
+ MutationResultApi,
85
+ MutationStatus,
86
+ QueryKey,
87
+ QueryResult,
88
+ QuerySnapshot,
89
+ QueryStatus,
90
+ } from "./data-client";
91
+
92
+ // The audited seam for inlining island JSON into a `<script>`: escapes the
93
+ // breakout characters `JSON.stringify` leaves raw. ALL island-manifest emission
94
+ // MUST go through this — never a bare stringify or a `String.replace` splice.
95
+ // `serializeScriptJson` is the canonical per-island form (one mount object, used
96
+ // by `defineIsland`'s co-located mount script); `serializeManifest` is the
97
+ // page-wide ARRAY form, now scoped to the DEMOTED Registry/`UiNode` content path
98
+ // (ADR 0011 Increment 2) where `renderPage` collects every island into one
99
+ // `#lesto-islands` manifest.
100
+ export { serializeManifest, serializeScriptJson } from "./serialize";
101
+
102
+ // Resource hints + LCP/modulepreload conventions over React 19's native APIs.
103
+ // Server-safe: the `react-dom` resource functions are isomorphic and only emit
104
+ // markup during an SSR render.
105
+ export {
106
+ lcpImage,
107
+ modulePreload,
108
+ preconnect,
109
+ prefetchDNS,
110
+ preinit,
111
+ preinitModule,
112
+ preload,
113
+ } from "./resources";
114
+ export type {
115
+ LcpImageProps,
116
+ PreconnectOptions,
117
+ PreinitModuleOptions,
118
+ PreinitOptions,
119
+ PreloadOptions,
120
+ ResourceRegistrar,
121
+ } from "./resources";
122
+
123
+ // Document metadata helpers + the dedup convention React's hoist-without-dedupe
124
+ // leaves to the framework. Pure: React elements / data in, data out.
125
+ export { dedupeMetadata, link, meta, renderMetadata, renderMetadataEntry, title } from "./metadata";
126
+ export type {
127
+ CharsetMeta,
128
+ LinkSpec,
129
+ MetadataEntry,
130
+ MetaSpec,
131
+ NamedMeta,
132
+ PropertyMeta,
133
+ } from "./metadata";
134
+
135
+ // Soft navigation's authoring half (ADR 0024): `<Link>` is an ordinary `<a>` that
136
+ // the client runtime upgrades to a fetch-and-swap when present, and a normal
137
+ // navigation when not — so it is isomorphic (renders the same anchor on server and
138
+ // client) and lives in this core barrel. It pulls only the DOM-FREE contract
139
+ // (`./softnav-contract`: the opt-out attribute + the click/anchor shapes both
140
+ // halves read), never the browser runtime — so a server build that imports
141
+ // `@lesto/ui` for `<Link>` drags in no `fetch`/`DOMParser`/`document`. The RUNTIME
142
+ // half (`enableSoftNav` + its injection seams) is browser-only and lives behind
143
+ // `@lesto/ui/client`.
144
+ export { Link, StrictLink } from "./link";
145
+ export type { LinkProps, StrictLinkProps } from "./link";
146
+ // The app-facing typed-route surface is `route` (the checked link builder), `Link` +
147
+ // `StrictLink` (lenient / strict href), the `RegisteredRoutes` augmentation target, and
148
+ // the `RouteHref` / `StrictRouteHref` href types. The helpers `HrefFor` / `StrictHrefFor`
149
+ // / `PatternsOf` / `KnownPatterns` / `ParamArgs` stay exported from `./routes` for
150
+ // `route.ts` and the type-gate (which maps `@lesto/ui` → `routes.ts` directly), but are
151
+ // not in the public barrel — nothing an app author needs.
152
+ export { route } from "./route";
153
+ export type { RegisteredRoutes, RouteHref, StrictRouteHref } from "./routes";
154
+ export { eligibleAnchor, LAYOUT_ATTR, PREFETCH_ATTR, RELOAD_ATTR } from "./softnav-contract";
155
+ export type { PrefetchStrategy, SoftNavAnchor, SoftNavClick } from "./softnav-contract";
156
+
157
+ // The hydration runtime and bfcache-safe lifecycle are browser-only (they touch
158
+ // `document`/`window`), so they live behind the `@lesto/ui/client` subpath —
159
+ // server-side importers of `@lesto/ui` never pull DOM code into a build without
160
+ // the DOM lib. Mirrors react-dom's server/client split.
161
+
162
+ export { LestoError, UiError } from "./errors";
163
+ export type { UiErrorCode } from "./errors";
164
+
165
+ export type { ChildrenPolicy, ComponentDef, PropSpec, PropType, UiNode } from "./types";
package/src/island.ts ADDED
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Islands — client-hydrated regions inside an otherwise static tree.
3
+ *
4
+ * The premise of *auth-aware static*: a page is prerendered to HTML once, but a
5
+ * few regions ("My Account", a cart count, a live price) must resolve on the
6
+ * client, per-visitor, after hydration. An island is the boundary between the
7
+ * two worlds.
8
+ *
9
+ * The author's experience stays uniform: an island is an ordinary `UiNode`. Its
10
+ * `type` names a *client* component the registry knows about, and its `props`
11
+ * are plain JSON that will cross the server -> client wire. The `island(...)`
12
+ * helper is sugar for writing that node by hand.
13
+ *
14
+ * A client component is declared with `defineClient` and differs from a server
15
+ * `ComponentDef` in three honest ways:
16
+ * - it carries no server `render`; the only thing the server can emit is an
17
+ * optional `fallback` placeholder (skeleton, last-known value, nothing);
18
+ * - its real implementation is a React `component`, mounted on the client;
19
+ * - the engine treats its `props` as a wire payload — they MUST be
20
+ * JSON-serializable, since a function or a class instance cannot survive the
21
+ * trip to the browser.
22
+ *
23
+ * Two flavors of island, distinguished by `ssr`:
24
+ * - **Deferred (default, `ssr` absent/false)** — the classic auth-aware-static
25
+ * island. The server CANNOT render the real component (it depends on the
26
+ * signed-in user the prerender never knew), so it ships only the `fallback`,
27
+ * and the client mounts the live component *fresh* (`createRoot`), swapping
28
+ * the fallback for per-visitor truth. `hydrateRoot` would throw a mismatch
29
+ * here, because the shell never held the component's real output.
30
+ * - **SSR-able (`ssr: true`)** — the author asserts the server CAN render the
31
+ * real component and that the client render will MATCH it. The server emits
32
+ * the component's actual output into the shell, and the client `hydrateRoot`s
33
+ * it: real hydration that reuses the server DOM and gains React 19's
34
+ * hydration resilience, with no re-render and no mismatch.
35
+ *
36
+ * The default is deferred because that is the safe one: declaring `ssr` is an
37
+ * explicit promise that server and client agree, and a broken promise is a
38
+ * hydration mismatch — worse than a fresh mount. We never assume it.
39
+ */
40
+
41
+ import type { ComponentType, ReactNode } from "react";
42
+
43
+ import type { DataSource, IslandBind } from "./data";
44
+ import { UiError } from "./errors";
45
+ import type { PropSpec, UiNode } from "./types";
46
+
47
+ /**
48
+ * A client component: the unit an island mounts on the browser.
49
+ *
50
+ * `props` is an optional `PropSpec` schema, validated exactly like a server
51
+ * component's props (required/enum/coercion all reuse the same validator).
52
+ * `fallback` renders the server-side placeholder; absent, the island ships an
53
+ * empty shell to be filled in on hydration.
54
+ *
55
+ * `ssr` opts the island into real hydration: the server renders the REAL
56
+ * `component` into the shell (not the fallback), and the client `hydrateRoot`s
57
+ * it. Only set it when the component renders identically on both sides — see the
58
+ * module doc. When `ssr` is true the `fallback` is unused (the real output IS the
59
+ * server markup) and may be omitted.
60
+ *
61
+ * `hydrate` chooses WHEN the client does the island's mount work, mirroring how
62
+ * `ssr` chooses WHICH shell the server ships — a per-component declaration, not a
63
+ * page-wide mode:
64
+ * - **`"load"` (default)** — eager: the island mounts as soon as
65
+ * `hydrateIslands` runs, the behavior every existing island already has.
66
+ * - **`"visible"`** — lazy: the island does not mount until its region first
67
+ * scrolls into view (an `IntersectionObserver`), Astro's `client:visible`
68
+ * analogue. For Lesto's deferred Account island this also defers its on-mount
69
+ * `/mls/api/session` fetch until the region is actually seen.
70
+ *
71
+ * Honest scope: Lesto ships ONE client bundle, so `"visible"` defers the island's
72
+ * MOUNT WORK (its render, effects, and fetches). Whether it also defers the
73
+ * island's BYTES depends on the declaration form below: an eager `component`
74
+ * already shipped in the main bundle, while a lazy `load` arrives as its own
75
+ * chunk only when the mount actually happens.
76
+ *
77
+ * A client component is declared in one of two forms, and the split is what
78
+ * makes per-island code-splitting real:
79
+ *
80
+ * - **Eager (`component`)** — the component function is referenced directly,
81
+ * so its code ships in the main client bundle. Required for `ssr: true`
82
+ * (the server must hold the real component to render it into the shell).
83
+ *
84
+ * - **Lazy (`load`)** — the def carries a `() => import(...)`-shaped loader
85
+ * instead of the component itself. A bundler with code-splitting turns that
86
+ * dynamic import into a separate chunk, so the island's BYTES stay out of
87
+ * the main bundle and only arrive when the island actually mounts. Combined
88
+ * with `hydrate: "visible"` this is true byte deferral: a below-the-fold
89
+ * island costs nothing — no code, no work — until it scrolls into view.
90
+ * A lazy island is necessarily deferred (`ssr` cannot be `true`): the server
91
+ * cannot SSR a component it does not hold, only its `fallback`.
92
+ *
93
+ * The two forms are a discriminated union, not two optional fields: exactly one
94
+ * of `component`/`load` is present, the compiler enforces `ssr: true` only on
95
+ * the eager form, and `defineClient` re-checks both rules at runtime for
96
+ * un-typed callers.
97
+ */
98
+ export type ClientComponentDef = EagerClientComponentDef | LazyClientComponentDef;
99
+
100
+ /** The declaration fields shared by both forms of client component. */
101
+ interface ClientComponentBase {
102
+ name: string;
103
+ description?: string;
104
+ props?: Record<string, PropSpec>;
105
+ fallback?: (props: Record<string, unknown>) => ReactNode;
106
+ hydrate?: HydrationStrategy;
107
+
108
+ /**
109
+ * Per-request data this island needs, as `propName → source token` (ADR
110
+ * 0010). The framework resolves each source and hands the value to the
111
+ * component as that prop — so the component is a pure function of props with
112
+ * no `fetch`-in-effect, and a waterfall has no author-side site to exist at.
113
+ * The source's implementation is bound on the server (`lesto().data(...)`);
114
+ * this only names the binding, so the client bundle pulls in no server code.
115
+ *
116
+ * Typed loosely here (`DataSource`, value type erased) ON PURPOSE. The
117
+ * type-checked binding — `DataSource<P[K]>` linked to the component's props
118
+ * (review F8) — lives on the `defineIsland` path (`IslandDef`), the canonical
119
+ * path. The Registry's `defineClient` typing is deferred to estate's `.page`
120
+ * convergence (ADR 0011 Increment 2): its storage is an erased
121
+ * `Map<string, ClientComponentDef>` and its consumers (the UiNode walk) are
122
+ * stringly by design, so generics here would be cosmetic until that path
123
+ * migrates or retires.
124
+ */
125
+ data?: Record<string, DataSource>;
126
+ }
127
+
128
+ /** An island whose component ships in the main bundle (and may be `ssr: true`). */
129
+ export interface EagerClientComponentDef extends ClientComponentBase {
130
+ component: ComponentType<Record<string, unknown>>;
131
+ load?: never;
132
+ ssr?: boolean;
133
+ }
134
+
135
+ /**
136
+ * An island whose component arrives as its own chunk, fetched on mount.
137
+ *
138
+ * `load` resolves the component — canonically `() => import("./x").then(m => m.X)`
139
+ * so the bundler splits it. The island is always deferred (`ssr` is not
140
+ * declarable): its server shell is the `fallback`, and the client swaps it for
141
+ * the loaded component with a fresh mount once the chunk arrives.
142
+ */
143
+ export interface LazyClientComponentDef extends ClientComponentBase {
144
+ load: () => Promise<ComponentType<Record<string, unknown>>>;
145
+ component?: never;
146
+ ssr?: false;
147
+ }
148
+
149
+ /**
150
+ * When the client mounts an island. `"load"` is eager (today's only behavior);
151
+ * `"visible"` defers the mount until the region first intersects the viewport.
152
+ * Kept as a named type because it rides both the authoring side
153
+ * ({@link ClientComponentDef.hydrate}) and the wire ({@link IslandMount.strategy}).
154
+ */
155
+ export type HydrationStrategy = "load" | "visible";
156
+
157
+ /**
158
+ * One hydration target: enough for ANY client runtime to find the marked DOM
159
+ * element and mount the right component with the right props. This is the wire
160
+ * contract between `renderPage` (server) and `hydrateIslands` (client).
161
+ *
162
+ * `ssr` tells the client whether the shell already holds the component's real
163
+ * server-rendered output (`hydrateRoot`) or only a fallback to replace
164
+ * (`createRoot`). It rides the wire so the client never has to guess and never
165
+ * risks a mismatch by hydrating a fallback-only shell.
166
+ *
167
+ * `strategy` tells the client WHEN to do that mount: `"visible"` defers it to the
168
+ * region's first intersection, anything else (including its absence) mounts
169
+ * eagerly. It is OPTIONAL and omitted for the default `"load"` so an eager
170
+ * island's wire entry stays byte-for-byte what it has always been — existing
171
+ * manifests, their serialized `<script>` payloads, and the tests that pin their
172
+ * exact shape all read unchanged. Only the rarer `"visible"` opt-in adds a field.
173
+ */
174
+ export interface IslandMount {
175
+ id: string;
176
+ component: string;
177
+ props: Record<string, unknown>;
178
+ ssr: boolean;
179
+ strategy?: HydrationStrategy;
180
+
181
+ /**
182
+ * Per-prop data bindings the client must resolve before mounting (ADR 0010),
183
+ * `propName → { source, href }`. Present only on an island with a `data`
184
+ * declaration whose values were NOT resolved server-side; a dynamically
185
+ * rendered page inlines the values into `props` and omits `bind` entirely, so
186
+ * a data-free or server-resolved island's wire entry is byte-for-byte what it
187
+ * always was. The client awaits each source (the parse-time primer promise on
188
+ * `window.__lestoData`, else a fetch of `href`) and merges it into props.
189
+ */
190
+ bind?: Record<string, IslandBind>;
191
+ }
192
+
193
+ /** The attribute that marks an island's wrapper element for hydration. */
194
+ export const ISLAND_ATTR = "data-lesto-island";
195
+
196
+ /**
197
+ * The attribute marking an island's co-located mount script (ADR 0011): a
198
+ * `<script type="application/json" data-lesto-island-mount>` carrying one
199
+ * {@link IslandMount}, emitted next to its shell by `defineIsland` and scanned
200
+ * by `hydrateDocumentIslands`.
201
+ */
202
+ export const ISLAND_MOUNT_ATTR = "data-lesto-island-mount";
203
+
204
+ /**
205
+ * Author an island NODE by hand-free sugar: `island("Account", { plan: "pro" })`.
206
+ * It is exactly the `UiNode` you would have written — nothing magic, so it
207
+ * composes as a child anywhere a node is allowed.
208
+ *
209
+ * This is the DEMOTED Registry/`UiNode` content path's sugar (ADR 0011 Increment
210
+ * 2): a node in an AI-/DB-driven tree the `renderPage` walk turns into a marked
211
+ * shell + a page-wide manifest entry. A hand-authored `.page` does NOT use this —
212
+ * it drops a `defineIsland` component (`<AccountIsland />`) straight into JSX,
213
+ * which self-emits its shell + co-located mount script. Same `IslandMount` wire,
214
+ * different authoring surface.
215
+ */
216
+ export function island(name: string, props: Record<string, unknown> = {}): UiNode {
217
+ return { type: name, props };
218
+ }
219
+
220
+ /**
221
+ * The runtime union rules for a {@link ClientComponentDef}, in one place so both
222
+ * declaration paths — the Registry (`defineClient`) and the `.page`
223
+ * (`defineIsland`) — refuse identical broken unions identically.
224
+ *
225
+ * The compiler already forbids these states for a typed caller (the branches
226
+ * would narrow `def` to `never`), but an un-typed caller can still hand over a
227
+ * def that violates them; a clear coded error beats a downstream
228
+ * undefined-component crash at hydrate time. We read the discriminant fields
229
+ * through a widened view because, to the compiler, they cannot be wrong.
230
+ *
231
+ * 1. neither `component` nor `load` → nothing to ever mount;
232
+ * 2. `ssr: true` without a `component` → the server cannot SSR a component it
233
+ * does not hold (a lazy island's server shell is only its fallback).
234
+ *
235
+ * `ssr: true` WITH `data` is NO LONGER refused here — ADR 0012 makes it the
236
+ * canonical island: legal at define time, resolved at render. The topology check
237
+ * (is a render-time resolver in scope?) lives at emission, where it belongs
238
+ * (`UI_ISLAND_SSR_DATA_UNRESOLVED` in `define-island.tsx`).
239
+ */
240
+ export function assertClientDef(def: ClientComponentDef): void {
241
+ const declared: { component?: unknown; load?: unknown; ssr?: unknown } = def;
242
+
243
+ if (declared.component === undefined && declared.load === undefined) {
244
+ throw new UiError(
245
+ "UI_CLIENT_COMPONENT_MISSING",
246
+ `client component "${def.name}" declares neither "component" nor "load" — nothing to mount`,
247
+ { name: def.name },
248
+ );
249
+ }
250
+
251
+ if (declared.ssr === true && declared.component === undefined) {
252
+ throw new UiError(
253
+ "UI_CLIENT_SSR_NEEDS_COMPONENT",
254
+ `client component "${def.name}" is ssr: true but lazy — the server cannot render a component it does not hold`,
255
+ { name: def.name },
256
+ );
257
+ }
258
+ }
package/src/link.tsx ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `<Link>` — an ordinary anchor that soft nav upgrades.
3
+ *
4
+ * The deliberate non-magic of Lesto's client router: a `Link` renders a real
5
+ * `<a href>`, nothing more. It works with JS off (a normal navigation), it is
6
+ * crawlable, it is an anchor in every devtool — and when {@link enableSoftNav} is
7
+ * running, its delegated click listener turns an eligible click into a
8
+ * fetch-and-swap. So the component carries no navigation logic itself; it is the
9
+ * authoring sugar that pairs with the runtime, exactly as a server `<a>` pairs
10
+ * with the browser.
11
+ *
12
+ * Authored with plain `createElement` so it stays in `@lesto/ui`'s isomorphic core
13
+ * (no `react-dom`): it renders the same markup on the server (into the prerendered
14
+ * document) and on the client (inside an island), and the soft-nav runtime reads
15
+ * the rendered anchor, never the component.
16
+ *
17
+ * <Link href="/listings/7">View listing</Link>
18
+ * <Link href="/download.pdf" reload>Download</Link> // opts out → full nav
19
+ */
20
+
21
+ import { createElement } from "react";
22
+ import type { AnchorHTMLAttributes, ReactNode } from "react";
23
+
24
+ import type { RouteHref, StrictRouteHref } from "./routes";
25
+ import { PREFETCH_ATTR, prefetchAttrValue, RELOAD_ATTR } from "./softnav-contract";
26
+ import type { PrefetchStrategy } from "./softnav-contract";
27
+
28
+ /** A `Link`'s props: every native anchor attribute, plus `href` (required), `reload`, `prefetch`. */
29
+ export interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
30
+ /**
31
+ * The destination. A same-origin path soft-navigates; anything else falls back to
32
+ * a full nav. Typed as {@link RouteHref}: when the app has route codegen, the
33
+ * known routes autocomplete; otherwise it is `string`, unchanged.
34
+ */
35
+ href: RouteHref;
36
+
37
+ /**
38
+ * Force a full document reload for this link, opting OUT of soft nav (renders
39
+ * the {@link RELOAD_ATTR} the runtime declines on). Use it for a link that must
40
+ * re-run the document — a logout that clears client state, a cross-app boundary.
41
+ */
42
+ reload?: boolean;
43
+
44
+ /**
45
+ * Opt this link INTO prefetch — warming the soft-nav fetch of its destination
46
+ * before the click, so the navigation feels instant. Renders the
47
+ * {@link PREFETCH_ATTR} the runtime reads; a server build with no soft-nav runtime
48
+ * (or one that declines the link, e.g. cross-origin) simply ignores it.
49
+ *
50
+ * - `"viewport"` (or bare `prefetch` / `prefetch={true}`) — warm when the link
51
+ * scrolls into view (eager, the cheaper-feeling default).
52
+ * - `"hover"` — warm on pointer-enter / keyboard focus (lazy, intent-driven).
53
+ * - `false`/omitted — no prefetch (the default; existing links are unchanged).
54
+ *
55
+ * Opt-in and additive: the runtime never warms a cross-origin or `route()`-escape
56
+ * href, so a marked external link is a harmless no-op.
57
+ */
58
+ prefetch?: boolean | PrefetchStrategy;
59
+
60
+ children?: ReactNode;
61
+ }
62
+
63
+ /**
64
+ * Render a soft-nav-aware anchor.
65
+ *
66
+ * `reload` and `prefetch` are lifted off the DOM props and turned into the
67
+ * `data-lesto-reload` / `data-lesto-prefetch` attributes the runtime reads, so
68
+ * neither leaks onto the `<a>` as an unknown attribute. `prefetch` renders only
69
+ * when it resolves to a strategy ({@link prefetchAttrValue}: `true` → `"viewport"`,
70
+ * `false`/omitted → nothing), so an un-prefetched link is byte-for-byte the anchor
71
+ * it always was. Everything else (`className`, `aria-*`, `onClick`, `rel`, a
72
+ * `target`) passes straight through — a `target="_blank"` link, for instance,
73
+ * still renders normally and the runtime declines to soft-nav it, the correct
74
+ * behavior with no special-casing here.
75
+ */
76
+ export function Link({ href, reload, prefetch, children, ...rest }: LinkProps): ReactNode {
77
+ const prefetchValue = prefetchAttrValue(prefetch);
78
+
79
+ return createElement(
80
+ "a",
81
+ {
82
+ href,
83
+ ...(reload === true ? { [RELOAD_ATTR]: "" } : {}),
84
+ ...(prefetchValue === undefined ? {} : { [PREFETCH_ATTR]: prefetchValue }),
85
+ ...rest,
86
+ },
87
+ children,
88
+ );
89
+ }
90
+
91
+ /** The props {@link StrictLink} takes: a `Link`'s, but with a STRICT {@link StrictRouteHref}. */
92
+ export type StrictLinkProps = Omit<LinkProps, "href"> & { href: StrictRouteHref };
93
+
94
+ /**
95
+ * `<StrictLink>` — `<Link>` with a STRICT href: only the app's known routes, no escape,
96
+ * so a typo'd `href` is a `tsc` error (the "a bad link won't compile" win, BY DEFAULT —
97
+ * no `route()` wrapper needed). It IS `Link`, re-typed: runtime-identical, zero new code.
98
+ *
99
+ * For a FULLY-file-routed app, where the codegen registry is the complete route set. A
100
+ * MIXED app (with code-first `.page()` routes) keeps `<Link>` — strict here would
101
+ * false-positive on those (see {@link StrictRouteHref}).
102
+ */
103
+ export const StrictLink: (props: StrictLinkProps) => ReactNode = Link;