use std::collections::HashMap;
use pulldown_cmark::{html, CowStr, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use serde_json::Value;
pub(crate) fn render_markdown(body: &str, link_base: Option<&str>) -> 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, &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>,
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)),
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) -> 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, stripped)
}
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(relative_no_ext: &str, link_base: Option<&str>) -> String {
join_url(link_base.unwrap_or("/"), relative_no_ext)
}
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)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn title_falls_back_to_first_h1_heading_when_no_frontmatter_title() {
let body = "# Getting Started\n\nSome intro text.\n";
let title = resolve_title(&json!({}), body, "fallback-slug");
assert_eq!(title, "Getting Started");
}
#[test]
fn frontmatter_title_takes_priority_over_heading() {
let body = "# Getting Started\n";
let title = resolve_title(&json!({"title": "Custom Title"}), body, "fallback-slug");
assert_eq!(title, "Custom Title");
}
#[test]
fn falls_back_to_filename_when_no_title_or_heading() {
let body = "Just a paragraph, no heading.\n";
let title = resolve_title(&json!({}), body, "fallback-slug");
assert_eq!(title, "fallback-slug");
}
#[test]
fn template_falls_back_to_default_when_no_frontmatter_key() {
let template = resolve_template(&json!({}), Some("page.html"));
assert_eq!(template, Some("page.html".to_string()));
}
#[test]
fn frontmatter_template_overrides_default() {
let template = resolve_template(&json!({"template": "custom.html"}), Some("page.html"));
assert_eq!(template, Some("custom.html".to_string()));
}
#[test]
fn no_default_and_no_frontmatter_template_resolves_to_none() {
let template = resolve_template(&json!({}), None);
assert_eq!(template, None);
}
#[test]
fn render_markdown_produces_expected_html() {
let html_out = render_markdown("# Title\n\nA *paragraph*.\n", None);
assert_eq!(
html_out,
"<h1 id=\"title\">Title</h1>\n<p>A <em>paragraph</em>.</p>\n"
);
}
#[test]
fn heading_with_punctuation_gets_slugified_anchor_id() {
let html_out = render_markdown("## Getting Started: A Guide!\n", None);
assert_eq!(
html_out,
"<h2 id=\"getting-started-a-guide\">Getting Started: A Guide!</h2>\n"
);
}
#[test]
fn duplicate_heading_text_gets_deduplicated_anchor_ids() {
let html_out = render_markdown("# Overview\n\ntext\n\n# Overview\n", None);
assert!(html_out.contains("<h1 id=\"overview\">Overview</h1>"));
assert!(html_out.contains("<h1 id=\"overview-1\">Overview</h1>"));
}
#[test]
fn slugify_lowercases_and_drops_punctuation() {
assert_eq!(
slugify("Getting Started: A Guide!"),
"getting-started-a-guide"
);
}
#[test]
fn slugify_collapses_repeated_separators_and_trims_ends() {
assert_eq!(
slugify(" Multiple Spaces--and__underscores_ "),
"multiple-spaces-and-underscores"
);
}
#[test]
fn page_url_defaults_to_root_relative_without_link_base() {
assert_eq!(page_url("guide/setup", None), "/guide/setup");
}
#[test]
fn page_url_joins_under_configured_link_base() {
assert_eq!(page_url("guide/setup", Some("/docs")), "/docs/guide/setup");
}
#[test]
fn rewrites_relative_md_link_to_clean_url_when_link_base_set() {
let html_out = render_markdown("[Setup](setup.md)\n", Some("/"));
assert_eq!(html_out, "<p><a href=\"/setup\">Setup</a></p>\n");
}
#[test]
fn rewrites_nested_md_link_under_link_base() {
let html_out = render_markdown("[Setup](guide/setup.md)\n", Some("/docs"));
assert_eq!(html_out, "<p><a href=\"/docs/guide/setup\">Setup</a></p>\n");
}
#[test]
fn leaves_md_link_untouched_when_no_link_base_set() {
let html_out = render_markdown("[Setup](setup.md)\n", None);
assert_eq!(html_out, "<p><a href=\"setup.md\">Setup</a></p>\n");
}
#[test]
fn leaves_external_md_link_untouched_even_with_link_base_set() {
let html_out = render_markdown("[Spec](https://example.com/README.md)\n", Some("/"));
assert_eq!(
html_out,
"<p><a href=\"https://example.com/README.md\">Spec</a></p>\n"
);
}
#[test]
fn leaves_non_md_link_untouched_with_link_base_set() {
let html_out = render_markdown("[Home](/index.html)\n", Some("/"));
assert_eq!(html_out, "<p><a href=\"/index.html\">Home</a></p>\n");
}
}