@moloodev/layout-sdk 0.0.0-stage → 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/README.md +148 -2
- package/package.json +50 -3
- package/src/basic.tsx +366 -0
- package/src/catalog/index.ts +3 -0
- package/src/catalog/resolve.ts +195 -0
- package/src/catalog/validate.ts +146 -0
- package/src/catalog-format/contract.ts +131 -0
- package/src/catalog-format/fixtures.ts +148 -0
- package/src/catalog-format/index.ts +10 -0
- package/src/catalog-format/schema.ts +146 -0
- package/src/catalog-format/value-schema.ts +110 -0
- package/src/compose.tsx +78 -0
- package/src/examples/components.tsx +156 -0
- package/src/examples/index.ts +1 -0
- package/src/examples/packages.ts +42 -0
- package/src/index.ts +18 -0
- package/src/layout-format/bind.ts +88 -0
- package/src/layout-format/contract.ts +146 -0
- package/src/layout-format/edit.ts +278 -0
- package/src/layout-format/index.ts +18 -0
- package/src/layout-format/schema.ts +97 -0
- package/src/layout.ts +74 -0
- package/src/react.tsx +34 -0
- package/src/render-fixture.ts +51 -0
- package/src/runtime.tsx +165 -0
- package/src/three.tsx +19 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { contentSchema, type Content, type Element } from "./schema";
|
|
3
|
+
|
|
4
|
+
export function blankContent(): Content {
|
|
5
|
+
return { version: 1, catalogs: [], rootSurface: null, surfaces: {} };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function childIds(element: Element): string[] {
|
|
9
|
+
return [
|
|
10
|
+
...(element.children ?? []),
|
|
11
|
+
...Object.values(element.slots ?? {}).flat(),
|
|
12
|
+
];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const layoutFormat = {
|
|
16
|
+
currentSchemaVersion: 1,
|
|
17
|
+
inspect(input: unknown) {
|
|
18
|
+
const rawVersion =
|
|
19
|
+
input && typeof input === "object" && "version" in input
|
|
20
|
+
? input.version
|
|
21
|
+
: null;
|
|
22
|
+
const schemaVersion =
|
|
23
|
+
typeof rawVersion === "number" &&
|
|
24
|
+
Number.isSafeInteger(rawVersion) &&
|
|
25
|
+
rawVersion > 0
|
|
26
|
+
? rawVersion
|
|
27
|
+
: null;
|
|
28
|
+
const issues: { severity: "error"; code: string; path: (string | number)[] }[] = [];
|
|
29
|
+
const error = (code: string, path: (string | number)[]) =>
|
|
30
|
+
issues.push({ severity: "error", code, path });
|
|
31
|
+
if (schemaVersion !== 1) {
|
|
32
|
+
error(
|
|
33
|
+
schemaVersion === null ? "invalid_document" : "unsupported_schema",
|
|
34
|
+
["version"],
|
|
35
|
+
);
|
|
36
|
+
return { schemaVersion, issues, references: [] };
|
|
37
|
+
}
|
|
38
|
+
const json = z.json().safeParse(input);
|
|
39
|
+
const parsed = json.success ? contentSchema.safeParse(input) : json;
|
|
40
|
+
if (!parsed.success)
|
|
41
|
+
return {
|
|
42
|
+
schemaVersion,
|
|
43
|
+
references: [],
|
|
44
|
+
issues: parsed.error.issues.map((issue) => ({
|
|
45
|
+
severity: "error" as const,
|
|
46
|
+
code: "invalid_structure",
|
|
47
|
+
path: issue.path.filter(
|
|
48
|
+
(key): key is string | number => typeof key !== "symbol",
|
|
49
|
+
),
|
|
50
|
+
})),
|
|
51
|
+
};
|
|
52
|
+
const content = input as Content;
|
|
53
|
+
const references = content.catalogs.map((assetId, index) => ({
|
|
54
|
+
assetId,
|
|
55
|
+
assetType: "moloo.catalog",
|
|
56
|
+
path: ["catalogs", index],
|
|
57
|
+
}));
|
|
58
|
+
if (new Set(content.catalogs).size !== content.catalogs.length)
|
|
59
|
+
error("duplicate_catalog", ["catalogs"]);
|
|
60
|
+
const allIds = new Set(Object.keys(content.surfaces));
|
|
61
|
+
for (const [surfaceId, surface] of Object.entries(content.surfaces)) {
|
|
62
|
+
const base = ["surfaces", surfaceId];
|
|
63
|
+
const elements = surface.spec.elements;
|
|
64
|
+
for (const id of Object.keys(elements)) {
|
|
65
|
+
if (allIds.has(id))
|
|
66
|
+
error("duplicate_id", [...base, "spec", "elements", id]);
|
|
67
|
+
allIds.add(id);
|
|
68
|
+
}
|
|
69
|
+
const seen = new Set<string>();
|
|
70
|
+
const active = new Set<string>();
|
|
71
|
+
const walk = (id: string): void => {
|
|
72
|
+
const path = [...base, "spec", "elements", id];
|
|
73
|
+
if (active.has(id)) {
|
|
74
|
+
error("element_cycle", path);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (seen.has(id)) {
|
|
78
|
+
error("multiple_parents", path);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const element = elements[id];
|
|
82
|
+
if (!element) {
|
|
83
|
+
error("missing_element", path);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
seen.add(id);
|
|
87
|
+
active.add(id);
|
|
88
|
+
childIds(element).forEach(walk);
|
|
89
|
+
active.delete(id);
|
|
90
|
+
};
|
|
91
|
+
walk(surface.spec.root);
|
|
92
|
+
for (const id of Object.keys(elements))
|
|
93
|
+
if (!seen.has(id))
|
|
94
|
+
error("unreachable_element", [...base, "spec", "elements", id]);
|
|
95
|
+
for (const elementId of Object.keys(surface.mounts))
|
|
96
|
+
if (!elements[elementId])
|
|
97
|
+
error("missing_mount_element", [...base, "mounts", elementId]);
|
|
98
|
+
}
|
|
99
|
+
const seen = new Set<string>();
|
|
100
|
+
const active = new Set<string>();
|
|
101
|
+
const walkSurface = (id: string): void => {
|
|
102
|
+
const path = ["surfaces", id];
|
|
103
|
+
if (active.has(id)) {
|
|
104
|
+
error("surface_cycle", path);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (seen.has(id)) {
|
|
108
|
+
error("multiple_surface_parents", path);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const surface = content.surfaces[id];
|
|
112
|
+
if (!surface) {
|
|
113
|
+
error("missing_surface", path);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
seen.add(id);
|
|
117
|
+
active.add(id);
|
|
118
|
+
for (const mounts of Object.values(surface.mounts))
|
|
119
|
+
Object.values(mounts).forEach(walkSurface);
|
|
120
|
+
active.delete(id);
|
|
121
|
+
};
|
|
122
|
+
if (content.rootSurface !== null) walkSurface(content.rootSurface);
|
|
123
|
+
for (const id of Object.keys(content.surfaces))
|
|
124
|
+
if (!seen.has(id)) error("unreachable_surface", ["surfaces", id]);
|
|
125
|
+
return { schemaVersion, issues, references };
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export function readContent(input: unknown): Content | null {
|
|
130
|
+
return layoutFormat.inspect(input).issues.length ? null : (input as Content);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export class LayoutError extends Error {
|
|
134
|
+
constructor(
|
|
135
|
+
readonly code: string,
|
|
136
|
+
readonly path: readonly (string | number)[] = [],
|
|
137
|
+
) {
|
|
138
|
+
super([code, ...path].join(": "));
|
|
139
|
+
this.name = "LayoutError";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function assertContent(input: unknown): asserts input is Content {
|
|
144
|
+
const issue = layoutFormat.inspect(input).issues[0];
|
|
145
|
+
if (issue) throw new LayoutError(issue.code, issue.path);
|
|
146
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { createId } from "@moloodev/platform";
|
|
2
|
+
import { assertContent, childIds, LayoutError } from "./contract";
|
|
3
|
+
import { slotName, type Content, type Element, type Surface } from "./schema";
|
|
4
|
+
|
|
5
|
+
/** Index is measured after detaching a moved node. Omitted index appends. */
|
|
6
|
+
export type Position = {
|
|
7
|
+
surface: string;
|
|
8
|
+
parent: string;
|
|
9
|
+
slot?: string;
|
|
10
|
+
index?: number;
|
|
11
|
+
};
|
|
12
|
+
export type Mount = { surface: string; element: string; slot: string };
|
|
13
|
+
export type Fragment = { version: 1; content: Content };
|
|
14
|
+
|
|
15
|
+
function surfaceAt(content: Content, id: string): Surface {
|
|
16
|
+
const surface = Object.hasOwn(content.surfaces, id)
|
|
17
|
+
? content.surfaces[id]
|
|
18
|
+
: undefined;
|
|
19
|
+
if (!surface) throw new LayoutError("missing_surface", [id]);
|
|
20
|
+
return surface;
|
|
21
|
+
}
|
|
22
|
+
function elementAt(surface: Surface, id: string): Element {
|
|
23
|
+
const element = Object.hasOwn(surface.spec.elements, id)
|
|
24
|
+
? surface.spec.elements[id]
|
|
25
|
+
: undefined;
|
|
26
|
+
if (!element) throw new LayoutError("missing_element", [id]);
|
|
27
|
+
return element;
|
|
28
|
+
}
|
|
29
|
+
function descendants(surface: Surface, root: string): string[] {
|
|
30
|
+
const ids: string[] = [];
|
|
31
|
+
const visit = (id: string) => {
|
|
32
|
+
ids.push(id);
|
|
33
|
+
childIds(elementAt(surface, id)).forEach(visit);
|
|
34
|
+
};
|
|
35
|
+
visit(root);
|
|
36
|
+
return ids;
|
|
37
|
+
}
|
|
38
|
+
function listAt(content: Content, position: Position): string[] {
|
|
39
|
+
const parent = elementAt(
|
|
40
|
+
surfaceAt(content, position.surface),
|
|
41
|
+
position.parent,
|
|
42
|
+
);
|
|
43
|
+
if (!position.slot || position.slot === "default")
|
|
44
|
+
return (parent.children ??= []);
|
|
45
|
+
if (!slotName.safeParse(position.slot).success)
|
|
46
|
+
throw new LayoutError("invalid_slot");
|
|
47
|
+
parent.slots ??= {};
|
|
48
|
+
return (parent.slots[position.slot] ??= []);
|
|
49
|
+
}
|
|
50
|
+
function attach(content: Content, position: Position, id: string): void {
|
|
51
|
+
const list = listAt(content, position);
|
|
52
|
+
const index = position.index ?? list.length;
|
|
53
|
+
if (!Number.isInteger(index) || index < 0 || index > list.length)
|
|
54
|
+
throw new LayoutError("invalid_index");
|
|
55
|
+
list.splice(index, 0, id);
|
|
56
|
+
}
|
|
57
|
+
function detach(surface: Surface, id: string): void {
|
|
58
|
+
for (const element of Object.values(surface.spec.elements)) {
|
|
59
|
+
for (const list of [
|
|
60
|
+
element.children ?? [],
|
|
61
|
+
...Object.values(element.slots ?? {}),
|
|
62
|
+
]) {
|
|
63
|
+
const index = list.indexOf(id);
|
|
64
|
+
if (index >= 0) {
|
|
65
|
+
list.splice(index, 1);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
throw new LayoutError("cannot_move_root", [id]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Pure edits run against a draft owned by applyLayoutChange; commit validates the whole draft. */
|
|
74
|
+
export function insertElement(
|
|
75
|
+
content: Content,
|
|
76
|
+
position: Position,
|
|
77
|
+
element: Element,
|
|
78
|
+
): string {
|
|
79
|
+
if (childIds(element).length) throw new LayoutError("insert_requires_leaf");
|
|
80
|
+
const id = createId();
|
|
81
|
+
surfaceAt(content, position.surface).spec.elements[id] =
|
|
82
|
+
structuredClone(element);
|
|
83
|
+
attach(content, position, id);
|
|
84
|
+
return id;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function addSurface(
|
|
88
|
+
content: Content,
|
|
89
|
+
target: string,
|
|
90
|
+
root: Element,
|
|
91
|
+
mount?: Mount,
|
|
92
|
+
label = "",
|
|
93
|
+
): string {
|
|
94
|
+
if (childIds(root).length) throw new LayoutError("insert_requires_leaf");
|
|
95
|
+
const id = createId();
|
|
96
|
+
const rootId = createId();
|
|
97
|
+
if (mount) {
|
|
98
|
+
const host = surfaceAt(content, mount.surface);
|
|
99
|
+
elementAt(host, mount.element);
|
|
100
|
+
if (host.mounts[mount.element]?.[mount.slot])
|
|
101
|
+
throw new LayoutError("occupied_mount");
|
|
102
|
+
if (!slotName.safeParse(mount.slot).success)
|
|
103
|
+
throw new LayoutError("invalid_slot");
|
|
104
|
+
(host.mounts[mount.element] ??= {})[mount.slot] = id;
|
|
105
|
+
} else {
|
|
106
|
+
if (content.rootSurface !== null)
|
|
107
|
+
throw new LayoutError("root_surface_exists");
|
|
108
|
+
content.rootSurface = id;
|
|
109
|
+
}
|
|
110
|
+
content.surfaces[id] = {
|
|
111
|
+
target,
|
|
112
|
+
label,
|
|
113
|
+
spec: { root: rootId, elements: { [rootId]: structuredClone(root) } },
|
|
114
|
+
mounts: {},
|
|
115
|
+
};
|
|
116
|
+
return id;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function moveElement(
|
|
120
|
+
content: Content,
|
|
121
|
+
surfaceId: string,
|
|
122
|
+
id: string,
|
|
123
|
+
position: Position,
|
|
124
|
+
): void {
|
|
125
|
+
if (surfaceId !== position.surface)
|
|
126
|
+
throw new LayoutError("cross_surface_move");
|
|
127
|
+
const surface = surfaceAt(content, surfaceId);
|
|
128
|
+
if (descendants(surface, id).includes(position.parent))
|
|
129
|
+
throw new LayoutError("element_cycle", [id]);
|
|
130
|
+
detach(surface, id);
|
|
131
|
+
attach(content, position, id);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function removeSurface(content: Content, id: string): void {
|
|
135
|
+
const surface = surfaceAt(content, id);
|
|
136
|
+
for (const slots of Object.values(surface.mounts))
|
|
137
|
+
for (const child of Object.values(slots)) removeSurface(content, child);
|
|
138
|
+
for (const host of Object.values(content.surfaces))
|
|
139
|
+
for (const [element, slots] of Object.entries(host.mounts)) {
|
|
140
|
+
for (const [slot, child] of Object.entries(slots))
|
|
141
|
+
if (child === id) delete slots[slot];
|
|
142
|
+
if (!Object.keys(slots).length) delete host.mounts[element];
|
|
143
|
+
}
|
|
144
|
+
delete content.surfaces[id];
|
|
145
|
+
if (content.rootSurface === id) content.rootSurface = null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function removeElement(
|
|
149
|
+
content: Content,
|
|
150
|
+
surfaceId: string,
|
|
151
|
+
id: string,
|
|
152
|
+
): void {
|
|
153
|
+
const surface = surfaceAt(content, surfaceId);
|
|
154
|
+
if (surface.spec.root === id) {
|
|
155
|
+
removeSurface(content, surfaceId);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const ids = descendants(surface, id);
|
|
159
|
+
detach(surface, id);
|
|
160
|
+
for (const node of ids) {
|
|
161
|
+
for (const mounted of Object.values(surface.mounts[node] ?? {}))
|
|
162
|
+
removeSurface(content, mounted);
|
|
163
|
+
delete surface.mounts[node];
|
|
164
|
+
delete surface.spec.elements[node];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Snapshot only the subtree and its mounted surfaces. State paths and action params stay literal. */
|
|
169
|
+
export function copyElement(
|
|
170
|
+
content: Content,
|
|
171
|
+
surfaceId: string,
|
|
172
|
+
id: string,
|
|
173
|
+
): Fragment {
|
|
174
|
+
assertContent(content);
|
|
175
|
+
const source = surfaceAt(content, surfaceId);
|
|
176
|
+
const ids = new Set(descendants(source, id));
|
|
177
|
+
const surface = structuredClone(source);
|
|
178
|
+
surface.spec.root = id;
|
|
179
|
+
// A paste never implicitly replaces the destination's state or spec extensions.
|
|
180
|
+
surface.spec = {
|
|
181
|
+
root: id,
|
|
182
|
+
elements: Object.fromEntries(
|
|
183
|
+
Object.entries(surface.spec.elements).filter(([key]) => ids.has(key)),
|
|
184
|
+
),
|
|
185
|
+
};
|
|
186
|
+
surface.mounts = Object.fromEntries(
|
|
187
|
+
Object.entries(surface.mounts).filter(([key]) => ids.has(key)),
|
|
188
|
+
);
|
|
189
|
+
const surfaces: Content["surfaces"] = { [surfaceId]: surface };
|
|
190
|
+
const copyMounts = (host: Surface) => {
|
|
191
|
+
for (const slots of Object.values(host.mounts))
|
|
192
|
+
for (const child of Object.values(slots)) {
|
|
193
|
+
surfaces[child] = structuredClone(content.surfaces[child]!);
|
|
194
|
+
copyMounts(surfaces[child]!);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
copyMounts(surface);
|
|
198
|
+
return {
|
|
199
|
+
version: 1,
|
|
200
|
+
content: {
|
|
201
|
+
version: 1,
|
|
202
|
+
catalogs: [...content.catalogs],
|
|
203
|
+
rootSurface: surfaceId,
|
|
204
|
+
surfaces,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function pasteElement(
|
|
210
|
+
content: Content,
|
|
211
|
+
position: Position,
|
|
212
|
+
fragment: Fragment,
|
|
213
|
+
): { root: string; ids: Record<string, string> } {
|
|
214
|
+
if (fragment.version !== 1) throw new LayoutError("unsupported_clipboard");
|
|
215
|
+
assertContent(fragment.content);
|
|
216
|
+
const source = fragment.content;
|
|
217
|
+
if (source.rootSurface === null) throw new LayoutError("empty_clipboard");
|
|
218
|
+
const rootSurface = source.surfaces[source.rootSurface]!;
|
|
219
|
+
const destination = surfaceAt(content, position.surface);
|
|
220
|
+
if (destination.target !== rootSurface.target)
|
|
221
|
+
throw new LayoutError("clipboard_target_mismatch");
|
|
222
|
+
// Explicitly reject cross-stack migration until contracts can be mapped by the editor.
|
|
223
|
+
if (JSON.stringify(source.catalogs) !== JSON.stringify(content.catalogs))
|
|
224
|
+
throw new LayoutError("clipboard_catalog_mismatch");
|
|
225
|
+
const ids: Record<string, string> = {};
|
|
226
|
+
for (const [id, surface] of Object.entries(source.surfaces)) {
|
|
227
|
+
ids[id] = createId();
|
|
228
|
+
for (const node of Object.keys(surface.spec.elements))
|
|
229
|
+
ids[node] = createId();
|
|
230
|
+
}
|
|
231
|
+
const remap = (surface: Surface): Surface => {
|
|
232
|
+
const next = structuredClone(surface);
|
|
233
|
+
next.spec.root = ids[surface.spec.root]!;
|
|
234
|
+
next.spec.elements = Object.fromEntries(
|
|
235
|
+
Object.entries(next.spec.elements).map(([id, element]) => {
|
|
236
|
+
if (element.children)
|
|
237
|
+
element.children = element.children.map((child) => ids[child]!);
|
|
238
|
+
if (element.slots)
|
|
239
|
+
element.slots = Object.fromEntries(
|
|
240
|
+
Object.entries(element.slots).map(([slot, children]) => [
|
|
241
|
+
slot,
|
|
242
|
+
children.map((child) => ids[child]!),
|
|
243
|
+
]),
|
|
244
|
+
);
|
|
245
|
+
return [ids[id]!, element];
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
next.mounts = Object.fromEntries(
|
|
249
|
+
Object.entries(next.mounts).map(([id, slots]) => [
|
|
250
|
+
ids[id]!,
|
|
251
|
+
Object.fromEntries(
|
|
252
|
+
Object.entries(slots).map(([slot, child]) => [slot, ids[child]!]),
|
|
253
|
+
),
|
|
254
|
+
]),
|
|
255
|
+
);
|
|
256
|
+
return next;
|
|
257
|
+
};
|
|
258
|
+
for (const [id, surface] of Object.entries(source.surfaces)) {
|
|
259
|
+
const next = remap(surface);
|
|
260
|
+
if (id === source.rootSurface) {
|
|
261
|
+
Object.assign(destination.spec.elements, next.spec.elements);
|
|
262
|
+
Object.assign(destination.mounts, next.mounts);
|
|
263
|
+
} else content.surfaces[ids[id]!] = next;
|
|
264
|
+
}
|
|
265
|
+
const root = ids[rootSurface.spec.root]!;
|
|
266
|
+
attach(content, position, root);
|
|
267
|
+
ids[source.rootSurface] = position.surface;
|
|
268
|
+
return { root, ids };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function duplicateElement(
|
|
272
|
+
content: Content,
|
|
273
|
+
surface: string,
|
|
274
|
+
id: string,
|
|
275
|
+
position: Position,
|
|
276
|
+
) {
|
|
277
|
+
return pasteElement(content, position, copyElement(content, surface, id));
|
|
278
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type { Content, Element, Surface } from "./schema";
|
|
2
|
+
export {
|
|
3
|
+
layoutFormat,
|
|
4
|
+
blankContent,
|
|
5
|
+
readContent,
|
|
6
|
+
assertContent,
|
|
7
|
+
} from "./contract";
|
|
8
|
+
export { projectLayout, applyLayoutChange } from "./bind";
|
|
9
|
+
export {
|
|
10
|
+
addSurface,
|
|
11
|
+
insertElement,
|
|
12
|
+
removeElement,
|
|
13
|
+
moveElement,
|
|
14
|
+
copyElement,
|
|
15
|
+
pasteElement,
|
|
16
|
+
duplicateElement,
|
|
17
|
+
} from "./edit";
|
|
18
|
+
export type { Position, Mount, Fragment } from "./edit";
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ActionConfirmSchema,
|
|
3
|
+
ActionOnErrorSchema,
|
|
4
|
+
ActionOnSuccessSchema,
|
|
5
|
+
VisibilityConditionStrictSchema,
|
|
6
|
+
type Spec,
|
|
7
|
+
type UIElement,
|
|
8
|
+
} from "@json-render/core";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
const LAYOUT_SCHEMA_VERSION = 1;
|
|
12
|
+
const idSchema = z.string().regex(/^[a-z0-9]{24}$/);
|
|
13
|
+
export const slotName = z
|
|
14
|
+
.string()
|
|
15
|
+
.regex(/^[A-Za-z][A-Za-z0-9_-]*$/)
|
|
16
|
+
.refine(
|
|
17
|
+
(key) => key !== "prototype" && !Object.hasOwn(Object.prototype, key),
|
|
18
|
+
);
|
|
19
|
+
const json = z.json();
|
|
20
|
+
const action = z
|
|
21
|
+
.object({
|
|
22
|
+
action: z.string().min(1),
|
|
23
|
+
params: z.record(z.string(), json).optional(),
|
|
24
|
+
confirm: ActionConfirmSchema.optional(),
|
|
25
|
+
onSuccess: ActionOnSuccessSchema.optional(),
|
|
26
|
+
onError: ActionOnErrorSchema.optional(),
|
|
27
|
+
preventDefault: z.boolean().optional(),
|
|
28
|
+
})
|
|
29
|
+
.catchall(json);
|
|
30
|
+
const actions = z.record(z.string().min(1), z.union([action, z.array(action)]));
|
|
31
|
+
const elementSchema = z
|
|
32
|
+
.object({
|
|
33
|
+
type: z.string().regex(/^[a-z][a-z0-9-]*\.[A-Za-z][A-Za-z0-9]*$/),
|
|
34
|
+
props: z.record(z.string(), json),
|
|
35
|
+
children: z.array(idSchema).optional(),
|
|
36
|
+
slots: z
|
|
37
|
+
.record(
|
|
38
|
+
slotName.refine((key) => key !== "default"),
|
|
39
|
+
z.array(idSchema),
|
|
40
|
+
)
|
|
41
|
+
.optional(),
|
|
42
|
+
on: actions.optional(),
|
|
43
|
+
watch: actions.optional(),
|
|
44
|
+
visible: VisibilityConditionStrictSchema.optional(),
|
|
45
|
+
repeat: z
|
|
46
|
+
.strictObject({
|
|
47
|
+
statePath: z.union([z.string(), z.strictObject({ $item: z.string() })]),
|
|
48
|
+
key: z.string().optional(),
|
|
49
|
+
})
|
|
50
|
+
.optional(),
|
|
51
|
+
label: z.string().optional(),
|
|
52
|
+
})
|
|
53
|
+
.catchall(json);
|
|
54
|
+
|
|
55
|
+
const surfaceSchema = z.strictObject({
|
|
56
|
+
target: z.string().regex(/^[a-z][a-z0-9-]*$/),
|
|
57
|
+
label: z.string(),
|
|
58
|
+
spec: z
|
|
59
|
+
.object({
|
|
60
|
+
root: idSchema,
|
|
61
|
+
elements: z.record(idSchema, elementSchema),
|
|
62
|
+
state: z.record(z.string(), json).optional(),
|
|
63
|
+
})
|
|
64
|
+
.catchall(json),
|
|
65
|
+
/** Native children stay within one renderer; boundaries use explicit mounts. */
|
|
66
|
+
mounts: z.record(idSchema, z.record(slotName, idSchema)),
|
|
67
|
+
});
|
|
68
|
+
export const contentSchema = z.strictObject({
|
|
69
|
+
version: z.literal(LAYOUT_SCHEMA_VERSION),
|
|
70
|
+
catalogs: z.array(
|
|
71
|
+
z
|
|
72
|
+
.string()
|
|
73
|
+
.min(1)
|
|
74
|
+
.refine((id) => id.trim() === id),
|
|
75
|
+
),
|
|
76
|
+
rootSurface: idSchema.nullable(),
|
|
77
|
+
surfaces: z.record(idSchema, surfaceSchema),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/** Compatible json-render extensions are retained verbatim, including bindings and actions. */
|
|
81
|
+
export type Element = UIElement & { label?: string; [key: string]: unknown };
|
|
82
|
+
type LayoutSpec = Spec & {
|
|
83
|
+
elements: Record<string, Element>;
|
|
84
|
+
[key: string]: unknown;
|
|
85
|
+
};
|
|
86
|
+
export type Surface = {
|
|
87
|
+
target: string;
|
|
88
|
+
label: string;
|
|
89
|
+
spec: LayoutSpec;
|
|
90
|
+
mounts: Record<string, Record<string, string>>;
|
|
91
|
+
};
|
|
92
|
+
export type Content = {
|
|
93
|
+
version: 1;
|
|
94
|
+
catalogs: string[];
|
|
95
|
+
rootSurface: string | null;
|
|
96
|
+
surfaces: Record<string, Surface>;
|
|
97
|
+
};
|
package/src/layout.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ResourceReference } from "./catalog-format";
|
|
2
|
+
import {
|
|
3
|
+
resolveCatalog,
|
|
4
|
+
validateLayout,
|
|
5
|
+
type CatalogSource,
|
|
6
|
+
type ResolvedCatalog,
|
|
7
|
+
} from "./catalog";
|
|
8
|
+
import {
|
|
9
|
+
assertContent,
|
|
10
|
+
type Content,
|
|
11
|
+
type Surface,
|
|
12
|
+
} from "./layout-format";
|
|
13
|
+
import {
|
|
14
|
+
bindSurface,
|
|
15
|
+
RuntimeError,
|
|
16
|
+
type ImplementationPackage,
|
|
17
|
+
type PreparedSurface,
|
|
18
|
+
} from "./runtime";
|
|
19
|
+
|
|
20
|
+
export type PreparedLayout = {
|
|
21
|
+
catalog: ResolvedCatalog;
|
|
22
|
+
rootSurface: string | null;
|
|
23
|
+
surfaces: Record<string, PreparedSurface & { mounts: Surface["mounts"] }>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Source documents are supplied by the host; the document's stack determines precedence.
|
|
27
|
+
* Mounts are explicit composition instructions for host boundary adapters, not native children.
|
|
28
|
+
*/
|
|
29
|
+
export function prepareLayout({
|
|
30
|
+
content,
|
|
31
|
+
catalogs,
|
|
32
|
+
packages,
|
|
33
|
+
resolveResource,
|
|
34
|
+
}: {
|
|
35
|
+
content: Content;
|
|
36
|
+
catalogs: readonly CatalogSource[];
|
|
37
|
+
packages: readonly ImplementationPackage[];
|
|
38
|
+
resolveResource: (reference: ResourceReference) => string;
|
|
39
|
+
}): PreparedLayout {
|
|
40
|
+
assertContent(content);
|
|
41
|
+
const sources = new Map<string, CatalogSource>();
|
|
42
|
+
for (const source of catalogs) {
|
|
43
|
+
if (sources.has(source.assetId))
|
|
44
|
+
throw new RuntimeError("duplicate_catalog_source", source.assetId);
|
|
45
|
+
sources.set(source.assetId, source);
|
|
46
|
+
}
|
|
47
|
+
const catalog = resolveCatalog(
|
|
48
|
+
content.catalogs.map((id) => {
|
|
49
|
+
const source = sources.get(id);
|
|
50
|
+
if (!source) throw new RuntimeError("missing_catalog_source", id);
|
|
51
|
+
return source;
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
const specs = validateLayout(content, catalog);
|
|
55
|
+
return {
|
|
56
|
+
catalog,
|
|
57
|
+
rootSurface: content.rootSurface,
|
|
58
|
+
surfaces: Object.fromEntries(
|
|
59
|
+
Object.entries(content.surfaces).map(([id, surface]) => [
|
|
60
|
+
id,
|
|
61
|
+
{
|
|
62
|
+
...bindSurface({
|
|
63
|
+
catalog,
|
|
64
|
+
target: surface.target,
|
|
65
|
+
spec: specs[id]!,
|
|
66
|
+
packages,
|
|
67
|
+
resolveResource,
|
|
68
|
+
}),
|
|
69
|
+
mounts: structuredClone(surface.mounts),
|
|
70
|
+
},
|
|
71
|
+
]),
|
|
72
|
+
),
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/react.tsx
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ActionProvider,
|
|
3
|
+
Renderer,
|
|
4
|
+
StateProvider,
|
|
5
|
+
VisibilityProvider,
|
|
6
|
+
type ActionProviderProps,
|
|
7
|
+
} from "@json-render/react";
|
|
8
|
+
import type { StateStore } from "@json-render/core";
|
|
9
|
+
import { type PreparedSurface, RuntimeError } from "./runtime";
|
|
10
|
+
|
|
11
|
+
export type SurfaceProps = {
|
|
12
|
+
surface: PreparedSurface;
|
|
13
|
+
handlers?: ActionProviderProps["handlers"];
|
|
14
|
+
store?: StateStore;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Host renderer must provide its own root (DOM or R3F Canvas). Providers create no DOM. */
|
|
18
|
+
export function SurfaceContent({ surface, handlers, store }: SurfaceProps) {
|
|
19
|
+
return (
|
|
20
|
+
<StateProvider store={store} initialState={surface.spec.state ?? {}}>
|
|
21
|
+
<VisibilityProvider>
|
|
22
|
+
<ActionProvider handlers={handlers}>
|
|
23
|
+
<Renderer spec={surface.spec} registry={surface.registry} />
|
|
24
|
+
</ActionProvider>
|
|
25
|
+
</VisibilityProvider>
|
|
26
|
+
</StateProvider>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function DomSurface(props: SurfaceProps) {
|
|
31
|
+
if (props.surface.target !== "react-dom")
|
|
32
|
+
throw new RuntimeError("wrong_surface_target");
|
|
33
|
+
return <SurfaceContent {...props} />;
|
|
34
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import {
|
|
3
|
+
createExampleCatalogs,
|
|
4
|
+
exampleImplementation,
|
|
5
|
+
} from "./catalog-format/fixtures";
|
|
6
|
+
import { resolveCatalog } from "./catalog";
|
|
7
|
+
import type { Spec } from "@json-render/core";
|
|
8
|
+
import { prepareSurface } from "./runtime";
|
|
9
|
+
import { examplePackages } from "./examples/packages";
|
|
10
|
+
|
|
11
|
+
export function prepare(
|
|
12
|
+
target: string,
|
|
13
|
+
season = true,
|
|
14
|
+
implementationOverride = false,
|
|
15
|
+
) {
|
|
16
|
+
const { puzzle, winter } = createExampleCatalogs({
|
|
17
|
+
puzzle: "puzzle",
|
|
18
|
+
tileImage: "tile",
|
|
19
|
+
winterImage: "winter",
|
|
20
|
+
buildingModel: "building",
|
|
21
|
+
});
|
|
22
|
+
if (implementationOverride) {
|
|
23
|
+
const override = winter.entries[0]!;
|
|
24
|
+
assert.ok("overrides" in override);
|
|
25
|
+
override.implementations = {
|
|
26
|
+
"react-dom": exampleImplementation("WinterTile"),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const catalog = resolveCatalog([
|
|
30
|
+
{ assetId: "puzzle", content: puzzle },
|
|
31
|
+
...(season ? [{ assetId: "winter", content: winter }] : []),
|
|
32
|
+
]);
|
|
33
|
+
const spec: Spec = {
|
|
34
|
+
root: "tile",
|
|
35
|
+
state: { selected: true },
|
|
36
|
+
elements: {
|
|
37
|
+
tile: {
|
|
38
|
+
type: "puzzle.Tile",
|
|
39
|
+
props: { selected: { $state: "/selected" } },
|
|
40
|
+
on: { press: { action: "select" } },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
return prepareSurface({
|
|
45
|
+
catalog,
|
|
46
|
+
target,
|
|
47
|
+
spec,
|
|
48
|
+
packages: examplePackages,
|
|
49
|
+
resolveResource: ({ assetId }) => `/assets/${assetId}.png`,
|
|
50
|
+
});
|
|
51
|
+
}
|