rich-ruby 1.0.1 → 1.0.2
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 +4 -4
- data/CHANGELOG.md +80 -0
- data/LICENSE +21 -21
- data/README.md +547 -547
- data/docs/architecture.md +43 -0
- data/docs/cheat-sheet.md +52 -0
- data/docs/customization.md +53 -0
- data/docs/how-to-use.md +96 -0
- data/docs/test-report.md +112 -0
- data/docs/troubleshooting.md +36 -0
- data/docs/windows-notes.md +30 -0
- data/examples/demo.rb +106 -106
- data/examples/showcase.rb +420 -420
- data/examples/smoke_test.rb +41 -41
- data/examples/stress_test.rb +604 -604
- data/examples/syntax_markdown_demo.rb +166 -166
- data/examples/verify.rb +216 -215
- data/examples/visual_demo.rb +145 -145
- data/lib/rich/_palettes.rb +148 -148
- data/lib/rich/box.rb +342 -342
- data/lib/rich/cells.rb +524 -512
- data/lib/rich/color.rb +631 -628
- data/lib/rich/color_triplet.rb +227 -220
- data/lib/rich/console.rb +604 -549
- data/lib/rich/control.rb +332 -332
- data/lib/rich/json.rb +260 -254
- data/lib/rich/layout.rb +314 -314
- data/lib/rich/markdown.rb +531 -509
- data/lib/rich/markup.rb +186 -175
- data/lib/rich/panel.rb +318 -311
- data/lib/rich/progress.rb +430 -430
- data/lib/rich/segment.rb +387 -387
- data/lib/rich/style.rb +464 -433
- data/lib/rich/syntax.rb +1220 -1145
- data/lib/rich/table.rb +547 -525
- data/lib/rich/terminal_theme.rb +126 -126
- data/lib/rich/text.rb +460 -433
- data/lib/rich/tree.rb +220 -220
- data/lib/rich/version.rb +5 -5
- data/lib/rich/win32_console.rb +620 -582
- data/lib/rich.rb +108 -108
- metadata +15 -5
data/lib/rich/markdown.rb
CHANGED
|
@@ -1,509 +1,531 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "style"
|
|
4
|
-
require_relative "segment"
|
|
5
|
-
require_relative "text"
|
|
6
|
-
require_relative "panel"
|
|
7
|
-
require_relative "box"
|
|
8
|
-
require_relative "cells"
|
|
9
|
-
|
|
10
|
-
module Rich
|
|
11
|
-
# Markdown rendering for terminal output.
|
|
12
|
-
# Parses and renders Markdown content with styling.
|
|
13
|
-
class Markdown
|
|
14
|
-
# Default styles for Markdown elements
|
|
15
|
-
DEFAULT_STYLES = {
|
|
16
|
-
# Headings
|
|
17
|
-
h1: Style.new(color: Color.parse("bright_cyan"), bold: true),
|
|
18
|
-
h2: Style.new(color: Color.parse("cyan"), bold: true),
|
|
19
|
-
h3: Style.new(color: Color.parse("bright_blue"), bold: true),
|
|
20
|
-
h4: Style.new(color: Color.parse("blue"), bold: true),
|
|
21
|
-
h5: Style.new(color: Color.parse("bright_magenta")),
|
|
22
|
-
h6: Style.new(color: Color.parse("magenta")),
|
|
23
|
-
|
|
24
|
-
# Text formatting
|
|
25
|
-
bold: Style.new(bold: true),
|
|
26
|
-
italic: Style.new(italic: true),
|
|
27
|
-
bold_italic: Style.new(bold: true, italic: true),
|
|
28
|
-
strikethrough: Style.new(strike: true),
|
|
29
|
-
code_inline: Style.new(color: Color.parse("bright_green"), bgcolor: Color.parse("color(236)")),
|
|
30
|
-
|
|
31
|
-
# Links and references
|
|
32
|
-
link: Style.new(color: Color.parse("bright_blue"), underline: true),
|
|
33
|
-
link_url: Style.new(color: Color.parse("blue"), dim: true),
|
|
34
|
-
|
|
35
|
-
# Lists
|
|
36
|
-
bullet: Style.new(color: Color.parse("yellow")),
|
|
37
|
-
list_number: Style.new(color: Color.parse("yellow")),
|
|
38
|
-
|
|
39
|
-
# Blockquotes
|
|
40
|
-
blockquote: Style.new(color: Color.parse("bright_black"), italic: true),
|
|
41
|
-
blockquote_border: Style.new(color: Color.parse("magenta")),
|
|
42
|
-
|
|
43
|
-
# Code blocks
|
|
44
|
-
code_block: Style.new(bgcolor: Color.parse("color(236)")),
|
|
45
|
-
code_border: Style.new(color: Color.parse("bright_black")),
|
|
46
|
-
|
|
47
|
-
# Horizontal rule
|
|
48
|
-
hr: Style.new(color: Color.parse("bright_black")),
|
|
49
|
-
|
|
50
|
-
# Table
|
|
51
|
-
table_header: Style.new(bold: true, color: Color.parse("cyan")),
|
|
52
|
-
table_border: Style.new(color: Color.parse("bright_black"))
|
|
53
|
-
}.freeze
|
|
54
|
-
|
|
55
|
-
# @return [String] Source markdown
|
|
56
|
-
attr_reader :source
|
|
57
|
-
|
|
58
|
-
# @return [Hash] Style configuration
|
|
59
|
-
attr_reader :styles
|
|
60
|
-
|
|
61
|
-
# @return [Boolean] Use hyperlinks
|
|
62
|
-
attr_reader :hyperlinks
|
|
63
|
-
|
|
64
|
-
# @return [Integer] Code block indent
|
|
65
|
-
attr_reader :code_indent
|
|
66
|
-
|
|
67
|
-
# Create a new Markdown renderer
|
|
68
|
-
# @param source [String] Markdown source text
|
|
69
|
-
# @param styles [Hash] Custom styles to override defaults
|
|
70
|
-
# @param hyperlinks [Boolean] Enable terminal hyperlinks
|
|
71
|
-
# @param code_indent [Integer] Indent for code blocks
|
|
72
|
-
def initialize(source, styles: {}, hyperlinks: true, code_indent: 4)
|
|
73
|
-
@source = source.to_s
|
|
74
|
-
@styles = DEFAULT_STYLES.merge(styles)
|
|
75
|
-
@hyperlinks = hyperlinks
|
|
76
|
-
@code_indent = code_indent
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
# Render markdown to string with ANSI codes
|
|
80
|
-
# @param max_width [Integer] Maximum width
|
|
81
|
-
# @return [String]
|
|
82
|
-
def render(max_width: 80)
|
|
83
|
-
lines = parse_and_render(max_width: max_width)
|
|
84
|
-
lines.join("\n")
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
# Convert to segments
|
|
88
|
-
# @param max_width [Integer] Maximum width
|
|
89
|
-
# @return [Array<Segment>]
|
|
90
|
-
def to_segments(max_width: 80)
|
|
91
|
-
segments = []
|
|
92
|
-
lines = parse_and_render(max_width: max_width)
|
|
93
|
-
|
|
94
|
-
lines.each_with_index do |line, i|
|
|
95
|
-
# Line is already a rendered string with ANSI codes
|
|
96
|
-
segments << Segment.new(line)
|
|
97
|
-
segments << Segment.new("\n") if i < lines.length - 1
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
segments
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
class << self
|
|
104
|
-
# Render markdown from string
|
|
105
|
-
# @param source [String] Markdown text
|
|
106
|
-
# @param kwargs [Hash] Options
|
|
107
|
-
# @return [String]
|
|
108
|
-
def render(source, **kwargs)
|
|
109
|
-
new(source, **kwargs).render(**kwargs)
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# Render markdown from file
|
|
113
|
-
# @param path [String] File path
|
|
114
|
-
# @param kwargs [Hash] Options
|
|
115
|
-
# @return [String]
|
|
116
|
-
def from_file(path, **kwargs)
|
|
117
|
-
source = File.read(path)
|
|
118
|
-
new(source, **kwargs)
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
private
|
|
123
|
-
|
|
124
|
-
# Parse and render markdown
|
|
125
|
-
# @param max_width [Integer] Maximum width
|
|
126
|
-
# @return [Array<String>]
|
|
127
|
-
def parse_and_render(max_width:)
|
|
128
|
-
lines = @source.lines.map(&:chomp)
|
|
129
|
-
output = []
|
|
130
|
-
i = 0
|
|
131
|
-
|
|
132
|
-
while i < lines.length
|
|
133
|
-
line = lines[i]
|
|
134
|
-
|
|
135
|
-
# Blank line
|
|
136
|
-
if line.strip.empty?
|
|
137
|
-
output << ""
|
|
138
|
-
i += 1
|
|
139
|
-
next
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
# Fenced code block
|
|
143
|
-
if line.match?(/^```/)
|
|
144
|
-
lang = line[3..].strip
|
|
145
|
-
code_lines = []
|
|
146
|
-
i += 1
|
|
147
|
-
while i < lines.length && !lines[i].start_with?("```")
|
|
148
|
-
code_lines << lines[i]
|
|
149
|
-
i += 1
|
|
150
|
-
end
|
|
151
|
-
output.concat(render_code_block(code_lines.join("\n"), lang, max_width))
|
|
152
|
-
i += 1
|
|
153
|
-
next
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
# Heading
|
|
157
|
-
if line.match?(%r{^\#{1,6}\s})
|
|
158
|
-
output.concat(render_heading(line, max_width))
|
|
159
|
-
i += 1
|
|
160
|
-
next
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
# Horizontal rule
|
|
164
|
-
if line.match?(/^[-*_]{3,}\s*$/)
|
|
165
|
-
output << render_hr(max_width)
|
|
166
|
-
i += 1
|
|
167
|
-
next
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
# Unordered list
|
|
171
|
-
if line.match?(/^\s*[-*+]\s/)
|
|
172
|
-
list_lines = [line]
|
|
173
|
-
i += 1
|
|
174
|
-
while i < lines.length && (lines[i].match?(/^\s*[-*+]\s/) || lines[i].match?(/^\s{2,}/))
|
|
175
|
-
list_lines << lines[i]
|
|
176
|
-
i += 1
|
|
177
|
-
end
|
|
178
|
-
output.concat(render_unordered_list(list_lines, max_width))
|
|
179
|
-
next
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
# Ordered list
|
|
183
|
-
if line.match?(/^\s*\d+\.\s/)
|
|
184
|
-
list_lines = [line]
|
|
185
|
-
i += 1
|
|
186
|
-
while i < lines.length && (lines[i].match?(/^\s*\d+\.\s/) || lines[i].match?(/^\s{2,}/))
|
|
187
|
-
list_lines << lines[i]
|
|
188
|
-
i += 1
|
|
189
|
-
end
|
|
190
|
-
output.concat(render_ordered_list(list_lines, max_width))
|
|
191
|
-
next
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
# Blockquote
|
|
195
|
-
if line.start_with?(">")
|
|
196
|
-
quote_lines = [line]
|
|
197
|
-
i += 1
|
|
198
|
-
while i < lines.length && (lines[i].start_with?(">") || (!lines[i].strip.empty? && !lines[i].match?(/^[#\-*+\d]/)))
|
|
199
|
-
quote_lines << lines[i]
|
|
200
|
-
i += 1
|
|
201
|
-
end
|
|
202
|
-
output.concat(render_blockquote(quote_lines, max_width))
|
|
203
|
-
next
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
# Table
|
|
207
|
-
if line.include?("|") && i + 1 < lines.length && lines[i + 1].match?(/^\|?\s*[-:]+/)
|
|
208
|
-
table_lines = [line]
|
|
209
|
-
i += 1
|
|
210
|
-
while i < lines.length && lines[i].include?("|")
|
|
211
|
-
table_lines << lines[i]
|
|
212
|
-
i += 1
|
|
213
|
-
end
|
|
214
|
-
output.concat(render_table(table_lines, max_width))
|
|
215
|
-
next
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
# Regular paragraph
|
|
219
|
-
para_lines = [line]
|
|
220
|
-
i += 1
|
|
221
|
-
while i < lines.length && !lines[i].strip.empty? && !lines[i].match?(/^[\#\-*+>\d`|]/)
|
|
222
|
-
para_lines << lines[i]
|
|
223
|
-
i += 1
|
|
224
|
-
end
|
|
225
|
-
output.concat(render_paragraph(para_lines.join(" "), max_width))
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
output
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
# Render a heading
|
|
232
|
-
def render_heading(line, max_width)
|
|
233
|
-
match = line.match(%r{^(\#{1,6})\s+(.*)})
|
|
234
|
-
return [line] unless match
|
|
235
|
-
|
|
236
|
-
level = match[1].length
|
|
237
|
-
text = match[2]
|
|
238
|
-
|
|
239
|
-
style = @styles[:"h#{level}"] || @styles[:h1]
|
|
240
|
-
styled_text = apply_inline_styles(text)
|
|
241
|
-
|
|
242
|
-
result = []
|
|
243
|
-
|
|
244
|
-
# Add decorations based on level
|
|
245
|
-
case level
|
|
246
|
-
when 1
|
|
247
|
-
border = style.render + ("=" * [text.length + 4, max_width].min) + "\e[0m"
|
|
248
|
-
result << border
|
|
249
|
-
result << style.render + " #{styled_text} " + "\e[0m"
|
|
250
|
-
result << border
|
|
251
|
-
when 2
|
|
252
|
-
result << style.render + styled_text + "\e[0m"
|
|
253
|
-
result << style.render + ("-" * [text.length, max_width].min) + "\e[0m"
|
|
254
|
-
else
|
|
255
|
-
prefix = "#" * level + " "
|
|
256
|
-
result << style.render + prefix + styled_text + "\e[0m"
|
|
257
|
-
end
|
|
258
|
-
|
|
259
|
-
result << ""
|
|
260
|
-
result
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
# Render horizontal rule
|
|
264
|
-
def render_hr(max_width)
|
|
265
|
-
@styles[:hr].render + ("─" * max_width) + "\e[0m"
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
# Render unordered list
|
|
269
|
-
def render_unordered_list(lines, max_width)
|
|
270
|
-
result = []
|
|
271
|
-
indent = 0
|
|
272
|
-
|
|
273
|
-
lines.each do |line|
|
|
274
|
-
match = line.match(/^(\s*)([-*+])\s+(.*)/)
|
|
275
|
-
next unless match
|
|
276
|
-
|
|
277
|
-
spaces = match[1]
|
|
278
|
-
content = match[3]
|
|
279
|
-
|
|
280
|
-
# Calculate indent level
|
|
281
|
-
indent = spaces.length / 2
|
|
282
|
-
|
|
283
|
-
bullet_char = case indent
|
|
284
|
-
when 0 then "•"
|
|
285
|
-
when 1 then "◦"
|
|
286
|
-
else "▪"
|
|
287
|
-
end
|
|
288
|
-
|
|
289
|
-
prefix = " " * indent
|
|
290
|
-
bullet = @styles[:bullet].render + bullet_char + "\e[0m "
|
|
291
|
-
styled_content = apply_inline_styles(content)
|
|
292
|
-
|
|
293
|
-
result << prefix + bullet + styled_content
|
|
294
|
-
end
|
|
295
|
-
|
|
296
|
-
result << ""
|
|
297
|
-
result
|
|
298
|
-
end
|
|
299
|
-
|
|
300
|
-
# Render ordered list
|
|
301
|
-
def render_ordered_list(lines, max_width)
|
|
302
|
-
result = []
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
lines.each do |line|
|
|
306
|
-
match = line.match(/^(\s*)(\d+)\.\s+(.*)/)
|
|
307
|
-
next unless match
|
|
308
|
-
|
|
309
|
-
spaces = match[1]
|
|
310
|
-
|
|
311
|
-
content = match[3]
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
result << @styles[:code_border].render + "
|
|
362
|
-
end
|
|
363
|
-
|
|
364
|
-
#
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
return [] if
|
|
381
|
-
|
|
382
|
-
#
|
|
383
|
-
rows
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
#
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
current_width
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
result.gsub
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
end
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
end
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "style"
|
|
4
|
+
require_relative "segment"
|
|
5
|
+
require_relative "text"
|
|
6
|
+
require_relative "panel"
|
|
7
|
+
require_relative "box"
|
|
8
|
+
require_relative "cells"
|
|
9
|
+
|
|
10
|
+
module Rich
|
|
11
|
+
# Markdown rendering for terminal output.
|
|
12
|
+
# Parses and renders Markdown content with styling.
|
|
13
|
+
class Markdown
|
|
14
|
+
# Default styles for Markdown elements
|
|
15
|
+
DEFAULT_STYLES = {
|
|
16
|
+
# Headings
|
|
17
|
+
h1: Style.new(color: Color.parse("bright_cyan"), bold: true),
|
|
18
|
+
h2: Style.new(color: Color.parse("cyan"), bold: true),
|
|
19
|
+
h3: Style.new(color: Color.parse("bright_blue"), bold: true),
|
|
20
|
+
h4: Style.new(color: Color.parse("blue"), bold: true),
|
|
21
|
+
h5: Style.new(color: Color.parse("bright_magenta")),
|
|
22
|
+
h6: Style.new(color: Color.parse("magenta")),
|
|
23
|
+
|
|
24
|
+
# Text formatting
|
|
25
|
+
bold: Style.new(bold: true),
|
|
26
|
+
italic: Style.new(italic: true),
|
|
27
|
+
bold_italic: Style.new(bold: true, italic: true),
|
|
28
|
+
strikethrough: Style.new(strike: true),
|
|
29
|
+
code_inline: Style.new(color: Color.parse("bright_green"), bgcolor: Color.parse("color(236)")),
|
|
30
|
+
|
|
31
|
+
# Links and references
|
|
32
|
+
link: Style.new(color: Color.parse("bright_blue"), underline: true),
|
|
33
|
+
link_url: Style.new(color: Color.parse("blue"), dim: true),
|
|
34
|
+
|
|
35
|
+
# Lists
|
|
36
|
+
bullet: Style.new(color: Color.parse("yellow")),
|
|
37
|
+
list_number: Style.new(color: Color.parse("yellow")),
|
|
38
|
+
|
|
39
|
+
# Blockquotes
|
|
40
|
+
blockquote: Style.new(color: Color.parse("bright_black"), italic: true),
|
|
41
|
+
blockquote_border: Style.new(color: Color.parse("magenta")),
|
|
42
|
+
|
|
43
|
+
# Code blocks
|
|
44
|
+
code_block: Style.new(bgcolor: Color.parse("color(236)")),
|
|
45
|
+
code_border: Style.new(color: Color.parse("bright_black")),
|
|
46
|
+
|
|
47
|
+
# Horizontal rule
|
|
48
|
+
hr: Style.new(color: Color.parse("bright_black")),
|
|
49
|
+
|
|
50
|
+
# Table
|
|
51
|
+
table_header: Style.new(bold: true, color: Color.parse("cyan")),
|
|
52
|
+
table_border: Style.new(color: Color.parse("bright_black"))
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
# @return [String] Source markdown
|
|
56
|
+
attr_reader :source
|
|
57
|
+
|
|
58
|
+
# @return [Hash] Style configuration
|
|
59
|
+
attr_reader :styles
|
|
60
|
+
|
|
61
|
+
# @return [Boolean] Use hyperlinks
|
|
62
|
+
attr_reader :hyperlinks
|
|
63
|
+
|
|
64
|
+
# @return [Integer] Code block indent
|
|
65
|
+
attr_reader :code_indent
|
|
66
|
+
|
|
67
|
+
# Create a new Markdown renderer
|
|
68
|
+
# @param source [String] Markdown source text
|
|
69
|
+
# @param styles [Hash] Custom styles to override defaults
|
|
70
|
+
# @param hyperlinks [Boolean] Enable terminal hyperlinks
|
|
71
|
+
# @param code_indent [Integer] Indent for code blocks
|
|
72
|
+
def initialize(source, styles: {}, hyperlinks: true, code_indent: 4)
|
|
73
|
+
@source = source.to_s
|
|
74
|
+
@styles = DEFAULT_STYLES.merge(styles)
|
|
75
|
+
@hyperlinks = hyperlinks
|
|
76
|
+
@code_indent = code_indent
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Render markdown to string with ANSI codes
|
|
80
|
+
# @param max_width [Integer] Maximum width
|
|
81
|
+
# @return [String]
|
|
82
|
+
def render(max_width: 80)
|
|
83
|
+
lines = parse_and_render(max_width: max_width)
|
|
84
|
+
lines.join("\n")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Convert to segments
|
|
88
|
+
# @param max_width [Integer] Maximum width
|
|
89
|
+
# @return [Array<Segment>]
|
|
90
|
+
def to_segments(max_width: 80)
|
|
91
|
+
segments = []
|
|
92
|
+
lines = parse_and_render(max_width: max_width)
|
|
93
|
+
|
|
94
|
+
lines.each_with_index do |line, i|
|
|
95
|
+
# Line is already a rendered string with ANSI codes
|
|
96
|
+
segments << Segment.new(line)
|
|
97
|
+
segments << Segment.new("\n") if i < lines.length - 1
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
segments
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
class << self
|
|
104
|
+
# Render markdown from string
|
|
105
|
+
# @param source [String] Markdown text
|
|
106
|
+
# @param kwargs [Hash] Options
|
|
107
|
+
# @return [String]
|
|
108
|
+
def render(source, **kwargs)
|
|
109
|
+
new(source, **kwargs).render(**kwargs)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Render markdown from file
|
|
113
|
+
# @param path [String] File path
|
|
114
|
+
# @param kwargs [Hash] Options
|
|
115
|
+
# @return [String]
|
|
116
|
+
def from_file(path, **kwargs)
|
|
117
|
+
source = File.read(path)
|
|
118
|
+
new(source, **kwargs)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
# Parse and render markdown
|
|
125
|
+
# @param max_width [Integer] Maximum width
|
|
126
|
+
# @return [Array<String>]
|
|
127
|
+
def parse_and_render(max_width:)
|
|
128
|
+
lines = @source.lines.map(&:chomp)
|
|
129
|
+
output = []
|
|
130
|
+
i = 0
|
|
131
|
+
|
|
132
|
+
while i < lines.length
|
|
133
|
+
line = lines[i]
|
|
134
|
+
|
|
135
|
+
# Blank line
|
|
136
|
+
if line.strip.empty?
|
|
137
|
+
output << ""
|
|
138
|
+
i += 1
|
|
139
|
+
next
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Fenced code block
|
|
143
|
+
if line.match?(/^```/)
|
|
144
|
+
lang = line[3..].strip
|
|
145
|
+
code_lines = []
|
|
146
|
+
i += 1
|
|
147
|
+
while i < lines.length && !lines[i].start_with?("```")
|
|
148
|
+
code_lines << lines[i]
|
|
149
|
+
i += 1
|
|
150
|
+
end
|
|
151
|
+
output.concat(render_code_block(code_lines.join("\n"), lang, max_width))
|
|
152
|
+
i += 1
|
|
153
|
+
next
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Heading
|
|
157
|
+
if line.match?(%r{^\#{1,6}\s})
|
|
158
|
+
output.concat(render_heading(line, max_width))
|
|
159
|
+
i += 1
|
|
160
|
+
next
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Horizontal rule
|
|
164
|
+
if line.match?(/^[-*_]{3,}\s*$/)
|
|
165
|
+
output << render_hr(max_width)
|
|
166
|
+
i += 1
|
|
167
|
+
next
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Unordered list
|
|
171
|
+
if line.match?(/^\s*[-*+]\s/)
|
|
172
|
+
list_lines = [line]
|
|
173
|
+
i += 1
|
|
174
|
+
while i < lines.length && (lines[i].match?(/^\s*[-*+]\s/) || lines[i].match?(/^\s{2,}/))
|
|
175
|
+
list_lines << lines[i]
|
|
176
|
+
i += 1
|
|
177
|
+
end
|
|
178
|
+
output.concat(render_unordered_list(list_lines, max_width))
|
|
179
|
+
next
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Ordered list
|
|
183
|
+
if line.match?(/^\s*\d+\.\s/)
|
|
184
|
+
list_lines = [line]
|
|
185
|
+
i += 1
|
|
186
|
+
while i < lines.length && (lines[i].match?(/^\s*\d+\.\s/) || lines[i].match?(/^\s{2,}/))
|
|
187
|
+
list_lines << lines[i]
|
|
188
|
+
i += 1
|
|
189
|
+
end
|
|
190
|
+
output.concat(render_ordered_list(list_lines, max_width))
|
|
191
|
+
next
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Blockquote
|
|
195
|
+
if line.start_with?(">")
|
|
196
|
+
quote_lines = [line]
|
|
197
|
+
i += 1
|
|
198
|
+
while i < lines.length && (lines[i].start_with?(">") || (!lines[i].strip.empty? && !lines[i].match?(/^[#\-*+\d]/)))
|
|
199
|
+
quote_lines << lines[i]
|
|
200
|
+
i += 1
|
|
201
|
+
end
|
|
202
|
+
output.concat(render_blockquote(quote_lines, max_width))
|
|
203
|
+
next
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Table
|
|
207
|
+
if line.include?("|") && i + 1 < lines.length && lines[i + 1].match?(/^\|?\s*[-:]+/)
|
|
208
|
+
table_lines = [line]
|
|
209
|
+
i += 1
|
|
210
|
+
while i < lines.length && lines[i].include?("|")
|
|
211
|
+
table_lines << lines[i]
|
|
212
|
+
i += 1
|
|
213
|
+
end
|
|
214
|
+
output.concat(render_table(table_lines, max_width))
|
|
215
|
+
next
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Regular paragraph
|
|
219
|
+
para_lines = [line]
|
|
220
|
+
i += 1
|
|
221
|
+
while i < lines.length && !lines[i].strip.empty? && !lines[i].match?(/^[\#\-*+>\d`|]/)
|
|
222
|
+
para_lines << lines[i]
|
|
223
|
+
i += 1
|
|
224
|
+
end
|
|
225
|
+
output.concat(render_paragraph(para_lines.join(" "), max_width))
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
output
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Render a heading
|
|
232
|
+
def render_heading(line, max_width)
|
|
233
|
+
match = line.match(%r{^(\#{1,6})\s+(.*)})
|
|
234
|
+
return [line] unless match
|
|
235
|
+
|
|
236
|
+
level = match[1].length
|
|
237
|
+
text = match[2]
|
|
238
|
+
|
|
239
|
+
style = @styles[:"h#{level}"] || @styles[:h1]
|
|
240
|
+
styled_text = apply_inline_styles(text)
|
|
241
|
+
|
|
242
|
+
result = []
|
|
243
|
+
|
|
244
|
+
# Add decorations based on level
|
|
245
|
+
case level
|
|
246
|
+
when 1
|
|
247
|
+
border = style.render + ("=" * [text.length + 4, max_width].min) + "\e[0m"
|
|
248
|
+
result << border
|
|
249
|
+
result << style.render + " #{styled_text} " + "\e[0m"
|
|
250
|
+
result << border
|
|
251
|
+
when 2
|
|
252
|
+
result << style.render + styled_text + "\e[0m"
|
|
253
|
+
result << style.render + ("-" * [text.length, max_width].min) + "\e[0m"
|
|
254
|
+
else
|
|
255
|
+
prefix = "#" * level + " "
|
|
256
|
+
result << style.render + prefix + styled_text + "\e[0m"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
result << ""
|
|
260
|
+
result
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Render horizontal rule
|
|
264
|
+
def render_hr(max_width)
|
|
265
|
+
@styles[:hr].render + ("─" * max_width) + "\e[0m"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Render unordered list
|
|
269
|
+
def render_unordered_list(lines, max_width)
|
|
270
|
+
result = []
|
|
271
|
+
indent = 0
|
|
272
|
+
|
|
273
|
+
lines.each do |line|
|
|
274
|
+
match = line.match(/^(\s*)([-*+])\s+(.*)/)
|
|
275
|
+
next unless match
|
|
276
|
+
|
|
277
|
+
spaces = match[1]
|
|
278
|
+
content = match[3]
|
|
279
|
+
|
|
280
|
+
# Calculate indent level
|
|
281
|
+
indent = spaces.length / 2
|
|
282
|
+
|
|
283
|
+
bullet_char = case indent
|
|
284
|
+
when 0 then "•"
|
|
285
|
+
when 1 then "◦"
|
|
286
|
+
else "▪"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
prefix = " " * indent
|
|
290
|
+
bullet = @styles[:bullet].render + bullet_char + "\e[0m "
|
|
291
|
+
styled_content = apply_inline_styles(content)
|
|
292
|
+
|
|
293
|
+
result << prefix + bullet + styled_content
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
result << ""
|
|
297
|
+
result
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Render ordered list
|
|
301
|
+
def render_ordered_list(lines, max_width)
|
|
302
|
+
result = []
|
|
303
|
+
counters = {} # indent level => current number
|
|
304
|
+
|
|
305
|
+
lines.each do |line|
|
|
306
|
+
match = line.match(/^(\s*)(\d+)\.\s+(.*)/)
|
|
307
|
+
next unless match
|
|
308
|
+
|
|
309
|
+
spaces = match[1]
|
|
310
|
+
explicit = match[2].to_i
|
|
311
|
+
content = match[3]
|
|
312
|
+
indent = spaces.length / 2
|
|
313
|
+
|
|
314
|
+
# Dedenting ends any deeper sub-lists, so their counters reset.
|
|
315
|
+
counters.keys.select { |level| level > indent }.each { |level| counters.delete(level) }
|
|
316
|
+
|
|
317
|
+
# Honor the author's starting number for the first item at each level;
|
|
318
|
+
# increment per level thereafter.
|
|
319
|
+
counters[indent] = counters.key?(indent) ? counters[indent] + 1 : explicit
|
|
320
|
+
|
|
321
|
+
prefix = " " * indent
|
|
322
|
+
num = @styles[:list_number].render + "#{counters[indent]}." + "\e[0m "
|
|
323
|
+
styled_content = apply_inline_styles(content)
|
|
324
|
+
|
|
325
|
+
result << prefix + num + styled_content
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
result << ""
|
|
329
|
+
result
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Render blockquote
|
|
333
|
+
def render_blockquote(lines, max_width)
|
|
334
|
+
result = []
|
|
335
|
+
border_style = @styles[:blockquote_border]
|
|
336
|
+
text_style = @styles[:blockquote]
|
|
337
|
+
|
|
338
|
+
lines.each do |line|
|
|
339
|
+
content = line.sub(/^>\s*/, "")
|
|
340
|
+
styled = text_style.render + apply_inline_styles(content) + "\e[0m"
|
|
341
|
+
result << border_style.render + "│ " + "\e[0m" + styled
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
result << ""
|
|
345
|
+
result
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Render code block
|
|
349
|
+
def render_code_block(code, language, max_width)
|
|
350
|
+
result = []
|
|
351
|
+
indent = " " * @code_indent
|
|
352
|
+
style = @styles[:code_block]
|
|
353
|
+
|
|
354
|
+
# Header with language
|
|
355
|
+
if language && !language.empty?
|
|
356
|
+
lang_display = " #{language} "
|
|
357
|
+
result << @styles[:code_border].render + "┌" + ("─" * (max_width - 2)) + "┐" + "\e[0m"
|
|
358
|
+
result << @styles[:code_border].render + "│" + "\e[0m" + " " + lang_display.ljust(max_width - 4) + @styles[:code_border].render + " │" + "\e[0m"
|
|
359
|
+
result << @styles[:code_border].render + "├" + ("─" * (max_width - 2)) + "┤" + "\e[0m"
|
|
360
|
+
else
|
|
361
|
+
result << @styles[:code_border].render + "┌" + ("─" * (max_width - 2)) + "┐" + "\e[0m"
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Code lines
|
|
365
|
+
code.each_line do |line|
|
|
366
|
+
line = line.chomp
|
|
367
|
+
padded = line.ljust(max_width - 4)
|
|
368
|
+
result << @styles[:code_border].render + "│ " + "\e[0m" + style.render + padded + "\e[0m" + @styles[:code_border].render + " │" + "\e[0m"
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Footer
|
|
372
|
+
result << @styles[:code_border].render + "└" + ("─" * (max_width - 2)) + "┘" + "\e[0m"
|
|
373
|
+
result << ""
|
|
374
|
+
|
|
375
|
+
result
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Render table
|
|
379
|
+
def render_table(lines, max_width)
|
|
380
|
+
return [] if lines.empty?
|
|
381
|
+
|
|
382
|
+
# Parse table
|
|
383
|
+
rows = lines.map do |line|
|
|
384
|
+
line.split("|").map(&:strip).reject(&:empty?)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
return [] if rows.empty?
|
|
388
|
+
|
|
389
|
+
# Skip separator row
|
|
390
|
+
rows.reject! { |row| row.all? { |cell| cell.match?(/^[-:]+$/) } }
|
|
391
|
+
return [] if rows.empty?
|
|
392
|
+
|
|
393
|
+
header = rows.first
|
|
394
|
+
body = rows[1..]
|
|
395
|
+
|
|
396
|
+
# Calculate column widths
|
|
397
|
+
col_widths = header.map(&:length)
|
|
398
|
+
body&.each do |row|
|
|
399
|
+
row.each_with_index do |cell, i|
|
|
400
|
+
col_widths[i] = [col_widths[i] || 0, cell.length].max
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
result = []
|
|
405
|
+
border_style = @styles[:table_border]
|
|
406
|
+
header_style = @styles[:table_header]
|
|
407
|
+
|
|
408
|
+
# Top border
|
|
409
|
+
top = col_widths.map { |w| "─" * (w + 2) }.join("┬")
|
|
410
|
+
result << border_style.render + "┌" + top + "┐" + "\e[0m"
|
|
411
|
+
|
|
412
|
+
# Header row
|
|
413
|
+
header_cells = header.each_with_index.map do |cell, i|
|
|
414
|
+
" " + header_style.render + cell.ljust(col_widths[i]) + "\e[0m" + " "
|
|
415
|
+
end
|
|
416
|
+
result << border_style.render + "│" + "\e[0m" + header_cells.join(border_style.render + "│" + "\e[0m") + border_style.render + "│" + "\e[0m"
|
|
417
|
+
|
|
418
|
+
# Header separator
|
|
419
|
+
sep = col_widths.map { |w| "━" * (w + 2) }.join("┿")
|
|
420
|
+
result << border_style.render + "┝" + sep + "┥" + "\e[0m"
|
|
421
|
+
|
|
422
|
+
# Body rows
|
|
423
|
+
body&.each do |row|
|
|
424
|
+
cells = row.each_with_index.map do |cell, i|
|
|
425
|
+
width = col_widths[i] || cell.length
|
|
426
|
+
" " + apply_inline_styles(cell).ljust(width) + " "
|
|
427
|
+
end
|
|
428
|
+
# Pad missing cells
|
|
429
|
+
while cells.length < col_widths.length
|
|
430
|
+
cells << " " * (col_widths[cells.length] + 2)
|
|
431
|
+
end
|
|
432
|
+
result << border_style.render + "│" + "\e[0m" + cells.join(border_style.render + "│" + "\e[0m") + border_style.render + "│" + "\e[0m"
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Bottom border
|
|
436
|
+
bottom = col_widths.map { |w| "─" * (w + 2) }.join("┴")
|
|
437
|
+
result << border_style.render + "└" + bottom + "┘" + "\e[0m"
|
|
438
|
+
|
|
439
|
+
result << ""
|
|
440
|
+
result
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Render paragraph
|
|
444
|
+
def render_paragraph(text, max_width)
|
|
445
|
+
styled = apply_inline_styles(text)
|
|
446
|
+
|
|
447
|
+
# Word wrap
|
|
448
|
+
words = styled.split(/(\s+)/)
|
|
449
|
+
lines = []
|
|
450
|
+
current_line = ""
|
|
451
|
+
current_width = 0
|
|
452
|
+
|
|
453
|
+
words.each do |word|
|
|
454
|
+
word_width = Cells.cell_len(Control.strip_ansi(word))
|
|
455
|
+
|
|
456
|
+
if current_width + word_width > max_width && !current_line.empty?
|
|
457
|
+
lines << current_line.rstrip
|
|
458
|
+
current_line = ""
|
|
459
|
+
current_width = 0
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
current_line += word
|
|
463
|
+
current_width += word_width
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
lines << current_line.rstrip unless current_line.empty?
|
|
467
|
+
lines << ""
|
|
468
|
+
|
|
469
|
+
lines
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# Apply inline styles (bold, italic, code, links)
|
|
473
|
+
def apply_inline_styles(text)
|
|
474
|
+
result = text.dup
|
|
475
|
+
|
|
476
|
+
# Protect inline code spans FIRST: their contents are literal and must not
|
|
477
|
+
# be reinterpreted as emphasis/strikethrough. Swap them for placeholders
|
|
478
|
+
# and restore (rendered) after all other inline rules have run.
|
|
479
|
+
code_spans = []
|
|
480
|
+
result = result.gsub(/`([^`]+)`/) do
|
|
481
|
+
code_spans << ::Regexp.last_match(1)
|
|
482
|
+
"CODE#{code_spans.length - 1}"
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Bold italic (***text*** or ___text___)
|
|
486
|
+
result.gsub!(/(\*\*\*|___)([^*_]+)\1/) do
|
|
487
|
+
@styles[:bold_italic].render + ::Regexp.last_match(2) + "\e[0m"
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Bold (**text** or __text__)
|
|
491
|
+
result.gsub!(/(\*\*|__)([^*_]+)\1/) do
|
|
492
|
+
@styles[:bold].render + ::Regexp.last_match(2) + "\e[0m"
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Italic with asterisks: *text*
|
|
496
|
+
result.gsub!(/\*([^*]+)\*/) do
|
|
497
|
+
@styles[:italic].render + ::Regexp.last_match(1) + "\e[0m"
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Italic with underscores: only when the underscores sit at word
|
|
501
|
+
# boundaries, so identifiers like some_var_name are left untouched.
|
|
502
|
+
result.gsub!(/(?<![\w])_([^_]+)_(?![\w])/) do
|
|
503
|
+
@styles[:italic].render + ::Regexp.last_match(1) + "\e[0m"
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# Strikethrough (~~text~~)
|
|
507
|
+
result.gsub!(/~~([^~]+)~~/) do
|
|
508
|
+
@styles[:strikethrough].render + ::Regexp.last_match(1) + "\e[0m"
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Restore protected inline code spans, now rendered.
|
|
512
|
+
result = result.gsub(/CODE(\d+)/) do
|
|
513
|
+
@styles[:code_inline].render + code_spans[::Regexp.last_match(1).to_i] + "\e[0m"
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Links [text](url)
|
|
517
|
+
result.gsub!(/\[([^\]]+)\]\(([^)]+)\)/) do
|
|
518
|
+
text_part = ::Regexp.last_match(1)
|
|
519
|
+
url = ::Regexp.last_match(2)
|
|
520
|
+
|
|
521
|
+
if @hyperlinks
|
|
522
|
+
@styles[:link].render + Control.hyperlink(url, text_part) + "\e[0m"
|
|
523
|
+
else
|
|
524
|
+
@styles[:link].render + text_part + "\e[0m" + " (" + @styles[:link_url].render + url + "\e[0m" + ")"
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
result
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
end
|