Module: RubyRich::Markdown::TerminalConverter::MermaidRenderer

Defined in:
lib/ruby_rich/markdown.rb

Overview

—- Mermaid diagram renderer —- Renders pie charts inline; other diagram types show source with a hint to install ‘mmdc` for full rendering.

Constant Summary collapse

BAR_MAX =
32
LEAF_BIN =
"leaf"

Class Method Summary collapse

Class Method Details

.AnsiCodeObject



1184
1185
1186
# File 'lib/ruby_rich/markdown.rb', line 1184

def self.AnsiCode
  ::RubyRich::AnsiCode
end

.detect_type(source) ⇒ Object



966
967
968
969
970
971
972
973
974
975
# File 'lib/ruby_rich/markdown.rb', line 966

def self.detect_type(source)
  first = source.lines.first&.strip&.downcase || ""
  return :pie if first.start_with?("pie")
  return :flowchart if first.start_with?("flowchart") || first.start_with?("graph")
  return :sequence  if first.start_with?("sequencediagram")
  return :class     if first.start_with?("classdiagram")
  return :gantt     if first.start_with?("gantt")
  return :state     if first.start_with?("statediagram")
  :generic
end

.leaf_available?Boolean

Returns:

  • (Boolean)


934
935
936
# File 'lib/ruby_rich/markdown.rb', line 934

def self.leaf_available?
  @leaf_available ||= system("which #{LEAF_BIN} > /dev/null 2>&1")
end

.parse_node(raw) ⇒ Object

Extract [id, label] from a node token like “A” or “B是否通过?”



1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
# File 'lib/ruby_rich/markdown.rb', line 1094

def self.parse_node(raw)
  raw = raw.strip
  # Square brackets
  if raw =~ /\A([A-Za-z0-9_]+)\s*\[(.+)\]\z/
    [$1, $2]
  # Curly braces (diamond)
  elsif raw =~ /\A([A-Za-z0-9_]+)\s*\{(.+)\}\z/
    [$1, $2]
  # Round parens
  elsif raw =~ /\A([A-Za-z0-9_]+)\s*\((.+)\)\z/
    [$1, $2]
  # Just an id
  elsif raw =~ /\A([A-Za-z0-9_]+)\z/
    [$1, $1]
  else
    [raw, raw]
  end
end

.render(source, width = 80) ⇒ Object



949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
# File 'lib/ruby_rich/markdown.rb', line 949

def self.render(source, width = 80)
  trimmed = source.strip
  return "" if trimmed.empty?

  type = detect_type(trimmed)
  case type
  when :pie
    render_pie(trimmed, width)
  when :flowchart, :sequence, :class, :gantt, :state, :generic
    # Prefer leaf for high-quality ASCII-art rendering
    result = render_via_leaf("```mermaid\n#{trimmed}\n```\n", width)
    result && !result.empty? ? result : render_fallback(trimmed, type, width)
  else
    render_fallback(trimmed, type, width)
  end
end

.render_fallback(source, type, width) ⇒ Object

Fallback: show diagram source with a labelled header.



1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
# File 'lib/ruby_rich/markdown.rb', line 1024

def self.render_fallback(source, type, width)
  label = type.to_s.capitalize
  pad = (width - label.length - 2).clamp(2, 60)
  lines = source.lines.map(&:chomp)
  [
    "#{tc(:code_border)}┌─ #{label} #{'' * pad}#{AnsiCode.reset}",
    *lines.map { |l| "#{tc(:code_border)}#{AnsiCode.reset} #{l}" },
    "#{tc(:code_border)}#{'' * (width - 2)}#{AnsiCode.reset}",
    "#{tc(:muted || :heading_4_6)}Install mmdc (npm i -g @mermaid-js/mermaid-cli) for full diagram rendering.#{AnsiCode.reset}",
  ].join("\n")
end

.render_flowchart(source, width) ⇒ Object

Flowchart / graph → edge-list rendering with node labels.



1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
# File 'lib/ruby_rich/markdown.rb', line 1037

def self.render_flowchart(source, width)
  lines = source.lines.map(&:chomp)
  # Build node registry: id => label
  nodes = {}
  edges = []

  lines.each do |line|
    stripped = line.strip
    next if stripped.empty?
    next if stripped.downcase.start_with?("flowchart", "graph")

    # Parse edge:  src ---|label|---> tgt
    m = stripped.match(
      /\A(.+?)\s*(-+>|==+>|-\.+>|=+>)\s*(\|(.*?)\|)?\s*(.+)\z/
    )
    if m
      src_raw = m[1].strip
      tgt_raw = m[5].strip
      arrow = m[2]
      label = m[4]&.strip

      src_id, src_lbl = parse_node(src_raw)
      tgt_id, tgt_lbl = parse_node(tgt_raw)

      # Only store shaped labels — don't let bare IDs overwrite them
      nodes[src_id] = src_lbl if src_lbl && src_raw =~ /[\[\(\{]/
      nodes[tgt_id] = tgt_lbl if tgt_lbl && tgt_raw =~ /[\[\(\{]/

      edges << {
        src: src_id, src_label: src_lbl || src_id,
        tgt: tgt_id, tgt_label: tgt_lbl || tgt_id,
        edge_label: label
      }
      next
    end

    # Standalone node definition:  id[text] / id{text} / id(text)
    nm = stripped.match(/\A([A-Za-z0-9_]+)\s*[\[\(\{].+[\]\)\}]\z/)
    if nm
      nid, nlbl = parse_node(stripped)
      nodes[nid] = nlbl if nlbl
    end
  end

  return "[Mermaid flowchart: no edges found]" if edges.empty?

  out = +""
  edges.each do |e|
    src = nodes[e[:src]] || e[:src_label]
    tgt = nodes[e[:tgt]] || e[:tgt_label]
    lbl = e[:edge_label] ? "#{e[:edge_label]}─▶ " : " ──▶ "
    out << "#{src}#{lbl}#{tgt}\n"
  end
  out.strip
end

.render_pie(source, width) ⇒ Object

Pie chart → horizontal bar chart with percentage labels.



978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
# File 'lib/ruby_rich/markdown.rb', line 978

def self.render_pie(source, width)
  title = ""
  entries = []
  source.each_line do |line|
    line = line.strip
    next if line.empty?
    if line.downcase.start_with?("pie")
      rest = line[3..].strip
      if rest.downcase.start_with?("title")
        title = rest[5..].strip
      end
      next
    end
    if line.downcase.start_with?("title")
      title = line[5..].strip
      next
    end
    # Parse "label" : value
    label_part, value_part = line.split(":", 2).map(&:strip)
    next unless label_part && value_part
    label = label_part.delete_prefix('"').delete_suffix('"')
    value = value_part.to_f
    entries << [label, value] if value > 0
  end

  return "[Mermaid pie: no data]" if entries.empty?

  total = entries.sum { |_l, v| v }
  return "[Mermaid pie: total is zero]" if total <= 0

  max_label = entries.map { |l, _| l.length }.max
  out = +""
  out << "#{tc(:heading_3)}#{title}#{AnsiCode.reset}\n" unless title.empty?

  entries.each do |label, value|
    pct = value / total * 100.0
    filled = (pct / 100.0 * BAR_MAX).round
    half = (pct / 100.0 * BAR_MAX * 2).round % 2 == 1
    bar = "" * filled + (half ? "" : "")
    out << sprintf("%-#{BAR_MAX + 1}s %-#{max_label}s %5.1f%%\n", bar, label, pct)
  end

  out.strip
end

.render_sequence(source, width) ⇒ Object

Sequence diagram → participant-message listing.



1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
# File 'lib/ruby_rich/markdown.rb', line 1114

def self.render_sequence(source, width)
  lines = source.lines.map(&:chomp)
  participants = []
  messages = []

  lines.each do |line|
    stripped = line.strip
    next if stripped.empty? || stripped.downcase.start_with?("sequencediagram")

    # participant / actor definition
    if stripped =~ /\A(?:participant|actor)\s+(.+)\z/i
      participants << $1.strip
      next
    end

    # Note
    if stripped =~ /\ANote\s+(?:over\s+)?(.+?):\s*(.+)\z/i
      messages << { type: :note, target: $1.strip, text: $2.strip }
      next
    end

    # Message:  A->>B: text  /  A-->>B: text  /  A-)B: text
    if stripped =~ /\A(.+?)\s*(-+>>?|-->>|-\)|-[xX])\s*(.+?)\s*:\s*(.+)\z/
      src  = $1.strip
      arrow_type = $2.strip
      tgt  = $3.strip
      text = $4.strip
      participants |= [src, tgt] unless participants.include?(src) && participants.include?(tgt)
      dashed = arrow_type.start_with?("--")
      messages << { type: :msg, src: src, tgt: tgt, text: text, dashed: dashed }
    end
  end

  return "[Mermaid sequence: no messages found]" if messages.empty?

  max_participant = participants.map(&:length).max
  max_participant = 8 if max_participant < 8

  out = +""
  participants.each do |p|
    out << sprintf("%-#{max_participant + 4}s", "[#{p}]")
  end
  out << "\n#{'' * ((max_participant + 4) * participants.size)}\n"

  messages.each do |m|
    case m[:type]
    when :note
      out << "  📝 #{m[:target]}: #{m[:text]}\n"
    when :msg
      src_idx = participants.index(m[:src]) || 0
      tgt_idx = participants.index(m[:tgt]) || participants.size - 1
      rightward = src_idx <= tgt_idx
      if m[:dashed]
        arrow = rightward ? "╌╌▶" : "◀╌╌"
      else
        arrow = rightward ? "──▶" : "◀──"
      end
      out << sprintf("  %-#{max_participant}s #{arrow} %s: %s\n",
                     m[:src], m[:tgt], m[:text])
    end
  end
  out.strip
end

.render_via_leaf(source, width) ⇒ Object



938
939
940
941
942
943
944
945
946
947
# File 'lib/ruby_rich/markdown.rb', line 938

def self.render_via_leaf(source, width)
  return nil unless leaf_available?
  IO.popen([LEAF_BIN, "--inline", "plain:#{width}"], "r+", err: "/dev/null") do |io|
    io.write(source)
    io.close_write
    io.read.strip
  end
rescue
  nil
end

.tc(key) ⇒ Object

Proxy theme colour access (same instance as TerminalConverter).



1179
1180
1181
1182
# File 'lib/ruby_rich/markdown.rb', line 1179

def self.tc(key)
  color, bright = MarkdownTheme[key]
  AnsiCode.color(color, bright)
end