pulldown_html_ext/html/
writer.rs

1use super::{ListContext, TableContext};
2use crate::html::state::HtmlState;
3use crate::html::HtmlError;
4use crate::HtmlConfig;
5
6use pulldown_cmark::{
7    Alignment, CodeBlockKind, CowStr, Event, HeadingLevel, LinkType, MetadataBlockKind,
8};
9use pulldown_cmark_escape::{escape_href, escape_html, escape_html_body_text, StrWrite};
10use std::iter::Peekable;
11
12/// Trait for handling Markdown tag rendering to HTML
13pub trait HtmlWriter<W: StrWrite> {
14    /// Write a string directly to the output
15    fn write_str(&mut self, s: &str) -> Result<(), HtmlError> {
16        self.get_writer()
17            .write_str(s)
18            .map_err(|_| HtmlError::Write(std::fmt::Error))
19    }
20
21    /// Write HTML attributes for a given element
22    fn write_attributes(&mut self, element: &str) -> Result<(), HtmlError> {
23        let mut attrs_string = String::new();
24
25        if let Some(attrs) = self.get_config().attributes.element_attributes.get(element) {
26            for (key, value) in attrs {
27                attrs_string.push_str(&format!(" {}=\"{}\"", key, value));
28            }
29        }
30
31        if !attrs_string.is_empty() {
32            self.write_str(&attrs_string)?;
33        }
34        Ok(())
35    }
36
37    fn get_config(&self) -> &HtmlConfig;
38
39    fn get_writer(&mut self) -> &mut W;
40
41    fn get_state(&mut self) -> &mut HtmlState;
42
43    /// Check if a URL points to an external resource
44    fn is_external_link(&self, url: &str) -> bool {
45        url.starts_with("http://") || url.starts_with("https://")
46    }
47
48    fn start_paragraph(&mut self) -> Result<(), HtmlError> {
49        if !self.get_state().currently_in_footnote {
50            self.write_str("<p")?;
51            self.write_attributes("p")?;
52            self.write_str(">")?;
53        }
54        Ok(())
55    }
56
57    fn end_paragraph(&mut self) -> Result<(), HtmlError> {
58        if !self.get_state().currently_in_footnote {
59            self.write_str("</p>")?;
60        }
61        Ok(())
62    }
63
64    fn start_heading(
65        &mut self,
66        level: HeadingLevel,
67        id: Option<&str>,
68        classes: &[CowStr],
69        attrs: &Vec<(CowStr, Option<CowStr>)>,
70    ) -> Result<(), HtmlError> {
71        // Get all config values up front
72        let level_num = level as u8;
73        let add_ids = self.get_config().elements.headings.add_ids;
74        let id_prefix = self.get_config().elements.headings.id_prefix.clone();
75        let level_classes = self
76            .get_config()
77            .elements
78            .headings
79            .level_classes
80            .get(&level_num)
81            .cloned();
82
83        // Start the heading tag
84        self.write_str(&format!("<h{}", level_num))?;
85
86        // Handle ID attribute
87        if add_ids {
88            let heading_id =
89                id.map_or_else(|| format!("{}{}", id_prefix, level_num), |s| s.to_string());
90            self.write_str(" id=\"")?;
91            escape_html(self.get_writer(), &heading_id)
92                .map_err(|_| HtmlError::Write(std::fmt::Error))?;
93            self.write_str("\"")?;
94            self.get_state().heading_stack.push(heading_id);
95        }
96
97        // Combine and handle classes
98        let mut all_classes = Vec::new();
99        if let Some(level_class) = level_classes {
100            all_classes.push(level_class);
101        }
102        all_classes.extend(classes.iter().map(|s| s.to_string()));
103
104        if !all_classes.is_empty() {
105            self.write_str(" class=\"")?;
106            escape_html(self.get_writer(), &all_classes.join(" "))
107                .map_err(|_| HtmlError::Write(std::fmt::Error))?;
108            self.write_str("\"")?;
109        }
110
111        // Handle additional attributes
112        for (key, value) in attrs {
113            self.write_str(" ")?;
114            escape_html(self.get_writer(), key).map_err(|_| HtmlError::Write(std::fmt::Error))?;
115            if let Some(val) = value {
116                self.write_str("=\"")?;
117                escape_html(self.get_writer(), val)
118                    .map_err(|_| HtmlError::Write(std::fmt::Error))?;
119                self.write_str("\"")?;
120            }
121        }
122
123        // Add any configured element attributes
124        self.write_attributes(&format!("h{}", level_num))?;
125
126        // Close the opening tag
127        self.write_str(">")
128    }
129    fn end_heading(&mut self, level: HeadingLevel) -> Result<(), HtmlError> {
130        self.write_str(&format!("</{}>", level))
131    }
132
133    fn start_blockquote(&mut self) -> Result<(), HtmlError> {
134        self.write_str("<blockquote")?;
135        self.write_attributes("blockquote")?;
136        self.write_str(">")?;
137        Ok(())
138    }
139
140    fn end_blockquote(&mut self) -> Result<(), HtmlError> {
141        self.write_str("</blockquote>")
142    }
143
144    fn start_code_block(&mut self, kind: CodeBlockKind) -> Result<(), HtmlError> {
145        self.get_state().currently_in_code_block = true;
146        self.write_str("<pre")?;
147        self.write_attributes("pre")?;
148        self.write_str("><code")?;
149
150        match kind {
151            CodeBlockKind::Fenced(info) => {
152                let lang = if info.is_empty() {
153                    self.get_config()
154                        .elements
155                        .code_blocks
156                        .default_language
157                        .as_deref()
158                } else {
159                    Some(&*info)
160                };
161
162                if let Some(lang) = lang {
163                    self.write_str(&format!(" class=\"language-{}\"", lang))?;
164                }
165            }
166            CodeBlockKind::Indented => {
167                if let Some(lang) = &self.get_config().elements.code_blocks.default_language {
168                    self.write_str(&format!(" class=\"language-{}\"", lang))?;
169                }
170            }
171        }
172
173        self.write_attributes("code")?;
174        self.write_str(">")?;
175        Ok(())
176    }
177
178    fn end_code_block(&mut self) -> Result<(), HtmlError> {
179        self.write_str("</code></pre>")
180    }
181
182    fn start_inline_code(&mut self) -> Result<(), HtmlError> {
183        self.write_str("<code")?;
184        self.write_attributes("code")?;
185        self.write_str(">")?;
186        Ok(())
187    }
188
189    fn end_inline_code(&mut self) -> Result<(), HtmlError> {
190        self.write_str("</code>")
191    }
192
193    fn start_list(&mut self, first_number: Option<u64>) -> Result<(), HtmlError> {
194        match first_number {
195            Some(n) => {
196                self.get_state().numbers.push(n.try_into().unwrap());
197                self.get_state()
198                    .list_stack
199                    .push(ListContext::Ordered(n.try_into().unwrap()));
200                self.write_str("<ol")?;
201                if n != 1 {
202                    self.write_str(&format!(" start=\"{}\"", n))?;
203                }
204                self.write_attributes("ol")?;
205                self.write_str(">")?;
206            }
207            None => {
208                self.get_state().list_stack.push(ListContext::Unordered);
209                self.write_str("<ul")?;
210                self.write_attributes("ul")?;
211                self.write_str(">")?;
212            }
213        }
214        Ok(())
215    }
216
217    fn end_list(&mut self, ordered: bool) -> Result<(), HtmlError> {
218        self.write_str(if ordered { "</ol>" } else { "</ul>" })
219    }
220
221    fn start_list_item(&mut self) -> Result<(), HtmlError> {
222        self.write_str("<li")?;
223        self.write_attributes("li")?;
224        self.write_str(">")
225    }
226
227    fn end_list_item(&mut self) -> Result<(), HtmlError> {
228        self.write_str("</li>")
229    }
230
231    fn start_table(&mut self, alignments: Vec<Alignment>) -> Result<(), HtmlError> {
232        self.get_state().table_state = TableContext::InHeader;
233        self.get_state().table_alignments = alignments;
234        self.write_str("<table")?;
235        self.write_attributes("table")?;
236        self.write_str(">")
237    }
238
239    fn end_table(&mut self) -> Result<(), HtmlError> {
240        self.write_str("</tbody></table>")
241    }
242
243    fn start_table_head(&mut self) -> Result<(), HtmlError> {
244        self.get_state().table_cell_index = 0;
245        self.write_str("<thead><tr>")
246    }
247
248    fn end_table_head(&mut self) -> Result<(), HtmlError> {
249        self.write_str("</tr></thead><tbody>")
250    }
251
252    fn start_table_row(&mut self) -> Result<(), HtmlError> {
253        self.get_state().table_cell_index = 0;
254        if self.get_state().table_state == TableContext::InHeader {
255            self.get_state().table_state = TableContext::InBody;
256        }
257        self.write_str("<tr>")
258    }
259
260    fn end_table_row(&mut self) -> Result<(), HtmlError> {
261        self.write_str("</tr>")
262    }
263
264    fn start_table_cell(&mut self) -> Result<(), HtmlError> {
265        let tag = match self.get_state().table_state {
266            TableContext::InHeader => "th",
267            _ => "td",
268        };
269
270        self.write_str("<")?;
271        self.write_str(tag)?;
272        let idx = self.get_state().table_cell_index;
273        if let Some(alignment) = self.get_state().table_alignments.get(idx) {
274            match alignment {
275                Alignment::Left => self.write_str(" style=\"text-align: left\"")?,
276                Alignment::Center => self.write_str(" style=\"text-align: center\"")?,
277                Alignment::Right => self.write_str(" style=\"text-align: right\"")?,
278                Alignment::None => {}
279            }
280        }
281
282        self.write_attributes(tag)?;
283        self.write_str(">")?;
284
285        self.get_state().table_cell_index += 1;
286        Ok(())
287    }
288
289    fn end_table_cell(&mut self) -> Result<(), HtmlError> {
290        self.write_str("</td>")
291    }
292
293    fn start_emphasis(&mut self) -> Result<(), HtmlError> {
294        self.write_str("<em")?;
295        self.write_attributes("em")?;
296        self.write_str(">")
297    }
298
299    fn end_emphasis(&mut self) -> Result<(), HtmlError> {
300        self.write_str("</em>")
301    }
302
303    fn start_strong(&mut self) -> Result<(), HtmlError> {
304        self.write_str("<strong")?;
305        self.write_attributes("strong")?;
306        self.write_str(">")
307    }
308
309    fn end_strong(&mut self) -> Result<(), HtmlError> {
310        self.write_str("</strong>")
311    }
312
313    fn start_strikethrough(&mut self) -> Result<(), HtmlError> {
314        self.write_str("<del")?;
315        self.write_attributes("del")?;
316        self.write_str(">")
317    }
318
319    fn end_strikethrough(&mut self) -> Result<(), HtmlError> {
320        self.write_str("</del>")
321    }
322
323    fn start_link(
324        &mut self,
325        _link_type: LinkType,
326        dest: &str,
327        title: &str,
328    ) -> Result<(), HtmlError> {
329        self.write_str("<a href=\"")?;
330        escape_href(self.get_writer(), dest).map_err(|_| HtmlError::Write(std::fmt::Error))?;
331
332        if !title.is_empty() {
333            self.write_str("\" title=\"")?;
334            escape_html(self.get_writer(), title).map_err(|_| HtmlError::Write(std::fmt::Error))?;
335        }
336
337        if self.is_external_link(dest) {
338            if self.get_config().elements.links.nofollow_external {
339                self.write_str("\" rel=\"nofollow")?;
340            }
341            if self.get_config().elements.links.open_external_blank {
342                self.write_str("\" target=\"_blank")?;
343            }
344        }
345
346        self.write_str("\"")?;
347        self.write_attributes("a")?;
348        self.write_str(">")
349    }
350
351    fn end_link(&mut self) -> Result<(), HtmlError> {
352        self.write_str("</a>")
353    }
354
355    fn start_image<'a, I>(
356        &mut self,
357        _link_type: LinkType,
358        dest: &str,
359        title: &str,
360        iter: &mut Peekable<I>,
361    ) -> Result<(), HtmlError>
362    where
363        I: Iterator<Item = Event<'a>>,
364    {
365        self.write_str("<img src=\"")?;
366        escape_href(self.get_writer(), dest).map_err(|_| HtmlError::Write(std::fmt::Error))?;
367        self.write_str("\" alt=\"")?;
368
369        let alt_text = self.collect_alt_text(iter);
370        escape_html(self.get_writer(), &alt_text).map_err(|_| HtmlError::Write(std::fmt::Error))?;
371        self.write_str("\"")?;
372
373        if !title.is_empty() {
374            self.write_str(" title=\"")?;
375            escape_html(self.get_writer(), title).map_err(|_| HtmlError::Write(std::fmt::Error))?;
376            self.write_str("\"")?;
377        }
378
379        self.write_attributes("img")?;
380
381        if self.get_config().html.xhtml_style {
382            self.write_str(" />")?;
383        } else {
384            self.write_str(">")?;
385        }
386        Ok(())
387    }
388
389    fn end_image(&mut self) -> Result<(), HtmlError> {
390        Ok(())
391    }
392
393    fn footnote_reference(&mut self, name: &str) -> Result<(), HtmlError> {
394        self.write_str("<sup class=\"footnote-reference\"><a href=\"#")?;
395        self.write_str(name)?;
396        self.write_str("\">")?;
397        self.write_str(name)?;
398        self.write_str("</a></sup>")
399    }
400
401    fn start_footnote_definition(&mut self, name: &str) -> Result<(), HtmlError> {
402        self.write_str("<div class=\"footnote-definition\" id=\"")?;
403        self.write_str(name)?;
404        self.write_str("\"><sup class=\"footnote-definition-label\">")?;
405        self.write_str(name)?;
406        self.get_state().currently_in_footnote = true;
407        self.write_str("</sup>")?;
408
409        Ok(())
410    }
411    fn end_footnote_definition(&mut self) -> Result<(), HtmlError> {
412        self.write_str("</div>")?;
413        self.get_state().currently_in_footnote = false;
414        Ok(())
415    }
416
417    // Task list handlers
418    fn task_list_item(&mut self, checked: bool) -> Result<(), HtmlError> {
419        self.write_str("<input type=\"checkbox\" disabled")?;
420        if checked {
421            self.write_str(" checked")?;
422        }
423        self.write_str(">")
424    }
425
426    // Special elements - simple HTML
427    fn horizontal_rule(&mut self) -> Result<(), HtmlError> {
428        self.write_str("<hr>")
429    }
430
431    fn soft_break(&mut self) -> Result<(), HtmlError> {
432        if self.get_config().html.break_on_newline {
433            self.write_str("<br>")
434        } else {
435            self.write_str("\n")
436        }
437    }
438
439    fn hard_break(&mut self) -> Result<(), HtmlError> {
440        self.write_str("<br>")
441    }
442
443    fn text(&mut self, text: &str) -> Result<(), HtmlError> {
444        if self.get_config().html.escape_html {
445            escape_html_body_text(self.get_writer(), text)
446                .map_err(|_| HtmlError::Write(std::fmt::Error))?;
447        } else {
448            self.write_str(text)?;
449        }
450        Ok(())
451    }
452
453    fn start_definition_list(&mut self) -> Result<(), HtmlError> {
454        self.write_str("<dl")?;
455        self.write_attributes("dl")?;
456        self.write_str(">")
457    }
458
459    fn end_definition_list(&mut self) -> Result<(), HtmlError> {
460        self.write_str("</dl>")
461    }
462
463    fn start_definition_list_title(&mut self) -> Result<(), HtmlError> {
464        self.write_str("<dt")?;
465        self.write_attributes("dt")?;
466        self.write_str(">")
467    }
468
469    fn end_definition_list_title(&mut self) -> Result<(), HtmlError> {
470        self.write_str("</dt>")
471    }
472
473    fn start_definition_list_definition(&mut self) -> Result<(), HtmlError> {
474        self.write_str("<dd")?;
475        self.write_attributes("dd")?;
476        self.write_str(">")
477    }
478
479    fn end_definition_list_definition(&mut self) -> Result<(), HtmlError> {
480        self.write_str("</dd>")
481    }
482
483    fn start_metadata_block(
484        &mut self,
485        _metadata_type: &MetadataBlockKind,
486    ) -> Result<(), HtmlError> {
487        // TODO - implement this
488        //self.get_state().in_non_writing_block = true
489        Ok(())
490    }
491    fn end_metadata_block(&mut self) -> Result<(), HtmlError> {
492        // TODO - implement this
493        //self.get_state().in_non_writing_block = false
494        Ok(())
495    }
496
497    fn html_raw(&mut self, html: &CowStr) -> Result<(), HtmlError> {
498        self.write_str(html)
499    }
500
501    fn collect_alt_text<'a, I>(&self, iter: &mut Peekable<I>) -> String
502    where
503        I: Iterator<Item = Event<'a>>,
504    {
505        let mut alt = String::new();
506        let mut nest = 0;
507
508        for event in iter.by_ref() {
509            match event {
510                Event::Start(_) => nest += 1,
511                Event::End(_) => {
512                    if nest == 0 {
513                        break;
514                    }
515                    nest -= 1;
516                }
517                Event::Text(text) => {
518                    alt.push_str(&text);
519                }
520                Event::Code(text) => {
521                    alt.push_str(&text);
522                }
523                Event::SoftBreak | Event::HardBreak => {
524                    alt.push(' ');
525                }
526                _ => {}
527            }
528        }
529        alt
530    }
531}
532
533// Default bases to derive from, implements the default getter methods.
534pub struct HtmlWriterBase<W: StrWrite> {
535    writer: W,
536    config: HtmlConfig,
537    state: HtmlState,
538}
539
540impl<W: StrWrite> HtmlWriterBase<W> {
541    pub fn new(writer: W, config: HtmlConfig) -> Self {
542        Self {
543            writer,
544            config,
545            state: HtmlState::new(),
546        }
547    }
548}
549
550impl<W: StrWrite> HtmlWriter<W> for HtmlWriterBase<W> {
551    fn get_writer(&mut self) -> &mut W {
552        &mut self.writer
553    }
554
555    fn get_config(&self) -> &HtmlConfig {
556        &self.config
557    }
558
559    fn get_state(&mut self) -> &mut HtmlState {
560        &mut self.state
561    }
562}
563
564#[cfg(test)]
565mod tests {
566
567    use super::*;
568    use pulldown_cmark_escape::FmtWriter;
569
570    struct TestHandler<W: StrWrite> {
571        writer: W,
572        config: HtmlConfig,
573        state: HtmlState,
574    }
575
576    impl<W: StrWrite> TestHandler<W> {
577        fn new(writer: W) -> Self {
578            let mut config = HtmlConfig::default();
579            config.html.break_on_newline = false;
580            Self {
581                writer,
582                config,
583                state: HtmlState::new(),
584            }
585        }
586    }
587
588    impl<W: StrWrite> HtmlWriter<W> for TestHandler<W> {
589        fn get_writer(&mut self) -> &mut W {
590            &mut self.writer
591        }
592        fn get_config(&self) -> &HtmlConfig {
593            &self.config
594        }
595
596        fn get_state(&mut self) -> &mut HtmlState {
597            &mut self.state
598        }
599    }
600
601    #[test]
602    fn test_paragraph() {
603        let mut output = String::new();
604        let mut handler = TestHandler::new(FmtWriter(&mut output));
605        handler.start_paragraph().unwrap();
606        handler.text("Hello world").unwrap();
607        handler.end_paragraph().unwrap();
608        assert_eq!(output, "<p>Hello world</p>");
609    }
610
611    #[test]
612    fn test_blockquote() {
613        let mut output = String::new();
614        let mut handler = TestHandler::new(FmtWriter(&mut output));
615        handler.start_blockquote().unwrap();
616        handler.text("Quote").unwrap();
617        handler.end_blockquote().unwrap();
618        assert_eq!(output, "<blockquote>Quote</blockquote>");
619    }
620
621    #[test]
622    fn test_emphasis() {
623        let mut output = String::new();
624        let mut handler = TestHandler::new(FmtWriter(&mut output));
625        handler.start_emphasis().unwrap();
626        handler.text("emphasized").unwrap();
627        handler.end_emphasis().unwrap();
628        assert_eq!(output, "<em>emphasized</em>");
629    }
630
631    #[test]
632    fn test_strong() {
633        let mut output = String::new();
634        let mut handler = TestHandler::new(FmtWriter(&mut output));
635        handler.start_strong().unwrap();
636        handler.text("bold").unwrap();
637        handler.end_strong().unwrap();
638        assert_eq!(output, "<strong>bold</strong>");
639    }
640
641    #[test]
642    fn test_strikethrough() {
643        let mut output = String::new();
644        let mut handler = TestHandler::new(FmtWriter(&mut output));
645        handler.start_strikethrough().unwrap();
646        handler.text("strike").unwrap();
647        handler.end_strikethrough().unwrap();
648        assert_eq!(output, "<del>strike</del>");
649    }
650
651    #[test]
652    fn test_inline_code() {
653        let mut output = String::new();
654        let mut handler = TestHandler::new(FmtWriter(&mut output));
655        handler.start_inline_code().unwrap();
656        handler.text("code").unwrap();
657        handler.end_inline_code().unwrap();
658        assert_eq!(output, "<code>code</code>");
659    }
660
661    #[test]
662    fn test_line_breaks() {
663        let mut output = String::new();
664        let mut handler = TestHandler::new(FmtWriter(&mut output));
665        handler.soft_break().unwrap();
666        handler.hard_break().unwrap();
667        assert_eq!(output, "\n<br>");
668    }
669
670    #[test]
671    fn test_horizontal_rule() {
672        let mut output = String::new();
673        let mut handler = TestHandler::new(FmtWriter(&mut output));
674        handler.horizontal_rule().unwrap();
675        assert_eq!(output, "<hr>");
676    }
677
678    #[test]
679    fn test_task_list() {
680        let mut output = String::new();
681        let mut handler = TestHandler::new(FmtWriter(&mut output));
682        handler.task_list_item(true).unwrap();
683        handler.text("Done").unwrap();
684
685        assert_eq!(output, "<input type=\"checkbox\" disabled checked>Done");
686
687        let mut output = String::new();
688        let mut handler = TestHandler::new(FmtWriter(&mut output));
689        handler.task_list_item(false).unwrap();
690        handler.text("Todo").unwrap();
691
692        assert_eq!(output, "<input type=\"checkbox\" disabled>Todo");
693    }
694
695    #[test]
696    fn test_footnote_definition() {
697        let mut output = String::new();
698        let mut handler = TestHandler::new(FmtWriter(&mut output));
699        handler.start_footnote_definition("1").unwrap();
700        handler.text("Footnote content").unwrap();
701        handler.end_footnote_definition().unwrap();
702        assert_eq!(
703            output,
704            "<div class=\"footnote-definition\" id=\"1\">\
705             <sup class=\"footnote-definition-label\">1</sup>\
706             Footnote content</div>"
707        );
708    }
709
710    #[test]
711    fn test_list_endings() {
712        let mut output = String::new();
713        let mut handler = TestHandler::new(FmtWriter(&mut output));
714        handler.end_list(true).unwrap();
715        assert_eq!(output, "</ol>");
716
717        let mut output = String::new();
718        let mut handler = TestHandler::new(FmtWriter(&mut output));
719        handler.end_list(false).unwrap();
720        assert_eq!(output, "</ul>");
721    }
722
723    #[test]
724    fn test_table_structure() {
725        let mut output = String::new();
726        let mut handler = TestHandler::new(FmtWriter(&mut output));
727        handler.end_table_head().unwrap();
728        handler.end_table_row().unwrap();
729        handler.end_table_cell().unwrap();
730        handler.end_table().unwrap();
731        assert_eq!(output, "</tr></thead><tbody></tr></td></tbody></table>");
732    }
733}