1036 lines
28 KiB
Rust
1036 lines
28 KiB
Rust
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))
|
||
})
|
||
}
|
||
}
|
||
// }}}
|