@moloodev/layout-sdk 0.0.0-stage → 0.2.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,195 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readContent,
|
|
3
|
+
valueValidator,
|
|
4
|
+
type Content,
|
|
5
|
+
type ComponentContract,
|
|
6
|
+
type ComponentDefinition,
|
|
7
|
+
} from "../catalog-format";
|
|
8
|
+
|
|
9
|
+
export type CatalogSource = { assetId: string; content: Content };
|
|
10
|
+
type Provenance = {
|
|
11
|
+
definition: string;
|
|
12
|
+
overrides: string[];
|
|
13
|
+
defaults: Record<string, string>;
|
|
14
|
+
resources: Record<string, string>;
|
|
15
|
+
implementations: Record<string, string>;
|
|
16
|
+
};
|
|
17
|
+
type ResolvedComponent = ComponentDefinition & {
|
|
18
|
+
provenance: Provenance;
|
|
19
|
+
};
|
|
20
|
+
export type ResolvedCatalog = {
|
|
21
|
+
stack: string[];
|
|
22
|
+
components: Record<string, ResolvedComponent>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type CatalogErrorCode =
|
|
26
|
+
| "invalid_catalog"
|
|
27
|
+
| "duplicate_catalog"
|
|
28
|
+
| "missing_dependency"
|
|
29
|
+
| "dependency_order"
|
|
30
|
+
| "dependency_cycle"
|
|
31
|
+
| "component_collision"
|
|
32
|
+
| "missing_override_target"
|
|
33
|
+
| "contract_mismatch"
|
|
34
|
+
| "invalid_default"
|
|
35
|
+
| "invalid_resource"
|
|
36
|
+
| "missing_resource"
|
|
37
|
+
| "unknown_component"
|
|
38
|
+
| "unsupported_target";
|
|
39
|
+
|
|
40
|
+
export class CatalogError extends Error {
|
|
41
|
+
constructor(
|
|
42
|
+
readonly code: CatalogErrorCode,
|
|
43
|
+
readonly assetId: string,
|
|
44
|
+
readonly component?: string,
|
|
45
|
+
readonly field?: string,
|
|
46
|
+
) {
|
|
47
|
+
super([code, assetId, component, field].filter(Boolean).join(": "));
|
|
48
|
+
this.name = "CatalogError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Stable structural identity; runtime packages must match the complete contract. */
|
|
53
|
+
export function contractIdentity(contract: ComponentContract): string {
|
|
54
|
+
function sort(value: unknown): unknown {
|
|
55
|
+
if (Array.isArray(value)) return value.map(sort);
|
|
56
|
+
if (value !== null && typeof value === "object")
|
|
57
|
+
return Object.fromEntries(
|
|
58
|
+
Object.entries(value)
|
|
59
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
60
|
+
.map(([key, child]) => [key, sort(child)]),
|
|
61
|
+
);
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
return JSON.stringify(sort(contract));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Caller resolves/pins assets. Composition neither fetches nor silently expands the stack. */
|
|
68
|
+
export function resolveCatalog(
|
|
69
|
+
stack: readonly CatalogSource[],
|
|
70
|
+
): ResolvedCatalog {
|
|
71
|
+
const sources = new Map<string, CatalogSource>();
|
|
72
|
+
for (const source of stack) {
|
|
73
|
+
if (
|
|
74
|
+
!source.assetId.trim() ||
|
|
75
|
+
source.assetId.trim() !== source.assetId ||
|
|
76
|
+
!readContent(source.content)
|
|
77
|
+
)
|
|
78
|
+
throw new CatalogError("invalid_catalog", source.assetId);
|
|
79
|
+
if (sources.has(source.assetId))
|
|
80
|
+
throw new CatalogError("duplicate_catalog", source.assetId);
|
|
81
|
+
sources.set(source.assetId, source);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const visiting = new Set<string>();
|
|
85
|
+
const visited = new Set<string>();
|
|
86
|
+
function visit(id: string) {
|
|
87
|
+
if (visiting.has(id)) throw new CatalogError("dependency_cycle", id);
|
|
88
|
+
if (visited.has(id)) return;
|
|
89
|
+
visiting.add(id);
|
|
90
|
+
for (const dependency of sources.get(id)!.content.requires) {
|
|
91
|
+
if (!sources.has(dependency))
|
|
92
|
+
throw new CatalogError("missing_dependency", id, undefined, dependency);
|
|
93
|
+
visit(dependency);
|
|
94
|
+
}
|
|
95
|
+
visiting.delete(id);
|
|
96
|
+
visited.add(id);
|
|
97
|
+
}
|
|
98
|
+
stack.forEach(({ assetId }) => visit(assetId));
|
|
99
|
+
|
|
100
|
+
const components: Record<string, ResolvedComponent> = Object.create(null);
|
|
101
|
+
const applied = new Set<string>();
|
|
102
|
+
for (const { assetId, content } of stack) {
|
|
103
|
+
for (const dependency of content.requires)
|
|
104
|
+
if (!applied.has(dependency))
|
|
105
|
+
throw new CatalogError(
|
|
106
|
+
"dependency_order",
|
|
107
|
+
assetId,
|
|
108
|
+
undefined,
|
|
109
|
+
dependency,
|
|
110
|
+
);
|
|
111
|
+
for (const original of content.entries) {
|
|
112
|
+
const entry = structuredClone(original);
|
|
113
|
+
if ("key" in entry) {
|
|
114
|
+
if (components[entry.key])
|
|
115
|
+
throw new CatalogError("component_collision", assetId, entry.key);
|
|
116
|
+
const origin = (values: object) =>
|
|
117
|
+
Object.fromEntries(Object.keys(values).map((key) => [key, assetId]));
|
|
118
|
+
components[entry.key] = {
|
|
119
|
+
...entry,
|
|
120
|
+
provenance: {
|
|
121
|
+
definition: assetId,
|
|
122
|
+
overrides: [],
|
|
123
|
+
defaults: origin(entry.defaults),
|
|
124
|
+
resources: origin(entry.resources),
|
|
125
|
+
implementations: origin(entry.implementations),
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
} else {
|
|
129
|
+
const current = components[entry.overrides];
|
|
130
|
+
if (!current)
|
|
131
|
+
throw new CatalogError(
|
|
132
|
+
"missing_override_target",
|
|
133
|
+
assetId,
|
|
134
|
+
entry.overrides,
|
|
135
|
+
);
|
|
136
|
+
if (current.contract.version !== entry.requiresContract)
|
|
137
|
+
throw new CatalogError("contract_mismatch", assetId, entry.overrides);
|
|
138
|
+
// Replace entries, never recursively merge objects, arrays, or package references.
|
|
139
|
+
for (const field of [
|
|
140
|
+
"defaults",
|
|
141
|
+
"resources",
|
|
142
|
+
"implementations",
|
|
143
|
+
] as const) {
|
|
144
|
+
Object.assign(current[field], entry[field]);
|
|
145
|
+
for (const key of Object.keys(entry[field] ?? {}))
|
|
146
|
+
current.provenance[field][key] = assetId;
|
|
147
|
+
}
|
|
148
|
+
current.provenance.overrides.push(assetId);
|
|
149
|
+
}
|
|
150
|
+
// Check every layer, so a later override cannot hide an invalid earlier value.
|
|
151
|
+
const key = "key" in entry ? entry.key : entry.overrides;
|
|
152
|
+
const current = components[key]!;
|
|
153
|
+
for (const [name, value] of Object.entries(current.defaults)) {
|
|
154
|
+
const schema = current.contract.props.properties[name];
|
|
155
|
+
if (!schema || !valueValidator(schema).safeParse(value).success)
|
|
156
|
+
throw new CatalogError("invalid_default", assetId, key, name);
|
|
157
|
+
}
|
|
158
|
+
for (const [slot, resource] of Object.entries(current.resources)) {
|
|
159
|
+
if (current.contract.resources[slot]?.assetType !== resource.assetType)
|
|
160
|
+
throw new CatalogError("invalid_resource", assetId, key, slot);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
applied.add(assetId);
|
|
164
|
+
}
|
|
165
|
+
for (const component of Object.values(components)) {
|
|
166
|
+
for (const slot of Object.keys(component.contract.resources))
|
|
167
|
+
if (!component.resources[slot])
|
|
168
|
+
throw new CatalogError(
|
|
169
|
+
"missing_resource",
|
|
170
|
+
component.provenance.definition,
|
|
171
|
+
component.key,
|
|
172
|
+
slot,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return { stack: stack.map(({ assetId }) => assetId), components };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Only components used by a surface need an implementation for that surface's target. */
|
|
179
|
+
export function requireTarget(
|
|
180
|
+
catalog: ResolvedCatalog,
|
|
181
|
+
keys: readonly string[],
|
|
182
|
+
target: string,
|
|
183
|
+
): void {
|
|
184
|
+
for (const key of keys) {
|
|
185
|
+
const component = catalog.components[key];
|
|
186
|
+
if (!component) throw new CatalogError("unknown_component", "", key);
|
|
187
|
+
if (!Object.hasOwn(component.implementations, target))
|
|
188
|
+
throw new CatalogError(
|
|
189
|
+
"unsupported_target",
|
|
190
|
+
component.provenance.definition,
|
|
191
|
+
key,
|
|
192
|
+
target,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { validateSpec, type Spec } from "@json-render/core";
|
|
2
|
+
import { valueValidator } from "../catalog-format";
|
|
3
|
+
import { assertContent, type Content } from "../layout-format";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { requireTarget, type ResolvedCatalog } from "./resolve";
|
|
6
|
+
|
|
7
|
+
export class SpecError extends Error {
|
|
8
|
+
constructor(
|
|
9
|
+
readonly code: string,
|
|
10
|
+
readonly element?: string,
|
|
11
|
+
readonly field?: string,
|
|
12
|
+
) {
|
|
13
|
+
super([code, element, field].filter(Boolean).join(": "));
|
|
14
|
+
this.name = "SpecError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const specShape = z
|
|
19
|
+
.object({
|
|
20
|
+
root: z.string().min(1),
|
|
21
|
+
elements: z.record(
|
|
22
|
+
z.string(),
|
|
23
|
+
z
|
|
24
|
+
.object({
|
|
25
|
+
type: z.string(),
|
|
26
|
+
props: z.record(z.string(), z.unknown()),
|
|
27
|
+
children: z.array(z.string()).optional(),
|
|
28
|
+
slots: z.record(z.string(), z.array(z.string())).optional(),
|
|
29
|
+
on: z.record(z.string(), z.unknown()).optional(),
|
|
30
|
+
})
|
|
31
|
+
.passthrough(),
|
|
32
|
+
),
|
|
33
|
+
})
|
|
34
|
+
.passthrough();
|
|
35
|
+
|
|
36
|
+
function dynamic(value: unknown): boolean {
|
|
37
|
+
if (!value || typeof value !== "object") return false;
|
|
38
|
+
if (Array.isArray(value)) return value.some(dynamic);
|
|
39
|
+
return Object.entries(value).some(
|
|
40
|
+
([key, child]) => key.startsWith("$") || dynamic(child),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Renderer-independent validation. Defaults apply to a clone, never the saved spec. */
|
|
45
|
+
export function prepareSpec(
|
|
46
|
+
catalog: ResolvedCatalog,
|
|
47
|
+
target: string,
|
|
48
|
+
input: Spec,
|
|
49
|
+
): Spec {
|
|
50
|
+
if (!specShape.safeParse(input).success) throw new SpecError("invalid_spec");
|
|
51
|
+
const spec = structuredClone(input);
|
|
52
|
+
const seen = new Set<string>();
|
|
53
|
+
const active = new Set<string>();
|
|
54
|
+
const used = new Set<string>();
|
|
55
|
+
|
|
56
|
+
function walk(id: string) {
|
|
57
|
+
if (active.has(id)) throw new SpecError("element_cycle", id);
|
|
58
|
+
if (seen.has(id)) throw new SpecError("multiple_parents", id);
|
|
59
|
+
const element = Object.hasOwn(spec.elements, id)
|
|
60
|
+
? spec.elements[id]
|
|
61
|
+
: undefined;
|
|
62
|
+
if (!element) throw new SpecError("missing_element", id);
|
|
63
|
+
const definition = catalog.components[element.type];
|
|
64
|
+
if (!definition) throw new SpecError("unknown_component", id, element.type);
|
|
65
|
+
used.add(element.type);
|
|
66
|
+
active.add(id);
|
|
67
|
+
seen.add(id);
|
|
68
|
+
element.props = { ...definition.defaults, ...element.props };
|
|
69
|
+
for (const key of definition.contract.props.required)
|
|
70
|
+
if (!Object.hasOwn(element.props, key))
|
|
71
|
+
throw new SpecError("missing_prop", id, key);
|
|
72
|
+
for (const [key, value] of Object.entries(element.props)) {
|
|
73
|
+
const property = Object.hasOwn(definition.contract.props.properties, key)
|
|
74
|
+
? definition.contract.props.properties[key]
|
|
75
|
+
: undefined;
|
|
76
|
+
if (
|
|
77
|
+
!property ||
|
|
78
|
+
(!dynamic(value) && !valueValidator(property).safeParse(value).success)
|
|
79
|
+
)
|
|
80
|
+
throw new SpecError("invalid_prop", id, key);
|
|
81
|
+
}
|
|
82
|
+
for (const event of Object.keys(element.on ?? {}))
|
|
83
|
+
if (!definition.contract.events.includes(event))
|
|
84
|
+
throw new SpecError("unknown_event", id, event);
|
|
85
|
+
if (element.slots && Object.hasOwn(element.slots, "default"))
|
|
86
|
+
throw new SpecError("invalid_default_slot", id);
|
|
87
|
+
const slots = {
|
|
88
|
+
...element.slots,
|
|
89
|
+
...(element.children?.length ? { default: element.children } : {}),
|
|
90
|
+
};
|
|
91
|
+
for (const [slot, children] of Object.entries(slots)) {
|
|
92
|
+
const rule = Object.hasOwn(definition.contract.slots, slot)
|
|
93
|
+
? definition.contract.slots[slot]
|
|
94
|
+
: undefined;
|
|
95
|
+
if (!rule) throw new SpecError("unknown_slot", id, slot);
|
|
96
|
+
for (const child of children) {
|
|
97
|
+
if (
|
|
98
|
+
rule.accepts.length &&
|
|
99
|
+
!rule.accepts.includes(spec.elements[child]?.type ?? "")
|
|
100
|
+
)
|
|
101
|
+
throw new SpecError("invalid_slot_child", id, child);
|
|
102
|
+
walk(child);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
active.delete(id);
|
|
106
|
+
}
|
|
107
|
+
walk(spec.root);
|
|
108
|
+
if (seen.size !== Object.keys(spec.elements).length)
|
|
109
|
+
throw new SpecError("unreachable_element");
|
|
110
|
+
const structural = validateSpec(spec);
|
|
111
|
+
if (!structural.valid)
|
|
112
|
+
throw new SpecError(
|
|
113
|
+
"invalid_spec",
|
|
114
|
+
structural.issues[0]?.elementKey,
|
|
115
|
+
structural.issues[0]?.code,
|
|
116
|
+
);
|
|
117
|
+
requireTarget(catalog, [...used], target);
|
|
118
|
+
|
|
119
|
+
return spec;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Validates the whole layout and returns prepared spec copies for runtime reuse.
|
|
123
|
+
* An editor can inject this into applyLayoutChange before committing a draft. */
|
|
124
|
+
export function validateLayout(
|
|
125
|
+
content: Content,
|
|
126
|
+
catalog: ResolvedCatalog,
|
|
127
|
+
): Record<string, Spec> {
|
|
128
|
+
assertContent(content);
|
|
129
|
+
if (JSON.stringify(content.catalogs) !== JSON.stringify(catalog.stack))
|
|
130
|
+
throw new SpecError("catalog_stack_mismatch");
|
|
131
|
+
const specs: Record<string, Spec> = Object.create(null);
|
|
132
|
+
for (const [surfaceId, surface] of Object.entries(content.surfaces)) {
|
|
133
|
+
specs[surfaceId] = prepareSpec(catalog, surface.target, surface.spec);
|
|
134
|
+
for (const [elementId, slots] of Object.entries(surface.mounts)) {
|
|
135
|
+
const element = surface.spec.elements[elementId]!;
|
|
136
|
+
const contract = catalog.components[element.type]!.contract;
|
|
137
|
+
for (const [slot, mountedId] of Object.entries(slots)) {
|
|
138
|
+
const rule = contract.surfaceSlots?.[slot];
|
|
139
|
+
if (!rule) throw new SpecError("unknown_surface_slot", elementId, slot);
|
|
140
|
+
if (content.surfaces[mountedId]!.target !== rule.target)
|
|
141
|
+
throw new SpecError("surface_target_mismatch", surfaceId, slot);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return specs;
|
|
146
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CATALOG_SCHEMA_VERSION,
|
|
3
|
+
contentSchema,
|
|
4
|
+
type Content,
|
|
5
|
+
} from "./schema";
|
|
6
|
+
import { valueValidator } from "./value-schema";
|
|
7
|
+
|
|
8
|
+
export function blankContent(): Content {
|
|
9
|
+
return { version: CATALOG_SCHEMA_VERSION, requires: [], entries: [] };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const catalogFormat = {
|
|
13
|
+
currentSchemaVersion: CATALOG_SCHEMA_VERSION,
|
|
14
|
+
inspect(input: unknown) {
|
|
15
|
+
const issues: { severity: "error"; code: string; path: (string | number)[] }[] = [];
|
|
16
|
+
const references: { assetId: string; assetType: string; path: (string | number)[] }[] = [];
|
|
17
|
+
const rawVersion =
|
|
18
|
+
input && typeof input === "object" && "version" in input
|
|
19
|
+
? input.version
|
|
20
|
+
: null;
|
|
21
|
+
const schemaVersion =
|
|
22
|
+
typeof rawVersion === "number" &&
|
|
23
|
+
Number.isSafeInteger(rawVersion) &&
|
|
24
|
+
rawVersion > 0
|
|
25
|
+
? rawVersion
|
|
26
|
+
: null;
|
|
27
|
+
if (schemaVersion !== CATALOG_SCHEMA_VERSION) {
|
|
28
|
+
return {
|
|
29
|
+
schemaVersion,
|
|
30
|
+
references,
|
|
31
|
+
issues: [
|
|
32
|
+
{
|
|
33
|
+
severity: "error" as const,
|
|
34
|
+
code:
|
|
35
|
+
schemaVersion === null
|
|
36
|
+
? "invalid_document"
|
|
37
|
+
: "unsupported_schema",
|
|
38
|
+
path: ["version"],
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const parsed = contentSchema.safeParse(input);
|
|
44
|
+
if (!parsed.success) {
|
|
45
|
+
return {
|
|
46
|
+
schemaVersion,
|
|
47
|
+
references,
|
|
48
|
+
issues: parsed.error.issues.map((issue) => ({
|
|
49
|
+
severity: "error" as const,
|
|
50
|
+
code: "invalid_structure",
|
|
51
|
+
path: issue.path.filter(
|
|
52
|
+
(key): key is string | number => typeof key !== "symbol",
|
|
53
|
+
),
|
|
54
|
+
})),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const content = parsed.data;
|
|
58
|
+
const error = (code: string, path: (string | number)[]) =>
|
|
59
|
+
issues.push({ severity: "error", code, path });
|
|
60
|
+
const dependencies = new Set<string>();
|
|
61
|
+
content.requires.forEach((assetId, index) => {
|
|
62
|
+
if (dependencies.has(assetId))
|
|
63
|
+
error("duplicate_dependency", ["requires", index]);
|
|
64
|
+
dependencies.add(assetId);
|
|
65
|
+
references.push({
|
|
66
|
+
assetId,
|
|
67
|
+
assetType: "moloo.catalog",
|
|
68
|
+
path: ["requires", index],
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
const keys = new Set<string>();
|
|
72
|
+
content.entries.forEach((entry, index) => {
|
|
73
|
+
const key = "key" in entry ? entry.key : entry.overrides;
|
|
74
|
+
if (keys.has(key)) error("duplicate_component", ["entries", index]);
|
|
75
|
+
keys.add(key);
|
|
76
|
+
for (const [slot, resource] of Object.entries(entry.resources ?? {}))
|
|
77
|
+
references.push({
|
|
78
|
+
...resource,
|
|
79
|
+
path: ["entries", index, "resources", slot],
|
|
80
|
+
});
|
|
81
|
+
if (!("key" in entry)) return;
|
|
82
|
+
for (const [target, axes] of Object.entries(entry.editor.resize ?? {})) {
|
|
83
|
+
const path = ["entries", index, "editor", "resize", target];
|
|
84
|
+
if (!entry.implementations[target])
|
|
85
|
+
error("invalid_resize_target", path);
|
|
86
|
+
for (const [axis, prop] of Object.entries(axes)) {
|
|
87
|
+
const schema = entry.contract.props.properties[prop];
|
|
88
|
+
if (
|
|
89
|
+
!schema ||
|
|
90
|
+
(schema.type !== "number" && schema.type !== "integer") ||
|
|
91
|
+
(schema.minimum !== undefined && schema.minimum < 0) ||
|
|
92
|
+
(schema.maximum !== undefined && schema.maximum <= 0)
|
|
93
|
+
)
|
|
94
|
+
error("invalid_resize_property", [...path, axis]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const [target, transform] of Object.entries(
|
|
98
|
+
entry.editor.transform ?? {},
|
|
99
|
+
)) {
|
|
100
|
+
const path = ["entries", index, "editor", "transform", target];
|
|
101
|
+
if (!entry.implementations[target])
|
|
102
|
+
error("invalid_transform_target", path);
|
|
103
|
+
for (const [operation, axes] of Object.entries(transform)) {
|
|
104
|
+
for (const [axis, prop] of Object.entries(axes ?? {})) {
|
|
105
|
+
const schema = entry.contract.props.properties[prop];
|
|
106
|
+
if (
|
|
107
|
+
!schema ||
|
|
108
|
+
(schema.type !== "number" && schema.type !== "integer")
|
|
109
|
+
)
|
|
110
|
+
error("invalid_transform_property", [...path, operation, axis]);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const [name, value] of Object.entries(entry.defaults)) {
|
|
115
|
+
const schema = entry.contract.props.properties[name];
|
|
116
|
+
if (!schema || !valueValidator(schema).safeParse(value).success)
|
|
117
|
+
error("invalid_default", ["entries", index, "defaults", name]);
|
|
118
|
+
}
|
|
119
|
+
for (const [slot, resource] of Object.entries(entry.resources)) {
|
|
120
|
+
if (entry.contract.resources[slot]?.assetType !== resource.assetType)
|
|
121
|
+
error("invalid_resource", ["entries", index, "resources", slot]);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
return { schemaVersion, issues, references };
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export function readContent(input: unknown): Content | null {
|
|
129
|
+
if (catalogFormat.inspect(input).issues.length) return null;
|
|
130
|
+
return input as Content;
|
|
131
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ComponentContract,
|
|
3
|
+
ComponentDefinition,
|
|
4
|
+
Content,
|
|
5
|
+
ImplementationReference,
|
|
6
|
+
} from "./schema";
|
|
7
|
+
|
|
8
|
+
/** Portable examples; resource IDs are supplied by callers, never installed into workspaces. */
|
|
9
|
+
export const tileContract: ComponentContract = {
|
|
10
|
+
version: 1,
|
|
11
|
+
props: {
|
|
12
|
+
type: "object",
|
|
13
|
+
additionalProperties: false,
|
|
14
|
+
properties: {
|
|
15
|
+
color: { type: "string" },
|
|
16
|
+
label: { type: "string", minLength: 1 },
|
|
17
|
+
selected: { type: "boolean" },
|
|
18
|
+
size: { type: "number", minimum: 0.01 },
|
|
19
|
+
},
|
|
20
|
+
required: ["color", "selected", "size"],
|
|
21
|
+
},
|
|
22
|
+
slots: {},
|
|
23
|
+
events: ["press"],
|
|
24
|
+
resources: { face: { assetType: "moloo.image" } },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const buildingContract: ComponentContract = {
|
|
28
|
+
version: 1,
|
|
29
|
+
props: {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: { population: { type: "integer", minimum: 0 } },
|
|
32
|
+
required: ["population"],
|
|
33
|
+
additionalProperties: false,
|
|
34
|
+
},
|
|
35
|
+
slots: {},
|
|
36
|
+
events: ["press"],
|
|
37
|
+
resources: { model: { assetType: "moloo.model" } },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const groupContract: ComponentContract = {
|
|
41
|
+
version: 1,
|
|
42
|
+
props: {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: {},
|
|
45
|
+
required: [],
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
},
|
|
48
|
+
slots: { default: { accepts: [] } },
|
|
49
|
+
events: [],
|
|
50
|
+
resources: {},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function exampleImplementation(entry: string): ImplementationReference {
|
|
54
|
+
return { package: "@moloodev/layout-examples", version: "1.0.0", entry, abi: 1 };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function definition(
|
|
58
|
+
key: string,
|
|
59
|
+
contract: ComponentContract,
|
|
60
|
+
implementations: ComponentDefinition["implementations"],
|
|
61
|
+
layout: ComponentDefinition["editor"]["layout"],
|
|
62
|
+
): ComponentDefinition {
|
|
63
|
+
return {
|
|
64
|
+
key,
|
|
65
|
+
contract,
|
|
66
|
+
implementations,
|
|
67
|
+
defaults: {},
|
|
68
|
+
resources: {},
|
|
69
|
+
description: key,
|
|
70
|
+
editor: { category: key.split(".")[0]!, layout },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createExampleCatalogs(ids: {
|
|
75
|
+
puzzle: string;
|
|
76
|
+
tileImage: string;
|
|
77
|
+
winterImage: string;
|
|
78
|
+
buildingModel: string;
|
|
79
|
+
}): { puzzle: Content; winter: Content; simulation: Content } {
|
|
80
|
+
const tile = definition(
|
|
81
|
+
"puzzle.Tile",
|
|
82
|
+
structuredClone(tileContract),
|
|
83
|
+
{
|
|
84
|
+
"react-dom": exampleImplementation("Tile"),
|
|
85
|
+
"react-three-fiber": exampleImplementation("Tile"),
|
|
86
|
+
},
|
|
87
|
+
{ "react-dom": "leaf", "react-three-fiber": "leaf" },
|
|
88
|
+
);
|
|
89
|
+
tile.defaults = { label: "Tile", color: "#e57c46", selected: false, size: 1 };
|
|
90
|
+
tile.resources = {
|
|
91
|
+
face: { assetId: ids.tileImage, assetType: "moloo.image" },
|
|
92
|
+
};
|
|
93
|
+
const snowfall = definition(
|
|
94
|
+
"winter.Snowfall",
|
|
95
|
+
structuredClone(groupContract),
|
|
96
|
+
{
|
|
97
|
+
"react-three-fiber": exampleImplementation("Snowfall"),
|
|
98
|
+
},
|
|
99
|
+
{ "react-three-fiber": "spatial" },
|
|
100
|
+
);
|
|
101
|
+
const building = definition(
|
|
102
|
+
"simulation.Building",
|
|
103
|
+
structuredClone(buildingContract),
|
|
104
|
+
{ "react-three-fiber": exampleImplementation("Building") },
|
|
105
|
+
{ "react-three-fiber": "spatial" },
|
|
106
|
+
);
|
|
107
|
+
building.defaults = { population: 0 };
|
|
108
|
+
building.resources = {
|
|
109
|
+
model: { assetId: ids.buildingModel, assetType: "moloo.model" },
|
|
110
|
+
};
|
|
111
|
+
return {
|
|
112
|
+
puzzle: {
|
|
113
|
+
version: 1,
|
|
114
|
+
requires: [],
|
|
115
|
+
entries: [
|
|
116
|
+
tile,
|
|
117
|
+
definition(
|
|
118
|
+
"dom.Stack",
|
|
119
|
+
structuredClone(groupContract),
|
|
120
|
+
{ "react-dom": exampleImplementation("Stack") },
|
|
121
|
+
{ "react-dom": "flow" },
|
|
122
|
+
),
|
|
123
|
+
definition(
|
|
124
|
+
"scene.Group",
|
|
125
|
+
structuredClone(groupContract),
|
|
126
|
+
{ "react-three-fiber": exampleImplementation("Group") },
|
|
127
|
+
{ "react-three-fiber": "spatial" },
|
|
128
|
+
),
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
winter: {
|
|
132
|
+
version: 1,
|
|
133
|
+
requires: [ids.puzzle],
|
|
134
|
+
entries: [
|
|
135
|
+
{
|
|
136
|
+
overrides: "puzzle.Tile",
|
|
137
|
+
requiresContract: 1,
|
|
138
|
+
defaults: { color: "#b8e6ff" },
|
|
139
|
+
resources: {
|
|
140
|
+
face: { assetId: ids.winterImage, assetType: "moloo.image" },
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
snowfall,
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
simulation: { version: 1, requires: [], entries: [building] },
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { catalogFormat, readContent } from "./contract";
|
|
2
|
+
|
|
3
|
+
export type {
|
|
4
|
+
Content,
|
|
5
|
+
ComponentContract,
|
|
6
|
+
ComponentDefinition,
|
|
7
|
+
ResourceReference,
|
|
8
|
+
} from "./schema";
|
|
9
|
+
export { valueValidator } from "./value-schema";
|
|
10
|
+
export type { ValueSchema, ObjectSchema } from "./value-schema";
|