Class: Rubino::UI::MarkdownRenderer

Inherits:
Object
  • Object
show all
Defined in:
lib/rubino/ui/markdown_renderer.rb

Overview

Renders a markdown string into a list of styled token-lines.

Output shape:

render(text) -> [LineTokens, LineTokens, ...]
LineTokens   = [[String, StyleHash], ...]
StyleHash    = { fg:, bg:, modifiers: [...] } (any subset; nil ≈ default)

The caller turns these into ANSI-colored strings via Pastel. Keeping the output as plain Ruby data lets the renderer be tested without a real terminal.

Coverage: headings 1-3, paragraphs, bold, italic, ‘inline code`, “`fenced“` code blocks, ordered/unordered lists (one level), block quotes, [links](url), horizontal rules. Anything unrecognized falls back to its raw text content, never blowing up.

Constant Summary collapse

MIN_TABLE_WIDTH =

Smallest width we’ll ask TTY::Table to fit into. Below this, resize tends to raise (a column needs at least ~2 cols + borders); we clamp up to keep the headless/extreme-narrow paths from blowing up.

20
DEFAULT_WIDTH =
80

Instance Method Summary collapse

Constructor Details

#initialize(width: nil) ⇒ MarkdownRenderer

Returns a new instance of MarkdownRenderer.

Parameters:

  • width (Integer, nil) (defaults to: nil)

    the column budget tables must fit into. When nil we detect the terminal width (IO.console winsize), falling back to 80 so the renderer still works headless / without a real terminal.



46
47
48
# File 'lib/rubino/ui/markdown_renderer.rb', line 46

def initialize(width: nil)
  @width = width || detect_width
end

Instance Method Details

#render(text) ⇒ Object



50
51
52
53
54
55
56
57
58
59
# File 'lib/rubino/ui/markdown_renderer.rb', line 50

def render(text)
  return [] if text.nil? || text.to_s.strip.empty?

  src = unwrap_markdown_wrapper(text.to_s)
  doc = Kramdown::Document.new(normalize(src), input: "GFM", auto_ids: false, hard_wrap: false)
  block_lines(doc.root).reject { |line| line == :drop }
rescue StandardError
  # Parser failure -> degrade to plain text rather than break the UI.
  text.to_s.split("\n", -1).map { |l| [[l, nil]] }
end

#render_partial_table(lines, max_rows: nil) ⇒ Object

Render a GROWING (partial) GFM table for the streaming live region — the header/separator plus the already-completed data rows (the in-flight last row is dropped upstream by StreamingMarkdown#table_rows_so_far). The same fitted, width-clamped, border-correct path the committed table uses (block_lines -> table_lines -> balanced_column_widths), so the partial never mid-cell soft-wraps and matches the final snap.

The live region is bounded (it must never push the prompt off-screen): max_rows caps the visible DATA rows. When the table-so-far is taller, only the header + the LAST max_rows data rows render (the user watches the bottom of the table fill in), with the full table snapping in on completion via the committed path. Returns [] until a separator row has arrived (nothing meaningful to draw yet — “hide until it means something”).



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/rubino/ui/markdown_renderer.rb', line 74

def render_partial_table(lines, max_rows: nil)
  rows = Array(lines)
  sep_idx = rows.index { |l| l.to_s.match?(TABLE_SEP_RE) }
  return [] if sep_idx.nil?

  head = rows[0..sep_idx] # header row(s) + separator
  data = rows[(sep_idx + 1)..] || []
  # No completed data row yet: kramdown won't parse a header+separator with
  # an empty body AS a table (it degrades to raw `| h | h |` text + an
  # em-dash separator), which is the very raw-pipe leak we're killing. Draw
  # nothing until the first data row arrives — "hide until it means
  # something"; the row in flight shows the moment it completes.
  return [] if data.empty?

  data = data.last(max_rows) if max_rows && data.size > max_rows
  render([*head, *data].join("\n"))
end