Skip to main content

dmc/engine/
utils.rs

1use serde_json::{Map, Value, json};
2use std::path::{Component, Path, PathBuf};
3
4use crate::engine::{compile::CompileOutput, config::EngineConfig};
5
6#[derive(Debug, Default)]
7pub struct EngineReport {
8  pub collections: Vec<CollectionReport>,
9  pub errors: Vec<EngineError>,
10}
11
12/// Non-fatal compile failure. Build continues unless `strict`.
13#[derive(Debug)]
14pub struct EngineError {
15  pub file: PathBuf,
16  pub message: String,
17}
18
19#[derive(Debug, Default)]
20pub struct CollectionReport {
21  pub name: String,
22  pub records: usize,
23  pub output_path: PathBuf,
24}
25
26pub fn build_schema_ctx(path: &Path, root: &Path, compiled: &CompileOutput, cfg: &EngineConfig) -> dmc_schema::Ctx {
27  let mut ctx = dmc_schema::Ctx::new(path.to_path_buf(), root.to_path_buf(), compiled.content.clone());
28  ctx.html = Some(compiled.html.clone());
29  ctx.mdx_body = Some(compiled.body.clone());
30  ctx.toc = Some(serde_json::to_value(&compiled.toc).unwrap_or(Value::Array(vec![])));
31  ctx.plain_text = Some(compiled.excerpt.clone());
32  if let (Some(dir), Some(base)) = (&cfg.compile.output_assets, &cfg.compile.output_base) {
33    let mut p = dmc_schema::AssetPipeline::new(dir.into(), base.into());
34    if let Some(t) = &cfg.output_name {
35      p.name_template = t.into();
36    }
37    ctx.assets = Some(p);
38  }
39  ctx
40}
41
42/// velite-shaped record: `{ ...frontmatter, body, content, slug, permalink, path, ...optional html }`.
43pub fn build_velite_record(
44  compiled: CompileOutput,
45  frontmatter: Value,
46  path: &Path,
47  base: &Path,
48  collection: &str,
49  include_html: bool,
50) -> Value {
51  let rel = path.strip_prefix(base).unwrap_or(path);
52  let rel_str = rel.to_string_lossy().to_string();
53  let source_file_path = path.to_string_lossy().to_string();
54  let source_file_name = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
55  let source_file_dir = path
56    .parent()
57    .map(|p| {
58      let mut comps: Vec<String> = p.components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect();
59      if comps.len() >= 2 {
60        let last2 = comps.split_off(comps.len() - 2);
61        last2.join("/")
62      } else {
63        comps.join("/")
64      }
65    })
66    .unwrap_or_default();
67  let content_type = path.extension().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
68  let permalink = velite_permalink(&source_file_path, &rel_str, collection);
69  let flattened_path = permalink.clone();
70  let slug = if permalink.is_empty() {
71    collection.to_lowercase()
72  } else {
73    format!("{}/{}", collection.to_lowercase(), permalink)
74  };
75
76  let mut map: Map<String, Value> = Map::new();
77  if let Value::Object(fm) = frontmatter {
78    for (k, v) in fm {
79      map.insert(k, v);
80    }
81  }
82
83  map.insert("body".into(), Value::String(compiled.body));
84  map.insert("content".into(), Value::String(compiled.content));
85  if include_html {
86    map.insert("html".into(), Value::String(compiled.html.clone()));
87  }
88  map.insert("excerpt".into(), Value::String(compiled.excerpt));
89  map.insert("metadata".into(), serde_json::to_value(&compiled.metadata).unwrap_or(json!({})));
90  map.insert("toc".into(), serde_json::to_value(&compiled.toc).unwrap_or(Value::Array(vec![])));
91  map.insert("contentType".into(), Value::String(content_type));
92  map.insert("flattenedPath".into(), Value::String(flattened_path));
93  map.insert("permalink".into(), Value::String(permalink));
94  map.insert("slug".into(), Value::String(slug));
95  map.insert("sourceFileDir".into(), Value::String(source_file_dir));
96  map.insert("sourceFileName".into(), Value::String(source_file_name));
97  map.insert("sourceFilePath".into(), Value::String(source_file_path));
98
99  Value::Object(map)
100}
101
102/// Wrap MDX body in an ES-module shell, hoisting imports above the function.
103pub fn wrap_mdx_module(body: &str, imports: &[String]) -> String {
104  // Imports re-emit at module scope.
105  let mut stripped = body.to_string();
106  for imp in imports {
107    let trimmed = imp.trim_end_matches('\n');
108    if !trimmed.is_empty() {
109      stripped = stripped.replacen(trimmed, "", 1);
110    }
111  }
112  // Strip the trailing default-export; module shell re-emits its own.
113  // Fallback handles the legacy direct-invoke return form.
114  let mut stripped = stripped.trim_end_matches('\n').to_string();
115  if let Some(idx) = stripped.rfind("return { default:") {
116    stripped.truncate(idx);
117  } else {
118    stripped = stripped
119      .trim_end_matches("return _createMdxContent(arguments[0]);")
120      .trim_end_matches("return _createMdxContent(arguments[0])")
121      .to_string();
122  }
123  let stripped = stripped.trim_end().to_string();
124  let stripped = stripped.replace("arguments[0]", "__runtime");
125
126  let mut out = String::new();
127  out.push_str("import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from 'react/jsx-runtime'\n");
128  for i in imports {
129    out.push_str(i);
130    if !i.ends_with('\n') {
131      out.push('\n');
132    }
133  }
134  out.push_str("const __runtime = { Fragment: _Fragment, jsx: _jsx, jsxs: _jsxs };\n");
135  out.push_str(&stripped);
136  out.push_str("\nexport default function MDXContent(props) { return _createMdxContent(props); }\n");
137  out
138}
139
140/// Best-effort minifier: strip comments + collapse whitespace.
141/// Not a full parser; regex literals and JSX edge cases not handled.
142pub fn minify_js(src: &str) -> String {
143  #[derive(Clone, Copy, PartialEq)]
144  enum St {
145    Code,
146    Squote,
147    Dquote,
148    Btick,
149    LineComment,
150    BlockComment,
151  }
152  let mut out = String::with_capacity(src.len());
153  let mut st = St::Code;
154  let mut prev_ws = false;
155  let mut chars = src.chars().peekable();
156  while let Some(c) = chars.next() {
157    match st {
158      St::Code => {
159        if c == '/' {
160          if matches!(chars.peek(), Some('/')) {
161            chars.next();
162            st = St::LineComment;
163            continue;
164          }
165          if matches!(chars.peek(), Some('*')) {
166            chars.next();
167            st = St::BlockComment;
168            continue;
169          }
170        }
171        if c == '"' {
172          st = St::Dquote;
173          out.push(c);
174          prev_ws = false;
175          continue;
176        }
177        if c == '\'' {
178          st = St::Squote;
179          out.push(c);
180          prev_ws = false;
181          continue;
182        }
183        if c == '`' {
184          st = St::Btick;
185          out.push(c);
186          prev_ws = false;
187          continue;
188        }
189        if c == '\n' || c == '\t' || c == ' ' {
190          if prev_ws {
191            continue;
192          }
193          prev_ws = true;
194          out.push(' ');
195          continue;
196        }
197        prev_ws = false;
198        out.push(c);
199      },
200      St::Squote => {
201        out.push(c);
202        if c == '\\' {
203          if let Some(n) = chars.next() {
204            out.push(n);
205          }
206          continue;
207        }
208        if c == '\'' {
209          st = St::Code;
210        }
211      },
212      St::Dquote => {
213        out.push(c);
214        if c == '\\' {
215          if let Some(n) = chars.next() {
216            out.push(n);
217          }
218          continue;
219        }
220        if c == '"' {
221          st = St::Code;
222        }
223      },
224      St::Btick => {
225        out.push(c);
226        if c == '\\' {
227          if let Some(n) = chars.next() {
228            out.push(n);
229          }
230          continue;
231        }
232        if c == '`' {
233          st = St::Code;
234        }
235      },
236      St::LineComment => {
237        if c == '\n' {
238          st = St::Code;
239        }
240      },
241      St::BlockComment => {
242        if c == '*' && matches!(chars.peek(), Some('/')) {
243          chars.next();
244          st = St::Code;
245        }
246      },
247    }
248  }
249  out
250}
251
252/// Velite permalink: collection name + slug (or file stem if no slug).
253fn velite_permalink(abs: &str, rel: &str, collection: &str) -> String {
254  let lc = collection.to_lowercase();
255  let needle = format!("/{lc}/");
256  let after = if let Some(idx) = abs.rfind(&needle) { &abs[idx + needle.len()..] } else { rel };
257  after.trim_end_matches(".mdx").trim_end_matches(".md").to_string()
258}
259
260pub fn is_js_ident(s: &str) -> bool {
261  let mut chars = s.chars();
262  match chars.next() {
263    Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$' => {},
264    _ => return false,
265  }
266  chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
267}
268
269/// `kebab/snake/space case` -> `PascalCase`. Empty input -> `"Doc"`.
270pub fn pascal_case(name: &str) -> String {
271  let mut out = String::with_capacity(name.len());
272  let mut upper = true;
273  for ch in name.chars() {
274    if ch == '-' || ch == '_' || ch == ' ' {
275      upper = true;
276      continue;
277    }
278    if upper {
279      out.extend(ch.to_uppercase());
280      upper = false;
281    } else {
282      out.push(ch);
283    }
284  }
285  if out.is_empty() { "Doc".into() } else { out }
286}
287
288/// POSIX-style relative path (forward slashes), canonicalising when possible.
289pub fn relative_from(from_dir: &Path, target: &Path) -> String {
290  let from_abs = from_dir.canonicalize().unwrap_or_else(|_| from_dir.to_path_buf());
291  let to_abs = target.canonicalize().unwrap_or_else(|_| target.to_path_buf());
292  let from_parts: Vec<Component<'_>> = from_abs.components().collect();
293  let to_parts: Vec<Component<'_>> = to_abs.components().collect();
294  let common = from_parts.iter().zip(&to_parts).take_while(|(a, b)| a == b).count();
295  let ups = from_parts.len().saturating_sub(common);
296  let mut out = String::new();
297  for _ in 0..ups {
298    out.push_str("../");
299  }
300  if ups == 0 {
301    out.push_str("./");
302  }
303  let tail: Vec<String> = to_parts[common..]
304    .iter()
305    .filter_map(|c| match c {
306      Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
307      _ => None,
308    })
309    .collect();
310  out.push_str(&tail.join("/"));
311  out
312}