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}