@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/routes.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The typed-route seam — how an app's generated `routes.gen.ts` teaches `<Link>` and
3
+ * {@link route} the app's real routes, with ZERO runtime and no react dependency
4
+ * (pure type-level — only a TYPE-only import of `PathParams` from `@lesto/router` —
5
+ * so the `@lesto/ui` isomorphic core stays react-free here and the type gate can pin
6
+ * it without pulling the component tree in).
7
+ *
8
+ * The mechanism is declaration merging — the same `Register` idiom TanStack Router
9
+ * uses. `@lesto/web`'s `generateRouteManifest` emits, into each app's
10
+ * `routes.gen.ts`, a `RoutePath` + `RoutePattern` union of every route plus:
11
+ *
12
+ * declare module "@lesto/ui" {
13
+ * interface RegisteredRoutes { href: RoutePath; pattern: RoutePattern }
14
+ * }
15
+ *
16
+ * which merges `href`/`pattern` members into {@link RegisteredRoutes} below.
17
+ * {@link RouteHref} reads `href` (so `<Link href>` autocompletes the app's routes);
18
+ * {@link KnownPatterns} reads `pattern` (so {@link route} constrains its pattern arg
19
+ * and types its params). An app with NO route codegen leaves `RegisteredRoutes`
20
+ * empty, so both stay unconstrained (`string`): nothing breaks.
21
+ */
22
+
23
+ import type { PathParams } from "@lesto/router";
24
+
25
+ /**
26
+ * The route registry an app augments by declaration merging. Empty by design — it
27
+ * IS the extension point a generated `routes.gen.ts` merges a `href` member into
28
+ * (the same pattern as `@lesto/content-core`'s `CollectionRegistry`). Augmenting
29
+ * `@lesto/ui` reaches this interface because the package re-exports it.
30
+ */
31
+ export interface RegisteredRoutes {}
32
+
33
+ /**
34
+ * The known route hrefs a registry shape `Reg` declares, or `never` when it declares
35
+ * none. Internal helper for {@link HrefFor} — kept as a generic over the registry
36
+ * (not reading the global {@link RegisteredRoutes}) so {@link HrefFor}'s behavior is
37
+ * pinnable in the type gate without a program-global `declare module` augmentation.
38
+ */
39
+ type KnownRoutesOf<Reg> = Reg extends { href: infer H extends string } ? H : never;
40
+
41
+ /**
42
+ * The `<Link href>` type derived from a registry shape `Reg`:
43
+ *
44
+ * - `Reg` declares no routes → exactly `string` (the unchanged default for an app
45
+ * with no route codegen).
46
+ * - `Reg` declares routes → the known routes (autocompleted) PLUS a `(string & {})`
47
+ * escape that still accepts any other string — an external URL, a `?query`/`#hash`,
48
+ * or a code-first route — so typed routes only SURFACE the known ones, never
49
+ * BLOCK a valid link. Strict dead-link erroring is a follow-up (docs/plans/
50
+ * dx-parity.md, Workstream 1 Increment 2).
51
+ *
52
+ * `[KnownRoutesOf<Reg>] extends [never]` guards the empty case without distributing
53
+ * over the union, and `string & {}` is the standard trick that keeps the literal
54
+ * members visible to autocomplete while staying assignable from any string.
55
+ */
56
+ export type HrefFor<Reg> = [KnownRoutesOf<Reg>] extends [never]
57
+ ? string
58
+ : KnownRoutesOf<Reg> | (string & {});
59
+
60
+ /** A `<Link>` href — see {@link HrefFor}, resolved against the app's {@link RegisteredRoutes}. */
61
+ export type RouteHref = HrefFor<RegisteredRoutes>;
62
+
63
+ /**
64
+ * Like {@link HrefFor} but WITHOUT the `(string & {})` escape — only the registry's
65
+ * known routes, so an unknown literal is NOT assignable (a typo'd link is a `tsc`
66
+ * error). A generic over `Reg` so it's pinnable in the type gate. An empty registry
67
+ * still yields `string` (no codegen → unchanged).
68
+ */
69
+ export type StrictHrefFor<Reg> = [KnownRoutesOf<Reg>] extends [never] ? string : KnownRoutesOf<Reg>;
70
+
71
+ /**
72
+ * A STRICT `<Link href>` type for {@link StrictLink}: the app's known routes with no
73
+ * escape, so a typo'd link does not compile. Sound only for a FULLY-file-routed app —
74
+ * every route is in the registry. A MIXED app (with code-first `.page()` routes the
75
+ * codegen can't see) must keep the lenient {@link RouteHref}, or strict mode would
76
+ * false-positive on those routes (making it strict BY DEFAULT for mixed apps needs
77
+ * code-first route capture, a deferred large refactor — docs/plans/dx-parity.md).
78
+ */
79
+ export type StrictRouteHref = StrictHrefFor<RegisteredRoutes>;
80
+
81
+ /**
82
+ * The known route PATTERNS a registry shape `Reg` declares (`"/blog/:id"`, with
83
+ * `:param` segments KEPT), or `never` when it declares none. {@link route} reads this
84
+ * to constrain its pattern argument to the app's real routes; a generic over `Reg`
85
+ * (like {@link HrefFor}) so it's pinnable in the type gate without a global augmentation.
86
+ */
87
+ export type PatternsOf<Reg> = Reg extends { pattern: infer P extends string } ? P : never;
88
+
89
+ /** The app's known route patterns once codegen augments {@link RegisteredRoutes}, else `never`. */
90
+ export type KnownPatterns = PatternsOf<RegisteredRoutes>;
91
+
92
+ /**
93
+ * The argument list a {@link route} call takes for a pattern `P`: the typed params
94
+ * object is REQUIRED when `P` has `:segments`, ABSENT otherwise — a tuple, so a
95
+ * param-less `route("/about")` needs no second argument (the `@lesto/client`
96
+ * `createApi` `GetArgs` precedent). Param names + value types come from
97
+ * `@lesto/router`'s `PathParams<P>`.
98
+ */
99
+ export type ParamArgs<P extends string> = keyof PathParams<P> extends never
100
+ ? []
101
+ : [params: PathParams<P>];
package/src/schema.ts ADDED
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The model-facing description of the registry.
3
+ *
4
+ * `treeJsonSchema` emits a JSON Schema an AI can be constrained to: a UI node is
5
+ * either a bare string (a text leaf) or one of the registered components — a
6
+ * `oneOf` whose variants each pin `type` to a const and describe their props.
7
+ * Each variant lives in its own `#/$defs/<name>` entry so a children allow-list
8
+ * can `$ref` exactly the components it permits — the children narrowing mirrors
9
+ * the runtime `validateTree` `allowsChild` policy, no looser. `componentCatalog`
10
+ * is the friendlier prose summary for a model's system prompt.
11
+ */
12
+
13
+ import { UiError } from "./errors";
14
+ import type { Registry } from "./registry";
15
+ import type { ChildrenPolicy, ComponentDef, PropSpec, PropType } from "./types";
16
+
17
+ /** Map a `PropType` to the JSON Schema fragment that describes it. */
18
+ function propSchema(spec: PropSpec): Record<string, unknown> {
19
+ const base = jsonTypeFor(spec.type, spec);
20
+
21
+ // Carry the human description through to the model when present.
22
+ return spec.description === undefined ? base : { ...base, description: spec.description };
23
+ }
24
+
25
+ /** The bare JSON Schema type fragment for a prop, before description is added. */
26
+ function jsonTypeFor(type: PropType, spec: PropSpec): Record<string, unknown> {
27
+ if (type === "enum") {
28
+ // An enum without values is unbuildable: we'd emit a schema that admits
29
+ // nothing. Refuse loudly so the registry author fixes the spec.
30
+ if (spec.values === undefined) {
31
+ throw new UiError("UI_INVALID_ENUM_SPEC", "enum prop spec has no values", {
32
+ type,
33
+ });
34
+ }
35
+
36
+ return { type: "string", enum: [...spec.values] };
37
+ }
38
+
39
+ if (type === "object") return { type: "object" };
40
+
41
+ if (type === "array") return { type: "array" };
42
+
43
+ // string / number / boolean map straight across.
44
+ return { type };
45
+ }
46
+
47
+ /** The `properties` + `required` shape for one component's props. */
48
+ function propsObjectSchema(specs: Record<string, PropSpec>): Record<string, unknown> {
49
+ const properties: Record<string, unknown> = {};
50
+
51
+ const required: string[] = [];
52
+
53
+ for (const [name, spec] of Object.entries(specs)) {
54
+ properties[name] = propSchema(spec);
55
+
56
+ if (spec.required === true) required.push(name);
57
+ }
58
+
59
+ // Only attach `required` when there is something to require — a cleaner schema.
60
+ return {
61
+ type: "object",
62
+ properties,
63
+ additionalProperties: false,
64
+ ...(required.length === 0 ? {} : { required }),
65
+ };
66
+ }
67
+
68
+ /** Does any of a component's props carry `required: true`? */
69
+ function hasRequiredProp(specs: Record<string, PropSpec>): boolean {
70
+ return Object.values(specs).some((spec) => spec.required === true);
71
+ }
72
+
73
+ /**
74
+ * Does a component accept children at all?
75
+ *
76
+ * `true` admits any registered component; a non-empty allow-list admits only its
77
+ * members; `false` (and the degenerate empty list) is a leaf. Leaves get no
78
+ * `children` property, so the model is constrained no more loosely than the
79
+ * validator's missing/forbidden-child checks enforce.
80
+ */
81
+ function acceptsChildren(policy: ChildrenPolicy): boolean {
82
+ if (policy === true) return true;
83
+
84
+ if (policy === false) return false;
85
+
86
+ return policy.length > 0;
87
+ }
88
+
89
+ /** A bare string is the universal text leaf — valid as a child of ANY parent. */
90
+ const stringLeaf = { type: "string" };
91
+
92
+ /**
93
+ * The `#/$defs/<name>` ref for one component variant.
94
+ *
95
+ * Component names are opaque strings, so the name is JSON-Pointer-escaped
96
+ * (`~` → `~0`, `/` → `~1`, in that order) before it goes into the ref — the def
97
+ * itself is keyed by the raw name (object keys are unrestricted), but a `$ref`
98
+ * is a JSON Pointer and must encode those two characters to resolve.
99
+ */
100
+ function componentRef(name: string): { $ref: string } {
101
+ const pointer = name.replaceAll("~", "~0").replaceAll("/", "~1");
102
+
103
+ return { $ref: `#/$defs/${pointer}` };
104
+ }
105
+
106
+ /**
107
+ * The reserved `$defs` key for the node union (the schema root). A registered
108
+ * component may not share this name — `treeJsonSchema` refuses it, since it would
109
+ * otherwise clobber the union.
110
+ */
111
+ const RESERVED_NODE_DEF = "node";
112
+
113
+ /**
114
+ * The `items` schema for a children-accepting component, mirroring `allowsChild`.
115
+ *
116
+ * - `policy === true` → the full node union (any string leaf or component).
117
+ * - allow-list `[A,B]` → ONLY the named variants, plus the string leaf.
118
+ *
119
+ * The allow-list case is the correctness pin: `validateTree`'s `allowsChild`
120
+ * only consults the list for *node objects* (`isNodeObject(child)`), so a bare
121
+ * string leaf is always permitted and a component child must be a list member.
122
+ * We mirror that exactly — `oneOf: [stringLeaf, ...refs to listed components]` —
123
+ * so a tree the runtime would reject for a disallowed child cannot pass the
124
+ * schema either. A listed name with no registered `$def` can never be satisfied
125
+ * (the runtime flags it `unknown_component`), so we drop it rather than emit a
126
+ * dangling `$ref`; the admitted set stays exactly what `allowsChild` *and*
127
+ * `validateTree` together accept.
128
+ */
129
+ function childItemsSchema(policy: ChildrenPolicy, registered: ReadonlySet<string>): object {
130
+ // `true`: any node. Keep the full union ref so the tree nests arbitrarily.
131
+ // (Only ever called for children-accepting policies, so `false` never lands
132
+ // here — but a list is the only other shape, so we narrow on that.)
133
+ if (!Array.isArray(policy)) return { $ref: `#/$defs/${RESERVED_NODE_DEF}` };
134
+
135
+ const allowed = policy.filter((name) => registered.has(name)).map(componentRef);
136
+
137
+ // A string child is always allowed (allowsChild is guarded by isNodeObject),
138
+ // so the leaf is a member alongside each listed, registered component.
139
+ return { oneOf: [stringLeaf, ...allowed] };
140
+ }
141
+
142
+ /** The full JSON Schema fragment for one component variant. */
143
+ function componentVariant(def: ComponentDef, registered: ReadonlySet<string>): object {
144
+ return {
145
+ type: "object",
146
+ properties: {
147
+ type: { const: def.name },
148
+ props: propsObjectSchema(def.props),
149
+
150
+ // Only children-accepting components advertise a `children` array; leaves
151
+ // omit it so the schema is as strict as the runtime policy. The `items`
152
+ // schema is narrowed to the allow-list when there is one (see
153
+ // `childItemsSchema`) so the schema matches `allowsChild` exactly.
154
+ ...(acceptsChildren(def.children)
155
+ ? { children: { type: "array", items: childItemsSchema(def.children, registered) } }
156
+ : {}),
157
+ },
158
+ // A component with required props must require the `props` object itself —
159
+ // otherwise the nested `props.required` never bites (a model could omit
160
+ // `props` wholesale, passing the schema yet failing `validateTree`'s
161
+ // missing-required-prop check). The two must agree, so require `props` here.
162
+ required: hasRequiredProp(def.props) ? ["type", "props"] : ["type"],
163
+ additionalProperties: false,
164
+ };
165
+ }
166
+
167
+ /**
168
+ * The recursive JSON Schema for a whole UI tree rooted at `#/$defs/node`.
169
+ *
170
+ * A node is a string leaf OR any one registered component. Each component gets
171
+ * its own `#/$defs/<name>` entry (so an allow-list can `$ref` individual
172
+ * variants); the `node` def is the `oneOf` of the string leaf and every variant.
173
+ * A component that accepts children also gets a `children` array whose `items`
174
+ * mirror the runtime `allowsChild` policy: the full node union for `children:
175
+ * true`, or a `oneOf` of just the allowed variants (plus the string leaf) for an
176
+ * allow-list — so a tree `validateTree` rejects for a disallowed child cannot
177
+ * pass the emitted schema either. A leaf omits `children` entirely.
178
+ */
179
+ export function treeJsonSchema(registry: Registry): object {
180
+ const defs = registry.all();
181
+
182
+ // `node` is the reserved $defs key for the node union (the schema root). A
183
+ // component sharing that exact name would, via the `...componentDefs` spread
184
+ // below, clobber the union — leaving the root `$ref` resolving to one component
185
+ // and silently rejecting every other tree. Refuse it loudly rather than emit a
186
+ // corrupt schema.
187
+ const reserved = defs.find((def) => def.name === RESERVED_NODE_DEF);
188
+ if (reserved !== undefined) {
189
+ throw new UiError(
190
+ "UI_RESERVED_COMPONENT_NAME",
191
+ `a component may not be named "${RESERVED_NODE_DEF}": it is the reserved JSON Schema node-union key`,
192
+ { name: reserved.name },
193
+ );
194
+ }
195
+
196
+ const registered = new Set(defs.map((def) => def.name));
197
+
198
+ // One `$def` per component so an allow-list `oneOf` can reference individual
199
+ // variants by name; the `node` union refs them all (plus the string leaf).
200
+ const componentDefs: Record<string, object> = {};
201
+
202
+ for (const def of defs) {
203
+ componentDefs[def.name] = componentVariant(def, registered);
204
+ }
205
+
206
+ return {
207
+ $schema: "https://json-schema.org/draft/2020-12/schema",
208
+ $ref: `#/$defs/${RESERVED_NODE_DEF}`,
209
+ $defs: {
210
+ [RESERVED_NODE_DEF]: { oneOf: [stringLeaf, ...defs.map((def) => componentRef(def.name))] },
211
+ ...componentDefs,
212
+ },
213
+ };
214
+ }
215
+
216
+ /** A compact, model-friendly summary of every component in the registry. */
217
+ export function componentCatalog(registry: Registry): object[] {
218
+ return registry.all().map(catalogEntry);
219
+ }
220
+
221
+ /** One component's entry in the catalog — only the fields that carry information. */
222
+ function catalogEntry(def: ComponentDef): Record<string, unknown> {
223
+ const props = Object.fromEntries(
224
+ Object.entries(def.props).map(([name, spec]) => [name, catalogProp(spec)]),
225
+ );
226
+
227
+ return {
228
+ name: def.name,
229
+ children: def.children,
230
+ props,
231
+ ...(def.description === undefined ? {} : { description: def.description }),
232
+ };
233
+ }
234
+
235
+ /** One prop's entry in the catalog — only the fields that carry information. */
236
+ function catalogProp(spec: PropSpec): Record<string, unknown> {
237
+ return {
238
+ type: spec.type,
239
+ ...(spec.required === true ? { required: true } : {}),
240
+ ...(spec.values === undefined ? {} : { values: [...spec.values] }),
241
+ ...(spec.default === undefined ? {} : { default: spec.default }),
242
+ ...(spec.description === undefined ? {} : { description: spec.description }),
243
+ };
244
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The wire guard for island props.
3
+ *
4
+ * Island props are serialized on the server and revived in the browser, so they
5
+ * must be pure JSON — the values `JSON.stringify`/`JSON.parse` round-trip
6
+ * losslessly. A function, a `Date`, a class instance, a `Symbol`, a `bigint`, or
7
+ * `undefined` would either vanish or arrive as something the client cannot use.
8
+ * We reject them at the boundary with a stable code, rather than let a prop
9
+ * silently disappear between server and client.
10
+ *
11
+ * The check is structural and reports the FIRST offending path (e.g.
12
+ * `props.user.onClick`), so the author is told exactly which value to fix.
13
+ */
14
+
15
+ import { UiError } from "./errors";
16
+
17
+ import type { IslandMount } from "./island";
18
+
19
+ /** A JSON-shaped value: the closure of null/boolean/number/string under array/object. */
20
+ type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
21
+
22
+ /**
23
+ * The first path at which `value` is not JSON-serializable, or `undefined` if
24
+ * the whole structure is clean. `path` seeds the reported location.
25
+ */
26
+ function firstNonSerializable(value: unknown, path: string): string | undefined {
27
+ if (value === null) return undefined;
28
+
29
+ // Primitives that JSON round-trips losslessly. A non-finite number (NaN,
30
+ // Infinity) becomes `null` through JSON, so it is not faithfully serializable.
31
+ if (typeof value === "boolean" || typeof value === "string") return undefined;
32
+
33
+ if (typeof value === "number") return Number.isFinite(value) ? undefined : path;
34
+
35
+ // Arrays: clean iff every element is clean, reported left to right.
36
+ if (Array.isArray(value)) {
37
+ for (const [index, element] of value.entries()) {
38
+ const offender = firstNonSerializable(element, `${path}[${index}]`);
39
+
40
+ if (offender !== undefined) return offender;
41
+ }
42
+
43
+ return undefined;
44
+ }
45
+
46
+ // A plain object: clean iff every own value is clean. Anything with a custom
47
+ // prototype (Date, Map, class instance) is rejected — it would not round-trip
48
+ // as the author intends.
49
+ if (typeof value === "object" && isPlainObject(value)) {
50
+ for (const [key, nested] of Object.entries(value)) {
51
+ const offender = firstNonSerializable(nested, `${path}.${key}`);
52
+
53
+ if (offender !== undefined) return offender;
54
+ }
55
+
56
+ return undefined;
57
+ }
58
+
59
+ // Everything else — function, symbol, bigint, undefined, exotic object — is
60
+ // not JSON and so cannot cross the wire.
61
+ return path;
62
+ }
63
+
64
+ /** Is `value` a plain `{}`-style object (Object.prototype or null prototype)? */
65
+ function isPlainObject(value: object): boolean {
66
+ const proto = Object.getPrototypeOf(value) as object | null;
67
+
68
+ return proto === null || proto === Object.prototype;
69
+ }
70
+
71
+ /**
72
+ * Assert that `props` is pure JSON, returning the same bag typed as JSON. Throws
73
+ * `UI_ISLAND_PROPS_NOT_SERIALIZABLE` naming the offending path otherwise.
74
+ */
75
+ export function assertSerializable(
76
+ component: string,
77
+ props: Record<string, unknown>,
78
+ ): Record<string, JsonValue> {
79
+ const offender = firstNonSerializable(props, "props");
80
+
81
+ if (offender !== undefined) {
82
+ throw new UiError(
83
+ "UI_ISLAND_PROPS_NOT_SERIALIZABLE",
84
+ `island "${component}" has a non-serializable prop at ${offender}`,
85
+ { component, path: offender },
86
+ );
87
+ }
88
+
89
+ // The structural walk above proves the cast true: every reachable value is JSON.
90
+ return props as Record<string, JsonValue>;
91
+ }
92
+
93
+ /**
94
+ * Serialize a page-wide island manifest ARRAY for safe embedding in an inline
95
+ * `<script>` — the DEMOTED Registry/`UiNode` content path's emission (ADR 0011
96
+ * Increment 2). The canonical `.page` path uses {@link serializeScriptJson} on
97
+ * one mount object per island (co-located, streaming-safe); this array form is
98
+ * the niche where `renderPage` walks an AI-/DB-driven tree into one
99
+ * `#lesto-islands` manifest. Same audited escape, applied to the whole array.
100
+ *
101
+ * `JSON.stringify` alone is NOT safe to drop into HTML. A string value carrying
102
+ * `</script>` (or `<!--`, or the JS line terminators U+2028 / U+2029) terminates
103
+ * the surrounding element and lets attacker-influenced prop data execute — a
104
+ * textbook SSR-serialization XSS. HTML-entity escaping does not help either:
105
+ * entities are not decoded inside `<script>`. So we escape the breakout
106
+ * characters to their `\uXXXX` JSON escapes, which `JSON.parse` reads back as the
107
+ * byte-identical string — `<` and `>` (defeat `</script>` and `<!--`), `&`
108
+ * (belt-and-braces), and the two separators a JS parser treats as line breaks.
109
+ *
110
+ * The emission that consumes this MUST use `<script type="application/json">` and
111
+ * revive with `JSON.parse(el.textContent)` on the client: a non-executable type
112
+ * keeps even a future escaping miss inert, and the payload stays compatible with
113
+ * a strict, nonce-based CSP. This is the one audited seam every manifest payload
114
+ * crosses — never hand-roll `JSON.stringify` into a `<script>`, and never splice
115
+ * it in with `String.prototype.replace`, whose `$&`/`$'` tokens are themselves an
116
+ * injection vector.
117
+ *
118
+ * Mirrors the script-context escape that `@lesto/seo` and `@lesto/content-shared`
119
+ * already apply to inline JSON-LD; kept local so `@lesto/ui`'s render hot path
120
+ * pulls in no extra dependency for it.
121
+ */
122
+ export function serializeManifest(manifest: readonly IslandMount[]): string {
123
+ return serializeScriptJson(manifest);
124
+ }
125
+
126
+ /**
127
+ * Serialize any value to JSON safe to embed inside a `<script>` element.
128
+ *
129
+ * The same script-context escape {@link serializeManifest} applies, generalized
130
+ * to one value — used by the per-island co-located mount script (ADR 0011),
131
+ * where each island emits its OWN `IslandMount` object rather than the page-wide
132
+ * array. The same rules and the same one-audited-seam discipline apply: emit only
133
+ * as `<script type="application/json">`, revive with `JSON.parse`, never a bare
134
+ * `JSON.stringify` into a `<script>`.
135
+ */
136
+ export function serializeScriptJson(value: unknown): string {
137
+ // The JS line/paragraph separators, built from code points so no raw
138
+ // U+2028/U+2029 byte sits in this source (where tooling may mangle it).
139
+ const lineSeparator = String.fromCharCode(0x2028);
140
+ const paragraphSeparator = String.fromCharCode(0x2029);
141
+
142
+ return JSON.stringify(value)
143
+ .replaceAll("<", "\\u003c")
144
+ .replaceAll(">", "\\u003e")
145
+ .replaceAll("&", "\\u0026")
146
+ .replaceAll(lineSeparator, "\\u2028")
147
+ .replaceAll(paragraphSeparator, "\\u2029");
148
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The Preact server-render dialect — re-exported from the `@lesto/ui/server`
3
+ * subpath as `preactServerRenderer`.
4
+ *
5
+ * This is the adapter half of the {@link ServerRenderer} seam (declared in
6
+ * `render.tsx`). It backs the two functions {@link renderPageMarkup} needs with
7
+ * `preact-render-to-string` instead of `react-dom/server`, so a page whose client
8
+ * bundle is Preact (the opt-in `react`→`preact/compat` alias, ADR 0007) renders
9
+ * its server markup in the SAME dialect the client will hydrate against.
10
+ *
11
+ * Why this matters, mechanically: an `ssr: true` island ships its real server
12
+ * render into the shell for the client to `hydrateRoot`. Hydration only succeeds
13
+ * when the server- and client-emitted markup agree, and React and Preact do NOT
14
+ * emit identical markup — most visibly, React delimits adjacent text segments with
15
+ * `<!-- -->` comment markers while Preact does not. Hydrate React's server markup
16
+ * with Preact's `hydrateRoot` and the common interpolated-text shape (`'Hi, ',
17
+ * name`) mismatches, firing `onRecoverableError` and forcing a full re-render —
18
+ * defeating `ssr: true`. Rendering the server side with THIS adapter removes the
19
+ * mismatch at the source: both sides speak Preact, so the markup lines up.
20
+ *
21
+ * Why it lives behind `@lesto/ui/server` (not the core barrel): keeping `@lesto/ui`
22
+ * dialect-agnostic means its core never hard-depends on `preact-render-to-string`.
23
+ * A server importer of `@lesto/ui` (the default React path, estate's `document.ts`)
24
+ * must never drag Preact's renderer into its build. So `preact-render-to-string` is
25
+ * an OPTIONAL peer dependency — present only when an adopter chooses the Preact
26
+ * client alias — and this module is the only place that imports it. Reach for it
27
+ * explicitly (`import { preactServerRenderer } from "@lesto/ui/server"`) and pass it
28
+ * to {@link renderPageMarkup}; the default React path never loads this file.
29
+ *
30
+ * The `as` cast at the call boundary is the honest cost of bridging two element
31
+ * factories: `@lesto/ui` builds its tree with React's `createElement`, and under
32
+ * the `preact/compat` alias those calls resolve to Preact-shaped vnodes that
33
+ * `preact-render-to-string` renders natively. The adapter's job is purely to map
34
+ * the engine's `ReactElement`-typed node onto that renderer's `VNode` parameter;
35
+ * the runtime shape is already a Preact vnode wherever this adapter is wired.
36
+ */
37
+
38
+ import type { ReactElement } from "react";
39
+ // `preact-render-to-string` mirrors `react-dom/server`'s two entry points, which
40
+ // is exactly the surface {@link ServerRenderer} asks for — so the adapter is a
41
+ // direct one-to-one binding, not a translation layer.
42
+ import { renderToStaticMarkup, renderToString } from "preact-render-to-string";
43
+
44
+ import type { ServerRenderer } from "./render";
45
+
46
+ /**
47
+ * The Preact dialect, ready to hand to {@link renderPageMarkup} as its `renderer`.
48
+ *
49
+ * Both functions forward straight to `preact-render-to-string`. The node arrives
50
+ * typed as `ReactElement` (the engine's element type), but at runtime — under the
51
+ * `preact/compat` alias that makes this adapter meaningful — it is a Preact vnode,
52
+ * which is what `preact-render-to-string` consumes. The cast names that bridge
53
+ * rather than widening either library's public types.
54
+ */
55
+ export const preactServerRenderer: ServerRenderer = {
56
+ dialect: "preact",
57
+ renderToString: (node: ReactElement) => renderToString(node as never),
58
+ renderToStaticMarkup: (node: ReactElement) => renderToStaticMarkup(node as never),
59
+ };
package/src/server.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `@lesto/ui/server` — the server-render half of the engine.
3
+ *
4
+ * Everything here imports `react-dom/server` (or its Preact twin,
5
+ * `preact-render-to-string`) directly or transitively: the buffered and streamed
6
+ * page renderers and the two server-render dialects. It is split out of the core
7
+ * `@lesto/ui` barrel for one load-bearing reason — a CLIENT bundle that imports
8
+ * `@lesto/ui` (for `Registry`, `defineIsland`, the island/data tokens) must NEVER
9
+ * drag `react-dom/server` into the browser graph. React's `react-dom/server` is
10
+ * ~60 KB gzip of code the browser never runs (the browser only *hydrates*), and
11
+ * before this split the barrel pulled it into every client bundle, defeating the
12
+ * whole point of the opt-in ~10 KB Preact dialect (ADR 0007/0008).
13
+ *
14
+ * So the rule is mechanical: anything that calls `renderToString` /
15
+ * `renderToStaticMarkup` / `renderToReadableStream` lives behind this subpath; a
16
+ * server (the `@lesto/web` page renderer, estate's `document.ts`) reaches for it
17
+ * explicitly. The core barrel stays isomorphic — safe to import from a module the
18
+ * client bundle reaches.
19
+ *
20
+ * Mirrors `@lesto/ui/client` (the browser-only hydration runtime) and
21
+ * `react-dom`'s own server/client split.
22
+ */
23
+
24
+ export { reactServerRenderer, renderPage, renderPageMarkup, renderTree } from "./render";
25
+ export type { Page, RenderError, ServerRenderer } from "./render";
26
+
27
+ // Streaming SSR: a live shell-first stream for humans, plus a buffered `allReady`
28
+ // exit for crawlers/SSG. Additive over `renderPageMarkup` (which stays the
29
+ // dependency-light buffered API). Server-safe — React's stream renderer runs on
30
+ // Node as of React 19.2.
31
+ export { renderPageStream, renderPageStreamToString } from "./stream";
32
+ export type {
33
+ ErrorInfo,
34
+ ReactRenderStream,
35
+ RenderToReadableStream,
36
+ StreamErrorSink,
37
+ StreamOptions,
38
+ } from "./stream";
39
+
40
+ // The Preact server-render dialect — the matched pair (ADR 0008) for a client
41
+ // bundle built under the `react`→`preact/compat` alias. An OPTIONAL peer
42
+ // (`preact-render-to-string`), present only when an adopter chooses Preact, so a
43
+ // default React server never drags Preact's renderer into its build.
44
+ export { preactServerRenderer } from "./server-preact";