mini-docs 0.3.5

A minimal, secure build-time Markdown to HTML generator for the mini-* family.
Documentation
use serde_json::{Map, Value};

use crate::error::DocError;

const DELIMITER: &str = "---";

/// Splits a leading `---`-delimited frontmatter block off `input`.
///
/// Returns `(frontmatter, body)`. `frontmatter` is `Value::Object` (empty if `input`
/// has no frontmatter block); `body` is `input` with the block and its delimiters
/// removed. Grammar is deliberately restricted, not general YAML:
///
/// - `key: value` — one per line, no nesting.
/// - `value` is a quoted string (`"..."`), an inline list (`[a, b, "c"]`), a bare
///   `true`/`false` (parsed as a JSON boolean), or any other unquoted scalar, which
///   is always parsed as a string (no numbers — `version: 2` stays a string, by
///   design, since mini-docs never interprets it arithmetically).
///
/// A block that opens with `---` but never closes is [`DocError::Frontmatter`] — an
/// unterminated block is far more likely a typo than intentional Markdown starting
/// with a horizontal rule immediately followed by more `---` text.
pub(crate) fn split_frontmatter(input: &str) -> Result<(Value, &str), DocError> {
    let Some(after_open) = opening_delimiter_body(input) else {
        return Ok((Value::Object(Map::new()), input));
    };

    let closing = find_closing_delimiter(after_open)
        .ok_or_else(|| DocError::Frontmatter("missing closing --- delimiter".to_string()))?;

    let block = &after_open[..closing.block_end];
    let body = &after_open[closing.body_start..];

    let mut map = Map::new();
    for line in block.lines() {
        if line.trim().is_empty() {
            continue;
        }
        let (key, value) = parse_line(line)?;
        if map.insert(key.clone(), value).is_some() {
            return Err(DocError::Frontmatter(format!("duplicate key: {key}")));
        }
    }

    Ok((Value::Object(map), body))
}

/// Returns the remainder of `input` after an opening `---` line, or `None` if `input`
/// does not open with `---` alone on its first line.
fn opening_delimiter_body(input: &str) -> Option<&str> {
    let after_marker = input.strip_prefix(DELIMITER)?;
    let newline_pos = after_marker.find('\n')?;
    let rest_of_line = after_marker[..newline_pos].trim_end_matches('\r');
    if !rest_of_line.is_empty() {
        return None;
    }
    Some(&after_marker[newline_pos + 1..])
}

struct ClosingDelimiter {
    block_end: usize,
    body_start: usize,
}

/// Scans `text` line by line for a `---`-only line, the frontmatter block's terminator.
fn find_closing_delimiter(text: &str) -> Option<ClosingDelimiter> {
    let mut offset = 0;
    for line in text.split_inclusive('\n') {
        let trimmed = line.trim_end_matches(['\n', '\r']);
        if trimmed == DELIMITER {
            return Some(ClosingDelimiter {
                block_end: offset,
                body_start: offset + line.len(),
            });
        }
        offset += line.len();
    }
    None
}

fn parse_line(line: &str) -> Result<(String, Value), DocError> {
    let (key, raw_value) = line
        .split_once(':')
        .ok_or_else(|| DocError::Frontmatter(format!("expected 'key: value', got: {line}")))?;

    let key = key.trim();
    if key.is_empty() {
        return Err(DocError::Frontmatter(format!("empty key in line: {line}")));
    }

    let value = parse_value(raw_value.trim())?;
    Ok((key.to_string(), value))
}

fn parse_value(raw: &str) -> Result<Value, DocError> {
    if let Some(inner) = raw.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
        let items = inner
            .split(',')
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .map(parse_scalar)
            .collect::<Result<Vec<_>, _>>()?;
        return Ok(Value::Array(items));
    }
    parse_scalar(raw)
}

fn parse_scalar(raw: &str) -> Result<Value, DocError> {
    if raw.is_empty() {
        return Err(DocError::Frontmatter("empty value".to_string()));
    }
    if let Some(inner) = raw.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
        if inner.contains('"') {
            return Err(DocError::Frontmatter(format!(
                "unescaped quote in string: {raw}"
            )));
        }
        return Ok(Value::String(inner.to_string()));
    }
    match raw {
        "true" => Ok(Value::Bool(true)),
        "false" => Ok(Value::Bool(false)),
        _ => Ok(Value::String(raw.to_string())),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn parses_title_string_and_list_fields() {
        let input = "---\ntitle: Getting Started\nauthor: \"Jane Doe\"\ntags: [rust, docs, cli]\n---\n# Body\n";

        let (frontmatter, body) = split_frontmatter(input).expect("valid frontmatter");

        assert_eq!(
            frontmatter,
            json!({
                "title": "Getting Started",
                "author": "Jane Doe",
                "tags": ["rust", "docs", "cli"],
            })
        );
        assert_eq!(body, "# Body\n");
    }

    #[test]
    fn no_leading_delimiter_returns_empty_frontmatter_and_full_body() {
        let input = "# Just Markdown\nNo frontmatter here.\n";

        let (frontmatter, body) = split_frontmatter(input).expect("no frontmatter is not an error");

        assert_eq!(frontmatter, json!({}));
        assert_eq!(body, input);
    }

    #[test]
    fn unterminated_block_is_a_frontmatter_error() {
        let input = "---\ntitle: Unterminated\n# Body without closing delimiter\n";

        let err = split_frontmatter(input).expect_err("missing closing delimiter must error");

        assert!(matches!(err, DocError::Frontmatter(_)));
    }

    #[test]
    fn empty_frontmatter_block_yields_empty_object() {
        let input = "---\n---\nbody text\n";

        let (frontmatter, body) = split_frontmatter(input).expect("empty block is valid");

        assert_eq!(frontmatter, json!({}));
        assert_eq!(body, "body text\n");
    }

    #[test]
    fn duplicate_key_is_a_frontmatter_error() {
        let input = "---\ntitle: One\ntitle: Two\n---\nbody\n";

        let err = split_frontmatter(input).expect_err("duplicate key must error");

        assert!(matches!(err, DocError::Frontmatter(_)));
    }

    #[test]
    fn bare_true_and_false_parse_as_booleans() {
        let input = "---\npinned: true\ndraft: false\n---\nbody\n";

        let (frontmatter, _) = split_frontmatter(input).expect("valid frontmatter");

        assert_eq!(frontmatter, json!({"pinned": true, "draft": false}));
    }

    #[test]
    fn quoted_true_stays_a_string() {
        let input = "---\nnote: \"true\"\n---\nbody\n";

        let (frontmatter, _) = split_frontmatter(input).expect("valid frontmatter");

        assert_eq!(frontmatter, json!({"note": "true"}));
    }

    #[test]
    fn version_number_stays_a_string_by_design() {
        let input = "---\nversion: 2\n---\nbody\n";

        let (frontmatter, _) = split_frontmatter(input).expect("valid frontmatter");

        assert_eq!(frontmatter, json!({"version": "2"}));
    }
}