v2 / vlib / x / markdown / html.v
394 lines · 357 sloc · 11.26 KB · 3094cf12ec589dd1a0c63b37010c156c581dd373
Raw
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.
4module markdown
5
6import strings
7
8// HTMLRenderer renders a parsed markdown AST to an HTML string.
9struct HTMLRenderer {
10 opts Options
11 ref_map map[string]LinkRef
12mut:
13 sb strings.Builder
14 // footnote tracking
15 fn_order []string // ordered list of encountered fn labels
16 fn_nodes map[string]&Node // label → footnote_def node
17 tight_list bool // whether we're inside a tight list
18 in_table_head bool
19}
20
21// render renders the document node to an HTML string.
22pub fn (mut r HTMLRenderer) render(doc &Node) string {
23 r.sb = strings.new_builder(1024)
24 // Pre-collect footnote definitions if extension is enabled.
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 // Append footnotes section if any refs were used.
34 if r.opts.footnotes && r.fn_order.len > 0 {
35 r.render_footnotes_section()
36 }
37 return r.sb.str()
38}
39
40// render_node dispatches rendering to the appropriate method.
41fn (mut r HTMLRenderer) render_node(node &Node) {
42 match node.kind {
43 .document { r.render_children(node) }
44 .heading { r.render_heading(node) }
45 .paragraph { r.render_paragraph(node) }
46 .blockquote { r.render_blockquote(node) }
47 .list { r.render_list(node) }
48 .list_item { r.render_list_item(node) }
49 .code_block { r.render_code_block(node) }
50 .fenced_code { r.render_fenced_code(node) }
51 .thematic_break { r.render_thematic_break() }
52 .html_block { r.render_html_block(node) }
53 .link_ref_def {} // already collected, nothing to render
54 .table { r.render_table(node) }
55 .table_head { r.render_table_section(node, 'thead') }
56 .table_body { r.render_table_section(node, 'tbody') }
57 .table_row { r.render_table_row(node) }
58 .table_cell { r.render_table_cell(node) }
59 .definition_list { r.render_definition_list(node) }
60 .definition_term { r.render_definition_term(node) }
61 .definition_desc { r.render_definition_desc(node) }
62 .footnote_def {} // rendered in the footnote section
63 .text { r.render_text(node) }
64 .emphasis { r.render_emphasis(node) }
65 .strong { r.render_strong(node) }
66 .code_span { r.render_code_span(node) }
67 .link { r.render_link(node) }
68 .image { r.render_image(node) }
69 .autolink { r.render_autolink(node) }
70 .raw_html { r.render_raw_html(node) }
71 .hard_break { r.render_hard_break() }
72 .soft_break { r.render_soft_break() }
73 .strikethrough { r.render_strikethrough(node) }
74 .task_checkbox { r.render_task_checkbox(node) }
75 .footnote_ref { r.render_footnote_ref(node) }
76 }
77}
78
79// render_children renders all children of node.
80fn (mut r HTMLRenderer) render_children(node &Node) {
81 for child in node.children {
82 r.render_node(child)
83 }
84}
85
86// render_inline parses and renders inline content from a literal string.
87fn (mut r HTMLRenderer) render_inline(src string) {
88 nodes := parse_inline(src, r.opts, r.ref_map)
89 for node in nodes {
90 r.render_node(node)
91 }
92}
93
94fn (mut r HTMLRenderer) render_heading(node &Node) {
95 tag := 'h${node.level}'
96 if node.id.len > 0 {
97 r.sb.write_string('<${tag} id="${html_escape(node.id)}">')
98 } else {
99 r.sb.write_string('<${tag}>')
100 }
101 if node.children.len > 0 {
102 r.render_children(node)
103 } else {
104 r.render_inline(node.literal)
105 }
106 r.sb.write_string('</${tag}>\n')
107}
108
109fn (mut r HTMLRenderer) render_paragraph(node &Node) {
110 if r.tight_list {
111 // In a tight list, paragraph content is rendered directly without <p> tags.
112 if node.children.len > 0 {
113 r.render_children(node)
114 } else {
115 r.render_inline(node.literal)
116 }
117 return
118 }
119 r.sb.write_string('<p>')
120 if node.children.len > 0 {
121 r.render_children(node)
122 } else {
123 r.render_inline(node.literal)
124 }
125 r.sb.write_string('</p>\n')
126}
127
128fn (mut r HTMLRenderer) render_blockquote(node &Node) {
129 r.sb.write_string('<blockquote>\n')
130 r.render_children(node)
131 r.sb.write_string('</blockquote>\n')
132}
133
134fn (mut r HTMLRenderer) render_list(node &Node) {
135 tag := if node.is_ordered { 'ol' } else { 'ul' }
136 if node.is_ordered && node.list_start != 1 {
137 r.sb.write_string('<${tag} start="${node.list_start}">\n')
138 } else {
139 r.sb.write_string('<${tag}>\n')
140 }
141 prev_tight := r.tight_list
142 r.tight_list = node.is_tight
143 r.render_children(node)
144 r.tight_list = prev_tight
145 r.sb.write_string('</${tag}>\n')
146}
147
148fn (mut r HTMLRenderer) render_list_item(node &Node) {
149 // Check if this is a task list item (first child is task_checkbox).
150 if r.opts.task_list && node.children.len > 0 && node.children[0].kind == .task_checkbox {
151 chk := node.children[0]
152 checked_attr := if chk.checked { ' checked=""' } else { '' }
153 if r.opts.renderer_opts.xhtml {
154 r.sb.write_string('<li><input type="checkbox" disabled=""${checked_attr} /> ')
155 } else {
156 r.sb.write_string('<li><input type="checkbox" disabled=""${checked_attr}> ')
157 }
158 for i := 1; i < node.children.len; i++ {
159 r.render_node(node.children[i])
160 }
161 r.sb.write_string('</li>\n')
162 return
163 }
164 r.sb.write_string('<li>')
165 r.render_children(node)
166 r.sb.write_string('</li>\n')
167}
168
169fn (mut r HTMLRenderer) render_code_block(node &Node) {
170 r.sb.write_string('<pre><code>')
171 r.sb.write_string(html_escape(node.literal))
172 r.sb.write_string('</code></pre>\n')
173}
174
175fn (mut r HTMLRenderer) render_fenced_code(node &Node) {
176 if node.fence_info.len > 0 {
177 // Use only the first word of the info string as the language class.
178 lang := node.fence_info.split(' ')[0].split('\t')[0]
179 r.sb.write_string('<pre><code class="language-${html_escape(lang)}">')
180 } else {
181 r.sb.write_string('<pre><code>')
182 }
183 r.sb.write_string(html_escape(node.literal))
184 r.sb.write_string('</code></pre>\n')
185}
186
187fn (mut r HTMLRenderer) render_thematic_break() {
188 if r.opts.renderer_opts.xhtml {
189 r.sb.write_string('<hr />\n')
190 } else {
191 r.sb.write_string('<hr>\n')
192 }
193}
194
195fn (mut r HTMLRenderer) render_html_block(node &Node) {
196 if r.opts.renderer_opts.unsafe_ {
197 r.sb.write_string(node.literal)
198 } else {
199 r.sb.write_string('<!-- raw HTML omitted -->\n')
200 }
201}
202
203fn (mut r HTMLRenderer) render_table(node &Node) {
204 r.sb.write_string('<table>\n')
205 r.render_children(node)
206 r.sb.write_string('</table>\n')
207}
208
209fn (mut r HTMLRenderer) render_table_section(node &Node, tag string) {
210 prev_in_table_head := r.in_table_head
211 r.in_table_head = tag == 'thead'
212 r.sb.write_string('<${tag}>\n')
213 r.render_children(node)
214 r.sb.write_string('</${tag}>\n')
215 r.in_table_head = prev_in_table_head
216}
217
218fn (mut r HTMLRenderer) render_table_row(node &Node) {
219 // Determine cell tag based on parent kind (table_head uses th).
220 // We pass the context via a field or inspect the row context.
221 // Since we don't have parent pointer, check if this is a header row via the
222 // node's parent tracking. We'll check node.children[0].align as a proxy.
223 // Instead, use a simple flag: if any sibling is a table_head, use <th>.
224 // For simplicity, we use <td> always and let render_table_cell decide.
225 r.sb.write_string('<tr>\n')
226 r.render_children(node)
227 r.sb.write_string('</tr>\n')
228}
229
230fn (mut r HTMLRenderer) render_table_cell(node &Node) {
231 // We use a flag in the renderer to know if we're in the head.
232 // Simple approach: the cell tag is set by the surrounding context.
233 // We'll use <td> and trust the renderer state.
234 align_attr := match node.align {
235 .left { ' align="left"' }
236 .center { ' align="center"' }
237 .right { ' align="right"' }
238 else { '' }
239 }
240
241 cell_tag := if r.in_table_head { 'th' } else { 'td' }
242 r.sb.write_string('<${cell_tag}${align_attr}>')
243 r.render_inline(node.literal)
244 r.sb.write_string('</${cell_tag}>\n')
245}
246
247fn (mut r HTMLRenderer) render_definition_list(node &Node) {
248 r.sb.write_string('<dl>\n')
249 r.render_children(node)
250 r.sb.write_string('</dl>\n')
251}
252
253fn (mut r HTMLRenderer) render_definition_term(node &Node) {
254 r.sb.write_string('<dt>')
255 r.render_inline(node.literal)
256 r.sb.write_string('</dt>\n')
257 for child in node.children {
258 r.render_node(child)
259 }
260}
261
262fn (mut r HTMLRenderer) render_definition_desc(node &Node) {
263 r.sb.write_string('<dd>')
264 r.render_inline(node.literal)
265 r.sb.write_string('</dd>\n')
266}
267
268fn (mut r HTMLRenderer) render_footnote_ref(node &Node) {
269 label := node.fn_label
270 // Assign an ordinal on first encounter.
271 mut idx := 0
272 for i, l in r.fn_order {
273 if l == label {
274 idx = i + 1
275 break
276 }
277 }
278 if idx == 0 {
279 r.fn_order << label
280 idx = r.fn_order.len
281 }
282 r.sb.write_string('<sup><a href="#fn-${html_escape(label)}" id="fnref-${html_escape(label)}">${idx}</a></sup>')
283}
284
285fn (mut r HTMLRenderer) render_footnotes_section() {
286 r.sb.write_string('<section class="footnotes">\n<ol>\n')
287 for label in r.fn_order {
288 fn_node := r.fn_nodes[label] or { continue }
289 r.sb.write_string('<li id="fn-${html_escape(label)}">')
290 r.render_inline(fn_node.literal)
291 r.sb.write_string(' <a href="#fnref-${html_escape(label)}">↩</a></li>\n')
292 }
293 r.sb.write_string('</ol>\n</section>\n')
294}
295
296fn (mut r HTMLRenderer) render_text(node &Node) {
297 content := if r.opts.typographer {
298 smart_punctuate(node.literal)
299 } else {
300 node.literal
301 }
302 r.sb.write_string(html_escape(content))
303}
304
305fn (mut r HTMLRenderer) render_emphasis(node &Node) {
306 r.sb.write_string('<em>')
307 r.render_children(node)
308 r.sb.write_string('</em>')
309}
310
311fn (mut r HTMLRenderer) render_strong(node &Node) {
312 r.sb.write_string('<strong>')
313 r.render_children(node)
314 r.sb.write_string('</strong>')
315}
316
317fn (mut r HTMLRenderer) render_code_span(node &Node) {
318 r.sb.write_string('<code>')
319 r.sb.write_string(html_escape(node.literal))
320 r.sb.write_string('</code>')
321}
322
323fn (mut r HTMLRenderer) render_link(node &Node) {
324 r.sb.write_string('<a href="${html_escape(url_encode(node.dest))}"')
325 if node.title.len > 0 {
326 r.sb.write_string(' title="${html_escape(node.title)}"')
327 }
328 r.sb.write_string('>')
329 r.render_children(node)
330 r.sb.write_string('</a>')
331}
332
333fn (mut r HTMLRenderer) render_image(node &Node) {
334 alt := node.text_content()
335 r.sb.write_string('<img src="${html_escape(url_encode(node.dest))}" alt="${html_escape(alt)}"')
336 if node.title.len > 0 {
337 r.sb.write_string(' title="${html_escape(node.title)}"')
338 }
339 if r.opts.renderer_opts.xhtml {
340 r.sb.write_string(' />')
341 } else {
342 r.sb.write_string('>')
343 }
344}
345
346fn (mut r HTMLRenderer) render_autolink(node &Node) {
347 r.sb.write_string('<a href="${html_escape(url_encode(node.dest))}">')
348 r.sb.write_string(html_escape(node.literal))
349 r.sb.write_string('</a>')
350}
351
352fn (mut r HTMLRenderer) render_raw_html(node &Node) {
353 if r.opts.renderer_opts.unsafe_ {
354 r.sb.write_string(node.literal)
355 } else {
356 r.sb.write_string('<!-- raw HTML omitted -->')
357 }
358}
359
360fn (mut r HTMLRenderer) render_hard_break() {
361 if r.opts.renderer_opts.xhtml {
362 r.sb.write_string('<br />\n')
363 } else {
364 r.sb.write_string('<br>\n')
365 }
366}
367
368fn (mut r HTMLRenderer) render_soft_break() {
369 if r.opts.renderer_opts.hard_wraps {
370 if r.opts.renderer_opts.xhtml {
371 r.sb.write_string('<br />\n')
372 } else {
373 r.sb.write_string('<br>\n')
374 }
375 } else {
376 r.sb.write_string('\n')
377 }
378}
379
380fn (mut r HTMLRenderer) render_strikethrough(node &Node) {
381 r.sb.write_string('<del>')
382 r.render_children(node)
383 r.sb.write_string('</del>')
384}
385
386fn (mut r HTMLRenderer) render_task_checkbox(node &Node) {
387 // Rendered inline in render_list_item; standalone fallback:
388 checked := if node.checked { ' checked=""' } else { '' }
389 if r.opts.renderer_opts.xhtml {
390 r.sb.write_string('<input type="checkbox" disabled=""${checked} />')
391 } else {
392 r.sb.write_string('<input type="checkbox" disabled=""${checked}>')
393 }
394}
395