#![allow(clippy::doc_markdown, reason = "Too many false positives")]
#![allow(clippy::multiple_crate_versions, reason = "Cannot resolve all these")]
use comrak::{
ComrakOptions,
ComrakExtensionOptions,
ComrakParseOptions,
ComrakRenderOptions,
ComrakPlugins,
ListStyleType,
markdown_to_html_with_plugins,
plugins::syntect::SyntectAdapter,
};
use nipper::{Document, Selection};
use rubedo::sugar::s;
use serde::{Deserialize, Serialize};
use tendril::StrTendril;
#[derive(Debug, Deserialize, Serialize)]
pub struct Heading {
level: u8,
id: String,
text: String,
}
#[must_use]
pub fn parse(markdown: &str, remove_title: bool) -> (String, Vec<Heading>, StrTendril) {
let adaptor = SyntectAdapter::new(Some("base16-ocean.dark"));
let mut plugins = ComrakPlugins::default();
plugins.render.codefence_syntax_highlighter = Some(&adaptor);
let html = markdown_to_html_with_plugins(
markdown,
&ComrakOptions {
extension: ComrakExtensionOptions {
strikethrough: true,
tagfilter: true,
table: true,
autolink: true,
tasklist: true,
superscript: true,
header_ids: Some(s!("")),
footnotes: true,
description_lists: true,
front_matter_delimiter: Some(s!("---")),
shortcodes: true,
..Default::default()
},
parse: ComrakParseOptions {
smart: true,
default_info_string: Some(s!("")),
relaxed_tasklist_matching: true,
..Default::default()
},
render: ComrakRenderOptions {
hardbreaks: false,
github_pre_lang: false,
full_info_string: true,
width: 80,
unsafe_: true,
escape: false,
list_style: ListStyleType::Dash,
sourcepos: false,
..Default::default()
},
},
&plugins,
);
let document = Document::from(&html);
let title = find_title(&document, remove_title);
let toc = find_headings(&document);
process_details(&document.select("blockquote"));
process_callouts(&document.select("blockquote"));
process_headings(&document);
(title, toc, document.html())
}
pub fn find_title(document: &Document, remove_title: bool) -> String {
let mut title = document.select("h1:first-child").text().to_string();
if title.is_empty() {
"Untitled".clone_into(&mut title);
}
if remove_title {
document.select("h1:first-child").remove();
}
title
}
pub fn find_headings(document: &Document) -> Vec<Heading> {
let mut toc: Vec<Heading> = vec![];
for element in document.select("h1, h2, h3, h4, h5, h6").iter() {
let Some(node) = element.get(0) else {
continue;
};
let Some(id) = element.select("a").attr("id").map(|s| s.to_string()) else {
continue;
};
let Some(tag) = node.node_name().map(|s| s.to_string().to_lowercase()) else {
continue;
};
let Some(level) = tag.strip_prefix('h').map(|s| s.parse::<u8>().unwrap_or(6)) else {
continue;
};
let text = node.text().to_string();
toc.push(Heading { level, id, text });
}
toc
}
#[allow(clippy::allow_attributes, reason = "using expect below doesn't work")]
#[allow(clippy::missing_panics_doc, reason = "Infallible")]
pub fn process_details(blockquotes: &Selection<'_>) {
for mut blockquote in blockquotes.iter() {
let mut paragraph = blockquote.select("p:first-child").first();
let para_text = paragraph.text().to_string();
if para_text.starts_with("->") {
process_details(&blockquote.select("blockquote"));
let mut summary = vec![];
let Some(mut para_html) = paragraph.html()
.strip_prefix("<p>")
.and_then(|s| s.strip_suffix("</p>"))
.map(|s| s.trim().to_owned())
else {
continue;
};
#[expect(clippy::unwrap_used, reason = "The prefix is checked in the loop and so must be present")]
while para_html.starts_with("->") {
if let Some((line, rest)) = para_html.split_once('\n') {
summary.push(line.strip_prefix("->").unwrap().trim().to_owned());
para_html = rest.trim().to_owned();
} else {
summary.push(para_html.strip_prefix("->").unwrap().trim().to_owned());
para_html = String::new();
}
}
paragraph.replace_with_html(format!(
r"<summary>{}</summary><p>{para_html}</p>",
summary.join("\n"),
));
if let Some(content) = blockquote.html()
.strip_prefix("<blockquote>")
.and_then(|s| s.strip_suffix("</blockquote>"))
{
blockquote.replace_with_html(format!(r"<details>{content}</details>"));
}
}
}
}
pub fn process_callouts(blockquotes: &Selection<'_>) {
for mut blockquote in blockquotes.iter() {
let mut paragraph = blockquote.select("p:first-child").first();
let mut strong = paragraph.select("strong:first-child").first();
let para_text = paragraph.text().to_string();
let strong_text = strong.text().to_string();
if strong_text.is_empty() || strong_text.contains(' ') {
continue;
}
let class = strong_text.replace(|c: char| !c.is_alphanumeric(), "").to_lowercase();
blockquote.add_class("callout");
blockquote.add_class(&class);
let para_html: String;
if para_text
.strip_prefix(&strong_text)
.is_some_and(|stripped| stripped.starts_with(':'))
{
para_html = paragraph.html()
.strip_prefix("<p>")
.and_then(|s| s.strip_suffix("</p>"))
.map_or_else(|| paragraph.html().to_string(), ToOwned::to_owned)
;
paragraph.remove();
} else {
para_html = strong.html().to_string();
strong.remove();
}
let open = !["image", "images", "screenshot", "screenshots"].contains(&&*class);
let mut chld_html = blockquote.children().iter()
.map(|c| c.html().to_string())
.collect::<Vec<String>>()
.join("\n")
;
chld_html
.replace("<p></p>", "")
.trim()
.clone_into(&mut chld_html)
;
blockquote.set_html(
if chld_html.is_empty() {
format!(r"<p>{para_html}</p>")
} else {
format!(
r#"<details {} class="callout-collapse"><summary>{para_html}</summary>{chld_html}</details>"#,
if open { "open" } else { "" },
)
}
);
process_callouts(&blockquote.select("blockquote"));
}
}
pub fn process_headings(document: &Document) {
let mut headings = vec!["h2", "h3", "h4", "h5", "h6"];
loop {
let Some(heading_tag) = headings.last().map(ToOwned::to_owned) else {
continue;
};
let mut heading_html = String::new();
let mut buffer_html = String::new();
let mut active = false;
let mut elements = document.select("body > *").iter().enumerate().peekable();
while let Some((_, mut element)) = elements.next() {
let next_element = elements.peek().cloned();
let next_tag = next_element.clone().map_or_else(|| s!(""), |(_, el)|
el.get(0).map_or_else(|| s!(""), |node|
node.node_name().map_or_else(|| s!(""), |name| name.to_string().to_lowercase())
),
);
if let Some(node) = element.get(0) {
if node.node_name().is_some() {
let Some(tag) = node.node_name().map(|s| s.to_string().to_lowercase()) else {
continue;
};
if !active && tag == heading_tag {
active = true;
heading_html = element.html().to_string();
element.remove();
continue;
}
}
}
if !active {
continue;
}
buffer_html.push_str(&element.html());
if
(!next_tag.is_empty() && headings.contains(&&*next_tag))
|| next_tag == "section"
|| next_element.is_none()
{
element.replace_with_html(format!(
r#"<details open class="heading-collapse {heading_tag}"><summary>{heading_html}</summary>{buffer_html}</details>"#,
));
active = false;
heading_html = String::new();
buffer_html = String::new();
continue;
}
element.remove();
}
_ = headings.pop();
if headings.is_empty() {
break;
}
}
}