Skip to main content

mini_docs/
extension.rs

1use serde_json::Value;
2
3use crate::error::DocError;
4
5/// A Markdown processor that transforms the raw body before title/template resolution.
6///
7/// Processors run in registration order on the raw body immediately after frontmatter
8/// split, and their output feeds title/template resolution and markdown rendering.
9///
10/// # Naming Contract
11///
12/// The `name()` method returns a unique identifier for this processor. It must be
13/// non-empty, ASCII-only, and a valid bare Tera identifier (`[a-z0-9_]+`). An invalid
14/// name will be rejected at build time.
15pub trait MarkdownProcessor: Send + Sync {
16    /// Transforms `body` in the context of this page's `frontmatter`, returning the
17    /// processed body or a `DocError`.
18    ///
19    /// Errors should be prefixed with `name()` for attribution, e.g. `"litmus: ..."`
20    /// so callers know which processor failed.
21    fn process(&self, body: &str, frontmatter: &Value) -> Result<String, DocError>;
22
23    /// Returns the name of this processor, used to detect duplicates and for error
24    /// attribution. Must be non-empty, ASCII-only, and a valid Tera identifier.
25    fn name(&self) -> &str;
26}
27
28/// A Markdown analyzer that extracts metadata from the body for template rendering.
29///
30/// Analyzers run after processors, and their results are merged into the Tera context
31/// under `page.extensions.<name()>` where templates can render them.
32///
33/// # Naming Contract
34///
35/// The `name()` method returns a unique identifier for this analyzer. It must be
36/// non-empty, ASCII-only, and a valid bare Tera identifier (`[a-z0-9_]+`). An invalid
37/// name will be rejected at build time. Analyzer names are scoped separately from
38/// processor names — a processor and analyzer may share a name without conflict.
39pub trait MarkdownAnalyzer: Send + Sync {
40    /// Analyzes `body` in the context of this page's `frontmatter`, returning a JSON
41    /// object with metadata or a `DocError`.
42    ///
43    /// Errors should be prefixed with `name()` for attribution, e.g. `"litmus: ..."`
44    /// so callers know which analyzer failed.
45    fn analyze(&self, body: &str, frontmatter: &Value) -> Result<Value, DocError>;
46
47    /// Returns the name of this analyzer, used to detect duplicates and as the key in
48    /// `page.extensions.<name()>`. Must be non-empty, ASCII-only, and a valid Tera
49    /// identifier.
50    fn name(&self) -> &str;
51}
52
53/// Validates that a name is a valid Tera identifier: non-empty, ASCII-only, `[a-z0-9_]+`.
54#[allow(dead_code)]
55pub(crate) fn validate_identifier(name: &str) -> bool {
56    !name.is_empty() && name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62
63    struct MockProcessor;
64
65    impl MarkdownProcessor for MockProcessor {
66        fn process(&self, body: &str, _frontmatter: &Value) -> Result<String, DocError> {
67            Ok(format!("{}[PROCESSED]", body))
68        }
69
70        fn name(&self) -> &str {
71            "mock"
72        }
73    }
74
75    struct MockAnalyzer;
76
77    impl MarkdownAnalyzer for MockAnalyzer {
78        fn analyze(&self, body: &str, _frontmatter: &Value) -> Result<Value, DocError> {
79            Ok(serde_json::json!({
80                "word_count": body.split_whitespace().count()
81            }))
82        }
83
84        fn name(&self) -> &str {
85            "mock_analyzer"
86        }
87    }
88
89    #[test]
90    fn processor_trait_works() {
91        let proc = MockProcessor;
92        let body = "hello world";
93        let fm = serde_json::json!({});
94        let result = proc.process(body, &fm).unwrap();
95        assert_eq!(result, "hello world[PROCESSED]");
96        assert_eq!(proc.name(), "mock");
97    }
98
99    #[test]
100    fn analyzer_trait_works() {
101        let analyzer = MockAnalyzer;
102        let body = "one two three";
103        let fm = serde_json::json!({});
104        let result = analyzer.analyze(body, &fm).unwrap();
105        assert_eq!(result["word_count"], 3);
106        assert_eq!(analyzer.name(), "mock_analyzer");
107    }
108
109    #[test]
110    fn validate_identifier_accepts_valid_names() {
111        assert!(validate_identifier("simple"));
112        assert!(validate_identifier("snake_case"));
113        assert!(validate_identifier("with123numbers"));
114        assert!(validate_identifier("a"));
115        assert!(validate_identifier("_underscore_start"));
116    }
117
118    #[test]
119    fn validate_identifier_rejects_invalid_names() {
120        assert!(!validate_identifier(""));
121        assert!(!validate_identifier("with space"));
122        assert!(!validate_identifier("with-dash"));
123        assert!(!validate_identifier("WithCaps"));
124        assert!(!validate_identifier("with.dot"));
125    }
126}