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#[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
42pub 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
102pub fn wrap_mdx_module(body: &str, imports: &[String]) -> String {
104 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 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
140pub 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
252fn 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
269pub 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
288pub 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}