statica 0.32.1

statica — Powered HTML pipeline (parse, funnel, bind, scope, emit)
Documentation
//! Markdown content with optional YAML frontmatter.

use std::path::Path;

use pulldown_cmark::{html, Options, Parser};
use serde_json::{Map, Value};

use crate::error::{Error, Result};

/// Convert Markdown to HTML (CommonMark + tables + strikethrough).
pub fn markdown_to_html(source: &str) -> String {
    let mut options = Options::empty();
    options.insert(Options::ENABLE_STRIKETHROUGH);
    options.insert(Options::ENABLE_TABLES);
    let parser = Parser::new_ext(source, options);
    let mut out = String::new();
    html::push_html(&mut out, parser);
    out
}

/// Parse a Markdown file: optional YAML frontmatter fields + `html` from the body.
pub fn parse_markdown_file(source: &str, path: &Path) -> Result<Value> {
    parse_markdown(source, path)
}

fn parse_markdown(source: &str, path: &Path) -> Result<Value> {
    let parts = split_frontmatter(source);
    let mut map: Map<String, Value> = if let Some(fm) = parts.frontmatter {
        serde_yaml::from_str(fm).map_err(|e| {
            Error::invalid_content(
                path.display().to_string(),
                format!("invalid frontmatter: {e}"),
            )
        })?
    } else {
        Map::new()
    };

    if !map.contains_key("slug") {
        if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
            map.insert("slug".into(), Value::String(stem.to_string()));
        }
    }

    if !map.contains_key("html") {
        map.insert("html".into(), Value::String(markdown_to_html(parts.body)));
    }

    Ok(Value::Object(map))
}

struct MarkdownParts<'a> {
    frontmatter: Option<&'a str>,
    body: &'a str,
}

fn split_frontmatter(source: &str) -> MarkdownParts<'_> {
    let trimmed = source.trim_start();
    if !trimmed.starts_with("---") {
        return MarkdownParts {
            frontmatter: None,
            body: source,
        };
    }
    let rest = trimmed.strip_prefix("---").unwrap_or(trimmed);
    let rest = rest.strip_prefix('\n').unwrap_or(rest);
    if let Some(end) = rest.find("\n---") {
        let fm = rest[..end].trim();
        let body = rest[end + 4..]
            .strip_prefix('\n')
            .unwrap_or(&rest[end + 4..])
            .trim_start();
        return MarkdownParts {
            frontmatter: Some(fm),
            body,
        };
    }
    MarkdownParts {
        frontmatter: None,
        body: source,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;

    #[test]
    fn splits_yaml_frontmatter() {
        let parts = split_frontmatter("---\nslug: a\n---\n\nHello **world**.");
        assert_eq!(parts.frontmatter, Some("slug: a"));
        assert_eq!(parts.body, "Hello **world**.");
    }

    #[test]
    fn no_frontmatter_returns_full_body() {
        let parts = split_frontmatter("# Hi\n");
        assert!(parts.frontmatter.is_none());
        assert_eq!(parts.body, "# Hi\n");
    }

    #[test]
    fn renders_markdown_headings() {
        let html = markdown_to_html("## Subtitle\n\nParagraph.");
        assert!(html.contains("<h2>Subtitle</h2>"));
        assert!(html.contains("<p>Paragraph.</p>"));
    }

    #[test]
    fn parse_adds_html_from_body() {
        let value =
            parse_markdown_file("---\ntitle: Test\n---\n\n**bold**", Path::new("test.md")).unwrap();
        assert_eq!(value["title"], "Test");
        assert_eq!(value["slug"], "test");
        assert!(value["html"]
            .as_str()
            .unwrap()
            .contains("<strong>bold</strong>"));
    }
}