| 1 | // Copyright 2026 The V Language. All rights reserved. |
| 2 | // Use of this source code is governed by an MIT license |
| 3 | // that can be found in the LICENSE file. |
| 4 | module markdown |
| 5 | |
| 6 | import strings |
| 7 | |
| 8 | // PlainTextRenderer renders a parsed markdown AST to UTF-8 console-friendly text. |
| 9 | struct PlainTextRenderer { |
| 10 | opts Options |
| 11 | ref_map map[string]LinkRef |
| 12 | mut: |
| 13 | sb strings.Builder |
| 14 | // footnote tracking |
| 15 | fn_order []string |
| 16 | fn_nodes map[string]&Node |
| 17 | // list tracking |
| 18 | list_depth int |
| 19 | list_nums []int |
| 20 | } |
| 21 | |
| 22 | // render renders the document node to plain text. |
| 23 | pub fn (mut r PlainTextRenderer) render(doc &Node) string { |
| 24 | r.sb = strings.new_builder(1024) |
| 25 | if r.opts.footnotes { |
| 26 | for child in doc.children { |
| 27 | if child.kind == .footnote_def { |
| 28 | r.fn_nodes[child.fn_label] = child |
| 29 | } |
| 30 | } |
| 31 | } |
| 32 | r.render_children(doc) |
| 33 | if r.opts.footnotes && r.fn_order.len > 0 { |
| 34 | r.render_footnotes_section() |
| 35 | } |
| 36 | return r.sb.str().trim_right('\n') + '\n' |
| 37 | } |
| 38 | |
| 39 | fn (mut r PlainTextRenderer) render_node(node &Node) { |
| 40 | match node.kind { |
| 41 | .document { r.render_children(node) } |
| 42 | .heading { r.render_heading(node) } |
| 43 | .paragraph { r.render_paragraph(node) } |
| 44 | .blockquote { r.render_blockquote(node) } |
| 45 | .list { r.render_list(node) } |
| 46 | .list_item { r.render_list_item(node) } |
| 47 | .code_block, .fenced_code { r.render_code_block(node) } |
| 48 | .thematic_break { r.sb.write_string('---\n') } |
| 49 | .html_block { r.render_html_block(node) } |
| 50 | .link_ref_def, .footnote_def {} |
| 51 | .table { r.render_table(node) } |
| 52 | .table_head, .table_body { r.render_children(node) } |
| 53 | .table_row { r.render_table_row(node) } |
| 54 | .table_cell { r.render_table_cell(node) } |
| 55 | .definition_list { r.render_children(node) } |
| 56 | .definition_term { r.render_definition_term(node) } |
| 57 | .definition_desc { r.render_definition_desc(node) } |
| 58 | .text { r.render_text(node) } |
| 59 | .emphasis { r.render_wrapped(node, '*') } |
| 60 | .strong { r.render_wrapped(node, '**') } |
| 61 | .code_span { r.sb.write_string('`' + node.literal + '`') } |
| 62 | .link { r.render_link(node) } |
| 63 | .image { r.render_image(node) } |
| 64 | .autolink { r.sb.write_string(node.literal) } |
| 65 | .raw_html { r.render_raw_html(node) } |
| 66 | .hard_break, .soft_break { r.sb.write_string('\n') } |
| 67 | .strikethrough { r.render_wrapped(node, '~~') } |
| 68 | .task_checkbox { r.render_task_checkbox(node) } |
| 69 | .footnote_ref { r.render_footnote_ref(node) } |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | fn (mut r PlainTextRenderer) render_children(node &Node) { |
| 74 | for child in node.children { |
| 75 | r.render_node(child) |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | fn (mut r PlainTextRenderer) render_inline(src string) { |
| 80 | nodes := parse_inline(src, r.opts, r.ref_map) |
| 81 | for node in nodes { |
| 82 | r.render_node(node) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | fn (mut r PlainTextRenderer) render_heading(node &Node) { |
| 87 | r.sb.write_string('${'#'.repeat(node.level)} ') |
| 88 | if node.children.len > 0 { |
| 89 | r.render_children(node) |
| 90 | } else { |
| 91 | r.render_inline(node.literal) |
| 92 | } |
| 93 | r.sb.write_string('\n\n') |
| 94 | } |
| 95 | |
| 96 | fn (mut r PlainTextRenderer) render_paragraph(node &Node) { |
| 97 | if node.children.len > 0 { |
| 98 | r.render_children(node) |
| 99 | } else { |
| 100 | r.render_inline(node.literal) |
| 101 | } |
| 102 | r.sb.write_string('\n') |
| 103 | } |
| 104 | |
| 105 | fn (mut r PlainTextRenderer) render_blockquote(node &Node) { |
| 106 | mut inner := strings.new_builder(128) |
| 107 | mut rr := PlainTextRenderer{ |
| 108 | opts: r.opts |
| 109 | ref_map: r.ref_map |
| 110 | fn_order: r.fn_order.clone() |
| 111 | fn_nodes: r.fn_nodes |
| 112 | } |
| 113 | rr.sb = inner |
| 114 | rr.render_children(node) |
| 115 | for line in rr.sb.str().trim_right('\n').split('\n') { |
| 116 | r.sb.write_string('> ${line}\n') |
| 117 | } |
| 118 | // Keep footnote reference order discovered inside the blockquote. |
| 119 | r.fn_order = rr.fn_order |
| 120 | r.sb.write_string('\n') |
| 121 | } |
| 122 | |
| 123 | fn (mut r PlainTextRenderer) render_list(node &Node) { |
| 124 | r.list_depth++ |
| 125 | if node.is_ordered { |
| 126 | r.list_nums << node.list_start |
| 127 | } else { |
| 128 | r.list_nums << 0 |
| 129 | } |
| 130 | r.render_children(node) |
| 131 | r.list_nums.delete_last() |
| 132 | r.list_depth-- |
| 133 | if r.list_depth == 0 { |
| 134 | r.sb.write_string('\n') |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | fn (mut r PlainTextRenderer) render_list_item(node &Node) { |
| 139 | indent := ' '.repeat(if r.list_depth > 0 { r.list_depth - 1 } else { 0 }) |
| 140 | idx := r.list_nums.len - 1 |
| 141 | marker := if idx >= 0 && r.list_nums[idx] > 0 { |
| 142 | m := '${r.list_nums[idx]}. ' |
| 143 | r.list_nums[idx]++ |
| 144 | m |
| 145 | } else { |
| 146 | '- ' |
| 147 | } |
| 148 | r.sb.write_string(indent + marker) |
| 149 | for i, child in node.children { |
| 150 | if i > 0 { |
| 151 | r.sb.write_string(' ') |
| 152 | } |
| 153 | if child.kind == .paragraph { |
| 154 | if child.children.len > 0 { |
| 155 | r.render_children(child) |
| 156 | } else { |
| 157 | r.render_inline(child.literal) |
| 158 | } |
| 159 | } else if child.kind == .list { |
| 160 | r.sb.write_string('\n') |
| 161 | r.render_node(child) |
| 162 | } else { |
| 163 | r.render_node(child) |
| 164 | } |
| 165 | } |
| 166 | r.sb.write_string('\n') |
| 167 | } |
| 168 | |
| 169 | fn (mut r PlainTextRenderer) render_code_block(node &Node) { |
| 170 | r.sb.write_string('```\n') |
| 171 | r.sb.write_string(node.literal.trim_right('\n')) |
| 172 | r.sb.write_string('\n```\n\n') |
| 173 | } |
| 174 | |
| 175 | fn (mut r PlainTextRenderer) render_html_block(node &Node) { |
| 176 | if r.opts.renderer_opts.unsafe_ { |
| 177 | r.sb.write_string(node.literal) |
| 178 | } else { |
| 179 | r.sb.write_string('[raw HTML omitted]\n') |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | fn (mut r PlainTextRenderer) render_table(node &Node) { |
| 184 | r.render_children(node) |
| 185 | r.sb.write_string('\n') |
| 186 | } |
| 187 | |
| 188 | fn (mut r PlainTextRenderer) render_table_row(node &Node) { |
| 189 | r.render_children(node) |
| 190 | r.sb.write_string('\n') |
| 191 | } |
| 192 | |
| 193 | fn (mut r PlainTextRenderer) render_table_cell(node &Node) { |
| 194 | r.sb.write_string(node.literal.trim_space()) |
| 195 | r.sb.write_string(' | ') |
| 196 | } |
| 197 | |
| 198 | fn (mut r PlainTextRenderer) render_definition_term(node &Node) { |
| 199 | r.render_inline(node.literal) |
| 200 | r.sb.write_string('\n') |
| 201 | for child in node.children { |
| 202 | r.render_node(child) |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | fn (mut r PlainTextRenderer) render_definition_desc(node &Node) { |
| 207 | r.sb.write_string(' - ') |
| 208 | r.render_inline(node.literal) |
| 209 | r.sb.write_string('\n') |
| 210 | } |
| 211 | |
| 212 | fn (mut r PlainTextRenderer) render_text(node &Node) { |
| 213 | content := if r.opts.typographer { |
| 214 | smart_punctuate(node.literal) |
| 215 | } else { |
| 216 | node.literal |
| 217 | } |
| 218 | r.sb.write_string(content) |
| 219 | } |
| 220 | |
| 221 | fn (mut r PlainTextRenderer) render_wrapped(node &Node, marker string) { |
| 222 | r.sb.write_string(marker) |
| 223 | r.render_children(node) |
| 224 | r.sb.write_string(marker) |
| 225 | } |
| 226 | |
| 227 | fn (mut r PlainTextRenderer) render_link(node &Node) { |
| 228 | r.render_children(node) |
| 229 | if node.dest.len > 0 { |
| 230 | r.sb.write_string(' (${node.dest})') |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | fn (mut r PlainTextRenderer) render_image(node &Node) { |
| 235 | alt := node.text_content().trim_space() |
| 236 | if alt.len == 0 { |
| 237 | r.sb.write_string('[image]') |
| 238 | } else { |
| 239 | r.sb.write_string('[image: ${alt}]') |
| 240 | } |
| 241 | if node.dest.len > 0 { |
| 242 | r.sb.write_string(' (${node.dest})') |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | fn (mut r PlainTextRenderer) render_raw_html(node &Node) { |
| 247 | if r.opts.renderer_opts.unsafe_ { |
| 248 | r.sb.write_string(node.literal) |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | fn (mut r PlainTextRenderer) render_task_checkbox(node &Node) { |
| 253 | r.sb.write_string(if node.checked { '☑' } else { '☐' }) |
| 254 | } |
| 255 | |
| 256 | fn (mut r PlainTextRenderer) render_footnote_ref(node &Node) { |
| 257 | label := node.fn_label |
| 258 | mut idx := 0 |
| 259 | for i, l in r.fn_order { |
| 260 | if l == label { |
| 261 | idx = i + 1 |
| 262 | break |
| 263 | } |
| 264 | } |
| 265 | if idx == 0 { |
| 266 | r.fn_order << label |
| 267 | idx = r.fn_order.len |
| 268 | } |
| 269 | r.sb.write_string('[${idx}]') |
| 270 | } |
| 271 | |
| 272 | fn (mut r PlainTextRenderer) render_footnotes_section() { |
| 273 | r.sb.write_string('\nFootnotes:\n') |
| 274 | for label in r.fn_order { |
| 275 | fn_node := r.fn_nodes[label] or { continue } |
| 276 | mut idx := 0 |
| 277 | for i, l in r.fn_order { |
| 278 | if l == label { |
| 279 | idx = i + 1 |
| 280 | break |
| 281 | } |
| 282 | } |
| 283 | r.sb.write_string('[${idx}] ') |
| 284 | r.render_inline(fn_node.literal) |
| 285 | r.sb.write_string('\n') |
| 286 | } |
| 287 | } |
| 288 | |