@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/package.json +58 -0
- package/src/bfcache.ts +121 -0
- package/src/client.ts +54 -0
- package/src/data-client.ts +361 -0
- package/src/data-resolve.tsx +120 -0
- package/src/data.ts +183 -0
- package/src/define-island.tsx +222 -0
- package/src/errors.ts +36 -0
- package/src/hydrate.tsx +563 -0
- package/src/index.ts +165 -0
- package/src/island.ts +258 -0
- package/src/link.tsx +103 -0
- package/src/metadata.ts +195 -0
- package/src/mount.ts +99 -0
- package/src/node.ts +8 -0
- package/src/props.ts +95 -0
- package/src/registry.ts +141 -0
- package/src/render.tsx +349 -0
- package/src/resources.ts +233 -0
- package/src/route.ts +62 -0
- package/src/routes.ts +101 -0
- package/src/schema.ts +244 -0
- package/src/serialize.ts +148 -0
- package/src/server-preact.ts +59 -0
- package/src/server.ts +44 -0
- package/src/softnav-contract.ts +144 -0
- package/src/softnav.ts +934 -0
- package/src/stream.tsx +387 -0
- package/src/types.ts +46 -0
- package/src/validate.ts +107 -0
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;
|