//! An HTML renderer that takes an iterator of [`Event`]s and emits HTML. use std::collections::HashMap; use anyhow::anyhow; use jotdown::Alignment; use jotdown::Container; use jotdown::Event; use jotdown::LinkType; use jotdown::ListKind; use jotdown::OrderedListNumbering::*; use jotdown::SpanLinkType; use crate::template; use crate::template::TemplateRenderer; // {{{ Renderer /// Render djot content as HTML. pub fn render_html<'s>( mut events: impl Iterator<Item = Event<'s>>, mut out: impl std::fmt::Write, ) -> anyhow::Result<()> { let mut w = Writer::new(); events.try_for_each(|e| w.render_event(&e, &mut out))?; w.render_epilogue(&mut out)?; Ok(()) } // }}} pub struct Writer<'s> { list_tightness: Vec<bool>, states: Vec<State<'s>>, footnotes: Footnotes<'s>, } #[derive(Debug, Clone)] enum State<'s> { TextOnly, Ignore, Raw, Math(bool), Aside(TemplateRenderer<'s>), } impl<'s> Writer<'s> { pub fn new() -> Self { Self { list_tightness: Vec::new(), states: Vec::new(), footnotes: Footnotes::default(), } } #[allow(clippy::single_match)] pub fn render_event( &mut self, e: &Event<'s>, mut out: impl std::fmt::Write, ) -> anyhow::Result<()> { // {{{ Handle footnotes if let Event::Start(Container::Footnote { label }, ..) = e { self.footnotes.start(label, Vec::new()); return Ok(()); } else if let Some(events) = self.footnotes.current() { if matches!(e, Event::End(Container::Footnote { .. })) { self.footnotes.end(); } else { events.push(e.clone()); } return Ok(()); } // }}} // {{{ Handle blocks which trigger the `Ignore` state. match e { Event::Start(Container::LinkDefinition { .. }, ..) => { self.states.push(State::Ignore); return Ok(()); } Event::End(Container::LinkDefinition { .. }) => { assert!(matches!(self.states.last(), Some(State::Ignore))); self.states.pop(); } Event::Start(Container::RawBlock { format } | Container::RawInline { format }, ..) => { if format == &"html" { self.states.push(State::Raw); } else { self.states.push(State::Ignore); }; return Ok(()); } Event::End(Container::RawBlock { format } | Container::RawInline { format }) => { if format == &"html" { assert!(matches!(self.states.last(), Some(State::Raw))); } else { assert!(matches!(self.states.last(), Some(State::Ignore))); }; self.states.pop(); } _ => {} } // }}} if matches!(self.states.last(), Some(State::Ignore)) { return Ok(()); } match e { // {{{ Container start Event::Start(c, attrs) => { if matches!(self.states.last(), Some(&State::TextOnly)) { return Ok(()); } match &c { Container::RawBlock { .. } => {} Container::RawInline { .. } => unreachable!(), Container::Footnote { .. } => unreachable!(), // {{{ List Container::List { kind, tight } => { self.list_tightness.push(*tight); match kind { ListKind::Unordered(..) | ListKind::Task(..) => out.write_str("<ul")?, ListKind::Ordered { numbering, start, .. } => { out.write_str("<ol")?; if *start > 1 { write!(out, r#" start="{}""#, start)?; } if let Some(ty) = match numbering { Decimal => None, AlphaLower => Some('a'), AlphaUpper => Some('A'), RomanLower => Some('i'), RomanUpper => Some('I'), } { write!(out, r#" type="{}""#, ty)?; } } } } // }}} // {{{ Link Container::Link(dst, ty) => { if matches!(ty, LinkType::Span(SpanLinkType::Unresolved)) { out.write_str("<a")?; } else { out.write_str(r#"<a href=""#)?; if matches!(ty, LinkType::Email) { out.write_str("mailto:")?; } write_attr(dst, &mut out)?; out.write_char('"')?; } } // }}} // {{{ Paragraph Container::Paragraph => { if self.list_tightness.last() == Some(&true) { return Ok(()); } out.write_str("<p")?; } // }}} Container::Blockquote => out.write_str("<blockquote")?, Container::ListItem { .. } => out.write_str("<li")?, Container::TaskListItem { .. } => out.write_str("<li")?, Container::DescriptionList => out.write_str("<dl")?, Container::DescriptionDetails => out.write_str("<dd")?, Container::Table => out.write_str("<table")?, Container::TableRow { .. } => out.write_str("<tr")?, Container::Section { .. } => out.write_str("<section")?, Container::Div { class: class @ ("aside" | "long-aside" | "char-aside"), } => { if *class == "aside" { self.list_tightness.push(true); } let template = if *class == "aside" { template!("templates/aside.html")? } else if *class == "char-aside" { template!("templates/character-aside.html")? } else { template!("templates/long-aside.html")? }; let mut renderer = TemplateRenderer::new(template); while let Some(label) = renderer.current(&mut out)? { if label == "character" { let character = attrs.get_value("character").ok_or_else(|| { anyhow!("Cannot find `character` attribute on `aside` element") })?; character .parts() .try_for_each(|part| write_attr(part, &mut out))?; renderer.next(&mut out)?; } else if label == "title" { let title = attrs.get_value("title").ok_or_else(|| { anyhow!("Cannot find `title` attribute on `aside` element") })?; title .parts() .try_for_each(|part| write_attr(part, &mut out))?; renderer.next(&mut out)?; } else { break; } } self.states.push(State::Aside(renderer)); } Container::Div { .. } => out.write_str("<div")?, Container::Heading { level, .. } => write!(out, "<h{}", level)?, Container::TableCell { head: false, .. } => out.write_str("<td")?, Container::TableCell { head: true, .. } => out.write_str("<th")?, Container::Caption => out.write_str("<caption")?, Container::Image(..) => out.write_str("<img")?, Container::DescriptionTerm => out.write_str("<dt")?, Container::CodeBlock { .. } => out.write_str("<pre")?, Container::Span | Container::Math { .. } => out.write_str("<span")?, Container::Verbatim => out.write_str("<code")?, Container::Subscript => out.write_str("<sub")?, Container::Superscript => out.write_str("<sup")?, Container::Insert => out.write_str("<ins")?, Container::Delete => out.write_str("<del")?, Container::Strong => out.write_str("<strong")?, Container::Emphasis => out.write_str("<em")?, Container::Mark => out.write_str("<mark")?, Container::LinkDefinition { .. } => return Ok(()), } let mut write_attribs = true; if matches!( c, Container::Div { class: "aside" | "long-aside" | "char-aside" } ) { write_attribs = false; } if write_attribs { // {{{ Write attributes let mut id_written = false; let mut class_written = false; if write_attribs { for (a, v) in attrs.unique_pairs() { let is_class = a == "class"; let is_id = a == "id"; if (!is_id || !id_written) && (!is_class || !class_written) { write!(out, r#" {}=""#, a)?; v.parts().try_for_each(|part| write_attr(part, &mut out))?; out.write_char('"')?; id_written |= is_id; class_written |= is_class; }; } } // }}} // {{{ Write default ids/classes if let Container::Heading { id, has_section: false, .. } | Container::Section { id } = &c { if !id_written { out.write_str(r#" id=""#)?; write_attr(id, &mut out)?; out.write_char('"')?; } // TODO: do I not want this to add onto the provided class? } else if (matches!(c, Container::Div { class } if !class.is_empty()) || matches!( c, Container::Math { .. } | Container::List { kind: ListKind::Task(..), .. } | Container::TaskListItem { .. } )) && !class_written { out.write_str(r#" class=""#)?; write_class(c, false, &mut out)?; out.write_char('"')?; } // }}} match c { // {{{ Write css for aligning table cell text Container::TableCell { alignment, .. } if !matches!(alignment, Alignment::Unspecified) => { let a = match alignment { Alignment::Unspecified => unreachable!(), Alignment::Left => "left", Alignment::Center => "center", Alignment::Right => "right", }; write!(out, r#" style="text-align: {};">"#, a)?; } // }}} // {{{ Write language for codeblock Container::CodeBlock { language } => { if language.is_empty() { out.write_str("><code>")?; } else { out.write_str(r#"><code class="language-"#)?; write_attr(language, &mut out)?; out.write_str(r#"">"#)?; } } // }}} Container::Image(..) => out.write_str(r#" alt=""#)?, Container::Math { display } => { out.write_str(r#">"#)?; self.states.push(State::Math(*display)); } _ => out.write_char('>')?, } } match &c { Container::Heading { id, .. } => { out.write_str(r##"<a href="#"##)?; write_attr(id, &mut out)?; out.write_str(r#"">◇</a> "#)?; } Container::Image(..) => { self.states.push(State::TextOnly); } _ => {} } } // }}} // {{{ Container end Event::End(c) => { match &c { Container::Image(..) => { assert!(matches!(self.states.last(), Some(State::TextOnly))); self.states.pop(); } _ => {} } if matches!(self.states.last(), Some(State::TextOnly)) { return Ok(()); } match c { Container::RawBlock { .. } => {} Container::RawInline { .. } => unreachable!(), Container::Footnote { .. } => unreachable!(), // {{{ List Container::List { kind, .. } => { self.list_tightness.pop(); match kind { ListKind::Unordered(..) | ListKind::Task(..) => { out.write_str("</ul>")? } ListKind::Ordered { .. } => out.write_str("</ol>")?, } } // }}} // {{{ Paragraph Container::Paragraph => { if matches!(self.list_tightness.last(), Some(true)) { return Ok(()); } if !self.footnotes.in_epilogue() { out.write_str("</p>")?; } } // }}} // {{{ Image Container::Image(src, ..) => { if !src.is_empty() { out.write_str(r#"" src=""#)?; write_attr(src, &mut out)?; } out.write_str(r#"">"#)?; } // }}} // {{{ Math Container::Math { .. } => { assert!(matches!(self.states.last(), Some(State::Math(_)))); self.states.pop(); out.write_str(r#"</span>"#)?; } // }}} Container::Blockquote => out.write_str("</blockquote>")?, Container::ListItem { .. } => out.write_str("</li>")?, Container::TaskListItem { .. } => out.write_str("</li>")?, Container::DescriptionList => out.write_str("</dl>")?, Container::DescriptionDetails => out.write_str("</dd>")?, Container::Table => out.write_str("</table>")?, Container::TableRow { .. } => out.write_str("</tr>")?, Container::Section { .. } => out.write_str("</section>")?, Container::Div { class: class @ ("aside" | "long-aside" | "char-aside"), } => { if *class == "aside" { self.list_tightness.pop(); } let state = self.states.pop().unwrap(); let State::Aside(mut renderer) = state else { panic!("Finished `aside` element without being in the `Aside` state.") }; assert_eq!(renderer.current(&mut out)?, Some("content")); renderer.finish(&mut out)?; } Container::Div { .. } => out.write_str("</div>")?, Container::Heading { level, .. } => write!(out, "</h{}>", level)?, Container::TableCell { head: false, .. } => out.write_str("</td>")?, Container::TableCell { head: true, .. } => out.write_str("</th>")?, Container::Caption => out.write_str("</caption>")?, Container::DescriptionTerm => out.write_str("</dt>")?, Container::CodeBlock { .. } => out.write_str("</code></pre>")?, Container::Span => out.write_str("</span>")?, Container::Link(..) => out.write_str("</a>")?, Container::Verbatim => out.write_str("</code>")?, Container::Subscript => out.write_str("</sub>")?, Container::Superscript => out.write_str("</sup>")?, Container::Insert => out.write_str("</ins>")?, Container::Delete => out.write_str("</del>")?, Container::Strong => out.write_str("</strong>")?, Container::Emphasis => out.write_str("</em>")?, Container::Mark => out.write_str("</mark>")?, Container::LinkDefinition { .. } => unreachable!(), } } // }}} // {{{ Raw string Event::Str(s) => match self.states.last() { Some(State::TextOnly) => write_attr(s, &mut out)?, Some(State::Raw) => out.write_str(s)?, Some(State::Math(display)) => { let config = pulldown_latex::RenderConfig { display_mode: { use pulldown_latex::config::DisplayMode::*; if *display { Block } else { Inline } }, annotation: None, error_color: (178, 34, 34), xml: true, math_style: pulldown_latex::config::MathStyle::TeX, }; let mut mathml = String::new(); let storage = pulldown_latex::Storage::new(); let parser = pulldown_latex::Parser::new(s, &storage); pulldown_latex::push_mathml(&mut mathml, parser, config).unwrap(); out.write_str(&mathml)?; } _ => write_text(s, &mut out)?, }, // }}} // {{{ Footnote reference Event::FootnoteReference(label) => { let number = self.footnotes.reference(label); if !matches!(self.states.last(), Some(State::TextOnly)) { write!( out, r##"<a id="fnref{}" href="#fn{}" role="doc-noteref"><sup>{}</sup></a>"##, number, number, number )?; } } // }}} // {{{ Symbol Event::Symbol(sym) => write!(out, ":{}:", sym)?, Event::LeftSingleQuote => out.write_str("‘")?, Event::RightSingleQuote => out.write_str("’")?, Event::LeftDoubleQuote => out.write_str("“")?, Event::RightDoubleQuote => out.write_str("”")?, Event::Ellipsis => out.write_str("…")?, Event::EnDash => out.write_str("–")?, Event::EmDash => out.write_str("—")?, Event::NonBreakingSpace => out.write_str(" ")?, Event::Hardbreak => out.write_str("<br>")?, Event::Softbreak => out.write_char('\n')?, // }}} // {{{ Thematic break Event::ThematicBreak(attrs) => { out.write_str("<hr")?; for (a, v) in attrs.unique_pairs() { write!(out, r#" {}=""#, a)?; v.parts().try_for_each(|part| write_attr(part, &mut out))?; out.write_char('"')?; } out.write_str(">")?; } // }}} Event::Escape | Event::Blankline | Event::Attributes(..) => {} } Ok(()) } // {{{ Render epilogue fn render_epilogue(&mut self, mut out: impl std::fmt::Write) -> anyhow::Result<()> { if self.footnotes.reference_encountered() { out.write_str("<section role=\"doc-endnotes\">")?; out.write_str("<hr>")?; out.write_str("<ol>")?; while let Some((number, events)) = self.footnotes.next() { write!(out, "<li id=\"fn{}\">", number)?; let mut unclosed_para = false; for e in events.iter().flatten() { if matches!(&e, Event::Blankline | Event::Escape) { continue; } if unclosed_para { // not a footnote, so no need to add href before para close out.write_str("</p>")?; } self.render_event(e, &mut out)?; unclosed_para = matches!(e, Event::End(Container::Paragraph { .. })) && !matches!(self.list_tightness.last(), Some(true)); } if !unclosed_para { // create a new paragraph out.write_str("<p>")?; } write!( out, "<a href=\"#fnref{}\" role=\"doc-backlink\">\u{21A9}\u{FE0E}</a></p>", number, )?; out.write_str("</li>")?; } out.write_str("</ol>")?; out.write_str("</section>")?; } Ok(()) } // }}} } // {{{ Writing helpers fn write_class<W>(c: &Container, mut first_written: bool, out: &mut W) -> std::fmt::Result where W: std::fmt::Write, { if let Some(cls) = match c { Container::List { kind: ListKind::Task(..), .. } => Some("task-list"), Container::TaskListItem { checked: false } => Some("unchecked"), Container::TaskListItem { checked: true } => Some("checked"), Container::Math { display: false } => Some("math inline"), Container::Math { display: true } => Some("math display"), _ => None, } { first_written = true; out.write_str(cls)?; } if let Container::Div { class } = c { if !class.is_empty() { if first_written { out.write_char(' ')?; } out.write_str(class)?; } } Ok(()) } #[inline] fn write_text(s: &str, out: impl std::fmt::Write) -> std::fmt::Result { write_escape(s, false, out) } #[inline] fn write_attr(s: &str, out: impl std::fmt::Write) -> std::fmt::Result { write_escape(s, true, out) } fn write_escape( mut s: &str, escape_quotes: bool, mut out: impl std::fmt::Write, ) -> std::fmt::Result { let mut ent = ""; while let Some(i) = s.find(|c| { match c { '<' => Some("<"), '>' => Some(">"), '&' => Some("&"), '"' if escape_quotes => Some("""), _ => None, } .map_or(false, |s| { ent = s; true }) }) { out.write_str(&s[..i])?; out.write_str(ent)?; s = &s[i + 1..]; } out.write_str(s) } // }}} // {{{ Footnotes /// Helper to aggregate footnotes for rendering at the end of the document. It will cache footnote /// events until they should be emitted at the end. /// /// When footnotes should be rendered, they can be pulled with the [`Footnotes::next`] function in /// the order they were first referenced. #[derive(Default)] struct Footnotes<'s> { /// Stack of current open footnotes, with label and staging buffer. open: Vec<(&'s str, Vec<Event<'s>>)>, /// Footnote references in the order they were first encountered. references: Vec<&'s str>, /// Events for each footnote. events: HashMap<&'s str, Vec<Event<'s>>>, /// Number of last footnote that was emitted. number: usize, } impl<'s> Footnotes<'s> { /// Returns `true` if any reference has been encountered. fn reference_encountered(&self) -> bool { !self.references.is_empty() } /// Returns `true` if within the epilogue, i.e. if any footnotes have been pulled. fn in_epilogue(&self) -> bool { self.number > 0 } /// Add a footnote reference. fn reference(&mut self, label: &'s str) -> usize { self.references .iter() .position(|t| *t == label) .map_or_else( || { self.references.push(label); self.references.len() }, |i| i + 1, ) } /// Start aggregating a footnote. fn start(&mut self, label: &'s str, events: Vec<Event<'s>>) { self.open.push((label, events)); } /// Obtain the current (most recently started) footnote. fn current(&mut self) -> Option<&mut Vec<Event<'s>>> { self.open.last_mut().map(|(_, e)| e) } /// End the current (most recently started) footnote. fn end(&mut self) { let (label, stage) = self.open.pop().unwrap(); self.events.insert(label, stage); } } impl<'s> Iterator for Footnotes<'s> { type Item = (usize, Option<Vec<Event<'s>>>); fn next(&mut self) -> Option<Self::Item> { self.references.get(self.number).map(|label| { self.number += 1; (self.number, self.events.remove(label)) }) } } // }}}