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.
Files changed (117) hide show
  1. package/README.md +123 -0
  2. package/dist/index.d.ts +267 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +195 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +63 -0
  7. package/src/parser/Core/Bounds.mjs +61 -0
  8. package/src/parser/Core/Bounds.res +65 -0
  9. package/src/parser/Core/Grid.mjs +268 -0
  10. package/src/parser/Core/Grid.res +265 -0
  11. package/src/parser/Core/Position.mjs +83 -0
  12. package/src/parser/Core/Position.res +54 -0
  13. package/src/parser/Core/Types.mjs +435 -0
  14. package/src/parser/Core/Types.res +331 -0
  15. package/src/parser/Core/__tests__/Bounds_test.mjs +326 -0
  16. package/src/parser/Core/__tests__/Bounds_test.res +412 -0
  17. package/src/parser/Core/__tests__/Grid_test.mjs +322 -0
  18. package/src/parser/Core/__tests__/Grid_test.res +319 -0
  19. package/src/parser/Core/__tests__/Types_test.mjs +614 -0
  20. package/src/parser/Core/__tests__/Types_test.res +650 -0
  21. package/src/parser/Detector/BoxTracer.mjs +302 -0
  22. package/src/parser/Detector/BoxTracer.res +374 -0
  23. package/src/parser/Detector/HierarchyBuilder.mjs +158 -0
  24. package/src/parser/Detector/HierarchyBuilder.res +315 -0
  25. package/src/parser/Detector/ShapeDetector.mjs +134 -0
  26. package/src/parser/Detector/ShapeDetector.res +236 -0
  27. package/src/parser/Detector/__tests__/BoxTracer_test.mjs +70 -0
  28. package/src/parser/Detector/__tests__/BoxTracer_test.res +92 -0
  29. package/src/parser/Detector/__tests__/HierarchyBuilder_test.mjs +489 -0
  30. package/src/parser/Detector/__tests__/HierarchyBuilder_test.res +849 -0
  31. package/src/parser/Detector/__tests__/ShapeDetector_test.mjs +377 -0
  32. package/src/parser/Detector/__tests__/ShapeDetector_test.res +563 -0
  33. package/src/parser/Errors/ErrorContext.mjs +106 -0
  34. package/src/parser/Errors/ErrorContext.res +191 -0
  35. package/src/parser/Errors/ErrorMessages.mjs +289 -0
  36. package/src/parser/Errors/ErrorMessages.res +303 -0
  37. package/src/parser/Errors/ErrorTypes.mjs +105 -0
  38. package/src/parser/Errors/ErrorTypes.res +169 -0
  39. package/src/parser/Interactions/InteractionMerger.mjs +266 -0
  40. package/src/parser/Interactions/InteractionMerger.res +450 -0
  41. package/src/parser/Interactions/InteractionParser.mjs +88 -0
  42. package/src/parser/Interactions/InteractionParser.res +127 -0
  43. package/src/parser/Interactions/SimpleInteractionParser.mjs +278 -0
  44. package/src/parser/Interactions/SimpleInteractionParser.res +262 -0
  45. package/src/parser/Interactions/__tests__/InteractionMerger_test.mjs +576 -0
  46. package/src/parser/Interactions/__tests__/InteractionMerger_test.res +646 -0
  47. package/src/parser/Parser.gen.tsx +96 -0
  48. package/src/parser/Parser.mjs +212 -0
  49. package/src/parser/Parser.res +481 -0
  50. package/src/parser/Scanner/__tests__/Grid_manual.mjs +214 -0
  51. package/src/parser/Scanner/__tests__/Grid_manual.res +141 -0
  52. package/src/parser/Semantic/ASTBuilder.mjs +197 -0
  53. package/src/parser/Semantic/ASTBuilder.res +288 -0
  54. package/src/parser/Semantic/AlignmentCalc.mjs +41 -0
  55. package/src/parser/Semantic/AlignmentCalc.res +104 -0
  56. package/src/parser/Semantic/Elements/ButtonParser.mjs +58 -0
  57. package/src/parser/Semantic/Elements/ButtonParser.res +131 -0
  58. package/src/parser/Semantic/Elements/CheckboxParser.mjs +58 -0
  59. package/src/parser/Semantic/Elements/CheckboxParser.res +79 -0
  60. package/src/parser/Semantic/Elements/CodeTextParser.mjs +50 -0
  61. package/src/parser/Semantic/Elements/CodeTextParser.res +111 -0
  62. package/src/parser/Semantic/Elements/ElementParser.mjs +15 -0
  63. package/src/parser/Semantic/Elements/ElementParser.res +83 -0
  64. package/src/parser/Semantic/Elements/EmphasisParser.mjs +46 -0
  65. package/src/parser/Semantic/Elements/EmphasisParser.res +67 -0
  66. package/src/parser/Semantic/Elements/InputParser.mjs +41 -0
  67. package/src/parser/Semantic/Elements/InputParser.res +97 -0
  68. package/src/parser/Semantic/Elements/LinkParser.mjs +60 -0
  69. package/src/parser/Semantic/Elements/LinkParser.res +156 -0
  70. package/src/parser/Semantic/Elements/TextParser.mjs +19 -0
  71. package/src/parser/Semantic/Elements/TextParser.res +42 -0
  72. package/src/parser/Semantic/Elements/__tests__/ButtonParser_test.mjs +189 -0
  73. package/src/parser/Semantic/Elements/__tests__/ButtonParser_test.res +257 -0
  74. package/src/parser/Semantic/Elements/__tests__/CheckboxParser_test.mjs +202 -0
  75. package/src/parser/Semantic/Elements/__tests__/CheckboxParser_test.res +250 -0
  76. package/src/parser/Semantic/Elements/__tests__/CodeTextParser_manual.mjs +293 -0
  77. package/src/parser/Semantic/Elements/__tests__/CodeTextParser_manual.res +134 -0
  78. package/src/parser/Semantic/Elements/__tests__/InputParser_test.mjs +253 -0
  79. package/src/parser/Semantic/Elements/__tests__/InputParser_test.res +304 -0
  80. package/src/parser/Semantic/Elements/__tests__/LinkParser_test.mjs +289 -0
  81. package/src/parser/Semantic/Elements/__tests__/LinkParser_test.res +402 -0
  82. package/src/parser/Semantic/Elements/__tests__/TextParser_test.mjs +149 -0
  83. package/src/parser/Semantic/Elements/__tests__/TextParser_test.res +167 -0
  84. package/src/parser/Semantic/ParserRegistry.mjs +82 -0
  85. package/src/parser/Semantic/ParserRegistry.res +145 -0
  86. package/src/parser/Semantic/SemanticParser.mjs +850 -0
  87. package/src/parser/Semantic/SemanticParser.res +1368 -0
  88. package/src/parser/Semantic/__tests__/ASTBuilder_test.mjs +187 -0
  89. package/src/parser/Semantic/__tests__/ASTBuilder_test.res +192 -0
  90. package/src/parser/Semantic/__tests__/ParserRegistry_test.mjs +154 -0
  91. package/src/parser/Semantic/__tests__/ParserRegistry_test.res +191 -0
  92. package/src/parser/Semantic/__tests__/SemanticParser_integration_test.mjs +768 -0
  93. package/src/parser/Semantic/__tests__/SemanticParser_integration_test.res +1069 -0
  94. package/src/parser/Semantic/__tests__/SemanticParser_manual.mjs +1329 -0
  95. package/src/parser/Semantic/__tests__/SemanticParser_manual.res +544 -0
  96. package/src/parser/TestMain.mjs +21 -0
  97. package/src/parser/TestMain.res +14 -0
  98. package/src/parser/TextExtractor.mjs +179 -0
  99. package/src/parser/TextExtractor.res +264 -0
  100. package/src/parser/__tests__/GridScanner_integration.test.mjs +632 -0
  101. package/src/parser/__tests__/GridScanner_integration.test.res +816 -0
  102. package/src/parser/__tests__/Performance.test.mjs +244 -0
  103. package/src/parser/__tests__/Performance.test.res +371 -0
  104. package/src/parser/__tests__/PerformanceFixtures.mjs +200 -0
  105. package/src/parser/__tests__/PerformanceFixtures.res +284 -0
  106. package/src/parser/__tests__/WyreframeParser_integration.test.mjs +770 -0
  107. package/src/parser/__tests__/WyreframeParser_integration.test.res +1008 -0
  108. package/src/parser/__tests__/fixtures/alignment-test.txt +9 -0
  109. package/src/parser/__tests__/fixtures/all-elements.txt +16 -0
  110. package/src/parser/__tests__/fixtures/login-scene.txt +17 -0
  111. package/src/parser/__tests__/fixtures/multi-scene.txt +25 -0
  112. package/src/parser/__tests__/fixtures/nested-boxes.txt +15 -0
  113. package/src/parser/__tests__/fixtures/simple-box.txt +5 -0
  114. package/src/parser/__tests__/fixtures/with-dividers.txt +14 -0
  115. package/src/renderer/Renderer.gen.tsx +32 -0
  116. package/src/renderer/Renderer.mjs +391 -0
  117. 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
+ }