mini-docs 0.4.0

A minimal, secure build-time Markdown to HTML generator for the mini-* family.
Documentation
use std::collections::HashMap;

use pulldown_cmark::{html, CowStr, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use serde_json::Value;

/// Renders a Markdown body (frontmatter already stripped) to an HTML string.
///
/// Sanitization is a separate step (see [`crate::sanitize::sanitize_html`]) — the
/// returned string may contain raw inline HTML the source author wrote, untouched,
/// per CommonMark's rules.
///
/// When `link_base` is `Some`, a link destination ending in `.md` is rewritten to a
/// clean URL under it (`[Setup](setup.md)` → `href="/setup"` for `link_base = "/"`).
/// External links (anything containing `://`) and non-`.md` links are left untouched.
/// With `link_base = None`, no rewriting happens at all — explicit over implicit.
///
/// Every heading gets an auto-generated `id` slug (see [`slugify`]), deduplicated
/// within the document, so templates and readers can link directly to a section.
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,
    }
}

/// Rewrites a relative `something.md` link destination into `{link_base}/something`.
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)
}

/// Joins `base` and `relative` into a single clean path, collapsing the boundary to
/// exactly one `/` regardless of whether either side already has one.
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}")
}

/// Computes a page's own clean URL from its `.md` path relative to `input_dir`
/// (extension already stripped, e.g. `"guide/setup"`), under `link_base` — defaulting
/// to `/` when no `link_base` is configured, since every page needs *some* URL for a
/// [`crate::data_json`] entry, unlike Markdown link rewriting, which stays untouched
/// with no `link_base` at all (explicit over implicit there; required here).
pub(crate) fn page_url(relative_no_ext: &str, link_base: Option<&str>) -> String {
    join_url(link_base.unwrap_or("/"), relative_no_ext)
}

/// Assigns each heading in `body` a unique anchor slug, in document order.
///
/// A repeated slug (two headings with the same text) gets `-1`, `-2`, ... appended,
/// so no two headings in one document ever collide on `id`.
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()
}

/// Slugifies text into a URL-safe anchor id: Unicode-aware lowercasing, alphanumerics
/// kept, whitespace/`-`/`_` collapsed to a single `-`, everything else (punctuation)
/// dropped outright, leading/trailing `-` trimmed.
fn slugify(text: &str) -> String {
    let mut slug = String::new();
    let mut last_was_separator = true; // suppresses a leading '-'

    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
}

/// Resolves a page's title: frontmatter `title` → first `# ` heading → `fallback`
/// (typically the filename slug).
///
/// `frontmatter` must be a `Value::Object` (or any non-object, treated as having no
/// `title` key) — the caller owns validating its shape.
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()
}

/// Resolves a page's template: frontmatter `template` → `default_template`.
///
/// Returns `None` when neither source names one — `default_template` is optional on
/// `Builder`, since a build where every page sets its own `template:` key never needs it.
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))
}

/// Returns the text of the first level-1 ATX heading in `body`, concatenating inline
/// text and code spans (so `# Setting up **fast**` yields `Setting up fast`).
fn first_h1_heading(body: &str) -> Option<String> {
    heading_texts(body)
        .into_iter()
        .find(|(level, _)| *level == HeadingLevel::H1)
        .map(|(_, text)| text)
}

/// Collects the text of every heading in `body`, in document order, alongside its
/// level — concatenating inline text and code spans (so `# Setting up **fast**`
/// yields `Setting up fast`).
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");
    }
}