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.
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
- counter = 0
304
-
305
- lines.each do |line|
306
- match = line.match(/^(\s*)(\d+)\.\s+(.*)/)
307
- next unless match
308
-
309
- spaces = match[1]
310
- counter += 1
311
- content = match[3]
312
-
313
- indent = spaces.length / 2
314
- prefix = " " * indent
315
- num = @styles[:list_number].render + "#{counter}." + "\e[0m "
316
- styled_content = apply_inline_styles(content)
317
-
318
- result << prefix + num + styled_content
319
- end
320
-
321
- result << ""
322
- result
323
- end
324
-
325
- # Render blockquote
326
- def render_blockquote(lines, max_width)
327
- result = []
328
- border_style = @styles[:blockquote_border]
329
- text_style = @styles[:blockquote]
330
-
331
- lines.each do |line|
332
- content = line.sub(/^>\s*/, "")
333
- styled = text_style.render + apply_inline_styles(content) + "\e[0m"
334
- result << border_style.render + "│ " + "\e[0m" + styled
335
- end
336
-
337
- result << ""
338
- result
339
- end
340
-
341
- # Render code block
342
- def render_code_block(code, language, max_width)
343
- result = []
344
- indent = " " * @code_indent
345
- style = @styles[:code_block]
346
-
347
- # Header with language
348
- if language && !language.empty?
349
- lang_display = " #{language} "
350
- result << @styles[:code_border].render + "┌" + ("─" * (max_width - 2)) + "┐" + "\e[0m"
351
- result << @styles[:code_border].render + "│" + "\e[0m" + " " + lang_display.ljust(max_width - 4) + @styles[:code_border].render + " │" + "\e[0m"
352
- result << @styles[:code_border].render + "├" + ("─" * (max_width - 2)) + "┤" + "\e[0m"
353
- else
354
- result << @styles[:code_border].render + "┌" + ("─" * (max_width - 2)) + "┐" + "\e[0m"
355
- end
356
-
357
- # Code lines
358
- code.each_line do |line|
359
- line = line.chomp
360
- padded = line.ljust(max_width - 4)
361
- result << @styles[:code_border].render + "" + "\e[0m" + style.render + padded + "\e[0m" + @styles[:code_border].render + " │" + "\e[0m"
362
- end
363
-
364
- # Footer
365
- result << @styles[:code_border].render + "└" + ("─" * (max_width - 2)) + "┘" + "\e[0m"
366
- result << ""
367
-
368
- result
369
- end
370
-
371
- # Render table
372
- def render_table(lines, max_width)
373
- return [] if lines.empty?
374
-
375
- # Parse table
376
- rows = lines.map do |line|
377
- line.split("|").map(&:strip).reject(&:empty?)
378
- end
379
-
380
- return [] if rows.empty?
381
-
382
- # Skip separator row
383
- rows.reject! { |row| row.all? { |cell| cell.match?(/^[-:]+$/) } }
384
- return [] if rows.empty?
385
-
386
- header = rows.first
387
- body = rows[1..]
388
-
389
- # Calculate column widths
390
- col_widths = header.map(&:length)
391
- body&.each do |row|
392
- row.each_with_index do |cell, i|
393
- col_widths[i] = [col_widths[i] || 0, cell.length].max
394
- end
395
- end
396
-
397
- result = []
398
- border_style = @styles[:table_border]
399
- header_style = @styles[:table_header]
400
-
401
- # Top border
402
- top = col_widths.map { |w| "─" * (w + 2) }.join("┬")
403
- result << border_style.render + "┌" + top + "┐" + "\e[0m"
404
-
405
- # Header row
406
- header_cells = header.each_with_index.map do |cell, i|
407
- " " + header_style.render + cell.ljust(col_widths[i]) + "\e[0m" + " "
408
- end
409
- result << border_style.render + "│" + "\e[0m" + header_cells.join(border_style.render + "│" + "\e[0m") + border_style.render + "" + "\e[0m"
410
-
411
- # Header separator
412
- sep = col_widths.map { |w| "━" * (w + 2) }.join("┿")
413
- result << border_style.render + "┝" + sep + "┥" + "\e[0m"
414
-
415
- # Body rows
416
- body&.each do |row|
417
- cells = row.each_with_index.map do |cell, i|
418
- width = col_widths[i] || cell.length
419
- " " + apply_inline_styles(cell).ljust(width) + " "
420
- end
421
- # Pad missing cells
422
- while cells.length < col_widths.length
423
- cells << " " * (col_widths[cells.length] + 2)
424
- end
425
- result << border_style.render + "│" + "\e[0m" + cells.join(border_style.render + "│" + "\e[0m") + border_style.render + "│" + "\e[0m"
426
- end
427
-
428
- # Bottom border
429
- bottom = col_widths.map { |w| "─" * (w + 2) }.join("┴")
430
- result << border_style.render + "└" + bottom + "┘" + "\e[0m"
431
-
432
- result << ""
433
- result
434
- end
435
-
436
- # Render paragraph
437
- def render_paragraph(text, max_width)
438
- styled = apply_inline_styles(text)
439
-
440
- # Word wrap
441
- words = styled.split(/(\s+)/)
442
- lines = []
443
- current_line = ""
444
- current_width = 0
445
-
446
- words.each do |word|
447
- word_width = Cells.cell_len(Control.strip_ansi(word))
448
-
449
- if current_width + word_width > max_width && !current_line.empty?
450
- lines << current_line.rstrip
451
- current_line = ""
452
- current_width = 0
453
- end
454
-
455
- current_line += word
456
- current_width += word_width
457
- end
458
-
459
- lines << current_line.rstrip unless current_line.empty?
460
- lines << ""
461
-
462
- lines
463
- end
464
-
465
- # Apply inline styles (bold, italic, code, links)
466
- def apply_inline_styles(text)
467
- result = text.dup
468
-
469
- # Bold italic (***text*** or ___text___)
470
- result.gsub!(/(\*\*\*|___)([^*_]+)\1/) do
471
- @styles[:bold_italic].render + ::Regexp.last_match(2) + "\e[0m"
472
- end
473
-
474
- # Bold (**text** or __text__)
475
- result.gsub!(/(\*\*|__)([^*_]+)\1/) do
476
- @styles[:bold].render + ::Regexp.last_match(2) + "\e[0m"
477
- end
478
-
479
- # Italic (*text* or _text_)
480
- result.gsub!(/(\*|_)([^*_]+)\1/) do
481
- @styles[:italic].render + ::Regexp.last_match(2) + "\e[0m"
482
- end
483
-
484
- # Strikethrough (~~text~~)
485
- result.gsub!(/~~([^~]+)~~/) do
486
- @styles[:strikethrough].render + ::Regexp.last_match(1) + "\e[0m"
487
- end
488
-
489
- # Inline code (`code`)
490
- result.gsub!(/`([^`]+)`/) do
491
- @styles[:code_inline].render + ::Regexp.last_match(1) + "\e[0m"
492
- end
493
-
494
- # Links [text](url)
495
- result.gsub!(/\[([^\]]+)\]\(([^)]+)\)/) do
496
- text_part = ::Regexp.last_match(1)
497
- url = ::Regexp.last_match(2)
498
-
499
- if @hyperlinks
500
- @styles[:link].render + Control.hyperlink(url, text_part) + "\e[0m"
501
- else
502
- @styles[:link].render + text_part + "\e[0m" + " (" + @styles[:link_url].render + url + "\e[0m" + ")"
503
- end
504
- end
505
-
506
- result
507
- end
508
- end
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