kumiki 0.1.1

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 (62) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +256 -0
  4. data/lib/kumiki/animation/animated_state.rb +83 -0
  5. data/lib/kumiki/animation/easing.rb +62 -0
  6. data/lib/kumiki/animation/value_tween.rb +69 -0
  7. data/lib/kumiki/app.rb +381 -0
  8. data/lib/kumiki/box.rb +40 -0
  9. data/lib/kumiki/chart/area_chart.rb +308 -0
  10. data/lib/kumiki/chart/bar_chart.rb +291 -0
  11. data/lib/kumiki/chart/base_chart.rb +213 -0
  12. data/lib/kumiki/chart/chart_helpers.rb +74 -0
  13. data/lib/kumiki/chart/gauge_chart.rb +174 -0
  14. data/lib/kumiki/chart/heatmap_chart.rb +223 -0
  15. data/lib/kumiki/chart/line_chart.rb +292 -0
  16. data/lib/kumiki/chart/pie_chart.rb +222 -0
  17. data/lib/kumiki/chart/scales.rb +79 -0
  18. data/lib/kumiki/chart/scatter_chart.rb +306 -0
  19. data/lib/kumiki/chart/stacked_bar_chart.rb +279 -0
  20. data/lib/kumiki/column.rb +351 -0
  21. data/lib/kumiki/core.rb +2511 -0
  22. data/lib/kumiki/dsl.rb +408 -0
  23. data/lib/kumiki/frame_ranma.rb +570 -0
  24. data/lib/kumiki/markdown/ast.rb +127 -0
  25. data/lib/kumiki/markdown/mermaid/layout.rb +389 -0
  26. data/lib/kumiki/markdown/mermaid/models.rb +235 -0
  27. data/lib/kumiki/markdown/mermaid/parser.rb +522 -0
  28. data/lib/kumiki/markdown/mermaid/renderer.rb +339 -0
  29. data/lib/kumiki/markdown/parser.rb +808 -0
  30. data/lib/kumiki/markdown/renderer.rb +642 -0
  31. data/lib/kumiki/markdown/theme.rb +168 -0
  32. data/lib/kumiki/render_node.rb +262 -0
  33. data/lib/kumiki/row.rb +288 -0
  34. data/lib/kumiki/spacer.rb +20 -0
  35. data/lib/kumiki/style.rb +799 -0
  36. data/lib/kumiki/theme.rb +567 -0
  37. data/lib/kumiki/themes/material.rb +40 -0
  38. data/lib/kumiki/themes/tokyo_night.rb +11 -0
  39. data/lib/kumiki/version.rb +5 -0
  40. data/lib/kumiki/widgets/button.rb +105 -0
  41. data/lib/kumiki/widgets/calendar.rb +1028 -0
  42. data/lib/kumiki/widgets/checkbox.rb +119 -0
  43. data/lib/kumiki/widgets/container.rb +111 -0
  44. data/lib/kumiki/widgets/data_table.rb +670 -0
  45. data/lib/kumiki/widgets/divider.rb +31 -0
  46. data/lib/kumiki/widgets/image.rb +105 -0
  47. data/lib/kumiki/widgets/input.rb +485 -0
  48. data/lib/kumiki/widgets/markdown.rb +58 -0
  49. data/lib/kumiki/widgets/modal.rb +165 -0
  50. data/lib/kumiki/widgets/multiline_input.rb +970 -0
  51. data/lib/kumiki/widgets/multiline_text.rb +180 -0
  52. data/lib/kumiki/widgets/net_image.rb +100 -0
  53. data/lib/kumiki/widgets/progress_bar.rb +72 -0
  54. data/lib/kumiki/widgets/radio_buttons.rb +93 -0
  55. data/lib/kumiki/widgets/slider.rb +135 -0
  56. data/lib/kumiki/widgets/switch.rb +84 -0
  57. data/lib/kumiki/widgets/tabs.rb +175 -0
  58. data/lib/kumiki/widgets/text.rb +120 -0
  59. data/lib/kumiki/widgets/tree.rb +434 -0
  60. data/lib/kumiki/widgets/webview.rb +87 -0
  61. data/lib/kumiki.rb +130 -0
  62. metadata +113 -0
@@ -0,0 +1,808 @@
1
+ module Kumiki
2
+ # Markdown parser - pure Ruby line-by-line parser
3
+ # No regex, no external libraries. Uses start_with?, index, [] only.
4
+
5
+ class MarkdownParser
6
+ def initialize
7
+ @lines = []
8
+ @pos = 0
9
+ end
10
+
11
+ def parse(text)
12
+ @lines = text.split("\n")
13
+ @pos = 0
14
+ doc = MdNode.new(MD_DOCUMENT)
15
+ while @pos < @lines.length
16
+ node = parse_block
17
+ if node
18
+ doc.add_child(node)
19
+ end
20
+ end
21
+ doc
22
+ end
23
+
24
+ def parse_block
25
+ if @pos >= @lines.length
26
+ return nil
27
+ end
28
+ line = @lines[@pos]
29
+ stripped = line.strip
30
+
31
+ # Blank line
32
+ if stripped.length == 0
33
+ @pos = @pos + 1
34
+ return nil
35
+ end
36
+
37
+ # Heading: # ... ######
38
+ if stripped.start_with?("#")
39
+ return parse_heading
40
+ end
41
+
42
+ # Horizontal rule: --- or *** or ___
43
+ if is_horizontal_rule(stripped)
44
+ @pos = @pos + 1
45
+ return MdNode.new(MD_HORIZONTAL_RULE)
46
+ end
47
+
48
+ # Image on its own line: ![alt](src)
49
+ if stripped.start_with?("![")
50
+ img_node = try_parse_image_line(stripped)
51
+ if img_node
52
+ @pos = @pos + 1
53
+ return img_node
54
+ end
55
+ end
56
+
57
+ # Code block: ```
58
+ if stripped.start_with?("```")
59
+ return parse_code_block
60
+ end
61
+
62
+ # Blockquote: > text
63
+ if stripped.start_with?("> ") || stripped == ">"
64
+ return parse_blockquote
65
+ end
66
+
67
+ # Table: | col | col |
68
+ if stripped.start_with?("|") && is_table_start
69
+ return parse_table
70
+ end
71
+
72
+ # Unordered list: - item or * item
73
+ if is_unordered_list_start(stripped)
74
+ return parse_list(false)
75
+ end
76
+
77
+ # Ordered list: 1. item
78
+ if is_ordered_list_start(stripped)
79
+ return parse_list(true)
80
+ end
81
+
82
+ # Paragraph (default)
83
+ return parse_paragraph
84
+ end
85
+
86
+ def parse_heading
87
+ line = @lines[@pos].strip
88
+ @pos = @pos + 1
89
+ level = 0
90
+ i = 0
91
+ while i < line.length && line[i, 1] == "#"
92
+ level = level + 1
93
+ i = i + 1
94
+ end
95
+ if level > 6
96
+ level = 6
97
+ end
98
+ # Skip space after #
99
+ if i < line.length && line[i, 1] == " "
100
+ i = i + 1
101
+ end
102
+ text = ""
103
+ if i < line.length
104
+ text = line[i, line.length - i]
105
+ end
106
+ node = MdNode.new(MD_HEADING)
107
+ node.level = level
108
+ inline_nodes = parse_inline(text)
109
+ ci = 0
110
+ while ci < inline_nodes.length
111
+ node.add_child(inline_nodes[ci])
112
+ ci = ci + 1
113
+ end
114
+ node
115
+ end
116
+
117
+ def parse_code_block
118
+ first_line = @lines[@pos].strip
119
+ @pos = @pos + 1
120
+ # Extract language from ```lang
121
+ lang = ""
122
+ if first_line.length > 3
123
+ lang = first_line[3, first_line.length - 3].strip
124
+ end
125
+ content_parts = []
126
+ while @pos < @lines.length
127
+ line = @lines[@pos]
128
+ if line.strip.start_with?("```")
129
+ @pos = @pos + 1
130
+ break
131
+ end
132
+ content_parts.push(line)
133
+ @pos = @pos + 1
134
+ end
135
+ if lang == "mermaid"
136
+ node = MdNode.new(MD_MERMAID)
137
+ node.content = content_parts.join("\n")
138
+ return node
139
+ end
140
+ node = MdNode.new(MD_CODE_BLOCK)
141
+ node.language = lang
142
+ node.content = content_parts.join("\n")
143
+ node
144
+ end
145
+
146
+ def parse_blockquote
147
+ collected = []
148
+ while @pos < @lines.length
149
+ line = @lines[@pos]
150
+ stripped = line.strip
151
+ if stripped.start_with?("> ")
152
+ collected.push(stripped[2, stripped.length - 2])
153
+ @pos = @pos + 1
154
+ elsif stripped == ">"
155
+ collected.push("")
156
+ @pos = @pos + 1
157
+ else
158
+ break
159
+ end
160
+ end
161
+ # Recursively parse the collected content
162
+ inner_text = collected.join("\n")
163
+ inner_parser = MarkdownParser.new
164
+ inner_doc = inner_parser.parse(inner_text)
165
+ node = MdNode.new(MD_BLOCKQUOTE)
166
+ ci = 0
167
+ while ci < inner_doc.children.length
168
+ node.add_child(inner_doc.children[ci])
169
+ ci = ci + 1
170
+ end
171
+ node
172
+ end
173
+
174
+ def parse_list(ordered)
175
+ node = MdNode.new(MD_LIST)
176
+ node.ordered = ordered
177
+ if ordered
178
+ # Extract start number
179
+ line = @lines[@pos].strip
180
+ num_str = ""
181
+ ni = 0
182
+ while ni < line.length && is_digit(line[ni, 1])
183
+ num_str = num_str + line[ni, 1]
184
+ ni = ni + 1
185
+ end
186
+ if num_str.length > 0
187
+ node.start_num = num_str.to_i
188
+ end
189
+ end
190
+ counter = node.start_num
191
+ while @pos < @lines.length
192
+ line = @lines[@pos].strip
193
+ if line.length == 0
194
+ # Blank line might end the list or separate items
195
+ @pos = @pos + 1
196
+ # Check if next line continues the list
197
+ if @pos < @lines.length
198
+ next_line = @lines[@pos].strip
199
+ if ordered && is_ordered_list_start(next_line)
200
+ next
201
+ elsif !ordered && is_unordered_list_start(next_line)
202
+ next
203
+ else
204
+ break
205
+ end
206
+ else
207
+ break
208
+ end
209
+ elsif ordered && is_ordered_list_start(line)
210
+ item = parse_list_item_ordered
211
+ node.add_child(item)
212
+ counter = counter + 1
213
+ elsif !ordered && is_unordered_list_start(line)
214
+ item = parse_list_item_unordered
215
+ node.add_child(item)
216
+ else
217
+ break
218
+ end
219
+ end
220
+ node
221
+ end
222
+
223
+ def parse_list_item_unordered
224
+ line = @lines[@pos].strip
225
+ @pos = @pos + 1
226
+ # Strip "- " or "* "
227
+ text = line[2, line.length - 2]
228
+ item = MdNode.new(MD_LIST_ITEM)
229
+
230
+ # Check for task list: [ ] or [x] or [X]
231
+ if text.length >= 4 && text[0, 4] == "[ ] "
232
+ item.checked = 0
233
+ text = text[4, text.length - 4]
234
+ elsif text.length >= 4 && (text[0, 4] == "[x] " || text[0, 4] == "[X] ")
235
+ item.checked = 1
236
+ text = text[4, text.length - 4]
237
+ end
238
+
239
+ # Parse inline content as a paragraph
240
+ para = MdNode.new(MD_PARAGRAPH)
241
+ inline_nodes = parse_inline(text)
242
+ ci = 0
243
+ while ci < inline_nodes.length
244
+ para.add_child(inline_nodes[ci])
245
+ ci = ci + 1
246
+ end
247
+ item.add_child(para)
248
+
249
+ # Check for nested sub-list (indented lines)
250
+ parse_nested_list(item)
251
+
252
+ item
253
+ end
254
+
255
+ def parse_list_item_ordered
256
+ line = @lines[@pos].strip
257
+ @pos = @pos + 1
258
+ # Find ". " after digits
259
+ di = 0
260
+ while di < line.length && is_digit(line[di, 1])
261
+ di = di + 1
262
+ end
263
+ # Skip ". "
264
+ if di < line.length && line[di, 1] == "."
265
+ di = di + 1
266
+ end
267
+ if di < line.length && line[di, 1] == " "
268
+ di = di + 1
269
+ end
270
+ text = ""
271
+ if di < line.length
272
+ text = line[di, line.length - di]
273
+ end
274
+ item = MdNode.new(MD_LIST_ITEM)
275
+ para = MdNode.new(MD_PARAGRAPH)
276
+ inline_nodes = parse_inline(text)
277
+ ci = 0
278
+ while ci < inline_nodes.length
279
+ para.add_child(inline_nodes[ci])
280
+ ci = ci + 1
281
+ end
282
+ item.add_child(para)
283
+
284
+ # Check for nested sub-list (indented lines)
285
+ parse_nested_list(item)
286
+
287
+ item
288
+ end
289
+
290
+ def parse_paragraph
291
+ parts = []
292
+ while @pos < @lines.length
293
+ line = @lines[@pos]
294
+ stripped = line.strip
295
+ if stripped.length == 0
296
+ break
297
+ end
298
+ if stripped.start_with?("#") || stripped.start_with?("```") || stripped.start_with?("> ") || stripped == ">"
299
+ break
300
+ end
301
+ if stripped.start_with?("|") && is_table_start
302
+ break
303
+ end
304
+ if is_horizontal_rule(stripped)
305
+ break
306
+ end
307
+ if is_unordered_list_start(stripped) || is_ordered_list_start(stripped)
308
+ break
309
+ end
310
+ if stripped.start_with?("![")
311
+ break
312
+ end
313
+ parts.push(stripped)
314
+ @pos = @pos + 1
315
+ end
316
+ text = parts.join(" ")
317
+ node = MdNode.new(MD_PARAGRAPH)
318
+ inline_nodes = parse_inline(text)
319
+ ci = 0
320
+ while ci < inline_nodes.length
321
+ node.add_child(inline_nodes[ci])
322
+ ci = ci + 1
323
+ end
324
+ node
325
+ end
326
+
327
+ # --- Inline parsing ---
328
+
329
+ def parse_inline(text)
330
+ nodes = []
331
+ buf = ""
332
+ i = 0
333
+ while i < text.length
334
+ ch = text[i, 1]
335
+
336
+ if ch == "*"
337
+ # Check ** (bold) or * (italic)
338
+ if i + 1 < text.length && text[i + 1, 1] == "*"
339
+ # Bold **...**
340
+ flush_buf(buf, nodes)
341
+ buf = ""
342
+ close_idx = find_marker(text, i + 2, "**")
343
+ if close_idx >= 0
344
+ inner = text[i + 2, close_idx - (i + 2)]
345
+ node = MdNode.new(MD_STRONG)
346
+ inner_nodes = parse_inline(inner)
347
+ ni = 0
348
+ while ni < inner_nodes.length
349
+ node.add_child(inner_nodes[ni])
350
+ ni = ni + 1
351
+ end
352
+ nodes.push(node)
353
+ i = close_idx + 2
354
+ else
355
+ buf = buf + "**"
356
+ i = i + 2
357
+ end
358
+ else
359
+ # Italic *...*
360
+ flush_buf(buf, nodes)
361
+ buf = ""
362
+ close_idx = find_single_star(text, i + 1)
363
+ if close_idx >= 0
364
+ inner = text[i + 1, close_idx - (i + 1)]
365
+ node = MdNode.new(MD_EMPHASIS)
366
+ inner_nodes = parse_inline(inner)
367
+ ni = 0
368
+ while ni < inner_nodes.length
369
+ node.add_child(inner_nodes[ni])
370
+ ni = ni + 1
371
+ end
372
+ nodes.push(node)
373
+ i = close_idx + 1
374
+ else
375
+ buf = buf + "*"
376
+ i = i + 1
377
+ end
378
+ end
379
+
380
+ elsif ch == "~"
381
+ # Strikethrough ~~...~~
382
+ if i + 1 < text.length && text[i + 1, 1] == "~"
383
+ flush_buf(buf, nodes)
384
+ buf = ""
385
+ close_idx = find_marker(text, i + 2, "~~")
386
+ if close_idx >= 0
387
+ inner = text[i + 2, close_idx - (i + 2)]
388
+ node = MdNode.new(MD_STRIKETHROUGH)
389
+ inner_nodes = parse_inline(inner)
390
+ ni = 0
391
+ while ni < inner_nodes.length
392
+ node.add_child(inner_nodes[ni])
393
+ ni = ni + 1
394
+ end
395
+ nodes.push(node)
396
+ i = close_idx + 2
397
+ else
398
+ buf = buf + "~~"
399
+ i = i + 2
400
+ end
401
+ else
402
+ buf = buf + "~"
403
+ i = i + 1
404
+ end
405
+
406
+ elsif ch == "`"
407
+ # Inline code `...`
408
+ flush_buf(buf, nodes)
409
+ buf = ""
410
+ close_idx = find_char(text, i + 1, "`")
411
+ if close_idx >= 0
412
+ code_text = text[i + 1, close_idx - (i + 1)]
413
+ node = MdNode.new(MD_CODE_INLINE)
414
+ node.content = code_text
415
+ nodes.push(node)
416
+ i = close_idx + 1
417
+ else
418
+ buf = buf + "`"
419
+ i = i + 1
420
+ end
421
+
422
+ elsif ch == "["
423
+ # Link [text](url)
424
+ flush_buf(buf, nodes)
425
+ buf = ""
426
+ bracket_close = find_char(text, i + 1, "]")
427
+ if bracket_close >= 0 && bracket_close + 1 < text.length && text[bracket_close + 1, 1] == "("
428
+ paren_close = find_char(text, bracket_close + 2, ")")
429
+ if paren_close >= 0
430
+ link_text = text[i + 1, bracket_close - (i + 1)]
431
+ link_url = text[bracket_close + 2, paren_close - (bracket_close + 2)]
432
+ node = MdNode.new(MD_LINK)
433
+ node.href = link_url
434
+ inner_nodes = parse_inline(link_text)
435
+ ni = 0
436
+ while ni < inner_nodes.length
437
+ node.add_child(inner_nodes[ni])
438
+ ni = ni + 1
439
+ end
440
+ nodes.push(node)
441
+ i = paren_close + 1
442
+ else
443
+ buf = buf + "["
444
+ i = i + 1
445
+ end
446
+ else
447
+ buf = buf + "["
448
+ i = i + 1
449
+ end
450
+
451
+ else
452
+ buf = buf + ch
453
+ i = i + 1
454
+ end
455
+ end
456
+ flush_buf(buf, nodes)
457
+ nodes
458
+ end
459
+
460
+ # --- Helpers ---
461
+
462
+ def flush_buf(buf, nodes)
463
+ if buf.length > 0
464
+ node = MdNode.new(MD_TEXT)
465
+ node.content = buf
466
+ nodes.push(node)
467
+ end
468
+ end
469
+
470
+ def find_marker(text, start, marker)
471
+ # Find the position of marker string starting from start
472
+ mlen = marker.length
473
+ i = start
474
+ while i <= text.length - mlen
475
+ if text[i, mlen] == marker
476
+ return i
477
+ end
478
+ i = i + 1
479
+ end
480
+ -1
481
+ end
482
+
483
+ def find_single_star(text, start)
484
+ # Find closing * that is not part of **
485
+ i = start
486
+ while i < text.length
487
+ if text[i, 1] == "*"
488
+ # Make sure it's not **
489
+ if i + 1 < text.length && text[i + 1, 1] == "*"
490
+ i = i + 2
491
+ else
492
+ return i
493
+ end
494
+ else
495
+ i = i + 1
496
+ end
497
+ end
498
+ -1
499
+ end
500
+
501
+ def find_char(text, start, ch)
502
+ i = start
503
+ while i < text.length
504
+ if text[i, 1] == ch
505
+ return i
506
+ end
507
+ i = i + 1
508
+ end
509
+ -1
510
+ end
511
+
512
+ def is_horizontal_rule(line)
513
+ if line.length < 3
514
+ return false
515
+ end
516
+ # Check if line is all dashes, all asterisks, or all underscores (with optional spaces)
517
+ ch = ""
518
+ count = 0
519
+ i = 0
520
+ while i < line.length
521
+ c = line[i, 1]
522
+ if c == " "
523
+ i = i + 1
524
+ next
525
+ end
526
+ if ch == ""
527
+ if c == "-" || c == "*" || c == "_"
528
+ ch = c
529
+ count = 1
530
+ else
531
+ return false
532
+ end
533
+ elsif c == ch
534
+ count = count + 1
535
+ else
536
+ return false
537
+ end
538
+ i = i + 1
539
+ end
540
+ count >= 3
541
+ end
542
+
543
+ def is_unordered_list_start(line)
544
+ if line.length >= 2
545
+ if (line[0, 1] == "-" || line[0, 1] == "*") && line[1, 1] == " "
546
+ return true
547
+ end
548
+ end
549
+ false
550
+ end
551
+
552
+ def is_ordered_list_start(line)
553
+ # Check if line starts with digits followed by ". "
554
+ i = 0
555
+ while i < line.length && is_digit(line[i, 1])
556
+ i = i + 1
557
+ end
558
+ if i > 0 && i + 1 < line.length && line[i, 1] == "." && line[i + 1, 1] == " "
559
+ return true
560
+ end
561
+ false
562
+ end
563
+
564
+ def try_parse_image_line(line)
565
+ # Parse ![alt](src) as a block-level image
566
+ # Returns MD_PARAGRAPH containing MD_IMAGE node, or nil
567
+ if !line.start_with?("![")
568
+ return nil
569
+ end
570
+ bracket_close = find_char(line, 2, "]")
571
+ if bracket_close < 0
572
+ return nil
573
+ end
574
+ if bracket_close + 1 >= line.length || line[bracket_close + 1, 1] != "("
575
+ return nil
576
+ end
577
+ paren_close = find_char(line, bracket_close + 2, ")")
578
+ if paren_close < 0
579
+ return nil
580
+ end
581
+ alt_text = line[2, bracket_close - 2]
582
+ img_src = line[bracket_close + 2, paren_close - (bracket_close + 2)]
583
+ img_node = MdNode.new(MD_IMAGE)
584
+ img_node.content = alt_text
585
+ img_node.href = img_src
586
+ img_node
587
+ end
588
+
589
+ def is_digit(ch)
590
+ ch == "0" || ch == "1" || ch == "2" || ch == "3" || ch == "4" || ch == "5" || ch == "6" || ch == "7" || ch == "8" || ch == "9"
591
+ end
592
+
593
+ # --- Table parsing ---
594
+
595
+ def is_table_start
596
+ # Check if current line + next line form a table (header + separator)
597
+ if @pos + 1 >= @lines.length
598
+ return false
599
+ end
600
+ next_line = @lines[@pos + 1].strip
601
+ if next_line.length < 3
602
+ return false
603
+ end
604
+ # Separator must start with | and contain ---
605
+ if !next_line.start_with?("|")
606
+ return false
607
+ end
608
+ # Quick check: separator contains at least one ---
609
+ has_dash = false
610
+ i = 0
611
+ while i < next_line.length
612
+ if next_line[i, 1] == "-"
613
+ has_dash = true
614
+ break
615
+ end
616
+ i = i + 1
617
+ end
618
+ has_dash
619
+ end
620
+
621
+ def parse_table
622
+ table = MdNode.new(MD_TABLE)
623
+
624
+ # Parse header row
625
+ header_cells = split_table_row(@lines[@pos].strip)
626
+ @pos = @pos + 1
627
+
628
+ # Parse separator row for alignment
629
+ alignments = parse_table_separator(@lines[@pos].strip)
630
+ @pos = @pos + 1
631
+
632
+ # Build header row node
633
+ header_row = MdNode.new(MD_TABLE_ROW)
634
+ header_row.is_header = true
635
+ ci = 0
636
+ while ci < header_cells.length
637
+ cell = MdNode.new(MD_TABLE_CELL)
638
+ cell.is_header = true
639
+ if alignments && ci < alignments.length
640
+ cell.align = alignments[ci]
641
+ end
642
+ inline_nodes = parse_inline(header_cells[ci])
643
+ ni = 0
644
+ while ni < inline_nodes.length
645
+ cell.add_child(inline_nodes[ni])
646
+ ni = ni + 1
647
+ end
648
+ header_row.add_child(cell)
649
+ ci = ci + 1
650
+ end
651
+ table.add_child(header_row)
652
+
653
+ # Parse data rows
654
+ while @pos < @lines.length
655
+ line = @lines[@pos].strip
656
+ if line.length == 0 || !line.start_with?("|")
657
+ break
658
+ end
659
+ data_cells = split_table_row(line)
660
+ row = MdNode.new(MD_TABLE_ROW)
661
+ di = 0
662
+ while di < data_cells.length
663
+ cell = MdNode.new(MD_TABLE_CELL)
664
+ if alignments && di < alignments.length
665
+ cell.align = alignments[di]
666
+ end
667
+ inline_nodes = parse_inline(data_cells[di])
668
+ ni = 0
669
+ while ni < inline_nodes.length
670
+ cell.add_child(inline_nodes[ni])
671
+ ni = ni + 1
672
+ end
673
+ row.add_child(cell)
674
+ di = di + 1
675
+ end
676
+ table.add_child(row)
677
+ @pos = @pos + 1
678
+ end
679
+
680
+ table
681
+ end
682
+
683
+ def split_table_row(line)
684
+ # Strip leading and trailing |
685
+ inner = ""
686
+ start = 0
687
+ if line.length > 0 && line[0, 1] == "|"
688
+ start = 1
689
+ end
690
+ stop = line.length
691
+ if stop > 0 && line[stop - 1, 1] == "|"
692
+ stop = stop - 1
693
+ end
694
+ if start < stop
695
+ inner = line[start, stop - start]
696
+ end
697
+
698
+ # Split by | character
699
+ cells = []
700
+ buf = ""
701
+ i = 0
702
+ while i < inner.length
703
+ ch = inner[i, 1]
704
+ if ch == "|"
705
+ cells.push(buf.strip)
706
+ buf = ""
707
+ else
708
+ buf = buf + ch
709
+ end
710
+ i = i + 1
711
+ end
712
+ cells.push(buf.strip)
713
+ cells
714
+ end
715
+
716
+ def parse_table_separator(line)
717
+ cells = split_table_row(line)
718
+ alignments = []
719
+ ci = 0
720
+ while ci < cells.length
721
+ cell = cells[ci]
722
+ # Check alignment markers
723
+ left_colon = cell.length > 0 && cell[0, 1] == ":"
724
+ right_colon = cell.length > 0 && cell[cell.length - 1, 1] == ":"
725
+ if left_colon && right_colon
726
+ alignments.push(1) # center
727
+ elsif right_colon
728
+ alignments.push(2) # right
729
+ else
730
+ alignments.push(0) # left (default)
731
+ end
732
+ ci = ci + 1
733
+ end
734
+ alignments
735
+ end
736
+
737
+ # --- Nested list parsing ---
738
+
739
+ def count_leading_spaces(line)
740
+ count = 0
741
+ i = 0
742
+ while i < line.length
743
+ if line[i, 1] == " "
744
+ count = count + 1
745
+ else
746
+ break
747
+ end
748
+ i = i + 1
749
+ end
750
+ count
751
+ end
752
+
753
+ def parse_nested_list(item)
754
+ # Peek at next lines: if indented 2+ spaces with list marker, parse as sub-list
755
+ sub_lines = collect_indented_lines
756
+ if sub_lines.length == 0
757
+ return
758
+ end
759
+
760
+ # Parse sub-lines as a new document, extract list nodes
761
+ sub_parser = MarkdownParser.new
762
+ sub_doc = sub_parser.parse(sub_lines.join("\n"))
763
+ ci = 0
764
+ while ci < sub_doc.children.length
765
+ child = sub_doc.children[ci]
766
+ if child.type == MD_LIST
767
+ item.add_child(child)
768
+ end
769
+ ci = ci + 1
770
+ end
771
+ end
772
+
773
+ def collect_indented_lines
774
+ result = []
775
+ if @pos >= @lines.length
776
+ return result
777
+ end
778
+ first_line = @lines[@pos]
779
+ first_spaces = count_leading_spaces(first_line)
780
+ if first_spaces < 2
781
+ return result
782
+ end
783
+
784
+ # Check first line is a list start after stripping indent
785
+ first_stripped = first_line.strip
786
+ if !is_unordered_list_start(first_stripped) && !is_ordered_list_start(first_stripped)
787
+ return result
788
+ end
789
+
790
+ # Collect lines with sufficient indentation (use strip to remove indent)
791
+ while @pos < @lines.length
792
+ line = @lines[@pos]
793
+ stripped = line.strip
794
+ if stripped.length == 0
795
+ break
796
+ end
797
+ spaces = count_leading_spaces(line)
798
+ if spaces < first_spaces
799
+ break
800
+ end
801
+ result.push(stripped)
802
+ @pos = @pos + 1
803
+ end
804
+ result
805
+ end
806
+ end
807
+
808
+ end