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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +256 -0
- data/lib/kumiki/animation/animated_state.rb +83 -0
- data/lib/kumiki/animation/easing.rb +62 -0
- data/lib/kumiki/animation/value_tween.rb +69 -0
- data/lib/kumiki/app.rb +381 -0
- data/lib/kumiki/box.rb +40 -0
- data/lib/kumiki/chart/area_chart.rb +308 -0
- data/lib/kumiki/chart/bar_chart.rb +291 -0
- data/lib/kumiki/chart/base_chart.rb +213 -0
- data/lib/kumiki/chart/chart_helpers.rb +74 -0
- data/lib/kumiki/chart/gauge_chart.rb +174 -0
- data/lib/kumiki/chart/heatmap_chart.rb +223 -0
- data/lib/kumiki/chart/line_chart.rb +292 -0
- data/lib/kumiki/chart/pie_chart.rb +222 -0
- data/lib/kumiki/chart/scales.rb +79 -0
- data/lib/kumiki/chart/scatter_chart.rb +306 -0
- data/lib/kumiki/chart/stacked_bar_chart.rb +279 -0
- data/lib/kumiki/column.rb +351 -0
- data/lib/kumiki/core.rb +2511 -0
- data/lib/kumiki/dsl.rb +408 -0
- data/lib/kumiki/frame_ranma.rb +570 -0
- data/lib/kumiki/markdown/ast.rb +127 -0
- data/lib/kumiki/markdown/mermaid/layout.rb +389 -0
- data/lib/kumiki/markdown/mermaid/models.rb +235 -0
- data/lib/kumiki/markdown/mermaid/parser.rb +522 -0
- data/lib/kumiki/markdown/mermaid/renderer.rb +339 -0
- data/lib/kumiki/markdown/parser.rb +808 -0
- data/lib/kumiki/markdown/renderer.rb +642 -0
- data/lib/kumiki/markdown/theme.rb +168 -0
- data/lib/kumiki/render_node.rb +262 -0
- data/lib/kumiki/row.rb +288 -0
- data/lib/kumiki/spacer.rb +20 -0
- data/lib/kumiki/style.rb +799 -0
- data/lib/kumiki/theme.rb +567 -0
- data/lib/kumiki/themes/material.rb +40 -0
- data/lib/kumiki/themes/tokyo_night.rb +11 -0
- data/lib/kumiki/version.rb +5 -0
- data/lib/kumiki/widgets/button.rb +105 -0
- data/lib/kumiki/widgets/calendar.rb +1028 -0
- data/lib/kumiki/widgets/checkbox.rb +119 -0
- data/lib/kumiki/widgets/container.rb +111 -0
- data/lib/kumiki/widgets/data_table.rb +670 -0
- data/lib/kumiki/widgets/divider.rb +31 -0
- data/lib/kumiki/widgets/image.rb +105 -0
- data/lib/kumiki/widgets/input.rb +485 -0
- data/lib/kumiki/widgets/markdown.rb +58 -0
- data/lib/kumiki/widgets/modal.rb +165 -0
- data/lib/kumiki/widgets/multiline_input.rb +970 -0
- data/lib/kumiki/widgets/multiline_text.rb +180 -0
- data/lib/kumiki/widgets/net_image.rb +100 -0
- data/lib/kumiki/widgets/progress_bar.rb +72 -0
- data/lib/kumiki/widgets/radio_buttons.rb +93 -0
- data/lib/kumiki/widgets/slider.rb +135 -0
- data/lib/kumiki/widgets/switch.rb +84 -0
- data/lib/kumiki/widgets/tabs.rb +175 -0
- data/lib/kumiki/widgets/text.rb +120 -0
- data/lib/kumiki/widgets/tree.rb +434 -0
- data/lib/kumiki/widgets/webview.rb +87 -0
- data/lib/kumiki.rb +130 -0
- 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: 
|
|
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  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
|