wyreframe 0.7.1 → 0.7.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wyreframe",
3
- "version": "0.7.1",
3
+ "version": "0.7.4",
4
4
  "description": "ASCII wireframe + interaction DSL to HTML converter with scene transitions",
5
5
  "author": "wickedev",
6
6
  "repository": {
package/src/index.test.ts CHANGED
@@ -352,9 +352,9 @@ describe('mixed text and link content (Issue #14)', () => {
352
352
  const wireframe = `
353
353
  @scene: test
354
354
 
355
- +------------------------------------+
356
- | Please "click here" to continue |
357
- +------------------------------------+
355
+ +---------------------------------+
356
+ | Please "click here" to continue |
357
+ +---------------------------------+
358
358
  `;
359
359
 
360
360
  const result = parse(wireframe);
@@ -402,6 +402,8 @@ function getElementType(elem) {
402
402
  return "Text";
403
403
  case "Divider" :
404
404
  return "Divider";
405
+ case "Spacer" :
406
+ return "Spacer";
405
407
  case "Row" :
406
408
  return "Row";
407
409
  case "Section" :
@@ -180,6 +180,9 @@ and element =
180
180
  | Divider({
181
181
  position: Position.t,
182
182
  })
183
+ | Spacer({
184
+ position: Position.t,
185
+ })
183
186
  | Row({
184
187
  children: array<element>,
185
188
  align: alignment,
@@ -284,6 +287,7 @@ let getElementType = (elem: element): string => {
284
287
  | Checkbox(_) => "Checkbox"
285
288
  | Text(_) => "Text"
286
289
  | Divider(_) => "Divider"
290
+ | Spacer(_) => "Spacer"
287
291
  | Row(_) => "Row"
288
292
  | Section(_) => "Section"
289
293
  }
@@ -87,7 +87,24 @@ function findBottomRightCorner(grid, topLeft, topRight) {
87
87
  switch (match) {
88
88
  case "Corner" :
89
89
  lastCorner = pos;
90
- row = row + 1 | 0;
90
+ let nextRow = row + 1 | 0;
91
+ if (nextRow < grid.height) {
92
+ let nextPos = Types.Position.make(nextRow, topRight.col);
93
+ let match$1 = Grid.get(grid, nextPos);
94
+ if (match$1 === "Corner" && typeof match$1 !== "object") {
95
+ let nextLeftPos = Types.Position.make(nextRow, topLeft.col);
96
+ let match$2 = Grid.get(grid, nextLeftPos);
97
+ if (match$2 === "Corner" && typeof match$2 !== "object") {
98
+ $$continue = false;
99
+ } else {
100
+ row = row + 1 | 0;
101
+ }
102
+ } else {
103
+ row = row + 1 | 0;
104
+ }
105
+ } else {
106
+ row = row + 1 | 0;
107
+ }
91
108
  break;
92
109
  case "VLine" :
93
110
  row = row + 1 | 0;
@@ -135,6 +152,57 @@ function traceBox(grid, topLeft) {
135
152
  })
136
153
  };
137
154
  }
155
+ let nextRow = topLeft.row + 1 | 0;
156
+ let isValidTopEdge;
157
+ if (nextRow < grid.height) {
158
+ let nextLeftPos = Types.Position.make(nextRow, topLeft.col);
159
+ let nextRightPos = Types.Position.make(nextRow, topRightOpt.col);
160
+ let leftChar = Grid.get(grid, nextLeftPos);
161
+ let rightChar = Grid.get(grid, nextRightPos);
162
+ if (leftChar !== undefined && typeof leftChar !== "object") {
163
+ switch (leftChar) {
164
+ case "Corner" :
165
+ if (rightChar !== undefined && typeof rightChar !== "object") {
166
+ switch (rightChar) {
167
+ case "Corner" :
168
+ isValidTopEdge = false;
169
+ break;
170
+ default:
171
+ isValidTopEdge = true;
172
+ }
173
+ } else {
174
+ isValidTopEdge = true;
175
+ }
176
+ break;
177
+ case "VLine" :
178
+ if (rightChar !== undefined && typeof rightChar !== "object") {
179
+ switch (rightChar) {
180
+ default:
181
+ isValidTopEdge = true;
182
+ }
183
+ } else {
184
+ isValidTopEdge = true;
185
+ }
186
+ break;
187
+ default:
188
+ isValidTopEdge = true;
189
+ }
190
+ } else {
191
+ isValidTopEdge = true;
192
+ }
193
+ } else {
194
+ isValidTopEdge = false;
195
+ }
196
+ if (!isValidTopEdge) {
197
+ return {
198
+ TAG: "Error",
199
+ _0: ErrorTypes.makeSimple({
200
+ TAG: "InvalidElement",
201
+ content: "Not a valid box top-left corner (adjacent box detected)",
202
+ position: topLeft
203
+ })
204
+ };
205
+ }
138
206
  let topEdgeChars = topEdgeScan.map(param => param[1]);
139
207
  if (isDividerOnlyEdge(topEdgeChars)) {
140
208
  return {
@@ -114,6 +114,7 @@ let rowHasVLineInRange = (grid: Grid.t, row: int, leftCol: int, rightCol: int):
114
114
  * - Continues through rows with misaligned VLines (records for warnings)
115
115
  * - Stops at rows with no VLine at all in the box's column range (box boundary)
116
116
  * - Handles internal dividers (+=====+) correctly by finding the last corner
117
+ * - IMPORTANT: Stops when detecting vertically adjacent boxes (Issue #18)
117
118
  *
118
119
  * @param grid - The 2D character grid
119
120
  * @param topLeft - Position of top-left corner (for determining left boundary)
@@ -132,7 +133,40 @@ let findBottomRightCorner = (grid: Grid.t, topLeft: Position.t, topRight: Positi
132
133
  | Some(Corner) => {
133
134
  // Found a corner at the expected column - remember it
134
135
  lastCorner := Some(pos)
135
- row := row.contents + 1
136
+
137
+ // Issue #18 fix: Check if the NEXT row also has a Corner at the same column.
138
+ // If so, we've found the bottom of THIS box and the top of ANOTHER box.
139
+ // We should stop scanning here to avoid merging adjacent boxes.
140
+ let nextRow = row.contents + 1
141
+ if nextRow < grid.height {
142
+ let nextPos = Position.make(nextRow, topRight.col)
143
+ switch Grid.get(grid, nextPos) {
144
+ | Some(Corner) => {
145
+ // Next row also has a corner - this indicates adjacent boxes
146
+ // Also check if the next row starts a new box by checking left column
147
+ let nextLeftPos = Position.make(nextRow, topLeft.col)
148
+ switch Grid.get(grid, nextLeftPos) {
149
+ | Some(Corner) => {
150
+ // Both corners present in next row = new box starting
151
+ // Stop scanning - current corner is our bottom-right
152
+ continue := false
153
+ }
154
+ | _ => {
155
+ // Only right corner in next row - might be internal structure
156
+ // Continue scanning
157
+ row := row.contents + 1
158
+ }
159
+ }
160
+ }
161
+ | _ => {
162
+ // Next row doesn't have a corner - continue normally
163
+ row := row.contents + 1
164
+ }
165
+ }
166
+ } else {
167
+ // No more rows - we're done
168
+ row := row.contents + 1
169
+ }
136
170
  }
137
171
  | Some(VLine) => {
138
172
  // Found a VLine at the expected column - continue scanning
@@ -198,6 +232,41 @@ let traceBox = (grid: Grid.t, topLeft: Position.t): traceResult => {
198
232
  ),
199
233
  )
200
234
  | Some(topRight) => {
235
+ // Issue #18 fix: Validate this is a TRUE top edge, not a bottom edge
236
+ // of another box. A valid top edge should have VLine characters (|)
237
+ // in the next row at the left and right columns, NOT Corner characters (+).
238
+ // If the next row has corners at both positions, this is likely the
239
+ // bottom edge of one box immediately followed by the top edge of another.
240
+ let nextRow = topLeft.row + 1
241
+ let isValidTopEdge = if nextRow < grid.height {
242
+ let nextLeftPos = Position.make(nextRow, topLeft.col)
243
+ let nextRightPos = Position.make(nextRow, topRight.col)
244
+ let leftChar = Grid.get(grid, nextLeftPos)
245
+ let rightChar = Grid.get(grid, nextRightPos)
246
+ // Valid top edge: next row has VLine on both sides (content row)
247
+ // Invalid: next row has Corner on both sides (another box edge)
248
+ switch (leftChar, rightChar) {
249
+ | (Some(Corner), Some(Corner)) => false // This is a bottom edge, not a top
250
+ | (Some(VLine), Some(VLine)) => true // This is a valid top edge
251
+ | (Some(VLine), Some(Corner)) => true // Could be valid with internal structure
252
+ | (Some(Corner), Some(VLine)) => true // Could be valid with internal structure
253
+ | _ => true // Be permissive for other cases
254
+ }
255
+ } else {
256
+ false // No next row means this can't be a valid box top
257
+ }
258
+
259
+ if !isValidTopEdge {
260
+ // This corner is likely a bottom edge of another box, not a valid top-left
261
+ Error(
262
+ ErrorTypes.makeSimple(
263
+ ErrorTypes.InvalidElement({
264
+ content: "Not a valid box top-left corner (adjacent box detected)",
265
+ position: topLeft,
266
+ }),
267
+ ),
268
+ )
269
+ } else {
201
270
  // Step 3: Extract box name from top edge
202
271
  let topEdgeChars = Array.map(topEdgeScan, ((_, cell)) => cell)
203
272
 
@@ -427,6 +496,7 @@ let traceBox = (grid: Grid.t, topLeft: Position.t): traceResult => {
427
496
  }
428
497
  }
429
498
  } // Close else block for isDividerOnlyEdge
499
+ } // Close else block for isValidTopEdge
430
500
  }
431
501
  }
432
502
  | _ =>
@@ -61,6 +61,7 @@ let rec collectElementIds = (element: element): Belt.Set.String.t => {
61
61
  | Checkbox(_) => ids // Checkboxes don't have explicit IDs
62
62
  | Text(_) => ids // Text elements don't have explicit IDs
63
63
  | Divider(_) => ids // Dividers don't have IDs
64
+ | Spacer(_) => ids // Spacers don't have IDs
64
65
  | Row({children}) => {
65
66
  children->Array.reduce(ids, (acc, child) => {
66
67
  Belt.Set.String.union(acc, collectElementIds(child))
@@ -337,6 +338,7 @@ let rec attachInteractionsToElement = (
337
338
  | Checkbox(_) as el => el
338
339
  | Text(_) as el => el
339
340
  | Divider(_) as el => el
341
+ | Spacer(_) as el => el
340
342
  }
341
343
  }
342
344
 
@@ -623,17 +623,40 @@ function segmentToElement(segment, basePosition, baseCol, bounds) {
623
623
  }
624
624
  }
625
625
 
626
+ function isNoiseText(content) {
627
+ let trimmed = content.trim();
628
+ if (trimmed === "") {
629
+ return false;
630
+ }
631
+ let borderPattern = /^[+|=\-\s]+$/;
632
+ let hasPipeOrPlus = /[+|]/;
633
+ if (borderPattern.test(trimmed)) {
634
+ return true;
635
+ } else {
636
+ return hasPipeOrPlus.test(trimmed);
637
+ }
638
+ }
639
+
626
640
  function parseContentLine(line, lineIndex, contentStartRow, box, registry) {
627
641
  let trimmed = line.trim();
628
642
  if (trimmed === "") {
643
+ let row = contentStartRow + lineIndex | 0;
644
+ let baseCol = box.bounds.left + 1 | 0;
645
+ let position = Types.Position.make(row, baseCol);
646
+ return {
647
+ TAG: "Spacer",
648
+ position: position
649
+ };
650
+ }
651
+ if (isNoiseText(trimmed)) {
629
652
  return;
630
653
  }
631
- let row = contentStartRow + lineIndex | 0;
632
- let baseCol = box.bounds.left + 1 | 0;
633
- let basePosition = Types.Position.make(row, baseCol);
654
+ let row$1 = contentStartRow + lineIndex | 0;
655
+ let baseCol$1 = box.bounds.left + 1 | 0;
656
+ let basePosition = Types.Position.make(row$1, baseCol$1);
634
657
  let segments = splitInlineSegments(trimmed);
635
658
  if (segments.length > 1) {
636
- let rowChildren = segments.map(segment => segmentToElement(segment, basePosition, baseCol, box.bounds));
659
+ let rowChildren = segments.map(segment => segmentToElement(segment, basePosition, baseCol$1, box.bounds));
637
660
  return {
638
661
  TAG: "Row",
639
662
  children: rowChildren,
@@ -649,24 +672,24 @@ function parseContentLine(line, lineIndex, contentStartRow, box, registry) {
649
672
  case "TextSegment" :
650
673
  break;
651
674
  case "ButtonSegment" :
652
- let actualCol = (baseCol + leadingSpaces | 0) + match._1 | 0;
653
- let position = Types.Position.make(row, actualCol);
675
+ let actualCol = (baseCol$1 + leadingSpaces | 0) + match._1 | 0;
676
+ let position$1 = Types.Position.make(row$1, actualCol);
654
677
  let buttonContent = "[ " + match._0 + " ]";
655
- return ParserRegistry.parse(registry, buttonContent, position, box.bounds);
678
+ return ParserRegistry.parse(registry, buttonContent, position$1, box.bounds);
656
679
  case "LinkSegment" :
657
- let actualCol$1 = (baseCol + leadingSpaces | 0) + match._1 | 0;
658
- let position$1 = Types.Position.make(row, actualCol$1);
680
+ let actualCol$1 = (baseCol$1 + leadingSpaces | 0) + match._1 | 0;
681
+ let position$2 = Types.Position.make(row$1, actualCol$1);
659
682
  let linkContent = "\"" + match._0 + "\"";
660
- return ParserRegistry.parse(registry, linkContent, position$1, box.bounds);
683
+ return ParserRegistry.parse(registry, linkContent, position$2, box.bounds);
661
684
  }
662
685
  }
663
- let position$2 = Types.Position.make(row, baseCol + leadingSpaces | 0);
664
- return ParserRegistry.parse(registry, trimmed, position$2, box.bounds);
686
+ let position$3 = Types.Position.make(row$1, baseCol$1 + leadingSpaces | 0);
687
+ return ParserRegistry.parse(registry, trimmed, position$3, box.bounds);
665
688
  }
666
689
  let trimmedStart$1 = line.trimStart();
667
690
  let leadingSpaces$1 = line.length - trimmedStart$1.length | 0;
668
- let position$3 = Types.Position.make(row, baseCol + leadingSpaces$1 | 0);
669
- return ParserRegistry.parse(registry, trimmed, position$3, box.bounds);
691
+ let position$4 = Types.Position.make(row$1, baseCol$1 + leadingSpaces$1 | 0);
692
+ return ParserRegistry.parse(registry, trimmed, position$4, box.bounds);
670
693
  }
671
694
 
672
695
  function parseBoxContent(box, gridCells, registry) {
@@ -831,6 +854,7 @@ function getElementRow(_elem) {
831
854
  case "Box" :
832
855
  return elem.bounds.top;
833
856
  case "Divider" :
857
+ case "Spacer" :
834
858
  return elem.position.row;
835
859
  case "Row" :
836
860
  let child = elem.children[0];
@@ -969,6 +993,7 @@ export {
969
993
  isSectionFooter,
970
994
  stripSectionBorders,
971
995
  segmentToElement,
996
+ isNoiseText,
972
997
  parseContentLine,
973
998
  parseBoxContent,
974
999
  getBoxColumn,
@@ -1049,9 +1049,29 @@ let segmentToElement = (
1049
1049
  }
1050
1050
  }
1051
1051
 
1052
+ /**
1053
+ * Noise text filter - filters out box border characters.
1054
+ * This identifies text that is part of ASCII art borders, not actual content.
1055
+ * Examples: "|", "+---+", "===", etc.
1056
+ */
1057
+ let isNoiseText = (content: string): bool => {
1058
+ let trimmed = content->String.trim
1059
+ if trimmed === "" {
1060
+ // Empty lines are not noise - they become Spacer elements
1061
+ false
1062
+ } else {
1063
+ // Box border patterns: +---+, |, ===, etc.
1064
+ let borderPattern = %re("/^[+|=\-\s]+$/")
1065
+ let hasPipeOrPlus = %re("/[+|]/")
1066
+
1067
+ Js.Re.test_(borderPattern, trimmed) || Js.Re.test_(hasPipeOrPlus, trimmed)
1068
+ }
1069
+ }
1070
+
1052
1071
  /**
1053
1072
  * Parse a single content line into an element.
1054
1073
  * Handles buttons, links, inline elements, and regular text.
1074
+ * Filters out noise (border characters) and creates Spacer for empty lines.
1055
1075
  */
1056
1076
  let parseContentLine = (
1057
1077
  line: string,
@@ -1062,8 +1082,14 @@ let parseContentLine = (
1062
1082
  ): option<element> => {
1063
1083
  let trimmed = line->String.trim
1064
1084
 
1065
- // Skip empty lines
1085
+ // Issue #16: Preserve empty lines as Spacer elements for vertical spacing
1066
1086
  if trimmed === "" {
1087
+ let row = contentStartRow + lineIndex
1088
+ let baseCol = box.bounds.left + 1
1089
+ let position = Position.make(row, baseCol)
1090
+ Some(Spacer({position: position}))
1091
+ } else if isNoiseText(trimmed) {
1092
+ // Filter out border/noise text - don't create any element
1067
1093
  None
1068
1094
  } else {
1069
1095
  // Calculate position in grid
@@ -1349,6 +1375,7 @@ let rec getElementRow = (elem: element): int => {
1349
1375
  | Checkbox({position, _}) => position.row
1350
1376
  | Text({position, _}) => position.row
1351
1377
  | Divider({position}) => position.row
1378
+ | Spacer({position}) => position.row
1352
1379
  | Row({children, _}) => {
1353
1380
  // Use the first child's row position
1354
1381
  switch children->Array.get(0) {
@@ -42,6 +42,7 @@ let defaultStyles = `
42
42
  .wf-row .wf-link { display:inline; margin:0 8px; }
43
43
  .wf-text { margin:4px 0; line-height:1.4; }
44
44
  .wf-text.emphasis { font-weight:bold; }
45
+ .wf-spacer { min-height:1em; }
45
46
  .wf-divider { border:none; border-top:1px solid #333; margin:12px 0; }
46
47
  .wf-section { border:1px solid #333; margin:8px 0; }
47
48
  .wf-section-header { background:#fff; padding:4px 8px; font-size:12px; color:#666; border-bottom:1px solid #333; }
@@ -55,20 +56,6 @@ let defaultStyles = `
55
56
  .wf-button.align-right, .wf-link.align-right { margin-left:auto; margin-right:0; }
56
57
  `;
57
58
 
58
- function isNoiseText(content) {
59
- let trimmed = content.trim();
60
- if (trimmed === "") {
61
- return false;
62
- }
63
- let borderPattern = /^[+|=\-\s]+$/;
64
- let hasPipeOrPlus = /[+|]/;
65
- if (borderPattern.test(trimmed)) {
66
- return true;
67
- } else {
68
- return hasPipeOrPlus.test(trimmed);
69
- }
70
- }
71
-
72
59
  function isInputOnlyBox(elem) {
73
60
  if (elem.TAG !== "Box") {
74
61
  return false;
@@ -253,22 +240,22 @@ function renderElement(_elem, onAction, onDeadEnd) {
253
240
  labelEl.appendChild(span);
254
241
  return Primitive_option.some(labelEl);
255
242
  case "Text" :
256
- let content = elem.content;
257
- if (isNoiseText(content)) {
258
- return;
259
- }
260
243
  let p = document.createElement("p");
261
244
  p.className = "wf-text";
262
245
  if (elem.emphasis) {
263
246
  p.classList.add("emphasis");
264
247
  }
265
248
  applyAlignment(p, elem.align);
266
- p.textContent = content;
249
+ p.textContent = elem.content;
267
250
  return Primitive_option.some(p);
268
251
  case "Divider" :
269
252
  let hr = document.createElement("hr");
270
253
  hr.className = "wf-divider";
271
254
  return Primitive_option.some(hr);
255
+ case "Spacer" :
256
+ let spacer = document.createElement("div");
257
+ spacer.className = "wf-spacer";
258
+ return Primitive_option.some(spacer);
272
259
  case "Row" :
273
260
  let row = document.createElement("div");
274
261
  row.className = "wf-row";
@@ -574,7 +561,6 @@ export {
574
561
  DomBindings,
575
562
  defaultOptions,
576
563
  defaultStyles,
577
- isNoiseText,
578
564
  isInputOnlyBox,
579
565
  getInputsFromBox,
580
566
  alignmentToClass,
@@ -154,6 +154,7 @@ let defaultStyles = `
154
154
  .wf-row .wf-link { display:inline; margin:0 8px; }
155
155
  .wf-text { margin:4px 0; line-height:1.4; }
156
156
  .wf-text.emphasis { font-weight:bold; }
157
+ .wf-spacer { min-height:1em; }
157
158
  .wf-divider { border:none; border-top:1px solid #333; margin:12px 0; }
158
159
  .wf-section { border:1px solid #333; margin:8px 0; }
159
160
  .wf-section-header { background:#fff; padding:4px 8px; font-size:12px; color:#666; border-bottom:1px solid #333; }
@@ -171,23 +172,6 @@ let defaultStyles = `
171
172
  // Helper Functions
172
173
  // ============================================================================
173
174
 
174
- // Noise text filter - filters out box border characters
175
- // Note: Empty lines are NOT noise - they represent intentional vertical spacing (Issue #16)
176
- let isNoiseText = (content: string): bool => {
177
- let trimmed = content->String.trim
178
- if trimmed == "" {
179
- // Empty lines should be preserved as vertical spacing
180
- false
181
- } else {
182
- // Box border patterns: +---+, |, ===, etc.
183
- let borderPattern = %re("/^[+|=\-\s]+$/")
184
- let hasPipeOrPlus = %re("/[+|]/")
185
-
186
- Js.Re.test_(borderPattern, trimmed) ||
187
- Js.Re.test_(hasPipeOrPlus, trimmed)
188
- }
189
- }
190
-
191
175
  // Check if a box contains only inputs (should unwrap and render as inputs directly)
192
176
  let isInputOnlyBox = (elem: element): bool => {
193
177
  switch elem {
@@ -425,19 +409,15 @@ let rec renderElement = (
425
409
  }
426
410
 
427
411
  | Text({content, emphasis, align, _}) => {
428
- // Filter noise text (box borders, etc.)
429
- if isNoiseText(content) {
430
- None
431
- } else {
432
- let p = DomBindings.document->DomBindings.createElement("p")
433
- p->DomBindings.setClassName("wf-text")
434
- if emphasis {
435
- p->DomBindings.classList->DomBindings.add("emphasis")
436
- }
437
- applyAlignment(p, align)
438
- p->DomBindings.setTextContent(content)
439
- Some(p)
412
+ // Note: Noise filtering is now done in SemanticParser
413
+ let p = DomBindings.document->DomBindings.createElement("p")
414
+ p->DomBindings.setClassName("wf-text")
415
+ if emphasis {
416
+ p->DomBindings.classList->DomBindings.add("emphasis")
440
417
  }
418
+ applyAlignment(p, align)
419
+ p->DomBindings.setTextContent(content)
420
+ Some(p)
441
421
  }
442
422
 
443
423
  | Divider(_) => {
@@ -446,6 +426,12 @@ let rec renderElement = (
446
426
  Some(hr)
447
427
  }
448
428
 
429
+ | Spacer(_) => {
430
+ let spacer = DomBindings.document->DomBindings.createElement("div")
431
+ spacer->DomBindings.setClassName("wf-spacer")
432
+ Some(spacer)
433
+ }
434
+
449
435
  | Row({children, align}) => {
450
436
  let row = DomBindings.document->DomBindings.createElement("div")
451
437
  row->DomBindings.setClassName("wf-row")