1
Fork 0
moonythm/src/html.rs

704 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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("&nbsp;")?,
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("&lt;"),
'>' => Some("&gt;"),
'&' => Some("&amp;"),
'"' if escape_quotes => Some("&quot;"),
_ => 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))
})
}
}
// }}}