wyreframe 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 +123 -0
- package/dist/index.d.ts +267 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +195 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
- package/src/parser/Core/Bounds.mjs +61 -0
- package/src/parser/Core/Bounds.res +65 -0
- package/src/parser/Core/Grid.mjs +268 -0
- package/src/parser/Core/Grid.res +265 -0
- package/src/parser/Core/Position.mjs +83 -0
- package/src/parser/Core/Position.res +54 -0
- package/src/parser/Core/Types.mjs +435 -0
- package/src/parser/Core/Types.res +331 -0
- package/src/parser/Core/__tests__/Bounds_test.mjs +326 -0
- package/src/parser/Core/__tests__/Bounds_test.res +412 -0
- package/src/parser/Core/__tests__/Grid_test.mjs +322 -0
- package/src/parser/Core/__tests__/Grid_test.res +319 -0
- package/src/parser/Core/__tests__/Types_test.mjs +614 -0
- package/src/parser/Core/__tests__/Types_test.res +650 -0
- package/src/parser/Detector/BoxTracer.mjs +302 -0
- package/src/parser/Detector/BoxTracer.res +374 -0
- package/src/parser/Detector/HierarchyBuilder.mjs +158 -0
- package/src/parser/Detector/HierarchyBuilder.res +315 -0
- package/src/parser/Detector/ShapeDetector.mjs +134 -0
- package/src/parser/Detector/ShapeDetector.res +236 -0
- package/src/parser/Detector/__tests__/BoxTracer_test.mjs +70 -0
- package/src/parser/Detector/__tests__/BoxTracer_test.res +92 -0
- package/src/parser/Detector/__tests__/HierarchyBuilder_test.mjs +489 -0
- package/src/parser/Detector/__tests__/HierarchyBuilder_test.res +849 -0
- package/src/parser/Detector/__tests__/ShapeDetector_test.mjs +377 -0
- package/src/parser/Detector/__tests__/ShapeDetector_test.res +563 -0
- package/src/parser/Errors/ErrorContext.mjs +106 -0
- package/src/parser/Errors/ErrorContext.res +191 -0
- package/src/parser/Errors/ErrorMessages.mjs +289 -0
- package/src/parser/Errors/ErrorMessages.res +303 -0
- package/src/parser/Errors/ErrorTypes.mjs +105 -0
- package/src/parser/Errors/ErrorTypes.res +169 -0
- package/src/parser/Interactions/InteractionMerger.mjs +266 -0
- package/src/parser/Interactions/InteractionMerger.res +450 -0
- package/src/parser/Interactions/InteractionParser.mjs +88 -0
- package/src/parser/Interactions/InteractionParser.res +127 -0
- package/src/parser/Interactions/SimpleInteractionParser.mjs +278 -0
- package/src/parser/Interactions/SimpleInteractionParser.res +262 -0
- package/src/parser/Interactions/__tests__/InteractionMerger_test.mjs +576 -0
- package/src/parser/Interactions/__tests__/InteractionMerger_test.res +646 -0
- package/src/parser/Parser.gen.tsx +96 -0
- package/src/parser/Parser.mjs +212 -0
- package/src/parser/Parser.res +481 -0
- package/src/parser/Scanner/__tests__/Grid_manual.mjs +214 -0
- package/src/parser/Scanner/__tests__/Grid_manual.res +141 -0
- package/src/parser/Semantic/ASTBuilder.mjs +197 -0
- package/src/parser/Semantic/ASTBuilder.res +288 -0
- package/src/parser/Semantic/AlignmentCalc.mjs +41 -0
- package/src/parser/Semantic/AlignmentCalc.res +104 -0
- package/src/parser/Semantic/Elements/ButtonParser.mjs +58 -0
- package/src/parser/Semantic/Elements/ButtonParser.res +131 -0
- package/src/parser/Semantic/Elements/CheckboxParser.mjs +58 -0
- package/src/parser/Semantic/Elements/CheckboxParser.res +79 -0
- package/src/parser/Semantic/Elements/CodeTextParser.mjs +50 -0
- package/src/parser/Semantic/Elements/CodeTextParser.res +111 -0
- package/src/parser/Semantic/Elements/ElementParser.mjs +15 -0
- package/src/parser/Semantic/Elements/ElementParser.res +83 -0
- package/src/parser/Semantic/Elements/EmphasisParser.mjs +46 -0
- package/src/parser/Semantic/Elements/EmphasisParser.res +67 -0
- package/src/parser/Semantic/Elements/InputParser.mjs +41 -0
- package/src/parser/Semantic/Elements/InputParser.res +97 -0
- package/src/parser/Semantic/Elements/LinkParser.mjs +60 -0
- package/src/parser/Semantic/Elements/LinkParser.res +156 -0
- package/src/parser/Semantic/Elements/TextParser.mjs +19 -0
- package/src/parser/Semantic/Elements/TextParser.res +42 -0
- package/src/parser/Semantic/Elements/__tests__/ButtonParser_test.mjs +189 -0
- package/src/parser/Semantic/Elements/__tests__/ButtonParser_test.res +257 -0
- package/src/parser/Semantic/Elements/__tests__/CheckboxParser_test.mjs +202 -0
- package/src/parser/Semantic/Elements/__tests__/CheckboxParser_test.res +250 -0
- package/src/parser/Semantic/Elements/__tests__/CodeTextParser_manual.mjs +293 -0
- package/src/parser/Semantic/Elements/__tests__/CodeTextParser_manual.res +134 -0
- package/src/parser/Semantic/Elements/__tests__/InputParser_test.mjs +253 -0
- package/src/parser/Semantic/Elements/__tests__/InputParser_test.res +304 -0
- package/src/parser/Semantic/Elements/__tests__/LinkParser_test.mjs +289 -0
- package/src/parser/Semantic/Elements/__tests__/LinkParser_test.res +402 -0
- package/src/parser/Semantic/Elements/__tests__/TextParser_test.mjs +149 -0
- package/src/parser/Semantic/Elements/__tests__/TextParser_test.res +167 -0
- package/src/parser/Semantic/ParserRegistry.mjs +82 -0
- package/src/parser/Semantic/ParserRegistry.res +145 -0
- package/src/parser/Semantic/SemanticParser.mjs +850 -0
- package/src/parser/Semantic/SemanticParser.res +1368 -0
- package/src/parser/Semantic/__tests__/ASTBuilder_test.mjs +187 -0
- package/src/parser/Semantic/__tests__/ASTBuilder_test.res +192 -0
- package/src/parser/Semantic/__tests__/ParserRegistry_test.mjs +154 -0
- package/src/parser/Semantic/__tests__/ParserRegistry_test.res +191 -0
- package/src/parser/Semantic/__tests__/SemanticParser_integration_test.mjs +768 -0
- package/src/parser/Semantic/__tests__/SemanticParser_integration_test.res +1069 -0
- package/src/parser/Semantic/__tests__/SemanticParser_manual.mjs +1329 -0
- package/src/parser/Semantic/__tests__/SemanticParser_manual.res +544 -0
- package/src/parser/TestMain.mjs +21 -0
- package/src/parser/TestMain.res +14 -0
- package/src/parser/TextExtractor.mjs +179 -0
- package/src/parser/TextExtractor.res +264 -0
- package/src/parser/__tests__/GridScanner_integration.test.mjs +632 -0
- package/src/parser/__tests__/GridScanner_integration.test.res +816 -0
- package/src/parser/__tests__/Performance.test.mjs +244 -0
- package/src/parser/__tests__/Performance.test.res +371 -0
- package/src/parser/__tests__/PerformanceFixtures.mjs +200 -0
- package/src/parser/__tests__/PerformanceFixtures.res +284 -0
- package/src/parser/__tests__/WyreframeParser_integration.test.mjs +770 -0
- package/src/parser/__tests__/WyreframeParser_integration.test.res +1008 -0
- package/src/parser/__tests__/fixtures/alignment-test.txt +9 -0
- package/src/parser/__tests__/fixtures/all-elements.txt +16 -0
- package/src/parser/__tests__/fixtures/login-scene.txt +17 -0
- package/src/parser/__tests__/fixtures/multi-scene.txt +25 -0
- package/src/parser/__tests__/fixtures/nested-boxes.txt +15 -0
- package/src/parser/__tests__/fixtures/simple-box.txt +5 -0
- package/src/parser/__tests__/fixtures/with-dividers.txt +14 -0
- package/src/renderer/Renderer.gen.tsx +32 -0
- package/src/renderer/Renderer.mjs +391 -0
- package/src/renderer/Renderer.res +558 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Js_dict from "@rescript/runtime/lib/es6/Js_dict.js";
|
|
4
|
+
import * as Js_json from "@rescript/runtime/lib/es6/Js_json.js";
|
|
5
|
+
import * as Core__Array from "@rescript/core/src/Core__Array.mjs";
|
|
6
|
+
import * as Belt_MapString from "@rescript/runtime/lib/es6/Belt_MapString.js";
|
|
7
|
+
import * as Belt_SetString from "@rescript/runtime/lib/es6/Belt_SetString.js";
|
|
8
|
+
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
9
|
+
|
|
10
|
+
function collectElementIds(element) {
|
|
11
|
+
switch (element.TAG) {
|
|
12
|
+
case "Box" :
|
|
13
|
+
return Core__Array.reduce(element.children, undefined, (acc, child) => Belt_SetString.union(acc, collectElementIds(child)));
|
|
14
|
+
case "Button" :
|
|
15
|
+
case "Input" :
|
|
16
|
+
case "Link" :
|
|
17
|
+
return Belt_SetString.add(undefined, element.id);
|
|
18
|
+
case "Row" :
|
|
19
|
+
return Core__Array.reduce(element.children, undefined, (acc, child) => Belt_SetString.union(acc, collectElementIds(child)));
|
|
20
|
+
case "Section" :
|
|
21
|
+
let withSection = Belt_SetString.add(undefined, element.name);
|
|
22
|
+
return Core__Array.reduce(element.children, withSection, (acc, child) => Belt_SetString.union(acc, collectElementIds(child)));
|
|
23
|
+
default:
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function collectSceneElementIds(scene) {
|
|
29
|
+
return Core__Array.reduce(scene.elements, undefined, (acc, element) => Belt_SetString.union(acc, collectElementIds(element)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildSceneElementMap(ast) {
|
|
33
|
+
return Core__Array.reduce(ast.scenes, undefined, (acc, scene) => {
|
|
34
|
+
let elementIds = collectSceneElementIds(scene);
|
|
35
|
+
return Belt_MapString.set(acc, scene.id, elementIds);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validateInteractions(sceneInteractionsList, sceneElementMap) {
|
|
40
|
+
let errors = [];
|
|
41
|
+
sceneInteractionsList.forEach(sceneInteractions => {
|
|
42
|
+
let sceneId = sceneInteractions.sceneId;
|
|
43
|
+
let elementIds = Belt_MapString.get(sceneElementMap, sceneId);
|
|
44
|
+
if (elementIds !== undefined) {
|
|
45
|
+
let elementIds$1 = Primitive_option.valFromOption(elementIds);
|
|
46
|
+
let seenElements = {
|
|
47
|
+
contents: undefined
|
|
48
|
+
};
|
|
49
|
+
sceneInteractions.interactions.forEach(interaction => {
|
|
50
|
+
let elementId = interaction.elementId;
|
|
51
|
+
if (Belt_SetString.has(seenElements.contents, elementId)) {
|
|
52
|
+
errors.push({
|
|
53
|
+
TAG: "DuplicateInteraction",
|
|
54
|
+
sceneId: sceneId,
|
|
55
|
+
elementId: elementId
|
|
56
|
+
});
|
|
57
|
+
} else {
|
|
58
|
+
seenElements.contents = Belt_SetString.add(seenElements.contents, elementId);
|
|
59
|
+
}
|
|
60
|
+
if (!Belt_SetString.has(elementIds$1, elementId)) {
|
|
61
|
+
errors.push({
|
|
62
|
+
TAG: "ElementNotFound",
|
|
63
|
+
sceneId: sceneId,
|
|
64
|
+
elementId: elementId,
|
|
65
|
+
position: undefined
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
errors.push({
|
|
73
|
+
TAG: "SceneNotFound",
|
|
74
|
+
sceneId: sceneId
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
return errors;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function findInteractionForElement(elementId, sceneInteractions) {
|
|
81
|
+
if (sceneInteractions !== undefined) {
|
|
82
|
+
return sceneInteractions.interactions.find(interaction => interaction.elementId === elementId);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function hasInputType(interaction) {
|
|
87
|
+
if (interaction === undefined) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
let json = Js_dict.get(interaction.properties, "type");
|
|
91
|
+
if (json === undefined) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
let str = Js_json.decodeString(json);
|
|
95
|
+
return str === "input";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function attachInteractionsToElement(element, sceneInteractions) {
|
|
99
|
+
switch (element.TAG) {
|
|
100
|
+
case "Box" :
|
|
101
|
+
let bounds = element.bounds;
|
|
102
|
+
let name = element.name;
|
|
103
|
+
let enhancedChildren = element.children.map(child => attachInteractionsToElement(child, sceneInteractions));
|
|
104
|
+
let isAnonymous = name === undefined;
|
|
105
|
+
let hasOnlyInputs = enhancedChildren.length > 0 && enhancedChildren.every(child => child.TAG === "Input");
|
|
106
|
+
let shouldUnwrap = isAnonymous && hasOnlyInputs && enhancedChildren.some(child => {
|
|
107
|
+
if (child.TAG === "Input") {
|
|
108
|
+
return hasInputType(findInteractionForElement(child.id, sceneInteractions));
|
|
109
|
+
} else {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
if (!shouldUnwrap) {
|
|
114
|
+
return {
|
|
115
|
+
TAG: "Box",
|
|
116
|
+
name: name,
|
|
117
|
+
bounds: bounds,
|
|
118
|
+
children: enhancedChildren
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
let input = enhancedChildren[0];
|
|
122
|
+
if (input !== undefined) {
|
|
123
|
+
return input;
|
|
124
|
+
} else {
|
|
125
|
+
return {
|
|
126
|
+
TAG: "Box",
|
|
127
|
+
name: name,
|
|
128
|
+
bounds: bounds,
|
|
129
|
+
children: enhancedChildren
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
case "Button" :
|
|
133
|
+
let id = element.id;
|
|
134
|
+
let interaction = findInteractionForElement(id, sceneInteractions);
|
|
135
|
+
let actions = interaction !== undefined ? interaction.actions : [];
|
|
136
|
+
return {
|
|
137
|
+
TAG: "Button",
|
|
138
|
+
id: id,
|
|
139
|
+
text: element.text,
|
|
140
|
+
position: element.position,
|
|
141
|
+
align: element.align,
|
|
142
|
+
actions: actions
|
|
143
|
+
};
|
|
144
|
+
case "Input" :
|
|
145
|
+
let placeholder = element.placeholder;
|
|
146
|
+
let id$1 = element.id;
|
|
147
|
+
let interaction$1 = findInteractionForElement(id$1, sceneInteractions);
|
|
148
|
+
let enhancedPlaceholder;
|
|
149
|
+
if (interaction$1 !== undefined) {
|
|
150
|
+
let json = Js_dict.get(interaction$1.properties, "placeholder");
|
|
151
|
+
if (json !== undefined) {
|
|
152
|
+
let str = Js_json.decodeString(json);
|
|
153
|
+
enhancedPlaceholder = str !== undefined ? str : placeholder;
|
|
154
|
+
} else {
|
|
155
|
+
enhancedPlaceholder = placeholder;
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
enhancedPlaceholder = placeholder;
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
TAG: "Input",
|
|
162
|
+
id: id$1,
|
|
163
|
+
placeholder: enhancedPlaceholder,
|
|
164
|
+
position: element.position
|
|
165
|
+
};
|
|
166
|
+
case "Link" :
|
|
167
|
+
let id$2 = element.id;
|
|
168
|
+
let interaction$2 = findInteractionForElement(id$2, sceneInteractions);
|
|
169
|
+
let actions$1 = interaction$2 !== undefined ? interaction$2.actions : [];
|
|
170
|
+
return {
|
|
171
|
+
TAG: "Link",
|
|
172
|
+
id: id$2,
|
|
173
|
+
text: element.text,
|
|
174
|
+
position: element.position,
|
|
175
|
+
align: element.align,
|
|
176
|
+
actions: actions$1
|
|
177
|
+
};
|
|
178
|
+
case "Row" :
|
|
179
|
+
let enhancedChildren$1 = element.children.map(child => attachInteractionsToElement(child, sceneInteractions));
|
|
180
|
+
return {
|
|
181
|
+
TAG: "Row",
|
|
182
|
+
children: enhancedChildren$1,
|
|
183
|
+
align: element.align
|
|
184
|
+
};
|
|
185
|
+
case "Section" :
|
|
186
|
+
let name$1 = element.name;
|
|
187
|
+
findInteractionForElement(name$1, sceneInteractions);
|
|
188
|
+
let enhancedChildren$2 = element.children.map(child => attachInteractionsToElement(child, sceneInteractions));
|
|
189
|
+
return {
|
|
190
|
+
TAG: "Section",
|
|
191
|
+
name: name$1,
|
|
192
|
+
children: enhancedChildren$2
|
|
193
|
+
};
|
|
194
|
+
default:
|
|
195
|
+
return element;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function attachInteractionsToScene(scene, sceneInteractionsList) {
|
|
200
|
+
let sceneInteractions = sceneInteractionsList.find(si => si.sceneId === scene.id);
|
|
201
|
+
let enhancedElements = scene.elements.map(element => attachInteractionsToElement(element, sceneInteractions));
|
|
202
|
+
return {
|
|
203
|
+
id: scene.id,
|
|
204
|
+
title: scene.title,
|
|
205
|
+
transition: scene.transition,
|
|
206
|
+
device: scene.device,
|
|
207
|
+
elements: enhancedElements
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function mergeInteractions(ast, sceneInteractionsList) {
|
|
212
|
+
let sceneElementMap = buildSceneElementMap(ast);
|
|
213
|
+
let validationErrors = validateInteractions(sceneInteractionsList, sceneElementMap);
|
|
214
|
+
let hardErrors = validationErrors.filter(error => {
|
|
215
|
+
switch (error.TAG) {
|
|
216
|
+
case "ElementNotFound" :
|
|
217
|
+
return false;
|
|
218
|
+
case "DuplicateInteraction" :
|
|
219
|
+
case "SceneNotFound" :
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
if (hardErrors.length > 0) {
|
|
224
|
+
return {
|
|
225
|
+
TAG: "Error",
|
|
226
|
+
_0: hardErrors
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
let enhancedScenes = ast.scenes.map(scene => attachInteractionsToScene(scene, sceneInteractionsList));
|
|
230
|
+
return {
|
|
231
|
+
TAG: "Ok",
|
|
232
|
+
_0: {
|
|
233
|
+
scenes: enhancedScenes
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function formatError(error) {
|
|
239
|
+
switch (error.TAG) {
|
|
240
|
+
case "ElementNotFound" :
|
|
241
|
+
return `Element "` + error.elementId + `" not found in scene "` + error.sceneId + `"`;
|
|
242
|
+
case "DuplicateInteraction" :
|
|
243
|
+
return `Duplicate interaction for element "` + error.elementId + `" in scene "` + error.sceneId + `"`;
|
|
244
|
+
case "SceneNotFound" :
|
|
245
|
+
return `Scene "` + error.sceneId + `" not found in wireframe`;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function formatErrors(errors) {
|
|
250
|
+
return errors.map(formatError).join("\n");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export {
|
|
254
|
+
collectElementIds,
|
|
255
|
+
collectSceneElementIds,
|
|
256
|
+
buildSceneElementMap,
|
|
257
|
+
validateInteractions,
|
|
258
|
+
findInteractionForElement,
|
|
259
|
+
hasInputType,
|
|
260
|
+
attachInteractionsToElement,
|
|
261
|
+
attachInteractionsToScene,
|
|
262
|
+
mergeInteractions,
|
|
263
|
+
formatError,
|
|
264
|
+
formatErrors,
|
|
265
|
+
}
|
|
266
|
+
/* No side effect */
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InteractionMerger.res
|
|
3
|
+
*
|
|
4
|
+
* Merges interaction definitions with wireframe AST elements.
|
|
5
|
+
* Validates that all element IDs referenced in interactions exist in the AST.
|
|
6
|
+
* Attaches properties and actions to the appropriate elements.
|
|
7
|
+
*
|
|
8
|
+
* Requirements: REQ-20 (Integration - Backward Compatibility)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
open Types
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Error Types
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Errors that can occur during interaction merging
|
|
19
|
+
*/
|
|
20
|
+
type mergeError =
|
|
21
|
+
| ElementNotFound({
|
|
22
|
+
sceneId: string,
|
|
23
|
+
elementId: string,
|
|
24
|
+
position: option<string>, // Optional position info from interaction
|
|
25
|
+
})
|
|
26
|
+
| DuplicateInteraction({
|
|
27
|
+
sceneId: string,
|
|
28
|
+
elementId: string,
|
|
29
|
+
})
|
|
30
|
+
| SceneNotFound({sceneId: string})
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Result type for merge operation
|
|
34
|
+
*/
|
|
35
|
+
type mergeResult = result<ast, array<mergeError>>
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// Element ID Collection
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Recursively collect all element IDs from an element and its children.
|
|
43
|
+
* Returns a set of element IDs found in the element tree.
|
|
44
|
+
*
|
|
45
|
+
* @param element - The element to collect IDs from
|
|
46
|
+
* @returns Set of element IDs
|
|
47
|
+
*/
|
|
48
|
+
let rec collectElementIds = (element: element): Belt.Set.String.t => {
|
|
49
|
+
let ids = Belt.Set.String.empty
|
|
50
|
+
|
|
51
|
+
switch element {
|
|
52
|
+
| Box({children}) => {
|
|
53
|
+
// Recursively collect IDs from all children
|
|
54
|
+
children->Array.reduce(ids, (acc, child) => {
|
|
55
|
+
Belt.Set.String.union(acc, collectElementIds(child))
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
| Button({id}) => ids->Belt.Set.String.add(id)
|
|
59
|
+
| Input({id}) => ids->Belt.Set.String.add(id)
|
|
60
|
+
| Link({id}) => ids->Belt.Set.String.add(id)
|
|
61
|
+
| Checkbox(_) => ids // Checkboxes don't have explicit IDs
|
|
62
|
+
| Text(_) => ids // Text elements don't have explicit IDs
|
|
63
|
+
| Divider(_) => ids // Dividers don't have IDs
|
|
64
|
+
| Row({children}) => {
|
|
65
|
+
children->Array.reduce(ids, (acc, child) => {
|
|
66
|
+
Belt.Set.String.union(acc, collectElementIds(child))
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
| Section({children, name}) => {
|
|
70
|
+
// Add section name as ID
|
|
71
|
+
let withSection = ids->Belt.Set.String.add(name)
|
|
72
|
+
children->Array.reduce(withSection, (acc, child) => {
|
|
73
|
+
Belt.Set.String.union(acc, collectElementIds(child))
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Collect all element IDs from a scene's elements.
|
|
81
|
+
*
|
|
82
|
+
* @param scene - The scene to collect IDs from
|
|
83
|
+
* @returns Set of element IDs in the scene
|
|
84
|
+
*/
|
|
85
|
+
let collectSceneElementIds = (scene: scene): Belt.Set.String.t => {
|
|
86
|
+
scene.elements->Array.reduce(Belt.Set.String.empty, (acc, element) => {
|
|
87
|
+
Belt.Set.String.union(acc, collectElementIds(element))
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build a map of scene IDs to their element ID sets.
|
|
93
|
+
*
|
|
94
|
+
* @param ast - The AST to analyze
|
|
95
|
+
* @returns Map of sceneId -> Set of element IDs
|
|
96
|
+
*/
|
|
97
|
+
let buildSceneElementMap = (ast: ast): Belt.Map.String.t<Belt.Set.String.t> => {
|
|
98
|
+
ast.scenes->Array.reduce(Belt.Map.String.empty, (acc, scene) => {
|
|
99
|
+
let elementIds = collectSceneElementIds(scene)
|
|
100
|
+
acc->Belt.Map.String.set(scene.id, elementIds)
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ============================================================================
|
|
105
|
+
// Interaction Validation
|
|
106
|
+
// ============================================================================
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Validate that all element IDs in interactions exist in the AST.
|
|
110
|
+
*
|
|
111
|
+
* @param sceneInteractions - Interactions to validate
|
|
112
|
+
* @param sceneElementMap - Map of scene IDs to element IDs
|
|
113
|
+
* @returns Array of validation errors (empty if all valid)
|
|
114
|
+
*/
|
|
115
|
+
let validateInteractions = (
|
|
116
|
+
sceneInteractionsList: array<sceneInteractions>,
|
|
117
|
+
sceneElementMap: Belt.Map.String.t<Belt.Set.String.t>,
|
|
118
|
+
): array<mergeError> => {
|
|
119
|
+
let errors = []
|
|
120
|
+
|
|
121
|
+
sceneInteractionsList->Array.forEach(sceneInteractions => {
|
|
122
|
+
let sceneId = sceneInteractions.sceneId
|
|
123
|
+
|
|
124
|
+
// Check if scene exists
|
|
125
|
+
switch sceneElementMap->Belt.Map.String.get(sceneId) {
|
|
126
|
+
| None => {
|
|
127
|
+
errors->Array.push(SceneNotFound({sceneId: sceneId}))->ignore
|
|
128
|
+
}
|
|
129
|
+
| Some(elementIds) => {
|
|
130
|
+
// Track seen element IDs to detect duplicates
|
|
131
|
+
let seenElements = ref(Belt.Set.String.empty)
|
|
132
|
+
|
|
133
|
+
// Validate each interaction
|
|
134
|
+
sceneInteractions.interactions->Array.forEach(interaction => {
|
|
135
|
+
let elementId = interaction.elementId
|
|
136
|
+
|
|
137
|
+
// Check for duplicate interaction
|
|
138
|
+
if seenElements.contents->Belt.Set.String.has(elementId) {
|
|
139
|
+
errors
|
|
140
|
+
->Array.push(
|
|
141
|
+
DuplicateInteraction({
|
|
142
|
+
sceneId: sceneId,
|
|
143
|
+
elementId: elementId,
|
|
144
|
+
}),
|
|
145
|
+
)
|
|
146
|
+
->ignore
|
|
147
|
+
} else {
|
|
148
|
+
seenElements := seenElements.contents->Belt.Set.String.add(elementId)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Check if element exists in scene
|
|
152
|
+
if !(elementIds->Belt.Set.String.has(elementId)) {
|
|
153
|
+
errors
|
|
154
|
+
->Array.push(
|
|
155
|
+
ElementNotFound({
|
|
156
|
+
sceneId: sceneId,
|
|
157
|
+
elementId: elementId,
|
|
158
|
+
position: None,
|
|
159
|
+
}),
|
|
160
|
+
)
|
|
161
|
+
->ignore
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
errors
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ============================================================================
|
|
172
|
+
// Element Enhancement
|
|
173
|
+
// ============================================================================
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Enhanced element type that includes interaction data.
|
|
177
|
+
* This is used internally during merging but converted back to
|
|
178
|
+
* the standard element type for the final AST.
|
|
179
|
+
*/
|
|
180
|
+
type elementWithInteraction = {
|
|
181
|
+
element: element,
|
|
182
|
+
interaction: option<interaction>,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Find interaction for a given element ID in a scene's interactions.
|
|
187
|
+
*
|
|
188
|
+
* @param elementId - The element ID to find interaction for
|
|
189
|
+
* @param sceneInteractions - Scene interactions to search
|
|
190
|
+
* @returns Option containing the interaction if found
|
|
191
|
+
*/
|
|
192
|
+
let findInteractionForElement = (
|
|
193
|
+
elementId: string,
|
|
194
|
+
sceneInteractions: option<sceneInteractions>,
|
|
195
|
+
): option<interaction> => {
|
|
196
|
+
switch sceneInteractions {
|
|
197
|
+
| None => None
|
|
198
|
+
| Some(si) => {
|
|
199
|
+
si.interactions->Array.find(interaction => interaction.elementId === elementId)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Helper to check if an interaction has type: "input" property.
|
|
206
|
+
*/
|
|
207
|
+
let hasInputType = (interaction: option<interaction>): bool => {
|
|
208
|
+
switch interaction {
|
|
209
|
+
| Some({properties}) => {
|
|
210
|
+
switch properties->Js.Dict.get("type") {
|
|
211
|
+
| Some(json) => {
|
|
212
|
+
switch Js.Json.decodeString(json) {
|
|
213
|
+
| Some(str) => str === "input"
|
|
214
|
+
| None => false
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
| None => false
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
| None => false
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Recursively attach interactions to elements.
|
|
226
|
+
* This creates a new element tree with interaction data attached.
|
|
227
|
+
*
|
|
228
|
+
* Key feature: If an element has type: "input" in its interaction properties,
|
|
229
|
+
* parent Box elements containing only that Input will be unwrapped to render
|
|
230
|
+
* as the Input directly.
|
|
231
|
+
*
|
|
232
|
+
* @param element - The element to process
|
|
233
|
+
* @param sceneInteractions - Interactions for the current scene
|
|
234
|
+
* @returns The processed element (may be unwrapped if type: input specified)
|
|
235
|
+
*/
|
|
236
|
+
let rec attachInteractionsToElement = (
|
|
237
|
+
element: element,
|
|
238
|
+
sceneInteractions: option<sceneInteractions>,
|
|
239
|
+
): element => {
|
|
240
|
+
switch element {
|
|
241
|
+
| Box({name, bounds, children}) => {
|
|
242
|
+
// Recursively process children first
|
|
243
|
+
let enhancedChildren = children->Array.map(child => {
|
|
244
|
+
attachInteractionsToElement(child, sceneInteractions)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
// Check if this is an input-only box that should be unwrapped
|
|
248
|
+
// A box is unwrapped if:
|
|
249
|
+
// 1. It has no name (anonymous box)
|
|
250
|
+
// 2. It contains only Input elements
|
|
251
|
+
// 3. At least one Input has type: "input" in its interaction
|
|
252
|
+
let isAnonymous = name === None
|
|
253
|
+
let hasOnlyInputs =
|
|
254
|
+
enhancedChildren->Array.length > 0 &&
|
|
255
|
+
enhancedChildren->Array.every(child => {
|
|
256
|
+
switch child {
|
|
257
|
+
| Input(_) => true
|
|
258
|
+
| _ => false
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
let shouldUnwrap = isAnonymous && hasOnlyInputs && {
|
|
263
|
+
enhancedChildren->Array.some(child => {
|
|
264
|
+
switch child {
|
|
265
|
+
| Input({id, _}) => {
|
|
266
|
+
let interaction = findInteractionForElement(id, sceneInteractions)
|
|
267
|
+
hasInputType(interaction)
|
|
268
|
+
}
|
|
269
|
+
| _ => false
|
|
270
|
+
}
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if shouldUnwrap {
|
|
275
|
+
// Return the first Input directly (unwrap the box)
|
|
276
|
+
switch enhancedChildren->Array.get(0) {
|
|
277
|
+
| Some(input) => input
|
|
278
|
+
| None => Box({name, bounds, children: enhancedChildren})
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
Box({name, bounds, children: enhancedChildren})
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
| Button({id, text, position, align}) => {
|
|
285
|
+
// Find interaction for this button (if any)
|
|
286
|
+
let interaction = findInteractionForElement(id, sceneInteractions)
|
|
287
|
+
// Extract actions from interaction
|
|
288
|
+
let actions = switch interaction {
|
|
289
|
+
| Some({actions}) => actions
|
|
290
|
+
| None => []
|
|
291
|
+
}
|
|
292
|
+
Button({id, text, position, align, actions})
|
|
293
|
+
}
|
|
294
|
+
| Input({id, placeholder, position}) => {
|
|
295
|
+
let interaction = findInteractionForElement(id, sceneInteractions)
|
|
296
|
+
// Extract placeholder from interaction properties if available
|
|
297
|
+
let enhancedPlaceholder = switch interaction {
|
|
298
|
+
| Some({properties}) => {
|
|
299
|
+
switch properties->Js.Dict.get("placeholder") {
|
|
300
|
+
| Some(json) => {
|
|
301
|
+
switch Js.Json.decodeString(json) {
|
|
302
|
+
| Some(str) => Some(str)
|
|
303
|
+
| None => placeholder
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
| None => placeholder
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
| None => placeholder
|
|
310
|
+
}
|
|
311
|
+
Input({id, placeholder: enhancedPlaceholder, position})
|
|
312
|
+
}
|
|
313
|
+
| Link({id, text, position, align}) => {
|
|
314
|
+
// Find interaction for this link (if any)
|
|
315
|
+
let interaction = findInteractionForElement(id, sceneInteractions)
|
|
316
|
+
// Extract actions from interaction
|
|
317
|
+
let actions = switch interaction {
|
|
318
|
+
| Some({actions}) => actions
|
|
319
|
+
| None => []
|
|
320
|
+
}
|
|
321
|
+
Link({id, text, position, align, actions})
|
|
322
|
+
}
|
|
323
|
+
| Row({children, align}) => {
|
|
324
|
+
let enhancedChildren = children->Array.map(child => {
|
|
325
|
+
attachInteractionsToElement(child, sceneInteractions)
|
|
326
|
+
})
|
|
327
|
+
Row({children: enhancedChildren, align})
|
|
328
|
+
}
|
|
329
|
+
| Section({name, children}) => {
|
|
330
|
+
let _interaction = findInteractionForElement(name, sceneInteractions)
|
|
331
|
+
let enhancedChildren = children->Array.map(child => {
|
|
332
|
+
attachInteractionsToElement(child, sceneInteractions)
|
|
333
|
+
})
|
|
334
|
+
Section({name, children: enhancedChildren})
|
|
335
|
+
}
|
|
336
|
+
// Elements without IDs just pass through
|
|
337
|
+
| Checkbox(_) as el => el
|
|
338
|
+
| Text(_) as el => el
|
|
339
|
+
| Divider(_) as el => el
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Attach interactions to all elements in a scene.
|
|
345
|
+
*
|
|
346
|
+
* @param scene - The scene to process
|
|
347
|
+
* @param sceneInteractionsList - All scene interactions
|
|
348
|
+
* @returns Enhanced scene with interactions attached
|
|
349
|
+
*/
|
|
350
|
+
let attachInteractionsToScene = (
|
|
351
|
+
scene: scene,
|
|
352
|
+
sceneInteractionsList: array<sceneInteractions>,
|
|
353
|
+
): scene => {
|
|
354
|
+
// Find interactions for this scene
|
|
355
|
+
let sceneInteractions = sceneInteractionsList->Array.find(si => si.sceneId === scene.id)
|
|
356
|
+
|
|
357
|
+
// Process all elements
|
|
358
|
+
let enhancedElements = scene.elements->Array.map(element => {
|
|
359
|
+
attachInteractionsToElement(element, sceneInteractions)
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
{
|
|
363
|
+
id: scene.id,
|
|
364
|
+
title: scene.title,
|
|
365
|
+
transition: scene.transition,
|
|
366
|
+
device: scene.device,
|
|
367
|
+
elements: enhancedElements,
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ============================================================================
|
|
372
|
+
// Public API
|
|
373
|
+
// ============================================================================
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Merge interactions into the AST.
|
|
377
|
+
* Validates that all element IDs exist and attaches interaction data to elements.
|
|
378
|
+
*
|
|
379
|
+
* Lenient mode: ElementNotFound errors are treated as warnings and skipped.
|
|
380
|
+
* Only SceneNotFound and DuplicateInteraction are hard errors.
|
|
381
|
+
*
|
|
382
|
+
* @param ast - The wireframe AST
|
|
383
|
+
* @param sceneInteractionsList - Array of scene interactions to merge
|
|
384
|
+
* @returns Result containing merged AST or validation errors
|
|
385
|
+
*
|
|
386
|
+
* @example
|
|
387
|
+
* let result = mergeInteractions(wireframeAst, interactions)
|
|
388
|
+
* switch result {
|
|
389
|
+
* | Ok(mergedAst) => Js.log("Merge successful!")
|
|
390
|
+
* | Error(errors) => errors->Array.forEach(err => Js.log(err))
|
|
391
|
+
* }
|
|
392
|
+
*/
|
|
393
|
+
let mergeInteractions = (
|
|
394
|
+
ast: ast,
|
|
395
|
+
sceneInteractionsList: array<sceneInteractions>,
|
|
396
|
+
): mergeResult => {
|
|
397
|
+
// Build map of scene IDs to element IDs
|
|
398
|
+
let sceneElementMap = buildSceneElementMap(ast)
|
|
399
|
+
|
|
400
|
+
// Validate all interactions
|
|
401
|
+
let validationErrors = validateInteractions(sceneInteractionsList, sceneElementMap)
|
|
402
|
+
|
|
403
|
+
// Separate hard errors from soft errors (ElementNotFound)
|
|
404
|
+
let hardErrors = validationErrors->Array.filter(error => {
|
|
405
|
+
switch error {
|
|
406
|
+
| ElementNotFound(_) => false // Soft error - element might not exist yet
|
|
407
|
+
| SceneNotFound(_) => true // Hard error - scene must exist
|
|
408
|
+
| DuplicateInteraction(_) => true // Hard error - duplicates are problematic
|
|
409
|
+
}
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
if hardErrors->Array.length > 0 {
|
|
413
|
+
// Return only hard errors
|
|
414
|
+
Error(hardErrors)
|
|
415
|
+
} else {
|
|
416
|
+
// Attach interactions to scenes (missing elements are silently skipped)
|
|
417
|
+
let enhancedScenes = ast.scenes->Array.map(scene => {
|
|
418
|
+
attachInteractionsToScene(scene, sceneInteractionsList)
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
// Return enhanced AST
|
|
422
|
+
Ok({scenes: enhancedScenes})
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Format merge error for display.
|
|
428
|
+
*
|
|
429
|
+
* @param error - The merge error to format
|
|
430
|
+
* @returns Human-readable error message
|
|
431
|
+
*/
|
|
432
|
+
let formatError = (error: mergeError): string => {
|
|
433
|
+
switch error {
|
|
434
|
+
| ElementNotFound({sceneId, elementId}) =>
|
|
435
|
+
`Element "${elementId}" not found in scene "${sceneId}"`
|
|
436
|
+
| DuplicateInteraction({sceneId, elementId}) =>
|
|
437
|
+
`Duplicate interaction for element "${elementId}" in scene "${sceneId}"`
|
|
438
|
+
| SceneNotFound({sceneId}) => `Scene "${sceneId}" not found in wireframe`
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Format all merge errors for display.
|
|
444
|
+
*
|
|
445
|
+
* @param errors - Array of merge errors
|
|
446
|
+
* @returns Formatted error messages joined with newlines
|
|
447
|
+
*/
|
|
448
|
+
let formatErrors = (errors: array<mergeError>): string => {
|
|
449
|
+
errors->Array.map(formatError)->Array.join("\n")
|
|
450
|
+
}
|