@uicast/core 0.0.1-beta.1

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,225 @@
1
+ type JSONSchemaType =
2
+ | "object"
3
+ | "array"
4
+ | "string"
5
+ | "number"
6
+ | "boolean"
7
+ | "null"
8
+ | "integer";
9
+
10
+ /**
11
+ * JSON Schema shape this module reads — a recursive structural type covering
12
+ * the draft-07 / 2020-12 keywords a Standard-Schema `toJSONSchema()` can emit
13
+ * (primitives, composition, objects, arrays/tuples, `$ref` + `$defs`, and the
14
+ * refinement keywords that constrain a value without changing its TypeScript
15
+ * type). Every sub-schema slot is another `JSONSchema`.
16
+ *
17
+ * `JSONSchemaToTs` still takes `unknown` and narrows at runtime, because a
18
+ * schema value can also be a boolean (`true`/`false`) — and, in legacy
19
+ * draft-07, an `items` array — which this object type intentionally doesn't
20
+ * model. The function reads back through itself, so the recursion stays loose.
21
+ */
22
+ export interface JSONSchema {
23
+ $schema?:
24
+ | "https://json-schema.org/draft/2020-12/schema"
25
+ | "http://json-schema.org/draft-07/schema#";
26
+ type?: JSONSchemaType | JSONSchemaType[];
27
+ format?: string;
28
+ pattern?: string;
29
+ $ref?: string;
30
+ items?: boolean | JSONSchema;
31
+ prefixItems?: JSONSchema[];
32
+ additionalItems?: boolean | JSONSchema;
33
+ enum?: unknown[];
34
+ minimum?: number;
35
+ maximum?: number;
36
+ exclusiveMinimum?: number;
37
+ exclusiveMaximum?: number;
38
+ minItems?: number;
39
+ maxItems?: number;
40
+ title?: string;
41
+ description?: string;
42
+ properties?: { [key: string]: JSONSchema };
43
+ required?: string[];
44
+ examples?: unknown[];
45
+ not?: JSONSchema;
46
+ // support both $defs and definitions
47
+ $defs?: { [key: string]: JSONSchema };
48
+ definitions?: { [key: string]: JSONSchema };
49
+ additionalProperties?: boolean | JSONSchema;
50
+ anyOf?: JSONSchema[];
51
+ oneOf?: JSONSchema[];
52
+ allOf?: JSONSchema[];
53
+ // older schema
54
+ const?: unknown;
55
+ example?: unknown;
56
+ // binary
57
+ contentEncoding?: string;
58
+ contentMediaType?: string;
59
+ minLength?: number;
60
+ maxLength?: number;
61
+ // explicit TypeScript-type override for code generation
62
+ "x-tsType"?: string;
63
+ }
64
+
65
+ /**
66
+ * Convert a JSON Schema to a compact TypeScript type string for the prompt.
67
+ *
68
+ * Pass the whole schema document (with any `$defs` / `definitions`): local
69
+ * `$ref`s resolve against it, and a recursive schema terminates — the cycle's
70
+ * back-edge renders as `unknown` while everything above it stays fully typed.
71
+ */
72
+ export function JSONSchemaToTs(jsonSchema: unknown): string {
73
+ return toTs(jsonSchema, jsonSchema, new Set());
74
+ }
75
+
76
+ /**
77
+ * Recursive worker. `root` is the document the first call was handed (the
78
+ * resolution base for `$ref`); `seen` is the set of refs currently being
79
+ * expanded on this path, so a cycle short-circuits to `unknown` instead of
80
+ * recursing forever.
81
+ */
82
+ function toTs(jsonSchema: unknown, root: unknown, seen: Set<string>): string {
83
+ if (jsonSchema === true) return "unknown";
84
+ if (jsonSchema === false) return "never";
85
+ if (jsonSchema === null || jsonSchema === undefined) return "unknown";
86
+ if (typeof jsonSchema !== "object") return "unknown";
87
+ const schema = jsonSchema as JSONSchema;
88
+
89
+ if (typeof schema.$ref === "string") {
90
+ if (seen.has(schema.$ref)) return "unknown"; // cycle back-edge
91
+ const target = resolveRef(schema.$ref, root);
92
+ if (target === undefined) return "unknown";
93
+ seen.add(schema.$ref);
94
+ const resolved = toTs(target, root, seen);
95
+ seen.delete(schema.$ref);
96
+ return resolved;
97
+ }
98
+
99
+ if ("const" in schema) return JSON.stringify(schema.const);
100
+
101
+ if (schema.enum) {
102
+ return schema.enum.map((v) => JSON.stringify(v)).join(" | ") || "never";
103
+ }
104
+
105
+ if (schema.allOf) {
106
+ const parts = schema.allOf.map((s) => toTs(s, root, seen));
107
+ return parts.length ? `(${parts.join(" & ")})` : "unknown";
108
+ }
109
+ if (schema.anyOf) {
110
+ const parts = schema.anyOf.map((s) => toTs(s, root, seen));
111
+ return parts.length ? `(${parts.join(" | ")})` : "never";
112
+ }
113
+ if (schema.oneOf) {
114
+ const parts = schema.oneOf.map((s) => toTs(s, root, seen));
115
+ return parts.length ? `(${parts.join(" | ")})` : "never";
116
+ }
117
+ if (schema.not) return "unknown";
118
+
119
+ if (Array.isArray(schema.type)) {
120
+ const types = schema.type.map((t) =>
121
+ toTs({ ...schema, type: t }, root, seen),
122
+ );
123
+ return types.length ? `(${types.join(" | ")})` : "unknown";
124
+ }
125
+
126
+ const type = schema.type;
127
+
128
+ if (type === "string") return "string";
129
+ if (type === "number" || type === "integer") return "number";
130
+ if (type === "boolean") return "boolean";
131
+ if (type === "null") return "null";
132
+
133
+ if (
134
+ type === "object" ||
135
+ schema.properties ||
136
+ schema.additionalProperties !== undefined
137
+ ) {
138
+ const props = schema.properties || {};
139
+ const required = schema.required || [];
140
+
141
+ const propStrings = Object.entries(props).map(([key, value]) => {
142
+ const isRequired = required.includes(key);
143
+ const safeName = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)
144
+ ? key
145
+ : JSON.stringify(key);
146
+ return `${safeName}${isRequired ? "" : "?"}: ${toTs(value, root, seen)}`;
147
+ });
148
+
149
+ let additionalType: string | null = null;
150
+ if (schema.additionalProperties === true) {
151
+ additionalType = "unknown";
152
+ } else if (
153
+ schema.additionalProperties &&
154
+ typeof schema.additionalProperties === "object"
155
+ ) {
156
+ additionalType = toTs(schema.additionalProperties, root, seen);
157
+ }
158
+
159
+ if (propStrings.length === 0 && additionalType) {
160
+ return `{ [key: string]: ${additionalType} }`;
161
+ }
162
+ if (propStrings.length === 0 && !additionalType) {
163
+ return schema.additionalProperties === false
164
+ ? "{}"
165
+ : "{ [key: string]: unknown }";
166
+ }
167
+
168
+ let result = `{ ${propStrings.join("; ")} }`;
169
+ if (additionalType) {
170
+ result = `(${result} & { [key: string]: ${additionalType} })`;
171
+ }
172
+ return result;
173
+ }
174
+
175
+ if (type === "array" || schema.items || schema.prefixItems) {
176
+ if (schema.prefixItems) {
177
+ const tupleTypes = schema.prefixItems.map((s) => toTs(s, root, seen));
178
+ if (schema.items === false) return `[${tupleTypes.join(", ")}]`;
179
+ const restType = schema.items ? toTs(schema.items, root, seen) : "unknown";
180
+ return `[${tupleTypes.join(", ")}, ...${restType}[]]`;
181
+ }
182
+
183
+ if (schema.items !== undefined && schema.items !== null) {
184
+ if (Array.isArray(schema.items)) {
185
+ const tupleTypes = schema.items.map((s) => toTs(s, root, seen));
186
+ if (schema.additionalItems === false) {
187
+ return `[${tupleTypes.join(", ")}]`;
188
+ }
189
+ const restType = schema.additionalItems
190
+ ? toTs(schema.additionalItems, root, seen)
191
+ : "unknown";
192
+ return `[${tupleTypes.join(", ")}, ...${restType}[]]`;
193
+ }
194
+ return `${toTs(schema.items, root, seen)}[]`;
195
+ }
196
+
197
+ return "unknown[]";
198
+ }
199
+
200
+ if (schema.properties) {
201
+ return toTs({ ...schema, type: "object" }, root, seen);
202
+ }
203
+
204
+ return "unknown";
205
+ }
206
+
207
+ /**
208
+ * Resolve a local JSON Pointer ref (`#/$defs/Foo`, `#/definitions/Bar`)
209
+ * against the document root. Returns the target sub-schema, or `undefined`
210
+ * for a non-local ref (`https://…`, bare `#`) or a path that doesn't exist —
211
+ * the caller renders those as `unknown`.
212
+ */
213
+ function resolveRef(ref: string, root: unknown): unknown {
214
+ if (!ref.startsWith("#/")) return undefined;
215
+ const segments = ref
216
+ .slice(2)
217
+ .split("/")
218
+ .map((seg) => seg.replace(/~1/g, "/").replace(/~0/g, "~"));
219
+ let node: unknown = root;
220
+ for (const seg of segments) {
221
+ if (node === null || typeof node !== "object") return undefined;
222
+ node = (node as Record<string, unknown>)[seg];
223
+ }
224
+ return node;
225
+ }
@@ -0,0 +1,131 @@
1
+ type EventHandler<T = unknown> = (payload: T) => void;
2
+
3
+ interface ChangePayload<T = unknown> {
4
+ path: string;
5
+ value: T;
6
+ oldValue: T;
7
+ }
8
+
9
+ interface Emitter {
10
+ on<T = unknown>(type: string, handler: EventHandler<T>): () => void;
11
+ off<T = unknown>(type: string, handler: EventHandler<T>): void;
12
+ emit<T = unknown>(type: string, payload: T): void;
13
+ }
14
+
15
+ function createEmitter(): Emitter {
16
+ const events = new Map<string, Set<EventHandler<any>>>();
17
+
18
+ return {
19
+ on(type, handler) {
20
+ const handlers = events.get(type);
21
+ if (handlers) handlers.add(handler);
22
+ else events.set(type, new Set([handler]));
23
+ return () => events.get(type)?.delete(handler);
24
+ },
25
+
26
+ off(type, handler) {
27
+ events.get(type)?.delete(handler);
28
+ },
29
+
30
+ emit(type, payload) {
31
+ events.get(type)?.forEach((fn) => {
32
+ fn(payload);
33
+ });
34
+ },
35
+ };
36
+ }
37
+
38
+ type ReactiveProxy<T extends object = object> = T & {
39
+ $emitter: Emitter;
40
+ $set: (path: string, value: unknown, options?: { default?: boolean }) => void;
41
+ };
42
+
43
+ function createProxyScope<T extends object>(
44
+ target: T = {} as T,
45
+ ): ReactiveProxy<T> {
46
+ const emitter = createEmitter();
47
+ const proxyCache = new WeakMap<object, object>();
48
+
49
+ function set(
50
+ path: string,
51
+ value: unknown,
52
+ options?: { default?: boolean },
53
+ ): void {
54
+ const keys = path.split(".");
55
+ let current: any = proxy;
56
+
57
+ for (let i = 0; i < keys.length - 1; i++) {
58
+ const key = keys[i];
59
+ if (current[key] === undefined || current[key] === null) {
60
+ current[key] = {};
61
+ }
62
+ current = current[key];
63
+ }
64
+
65
+ const lastKey = keys[keys.length - 1];
66
+ // `default: true` → init-if-absent (first-writer-wins); never clobber an
67
+ // existing value. Used by `seed` so two components seeding the same
68
+ // path don't overwrite each other, and user edits survive remounts.
69
+ if (options?.default && current[lastKey] !== undefined) return;
70
+ current[lastKey] = value;
71
+ }
72
+
73
+ function wrap<U>(obj: U, path: PropertyKey[] = []): U {
74
+ if (obj === null || typeof obj !== "object") return obj;
75
+ if (proxyCache.has(obj as object))
76
+ return proxyCache.get(obj as object) as U;
77
+
78
+ const proxy = new Proxy(obj as object, {
79
+ get(target, prop, receiver) {
80
+ if (path.length === 0) {
81
+ if (prop === "$emitter") return emitter;
82
+ if (prop === "$set") return set;
83
+ }
84
+ const value = Reflect.get(target, prop, receiver);
85
+
86
+ // Proxy invariant: for non-configurable, non-writable properties
87
+ // the trap MUST return the exact target value.
88
+ if (value !== null && typeof value === "object") {
89
+ const desc = Object.getOwnPropertyDescriptor(target, prop);
90
+ if (desc && !desc.configurable && !desc.writable) {
91
+ return value;
92
+ }
93
+ }
94
+
95
+ return wrap(value, path.concat(prop));
96
+ },
97
+
98
+ set(target, prop, value, receiver) {
99
+ const oldValue = (target as Record<PropertyKey, unknown>)[prop];
100
+ const result = Reflect.set(target, prop, value, receiver);
101
+
102
+ if (oldValue !== value) {
103
+ const fullPath = path.concat(prop).join(".");
104
+ emitter.emit<ChangePayload>(fullPath, {
105
+ path: fullPath,
106
+ value,
107
+ oldValue,
108
+ });
109
+ }
110
+
111
+ return result;
112
+ },
113
+ }) as U;
114
+
115
+ proxyCache.set(obj as object, proxy as object);
116
+ return proxy;
117
+ }
118
+
119
+ const proxy = wrap(target) as ReactiveProxy<T>;
120
+
121
+ return proxy;
122
+ }
123
+
124
+ export {
125
+ createProxyScope,
126
+ createEmitter,
127
+ type Emitter,
128
+ type ChangePayload,
129
+ type EventHandler,
130
+ type ReactiveProxy,
131
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Split a scope key into `[scopeName, leafPath]`.
3
+ *
4
+ * Strips the leading namespace token (default `"scopes"`) if present, then
5
+ * splits on the first dot: the head is the named scope, the tail is the path
6
+ * *within* that scope.
7
+ *
8
+ * parseScope("scopes.root.user.name") → ["root", "user.name"]
9
+ * parseScope("root.count") → ["root", "count"] (prefix optional)
10
+ *
11
+ * `prefix` (default `"scopes"`) is the leading namespace identifier the dotted
12
+ * key is written against; it builds the `scopesPrefix` (`prefix + "."`) that
13
+ * gets stripped. Pass a different value when keys are written against another
14
+ * root identifier.
15
+ *
16
+ * This is the scope-layer counterpart to `createProxyScope`: `extractDeps`
17
+ * produces `scopes.X.Y` read strings, and the renderer hands each to
18
+ * `parseScope(...)` to route an `$emitter.on(targetPath, …)` subscription.
19
+ */
20
+ export const parseScope = (key: string, prefix = "scopes") => {
21
+ // Strip the "<prefix>." namespace token if present.
22
+ const scopesPrefix = `${prefix}.`;
23
+ const normalizedKey = key.startsWith(scopesPrefix)
24
+ ? key.slice(scopesPrefix.length)
25
+ : key;
26
+
27
+ const dotIndex = normalizedKey.indexOf(".");
28
+ if (dotIndex === -1) {
29
+ throw new Error(`Invalid scope key: ${key}`);
30
+ } else {
31
+ return [
32
+ normalizedKey.slice(0, dotIndex),
33
+ normalizedKey.slice(dotIndex + 1),
34
+ ] as [string, string];
35
+ }
36
+ };
@@ -0,0 +1,56 @@
1
+ // Yields every parseable JSON value from a byte or string stream, skipping
2
+ // blank/non-JSON lines (code fences, model prose, partial lines). Narrow the
3
+ // element type via <T>; shape-checking is the caller's concern.
4
+ export async function* streamJsonLines<T = unknown>(
5
+ source: ReadableStream<Uint8Array | string> | AsyncIterable<string | Uint8Array>,
6
+ ): AsyncGenerator<T> {
7
+ const decoder = new TextDecoder();
8
+ let buffer = "";
9
+
10
+ for await (const chunk of toAsyncIterable(source)) {
11
+ buffer += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
12
+
13
+ let nl = buffer.indexOf("\n");
14
+ while (nl !== -1) {
15
+ yield* parseLine<T>(buffer.slice(0, nl));
16
+ buffer = buffer.slice(nl + 1);
17
+ nl = buffer.indexOf("\n");
18
+ }
19
+ }
20
+
21
+ yield* parseLine<T>(buffer + decoder.decode());
22
+ }
23
+
24
+ function* parseLine<T>(raw: string): Generator<T> {
25
+ const line = raw.trim();
26
+ if (!line) return;
27
+ try {
28
+ yield JSON.parse(line) as T;
29
+ } catch {
30
+ // non-JSON noise (fences, prose, partial) — drop it
31
+ }
32
+ }
33
+
34
+ function toAsyncIterable(
35
+ source: ReadableStream<Uint8Array | string> | AsyncIterable<string | Uint8Array>,
36
+ ): AsyncIterable<string | Uint8Array> {
37
+ if (Symbol.asyncIterator in source) {
38
+ return source as AsyncIterable<string | Uint8Array>;
39
+ }
40
+ // Browser ReadableStream isn't async-iterable — pull it through a reader.
41
+ const stream = source as ReadableStream<Uint8Array | string>;
42
+ return {
43
+ async *[Symbol.asyncIterator]() {
44
+ const reader = stream.getReader();
45
+ try {
46
+ for (;;) {
47
+ const { done, value } = await reader.read();
48
+ if (done) return;
49
+ if (value !== undefined) yield value;
50
+ }
51
+ } finally {
52
+ reader.releaseLock();
53
+ }
54
+ },
55
+ };
56
+ }
package/src/types.ts ADDED
@@ -0,0 +1,124 @@
1
+ import type {
2
+ StandardJSONSchemaV1,
3
+ StandardSchemaV1,
4
+ } from "@standard-schema/spec";
5
+
6
+ // A bare JavaScript expression string, always evaluated as code (no literal
7
+ // form). Used by `hidden` and `each`.
8
+ export type Expression = string;
9
+
10
+ // A dotted path into the reactive `scopes` store — the write target of an
11
+ // assignment, e.g. `"scopes.root.users"`. Logically just a string; the alias
12
+ // documents intent (mirrors `Expression`). Used by the `set` field below.
13
+ export type ScopePath = string;
14
+
15
+ // A value: `literal` is any JSON value used as-is (no evaluation); `expr` is
16
+ // evaluated as code.
17
+ export type ValueSource = { expr: Expression } | { literal: unknown };
18
+ export type ValueSourceAssignment = { set: ScopePath } & ValueSource;
19
+ export type ConfirmableValueSourceAssignment = {
20
+ confirm?: string;
21
+ } & ValueSourceAssignment;
22
+
23
+ /**
24
+ * The uicast entry model. One line of a uicast document is a
25
+ * `ComponentEntry`. A `ComponentListEntry` is an entry that repeats: it carries
26
+ * `each` (the array to iterate) plus `as` and an optional `keyBy`.
27
+ *
28
+ * There is no separate "plain entry" type and no
29
+ * `ComponentEntry | ComponentListEntry` union: the list fields (`each`, `as`,
30
+ * `keyBy`) live on `ComponentEntry` as optional, so one type describes every
31
+ * line and a heterogeneous `ComponentEntry[]` can hold list lines directly.
32
+ * `ComponentListEntry` is the same shape with `each`/`as` required — narrow
33
+ * `ComponentEntry` → `ComponentListEntry` with `isComponentListEntry` (presence
34
+ * of `each`).
35
+ */
36
+ export interface ComponentEntry {
37
+ key: string;
38
+ component: string;
39
+ props?: ValueSource;
40
+ seed?: ValueSourceAssignment[];
41
+ hidden?: Expression;
42
+ callbacks?: Record<string, ConfirmableValueSourceAssignment[]>;
43
+ children?: string[];
44
+ // List fields — present only when the entry repeats (`each` is the marker).
45
+ // Optional here so a heterogeneous `ComponentEntry[]` can hold list lines
46
+ // directly; `ComponentListEntry` below is the same shape with `each`/`as`
47
+ // required.
48
+ each?: Expression;
49
+ as?: string;
50
+ keyBy?: (string & {}) | "_index" | "_item";
51
+ }
52
+
53
+ /**
54
+ * A list entry repeats — the same shape as `ComponentEntry` with the iteration
55
+ * fields required. `each` is the structural marker (its presence is what makes
56
+ * an entry a list); `as` names the per-item scope.
57
+ */
58
+ export interface ComponentListEntry extends ComponentEntry {
59
+ each: Expression;
60
+ as: string;
61
+ }
62
+
63
+ /**
64
+ * Narrow a `ComponentEntry` to a `ComponentListEntry`. List-ness is the
65
+ * presence of `each` — there is no `kind` discriminator, exactly like root-ness
66
+ * (derived from an entry being referenced by no other entry's `children`).
67
+ */
68
+ export function isComponentListEntry(
69
+ entry: ComponentEntry,
70
+ ): entry is ComponentListEntry {
71
+ return entry.each !== undefined;
72
+ }
73
+
74
+ /**
75
+ * Narrow an untrusted parsed value to a `ComponentEntry` — the shape guard for
76
+ * model-emitted lines (`streamJsonLines` output, fence bodies, stored JSONL).
77
+ */
78
+ export function isComponentEntry(value: unknown): value is ComponentEntry {
79
+ if (!value || typeof value !== "object") return false;
80
+ const entry = value as ComponentEntry;
81
+ return (
82
+ typeof entry.key === "string" &&
83
+ typeof entry.component === "string" &&
84
+ // The least-trusted input path: `children` as a string (a plausible LLM
85
+ // slip) would be iterated char-by-char downstream into phantom child slots.
86
+ (entry.children === undefined || Array.isArray(entry.children))
87
+ );
88
+ }
89
+
90
+ /**
91
+ * An interface that combines StandardJSONSchema and StandardSchema.
92
+ * */
93
+ export type CombinedSpec<Input = unknown, Output = Input> = StandardSchemaV1<Input, Output> &
94
+ StandardJSONSchemaV1<Input, Output>;
95
+
96
+ /**
97
+ * The partner module the LLM reads for a component: a props spec plus optional
98
+ * callback specs (its `description` + `props` schema are serialized into the
99
+ * prompt). Parameterized over the concrete `props` / `callbacks` specs so an
100
+ * implementation can recover their exact types — `createComponentImplementation`
101
+ * infers `TProps` / `TCallbacks` straight off the `def` it's handed to type its
102
+ * `render` callback. Both params default to the widened base, so a bare
103
+ * `ComponentDefinition` is the heterogeneous form used in collections
104
+ * (`ComponentDefinition[]`, the `allDefinitions` registry, the prompt
105
+ * serializer). The `createComponentDefinition` factory (in
106
+ * `def/create-component-definition.ts`) is the value-side constructor for this.
107
+ */
108
+ export type ComponentDefinition<
109
+ TProps extends CombinedSpec = CombinedSpec,
110
+ TCallbacks extends Record<string, CombinedSpec> = Record<string, CombinedSpec>,
111
+ > = {
112
+ name: string;
113
+ description: string;
114
+ props: TProps;
115
+ callbacks?: TCallbacks;
116
+ /**
117
+ * Host-only component. Registered in the renderer registry so entries
118
+ * referencing it mount correctly, but filtered out of the LLM-facing
119
+ * prompt list in `getComponentsPartialPrompt`. Use for internal infrastructure
120
+ * (e.g. RootFragment — the synthetic wrapper used by `Renderer`'s `init`
121
+ * prop machinery) that the LLM should never emit.
122
+ */
123
+ hidden?: boolean;
124
+ };
@@ -0,0 +1,64 @@
1
+ import type { ComponentEntry } from "../types";
2
+
3
+ /**
4
+ * Collect all descendant IDs of a given entry (not including the entry itself)
5
+ * by walking its children array recursively.
6
+ */
7
+ function collectDescendantIds(
8
+ id: string,
9
+ map: Record<string, ComponentEntry>,
10
+ ): Set<string> {
11
+ const result = new Set<string>();
12
+ const stack = [id];
13
+ while (stack.length > 0) {
14
+ const current = stack.pop()!;
15
+ const entry = map[current];
16
+ if (entry?.children) {
17
+ for (const childId of entry.children) {
18
+ if (!result.has(childId)) {
19
+ result.add(childId);
20
+ stack.push(childId);
21
+ }
22
+ }
23
+ }
24
+ }
25
+ return result;
26
+ }
27
+
28
+ /**
29
+ * Build an elements-by-key map from the NDJSON lines array.
30
+ *
31
+ * When an entry with a duplicate key is encountered (i.e. the LLM re-emits a
32
+ * entry to correct a mistake), the old subtree is removed before inserting
33
+ * the replacement — except old nodes the new `children` array still
34
+ * references (directly or through a kept child), which survive with their
35
+ * own subtrees. This lets the LLM restructure a parent by re-emitting just
36
+ * that entry, keeping existing children by reference, without regenerating
37
+ * the entire tree.
38
+ */
39
+ export function buildElementsById(
40
+ lines: ComponentEntry[],
41
+ ): Record<string, ComponentEntry> {
42
+ const map: Record<string, ComponentEntry> = {};
43
+
44
+ for (const line of lines) {
45
+ if (map[line.key]) {
46
+ const oldDescendants = collectDescendantIds(line.key, map);
47
+ const kept = new Set<string>();
48
+ const stack = [...(line.children ?? [])];
49
+ while (stack.length > 0) {
50
+ const id = stack.pop()!;
51
+ if (kept.has(id) || !oldDescendants.has(id)) continue;
52
+ kept.add(id);
53
+ const entry = map[id];
54
+ if (entry?.children) stack.push(...entry.children);
55
+ }
56
+ for (const descId of oldDescendants) {
57
+ if (!kept.has(descId)) delete map[descId];
58
+ }
59
+ }
60
+ map[line.key] = line;
61
+ }
62
+
63
+ return map;
64
+ }