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