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



1191
1192
1193
# File 'lib/ruby_rich/markdown.rb', line 1191

def self.AnsiCode
  ::RubyRich::AnsiCode
end

.detect_type(source) ⇒ Object



973
974
975
976
977
978
979
980
981
982
# File 'lib/ruby_rich/markdown.rb', line 973

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)


941
942
943
# File 'lib/ruby_rich/markdown.rb', line 941

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是否通过?”



1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
# File 'lib/ruby_rich/markdown.rb', line 1101

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



956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
# File 'lib/ruby_rich/markdown.rb', line 956

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.



1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
# File 'lib/ruby_rich/markdown.rb', line 1031

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.



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
1092
1093
1094
1095
1096
1097
1098
# File 'lib/ruby_rich/markdown.rb', line 1044

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.



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
1022
1023
1024
1025
1026
1027
1028
# File 'lib/ruby_rich/markdown.rb', line 985

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.



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
1177
1178
1179
1180
1181
1182
1183
# File 'lib/ruby_rich/markdown.rb', line 1121

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



945
946
947
948
949
950
951
952
953
954
# File 'lib/ruby_rich/markdown.rb', line 945

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).



1186
1187
1188
1189
# File 'lib/ruby_rich/markdown.rb', line 1186

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