//! Djot -> HTML renderer adapted from the one in jotdown.
//! Made concrete to avoid generic hell, with added treehouse-specific features.
use std::fmt::Write;
use std::mem;
use std::ops::Range;
use codespan_reporting::diagnostic::Diagnostic;
use jotdown::Alignment;
use jotdown::Container;
use jotdown::Event;
use jotdown::LinkType;
use jotdown::ListKind;
use jotdown::OrderedListNumbering::*;
use jotdown::SpanLinkType;
use crate::config::Config;
use crate::dirs::Dirs;
use crate::state::FileId;
use crate::state::Treehouse;
use crate::vfs;
use crate::vfs::Dir;
use crate::vfs::ImageSize;
use crate::vfs::VPathBuf;
use super::highlight::highlight;
/// [`Render`] implementor that writes HTML output.
pub struct Renderer<'a> {
pub config: &'a Config,
pub dirs: &'a Dirs,
pub treehouse: &'a Treehouse,
pub file_id: FileId,
pub page_id: String,
}
impl Renderer<'_> {
#[must_use]
pub fn render(
self,
events: &[(Event, Range)],
out: &mut String,
) -> Vec> {
let mut writer = Writer {
renderer: self,
raw: Raw::None,
code_block: None,
code_block_text: String::new(),
img_alt_text: 0,
list_tightness: vec![],
not_first_line: false,
ignore_next_event: false,
diagnostics: vec![],
};
for (event, range) in events {
writer
.render_event(event, range.clone(), out)
.expect("formatting event into string should not fail");
}
writer.diagnostics
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum Raw {
#[default]
None,
Html,
Other,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum CodeBlockKind {
PlainText,
SyntaxHighlight,
LiterateProgram {
program_name: String,
placeholder_pic_id: Option,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CodeBlock<'a> {
kind: CodeBlockKind,
language: &'a str,
}
struct Writer<'a> {
renderer: Renderer<'a>,
raw: Raw,
code_block: Option>,
code_block_text: String,
img_alt_text: usize,
list_tightness: Vec,
not_first_line: bool,
ignore_next_event: bool,
diagnostics: Vec>,
}
impl<'a> Writer<'a> {
fn render_event(
&mut self,
e: &Event<'a>,
#[expect(unused)] range: Range,
out: &mut String,
) -> std::fmt::Result {
if matches!(&e, Event::Start(Container::LinkDefinition { .. }, ..)) {
self.ignore_next_event = true;
return Ok(());
}
if matches!(&e, Event::End(Container::LinkDefinition { .. })) {
self.ignore_next_event = false;
return Ok(());
}
// Completely omit section events. The treehouse's structure contains linkable ids in
// branches instead.
if matches!(
&e,
Event::Start(Container::Section { .. }, _) | Event::End(Container::Section { .. })
) {
return Ok(());
}
if self.ignore_next_event {
return Ok(());
}
match e {
Event::Start(c, attrs) => {
if c.is_block() && self.not_first_line {
out.push('\n');
}
if self.img_alt_text > 0 && !matches!(c, Container::Image(..)) {
return Ok(());
}
match &c {
Container::Blockquote => out.push_str(" {
self.list_tightness.push(*tight);
match kind {
ListKind::Unordered | ListKind::Task => out.push_str(" {
out.push_str(" 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}""#)?;
}
}
}
}
Container::ListItem | Container::TaskListItem { .. } => {
out.push_str("- out.push_str("
out.push_str("- out.push_str(label),
Container::Table => out.push_str("
out.push_str(" {}
Container::Div { class } => {
if !class.is_empty() {
out.push('<');
write_attr(class, out);
} else {
out.push_str(" {
if matches!(self.list_tightness.last(), Some(true)) {
return Ok(());
}
out.push_str("
write!(out, r#" out.push_str("| out.push_str(" | out.push_str(" out.push_str("- {
if let Some(program) = attrs.get(":program") {
self.code_block = Some(CodeBlock {
kind: CodeBlockKind::LiterateProgram {
program_name: program.parts().collect(),
placeholder_pic_id: attrs
.get(":placeholder")
.map(|value| value.parts().collect()),
},
language,
});
out.push_str(" CodeBlockKind::SyntaxHighlight,
false => CodeBlockKind::PlainText,
},
language,
});
out.push_str("
out.push_str(" {
if matches!(ty, LinkType::Span(SpanLinkType::Unresolved)) {
out.push_str(" {
self.img_alt_text += 1;
if self.img_alt_text == 1 {
out.push_str(r#" out.push_str(" {
self.raw = if format == &"html" {
Raw::Html
} else {
Raw::Other
};
return Ok(());
}
Container::Subscript => out.push_str(" out.push_str(" out.push_str(" out.push_str(" out.push_str(" out.push_str(" out.push_str(" return Ok(()),
}
for (key, value) in attrs
.into_iter()
.filter(|(a, _)| !(*a == "class" || a.starts_with(':')))
{
write!(out, r#" {key}=""#)?;
value.parts().for_each(|part| write_attr(part, out));
out.push('"');
}
if attrs.into_iter().any(|(a, _)| a == "class")
|| matches!(c, |Container::Math { .. }| Container::List {
kind: ListKind::Task,
..
} | Container::TaskListItem { .. })
{
out.push_str(r#" class=""#);
let mut first_written = false;
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.push_str(cls);
}
for class in attrs
.into_iter()
.filter(|(a, _)| a == &"class")
.map(|(_, cls)| cls)
{
if first_written {
out.push(' ');
}
first_written = true;
class.parts().for_each(|part| write_attr(part, out));
}
out.push('"');
}
match c {
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};">"#)?;
}
Container::CodeBlock { language } => {
if language.is_empty() {
out.push_str(">");
} else {
let code_block = self.code_block.as_ref().unwrap();
if let CodeBlockKind::LiterateProgram { program_name, .. } =
&code_block.kind
{
out.push_str(r#" data-program=""#);
write_attr(&self.renderer.page_id, out);
out.push(':');
write_attr(program_name, out);
out.push('"');
out.push_str(r#" data-language=""#);
write_attr(language, out);
out.push('"');
if *language == "output" {
out.push_str(r#" data-mode="output""#);
} else {
out.push_str(r#" data-mode="input""#);
}
}
out.push('>');
if let CodeBlockKind::LiterateProgram {
placeholder_pic_id: Some(placeholder_pic_id),
..
} = &code_block.kind
{
out.push_str(
r#" ');
}
if let (CodeBlockKind::LiterateProgram { .. }, "output") =
(&code_block.kind, *language)
{
out.push_str(r#""#);
} else {
out.push_str(r#""#);
}
}
}
Container::Image(..) => {
if self.img_alt_text == 1 {
out.push_str(r#" alt=""#);
}
}
Container::Math { display } => {
out.push_str(if *display { r#">\["# } else { r#">\("# });
}
_ => out.push('>'),
}
}
Event::End(c) => {
if c.is_block_container() {
out.push('\n');
}
if self.img_alt_text > 0 && !matches!(c, Container::Image(..)) {
return Ok(());
}
match c {
Container::Blockquote => out.push_str(""),
Container::List { kind, .. } => {
self.list_tightness.pop();
match kind {
ListKind::Unordered | ListKind::Task => out.push_str(""),
ListKind::Ordered { .. } => out.push_str(""),
}
}
Container::ListItem | Container::TaskListItem { .. } => {
out.push_str("");
}
Container::DescriptionList => out.push_str(""),
Container::DescriptionDetails => out.push_str(""),
Container::Footnote { label } => out.push_str(label),
Container::Table => out.push_str(" |
"),
Container::TableRow { .. } => out.push_str(""),
Container::Section { .. } => {}
Container::Div { class } => {
if !class.is_empty() {
out.push_str("");
write_attr(class, out);
out.push('>');
} else {
out.push_str("");
}
}
Container::Paragraph => {
if matches!(self.list_tightness.last(), Some(true)) {
return Ok(());
}
out.push_str("
");
}
Container::Heading { level, .. } => write!(out, "")?,
Container::TableCell { head: false, .. } => out.push_str(""),
Container::TableCell { head: true, .. } => out.push_str(""),
Container::Caption => out.push_str(""),
Container::DescriptionTerm => out.push_str(""),
Container::CodeBlock { language } => {
let code_block = self.code_block.take().unwrap();
let rendered =
if let Some(syntax) = self.renderer.config.syntaxes.get(*language) {
let mut rendered = String::new();
highlight(&mut rendered, syntax, &self.code_block_text);
self.code_block_text.clear();
rendered
} else {
mem::take(&mut self.code_block_text)
};
out.push_str(&rendered);
out.push_str(match &code_block.kind {
CodeBlockKind::PlainText | CodeBlockKind::SyntaxHighlight => {
""
}
CodeBlockKind::LiterateProgram { .. } if *language == "output" => {
""
}
CodeBlockKind::LiterateProgram { .. } => {
""
}
});
}
Container::Span => out.push_str(""),
Container::Link(..) => out.push_str(""),
Container::Image(src, link_type) => {
if self.img_alt_text == 1 {
if !src.is_empty() {
out.push_str(r#"" "#);
if let SpanLinkType::Unresolved = link_type {
// TODO: Image size.
let resolved_image =
resolve_image_link(self.renderer.config, src);
let size = if let Some(ResolvedImageLink::VPath(vpath)) =
&resolved_image
{
vfs::query::(&self.renderer.dirs.pic, vpath)
} else {
None
};
if let Some(size) = size {
write!(
out,
r#" width="{}" height="{}""#,
size.width, size.height
)?;
}
out.push_str(r#" src=""#);
if let Some(resolved) = resolve_link(
self.renderer.config,
self.renderer.treehouse,
self.renderer.dirs,
src,
) {
write_attr(&resolved, out);
} else {
write_attr(src, out);
}
out.push('"');
} else {
out.push_str(r#" src=""#);
write_attr(src, out);
out.push('"');
}
}
out.push('>');
}
self.img_alt_text -= 1;
}
Container::Verbatim => out.push_str(""),
Container::Math { display } => {
out.push_str(if *display {
r#"\]"#
} else {
r#"\)"#
});
}
Container::RawBlock { .. } | Container::RawInline { .. } => {
self.raw = Raw::None;
}
Container::Subscript => out.push_str(""),
Container::Superscript => out.push_str(""),
Container::Insert => out.push_str(""),
Container::Delete => out.push_str(""),
Container::Strong => out.push_str(""),
Container::Emphasis => out.push_str(""),
Container::Mark => out.push_str(""),
Container::LinkDefinition { .. } => unreachable!(),
}
}
Event::Str(s) => match self.raw {
Raw::None if self.img_alt_text > 0 => write_attr(s, out),
Raw::None => {
if self.code_block.is_some() {
self.code_block_text.push_str(s);
} else {
write_text(s, out);
}
}
Raw::Html => out.push_str(s),
Raw::Other => {}
},
Event::FootnoteReference(label) => {
out.push_str(label);
}
Event::Symbol(sym) => {
if let Some(vpath) = self.renderer.config.emoji.get(sym.as_ref()) {
let branch_id = self
.renderer
.treehouse
.branches_by_named_id
.get(&format!("emoji/{sym}"))
.copied();
if let Some(branch) =
branch_id.map(|id| self.renderer.treehouse.tree.branch(id))
{
out.push_str(r#""#)
}
let url = vfs::url(
&self.renderer.config.site,
&*self.renderer.dirs.emoji,
vpath,
)
.expect("emoji directory is not anchored anywhere");
// TODO: this could do with better alt text
write!(
out,
r#"
(&self.renderer.dirs.emoji, vpath)
{
write!(
out,
r#" width="{}" height="{}""#,
image_size.width, image_size.height
)?;
}
out.push('>');
if branch_id.is_some() {
out.push_str("");
}
} else {
write!(
out,
r#":{sym}:"#,
)?
}
}
Event::LeftSingleQuote => out.push('‘'),
Event::RightSingleQuote => out.push('’'),
Event::LeftDoubleQuote => out.push('“'),
Event::RightDoubleQuote => out.push('”'),
Event::Ellipsis => out.push('…'),
Event::EnDash => out.push('–'),
Event::EmDash => out.push('—'),
Event::NonBreakingSpace => out.push_str(" "),
Event::Hardbreak => out.push_str("
\n"),
Event::Softbreak => out.push('\n'),
Event::Escape | Event::Blankline => {}
Event::ThematicBreak(attrs) => {
if self.not_first_line {
out.push('\n');
}
out.push_str("
');
}
}
self.not_first_line = true;
Ok(())
}
}
fn write_text(s: &str, out: &mut String) {
write_escape(s, false, out)
}
fn write_attr(s: &str, out: &mut String) {
write_escape(s, true, out)
}
fn write_escape(mut s: &str, escape_quotes: bool, out: &mut String) {
let mut ent = "";
while let Some(i) = s.find(|c| {
match c {
'<' => Some("<"),
'>' => Some(">"),
'&' => Some("&"),
'"' if escape_quotes => Some("""),
_ => None,
}
.is_some_and(|s| {
ent = s;
true
})
}) {
out.push_str(&s[..i]);
out.push_str(ent);
s = &s[i + 1..];
}
out.push_str(s);
}
pub fn resolve_link(
config: &Config,
treehouse: &Treehouse,
dirs: &Dirs,
link: &str,
) -> Option {
link.split_once(':').and_then(|(kind, linked)| match kind {
"def" => config.defs.get(linked).cloned(),
"branch" => treehouse
.branches_by_named_id
.get(linked)
.map(|&branch_id| {
format!(
"{}/b?{}",
config.site,
treehouse.tree.branch(branch_id).attributes.id
)
}),
"page" => Some(config.page_url(linked)),
"pic" => Some(config.pic_url(&*dirs.pic, linked)),
_ => None,
})
}
#[derive(Debug, Clone)]
pub enum ResolvedImageLink {
VPath(VPathBuf),
Url(String),
}
impl ResolvedImageLink {
pub fn into_url(self, config: &Config, pics_dir: &dyn Dir) -> Option {
match self {
ResolvedImageLink::VPath(vpath) => vfs::url(&config.site, pics_dir, &vpath),
ResolvedImageLink::Url(url) => Some(url),
}
}
}
pub fn resolve_image_link(config: &Config, link: &str) -> Option {
link.split_once(':').and_then(|(kind, linked)| match kind {
"def" => config.defs.get(linked).cloned().map(ResolvedImageLink::Url),
"pic" => config
.pics
.get(linked)
.cloned()
.map(ResolvedImageLink::VPath),
_ => None,
})
}