use std::cmp::Ordering; use std::collections::HashMap; use std::fmt::Display; use anyhow::anyhow; use anyhow::bail; use anyhow::Context; use chrono::DateTime; use chrono::NaiveDate; use chrono::TimeZone; use jotdown::Alignment; use jotdown::AttributeValue; use jotdown::Container; use jotdown::Event; use jotdown::LinkType; use jotdown::ListKind; use jotdown::OrderedListNumbering::*; use jotdown::SpanLinkType; use tree_sitter::Language; use tree_sitter_highlight::Highlight; use tree_sitter_highlight::HighlightConfiguration; use tree_sitter_highlight::HighlightEvent; use tree_sitter_highlight::Highlighter; use crate::metadata::has_role; use crate::metadata::PageMetadata; use crate::metadata::PageRoute; use crate::template; use crate::template::TemplateRenderer; pub struct Writer<'s> { pub states: Vec<State<'s>>, list_tightness: Vec<bool>, footnotes: Footnotes<'s>, metadata: &'s PageMetadata<'s>, pages: &'s [PageMetadata<'s>], base_url: &'s str, } #[derive(Debug, Clone)] pub enum State<'s> { TextOnly, Ignore, Raw, Figure, Math(bool), CodeBlock(String), Aside(TemplateRenderer<'s>), Article(TemplateRenderer<'s>), Footnote(Vec<jotdown::Event<'s>>), Datetime(String), Date(String), } impl<'s> Writer<'s> { pub fn new(metadata: &'s PageMetadata, pages: &'s [PageMetadata], base_url: &'s str) -> Self { Self { list_tightness: Vec::new(), states: Vec::new(), footnotes: Footnotes::default(), metadata, pages, base_url, } } #[allow(clippy::single_match)] pub fn render_event( &mut self, e: &Event<'s>, out: &mut impl std::fmt::Write, ) -> anyhow::Result<()> { // {{{ Handle "footnote" states if matches!(self.states.last(), Some(State::Footnote(_))) { if let Event::End(Container::Footnote { label }) = e { let Some(State::Footnote(events)) = self.states.pop() else { unreachable!() }; self.footnotes.insert(label, events); } else { let Some(State::Footnote(events)) = self.states.last_mut() else { unreachable!() }; events.push(e.clone()); return Ok(()); } } // }}} // {{{ Handle "text-only" states if matches!(self.states.last(), Some(State::TextOnly)) { match e { Event::End(Container::Image(..)) => { self.states.pop(); } Event::Str(s) => { write!(out, "{}", Escaped(s))?; return Ok(()); } _ => return Ok(()), } } // }}} // {{{ Handle "ignore" states if matches!(self.states.last(), Some(State::Ignore)) { match e { Event::End( Container::RawBlock { .. } | Container::RawInline { .. } | Container::LinkDefinition { .. } | Container::Div { .. }, ) => { self.states.pop(); return Ok(()); } _ => return Ok(()), } } // }}} match e { // {{{ Container start Event::Start(c, attrs) => { match &c { // {{{ Section Container::Section { id } => { if self.metadata.title.id == *id { if matches!(self.metadata.route, PageRoute::Post(_)) { let mut renderer = template!("templates/post.html", out)?; assert_eq!(renderer.current(), Some("attrs")); write!(out, "{}", Attr("aria-labelledby", id))?; renderer.next(out)?; self.states.push(State::Article(renderer)); } } else { write!(out, "<section {}>", Attr("aria-labelledby", id))?; } } // }}} // {{{ Aside Container::Div { class: class @ ("aside" | "long-aside" | "char-aside"), } => { let mut renderer = if *class == "aside" { template!("templates/aside.html", out)? } else if *class == "char-aside" { template!("templates/character-aside.html", out)? } else { template!("templates/long-aside.html", out)? }; while let Some(label) = renderer.current() { match label { "id" => { let id = attrs.get_value("id").ok_or_else(|| { anyhow!("Cannot find `id` attribute on `aside` element") })?; write_attribute(out, &id)?; } "character" => { let character = attrs.get_value("character").ok_or_else(|| { anyhow!("Cannot find `character` attribute on `aside` element") })?; write_attribute(out, &character)?; } "title" => { let title = attrs.get_value("title").ok_or_else(|| { anyhow!("Cannot find `title` attribute on `aside` element") })?; write_attribute(out, &title)?; } _ => break, } renderer.next(out)?; } self.states.push(State::Aside(renderer)); } // }}} // {{{ List Container::List { kind, tight } => { self.list_tightness.push(*tight); match kind { ListKind::Unordered(..) => 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)?; } write!(out, ">")?; } ListKind::Task(_) => bail!("Task lists are not supported"), } } // }}} // {{{ Link Container::Link(dst, ty) => { if matches!(ty, LinkType::Span(SpanLinkType::Unresolved)) { bail!("Unresolved url {dst:?}") } else { let prefix = if matches!(ty, LinkType::Email) { "mailto:" } else { "" }; write!(out, r#"<a href="{prefix}{}">"#, Escaped(dst))?; } } // }}} // {{{ Table cell Container::TableCell { head, alignment, .. } => { if *head { out.write_str("<td")?; } else { out.write_str("<th")?; } if !matches!(alignment, Alignment::Unspecified) { // TODO: move this to css let a = match alignment { Alignment::Unspecified => unreachable!(), Alignment::Left => "left", Alignment::Center => "center", Alignment::Right => "right", }; write!(out, r#" style="text-align: {};""#, a)?; } write!(out, ">")?; } // }}} // {{{ Heading Container::Heading { level, id, .. } => { write!( out, r##"<h{level} id="{}"><a class="heading-anchor" href="#{}">◇</a> "##, Escaped(id), Escaped(id) )?; } // }}} // {{{ Paragraph Container::Paragraph => { if self.list_tightness.last() == Some(&true) { return Ok(()); } out.write_str("<p>")?; } // }}} // {{{ Post list Container::Div { class: "posts" } => { write!(out, r#"<ol class="article-list">"#)?; for post in self.pages { // Skip non-posts if !matches!(post.route, PageRoute::Post(_)) { continue; } // Skip hidden pages if post.config.hidden { continue; } // Skip drafts if std::env::var("MOONYTHM_DRAFTS").unwrap_or_default() != "1" && post.config.created_at.is_none() { continue; } template!("templates/post-summary.html", out)?.feed( out, |label, out| { match label { "id" => { if let PageRoute::Post(id) = &post.route { write!(out, "{id}")?; } else { unreachable!() } } "title" => { for event in &post.title.events { self.render_event(event, out)?; } } "description" => { for event in &post.description { self.render_event(event, out)?; } } _ => { if !Self::write_metadata_label( out, label, post, self.base_url, )? { bail!("Unknown label {label} in `post-summary` template"); }; } } Ok(true) }, )?; } write!(out, "</ol>")?; // We don't care about the contents of this block self.states.push(State::Ignore); } // }}} // {{{ Table of contents // This component renders out a nice tree of all the headings in the article. Container::Div { class: "toc" } => { template!("templates/table-of-contents.html", out)?.feed_fully( out, |label, out| { if label == "content" { // Sometimes we can have TOCs that look like this: // # foo // ## bar // ### goo // # help // // In this case, we need to close two different sublists when // going from ### goo to # help. To achieve this, we use this // vec as a stack of all the different levels we are yet to // close out. // // Note that the list for the initial level is included in the // template, as it would never get opened/closed out otherwise // (there can be no level smaller than 1). let mut level_stack = Vec::with_capacity(6); level_stack.push(2); for (i, heading) in self.metadata.toc.iter().enumerate() { // We exclude the article title from the table of contents. if heading.level == 1 { continue; } write!(out, r##"<li><a href="#{}">"##, heading.id)?; for event in &heading.events { self.render_event(event, out)?; } // We only close the <a> here, as we might want to include a // sublist inside the same <li> element. write!(out, "</a>")?; let next_level = self.metadata.toc.get(i + 1).map_or(2, |h| h.level); match heading.level.cmp(&next_level) { Ordering::Equal => { write!(out, "</li>")?; } Ordering::Less => { write!(out, "<ol>")?; level_stack.push(next_level); } Ordering::Greater => { while level_stack.last().unwrap() > &next_level { write!(out, "</ol></li>")?; level_stack.pop(); } } } } Ok(true) } else { Ok(false) } }, )?; // We don't care about the contents of this block self.states.push(State::Ignore); } // }}} // {{{ Figure Container::Div { class: "figure" } => { self.states.push(State::Figure); let alt = attrs.get_value("alt").ok_or_else(|| { anyhow!("Figure element encountered without an `alt` attribute") })?; let src = attrs.get_value("src").ok_or_else(|| { anyhow!("Figure element encountered without a `src` attribute") })?; write!(out, r#"<figure><img alt=""#)?; write_attribute(out, &alt)?; write!(out, r#"" src=""#)?; write_attribute(out, &src)?; write!(out, r#""><figcaption>"#)?; } // }}} // {{{ Div Container::Div { class } => { if has_role(attrs, "description") { self.states.push(State::Ignore); } else { write!(out, "<div{}>", Attr("class", class))?; } } // }}} // {{{ Raw block Container::RawBlock { format } | Container::RawInline { format } => { if format == &"html" { self.states.push(State::Raw); } else { self.states.push(State::Ignore); }; } // }}} Container::CodeBlock { .. } => { self.states.push(State::CodeBlock(String::new())); out.write_str("<pre><code>")?; } Container::Math { display } => { self.states.push(State::Math(*display)); } Container::Image(_, _) => { self.states.push(State::TextOnly); out.write_str(r#"<img alt=""#)?; } Container::Footnote { .. } => self.states.push(State::Footnote(Vec::new())), Container::LinkDefinition { .. } => self.states.push(State::Ignore), Container::Blockquote => out.write_str("<blockquote>")?, Container::ListItem { .. } => 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::Caption => out.write_str("<caption>")?, Container::DescriptionTerm => out.write_str("<dt>")?, Container::Span => { if has_role(attrs, "datetime") { self.states.push(State::Datetime(String::new())) } else if has_role(attrs, "date") { self.states.push(State::Date(String::new())) } else { 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>")?, e => bail!("DJot element {e:?} is not supported"), } } // }}} // {{{ Container end Event::End(c) => { match c { Container::Footnote { .. } => unreachable!(), // {{{ Raw block Container::RawBlock { .. } | Container::RawInline { .. } => { // Sanity check assert!(matches!(self.states.last(), Some(State::Raw))); self.states.pop(); } // }}} // {{{ List Container::List { kind, .. } => { self.list_tightness.pop(); match kind { ListKind::Unordered(..) => out.write_str("</ul>")?, ListKind::Ordered { .. } => out.write_str("</ol>")?, // We error out when the task list begins ListKind::Task(..) => unreachable!(), } } // }}} // {{{ Paragraph Container::Paragraph => { if matches!(self.list_tightness.last(), Some(true)) { return Ok(()); } out.write_str("</p>")?; } // }}} // {{{ Math Container::Math { .. } => { // Sanity check assert!(matches!(self.states.last(), Some(State::Math(_)))); self.states.pop(); } // }}} // {{{ Section Container::Section { id, .. } => { if self.metadata.title.id == *id { if matches!(self.states.last(), Some(State::Article(_))) { let Some(State::Article(renderer)) = self.states.pop() else { unreachable!() }; renderer.finish(out)?; } } else { out.write_str("</section>")? } } // }}} // {{{ Aside Container::Div { class: "aside" | "long-aside" | "char-aside", } => { let state = self.states.pop().unwrap(); let State::Aside(renderer) = state else { panic!("Finished `aside` element without being in the `Aside` state.") }; renderer.finish(out)?; } // }}} // {{{ Figure Container::Div { class: "figure" } => { let State::Figure = self.states.pop().unwrap() else { panic!( "Arrived at end of figure without being in the approriate state." ); }; write!(out, "</figcaption></figure>")?; } // }}} Container::Heading { level, .. } => { write!(out, "</h{}>", level)?; // {{{ Article title if let Some(State::Article(renderer)) = self.states.last_mut() { if renderer.current() == Some("title") { while let Some(label) = renderer.next(out)? { if !Self::write_metadata_label( out, label, self.metadata, self.base_url, )? { break; } } } } // }}} } Container::Image(src, ..) => { write!(out, r#"" {}>"#, Attr("src", src))?; } Container::Blockquote => out.write_str("</blockquote>")?, Container::ListItem { .. } => 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::Div { .. } => out.write_str("</div>")?, 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>")?, // {{{ Syntax highlighting Container::CodeBlock { language } => { let Some(State::CodeBlock(buffer)) = self.states.pop() else { panic!("Arrived at end of code block without being in the approriate state."); }; let grammar = match *language { "rust" => Some(( "rust", Language::new(tree_sitter_rust::LANGUAGE), tree_sitter_rust::HIGHLIGHTS_QUERY, tree_sitter_rust::INJECTIONS_QUERY, )), "djot" => Some(( "dj", tree_sitter_djot::language(), tree_sitter_djot::HIGHLIGHTS_QUERY, tree_sitter_djot::INJECTIONS_QUERY, )), "html" => Some(( "html", Language::new(tree_sitter_html::LANGUAGE), tree_sitter_html::HIGHLIGHTS_QUERY, tree_sitter_html::INJECTIONS_QUERY, )), // "tex" => Some(( // "tex", // crate::bindings::tree_sitter_latex::language(), // "", // "", // )), _ => None, }; if let Some((ft, ts_language, highlights, injections)) = grammar { let mut highlighter = Highlighter::new(); let mut config = HighlightConfiguration::new( ts_language, ft, highlights, injections, "", )?; let highlight_names = [ "attribute", "comment", "comment.documentation", "constant", "constant.builtin", "constructor", "function", "function.builtin", "function.macro", "function.method", "keyword", "label", "operator", "property", "punctuation", "punctuation.bracket", "punctuation.delimiter", "string", "string.special", "tag", "type", "type.builtin", "variable", "variable.builtin", "variable.parameter", ]; let highlight_classes = highlight_names .iter() .map(|s| s.replace(".", "-")) .collect::<Vec<_>>(); config.configure(&highlight_names); let highlights = highlighter .highlight(&config, buffer.as_bytes(), None, |_| None)?; for event in highlights { match event? { HighlightEvent::Source { start, end } => { write!(out, "{}", Escaped(&buffer[start..end]))?; } HighlightEvent::HighlightStart(Highlight(index)) => { write!( out, r#"<span class="{}">"#, highlight_classes[index] )?; } HighlightEvent::HighlightEnd => { write!(out, r#"</span>"#)?; } } } } else { write!(out, "{}", Escaped(&buffer))?; } out.write_str("</code></pre>")? } // }}} Container::Span => { if matches!(self.states.last(), Some(State::Datetime(_))) { let Some(State::Datetime(buffer)) = self.states.pop() else { unreachable!() }; write_datetime(out, &DateTime::parse_from_rfc3339(&buffer)?)?; } else if matches!(self.states.last(), Some(State::Date(_))) { let Some(State::Date(buffer)) = self.states.pop() else { unreachable!() }; let date = NaiveDate::parse_from_str(&buffer, "%Y-%m-%d") .with_context(|| "Failed to parse date inside span")?; write!( out, r#"<time datetime="{}">{}</time>"#, date.format("%Y-%m-%d"), date.format("%a, %d %b %Y") )?; } else { 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>")?, e => bail!("DJot element {e:?} is not supported"), } } // }}} // {{{ Raw string Event::Str(s) => match self.states.last_mut() { Some(State::Raw) => out.write_str(s)?, Some(State::CodeBlock(buffer) | State::Datetime(buffer) | State::Date(buffer)) => { buffer.push_str(s) } // {{{ Math 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!(out, "{}", Escaped(s))?, }, // }}} // {{{ Footnote reference Event::FootnoteReference(label) => { let number = self.footnotes.reference(label); if !matches!(self.states.last(), Some(State::TextOnly)) { write!( out, r##" <sup> <a id="fnref{number}" href="#fn{number}" role="doc-noteref"> {number} </a> </sup> "## )?; } } // }}} // {{{ 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')?, // }}} Event::ThematicBreak(_) => out.write_str("<hr />")?, Event::Escape | Event::Blankline | Event::Attributes(_) => {} } Ok(()) } // {{{ Render epilogue pub fn render_epilogue(&mut self, out: &mut impl std::fmt::Write) -> anyhow::Result<()> { if self.footnotes.reference_encountered() { // TODO: rewrite this using a template out.write_str("<section role=\"doc-endnotes\"><hr/><ol>")?; while let Some((number, events)) = self.footnotes.next() { write!(out, r#"<li id="fn{number}">"#)?; for e in events.iter().flatten() { self.render_event(e, out)?; } write!( out, r##" <a href="#fnref{number}" role="doc-backlink"> Return to content ↩︎ </a></li> "##, )?; } out.write_str("</ol></section>")?; } Ok(()) } // }}} // {{{ Fill in metadata labels fn write_metadata_label( out: &mut impl std::fmt::Write, label: &str, meta: &PageMetadata, base_url: &str, ) -> anyhow::Result<bool> { match label { "posted_on" => { if let Some(d) = meta.config.created_at { write!(out, "Posted on ")?; write_datetime(out, &d)?; } else { write!(out, "Being conjured ")?; } } "base_url" => { write!(out, "{}", base_url)?; } "updated_on" => { write_datetime(out, &meta.last_modified)?; } "word_count" => { let wc = meta.word_count; if wc < 400 { write!(out, "{}", wc)?; } else if wc < 1000 { write!(out, "{}", wc / 10 * 10)?; } else if wc < 2000 { write!(out, "{}", wc / 100 * 100)?; } else { write!(out, "{} thousand", wc / 1000)?; } } "reading_duration" => { let minutes = meta.word_count / 200; if minutes == 0 { let seconds = meta.word_count * 60 / 200; write!(out, "very short {seconds} second")?; } else if minutes < 10 { write!(out, "short {minutes} minute")?; } else if minutes < 20 { write!(out, "somewhat short {minutes} minute")?; } else if minutes < 30 { write!(out, "somewhat long {minutes}")?; } else if minutes < 60 { write!(out, "long {minutes}")?; } else { let hours = minutes / 60; let minutes = minutes % 60; write!(out, "very long {hours} hour and {minutes} minute")?; } } "source_url" => { write!( out, "https://git.moonythm.dev/prescientmoon/moonythm/src/branch/main/{}", meta.source_path.to_str().unwrap() )?; } "changelog_url" => { write!( out, "https://git.moonythm.dev/prescientmoon/moonythm/commits/branch/main/{}", meta.source_path.to_str().unwrap() )?; } _ => { return Ok(false); } } Ok(true) } // }}} } // {{{ HTMl escaper pub struct Escaped<'a>(&'a str); impl<'s> Display for Escaped<'s> { fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let mut s = self.0; let mut ent = ""; while let Some(i) = s.find(|c| { match c { '<' => Some("<"), '>' => Some(">"), '&' => Some("&"), '"' => 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) } } // }}} // {{{ Render attributes pub struct Attr<'a>(&'static str, &'a str); impl<'s> Display for Attr<'s> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if !self.1.is_empty() { write!(f, r#" {}="{}""#, self.0, Escaped(self.1))?; } Ok(()) } } // }}} // {{{ Render datetimes #[inline] fn write_datetime<T: TimeZone>( out: &mut impl std::fmt::Write, datetime: &DateTime<T>, ) -> std::fmt::Result { let datetime = datetime.to_utc(); write!( out, r#"<time datetime="{}">{}</time>"#, datetime.to_rfc3339_opts(chrono::SecondsFormat::Millis, false), datetime.format("%a, %d %b %Y") ) } // }}} // {{{ Jotdown attribute helpers #[inline] fn write_attribute(out: &mut impl std::fmt::Write, attr: &AttributeValue) -> std::fmt::Result { attr.parts() .try_for_each(|part| write!(out, "{}", Escaped(part))) } // }}} // {{{ 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> { /// 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() } /// 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, ) } /// Insert a new footnote to be renderer later fn insert(&mut self, label: &'s str, events: Vec<jotdown::Event<'s>>) { self.events.insert(label, events); } } 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)) }) } } // }}}