1use std::{path::Path, sync::Arc};
2
3use dmc_codegen::{HtmlEmitter, MdxBodyEmitter, Walker};
4use dmc_diagnostic::{
5 Code,
6 metadata::{Origin, SourceMeta},
7};
8use dmc_lexer::Lexer;
9use dmc_parser::{Parser, ast::Document};
10use dmc_transform::{CopyLinkedFilesOptions, MathEngine, MermaidOptions, PipelineConfig, PrettyCodeOptions};
11use duck_diagnostic::DiagnosticEngine;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15use crate::engine::accumulator::Accumulator;
16
17#[derive(Debug, Deserialize, Serialize, Clone)]
18#[serde(default)]
19pub struct CompileConfig {
20 pub markdown_gfm: bool,
21 pub emit_html: bool,
22 pub emit_body: bool,
23 pub mdx_minify: bool,
24 pub mdx_output_format: Option<String>,
25 pub markdown_remark_plugins: Vec<Value>,
26 pub markdown_rehype_plugins: Vec<Value>,
27 pub mdx_remark_plugins: Vec<Value>,
28 pub mdx_rehype_plugins: Vec<Value>,
29 pub copy_linked_files: bool,
30 pub output_assets: Option<String>,
31 pub output_base: Option<String>,
32 pub pretty_code: Option<PrettyCodeOptions>,
34 pub mermaid: Option<MermaidOptions>,
36 pub math_engine: Option<MathEngine>,
38 pub force_sidecar: bool,
40 pub prefer_sidecar: Vec<String>,
45}
46
47impl Default for CompileConfig {
48 fn default() -> Self {
49 Self {
50 markdown_gfm: true,
51 emit_html: true,
52 emit_body: true,
53 mdx_output_format: None,
54 mdx_minify: false,
55 markdown_remark_plugins: vec![],
56 markdown_rehype_plugins: vec![],
57 mdx_remark_plugins: vec![],
58 mdx_rehype_plugins: vec![],
59 copy_linked_files: false,
60 output_assets: None,
61 output_base: None,
62 pretty_code: None,
63 mermaid: None,
64 math_engine: None,
65 force_sidecar: false,
66 prefer_sidecar: vec![],
67 }
68 }
69}
70
71impl CompileConfig {
72 pub fn new() -> Self {
73 Self::default()
74 }
75
76 pub fn has_js_plugins(&self) -> bool {
77 !self.effective_markdown_remark_plugins().is_empty()
78 || !self.effective_mdx_remark_plugins().is_empty()
79 || !self.effective_markdown_rehype_plugins().is_empty()
80 || !self.effective_mdx_rehype_plugins().is_empty()
81 }
82
83 pub fn effective_markdown_remark_plugins(&self) -> Vec<Value> {
87 self.filter_native_owned_remark(&self.markdown_remark_plugins)
88 }
89
90 pub fn effective_mdx_remark_plugins(&self) -> Vec<Value> {
91 self.filter_native_owned_remark(&self.mdx_remark_plugins)
92 }
93
94 pub fn effective_markdown_rehype_plugins(&self) -> Vec<Value> {
95 self.filter_native_owned_rehype(&self.markdown_rehype_plugins)
96 }
97
98 pub fn effective_mdx_rehype_plugins(&self) -> Vec<Value> {
99 self.filter_native_owned_rehype(&self.mdx_rehype_plugins)
100 }
101
102 fn user_forces_sidecar(&self, name: &str) -> bool {
103 self.force_sidecar || self.prefer_sidecar.iter().any(|n| n == name)
104 }
105
106 fn filter_native_owned_remark(&self, plugins: &[Value]) -> Vec<Value> {
107 plugins
108 .iter()
109 .filter(|p| {
110 let Some(name) = plugin_name(p) else { return true };
111 if self.user_forces_sidecar(name) {
112 return true;
113 }
114 !is_native_owned_remark(p)
115 })
116 .cloned()
117 .collect()
118 }
119
120 fn filter_native_owned_rehype(&self, plugins: &[Value]) -> Vec<Value> {
121 plugins
122 .iter()
123 .filter(|p| {
124 let Some(name) = plugin_name(p) else { return true };
125 if self.user_forces_sidecar(name) {
126 return true;
127 }
128 !is_native_owned_rehype(p)
129 })
130 .cloned()
131 .collect()
132 }
133
134 pub fn for_render(&self) -> Self {
136 let mut c = self.clone();
137 c.emit_html = !self.has_js_plugins();
138 c
139 }
140
141 pub fn pipeline_config(&self, path: &Path) -> PipelineConfig {
143 let copy_linked_files = if self.copy_linked_files
144 && let (Some(assets), Some(public)) = (self.output_assets.as_ref(), self.output_base.as_ref())
145 {
146 Some(CopyLinkedFilesOptions {
147 source_dir: path.parent().unwrap_or(Path::new(".")).to_path_buf(),
148 assets_dir: assets.into(),
149 public_base: public.clone(),
150 })
151 } else {
152 None
153 };
154 let prefers = |needles: &[&str]| -> bool {
156 self.force_sidecar || self.prefer_sidecar.iter().any(|n| needles.contains(&n.as_str()))
157 };
158 let drop_pretty_code = prefers(&["rehype-pretty-code", "shiki"]);
159 let drop_math = prefers(&["remark-math", "rehype-katex", "rehype-mathjax"]);
160 let drop_emoji = prefers(&["remark-emoji"]);
161 let drop_autolink_headings = prefers(&["rehype-slug", "rehype-autolink-headings"]);
162 let drop_gfm = prefers(&["remark-gfm"]);
163 let drop_mermaid = prefers(&["mermaid", "rehype-mermaid", "remark-mermaid"]);
164
165 PipelineConfig {
166 markdown_gfm: Some(if drop_gfm { false } else { self.markdown_gfm }),
167 pretty_code: if drop_pretty_code { None } else { self.pretty_code.clone() },
168 math_engine: if drop_math { None } else { self.math_engine },
169 copy_linked_files,
170 emoji: if drop_emoji { Some(false) } else { None },
171 autolink_headings: if drop_autolink_headings { Some(false) } else { None },
172 math: if drop_math { Some(false) } else { None },
173 pretty_code_enabled: if drop_pretty_code { Some(false) } else { None },
174 mermaid: if drop_mermaid { None } else { self.mermaid.clone() },
175 mermaid_enabled: if drop_mermaid { Some(false) } else { None },
176 }
177 }
178}
179
180fn plugin_name(plugin: &Value) -> Option<&str> {
182 match plugin {
183 Value::String(s) => Some(s.as_str()),
184 Value::Array(a) => a.first().and_then(Value::as_str),
185 _ => None,
186 }
187}
188
189#[allow(clippy::match_like_matches_macro)]
190fn is_native_owned_remark(plugin: &Value) -> bool {
191 let Some(name) = plugin_name(plugin) else { return false };
192 match name {
193 "remark-gfm" => true,
194 "remark-math" => cfg!(feature = "math"),
195 "remark-emoji" => cfg!(feature = "emoji"),
196 _ => false,
197 }
198}
199
200#[allow(clippy::match_like_matches_macro)]
201fn is_native_owned_rehype(plugin: &Value) -> bool {
202 let Some(name) = plugin_name(plugin) else { return false };
203 match name {
204 "rehype-pretty-code" | "shiki" => cfg!(feature = "pretty-code"),
205 "rehype-katex" | "rehype-mathjax" => cfg!(feature = "math"),
206 "rehype-slug" | "rehype-autolink-headings" => true,
207 _ => false,
208 }
209}
210
211pub struct Compiler;
212
213impl Compiler {
214 pub fn compile(source: &str, diag_engine: &mut DiagnosticEngine<Code>) -> CompileOutput {
217 Self::compile_with_pipeline(source, Path::new("."), &CompileConfig::new(), diag_engine)
218 }
219
220 pub fn compile_with_pipeline(
221 source: &str,
222 path: &Path,
223 compile_cfg: &CompileConfig,
224 diag_engine: &mut DiagnosticEngine<Code>,
225 ) -> CompileOutput {
226 let meta = Arc::from(SourceMeta { path: Arc::from(path.display().to_string()), origin: Origin::File(path.into()) });
227 #[cfg(feature = "math")]
230 let preprocessed = dmc_transform::Math::preprocess_source(source);
231 #[cfg(feature = "math")]
232 let source: &str = &preprocessed;
233 let mut lexer = Lexer::new(source, meta.clone(), diag_engine);
234 let _ = lexer.scan_tokens();
235
236 let mut doc = {
237 let mut parser = Parser::new(lexer.tokens, meta.clone(), diag_engine);
238 parser.parse()
239 };
240
241 let pipeline_cfg = compile_cfg.pipeline_config(path);
242 let pipeline = dmc_transform::Pipeline::with_defaults_for(&pipeline_cfg);
243
244 pipeline.run(&mut doc, &meta, diag_engine);
245
246 Self::finalize(source, doc, compile_cfg, diag_engine)
247 }
248
249 fn finalize(
252 source: &str,
253 doc: Document,
254 compile_cfg: &CompileConfig,
255 diag_engine: &mut DiagnosticEngine<Code>,
256 ) -> CompileOutput {
257 let mut acc = Accumulator::new();
258 let mut html_sink = if compile_cfg.emit_html { Some(HtmlEmitter::new()) } else { None };
259 let mut body_sink = if compile_cfg.emit_body { Some(MdxBodyEmitter::new()) } else { None };
260
261 let mut sinks: Vec<&mut dyn dmc_codegen::NodeSink> = Vec::with_capacity(3);
262 sinks.push(&mut acc);
263 if let Some(ref mut h) = html_sink {
264 sinks.push(h);
265 }
266 if let Some(ref mut b) = body_sink {
267 sinks.push(b);
268 }
269
270 Walker::new(&doc).walk(sinks.as_mut_slice());
271
272 let (html, body) = match (html_sink, body_sink) {
273 (Some(h), Some(b)) => {
274 let (s, hd) = h.into_parts();
275 let (m, bd) = b.into_parts();
276 diag_engine.extend(hd);
277 diag_engine.extend(bd);
278 (s, m)
279 },
280 (Some(h), None) => {
281 let (s, hd) = h.into_parts();
282 diag_engine.extend(hd);
283 (s, String::new())
284 },
285 (None, Some(b)) => {
286 let (m, bd) = b.into_parts();
287 diag_engine.extend(bd);
288 (String::new(), m)
289 },
290 (None, None) => (String::new(), String::new()),
291 };
292
293 acc.into_compile_output(source, html, body, compile_cfg)
294 }
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize, Default)]
299#[serde(rename_all = "camelCase")]
300pub struct Metadata {
301 pub reading_time: u32,
302 pub word_count: u32,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct TocItem {
308 pub title: String,
309 pub url: String,
310 pub items: Vec<TocItem>,
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use serde_json::json;
317
318 #[test]
319 fn empty_plugin_lists_no_sidecar() {
320 let cfg = CompileConfig::default();
321 assert!(!cfg.has_js_plugins());
322 }
323
324 #[test]
325 fn arbitrary_remark_plugin_triggers_sidecar() {
326 let mut cfg = CompileConfig::default();
327 cfg.markdown_remark_plugins.push(json!("remark-frontmatter"));
328 assert!(cfg.has_js_plugins());
329 }
330
331 #[test]
332 fn remark_gfm_alone_skips_sidecar() {
333 let mut cfg = CompileConfig::default();
334 cfg.markdown_remark_plugins.push(json!("remark-gfm"));
335 assert!(!cfg.has_js_plugins(), "dmc parser handles GFM natively");
336 }
337
338 #[test]
339 fn rehype_slug_and_autolink_alone_skip_sidecar() {
340 let mut cfg = CompileConfig::default();
341 cfg.markdown_rehype_plugins.push(json!("rehype-slug"));
342 cfg.markdown_rehype_plugins.push(json!(["rehype-autolink-headings", { "behavior": "wrap" }]));
343 assert!(!cfg.has_js_plugins(), "AutolinkHeadings transformer handles slug + anchor natively");
344 }
345
346 #[cfg(feature = "math")]
347 #[test]
348 fn remark_math_alone_with_native_skips_sidecar() {
349 let mut cfg = CompileConfig::default();
350 cfg.markdown_remark_plugins.push(json!("remark-math"));
351 cfg.markdown_rehype_plugins.push(json!(["rehype-katex", { "errorColor": "red" }]));
352 assert!(!cfg.has_js_plugins(), "native math should absorb remark-math + rehype-katex");
353 }
354
355 #[cfg(feature = "emoji")]
356 #[test]
357 fn remark_emoji_alone_with_native_skips_sidecar() {
358 let mut cfg = CompileConfig::default();
359 cfg.markdown_remark_plugins.push(json!("remark-emoji"));
360 assert!(!cfg.has_js_plugins(), "native emoji should absorb remark-emoji");
361 }
362
363 #[cfg(feature = "pretty-code")]
364 #[test]
365 fn rehype_pretty_code_alone_with_native_skips_sidecar() {
366 let mut cfg = CompileConfig::default();
367 cfg.markdown_rehype_plugins.push(json!("rehype-pretty-code"));
368 cfg.mdx_rehype_plugins.push(json!(["rehype-pretty-code", { "theme": "github-dark" }]));
369 cfg.mdx_rehype_plugins.push(json!("shiki"));
370 assert!(!cfg.has_js_plugins(), "native should absorb rehype-pretty-code/shiki");
371 }
372
373 #[cfg(feature = "pretty-code")]
374 #[test]
375 fn other_rehype_plugin_still_triggers_sidecar_even_with_native() {
376 let mut cfg = CompileConfig::default();
377 cfg.markdown_rehype_plugins.push(json!("rehype-pretty-code"));
378 cfg.markdown_rehype_plugins.push(json!("rehype-external-links"));
379 assert!(cfg.has_js_plugins());
380 }
381
382 #[cfg(not(feature = "pretty-code"))]
383 #[test]
384 fn pretty_code_feature_off_means_rehype_pretty_code_routes_to_sidecar() {
385 let mut cfg = CompileConfig::default();
386 cfg.markdown_rehype_plugins.push(json!("rehype-pretty-code"));
387 assert!(cfg.has_js_plugins());
388 }
389}
390
391#[derive(Debug, Clone, Serialize, Deserialize)]
393#[serde(rename_all = "camelCase")]
394pub struct CompileOutput {
395 pub frontmatter: serde_json::Value,
396 pub frontmatter_raw: String,
397 pub content: String,
398 pub html: String,
399 pub body: String,
400 pub excerpt: String,
401 pub metadata: Metadata,
402 pub toc: Vec<TocItem>,
403 pub imports: Vec<String>,
404 pub exports: Vec<String>,
405}