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