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