use std::collections::HashMap;
use pulldown_cmark::{html, CowStr, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use serde_json::Value;
use crate::route;
pub(crate) fn render_markdown(body: &str, link_base: Option<&str>, pretty_urls: bool) -> String {
let mut slugs = heading_slugs(body).into_iter();
let parser = Parser::new_ext(body, Options::empty());
let events = parser.map(|event| rewrite_event(event, link_base, pretty_urls, &mut slugs));
let mut html_out = String::new();
html::push_html(&mut html_out, events);
html_out
}
fn rewrite_event<'a>(
event: Event<'a>,
link_base: Option<&str>,
pretty_urls: bool,
slugs: &mut impl Iterator<Item = String>,
) -> Event<'a> {
match event {
Event::Start(Tag::Heading {
level,
classes,
attrs,
..
}) => Event::Start(Tag::Heading {
level,
id: slugs.next().map(CowStr::from),
classes,
attrs,
}),
Event::Start(Tag::Link {
link_type,
dest_url,
title,
id,
}) => {
let dest_url = match link_base {
Some(base) => CowStr::from(rewrite_md_link(&dest_url, base, pretty_urls)),
None => dest_url,
};
Event::Start(Tag::Link {
link_type,
dest_url,
title,
id,
})
}
other => other,
}
}
fn rewrite_md_link(dest_url: &str, link_base: &str, pretty_urls: bool) -> String {
if dest_url.contains("://") || !dest_url.ends_with(".md") {
return dest_url.to_string();
}
let stripped = &dest_url[..dest_url.len() - ".md".len()];
join_url(link_base, &route::route_link(stripped, pretty_urls))
}
pub(crate) fn join_url(base: &str, relative: &str) -> String {
let base = base.trim_end_matches('/');
let relative = relative.trim_start_matches('/');
format!("{base}/{relative}")
}
pub(crate) fn page_url(url_relative: &str, link_base: Option<&str>) -> String {
join_url(link_base.unwrap_or("/"), url_relative)
}
fn heading_slugs(body: &str) -> Vec<String> {
let mut seen: HashMap<String, u32> = HashMap::new();
heading_texts(body)
.into_iter()
.map(|(_, text)| slugify(&text))
.map(|slug| {
let count = seen.entry(slug.clone()).or_insert(0);
let unique = if *count == 0 {
slug
} else {
format!("{slug}-{count}")
};
*count += 1;
unique
})
.collect()
}
fn slugify(text: &str) -> String {
let mut slug = String::new();
let mut last_was_separator = true;
for ch in text.chars() {
if ch.is_alphanumeric() {
slug.extend(ch.to_lowercase());
last_was_separator = false;
} else if (ch.is_whitespace() || ch == '-' || ch == '_') && !last_was_separator {
slug.push('-');
last_was_separator = true;
}
}
while slug.ends_with('-') {
slug.pop();
}
slug
}
pub(crate) fn resolve_title(frontmatter: &Value, body: &str, fallback: &str) -> String {
if let Some(title) = frontmatter.get("title").and_then(Value::as_str) {
return title.to_string();
}
if let Some(heading) = first_h1_heading(body) {
return heading;
}
fallback.to_string()
}
pub(crate) fn resolve_template(
frontmatter: &Value,
default_template: Option<&str>,
) -> Option<String> {
frontmatter
.get("template")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| default_template.map(str::to_string))
}
fn first_h1_heading(body: &str) -> Option<String> {
heading_texts(body)
.into_iter()
.find(|(level, _)| *level == HeadingLevel::H1)
.map(|(_, text)| text)
}
fn heading_texts(body: &str) -> Vec<(HeadingLevel, String)> {
let parser = Parser::new_ext(body, Options::empty());
let mut headings = Vec::new();
let mut current_level = None;
let mut text = String::new();
for event in parser {
match event {
Event::Start(Tag::Heading { level, .. }) => {
current_level = Some(level);
text.clear();
}
Event::End(TagEnd::Heading(level)) => {
if current_level == Some(level) {
headings.push((level, text.trim().to_string()));
current_level = None;
}
}
Event::Text(t) | Event::Code(t) if current_level.is_some() => text.push_str(&t),
_ => {}
}
}
headings
}
#[cfg(test)]
#[path = "../tests/unit/page.rs"]
mod tests;