mini-docs 0.7.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;

use crate::route;

/// 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, in the style `pretty_urls` selects: `[Setup](setup.md)` becomes
/// `href="/setup.html"` plainly, or `href="/setup"` with pretty URLs on, matching where
/// the target page is written. 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>, 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,
    }
}

/// Rewrites a relative `something.md` link destination into a URL under `link_base`,
/// in the same style [`crate::route::route`] serves the target page at.
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))
}

/// 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 URL from its routed URL path (see `route`) 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(url_relative: &str, link_base: Option<&str>) -> String {
    join_url(link_base.unwrap_or("/"), url_relative)
}

/// 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)]
#[path = "../tests/unit/page.rs"]
mod tests;