Skip to main content

dmc_schema/
markdown.rs

1use crate::{Ctx, Schema, ValidationError};
2use serde_json::Value;
3
4pub struct RawSchema;
5
6impl Schema for RawSchema {
7  fn parse(&self, _value: &Value, ctx: &Ctx) -> Result<Value, ValidationError> {
8    Ok(Value::String(ctx.body.clone()))
9  }
10}
11
12pub struct MarkdownSchema;
13
14impl Schema for MarkdownSchema {
15  fn parse(&self, _value: &Value, ctx: &Ctx) -> Result<Value, ValidationError> {
16    let html = ctx.html.clone().ok_or_else(|| ValidationError::root("markdown body not yet rendered (engine bug?)"))?;
17    Ok(Value::String(html))
18  }
19}
20
21pub struct MdxSchema;
22
23impl Schema for MdxSchema {
24  fn parse(&self, _value: &Value, ctx: &Ctx) -> Result<Value, ValidationError> {
25    let body = ctx.mdx_body.clone().ok_or_else(|| ValidationError::root("mdx body not yet rendered (engine bug?)"))?;
26    Ok(Value::String(body))
27  }
28}
29
30pub struct TocSchema;
31
32impl Schema for TocSchema {
33  fn parse(&self, _value: &Value, ctx: &Ctx) -> Result<Value, ValidationError> {
34    Ok(ctx.toc.clone().unwrap_or_else(|| Value::Array(vec![])))
35  }
36}
37
38pub struct MetadataSchema;
39
40impl Schema for MetadataSchema {
41  fn parse(&self, _value: &Value, ctx: &Ctx) -> Result<Value, ValidationError> {
42    // Counts against raw post-frontmatter source (velite parity); `plain_text`
43    // strips JSX + structural words and undercounts by 30-40%.
44    let words = word_count(&ctx.body);
45    let reading = ((words as f32) / 200.0).ceil() as u32;
46    Ok(serde_json::json!({
47      "readingTime": reading.max(1),
48      "wordCount": words,
49    }))
50  }
51}
52
53/// Strips fenced code blocks then counts whitespace tokens (velite/`reading-time` parity).
54fn word_count(source: &str) -> u32 {
55  let mut out = String::with_capacity(source.len());
56  let mut in_fence = false;
57  for line in source.lines() {
58    if line.trim_start().starts_with("```") {
59      in_fence = !in_fence;
60      continue;
61    }
62    if !in_fence {
63      out.push_str(line);
64      out.push('\n');
65    }
66  }
67  out.split_whitespace().count() as u32
68}
69
70pub struct ExcerptSchema {
71  pub length: usize,
72}
73
74impl ExcerptSchema {
75  pub fn length(mut self, n: usize) -> Self {
76    self.length = n;
77    self
78  }
79}
80
81impl Default for ExcerptSchema {
82  fn default() -> Self {
83    Self { length: 260 }
84  }
85}
86
87impl Schema for ExcerptSchema {
88  fn parse(&self, _value: &Value, ctx: &Ctx) -> Result<Value, ValidationError> {
89    let plain = ctx.plain_text.clone().unwrap_or_default();
90    let s: String = plain.split_whitespace().collect::<Vec<_>>().join(" ");
91    let out = if s.chars().count() <= self.length {
92      s
93    } else {
94      let truncated: String = s.chars().take(self.length).collect();
95      format!("{}...", truncated.trim_end())
96    };
97    Ok(Value::String(out))
98  }
99}
100
101#[derive(Default)]
102pub struct PathSchema {
103  pub remove_index: bool,
104}
105
106impl PathSchema {
107  pub fn remove_index(mut self) -> Self {
108    self.remove_index = true;
109    self
110  }
111}
112
113impl Schema for PathSchema {
114  fn parse(&self, _value: &Value, ctx: &Ctx) -> Result<Value, ValidationError> {
115    let rel = ctx.file_path.strip_prefix(&ctx.root).unwrap_or(&ctx.file_path);
116    let mut s = rel.to_string_lossy().to_string();
117    s = s.trim_end_matches(".mdx").trim_end_matches(".md").to_string();
118    if self.remove_index {
119      s = s.trim_end_matches("/index").to_string();
120    }
121    Ok(Value::String(s))
122  }
123}
124
125pub struct SlugSchema {
126  pub bucket: String,
127  pub reserved: Vec<String>,
128}
129
130impl SlugSchema {
131  pub fn by(mut self, bucket: impl Into<String>) -> Self {
132    self.bucket = bucket.into();
133    self
134  }
135  pub fn reserved(mut self, list: Vec<String>) -> Self {
136    self.reserved = list;
137    self
138  }
139}
140
141impl Default for SlugSchema {
142  fn default() -> Self {
143    Self { bucket: "global".into(), reserved: Vec::new() }
144  }
145}
146
147impl Schema for SlugSchema {
148  fn parse(&self, value: &Value, ctx: &Ctx) -> Result<Value, ValidationError> {
149    let s = value.as_str().ok_or_else(|| ValidationError::root("slug must be a string"))?;
150    if s.len() < 3 || s.len() > 200 {
151      return Err(ValidationError::root(format!("slug length must be 3..=200 (got {})", s.len(),)));
152    }
153    let valid = !s.is_empty()
154      && !s.starts_with('-')
155      && !s.ends_with('-')
156      && !s.contains("--")
157      && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
158    if !valid {
159      return Err(ValidationError::root("slug must be kebab-case (lowercase letters, digits, single dashes)"));
160    }
161    if self.reserved.iter().any(|r| r == s) {
162      return Err(ValidationError::root(format!("slug '{s}' is reserved")));
163    }
164    let key = format!("{}::{s}", self.bucket);
165    let mut cache = ctx.unique_cache.lock().unwrap();
166    if cache.contains(&key) {
167      return Err(ValidationError::root(format!("slug '{s}' already used in bucket '{}'", self.bucket)));
168    }
169    cache.insert(key);
170    Ok(Value::String(s.to_string()))
171  }
172}
173
174pub struct UniqueSchema {
175  pub bucket: String,
176}
177
178impl UniqueSchema {
179  pub fn by(mut self, bucket: impl Into<String>) -> Self {
180    self.bucket = bucket.into();
181    self
182  }
183}
184
185impl Default for UniqueSchema {
186  fn default() -> Self {
187    Self { bucket: "global".into() }
188  }
189}
190
191impl Schema for UniqueSchema {
192  fn parse(&self, value: &Value, ctx: &Ctx) -> Result<Value, ValidationError> {
193    let s = value.as_str().ok_or_else(|| ValidationError::root("unique value must be a string"))?;
194    let key = format!("{}::{s}", self.bucket);
195    let mut cache = ctx.unique_cache.lock().unwrap();
196    if cache.contains(&key) {
197      return Err(ValidationError::root(format!("'{s}' already used in unique bucket '{}'", self.bucket)));
198    }
199    cache.insert(key);
200    Ok(Value::String(s.to_string()))
201  }
202}
203
204pub struct IsodateSchema;
205
206impl Schema for IsodateSchema {
207  fn parse(&self, value: &Value, _ctx: &Ctx) -> Result<Value, ValidationError> {
208    let s = value.as_str().ok_or_else(|| ValidationError::root("isodate must be a string"))?;
209    let bytes = s.as_bytes();
210    if bytes.len() < 10
211      || !bytes[0].is_ascii_digit()
212      || !bytes[1].is_ascii_digit()
213      || !bytes[2].is_ascii_digit()
214      || !bytes[3].is_ascii_digit()
215      || bytes[4] != b'-'
216      || !bytes[5].is_ascii_digit()
217      || !bytes[6].is_ascii_digit()
218      || bytes[7] != b'-'
219      || !bytes[8].is_ascii_digit()
220      || !bytes[9].is_ascii_digit()
221    {
222      return Err(ValidationError::root(format!("'{s}' is not a valid ISO date")));
223    }
224    Ok(Value::String(s.to_string()))
225  }
226}