asciinema_win 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +575 -0
- data/exe/asciinema_win +17 -0
- data/lib/asciinema_win/ansi_parser.rb +437 -0
- data/lib/asciinema_win/asciicast.rb +537 -0
- data/lib/asciinema_win/cli.rb +591 -0
- data/lib/asciinema_win/export.rb +780 -0
- data/lib/asciinema_win/output_organizer.rb +276 -0
- data/lib/asciinema_win/player.rb +348 -0
- data/lib/asciinema_win/recorder.rb +480 -0
- data/lib/asciinema_win/screen_buffer.rb +375 -0
- data/lib/asciinema_win/themes.rb +334 -0
- data/lib/asciinema_win/version.rb +6 -0
- data/lib/asciinema_win.rb +153 -0
- data/lib/rich/_palettes.rb +148 -0
- data/lib/rich/box.rb +342 -0
- data/lib/rich/cells.rb +512 -0
- data/lib/rich/color.rb +628 -0
- data/lib/rich/color_triplet.rb +220 -0
- data/lib/rich/console.rb +549 -0
- data/lib/rich/control.rb +332 -0
- data/lib/rich/json.rb +254 -0
- data/lib/rich/layout.rb +314 -0
- data/lib/rich/markdown.rb +509 -0
- data/lib/rich/markup.rb +175 -0
- data/lib/rich/panel.rb +311 -0
- data/lib/rich/progress.rb +430 -0
- data/lib/rich/segment.rb +387 -0
- data/lib/rich/style.rb +433 -0
- data/lib/rich/syntax.rb +1145 -0
- data/lib/rich/table.rb +525 -0
- data/lib/rich/terminal_theme.rb +126 -0
- data/lib/rich/text.rb +433 -0
- data/lib/rich/tree.rb +220 -0
- data/lib/rich/version.rb +5 -0
- data/lib/rich/win32_console.rb +859 -0
- data/lib/rich.rb +108 -0
- metadata +123 -0
|
@@ -0,0 +1,509 @@
|
|
|
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
|
data/lib/rich/markup.rb
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "style"
|
|
4
|
+
require_relative "text"
|
|
5
|
+
|
|
6
|
+
module Rich
|
|
7
|
+
# Markup parsing error
|
|
8
|
+
class MarkupError < StandardError
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Parser for Rich markup syntax: [style]text[/style]
|
|
12
|
+
module Markup
|
|
13
|
+
# Tag regex for matching markup tags, excluding escaped ones
|
|
14
|
+
TAG_REGEX = /(?<!\\)\[(?<closing>\/)?(?<tag>[^\[\]\/]*)\]/
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# Parse markup into a Text object
|
|
18
|
+
# @param markup [String] Markup text
|
|
19
|
+
# @param style [Style, String, nil] Base style
|
|
20
|
+
# @return [Text]
|
|
21
|
+
def parse(markup, style: nil)
|
|
22
|
+
result_text = Text.new(style: style)
|
|
23
|
+
style_stack = []
|
|
24
|
+
pos = 0
|
|
25
|
+
|
|
26
|
+
markup.scan(TAG_REGEX) do
|
|
27
|
+
match = Regexp.last_match
|
|
28
|
+
tag_start = match.begin(0)
|
|
29
|
+
|
|
30
|
+
# Add text before tag
|
|
31
|
+
if tag_start > pos
|
|
32
|
+
pre_text = unescape(markup[pos...tag_start])
|
|
33
|
+
start_pos = result_text.length
|
|
34
|
+
result_text.append(pre_text)
|
|
35
|
+
|
|
36
|
+
# Apply stacked styles to this text
|
|
37
|
+
style_stack.each do |stacked_style|
|
|
38
|
+
result_text.spans << Span.new(start_pos, result_text.length, stacked_style)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Process tag
|
|
43
|
+
if match[:closing]
|
|
44
|
+
# Closing tag - pop style
|
|
45
|
+
style_stack.pop unless style_stack.empty?
|
|
46
|
+
else
|
|
47
|
+
# Opening tag - parse and push style
|
|
48
|
+
tag_content = match[:tag].strip
|
|
49
|
+
if tag_content.empty?
|
|
50
|
+
# Literal []
|
|
51
|
+
result_text.append("[]")
|
|
52
|
+
else
|
|
53
|
+
begin
|
|
54
|
+
parsed_style = Style.parse(tag_content)
|
|
55
|
+
style_stack << parsed_style
|
|
56
|
+
rescue StandardError
|
|
57
|
+
# Invalid style, treat as literal text
|
|
58
|
+
result_text.append("[#{tag_content}]")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
pos = match.end(0)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Add remaining text
|
|
67
|
+
if pos < markup.length
|
|
68
|
+
remaining = unescape(markup[pos..])
|
|
69
|
+
start_pos = result_text.length
|
|
70
|
+
result_text.append(remaining)
|
|
71
|
+
|
|
72
|
+
style_stack.each do |stacked_style|
|
|
73
|
+
result_text.spans << Span.new(start_pos, result_text.length, stacked_style)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
result_text
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Render markup directly to ANSI string
|
|
81
|
+
# @param markup [String] Markup text
|
|
82
|
+
# @param color_system [Symbol] Color system
|
|
83
|
+
# @return [String]
|
|
84
|
+
def render(markup, color_system: ColorSystem::TRUECOLOR)
|
|
85
|
+
parse(markup).render(color_system: color_system)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Escape text for use in markup (escape square brackets)
|
|
89
|
+
# @param text [String] Text to escape
|
|
90
|
+
# @return [String]
|
|
91
|
+
def escape(text)
|
|
92
|
+
text.gsub(/[\[\]]/) { |m| "\\#{m}" }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Unescape markup text
|
|
96
|
+
# @param text [String] Text to unescape
|
|
97
|
+
# @return [String]
|
|
98
|
+
def unescape(text)
|
|
99
|
+
text.gsub(/\\([\[\]\\])/, '\1')
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Strip markup tags from text
|
|
103
|
+
# @param markup [String] Markup text
|
|
104
|
+
# @return [String]
|
|
105
|
+
def strip(markup)
|
|
106
|
+
markup.gsub(TAG_REGEX, "")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Check if text contains markup
|
|
110
|
+
# @param text [String] Text to check
|
|
111
|
+
# @return [Boolean]
|
|
112
|
+
def contains_markup?(text)
|
|
113
|
+
text.match?(TAG_REGEX)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Extract all tags from markup
|
|
117
|
+
# @param markup [String] Markup text
|
|
118
|
+
# @return [Array<Hash>] Array of tag info
|
|
119
|
+
def extract_tags(markup)
|
|
120
|
+
tags = []
|
|
121
|
+
|
|
122
|
+
markup.scan(TAG_REGEX) do
|
|
123
|
+
match = Regexp.last_match
|
|
124
|
+
tags << {
|
|
125
|
+
position: match.begin(0),
|
|
126
|
+
closing: !match[:closing].nil?,
|
|
127
|
+
tag: match[:tag].to_s.strip,
|
|
128
|
+
full_match: match[0]
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
tags
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Validate markup (check for unclosed tags)
|
|
136
|
+
# @param markup [String] Markup to validate
|
|
137
|
+
# @return [Array<String>] List of errors (empty if valid)
|
|
138
|
+
def validate(markup)
|
|
139
|
+
errors = []
|
|
140
|
+
open_tags = []
|
|
141
|
+
|
|
142
|
+
extract_tags(markup).each do |tag|
|
|
143
|
+
if tag[:closing]
|
|
144
|
+
if open_tags.empty?
|
|
145
|
+
errors << "Unexpected closing tag [/#{tag[:tag]}] at position #{tag[:position]}"
|
|
146
|
+
else
|
|
147
|
+
# In Rich, [/] closes the LAST tag, [ /tag] closes specific tag
|
|
148
|
+
# Let's keep it simple for now: pop last.
|
|
149
|
+
# If tag name matches, pop it. If it doesn't match and not empty, it's an error.
|
|
150
|
+
last_tag = open_tags.pop
|
|
151
|
+
if !tag[:tag].empty? && tag[:tag] != last_tag
|
|
152
|
+
errors << "Mismatched closing tag [/#{tag[:tag]}] for [#{last_tag}]"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
else
|
|
156
|
+
open_tags << tag[:tag]
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
open_tags.each do |tag|
|
|
161
|
+
errors << "Unclosed tag [#{tag}]"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
errors
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Check if markup is valid
|
|
168
|
+
# @param markup [String] Markup to check
|
|
169
|
+
# @return [Boolean]
|
|
170
|
+
def valid?(markup)
|
|
171
|
+
validate(markup).empty?
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|