mini-docs 0.4.0

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

use crate::error::DocError;

/// A Markdown processor that transforms the raw body before title/template resolution.
///
/// Processors run in registration order on the raw body immediately after frontmatter
/// split, and their output feeds title/template resolution and markdown rendering.
///
/// # Naming Contract
///
/// The `name()` method returns a unique identifier for this processor. It must be
/// non-empty, ASCII-only, and a valid bare Tera identifier (`[a-z0-9_]+`). An invalid
/// name will be rejected at build time.
pub trait MarkdownProcessor: Send + Sync {
    /// Transforms `body` in the context of this page's `frontmatter`, returning the
    /// processed body or a `DocError`.
    ///
    /// Errors should be prefixed with `name()` for attribution, e.g. `"litmus: ..."`
    /// so callers know which processor failed.
    fn process(&self, body: &str, frontmatter: &Value) -> Result<String, DocError>;

    /// Returns the name of this processor, used to detect duplicates and for error
    /// attribution. Must be non-empty, ASCII-only, and a valid Tera identifier.
    fn name(&self) -> &str;
}

/// A Markdown analyzer that extracts metadata from the body for template rendering.
///
/// Analyzers run after processors, and their results are merged into the Tera context
/// under `page.extensions.<name()>` where templates can render them.
///
/// # Naming Contract
///
/// The `name()` method returns a unique identifier for this analyzer. It must be
/// non-empty, ASCII-only, and a valid bare Tera identifier (`[a-z0-9_]+`). An invalid
/// name will be rejected at build time. Analyzer names are scoped separately from
/// processor names — a processor and analyzer may share a name without conflict.
pub trait MarkdownAnalyzer: Send + Sync {
    /// Analyzes `body` in the context of this page's `frontmatter`, returning a JSON
    /// object with metadata or a `DocError`.
    ///
    /// Errors should be prefixed with `name()` for attribution, e.g. `"litmus: ..."`
    /// so callers know which analyzer failed.
    fn analyze(&self, body: &str, frontmatter: &Value) -> Result<Value, DocError>;

    /// Returns the name of this analyzer, used to detect duplicates and as the key in
    /// `page.extensions.<name()>`. Must be non-empty, ASCII-only, and a valid Tera
    /// identifier.
    fn name(&self) -> &str;
}

/// Validates that a name is a valid Tera identifier: non-empty, ASCII-only, `[a-z0-9_]+`.
#[allow(dead_code)]
pub(crate) fn validate_identifier(name: &str) -> bool {
    !name.is_empty() && name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}

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

    struct MockProcessor;

    impl MarkdownProcessor for MockProcessor {
        fn process(&self, body: &str, _frontmatter: &Value) -> Result<String, DocError> {
            Ok(format!("{}[PROCESSED]", body))
        }

        fn name(&self) -> &str {
            "mock"
        }
    }

    struct MockAnalyzer;

    impl MarkdownAnalyzer for MockAnalyzer {
        fn analyze(&self, body: &str, _frontmatter: &Value) -> Result<Value, DocError> {
            Ok(serde_json::json!({
                "word_count": body.split_whitespace().count()
            }))
        }

        fn name(&self) -> &str {
            "mock_analyzer"
        }
    }

    #[test]
    fn processor_trait_works() {
        let proc = MockProcessor;
        let body = "hello world";
        let fm = serde_json::json!({});
        let result = proc.process(body, &fm).unwrap();
        assert_eq!(result, "hello world[PROCESSED]");
        assert_eq!(proc.name(), "mock");
    }

    #[test]
    fn analyzer_trait_works() {
        let analyzer = MockAnalyzer;
        let body = "one two three";
        let fm = serde_json::json!({});
        let result = analyzer.analyze(body, &fm).unwrap();
        assert_eq!(result["word_count"], 3);
        assert_eq!(analyzer.name(), "mock_analyzer");
    }

    #[test]
    fn validate_identifier_accepts_valid_names() {
        assert!(validate_identifier("simple"));
        assert!(validate_identifier("snake_case"));
        assert!(validate_identifier("with123numbers"));
        assert!(validate_identifier("a"));
        assert!(validate_identifier("_underscore_start"));
    }

    #[test]
    fn validate_identifier_rejects_invalid_names() {
        assert!(!validate_identifier(""));
        assert!(!validate_identifier("with space"));
        assert!(!validate_identifier("with-dash"));
        assert!(!validate_identifier("WithCaps"));
        assert!(!validate_identifier("with.dot"));
    }
}