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,1368 @@
|
|
|
1
|
+
// SemanticParser.res
|
|
2
|
+
// Stage 3: Semantic parsing - extracts and interprets box content
|
|
3
|
+
|
|
4
|
+
open Types
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Type Definitions
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Box type representing a rectangular container with bounds and children.
|
|
12
|
+
* This matches the Box variant from the element type but is used during
|
|
13
|
+
* the shape detection stage before full element parsing.
|
|
14
|
+
*/
|
|
15
|
+
type rec box = {
|
|
16
|
+
name: option<string>,
|
|
17
|
+
bounds: Bounds.t,
|
|
18
|
+
children: array<box>,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Type for scene metadata extracted from directives.
|
|
23
|
+
* Represents the parsed values from @scene, @title, @transition, @device directives.
|
|
24
|
+
*/
|
|
25
|
+
type sceneMetadata = {
|
|
26
|
+
id: string,
|
|
27
|
+
title: string,
|
|
28
|
+
transition: string,
|
|
29
|
+
device: deviceType,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// Box Content Extraction
|
|
34
|
+
// ============================================================================
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check if a position is within any child box (for Grid.t version).
|
|
38
|
+
*/
|
|
39
|
+
let isWithinChildBoxGrid = (row: int, col: int, children: array<box>): bool => {
|
|
40
|
+
children->Array.some(child => {
|
|
41
|
+
let b = child.bounds
|
|
42
|
+
row >= b.top && row <= b.bottom && col >= b.left && col <= b.right
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extracts content lines from within a box's bounds, excluding child box regions.
|
|
48
|
+
*
|
|
49
|
+
* Core box content extraction function for SemanticParser
|
|
50
|
+
*
|
|
51
|
+
* Algorithm:
|
|
52
|
+
* 1. Calculate content area by excluding border rows (top and bottom)
|
|
53
|
+
* 2. For each content row, extract characters between left and right borders
|
|
54
|
+
* 3. SKIP regions covered by child boxes to avoid duplicate content
|
|
55
|
+
* 4. Preserve all internal whitespace exactly as it appears
|
|
56
|
+
* 5. Convert cellChar array to string representation
|
|
57
|
+
*
|
|
58
|
+
* Border Exclusion:
|
|
59
|
+
* - Top border: row at bounds.top
|
|
60
|
+
* - Bottom border: row at bounds.bottom
|
|
61
|
+
* - Left border: column at bounds.left
|
|
62
|
+
* - Right border: column at bounds.right
|
|
63
|
+
*
|
|
64
|
+
* Content Area:
|
|
65
|
+
* - Starts at row: bounds.top + 1
|
|
66
|
+
* - Ends at row: bounds.bottom - 1
|
|
67
|
+
* - Starts at column: bounds.left + 1
|
|
68
|
+
* - Ends at column: bounds.right - 1
|
|
69
|
+
*
|
|
70
|
+
* @param box - The box to extract content from
|
|
71
|
+
* @param grid - The grid containing the box (Grid.t type from Scanner module)
|
|
72
|
+
* @returns Array of content lines (strings) without borders
|
|
73
|
+
*
|
|
74
|
+
* Example:
|
|
75
|
+
* ```
|
|
76
|
+
* +--Login--+ (row 0 - top border, excluded)
|
|
77
|
+
* | #email | (row 1 - content: " #email ")
|
|
78
|
+
* | [Submit]| (row 2 - content: " [Submit]")
|
|
79
|
+
* +---------+ (row 3 - bottom border, excluded)
|
|
80
|
+
* ```
|
|
81
|
+
* Returns: [" #email ", " [Submit]"]
|
|
82
|
+
*
|
|
83
|
+
* The function preserves:
|
|
84
|
+
* - Leading whitespace: " #email" keeps the two leading spaces
|
|
85
|
+
* - Trailing whitespace: " [Submit]" keeps the leading space
|
|
86
|
+
* - Internal whitespace: all spaces within content are preserved
|
|
87
|
+
*/
|
|
88
|
+
let extractContentLinesFromGrid = (box: box, grid: Grid.t): array<string> => {
|
|
89
|
+
let bounds = box.bounds
|
|
90
|
+
let children = box.children
|
|
91
|
+
|
|
92
|
+
// Calculate content row range (exclude top and bottom borders)
|
|
93
|
+
let contentStartRow = bounds.top + 1
|
|
94
|
+
let contentEndRow = bounds.bottom - 1
|
|
95
|
+
|
|
96
|
+
// If box has no content area (height <= 2), return empty array
|
|
97
|
+
// This handles edge cases like single-line boxes: +---+
|
|
98
|
+
if contentStartRow > contentEndRow {
|
|
99
|
+
[]
|
|
100
|
+
} else {
|
|
101
|
+
// Build array of content lines
|
|
102
|
+
let contentLines = []
|
|
103
|
+
|
|
104
|
+
// Iterate through each content row
|
|
105
|
+
for row in contentStartRow to contentEndRow {
|
|
106
|
+
// Skip rows that are entirely within child boxes
|
|
107
|
+
let rowCoveredByChild = children->Array.some(child => {
|
|
108
|
+
let b = child.bounds
|
|
109
|
+
row >= b.top && row <= b.bottom &&
|
|
110
|
+
b.left <= bounds.left + 1 && b.right >= bounds.right - 1
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
if !rowCoveredByChild {
|
|
114
|
+
// Use Grid.getLine to safely retrieve the row
|
|
115
|
+
switch Grid.getLine(grid, row) {
|
|
116
|
+
| Some(rowCells) => {
|
|
117
|
+
// Calculate content column range (exclude left and right borders)
|
|
118
|
+
let contentStartCol = bounds.left + 1
|
|
119
|
+
let contentEndCol = bounds.right - 1
|
|
120
|
+
|
|
121
|
+
// Validate that we have a valid content area
|
|
122
|
+
if contentStartCol <= contentEndCol {
|
|
123
|
+
// Build line character by character, skipping child box regions
|
|
124
|
+
let lineChars = []
|
|
125
|
+
|
|
126
|
+
for col in contentStartCol to contentEndCol {
|
|
127
|
+
// Skip columns within child boxes
|
|
128
|
+
if !isWithinChildBoxGrid(row, col, children) {
|
|
129
|
+
switch rowCells->Array.get(col) {
|
|
130
|
+
| Some(cell) => {
|
|
131
|
+
let char = switch cell {
|
|
132
|
+
| Char(c) => c
|
|
133
|
+
| Space => " "
|
|
134
|
+
| VLine => "|"
|
|
135
|
+
| HLine => "-"
|
|
136
|
+
| Corner => "+"
|
|
137
|
+
| Divider => "="
|
|
138
|
+
}
|
|
139
|
+
lineChars->Array.push(char)->ignore
|
|
140
|
+
}
|
|
141
|
+
| None => ()
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let line = lineChars->Array.join("")
|
|
147
|
+
contentLines->Array.push(line)->ignore
|
|
148
|
+
} else {
|
|
149
|
+
// Invalid content area (left border >= right border)
|
|
150
|
+
contentLines->Array.push("")->ignore
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
| None => {
|
|
154
|
+
// Row doesn't exist in grid
|
|
155
|
+
contentLines->Array.push("")->ignore
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
contentLines
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Convert a single cellChar to its string representation
|
|
167
|
+
* Used internally for debugging and display purposes
|
|
168
|
+
*/
|
|
169
|
+
let cellCharToString = (cell: cellChar): string => {
|
|
170
|
+
switch cell {
|
|
171
|
+
| Char(c) => c
|
|
172
|
+
| Space => " "
|
|
173
|
+
| VLine => "|"
|
|
174
|
+
| HLine => "-"
|
|
175
|
+
| Corner => "+"
|
|
176
|
+
| Divider => "="
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Convert an array of cellChar to a string
|
|
182
|
+
* This is a utility function that can be used by other semantic parsing functions
|
|
183
|
+
*/
|
|
184
|
+
let cellCharsToString = (cells: array<cellChar>): string => {
|
|
185
|
+
cells->Array.map(cellCharToString)->Array.join("")
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Get box content as a single string with newlines
|
|
190
|
+
* Useful for debugging and logging
|
|
191
|
+
*/
|
|
192
|
+
let getBoxContentAsString = (box: box, grid: Grid.t): string => {
|
|
193
|
+
let lines = extractContentLinesFromGrid(box, grid)
|
|
194
|
+
lines->Array.join("\n")
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Check if a box has any content (non-empty lines)
|
|
199
|
+
*/
|
|
200
|
+
let hasContent = (box: box, grid: Grid.t): bool => {
|
|
201
|
+
let lines = extractContentLinesFromGrid(box, grid)
|
|
202
|
+
lines->Array.some(line => {
|
|
203
|
+
let trimmed = String.trim(line)
|
|
204
|
+
String.length(trimmed) > 0
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get the number of content lines in a box (excluding borders)
|
|
210
|
+
*/
|
|
211
|
+
let getContentLineCount = (box: box, _grid: Grid.t): int => {
|
|
212
|
+
let bounds = box.bounds
|
|
213
|
+
let contentStartRow = bounds.top + 1
|
|
214
|
+
let contentEndRow = bounds.bottom - 1
|
|
215
|
+
|
|
216
|
+
if contentStartRow > contentEndRow {
|
|
217
|
+
0
|
|
218
|
+
} else {
|
|
219
|
+
contentEndRow - contentStartRow + 1
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ============================================================================
|
|
224
|
+
// Scene Directive Parsing
|
|
225
|
+
// ============================================================================
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Default scene metadata values.
|
|
229
|
+
* Used when directives are not present or as fallback values.
|
|
230
|
+
*/
|
|
231
|
+
let defaultSceneMetadata = (): sceneMetadata => {
|
|
232
|
+
id: "main",
|
|
233
|
+
title: "main",
|
|
234
|
+
transition: "fade",
|
|
235
|
+
device: Desktop,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Parse scene directives from an array of lines.
|
|
240
|
+
*
|
|
241
|
+
* Recognizes and extracts:
|
|
242
|
+
* - @scene: <id> - Sets the scene identifier
|
|
243
|
+
* - @title: <title> - Sets the scene title (quotes are removed)
|
|
244
|
+
* - @transition: <transition> - Sets the transition type
|
|
245
|
+
*
|
|
246
|
+
* Returns a tuple of (sceneMetadata, contentLines) where contentLines
|
|
247
|
+
* are the non-directive lines that contain actual wireframe content.
|
|
248
|
+
*
|
|
249
|
+
* @param lines - Array of lines from a scene block
|
|
250
|
+
* @returns Tuple of (metadata, content lines)
|
|
251
|
+
*
|
|
252
|
+
* Example:
|
|
253
|
+
* ```
|
|
254
|
+
* @scene: login
|
|
255
|
+
* @title: "Login Page"
|
|
256
|
+
* @transition: slide
|
|
257
|
+
*
|
|
258
|
+
* +--Login--+
|
|
259
|
+
* | #email |
|
|
260
|
+
* +----------+
|
|
261
|
+
* ```
|
|
262
|
+
* Returns: ({id: "login", title: "Login Page", transition: "slide"}, ["+--Login--+", ...])
|
|
263
|
+
*/
|
|
264
|
+
let parseSceneDirectives = (lines: array<string>): (sceneMetadata, array<string>) => {
|
|
265
|
+
// Use mutable refs to accumulate directive values
|
|
266
|
+
let sceneId = ref(None)
|
|
267
|
+
let title = ref(None)
|
|
268
|
+
let transition = ref(None)
|
|
269
|
+
let device = ref(None)
|
|
270
|
+
let contentLines = []
|
|
271
|
+
|
|
272
|
+
lines->Array.forEach(line => {
|
|
273
|
+
let trimmed = line->String.trim
|
|
274
|
+
|
|
275
|
+
if trimmed->String.startsWith("@scene:") {
|
|
276
|
+
// Extract scene ID: "@scene: login" -> "login"
|
|
277
|
+
let id = trimmed->String.replace("@scene:", "")->String.trim
|
|
278
|
+
sceneId := Some(id)
|
|
279
|
+
} else if trimmed->String.startsWith("@title:") {
|
|
280
|
+
// Extract title: "@title: Login Page" -> "Login Page"
|
|
281
|
+
// Remove quotes if present: "@title: "Login Page"" -> "Login Page"
|
|
282
|
+
let titleValue =
|
|
283
|
+
trimmed
|
|
284
|
+
->String.replace("@title:", "")
|
|
285
|
+
->String.trim
|
|
286
|
+
->String.replaceAll("\"", "")
|
|
287
|
+
title := Some(titleValue)
|
|
288
|
+
} else if trimmed->String.startsWith("@transition:") {
|
|
289
|
+
// Extract transition: "@transition: slide" -> "slide"
|
|
290
|
+
let transitionValue = trimmed->String.replace("@transition:", "")->String.trim
|
|
291
|
+
transition := Some(transitionValue)
|
|
292
|
+
} else if trimmed->String.startsWith("@device:") {
|
|
293
|
+
// Extract device: "@device: mobile" -> Mobile
|
|
294
|
+
let deviceValue = trimmed->String.replace("@device:", "")->String.trim
|
|
295
|
+
switch parseDeviceType(deviceValue) {
|
|
296
|
+
| Some(d) => device := Some(d)
|
|
297
|
+
| None => () // Invalid device, use default
|
|
298
|
+
}
|
|
299
|
+
} else if trimmed->String.startsWith("@") {
|
|
300
|
+
// Skip other @ directives (for future extensibility)
|
|
301
|
+
()
|
|
302
|
+
} else {
|
|
303
|
+
// Non-directive line - add to content
|
|
304
|
+
contentLines->Array.push(line)
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
// Build final metadata record
|
|
309
|
+
let finalId = switch sceneId.contents {
|
|
310
|
+
| Some(id) => id
|
|
311
|
+
| None => "main"
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
let finalTitle = switch (title.contents, sceneId.contents) {
|
|
315
|
+
| (Some(t), _) => t // Explicit title takes precedence
|
|
316
|
+
| (None, Some(id)) => id // Use scene ID as title if no explicit title
|
|
317
|
+
| (None, None) => "main" // Default
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let finalTransition = switch transition.contents {
|
|
321
|
+
| Some(t) => t
|
|
322
|
+
| None => "fade"
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let finalDevice = switch device.contents {
|
|
326
|
+
| Some(d) => d
|
|
327
|
+
| None => Desktop // Default to desktop
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let metadata = {
|
|
331
|
+
id: finalId,
|
|
332
|
+
title: finalTitle,
|
|
333
|
+
transition: finalTransition,
|
|
334
|
+
device: finalDevice,
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
(metadata, contentLines)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Split wireframe input into scene blocks.
|
|
342
|
+
*
|
|
343
|
+
* Scenes are separated by:
|
|
344
|
+
* 1. The separator "---" on its own line
|
|
345
|
+
* 2. A new @scene directive
|
|
346
|
+
*
|
|
347
|
+
* This function groups the wireframe content into distinct scene blocks
|
|
348
|
+
* that can then be individually parsed for metadata and elements.
|
|
349
|
+
*
|
|
350
|
+
* @param wireframeText - The complete wireframe input string
|
|
351
|
+
* @returns Array of scene block strings
|
|
352
|
+
*
|
|
353
|
+
* Example:
|
|
354
|
+
* ```
|
|
355
|
+
* @scene: login
|
|
356
|
+
* +--Login--+
|
|
357
|
+
*
|
|
358
|
+
* ---
|
|
359
|
+
*
|
|
360
|
+
* @scene: home
|
|
361
|
+
* +--Home--+
|
|
362
|
+
* ```
|
|
363
|
+
* Returns: ["@scene: login\n+--Login--+", "@scene: home\n+--Home--+"]
|
|
364
|
+
*/
|
|
365
|
+
let splitSceneBlocks = (wireframeText: string): array<string> => {
|
|
366
|
+
let lines = wireframeText->String.split("\n")
|
|
367
|
+
let blocks = []
|
|
368
|
+
let currentBlock = ref([])
|
|
369
|
+
|
|
370
|
+
lines->Array.forEach(line => {
|
|
371
|
+
let trimmed = line->String.trim
|
|
372
|
+
|
|
373
|
+
// Check for scene separator "---"
|
|
374
|
+
if trimmed === "---" {
|
|
375
|
+
// Finish current block if it has content
|
|
376
|
+
if currentBlock.contents->Array.length > 0 {
|
|
377
|
+
blocks->Array.push(currentBlock.contents->Array.join("\n"))
|
|
378
|
+
currentBlock := []
|
|
379
|
+
}
|
|
380
|
+
} else if trimmed->String.startsWith("@scene:") && currentBlock.contents->Array.length > 0 {
|
|
381
|
+
// New scene directive - finish previous block
|
|
382
|
+
blocks->Array.push(currentBlock.contents->Array.join("\n"))
|
|
383
|
+
currentBlock := [line]
|
|
384
|
+
} else {
|
|
385
|
+
// Regular content line - add to current block
|
|
386
|
+
currentBlock := currentBlock.contents->Array.concat([line])
|
|
387
|
+
}
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
// Add final block if it has content
|
|
391
|
+
if currentBlock.contents->Array.length > 0 {
|
|
392
|
+
blocks->Array.push(currentBlock.contents->Array.join("\n"))
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Filter out empty blocks
|
|
396
|
+
blocks->Array.filter(block => block->String.trim !== "")
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Group content lines by scene boundaries.
|
|
401
|
+
*
|
|
402
|
+
* This is the main function for scene directive parsing that:
|
|
403
|
+
* 1. Splits input into scene blocks
|
|
404
|
+
* 2. Parses directives from each block
|
|
405
|
+
* 3. Returns metadata and content for each scene
|
|
406
|
+
*
|
|
407
|
+
* If no scene blocks are found, creates a single default scene
|
|
408
|
+
* with all content.
|
|
409
|
+
*
|
|
410
|
+
* @param wireframeText - Complete wireframe input
|
|
411
|
+
* @returns Array of tuples (metadata, contentLines) for each scene
|
|
412
|
+
*
|
|
413
|
+
* Example:
|
|
414
|
+
* ```
|
|
415
|
+
* @scene: login
|
|
416
|
+
* @title: Login
|
|
417
|
+
* +--Login--+
|
|
418
|
+
* ```
|
|
419
|
+
* Returns: [({id: "login", title: "Login", transition: "fade"}, ["+--Login--+"])]
|
|
420
|
+
*/
|
|
421
|
+
let groupContentByScenes = (wireframeText: string): array<(sceneMetadata, array<string>)> => {
|
|
422
|
+
let blocks = splitSceneBlocks(wireframeText)
|
|
423
|
+
|
|
424
|
+
// If no blocks found, create default scene with all content
|
|
425
|
+
if blocks->Array.length === 0 {
|
|
426
|
+
let trimmed = wireframeText->String.trim
|
|
427
|
+
if trimmed !== "" {
|
|
428
|
+
[(defaultSceneMetadata(), [wireframeText])]
|
|
429
|
+
} else {
|
|
430
|
+
[]
|
|
431
|
+
}
|
|
432
|
+
} else {
|
|
433
|
+
// Parse each block into metadata and content
|
|
434
|
+
blocks->Array.map(block => {
|
|
435
|
+
let lines = block->String.split("\n")
|
|
436
|
+
parseSceneDirectives(lines)
|
|
437
|
+
})
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ============================================================================
|
|
442
|
+
// Box Content Extraction (continued)
|
|
443
|
+
// ============================================================================
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Check if a column is within any child box's horizontal bounds for a given row.
|
|
447
|
+
* Returns true if the column falls inside a child box at the specified row.
|
|
448
|
+
*/
|
|
449
|
+
let isWithinChildBox = (row: int, col: int, children: array<box>): bool => {
|
|
450
|
+
children->Array.some(child => {
|
|
451
|
+
let b = child.bounds
|
|
452
|
+
// Check if position is within child box bounds (including borders)
|
|
453
|
+
row >= b.top && row <= b.bottom && col >= b.left && col <= b.right
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Check if a row intersects with any child box.
|
|
459
|
+
* Returns true if the row passes through a child box.
|
|
460
|
+
*/
|
|
461
|
+
let rowIntersectsChildBox = (row: int, children: array<box>): bool => {
|
|
462
|
+
children->Array.some(child => {
|
|
463
|
+
let b = child.bounds
|
|
464
|
+
row >= b.top && row <= b.bottom
|
|
465
|
+
})
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Extracts content lines from within a box's bounds, excluding child box regions.
|
|
470
|
+
*
|
|
471
|
+
* This function:
|
|
472
|
+
* - Excludes the top border line (row at bounds.top)
|
|
473
|
+
* - Excludes the bottom border line (row at bounds.bottom)
|
|
474
|
+
* - Extracts content from rows between top and bottom
|
|
475
|
+
* - Removes the left and right border characters ('|') from each line
|
|
476
|
+
* - SKIPS regions covered by child boxes to avoid duplicate content
|
|
477
|
+
* - Preserves all internal whitespace
|
|
478
|
+
*
|
|
479
|
+
* @param box - The box to extract content from
|
|
480
|
+
* @param gridCells - The 2D array of grid cells
|
|
481
|
+
* @returns Array of content lines (strings) without borders
|
|
482
|
+
*
|
|
483
|
+
* Example:
|
|
484
|
+
* ```
|
|
485
|
+
* +--Login--+
|
|
486
|
+
* | #email |
|
|
487
|
+
* | [Submit]|
|
|
488
|
+
* +---------+
|
|
489
|
+
* ```
|
|
490
|
+
* Returns: [" #email ", " [Submit]"]
|
|
491
|
+
*/
|
|
492
|
+
let extractContentLines = (box: box, gridCells: array<array<cellChar>>): array<string> => {
|
|
493
|
+
let bounds = box.bounds
|
|
494
|
+
let children = box.children
|
|
495
|
+
|
|
496
|
+
// Calculate content rows (exclude top and bottom borders)
|
|
497
|
+
let contentStartRow = bounds.top + 1
|
|
498
|
+
let contentEndRow = bounds.bottom - 1
|
|
499
|
+
|
|
500
|
+
// If box has no content area (height <= 2), return empty array
|
|
501
|
+
if contentStartRow > contentEndRow {
|
|
502
|
+
[]
|
|
503
|
+
} else {
|
|
504
|
+
// Build array of content lines
|
|
505
|
+
let contentLines = []
|
|
506
|
+
let gridHeight = Array.length(gridCells)
|
|
507
|
+
|
|
508
|
+
for row in contentStartRow to contentEndRow {
|
|
509
|
+
// Skip rows that are entirely within child boxes
|
|
510
|
+
// (rows where the entire content area is covered by children)
|
|
511
|
+
let rowCoveredByChild = children->Array.some(child => {
|
|
512
|
+
let b = child.bounds
|
|
513
|
+
// Row is completely covered if it's within child's vertical bounds
|
|
514
|
+
// AND child spans the entire parent content width
|
|
515
|
+
row >= b.top && row <= b.bottom &&
|
|
516
|
+
b.left <= bounds.left + 1 && b.right >= bounds.right - 1
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
if !rowCoveredByChild && row >= 0 && row < gridHeight {
|
|
520
|
+
// Get the row from the grid
|
|
521
|
+
switch gridCells[row] {
|
|
522
|
+
| Some(rowCells) => {
|
|
523
|
+
// Check if this row starts with a Corner (section header pattern)
|
|
524
|
+
// In this case, include the corner in the content
|
|
525
|
+
let leftBorderCell = rowCells->Array.get(bounds.left)
|
|
526
|
+
let startsWithCorner = switch leftBorderCell {
|
|
527
|
+
| Some(Corner) => true
|
|
528
|
+
| _ => false
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Determine content column range
|
|
532
|
+
// If row starts with Corner, include it (section header)
|
|
533
|
+
// Otherwise, skip the left border as usual
|
|
534
|
+
let contentStartCol = if startsWithCorner {
|
|
535
|
+
bounds.left // Include the '+' at the left edge
|
|
536
|
+
} else {
|
|
537
|
+
bounds.left + 1 // Skip the '|' border
|
|
538
|
+
}
|
|
539
|
+
let contentEndCol = bounds.right - 1
|
|
540
|
+
|
|
541
|
+
// Extract characters between borders, skipping child box regions
|
|
542
|
+
let lineChars = []
|
|
543
|
+
|
|
544
|
+
for col in contentStartCol to contentEndCol {
|
|
545
|
+
// Skip columns that are within child boxes at this row
|
|
546
|
+
if !isWithinChildBox(row, col, children) {
|
|
547
|
+
if col >= 0 && col < Array.length(rowCells) {
|
|
548
|
+
let cell = rowCells->Array.getUnsafe(col)
|
|
549
|
+
switch cell {
|
|
550
|
+
| Char(c) => lineChars->Array.push(c)
|
|
551
|
+
| Space => lineChars->Array.push(" ")
|
|
552
|
+
| VLine => lineChars->Array.push("|")
|
|
553
|
+
| HLine => lineChars->Array.push("-")
|
|
554
|
+
| Corner => lineChars->Array.push("+")
|
|
555
|
+
| Divider => lineChars->Array.push("=")
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Convert character array to string
|
|
562
|
+
let line = lineChars->Array.join("")
|
|
563
|
+
contentLines->Array.push(line)
|
|
564
|
+
}
|
|
565
|
+
| None => ()
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
contentLines
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ============================================================================
|
|
575
|
+
// Element Recognition Pipeline
|
|
576
|
+
// ============================================================================
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Inline element segment type.
|
|
580
|
+
* Represents a piece of content that may be text or a special element.
|
|
581
|
+
* Includes column offset for position-based alignment calculation.
|
|
582
|
+
*/
|
|
583
|
+
type inlineSegment =
|
|
584
|
+
| TextSegment(string, int) // (text, column offset within line)
|
|
585
|
+
| ButtonSegment(string, int) // (text inside [ ], column offset)
|
|
586
|
+
| LinkSegment(string, int) // (text inside " ", column offset)
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Split a content line into inline segments with position information.
|
|
590
|
+
* Handles mixed content like "Dashboard [ Logout ]" -> [TextSegment("Dashboard", 0), ButtonSegment("Logout", 15)]
|
|
591
|
+
*
|
|
592
|
+
* Pattern recognition:
|
|
593
|
+
* - [ Text ] -> ButtonSegment with column offset
|
|
594
|
+
* - Other text -> TextSegment with column offset
|
|
595
|
+
*
|
|
596
|
+
* @param line - The content line to split
|
|
597
|
+
* @returns Array of inline segments with column offsets
|
|
598
|
+
*/
|
|
599
|
+
/**
|
|
600
|
+
* Check if content inside brackets is a checkbox pattern.
|
|
601
|
+
* Checkbox patterns: "x", "X", " " (single character only)
|
|
602
|
+
*/
|
|
603
|
+
let isCheckboxContent = (content: string): bool => {
|
|
604
|
+
let trimmed = content->String.trim
|
|
605
|
+
let lowerContent = trimmed->String.toLowerCase
|
|
606
|
+
// [x] or [X] - checked checkbox
|
|
607
|
+
// [ ] - unchecked checkbox (empty or just whitespace)
|
|
608
|
+
lowerContent === "x" || trimmed === ""
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
let splitInlineSegments = (line: string): array<inlineSegment> => {
|
|
612
|
+
let segments = []
|
|
613
|
+
let currentText = ref("")
|
|
614
|
+
let currentTextStart = ref(0)
|
|
615
|
+
let i = ref(0)
|
|
616
|
+
let len = line->String.length
|
|
617
|
+
|
|
618
|
+
while i.contents < len {
|
|
619
|
+
let char = line->String.charAt(i.contents)
|
|
620
|
+
|
|
621
|
+
// Check for button pattern [ ... ]
|
|
622
|
+
if char === "[" {
|
|
623
|
+
// Find matching ]
|
|
624
|
+
let buttonStart = i.contents
|
|
625
|
+
let start = i.contents + 1
|
|
626
|
+
let endPos = ref(None)
|
|
627
|
+
let j = ref(start)
|
|
628
|
+
while j.contents < len && endPos.contents === None {
|
|
629
|
+
if line->String.charAt(j.contents) === "]" {
|
|
630
|
+
endPos := Some(j.contents)
|
|
631
|
+
}
|
|
632
|
+
j := j.contents + 1
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
switch endPos.contents {
|
|
636
|
+
| Some(end) => {
|
|
637
|
+
let bracketContent = line->String.slice(~start, ~end)
|
|
638
|
+
|
|
639
|
+
// Check if this is a checkbox pattern [x], [X], or [ ]
|
|
640
|
+
// If so, treat the whole thing as text, not a button
|
|
641
|
+
if isCheckboxContent(bracketContent) {
|
|
642
|
+
// Include the brackets in text accumulation
|
|
643
|
+
if currentText.contents === "" {
|
|
644
|
+
currentTextStart := i.contents
|
|
645
|
+
}
|
|
646
|
+
// Add the entire checkbox pattern to current text
|
|
647
|
+
let checkboxText = "[" ++ bracketContent ++ "]"
|
|
648
|
+
currentText := currentText.contents ++ checkboxText
|
|
649
|
+
i := end + 1
|
|
650
|
+
} else {
|
|
651
|
+
// This is a button pattern
|
|
652
|
+
// Flush any accumulated text
|
|
653
|
+
let text = currentText.contents->String.trim
|
|
654
|
+
if text !== "" {
|
|
655
|
+
// Find actual start position (skip leading whitespace)
|
|
656
|
+
let leadingSpaces = String.length(currentText.contents) - String.length(currentText.contents->String.trimStart)
|
|
657
|
+
segments->Array.push(TextSegment(text, currentTextStart.contents + leadingSpaces))->ignore
|
|
658
|
+
}
|
|
659
|
+
currentText := ""
|
|
660
|
+
|
|
661
|
+
let buttonText = bracketContent->String.trim
|
|
662
|
+
if buttonText !== "" {
|
|
663
|
+
segments->Array.push(ButtonSegment(buttonText, buttonStart))->ignore
|
|
664
|
+
}
|
|
665
|
+
i := end + 1
|
|
666
|
+
currentTextStart := i.contents
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
| None => {
|
|
670
|
+
// No matching ], treat as regular text
|
|
671
|
+
if currentText.contents === "" {
|
|
672
|
+
currentTextStart := i.contents
|
|
673
|
+
}
|
|
674
|
+
currentText := currentText.contents ++ char
|
|
675
|
+
i := i.contents + 1
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
} else {
|
|
679
|
+
// Regular character
|
|
680
|
+
if currentText.contents === "" {
|
|
681
|
+
currentTextStart := i.contents
|
|
682
|
+
}
|
|
683
|
+
currentText := currentText.contents ++ char
|
|
684
|
+
i := i.contents + 1
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Flush remaining text
|
|
689
|
+
let text = currentText.contents->String.trim
|
|
690
|
+
if text !== "" {
|
|
691
|
+
let leadingSpaces = String.length(currentText.contents) - String.length(currentText.contents->String.trimStart)
|
|
692
|
+
segments->Array.push(TextSegment(text, currentTextStart.contents + leadingSpaces))->ignore
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
segments
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Check if a line contains inline elements (buttons, links mixed with text).
|
|
700
|
+
* Returns true if the line has multiple segments or a single non-text segment.
|
|
701
|
+
*/
|
|
702
|
+
let hasInlineElements = (line: string): bool => {
|
|
703
|
+
let segments = splitInlineSegments(line)
|
|
704
|
+
// Has inline elements if:
|
|
705
|
+
// 1. More than one segment
|
|
706
|
+
// 2. Or single segment that is not text
|
|
707
|
+
if segments->Array.length > 1 {
|
|
708
|
+
true
|
|
709
|
+
} else {
|
|
710
|
+
switch segments->Array.get(0) {
|
|
711
|
+
| Some(TextSegment(_, _)) => false
|
|
712
|
+
| Some(ButtonSegment(_, _)) => true
|
|
713
|
+
| Some(LinkSegment(_, _)) => true
|
|
714
|
+
| None => false
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Check if a line is a divider (consists of '=' characters).
|
|
721
|
+
*
|
|
722
|
+
* Dividers act as section separators within boxes.
|
|
723
|
+
* A line is considered a divider if it consists primarily of '=' characters.
|
|
724
|
+
*
|
|
725
|
+
* @param line - The content line to check
|
|
726
|
+
* @returns true if this is a divider line
|
|
727
|
+
*/
|
|
728
|
+
let isDividerLine = (line: string): bool => {
|
|
729
|
+
let trimmed = line->String.trim
|
|
730
|
+
// Check if the line consists only of '=' characters (at least 3)
|
|
731
|
+
// Also match +===+ or +=== patterns (divider with corners, trailing + may be cut off)
|
|
732
|
+
let pureEqualsPattern = %re("/^=+$/")
|
|
733
|
+
let cornerDividerPattern = %re("/^\+=+\+?$/") // Optional trailing +
|
|
734
|
+
let length = trimmed->String.length
|
|
735
|
+
length >= 3 && (Js.Re.test_(pureEqualsPattern, trimmed) || Js.Re.test_(cornerDividerPattern, trimmed))
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Check if a line is a section header pattern.
|
|
740
|
+
* Matches patterns like:
|
|
741
|
+
* - "+--Name--+" (full section header)
|
|
742
|
+
* - "--Name--+" (left edge shared with outer box)
|
|
743
|
+
* - "+--Name--" (right edge shared with outer box)
|
|
744
|
+
* - "--Name--" (both edges shared)
|
|
745
|
+
*
|
|
746
|
+
* @param line - The content line to check
|
|
747
|
+
* @returns Option containing section name if this is a section header
|
|
748
|
+
*/
|
|
749
|
+
let extractSectionName = (line: string): option<string> => {
|
|
750
|
+
let trimmed = line->String.trim
|
|
751
|
+
|
|
752
|
+
// Pattern: +--Name--+ or --Name--+ or +--Name-- or --Name--
|
|
753
|
+
// Must contain at least -- on each side of the name
|
|
754
|
+
// Allow optional trailing space or characters after the closing +
|
|
755
|
+
let sectionPattern = %re("/^\+?-{2,}([^-+]+)-{2,}\+?\s*$/")
|
|
756
|
+
|
|
757
|
+
switch Js.Re.exec_(sectionPattern, trimmed) {
|
|
758
|
+
| Some(result) => {
|
|
759
|
+
// Get the captured group (the name)
|
|
760
|
+
switch Js.Re.captures(result)->Array.get(1) {
|
|
761
|
+
| Some(Js.Nullable.Value(name)) => {
|
|
762
|
+
let cleanName = name->String.trim
|
|
763
|
+
if cleanName !== "" {
|
|
764
|
+
Some(cleanName)
|
|
765
|
+
} else {
|
|
766
|
+
None
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
| _ => None
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
| None => None
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Check if a line is a section footer (closing border).
|
|
778
|
+
* Matches patterns like:
|
|
779
|
+
* - "+-------+" (full footer)
|
|
780
|
+
* - "--------+" (left edge shared)
|
|
781
|
+
* - "+--------" (right edge shared)
|
|
782
|
+
* - "--------" (both edges shared)
|
|
783
|
+
*
|
|
784
|
+
* @param line - The content line to check
|
|
785
|
+
* @returns true if this is a section footer
|
|
786
|
+
*/
|
|
787
|
+
let isSectionFooter = (line: string): bool => {
|
|
788
|
+
let trimmed = line->String.trim
|
|
789
|
+
// Pattern: only + and - characters, at least 3 dashes
|
|
790
|
+
// Allow optional trailing space
|
|
791
|
+
let footerPattern = %re("/^\+?-{3,}\+?\s*$/")
|
|
792
|
+
Js.Re.test_(footerPattern, trimmed)
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Strip section borders from a content line.
|
|
797
|
+
* Section content lines often have their own | borders that need to be removed.
|
|
798
|
+
* Handles patterns like "| Content |" or "| Content"
|
|
799
|
+
*
|
|
800
|
+
* @param line - The content line to strip
|
|
801
|
+
* @returns The line with leading/trailing | borders removed
|
|
802
|
+
*/
|
|
803
|
+
let stripSectionBorders = (line: string): string => {
|
|
804
|
+
let trimmed = line->String.trim
|
|
805
|
+
|
|
806
|
+
// Remove leading "| " or "|"
|
|
807
|
+
let withoutLeading = if String.startsWith(trimmed, "| ") {
|
|
808
|
+
String.sliceToEnd(trimmed, ~start=2)
|
|
809
|
+
} else if String.startsWith(trimmed, "|") {
|
|
810
|
+
String.sliceToEnd(trimmed, ~start=1)
|
|
811
|
+
} else {
|
|
812
|
+
trimmed
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Remove trailing " |" or "|"
|
|
816
|
+
let result = if String.endsWith(withoutLeading, " |") {
|
|
817
|
+
String.slice(withoutLeading, ~start=0, ~end=String.length(withoutLeading) - 2)
|
|
818
|
+
} else if String.endsWith(withoutLeading, "|") {
|
|
819
|
+
String.slice(withoutLeading, ~start=0, ~end=String.length(withoutLeading) - 1)
|
|
820
|
+
} else {
|
|
821
|
+
withoutLeading
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
result
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Parse elements from box content lines using the parser registry.
|
|
829
|
+
*
|
|
830
|
+
* Algorithm:
|
|
831
|
+
* 1. Extract content lines from box
|
|
832
|
+
* 2. Iterate through each content line
|
|
833
|
+
* 3. Detect dividers as section separators
|
|
834
|
+
* 4. Calculate line position in grid (with column offset for alignment)
|
|
835
|
+
* 5. Call registry.parse to recognize element type
|
|
836
|
+
* 6. Collect all parsed elements
|
|
837
|
+
*
|
|
838
|
+
* Divider Handling:
|
|
839
|
+
* - Lines consisting only of '===' are treated as dividers
|
|
840
|
+
* - Dividers create Divider elements in the output
|
|
841
|
+
* - Dividers act as visual section separators in the UI
|
|
842
|
+
*
|
|
843
|
+
* @param box - The box containing elements
|
|
844
|
+
* @param gridCells - The 2D array of grid cells
|
|
845
|
+
* @param registry - The parser registry for element recognition
|
|
846
|
+
* @returns Array of parsed elements
|
|
847
|
+
*
|
|
848
|
+
* Requirements: REQ-15 (Element parsing and AST generation)
|
|
849
|
+
*/
|
|
850
|
+
/**
|
|
851
|
+
* Convert inline segment to element.
|
|
852
|
+
* Creates appropriate element type based on segment variant.
|
|
853
|
+
* Uses the segment's column offset for accurate position-based alignment.
|
|
854
|
+
*/
|
|
855
|
+
let segmentToElement = (
|
|
856
|
+
segment: inlineSegment,
|
|
857
|
+
basePosition: Position.t,
|
|
858
|
+
baseCol: int,
|
|
859
|
+
bounds: Bounds.t,
|
|
860
|
+
): element => {
|
|
861
|
+
switch segment {
|
|
862
|
+
| TextSegment(text, colOffset) => {
|
|
863
|
+
// Calculate actual position using segment's column offset
|
|
864
|
+
let actualCol = baseCol + colOffset
|
|
865
|
+
let position = Position.make(basePosition.row, actualCol)
|
|
866
|
+
|
|
867
|
+
// Calculate alignment based on actual position within bounds
|
|
868
|
+
let align = AlignmentCalc.calculateWithStrategy(
|
|
869
|
+
text,
|
|
870
|
+
position,
|
|
871
|
+
bounds,
|
|
872
|
+
AlignmentCalc.RespectPosition,
|
|
873
|
+
)
|
|
874
|
+
Text({
|
|
875
|
+
content: text,
|
|
876
|
+
position: position,
|
|
877
|
+
emphasis: false,
|
|
878
|
+
align: align,
|
|
879
|
+
})
|
|
880
|
+
}
|
|
881
|
+
| ButtonSegment(text, colOffset) => {
|
|
882
|
+
// Calculate actual position using segment's column offset
|
|
883
|
+
let actualCol = baseCol + colOffset
|
|
884
|
+
let position = Position.make(basePosition.row, actualCol)
|
|
885
|
+
|
|
886
|
+
// Create button ID from text (slugified)
|
|
887
|
+
let id = text
|
|
888
|
+
->String.trim
|
|
889
|
+
->String.toLowerCase
|
|
890
|
+
->Js.String2.replaceByRe(%re("/\\s+/g"), "-")
|
|
891
|
+
->Js.String2.replaceByRe(%re("/[^a-z0-9-]/g"), "")
|
|
892
|
+
->Js.String2.replaceByRe(%re("/-+/g"), "-")
|
|
893
|
+
->Js.String2.replaceByRe(%re("/^-+|-+$/g"), "")
|
|
894
|
+
|
|
895
|
+
// Use "[ text ]" format (with spaces) to match visual button width for alignment
|
|
896
|
+
let buttonContent = "[ " ++ text ++ " ]"
|
|
897
|
+
let align = AlignmentCalc.calculateWithStrategy(
|
|
898
|
+
buttonContent,
|
|
899
|
+
position,
|
|
900
|
+
bounds,
|
|
901
|
+
AlignmentCalc.RespectPosition,
|
|
902
|
+
)
|
|
903
|
+
Button({
|
|
904
|
+
id: id,
|
|
905
|
+
text: text,
|
|
906
|
+
position: position,
|
|
907
|
+
align: align,
|
|
908
|
+
actions: [],
|
|
909
|
+
})
|
|
910
|
+
}
|
|
911
|
+
| LinkSegment(text, colOffset) => {
|
|
912
|
+
// Calculate actual position using segment's column offset
|
|
913
|
+
let actualCol = baseCol + colOffset
|
|
914
|
+
let position = Position.make(basePosition.row, actualCol)
|
|
915
|
+
|
|
916
|
+
let id = text
|
|
917
|
+
->String.trim
|
|
918
|
+
->String.toLowerCase
|
|
919
|
+
->Js.String2.replaceByRe(%re("/\\s+/g"), "-")
|
|
920
|
+
->Js.String2.replaceByRe(%re("/[^a-z0-9-]/g"), "")
|
|
921
|
+
|
|
922
|
+
let align = AlignmentCalc.calculateWithStrategy(
|
|
923
|
+
text,
|
|
924
|
+
position,
|
|
925
|
+
bounds,
|
|
926
|
+
AlignmentCalc.RespectPosition,
|
|
927
|
+
)
|
|
928
|
+
Link({
|
|
929
|
+
id: id,
|
|
930
|
+
text: text,
|
|
931
|
+
position: position,
|
|
932
|
+
align: align,
|
|
933
|
+
actions: [],
|
|
934
|
+
})
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Parse a single content line into an element.
|
|
941
|
+
* Handles buttons, links, inline elements, and regular text.
|
|
942
|
+
*/
|
|
943
|
+
let parseContentLine = (
|
|
944
|
+
line: string,
|
|
945
|
+
lineIndex: int,
|
|
946
|
+
contentStartRow: int,
|
|
947
|
+
box: box,
|
|
948
|
+
registry: ParserRegistry.t,
|
|
949
|
+
): option<element> => {
|
|
950
|
+
let trimmed = line->String.trim
|
|
951
|
+
|
|
952
|
+
// Skip empty lines
|
|
953
|
+
if trimmed === "" {
|
|
954
|
+
None
|
|
955
|
+
} else {
|
|
956
|
+
// Calculate position in grid
|
|
957
|
+
let row = contentStartRow + lineIndex
|
|
958
|
+
|
|
959
|
+
// Calculate base column (content starts after left border)
|
|
960
|
+
let baseCol = box.bounds.left + 1
|
|
961
|
+
|
|
962
|
+
let basePosition = Position.make(row, baseCol)
|
|
963
|
+
|
|
964
|
+
// Check for inline elements (mixed content like "Dashboard [ Logout ]")
|
|
965
|
+
let segments = splitInlineSegments(trimmed)
|
|
966
|
+
|
|
967
|
+
if segments->Array.length > 1 {
|
|
968
|
+
// Multiple segments - create a Row with all elements
|
|
969
|
+
// Each segment has its own column offset for correct alignment calculation
|
|
970
|
+
let rowChildren = segments->Array.map(segment => {
|
|
971
|
+
segmentToElement(segment, basePosition, baseCol, box.bounds)
|
|
972
|
+
})
|
|
973
|
+
Some(Row({
|
|
974
|
+
children: rowChildren,
|
|
975
|
+
align: Left,
|
|
976
|
+
}))
|
|
977
|
+
} else if segments->Array.length === 1 {
|
|
978
|
+
// Single segment - check if it's a special element
|
|
979
|
+
// Need to account for leading spaces from the original line since
|
|
980
|
+
// splitInlineSegments operates on the trimmed string
|
|
981
|
+
let leadingSpaces = {
|
|
982
|
+
let original = line
|
|
983
|
+
let trimmedStart = original->String.trimStart
|
|
984
|
+
original->String.length - trimmedStart->String.length
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
switch segments->Array.get(0) {
|
|
988
|
+
| Some(ButtonSegment(text, colOffset)) => {
|
|
989
|
+
// Add leading spaces to colOffset for correct position calculation
|
|
990
|
+
let actualCol = baseCol + leadingSpaces + colOffset
|
|
991
|
+
let position = Position.make(row, actualCol)
|
|
992
|
+
// Use "[ text ]" format (with spaces) to match visual button width
|
|
993
|
+
let buttonContent = "[ " ++ text ++ " ]"
|
|
994
|
+
Some(registry->ParserRegistry.parse(buttonContent, position, box.bounds))
|
|
995
|
+
}
|
|
996
|
+
| Some(LinkSegment(text, colOffset)) => {
|
|
997
|
+
// For LinkSegment, we also need to account for leading spaces
|
|
998
|
+
let actualCol = baseCol + leadingSpaces + colOffset
|
|
999
|
+
let adjustedPosition = Position.make(basePosition.row, actualCol)
|
|
1000
|
+
Some(segmentToElement(LinkSegment(text, colOffset), adjustedPosition, baseCol + leadingSpaces, box.bounds))
|
|
1001
|
+
}
|
|
1002
|
+
| Some(TextSegment(_, _)) | None => {
|
|
1003
|
+
// For single text segment, use original position calculation
|
|
1004
|
+
let position = Position.make(row, baseCol + leadingSpaces)
|
|
1005
|
+
Some(registry->ParserRegistry.parse(trimmed, position, box.bounds))
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
} else {
|
|
1009
|
+
let leadingSpaces = {
|
|
1010
|
+
let original = line
|
|
1011
|
+
let trimmedStart = original->String.trimStart
|
|
1012
|
+
original->String.length - trimmedStart->String.length
|
|
1013
|
+
}
|
|
1014
|
+
let position = Position.make(row, baseCol + leadingSpaces)
|
|
1015
|
+
Some(registry->ParserRegistry.parse(trimmed, position, box.bounds))
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
let parseBoxContent = (
|
|
1021
|
+
box: box,
|
|
1022
|
+
gridCells: array<array<cellChar>>,
|
|
1023
|
+
registry: ParserRegistry.t,
|
|
1024
|
+
): array<element> => {
|
|
1025
|
+
let contentLines = extractContentLines(box, gridCells)
|
|
1026
|
+
let elements = []
|
|
1027
|
+
|
|
1028
|
+
// Calculate starting row for content (first row inside box border)
|
|
1029
|
+
let contentStartRow = box.bounds.top + 1
|
|
1030
|
+
|
|
1031
|
+
// State for section parsing
|
|
1032
|
+
let currentSection = ref(None) // (name, startIndex, contentLines)
|
|
1033
|
+
let i = ref(0)
|
|
1034
|
+
|
|
1035
|
+
while i.contents < contentLines->Array.length {
|
|
1036
|
+
let lineOpt = contentLines->Array.get(i.contents)
|
|
1037
|
+
switch lineOpt {
|
|
1038
|
+
| None => i := i.contents + 1
|
|
1039
|
+
| Some(line) => {
|
|
1040
|
+
let lineIndex = i.contents
|
|
1041
|
+
|
|
1042
|
+
// Check if this line is a divider
|
|
1043
|
+
if isDividerLine(line) {
|
|
1044
|
+
let row = contentStartRow + lineIndex
|
|
1045
|
+
let col = box.bounds.left + 1
|
|
1046
|
+
elements->Array.push(Divider({position: Position.make(row, col)}))->ignore
|
|
1047
|
+
i := i.contents + 1
|
|
1048
|
+
} else {
|
|
1049
|
+
// Check for section header pattern
|
|
1050
|
+
switch extractSectionName(line) {
|
|
1051
|
+
| Some(sectionName) => {
|
|
1052
|
+
// Start a new section - collect lines until footer
|
|
1053
|
+
let sectionContent = []
|
|
1054
|
+
i := i.contents + 1
|
|
1055
|
+
|
|
1056
|
+
// Collect content until section footer or end of lines
|
|
1057
|
+
let foundFooter = ref(false)
|
|
1058
|
+
while i.contents < contentLines->Array.length && !foundFooter.contents {
|
|
1059
|
+
switch contentLines->Array.get(i.contents) {
|
|
1060
|
+
| Some(contentLine) => {
|
|
1061
|
+
if isSectionFooter(contentLine) {
|
|
1062
|
+
foundFooter := true
|
|
1063
|
+
i := i.contents + 1
|
|
1064
|
+
} else {
|
|
1065
|
+
sectionContent->Array.push((contentLine, i.contents))->ignore
|
|
1066
|
+
i := i.contents + 1
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
| None => i := i.contents + 1
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// Parse section content into elements
|
|
1074
|
+
let sectionElements = []
|
|
1075
|
+
sectionContent->Array.forEach(((contentLine, contentLineIndex)) => {
|
|
1076
|
+
// Strip section borders (| characters) before parsing
|
|
1077
|
+
let strippedLine = stripSectionBorders(contentLine)
|
|
1078
|
+
switch parseContentLine(strippedLine, contentLineIndex, contentStartRow, box, registry) {
|
|
1079
|
+
| Some(elem) => sectionElements->Array.push(elem)->ignore
|
|
1080
|
+
| None => ()
|
|
1081
|
+
}
|
|
1082
|
+
})
|
|
1083
|
+
|
|
1084
|
+
// Create Section element
|
|
1085
|
+
let sectionElement = Section({
|
|
1086
|
+
name: sectionName,
|
|
1087
|
+
children: sectionElements,
|
|
1088
|
+
})
|
|
1089
|
+
elements->Array.push(sectionElement)->ignore
|
|
1090
|
+
}
|
|
1091
|
+
| None => {
|
|
1092
|
+
// Not a section header - check for section footer (without header)
|
|
1093
|
+
if isSectionFooter(line) {
|
|
1094
|
+
// Skip orphan footer
|
|
1095
|
+
i := i.contents + 1
|
|
1096
|
+
} else {
|
|
1097
|
+
// Regular content line
|
|
1098
|
+
switch parseContentLine(line, lineIndex, contentStartRow, box, registry) {
|
|
1099
|
+
| Some(elem) => elements->Array.push(elem)->ignore
|
|
1100
|
+
| None => ()
|
|
1101
|
+
}
|
|
1102
|
+
i := i.contents + 1
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// Handle any remaining section
|
|
1112
|
+
switch currentSection.contents {
|
|
1113
|
+
| Some((name, _, sectionLines)) => {
|
|
1114
|
+
let sectionElements = []
|
|
1115
|
+
sectionLines->Array.forEach(((contentLine, contentLineIndex)) => {
|
|
1116
|
+
// Strip section borders (| characters) before parsing
|
|
1117
|
+
let strippedLine = stripSectionBorders(contentLine)
|
|
1118
|
+
switch parseContentLine(strippedLine, contentLineIndex, contentStartRow, box, registry) {
|
|
1119
|
+
| Some(elem) => sectionElements->Array.push(elem)->ignore
|
|
1120
|
+
| None => ()
|
|
1121
|
+
}
|
|
1122
|
+
})
|
|
1123
|
+
elements->Array.push(Section({name: name, children: sectionElements}))->ignore
|
|
1124
|
+
}
|
|
1125
|
+
| None => ()
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
elements
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* Get the row position of an element for sorting purposes.
|
|
1133
|
+
*/
|
|
1134
|
+
let rec getElementRow = (elem: element): int => {
|
|
1135
|
+
switch elem {
|
|
1136
|
+
| Box({bounds, _}) => bounds.top
|
|
1137
|
+
| Button({position, _}) => position.row
|
|
1138
|
+
| Input({position, _}) => position.row
|
|
1139
|
+
| Link({position, _}) => position.row
|
|
1140
|
+
| Checkbox({position, _}) => position.row
|
|
1141
|
+
| Text({position, _}) => position.row
|
|
1142
|
+
| Divider({position}) => position.row
|
|
1143
|
+
| Row({children, _}) => {
|
|
1144
|
+
// Use the first child's row position
|
|
1145
|
+
switch children->Array.get(0) {
|
|
1146
|
+
| Some(child) => getElementRow(child)
|
|
1147
|
+
| None => 0
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
| Section({children, _}) => {
|
|
1151
|
+
// Use the first child's row position, or high value if empty
|
|
1152
|
+
switch children->Array.get(0) {
|
|
1153
|
+
| Some(child) => getElementRow(child)
|
|
1154
|
+
| None => Int.Constants.maxValue // Empty sections sort last
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Recursively parse a box and all its children into elements.
|
|
1162
|
+
*
|
|
1163
|
+
* This function:
|
|
1164
|
+
* 1. Parses the content of the current box
|
|
1165
|
+
* 2. Recursively parses all child boxes
|
|
1166
|
+
* 3. SORTS all elements by their row position to preserve visual order
|
|
1167
|
+
* 4. Creates a Box element containing all parsed children
|
|
1168
|
+
*
|
|
1169
|
+
* @param box - The box to parse
|
|
1170
|
+
* @param gridCells - The 2D array of grid cells
|
|
1171
|
+
* @param registry - The parser registry
|
|
1172
|
+
* @returns A Box element with all children
|
|
1173
|
+
*
|
|
1174
|
+
* Requirements: REQ-15, REQ-6 (Hierarchy reflection in AST)
|
|
1175
|
+
*/
|
|
1176
|
+
let rec parseBoxRecursive = (
|
|
1177
|
+
box: box,
|
|
1178
|
+
gridCells: array<array<cellChar>>,
|
|
1179
|
+
registry: ParserRegistry.t,
|
|
1180
|
+
): element => {
|
|
1181
|
+
// Parse immediate content of this box
|
|
1182
|
+
let contentElements = parseBoxContent(box, gridCells, registry)
|
|
1183
|
+
|
|
1184
|
+
// Recursively parse child boxes
|
|
1185
|
+
let childBoxElements = box.children->Array.map(childBox => {
|
|
1186
|
+
parseBoxRecursive(childBox, gridCells, registry)
|
|
1187
|
+
})
|
|
1188
|
+
|
|
1189
|
+
// Combine content elements and child boxes
|
|
1190
|
+
let allChildren = Array.concat(contentElements, childBoxElements)
|
|
1191
|
+
|
|
1192
|
+
// Sort elements by their row position to preserve visual order
|
|
1193
|
+
let sortedChildren = allChildren->Array.toSorted((a, b) => {
|
|
1194
|
+
let rowA = getElementRow(a)
|
|
1195
|
+
let rowB = getElementRow(b)
|
|
1196
|
+
Float.fromInt(rowA - rowB)
|
|
1197
|
+
})
|
|
1198
|
+
|
|
1199
|
+
// Create Box element
|
|
1200
|
+
Box({
|
|
1201
|
+
name: box.name,
|
|
1202
|
+
bounds: box.bounds,
|
|
1203
|
+
children: sortedChildren,
|
|
1204
|
+
})
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// ============================================================================
|
|
1208
|
+
// AST Builder
|
|
1209
|
+
// ============================================================================
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* Build a scene from metadata and parsed elements.
|
|
1213
|
+
*
|
|
1214
|
+
* @param metadata - Scene metadata (id, title, transition)
|
|
1215
|
+
* @param elements - Array of parsed elements in the scene
|
|
1216
|
+
* @returns A complete scene record
|
|
1217
|
+
*
|
|
1218
|
+
* Requirements: REQ-15 (Scene structure in AST)
|
|
1219
|
+
*/
|
|
1220
|
+
let buildScene = (metadata: sceneMetadata, elements: array<element>): scene => {
|
|
1221
|
+
{
|
|
1222
|
+
id: metadata.id,
|
|
1223
|
+
title: metadata.title,
|
|
1224
|
+
transition: metadata.transition,
|
|
1225
|
+
device: metadata.device,
|
|
1226
|
+
elements: elements,
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* Build complete AST from array of scenes.
|
|
1232
|
+
*
|
|
1233
|
+
* @param scenes - Array of parsed scenes
|
|
1234
|
+
* @returns Complete AST with scenes array
|
|
1235
|
+
*
|
|
1236
|
+
* Requirements: REQ-15 (AST root structure)
|
|
1237
|
+
*/
|
|
1238
|
+
let buildAST = (scenes: array<scene>): ast => {
|
|
1239
|
+
{
|
|
1240
|
+
scenes: scenes,
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// ============================================================================
|
|
1245
|
+
// Main Parse Function
|
|
1246
|
+
// ============================================================================
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* Parse context containing all inputs needed for semantic parsing.
|
|
1250
|
+
*/
|
|
1251
|
+
type parseContext = {
|
|
1252
|
+
gridCells: array<array<cellChar>>,
|
|
1253
|
+
shapes: array<box>,
|
|
1254
|
+
registry: ParserRegistry.t,
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* Parse result: either an AST or a list of errors.
|
|
1259
|
+
*/
|
|
1260
|
+
type parseResult = result<ast, array<ErrorTypes.t>>
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* Main semantic parsing function.
|
|
1264
|
+
*
|
|
1265
|
+
* This function integrates all parsing stages:
|
|
1266
|
+
* 1. Groups shapes by scene (using scene directives if present)
|
|
1267
|
+
* 2. Parses content from each box
|
|
1268
|
+
* 3. Recognizes elements using the parser registry
|
|
1269
|
+
* 4. Builds complete AST with scenes
|
|
1270
|
+
* 5. Collects all errors encountered during parsing
|
|
1271
|
+
*
|
|
1272
|
+
* Algorithm:
|
|
1273
|
+
* - Accept gridCells, shapes, and registry as input
|
|
1274
|
+
* - For each shape (root box):
|
|
1275
|
+
* - Parse box content recursively
|
|
1276
|
+
* - Build scene with metadata
|
|
1277
|
+
* - Combine all scenes into AST
|
|
1278
|
+
* - Return Result with AST or errors
|
|
1279
|
+
*
|
|
1280
|
+
* @param context - Parse context containing grid cells, shapes, and registry
|
|
1281
|
+
* @returns Result<ast, errors> - Either complete AST or array of parse errors
|
|
1282
|
+
*
|
|
1283
|
+
* Requirements: REQ-15 (Complete AST generation)
|
|
1284
|
+
*/
|
|
1285
|
+
let parse = (context: parseContext): parseResult => {
|
|
1286
|
+
let errors = []
|
|
1287
|
+
|
|
1288
|
+
// If no shapes, return empty AST
|
|
1289
|
+
if context.shapes->Array.length === 0 {
|
|
1290
|
+
Ok(buildAST([]))
|
|
1291
|
+
} else {
|
|
1292
|
+
|
|
1293
|
+
// Parse each root-level box into a scene
|
|
1294
|
+
let scenes = context.shapes->Array.map(box => {
|
|
1295
|
+
// Parse box recursively to get all elements
|
|
1296
|
+
let boxElement = parseBoxRecursive(box, context.gridCells, context.registry)
|
|
1297
|
+
|
|
1298
|
+
// Extract children from the box element
|
|
1299
|
+
let elements = switch boxElement {
|
|
1300
|
+
| Box({children, _}) => children
|
|
1301
|
+
| _ => [] // Should never happen for root boxes
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// Create scene metadata
|
|
1305
|
+
// For now, we use the box name as scene ID if available
|
|
1306
|
+
let metadata = switch box.name {
|
|
1307
|
+
| Some(name) => {
|
|
1308
|
+
id: name,
|
|
1309
|
+
title: name,
|
|
1310
|
+
transition: "fade",
|
|
1311
|
+
device: Desktop,
|
|
1312
|
+
}
|
|
1313
|
+
| None => defaultSceneMetadata()
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// Build scene
|
|
1317
|
+
buildScene(metadata, elements)
|
|
1318
|
+
})
|
|
1319
|
+
|
|
1320
|
+
// Build complete AST
|
|
1321
|
+
let ast = buildAST(scenes)
|
|
1322
|
+
|
|
1323
|
+
// Return result
|
|
1324
|
+
if errors->Array.length > 0 {
|
|
1325
|
+
Error(errors)
|
|
1326
|
+
} else {
|
|
1327
|
+
Ok(ast)
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* Parse wireframe text with scene directives.
|
|
1334
|
+
*
|
|
1335
|
+
* This is a higher-level function that:
|
|
1336
|
+
* 1. Splits input by scene directives
|
|
1337
|
+
* 2. Parses each scene separately
|
|
1338
|
+
* 3. Combines into complete AST
|
|
1339
|
+
*
|
|
1340
|
+
* Note: This function assumes the grid and shapes have already been
|
|
1341
|
+
* extracted. For complete wireframe parsing, use WyreframeParser module.
|
|
1342
|
+
*
|
|
1343
|
+
* @param wireframeText - Raw wireframe text with scene directives
|
|
1344
|
+
* @param context - Parse context
|
|
1345
|
+
* @returns Result with complete AST or errors
|
|
1346
|
+
*/
|
|
1347
|
+
let parseWithSceneDirectives = (
|
|
1348
|
+
wireframeText: string,
|
|
1349
|
+
context: parseContext,
|
|
1350
|
+
): parseResult => {
|
|
1351
|
+
// Group content by scene directives
|
|
1352
|
+
let sceneGroups = groupContentByScenes(wireframeText)
|
|
1353
|
+
|
|
1354
|
+
// For each scene group, find matching shapes and parse
|
|
1355
|
+
let scenes = sceneGroups->Array.map(((metadata, _contentLines)) => {
|
|
1356
|
+
// For now, parse all shapes into this scene
|
|
1357
|
+
// TODO: In future, match shapes to scene boundaries
|
|
1358
|
+
let elements = context.shapes->Array.map(box => {
|
|
1359
|
+
parseBoxRecursive(box, context.gridCells, context.registry)
|
|
1360
|
+
})
|
|
1361
|
+
|
|
1362
|
+
buildScene(metadata, elements)
|
|
1363
|
+
})
|
|
1364
|
+
|
|
1365
|
+
// Build AST
|
|
1366
|
+
let ast = buildAST(scenes)
|
|
1367
|
+
Ok(ast)
|
|
1368
|
+
}
|