@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.
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Document metadata — `<title>`, `<meta>`, `<link>` — that any component may
3
+ * render, anywhere in the tree.
4
+ *
5
+ * React 19 hoists these tags out of the body and into the document head during
6
+ * SSR (and under the framework's buffered `renderToStaticMarkup`, to the front of
7
+ * the emitted markup). So a deeply nested component can declare the page's title
8
+ * or description and React floats it up — no prop-drilling a metadata bag to the
9
+ * document shell.
10
+ *
11
+ * The catch React leaves to the framework: **React hoists but does not dedupe.**
12
+ * Two components that each render a `<title>` ship two `<title>` tags, and the
13
+ * browser honors the first — a silent footgun. A page has exactly one title and
14
+ * one canonical link; many `<meta name>` are singletons too. So this module pairs
15
+ * the thin element helpers with {@link dedupeMetadata}, a pure pass the document
16
+ * shell runs over collected metadata to keep the last value per logical key.
17
+ *
18
+ * The helpers return plain React elements; nothing here renders or touches the
19
+ * DOM. `dedupeMetadata` is pure data-in/data-out so it is fully testable without
20
+ * React at all.
21
+ */
22
+
23
+ import { createElement } from "react";
24
+ import type { ReactElement } from "react";
25
+
26
+ /** Render `<title>{text}</title>`. A page should resolve to exactly one. */
27
+ export function title(text: string): ReactElement {
28
+ return createElement("title", null, text);
29
+ }
30
+
31
+ /** A `<meta name=… content=…>` tag (description, theme-color, robots, …). */
32
+ export interface NamedMeta {
33
+ name: string;
34
+ content: string;
35
+ }
36
+
37
+ /** A `<meta property=… content=…>` tag (Open Graph / `og:` namespace). */
38
+ export interface PropertyMeta {
39
+ property: string;
40
+ content: string;
41
+ }
42
+
43
+ /** A `<meta charset>` tag — the one meta that must come first in the head. */
44
+ export interface CharsetMeta {
45
+ charSet: string;
46
+ }
47
+
48
+ /** Any meta tag this module knows how to render and dedupe. */
49
+ export type MetaSpec = NamedMeta | PropertyMeta | CharsetMeta;
50
+
51
+ /** A `<link>` tag — canonical, alternate, icon, preconnect authored by hand, … */
52
+ export interface LinkSpec {
53
+ rel: string;
54
+ href: string;
55
+ hrefLang?: string;
56
+ type?: string;
57
+ sizes?: string;
58
+ media?: string;
59
+ }
60
+
61
+ /** Render a `<meta>` from any {@link MetaSpec}. */
62
+ export function meta(spec: MetaSpec): ReactElement {
63
+ return createElement("meta", { ...spec });
64
+ }
65
+
66
+ /**
67
+ * Render a `<link>` from a {@link LinkSpec}, dropping absent optionals. An
68
+ * optional `key` is stamped for use inside a sibling list (see {@link renderMetadata}).
69
+ */
70
+ export function link(spec: LinkSpec, key?: string): ReactElement {
71
+ return createElement("link", {
72
+ ...(key === undefined ? {} : { key }),
73
+ rel: spec.rel,
74
+ href: spec.href,
75
+ ...(spec.hrefLang === undefined ? {} : { hrefLang: spec.hrefLang }),
76
+ ...(spec.type === undefined ? {} : { type: spec.type }),
77
+ ...(spec.sizes === undefined ? {} : { sizes: spec.sizes }),
78
+ ...(spec.media === undefined ? {} : { media: spec.media }),
79
+ });
80
+ }
81
+
82
+ /**
83
+ * A single declared piece of metadata before it is rendered or deduped. Keeping
84
+ * metadata as data (not elements) until the last moment is what lets the document
85
+ * shell dedupe it — you cannot inspect a built React element's identity cheaply.
86
+ */
87
+ export type MetadataEntry =
88
+ | { kind: "title"; text: string }
89
+ | { kind: "meta"; spec: MetaSpec }
90
+ | { kind: "link"; spec: LinkSpec };
91
+
92
+ /** The dedupe key charset entries collapse to — also the key we promote first. */
93
+ const CHARSET_KEY = "meta:charset";
94
+
95
+ /**
96
+ * The logical identity of a metadata entry — entries sharing a key are the "same"
97
+ * tag, and the LAST one wins. This encodes the de-facto HTML singletons:
98
+ * - a page has one `<title>`;
99
+ * - one `<meta charset>`;
100
+ * - one `<meta name="x">` per name, one `<meta property="og:y">` per property;
101
+ * - one `<link rel="canonical">` (rel alone), but MANY `<link rel="alternate">`
102
+ * (keyed by rel+href+hrefLang, so hreflang variants and icon sizes coexist).
103
+ */
104
+ function dedupeKey(entry: MetadataEntry): string {
105
+ if (entry.kind === "title") return "title";
106
+
107
+ if (entry.kind === "meta") return metaKey(entry.spec);
108
+
109
+ return linkKey(entry.spec);
110
+ }
111
+
112
+ /** The dedupe key for a meta entry: charset, name, or og-property are singletons. */
113
+ function metaKey(spec: MetaSpec): string {
114
+ if ("charSet" in spec) return CHARSET_KEY;
115
+
116
+ if ("name" in spec) return `meta:name:${spec.name}`;
117
+
118
+ return `meta:property:${spec.property}`;
119
+ }
120
+
121
+ /**
122
+ * The dedupe key for a link entry. `rel="canonical"` is a true singleton (keyed by
123
+ * rel alone). Everything else — stylesheets, icons, alternates, preloads — may
124
+ * legitimately repeat, so it is keyed by rel+href+hrefLang and only an exact
125
+ * duplicate collapses.
126
+ */
127
+ function linkKey(spec: LinkSpec): string {
128
+ if (spec.rel === "canonical") return "link:canonical";
129
+
130
+ return `link:${spec.rel}:${spec.href}:${spec.hrefLang ?? ""}`;
131
+ }
132
+
133
+ /**
134
+ * Collapse a metadata list so each logical key appears once, keeping the LAST
135
+ * occurrence (a nested component's value overrides a layout's default) while
136
+ * preserving the order in which surviving keys first appeared.
137
+ *
138
+ * One position is NOT first-seen: `<meta charset>` is hoisted to the FRONT
139
+ * regardless of where it was declared. The HTML spec requires the charset
140
+ * declaration within the first 1024 bytes of the document and ideally first;
141
+ * since any component may declare it from deep in the tree, a first-seen order
142
+ * would let a late declaration sit after other hoisted tags and miss that window.
143
+ * Promoting it is mechanical and safe — charset is a singleton, so there is only
144
+ * ever one to move.
145
+ *
146
+ * Pure: same input, same output; no React, no DOM. The document shell runs this
147
+ * before rendering the entries, so duplicate `<title>`s never ship — closing the
148
+ * gap React's hoist-without-dedupe leaves open.
149
+ */
150
+ export function dedupeMetadata(entries: readonly MetadataEntry[]): MetadataEntry[] {
151
+ // First appearance fixes order; last value wins. We record both, then emit in
152
+ // first-seen order with the winning value — stable and intention-revealing.
153
+ const order: string[] = [];
154
+
155
+ const winner = new Map<string, MetadataEntry>();
156
+
157
+ for (const entry of entries) {
158
+ const key = dedupeKey(entry);
159
+
160
+ if (!winner.has(key)) order.push(key);
161
+
162
+ winner.set(key, entry);
163
+ }
164
+
165
+ // Charset must lead the head, so pull it out of its first-seen slot and emit it
166
+ // first. Everything else keeps first-seen order behind it.
167
+ const charsetFirst = winner.has(CHARSET_KEY)
168
+ ? [CHARSET_KEY, ...order.filter((key) => key !== CHARSET_KEY)]
169
+ : order;
170
+
171
+ // Surviving entries in (charset-first) order, each with its winning value.
172
+ return charsetFirst.map((key) => winner.get(key) as MetadataEntry);
173
+ }
174
+
175
+ /**
176
+ * Render one {@link MetadataEntry} to its React element, stamping `key` so the
177
+ * element can sit in a sibling list without a missing-key warning. The key flows
178
+ * into the element config alongside the tag's own attributes.
179
+ */
180
+ export function renderMetadataEntry(entry: MetadataEntry, key: string): ReactElement {
181
+ if (entry.kind === "title") return createElement("title", { key }, entry.text);
182
+
183
+ if (entry.kind === "meta") return createElement("meta", { key, ...entry.spec });
184
+
185
+ return link(entry.spec, key);
186
+ }
187
+
188
+ /**
189
+ * Dedupe then render a metadata list into React elements, each keyed by its
190
+ * first-seen index so React places the siblings cleanly. Drop this array into the
191
+ * tree and React hoists every tag into the head.
192
+ */
193
+ export function renderMetadata(entries: readonly MetadataEntry[]): ReactElement[] {
194
+ return dedupeMetadata(entries).map((entry, index) => renderMetadataEntry(entry, `m${index}`));
195
+ }
package/src/mount.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Build an island's wire `IslandMount` from its declaration — the single author
3
+ * of those bytes, shared by the two server paths that emit islands:
4
+ *
5
+ * - `buildIsland` (render.tsx) — the Registry/`UiNode` tree path, one page-wide
6
+ * `#lesto-islands` manifest array;
7
+ * - `defineIsland` (define-island.ts) — the `.page` path (ADR 0011), one
8
+ * co-located mount script per island.
9
+ *
10
+ * Both must produce byte-identical mount shapes (props validated + serializable,
11
+ * `ssr`, the optional `strategy`/`bind` emitted only when they deviate from the
12
+ * default), so the shape lives here once rather than drifting between them.
13
+ */
14
+
15
+ import { dataSourceHref } from "./data";
16
+ import type { IslandBind } from "./data";
17
+ import { UiError } from "./errors";
18
+ import type { ClientComponentDef, IslandMount } from "./island";
19
+ import { validateProps } from "./props";
20
+ import { assertSerializable } from "./serialize";
21
+
22
+ /**
23
+ * Validate `rawProps` against the island's schema, prove them serializable, and
24
+ * assemble the wire mount. Returns the mount AND the validated props (the caller
25
+ * renders the fallback / ssr output from the same validated bag). May throw
26
+ * `UI_ISLAND_PROPS_NOT_SERIALIZABLE` — the caller decides whether to contain it.
27
+ *
28
+ * `resolved` is the render-time-resolved data (ADR 0012, the canonical island):
29
+ * when present, its entries are merged into the props AFTER schema validation
30
+ * (bound props were never schema-validated — they bypass `validateProps` on the
31
+ * client path today, kept symmetric) and BEFORE `assertSerializable` (inlined
32
+ * data rides the wire, so it passes the same JSON guard). A `bind` is then
33
+ * emitted only for `def.data` entries NOT in `resolved` (e.g. a `visible`
34
+ * island's deferred source). The Registry path (`buildIsland`) passes nothing,
35
+ * so its mount is byte-for-byte unchanged.
36
+ */
37
+ export function islandMount(
38
+ def: ClientComponentDef,
39
+ rawProps: Record<string, unknown>,
40
+ id: string,
41
+ resolved?: Record<string, unknown>,
42
+ ): { mount: IslandMount; props: Record<string, unknown> } {
43
+ const validated = def.props === undefined ? rawProps : validateProps(def.props, rawProps).props;
44
+
45
+ // Inlined data merges OVER the validated props (a bound prop wins over a static
46
+ // one of the same name), then the whole bag must pass the serialize guard.
47
+ const props = resolved === undefined ? validated : { ...validated, ...resolved };
48
+
49
+ const serializable = assertSerializable(def.name, props);
50
+
51
+ const mount: IslandMount = {
52
+ id,
53
+ component: def.name,
54
+ props: serializable,
55
+ ssr: def.ssr === true,
56
+ };
57
+
58
+ // `strategy`/`bind` ride the wire only when they deviate from the default, so a
59
+ // plain eager island's mount is byte-for-byte what it has always been.
60
+ if (def.hydrate === "visible") {
61
+ mount.strategy = "visible";
62
+ }
63
+
64
+ if (def.data !== undefined) {
65
+ const bind: Record<string, IslandBind> = {};
66
+
67
+ for (const [prop, source] of Object.entries(def.data)) {
68
+ // A source already resolved into props needs no client-side bind — its
69
+ // value crossed the wire inline. Only unresolved sources keep a bind.
70
+ if (resolved !== undefined && prop in resolved) continue;
71
+
72
+ bind[prop] = { source: source.name, href: dataSourceHref(source.name) };
73
+ }
74
+
75
+ // Emit `bind` only when at least one source is still unresolved, so a fully
76
+ // inlined island's wire entry has no `bind` key at all (byte-stable).
77
+ if (Object.keys(bind).length > 0) {
78
+ mount.bind = bind;
79
+ }
80
+ }
81
+
82
+ // The shared invariant, authored here so BOTH emission paths obey it by
83
+ // construction (chief-architect review 2c — it had drifted onto only the
84
+ // `defineIsland` path): an `ssr: true` island that still carries a `bind` would
85
+ // be server-rendered WITHOUT its bound data and then hydrated WITH it — a
86
+ // guaranteed mismatch. It is legal only when the data was resolved at render
87
+ // (a dynamic page's resolver inlines it, leaving no bind). Refuse it otherwise:
88
+ // the Registry/`buildIsland` path contains this throw as a reported render
89
+ // error; the `defineIsland` path propagates it to the page boundary.
90
+ if (mount.ssr && mount.bind !== undefined) {
91
+ throw new UiError(
92
+ "UI_ISLAND_SSR_DATA_UNRESOLVED",
93
+ `island "${def.name}" is ssr: true with unresolved data bindings — the server cannot render correct markup without the data, so hydration would mismatch; render it on a dynamic page (where a resolver inlines the values) or drop ssr`,
94
+ { name: def.name },
95
+ );
96
+ }
97
+
98
+ return { mount, props };
99
+ }
package/src/node.ts ADDED
@@ -0,0 +1,8 @@
1
+ /** Shared guard for distinguishing a UI node object from a string leaf or junk. */
2
+
3
+ import type { UiNode } from "./types";
4
+
5
+ /** Is `value` a UiNode-shaped object (vs a string leaf, array, or null)? */
6
+ export function isNodeObject(value: unknown): value is UiNode {
7
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8
+ }
package/src/props.ts ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Prop validation: take whatever the AI emitted and reconcile it against a
3
+ * component's declared `PropSpec`s. The result is a clean prop bag the renderer
4
+ * can trust, plus a list of human-readable errors for anything that went wrong.
5
+ *
6
+ * The rules, per spec:
7
+ * - unknown props (not in the schema) are silently dropped
8
+ * - an absent prop falls back to its `default`, if one is declared
9
+ * - a missing *required* prop is an error
10
+ * - values are coerced to their declared type where it's safe
11
+ * - an enum value outside the allowed `values` is an error
12
+ */
13
+
14
+ import type { PropSpec, PropType } from "./types";
15
+
16
+ /** Coerce a raw value toward `type`. Returns the value unchanged if it can't. */
17
+ function coerce(type: PropType, value: unknown): unknown {
18
+ if (type === "number") return coerceNumber(value);
19
+
20
+ if (type === "boolean") return coerceBoolean(value);
21
+
22
+ // string / enum / object / array pass through; the renderer and enum check
23
+ // handle them. We never invent structure that wasn't there.
24
+ return value;
25
+ }
26
+
27
+ /** A numeric string becomes a number; anything non-finite is left as-is. */
28
+ function coerceNumber(value: unknown): unknown {
29
+ if (typeof value === "number") return value;
30
+
31
+ if (typeof value === "string") {
32
+ const n = Number(value);
33
+
34
+ if (value.trim() !== "" && Number.isFinite(n)) return n;
35
+ }
36
+
37
+ return value;
38
+ }
39
+
40
+ /** The strings "true"/"false" become booleans; everything else is left as-is. */
41
+ function coerceBoolean(value: unknown): unknown {
42
+ if (typeof value === "boolean") return value;
43
+
44
+ if (value === "true") return true;
45
+
46
+ if (value === "false") return false;
47
+
48
+ return value;
49
+ }
50
+
51
+ /** Is `value` one of these allowed enum strings? A non-string is never a member. */
52
+ function isAllowed(values: readonly string[], value: unknown): boolean {
53
+ return typeof value === "string" && values.includes(value);
54
+ }
55
+
56
+ /**
57
+ * Validate `props` against `specs`. Pure: no throws, no mutation of the input.
58
+ */
59
+ export function validateProps(
60
+ specs: Record<string, PropSpec>,
61
+ props: Record<string, unknown>,
62
+ ): { props: Record<string, unknown>; errors: string[] } {
63
+ const out: Record<string, unknown> = {};
64
+
65
+ const errors: string[] = [];
66
+
67
+ for (const [name, spec] of Object.entries(specs)) {
68
+ const present = Object.hasOwn(props, name);
69
+
70
+ // Absent prop: apply a default if one exists, else flag if required.
71
+ if (!present) {
72
+ if (spec.default !== undefined) {
73
+ out[name] = spec.default;
74
+ } else if (spec.required === true) {
75
+ errors.push(`missing required prop "${name}"`);
76
+ }
77
+
78
+ continue;
79
+ }
80
+
81
+ const value = coerce(spec.type, props[name]);
82
+
83
+ // An enum value must be one of the declared options. An enum spec with no
84
+ // `values` constrains nothing, so only a present-and-mismatched value errs.
85
+ if (spec.type === "enum" && spec.values !== undefined && !isAllowed(spec.values, value)) {
86
+ errors.push(`prop "${name}" must be one of [${spec.values.join(", ")}]`);
87
+
88
+ continue;
89
+ }
90
+
91
+ out[name] = value;
92
+ }
93
+
94
+ return { props: out, errors };
95
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The registry is the vetted vocabulary: only components defined here can ever
3
+ * appear in a rendered tree. The AI proposes; the registry disposes.
4
+ */
5
+
6
+ import { assertClientDef } from "./island";
7
+ import type { ClientComponentDef } from "./island";
8
+ import type { ComponentDef } from "./types";
9
+
10
+ /**
11
+ * Where a cross-namespace shadowing warning goes. A name resolves to exactly ONE
12
+ * thing, so registering a server component over an existing client one of the
13
+ * same name (or vice versa) silently changes which side of the wire that `type`
14
+ * lives on — a likely copy-paste/rename slip, not an intent. We warn (never
15
+ * throw: last-write-wins is a legitimate redefinition) so the slip is loud.
16
+ * Injectable, defaulting to `console.warn`, so the warning is asserted in a test
17
+ * without spying on the global (the codebase's seam-not-global pattern).
18
+ */
19
+ export type ShadowWarn = (message: string) => void;
20
+
21
+ const consoleShadowWarn: ShadowWarn = (message) => console.warn(`[lesto/ui] ${message}`);
22
+
23
+ /**
24
+ * A mutable, fluent catalog of the components the engine is allowed to render.
25
+ *
26
+ * Two flavors live side by side: ordinary server components (`define`) that
27
+ * render to HTML in place, and *client* components (`defineClient`) that mark an
28
+ * island — a region the server stubs out and the browser later hydrates. Both
29
+ * share one namespace, because at the authoring layer an island is just another
30
+ * node `type`; the registry is what remembers which side of the wire it belongs
31
+ * to.
32
+ */
33
+ export class Registry {
34
+ // Insertion order is preserved so `all()` and the generated schema read
35
+ // predictably, in the order a human declared them.
36
+ private readonly byName = new Map<string, ComponentDef>();
37
+
38
+ private readonly clientsByName = new Map<string, ClientComponentDef>();
39
+
40
+ private readonly warn: ShadowWarn;
41
+
42
+ /**
43
+ * @param warn where a cross-namespace shadowing warning goes (default
44
+ * `console.warn`). Injectable so a test asserts the warning without spying on
45
+ * the global; an app rarely sets it.
46
+ */
47
+ constructor(warn: ShadowWarn = consoleShadowWarn) {
48
+ this.warn = warn;
49
+ }
50
+
51
+ /**
52
+ * Register a server component. Last definition for a name wins. Chainable.
53
+ * A name resolves to exactly one thing, so this shadows any client component
54
+ * of the same name (the inverse of `defineClient`) — and warns when it does,
55
+ * since flipping a `type` from an island to a server component is almost
56
+ * always a rename/copy-paste slip, not intent.
57
+ */
58
+ define(def: ComponentDef): this {
59
+ if (this.clientsByName.has(def.name)) {
60
+ this.warn(
61
+ `server component "${def.name}" shadows a client component of the same name — ` +
62
+ `a name resolves to one thing, so the island is no longer reachable`,
63
+ );
64
+ }
65
+
66
+ this.byName.set(def.name, def);
67
+ this.clientsByName.delete(def.name);
68
+
69
+ return this;
70
+ }
71
+
72
+ /**
73
+ * Register a client component — an island. Last definition for a name wins.
74
+ * Chainable. A name may be a server component OR a client one, never both;
75
+ * declaring a client shadows any server component of the same name and vice
76
+ * versa, so a `type` resolves to exactly one thing — and warns when it crosses
77
+ * the namespace, the same loud-when-likely-wrong as {@link define}.
78
+ *
79
+ * The eager/lazy union is re-checked at runtime for un-typed callers via the
80
+ * shared {@link assertClientDef} (the rule lives once, so this path and the
81
+ * `.page` `defineIsland` path refuse identical broken unions identically).
82
+ *
83
+ * The parameter is the ERASED {@link ClientComponentDef}, ON PURPOSE — the
84
+ * item-9/F8 phantom-type deferral (ADR 0012) is resolved here by *decision*,
85
+ * not by faking generics: the typed island authoring path is `defineIsland`
86
+ * (whose `IslandDef<P, D>` links each `DataSource<P[K]>` to the component's
87
+ * props and narrows the returned component to the unbound remainder). The
88
+ * Registry is the DB-/AI-content niche the manifest path now serves (ADR 0011
89
+ * Increment 2): its storage is a `Map<string, ClientComponentDef>` and its
90
+ * consumers (the `UiNode` walk, `getClient(name)`) are stringly by their nature
91
+ * — a `type` is a JSON string the model emitted — so a prop/data generic on
92
+ * this method would be cosmetic, erased the instant the def enters the map.
93
+ * `Island.island` (a `ClientComponentDef`) flows straight in; the type safety
94
+ * already happened at `defineIsland`.
95
+ */
96
+ defineClient(def: ClientComponentDef): this {
97
+ assertClientDef(def);
98
+
99
+ if (this.byName.has(def.name)) {
100
+ this.warn(
101
+ `client component "${def.name}" shadows a server component of the same name — ` +
102
+ `a name resolves to one thing, so the server component is no longer reachable`,
103
+ );
104
+ }
105
+
106
+ this.clientsByName.set(def.name, def);
107
+ this.byName.delete(def.name);
108
+
109
+ return this;
110
+ }
111
+
112
+ /** Look up a server component by its `type` name, or `undefined` if unknown. */
113
+ get(name: string): ComponentDef | undefined {
114
+ return this.byName.get(name);
115
+ }
116
+
117
+ /** Look up a client component (island) by name, or `undefined` if unknown. */
118
+ getClient(name: string): ClientComponentDef | undefined {
119
+ return this.clientsByName.get(name);
120
+ }
121
+
122
+ /** Is this name a registered server component? */
123
+ has(name: string): boolean {
124
+ return this.byName.has(name);
125
+ }
126
+
127
+ /** Is this name a registered client component (island)? */
128
+ hasClient(name: string): boolean {
129
+ return this.clientsByName.has(name);
130
+ }
131
+
132
+ /** Every registered server component, in declaration order. */
133
+ all(): ComponentDef[] {
134
+ return [...this.byName.values()];
135
+ }
136
+
137
+ /** Every registered client component (island), in declaration order. */
138
+ clients(): ClientComponentDef[] {
139
+ return [...this.clientsByName.values()];
140
+ }
141
+ }