wyreframe 0.5.1 → 0.7.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Wyreframe
2
2
 
3
- > A library that converts ASCII wireframes into working HTML/UI
3
+ > A library that converts ASCII wireframes into working HTML/UI with scene management
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/wyreframe.svg)](https://www.npmjs.com/package/wyreframe)
6
6
  [![npm downloads](https://img.shields.io/npm/dm/wyreframe.svg)](https://www.npmjs.com/package/wyreframe)
@@ -19,6 +19,15 @@
19
19
  +---------------------------+
20
20
  ```
21
21
 
22
+ ## Features
23
+
24
+ - **ASCII to HTML**: Convert simple ASCII art into interactive UI elements
25
+ - **Scene Management**: Multi-screen prototypes with transitions (fade, slide, zoom)
26
+ - **Interaction DSL**: Define button clicks, navigation, and form validation
27
+ - **Device Preview**: Responsive previews for mobile, tablet, and desktop
28
+ - **Auto-Fix**: Automatically correct common wireframe formatting issues
29
+ - **TypeScript/ReScript**: Full type safety with both language support
30
+
22
31
  ## Installation
23
32
 
24
33
  ```bash
@@ -56,121 +65,284 @@ if (result.success) {
56
65
  }
57
66
  ```
58
67
 
59
- ### ReScript
68
+ ## Syntax Reference
60
69
 
61
- ```rescript
62
- let ui = `
63
- @scene: login
70
+ ### UI Elements
64
71
 
65
- +---------------------------+
66
- | 'WYREFRAME' |
67
- | +---------------------+ |
68
- | | #email | |
69
- | +---------------------+ |
70
- | [ Login ] |
71
- +---------------------------+
72
+ | Syntax | Description | HTML Output |
73
+ |--------|-------------|-------------|
74
+ | `+---+` | Box/Container | `<div>` |
75
+ | `[ Text ]` | Button | `<button>` |
76
+ | `#id` | Input field | `<input>` |
77
+ | `"text"` | Link | `<a>` |
78
+ | `'text'` | Emphasis text | Title, Heading |
79
+ | `[x]` / `[ ]` | Checkbox | `<input type="checkbox">` |
80
+ | `---` | Divider | `<hr>` |
81
+
82
+ ### Scene Directives
83
+
84
+ ```yaml
85
+ @scene: sceneId # Scene identifier (required)
86
+ @title: Page Title # Optional page title
87
+ @device: mobile # Device type for sizing
88
+ @transition: fade # Default transition effect
89
+ ```
72
90
 
91
+ ### Device Types
92
+
93
+ | Device | Dimensions | Description |
94
+ |--------|------------|-------------|
95
+ | `desktop` | 1440x900 | Desktop monitor |
96
+ | `laptop` | 1280x800 | Laptop screen |
97
+ | `tablet` | 768x1024 | Tablet portrait |
98
+ | `tablet-landscape` | 1024x768 | Tablet landscape |
99
+ | `mobile` | 375x812 | iPhone X ratio |
100
+ | `mobile-landscape` | 812x375 | Mobile landscape |
101
+
102
+ ### Interactions
103
+
104
+ ```yaml
73
105
  #email:
74
- placeholder: "Enter your email"
106
+ placeholder: "Email"
75
107
 
76
108
  [Login]:
109
+ variant: primary
77
110
  @click -> goto(dashboard, slide-left)
78
- `
79
111
 
80
- switch Renderer.createUI(ui, None) {
81
- | Ok({root, sceneManager, _}) => {
82
- // Append root to DOM
83
- sceneManager.goto("login")
84
- }
85
- | Error(errors) => Console.error(errors)
86
- }
112
+ "Forgot Password":
113
+ @click -> goto(reset)
87
114
  ```
88
115
 
89
- ## Syntax Summary
116
+ **Actions:**
90
117
 
91
- | Syntax | Description | Example |
92
- |------|------|------|
93
- | `+---+` | Box/Container | `<div>` |
94
- | `[ Text ]` | Button | `<button>` |
95
- | `#id` | Input field | `<input>` |
96
- | `"text"` | Link | `<a>` |
97
- | `'text'` | Emphasis text | Title, Heading |
98
- | `[x]` / `[ ]` | Checkbox | `<input type="checkbox">` |
99
- | `---` | Scene separator | Multi-scene |
118
+ | Action | Description | Example |
119
+ |--------|-------------|---------|
120
+ | `goto(scene, transition?)` | Navigate to scene | `@click -> goto(home, fade)` |
121
+ | `back()` | Navigate back | `@click -> back()` |
122
+ | `forward()` | Navigate forward | `@click -> forward()` |
123
+ | `validate(fields)` | Validate inputs | `@submit -> validate(email, password)` |
124
+ | `call(fn, args)` | Custom function | `@click -> call(submit, form)` |
125
+
126
+ **Transitions:** `fade`, `slide-left`, `slide-right`, `zoom`
127
+
128
+ **Variants:** `primary`, `secondary`, `ghost`
100
129
 
101
130
  ## API
102
131
 
103
132
  ### JavaScript/TypeScript
104
133
 
105
134
  ```javascript
106
- import { parse, render, createUI, createUIOrThrow } from 'wyreframe';
135
+ import {
136
+ parse,
137
+ parseOrThrow,
138
+ render,
139
+ createUI,
140
+ createUIOrThrow,
141
+ fix,
142
+ fixOnly
143
+ } from 'wyreframe';
144
+
145
+ // Parse only - returns { success, ast, warnings } or { success: false, errors }
146
+ const parseResult = parse(text);
147
+
148
+ // Parse and throw on error
149
+ const ast = parseOrThrow(text);
150
+
151
+ // Render AST to DOM (pass ast, not parseResult!)
152
+ if (parseResult.success) {
153
+ const { root, sceneManager } = render(parseResult.ast, options);
154
+ }
107
155
 
108
- // Parse only - returns { success, ast } or { success, errors }
109
- const result = parse(text);
156
+ // Parse + Render combined (recommended)
157
+ const result = createUI(text, options);
110
158
 
111
- // Render only - IMPORTANT: pass result.ast, not result directly!
112
- if (result.success) {
113
- const { root, sceneManager } = render(result.ast);
114
- }
159
+ // Parse + Render, throw on error
160
+ const { root, sceneManager } = createUIOrThrow(text, options);
115
161
 
116
- // Parse + Render (recommended) - handles the wrapper automatically
117
- const result = createUI(text);
162
+ // Auto-fix wireframe formatting issues
163
+ const fixResult = fix(text);
164
+ if (fixResult.success) {
165
+ console.log('Fixed:', fixResult.fixed.length, 'issues');
166
+ const cleanText = fixResult.text;
167
+ }
118
168
 
119
- // Throw on error
120
- const { root, sceneManager } = createUIOrThrow(text);
169
+ // Fix and return text only
170
+ const fixedText = fixOnly(text);
121
171
  ```
122
172
 
123
- > **Note:** `parse()` returns a wrapper object `{ success: true, ast: AST }` or `{ success: false, errors: [] }`.
124
- > When using `render()` directly, make sure to pass `result.ast`, not the result object itself.
173
+ ### Render Options
125
174
 
126
- ### ReScript
175
+ ```typescript
176
+ const options = {
177
+ // Additional CSS class for container
178
+ containerClass: 'my-app',
127
179
 
128
- ```rescript
129
- // Parse only
130
- let result = Parser.parse(text)
180
+ // Inject default styles (default: true)
181
+ injectStyles: true,
182
+
183
+ // Override device type for all scenes
184
+ device: 'mobile',
131
185
 
132
- // Render only
133
- let {root, sceneManager} = Renderer.render(ast, None)
186
+ // Scene change callback
187
+ onSceneChange: (fromScene, toScene) => {
188
+ console.log(`Navigated from ${fromScene} to ${toScene}`);
189
+ },
134
190
 
135
- // Parse + Render (recommended)
136
- let result = Renderer.createUI(text, None)
191
+ // Dead-end click callback (buttons/links without navigation)
192
+ onDeadEndClick: (info) => {
193
+ console.log(`Clicked: ${info.elementText} in scene ${info.sceneId}`);
194
+ // Show modal, custom logic, etc.
195
+ }
196
+ };
137
197
 
138
- // Throw on error
139
- let {root, sceneManager, ast} = Renderer.createUIOrThrow(text, None)
198
+ const result = createUI(text, options);
140
199
  ```
141
200
 
142
201
  ### SceneManager
143
202
 
144
203
  ```javascript
145
- sceneManager.goto('dashboard'); // Navigate to scene
146
- sceneManager.getCurrentScene(); // Get current scene
147
- sceneManager.getSceneIds(); // Get all scene IDs
204
+ const { sceneManager } = result;
205
+
206
+ sceneManager.goto('dashboard'); // Navigate to scene
207
+ sceneManager.goto('home', 'fade'); // Navigate with transition
208
+ sceneManager.back(); // Go back in history
209
+ sceneManager.forward(); // Go forward in history
210
+ sceneManager.getCurrentScene(); // Get current scene ID
211
+ sceneManager.getSceneIds(); // Get all scene IDs
148
212
  ```
149
213
 
214
+ ### ReScript
215
+
150
216
  ```rescript
151
- sceneManager.goto("dashboard") // Navigate to scene
152
- sceneManager.getCurrentScene() // Get current scene (option<string>)
153
- sceneManager.getSceneIds() // Get all scene IDs (array<string>)
217
+ // Parse + Render
218
+ switch Renderer.createUI(ui, None) {
219
+ | Ok({root, sceneManager, _}) =>
220
+ sceneManager.goto("login")
221
+ | Error(errors) => Console.error(errors)
222
+ }
223
+
224
+ // With options
225
+ let options = {
226
+ device: Some(#mobile),
227
+ onSceneChange: Some((from, to) => Console.log2(from, to)),
228
+ onDeadEndClick: None,
229
+ containerClass: None,
230
+ injectStyles: None,
231
+ }
232
+ switch Renderer.createUI(ui, Some(options)) {
233
+ | Ok({root, sceneManager, _}) => ...
234
+ | Error(errors) => ...
235
+ }
154
236
  ```
155
237
 
156
- ## Interactions
238
+ ## Auto-Fix
239
+
240
+ Wyreframe can automatically fix common formatting issues:
241
+
242
+ ```javascript
243
+ import { fix, fixOnly } from 'wyreframe';
244
+
245
+ const messyWireframe = `
246
+ +----------+
247
+ | Button | <- Misaligned pipe
248
+ +---------+ <- Width mismatch
249
+ `;
250
+
251
+ const result = fix(messyWireframe);
252
+ if (result.success) {
253
+ console.log('Fixed issues:', result.fixed);
254
+ console.log('Remaining issues:', result.remaining);
255
+ console.log('Clean wireframe:', result.text);
256
+ }
257
+
258
+ // Or just get the fixed text
259
+ const cleanText = fixOnly(messyWireframe);
260
+ ```
261
+
262
+ **Fixable Issues:**
263
+ - Misaligned pipes (|)
264
+ - Mismatched border widths
265
+ - Tabs instead of spaces
266
+ - Unclosed brackets
267
+
268
+ ## Multi-Scene Example
269
+
270
+ ```javascript
271
+ const app = `
272
+ @scene: login
273
+ @device: mobile
274
+
275
+ +---------------------------+
276
+ | 'Login' |
277
+ | +---------------------+ |
278
+ | | #email | |
279
+ | +---------------------+ |
280
+ | +---------------------+ |
281
+ | | #password | |
282
+ | +---------------------+ |
283
+ | [ Sign In ] |
284
+ | |
285
+ | "Create Account" |
286
+ +---------------------------+
287
+
288
+ ---
289
+
290
+ @scene: signup
291
+ @device: mobile
292
+
293
+ +---------------------------+
294
+ | 'Sign Up' |
295
+ | +---------------------+ |
296
+ | | #name | |
297
+ | +---------------------+ |
298
+ | +---------------------+ |
299
+ | | #email | |
300
+ | +---------------------+ |
301
+ | [ Register ] |
302
+ | |
303
+ | "Back to Login" |
304
+ +---------------------------+
157
305
 
158
- ```yaml
159
306
  #email:
160
307
  placeholder: "Email"
308
+ #password:
309
+ placeholder: "Password"
310
+ #name:
311
+ placeholder: "Full Name"
161
312
 
162
- [Login]:
313
+ [Sign In]:
163
314
  variant: primary
164
- @click -> goto(dashboard, slide-left)
165
- ```
315
+ @click -> goto(signup, slide-left)
316
+
317
+ "Create Account":
318
+ @click -> goto(signup, slide-left)
319
+
320
+ [Register]:
321
+ variant: primary
322
+ @click -> goto(login, slide-right)
323
+
324
+ "Back to Login":
325
+ @click -> goto(login, slide-right)
326
+ `;
327
+
328
+ const result = createUI(app, {
329
+ onSceneChange: (from, to) => {
330
+ console.log(`Scene: ${from} -> ${to}`);
331
+ }
332
+ });
166
333
 
167
- **Transition effects:** `fade`, `slide-left`, `slide-right`, `zoom`
334
+ if (result.success) {
335
+ document.getElementById('app').appendChild(result.root);
336
+ result.sceneManager.goto('login');
337
+ }
338
+ ```
168
339
 
169
340
  ## Documentation
170
341
 
171
342
  - [API Reference](docs/api.md)
172
- - [Developer Guide](docs/developer-guide.md)
343
+ - [Type Definitions](docs/types.md)
173
344
  - [Examples](docs/examples.md)
345
+ - [Developer Guide](docs/developer-guide.md)
174
346
  - [Testing Guide](docs/testing.md)
175
347
  - [Live Demo](examples/index.html)
176
348
 
@@ -179,10 +351,24 @@ sceneManager.getSceneIds() // Get all scene IDs (array<string>)
179
351
  ```bash
180
352
  npm install
181
353
  npm run res:build # ReScript build
354
+ npm run ts:build # TypeScript build
355
+ npm run build # Full build
182
356
  npm run dev # Dev server (http://localhost:3000/examples)
183
357
  npm test # Run tests
358
+ npm run test:watch # Test watch mode
359
+ npm run test:coverage # Generate coverage report
184
360
  ```
185
361
 
362
+ ## Architecture
363
+
364
+ Wyreframe uses a 3-stage parsing pipeline:
365
+
366
+ 1. **Grid Scanner**: Converts ASCII text to 2D character grid
367
+ 2. **Shape Detector**: Identifies boxes, nesting, and hierarchy
368
+ 3. **Semantic Parser**: Recognizes UI elements via pluggable parsers
369
+
370
+ The renderer generates pure DOM elements with CSS-based scene visibility and transitions.
371
+
186
372
  ## License
187
373
 
188
374
  GPL-3.0 License - see [LICENSE](LICENSE) for details.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wyreframe",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
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
@@ -251,6 +251,148 @@ describe('onSceneChange callback (Issue #2)', () => {
251
251
  });
252
252
  });
253
253
 
254
+ describe('mixed text and link content (Issue #14)', () => {
255
+ // Regression test for issue #14: Text before link not rendering in mixed text-link line
256
+ // When a line contains both plain text and a link (e.g., "Don't have an account? "Sign up""),
257
+ // both parts should be rendered, not just the link.
258
+
259
+ const mixedTextLinkWireframe = `
260
+ @scene: test
261
+
262
+ +---------------------------------------+
263
+ | Don't have an account? "Sign up" |
264
+ +---------------------------------------+
265
+ `;
266
+
267
+ test('parses both text and link from mixed text-link line', () => {
268
+ const result = parse(mixedTextLinkWireframe);
269
+
270
+ expect(result.success).toBe(true);
271
+ if (result.success) {
272
+ expect(result.ast.scenes).toHaveLength(1);
273
+ const scene = result.ast.scenes[0];
274
+
275
+ // Should have elements (likely a Row containing Text and Link)
276
+ expect(scene.elements.length).toBeGreaterThan(0);
277
+
278
+ // Helper to recursively find elements
279
+ const findElements = (elements: any[], tags: string[]): any[] => {
280
+ const found: any[] = [];
281
+ for (const el of elements) {
282
+ if (tags.includes(el.TAG)) {
283
+ found.push(el);
284
+ }
285
+ if (el.children) {
286
+ found.push(...findElements(el.children, tags));
287
+ }
288
+ }
289
+ return found;
290
+ };
291
+
292
+ // Find all Text and Link elements
293
+ const textElements = findElements(scene.elements, ['Text']);
294
+ const linkElements = findElements(scene.elements, ['Link']);
295
+
296
+ // Should have at least one Text element containing "Don't have an account?"
297
+ const hasAccountText = textElements.some(
298
+ (t) => t.content && t.content.includes("Don't have an account?")
299
+ );
300
+ expect(hasAccountText).toBe(true);
301
+
302
+ // Should have the "Sign up" link
303
+ const hasSignUpLink = linkElements.some((l) => l.text === 'Sign up');
304
+ expect(hasSignUpLink).toBe(true);
305
+ }
306
+ });
307
+
308
+ test('parses link at start followed by text', () => {
309
+ const wireframe = `
310
+ @scene: test
311
+
312
+ +----------------------------+
313
+ | "Click here" for details |
314
+ +----------------------------+
315
+ `;
316
+
317
+ const result = parse(wireframe);
318
+
319
+ expect(result.success).toBe(true);
320
+ if (result.success) {
321
+ const scene = result.ast.scenes[0];
322
+
323
+ const findElements = (elements: any[], tags: string[]): any[] => {
324
+ const found: any[] = [];
325
+ for (const el of elements) {
326
+ if (tags.includes(el.TAG)) {
327
+ found.push(el);
328
+ }
329
+ if (el.children) {
330
+ found.push(...findElements(el.children, tags));
331
+ }
332
+ }
333
+ return found;
334
+ };
335
+
336
+ const textElements = findElements(scene.elements, ['Text']);
337
+ const linkElements = findElements(scene.elements, ['Link']);
338
+
339
+ // Should have "Click here" link
340
+ const hasClickHereLink = linkElements.some((l) => l.text === 'Click here');
341
+ expect(hasClickHereLink).toBe(true);
342
+
343
+ // Should have "for details" text
344
+ const hasDetailsText = textElements.some(
345
+ (t) => t.content && t.content.includes('for details')
346
+ );
347
+ expect(hasDetailsText).toBe(true);
348
+ }
349
+ });
350
+
351
+ test('parses text with link in middle', () => {
352
+ const wireframe = `
353
+ @scene: test
354
+
355
+ +------------------------------------+
356
+ | Please "click here" to continue |
357
+ +------------------------------------+
358
+ `;
359
+
360
+ const result = parse(wireframe);
361
+
362
+ expect(result.success).toBe(true);
363
+ if (result.success) {
364
+ const scene = result.ast.scenes[0];
365
+
366
+ const findElements = (elements: any[], tags: string[]): any[] => {
367
+ const found: any[] = [];
368
+ for (const el of elements) {
369
+ if (tags.includes(el.TAG)) {
370
+ found.push(el);
371
+ }
372
+ if (el.children) {
373
+ found.push(...findElements(el.children, tags));
374
+ }
375
+ }
376
+ return found;
377
+ };
378
+
379
+ const textElements = findElements(scene.elements, ['Text']);
380
+ const linkElements = findElements(scene.elements, ['Link']);
381
+
382
+ // Should have "click here" link
383
+ expect(linkElements.some((l) => l.text === 'click here')).toBe(true);
384
+
385
+ // Should have both text parts
386
+ const hasPlease = textElements.some((t) => t.content && t.content.includes('Please'));
387
+ const hasContinue = textElements.some(
388
+ (t) => t.content && t.content.includes('to continue')
389
+ );
390
+ expect(hasPlease).toBe(true);
391
+ expect(hasContinue).toBe(true);
392
+ }
393
+ });
394
+ });
395
+
254
396
  describe('device option override (Issue #11)', () => {
255
397
  const desktopWireframe = `
256
398
  @scene: test
@@ -431,6 +431,57 @@ function splitInlineSegments(line) {
431
431
  currentText = currentText + char;
432
432
  i = i + 1 | 0;
433
433
  }
434
+ } else if (char === "\"") {
435
+ let linkStart = i;
436
+ let start$1 = i + 1 | 0;
437
+ let endPos$1;
438
+ let j$1 = start$1;
439
+ while (j$1 < len && endPos$1 === undefined) {
440
+ let currentChar = line.charAt(j$1);
441
+ if (currentChar === "\"") {
442
+ let isEscaped = j$1 > start$1 && line.charAt(j$1 - 1 | 0) === "\\";
443
+ if (!isEscaped) {
444
+ endPos$1 = j$1;
445
+ }
446
+ }
447
+ j$1 = j$1 + 1 | 0;
448
+ };
449
+ let end$1 = endPos$1;
450
+ if (end$1 !== undefined) {
451
+ let quotedContent = line.slice(start$1, end$1);
452
+ let trimmedContent = quotedContent.trim();
453
+ if (trimmedContent !== "") {
454
+ let text$1 = currentText.trim();
455
+ if (text$1 !== "") {
456
+ let leadingSpaces$1 = currentText.length - currentText.trimStart().length | 0;
457
+ segments.push({
458
+ TAG: "TextSegment",
459
+ _0: text$1,
460
+ _1: currentTextStart + leadingSpaces$1 | 0
461
+ });
462
+ }
463
+ currentText = "";
464
+ segments.push({
465
+ TAG: "LinkSegment",
466
+ _0: trimmedContent,
467
+ _1: linkStart
468
+ });
469
+ i = end$1 + 1 | 0;
470
+ currentTextStart = i;
471
+ } else {
472
+ if (currentText === "") {
473
+ currentTextStart = i;
474
+ }
475
+ currentText = currentText + char;
476
+ i = i + 1 | 0;
477
+ }
478
+ } else {
479
+ if (currentText === "") {
480
+ currentTextStart = i;
481
+ }
482
+ currentText = currentText + char;
483
+ i = i + 1 | 0;
484
+ }
434
485
  } else {
435
486
  if (currentText === "") {
436
487
  currentTextStart = i;
@@ -439,13 +490,13 @@ function splitInlineSegments(line) {
439
490
  i = i + 1 | 0;
440
491
  }
441
492
  };
442
- let text$1 = currentText.trim();
443
- if (text$1 !== "") {
444
- let leadingSpaces$1 = currentText.length - currentText.trimStart().length | 0;
493
+ let text$2 = currentText.trim();
494
+ if (text$2 !== "") {
495
+ let leadingSpaces$2 = currentText.length - currentText.trimStart().length | 0;
445
496
  segments.push({
446
497
  TAG: "TextSegment",
447
- _0: text$1,
448
- _1: currentTextStart + leadingSpaces$1 | 0
498
+ _0: text$2,
499
+ _1: currentTextStart + leadingSpaces$2 | 0
449
500
  });
450
501
  }
451
502
  return segments;
@@ -544,7 +595,7 @@ function segmentToElement(segment, basePosition, baseCol, bounds) {
544
595
  let text$1 = segment._0;
545
596
  let actualCol$1 = baseCol + segment._1 | 0;
546
597
  let position$1 = Types.Position.make(basePosition.row, actualCol$1);
547
- let id = text$1.trim().toLowerCase().replace(/\\s+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
598
+ let id = text$1.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
548
599
  let buttonContent = "[ " + text$1 + " ]";
549
600
  let align$1 = AlignmentCalc.calculateWithStrategy(buttonContent, position$1, bounds, "RespectPosition");
550
601
  return {
@@ -559,7 +610,7 @@ function segmentToElement(segment, basePosition, baseCol, bounds) {
559
610
  let text$2 = segment._0;
560
611
  let actualCol$2 = baseCol + segment._1 | 0;
561
612
  let position$2 = Types.Position.make(basePosition.row, actualCol$2);
562
- let id$1 = text$2.trim().toLowerCase().replace(/\\s+/g, "-").replace(/[^a-z0-9-]/g, "");
613
+ let id$1 = text$2.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
563
614
  let align$2 = AlignmentCalc.calculateWithStrategy(text$2, position$2, bounds, "RespectPosition");
564
615
  return {
565
616
  TAG: "Link",
@@ -603,23 +654,19 @@ function parseContentLine(line, lineIndex, contentStartRow, box, registry) {
603
654
  let buttonContent = "[ " + match._0 + " ]";
604
655
  return ParserRegistry.parse(registry, buttonContent, position, box.bounds);
605
656
  case "LinkSegment" :
606
- let colOffset = match._1;
607
- let actualCol$1 = (baseCol + leadingSpaces | 0) + colOffset | 0;
608
- let adjustedPosition = Types.Position.make(basePosition.row, actualCol$1);
609
- return segmentToElement({
610
- TAG: "LinkSegment",
611
- _0: match._0,
612
- _1: colOffset
613
- }, adjustedPosition, baseCol + leadingSpaces | 0, box.bounds);
657
+ let actualCol$1 = (baseCol + leadingSpaces | 0) + match._1 | 0;
658
+ let position$1 = Types.Position.make(row, actualCol$1);
659
+ let linkContent = "\"" + match._0 + "\"";
660
+ return ParserRegistry.parse(registry, linkContent, position$1, box.bounds);
614
661
  }
615
662
  }
616
- let position$1 = Types.Position.make(row, baseCol + leadingSpaces | 0);
617
- return ParserRegistry.parse(registry, trimmed, position$1, box.bounds);
663
+ let position$2 = Types.Position.make(row, baseCol + leadingSpaces | 0);
664
+ return ParserRegistry.parse(registry, trimmed, position$2, box.bounds);
618
665
  }
619
666
  let trimmedStart$1 = line.trimStart();
620
667
  let leadingSpaces$1 = line.length - trimmedStart$1.length | 0;
621
- let position$2 = Types.Position.make(row, baseCol + leadingSpaces$1 | 0);
622
- return ParserRegistry.parse(registry, trimmed, position$2, box.bounds);
668
+ let position$3 = Types.Position.make(row, baseCol + leadingSpaces$1 | 0);
669
+ return ParserRegistry.parse(registry, trimmed, position$3, box.bounds);
623
670
  }
624
671
 
625
672
  function parseBoxContent(box, gridCells, registry) {
@@ -712,6 +759,71 @@ function parseBoxContent(box, gridCells, registry) {
712
759
  return elements;
713
760
  }
714
761
 
762
+ function getBoxColumn(elem) {
763
+ if (elem.TAG === "Box") {
764
+ return elem.bounds.left;
765
+ } else {
766
+ return 0;
767
+ }
768
+ }
769
+
770
+ function groupHorizontalBoxes(elements) {
771
+ let boxes = elements.filter(el => el.TAG === "Box");
772
+ let nonBoxes = elements.filter(el => el.TAG !== "Box");
773
+ if (boxes.length <= 1) {
774
+ return elements;
775
+ }
776
+ let boxesWithRow = boxes.map(box => {
777
+ let row;
778
+ row = box.TAG === "Box" ? box.bounds.top : 0;
779
+ return [
780
+ row,
781
+ box
782
+ ];
783
+ });
784
+ let sorted = boxesWithRow.toSorted((param, param$1) => {
785
+ let rowB = param$1[0];
786
+ let rowA = param[0];
787
+ if (rowA !== rowB) {
788
+ return rowA - rowB | 0;
789
+ } else {
790
+ return getBoxColumn(param[1]) - getBoxColumn(param$1[1]) | 0;
791
+ }
792
+ });
793
+ let groups = [];
794
+ sorted.forEach(param => {
795
+ let box = param[1];
796
+ let row = param[0];
797
+ let existingGroupIdx = groups.findIndex(param => param[0] === row);
798
+ if (existingGroupIdx >= 0) {
799
+ let match = groups[existingGroupIdx];
800
+ if (match !== undefined) {
801
+ match[1].push(box);
802
+ return;
803
+ } else {
804
+ return;
805
+ }
806
+ }
807
+ groups.push([
808
+ row,
809
+ [box]
810
+ ]);
811
+ });
812
+ let groupedElements = groups.map(param => {
813
+ let groupBoxes = param[1];
814
+ if (groupBoxes.length >= 2) {
815
+ return {
816
+ TAG: "Row",
817
+ children: groupBoxes,
818
+ align: "Left"
819
+ };
820
+ } else {
821
+ return groupBoxes[0];
822
+ }
823
+ });
824
+ return nonBoxes.concat(groupedElements);
825
+ }
826
+
715
827
  function getElementRow(_elem) {
716
828
  while (true) {
717
829
  let elem = _elem;
@@ -743,7 +855,8 @@ function getElementRow(_elem) {
743
855
  function parseBoxRecursive(box, gridCells, registry) {
744
856
  let contentElements = parseBoxContent(box, gridCells, registry);
745
857
  let childBoxElements = box.children.map(childBox => parseBoxRecursive(childBox, gridCells, registry));
746
- let allChildren = contentElements.concat(childBoxElements);
858
+ let groupedChildElements = groupHorizontalBoxes(childBoxElements);
859
+ let allChildren = contentElements.concat(groupedChildElements);
747
860
  let sortedChildren = allChildren.toSorted((a, b) => {
748
861
  let rowA = getElementRow(a);
749
862
  let rowB = getElementRow(b);
@@ -858,6 +971,8 @@ export {
858
971
  segmentToElement,
859
972
  parseContentLine,
860
973
  parseBoxContent,
974
+ getBoxColumn,
975
+ groupHorizontalBoxes,
861
976
  getElementRow,
862
977
  parseBoxRecursive,
863
978
  buildScene,
@@ -727,6 +727,62 @@ let splitInlineSegments = (line: string): array<inlineSegment> => {
727
727
  i := i.contents + 1
728
728
  }
729
729
  }
730
+ } else if char === "\"" {
731
+ // Check for link pattern "..."
732
+ let linkStart = i.contents
733
+ let start = i.contents + 1
734
+ let endPos = ref(None)
735
+ let j = ref(start)
736
+ // Find matching closing quote, handling escaped quotes
737
+ while j.contents < len && endPos.contents === None {
738
+ let currentChar = line->String.charAt(j.contents)
739
+ if currentChar === "\"" {
740
+ // Check if this quote is escaped (preceded by backslash)
741
+ let isEscaped = j.contents > start && line->String.charAt(j.contents - 1) === "\\"
742
+ if !isEscaped {
743
+ endPos := Some(j.contents)
744
+ }
745
+ }
746
+ j := j.contents + 1
747
+ }
748
+
749
+ switch endPos.contents {
750
+ | Some(end) => {
751
+ let quotedContent = line->String.slice(~start, ~end)
752
+ let trimmedContent = quotedContent->String.trim
753
+
754
+ // Check if the quoted content is not empty
755
+ if trimmedContent !== "" {
756
+ // Flush any accumulated text before the link
757
+ let text = currentText.contents->String.trim
758
+ if text !== "" {
759
+ let leadingSpaces = String.length(currentText.contents) - String.length(currentText.contents->String.trimStart)
760
+ segments->Array.push(TextSegment(text, currentTextStart.contents + leadingSpaces))->ignore
761
+ }
762
+ currentText := ""
763
+
764
+ // Add the link segment
765
+ segments->Array.push(LinkSegment(trimmedContent, linkStart))->ignore
766
+ i := end + 1
767
+ currentTextStart := i.contents
768
+ } else {
769
+ // Empty quoted content, treat as regular text
770
+ if currentText.contents === "" {
771
+ currentTextStart := i.contents
772
+ }
773
+ currentText := currentText.contents ++ char
774
+ i := i.contents + 1
775
+ }
776
+ }
777
+ | None => {
778
+ // No matching closing quote, treat as regular text
779
+ if currentText.contents === "" {
780
+ currentTextStart := i.contents
781
+ }
782
+ currentText := currentText.contents ++ char
783
+ i := i.contents + 1
784
+ }
785
+ }
730
786
  } else {
731
787
  // Regular character
732
788
  if currentText.contents === "" {
@@ -936,13 +992,14 @@ let segmentToElement = (
936
992
  let position = Position.make(basePosition.row, actualCol)
937
993
 
938
994
  // Create button ID from text (slugified)
995
+ // Use String.replaceRegExp (modern API) to avoid escaping issues with %re
939
996
  let id = text
940
997
  ->String.trim
941
998
  ->String.toLowerCase
942
- ->Js.String2.replaceByRe(%re("/\\s+/g"), "-")
943
- ->Js.String2.replaceByRe(%re("/[^a-z0-9-]/g"), "")
944
- ->Js.String2.replaceByRe(%re("/-+/g"), "-")
945
- ->Js.String2.replaceByRe(%re("/^-+|-+$/g"), "")
999
+ ->String.replaceRegExp(%re("/\s+/g"), "-")
1000
+ ->String.replaceRegExp(%re("/[^a-z0-9-]/g"), "")
1001
+ ->String.replaceRegExp(%re("/-+/g"), "-")
1002
+ ->String.replaceRegExp(%re("/^-+|-+$/g"), "")
946
1003
 
947
1004
  // Use "[ text ]" format (with spaces) to match visual button width for alignment
948
1005
  let buttonContent = "[ " ++ text ++ " ]"
@@ -965,11 +1022,15 @@ let segmentToElement = (
965
1022
  let actualCol = baseCol + colOffset
966
1023
  let position = Position.make(basePosition.row, actualCol)
967
1024
 
1025
+ // Use the same slugify logic as LinkParser for consistent ID generation
1026
+ // Use String.replaceRegExp (modern API) to avoid escaping issues with %re
968
1027
  let id = text
969
1028
  ->String.trim
970
1029
  ->String.toLowerCase
971
- ->Js.String2.replaceByRe(%re("/\\s+/g"), "-")
972
- ->Js.String2.replaceByRe(%re("/[^a-z0-9-]/g"), "")
1030
+ ->String.replaceRegExp(%re("/\s+/g"), "-")
1031
+ ->String.replaceRegExp(%re("/[^a-z0-9-]/g"), "")
1032
+ ->String.replaceRegExp(%re("/-+/g"), "-")
1033
+ ->String.replaceRegExp(%re("/^-+|-+$/g"), "")
973
1034
 
974
1035
  let align = AlignmentCalc.calculateWithStrategy(
975
1036
  text,
@@ -1046,10 +1107,12 @@ let parseContentLine = (
1046
1107
  Some(registry->ParserRegistry.parse(buttonContent, position, box.bounds))
1047
1108
  }
1048
1109
  | Some(LinkSegment(text, colOffset)) => {
1049
- // For LinkSegment, we also need to account for leading spaces
1110
+ // For single LinkSegment, pass through to ParserRegistry to use LinkParser's slugify
1050
1111
  let actualCol = baseCol + leadingSpaces + colOffset
1051
- let adjustedPosition = Position.make(basePosition.row, actualCol)
1052
- Some(segmentToElement(LinkSegment(text, colOffset), adjustedPosition, baseCol + leadingSpaces, box.bounds))
1112
+ let position = Position.make(row, actualCol)
1113
+ // Reconstruct the quoted text format for the parser
1114
+ let linkContent = "\"" ++ text ++ "\""
1115
+ Some(registry->ParserRegistry.parse(linkContent, position, box.bounds))
1053
1116
  }
1054
1117
  | Some(TextSegment(_, _)) | None => {
1055
1118
  // For single text segment, use original position calculation
@@ -1180,6 +1243,100 @@ let parseBoxContent = (
1180
1243
  elements
1181
1244
  }
1182
1245
 
1246
+ /**
1247
+ * Get the column position of a Box element.
1248
+ */
1249
+ let getBoxColumn = (elem: element): int => {
1250
+ switch elem {
1251
+ | Box({bounds, _}) => bounds.left
1252
+ | _ => 0
1253
+ }
1254
+ }
1255
+
1256
+ /**
1257
+ * Group horizontally aligned box elements into Row elements.
1258
+ *
1259
+ * Boxes are considered horizontally aligned if they share the same top row.
1260
+ * Single boxes are returned as-is, while groups of 2+ are wrapped in a Row.
1261
+ *
1262
+ * @param elements - Array of elements (should be Box elements)
1263
+ * @returns Array of elements with horizontal boxes wrapped in Rows
1264
+ */
1265
+ let groupHorizontalBoxes = (elements: array<element>): array<element> => {
1266
+ // Only process Box elements - separate them from non-boxes
1267
+ let boxes = elements->Array.filter(el =>
1268
+ switch el {
1269
+ | Box(_) => true
1270
+ | _ => false
1271
+ }
1272
+ )
1273
+ let nonBoxes = elements->Array.filter(el =>
1274
+ switch el {
1275
+ | Box(_) => false
1276
+ | _ => true
1277
+ }
1278
+ )
1279
+
1280
+ // If no boxes or only one box, return as-is
1281
+ if Array.length(boxes) <= 1 {
1282
+ elements
1283
+ } else {
1284
+ // Group boxes by their top row
1285
+ let boxesWithRow = boxes->Array.map(box => {
1286
+ let row = switch box {
1287
+ | Box({bounds, _}) => bounds.top
1288
+ | _ => 0
1289
+ }
1290
+ (row, box)
1291
+ })
1292
+
1293
+ // Sort by row first, then by column within same row
1294
+ let sorted = boxesWithRow->Array.toSorted(((rowA, boxA), (rowB, boxB)) => {
1295
+ if rowA !== rowB {
1296
+ Float.fromInt(rowA - rowB)
1297
+ } else {
1298
+ Float.fromInt(getBoxColumn(boxA) - getBoxColumn(boxB))
1299
+ }
1300
+ })
1301
+
1302
+ // Group boxes by row
1303
+ let groups: array<(int, array<element>)> = []
1304
+ sorted->Array.forEach(((row, box)) => {
1305
+ // Find if there's already a group for this row
1306
+ let existingGroupIdx = groups->Array.findIndex(((groupRow, _)) => groupRow === row)
1307
+ if existingGroupIdx >= 0 {
1308
+ // Add to existing group
1309
+ switch groups->Array.get(existingGroupIdx) {
1310
+ | Some((_, groupBoxes)) => {
1311
+ groupBoxes->Array.push(box)
1312
+ }
1313
+ | None => ()
1314
+ }
1315
+ } else {
1316
+ // Create new group
1317
+ groups->Array.push((row, [box]))
1318
+ }
1319
+ })
1320
+
1321
+ // Convert groups to elements
1322
+ let groupedElements = groups->Array.map(((_, groupBoxes)) => {
1323
+ if Array.length(groupBoxes) >= 2 {
1324
+ // Wrap multiple boxes in a Row
1325
+ Row({
1326
+ children: groupBoxes,
1327
+ align: Left, // Default alignment
1328
+ })
1329
+ } else {
1330
+ // Single box, return as-is
1331
+ groupBoxes->Array.getUnsafe(0)
1332
+ }
1333
+ })
1334
+
1335
+ // Combine non-boxes with grouped elements
1336
+ Array.concat(nonBoxes, groupedElements)
1337
+ }
1338
+ }
1339
+
1183
1340
  /**
1184
1341
  * Get the row position of an element for sorting purposes.
1185
1342
  */
@@ -1238,8 +1395,11 @@ let rec parseBoxRecursive = (
1238
1395
  parseBoxRecursive(childBox, gridCells, registry)
1239
1396
  })
1240
1397
 
1241
- // Combine content elements and child boxes
1242
- let allChildren = Array.concat(contentElements, childBoxElements)
1398
+ // Group horizontally aligned child boxes into Row elements
1399
+ let groupedChildElements = groupHorizontalBoxes(childBoxElements)
1400
+
1401
+ // Combine content elements and grouped child boxes
1402
+ let allChildren = Array.concat(contentElements, groupedChildElements)
1243
1403
 
1244
1404
  // Sort elements by their row position to preserve visual order
1245
1405
  let sortedChildren = allChildren->Array.toSorted((a, b) => {