Skip to main content

dmc/engine/
compile.rs

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  /// `None` = bundled defaults (Catppuccin Latte/Mocha, CSS-vars output).
33  pub pretty_code: Option<PrettyCodeOptions>,
34  /// `None` = bundled defaults (light+dark, `htmlLabels:false`, responsive SVG).
35  pub mermaid: Option<MermaidOptions>,
36  /// `None` = KaTeX (rehype-katex parity); `Some(Mathml)` = pulldown-latex (fast).
37  pub math_engine: Option<MathEngine>,
38  /// Force every listed JS plugin to the sidecar; drop every native transformer.
39  pub force_sidecar: bool,
40  /// Per-plugin sidecar override. Recognised entries: "remark-gfm",
41  /// "remark-math", "remark-emoji", "rehype-pretty-code", "shiki",
42  /// "rehype-katex", "rehype-mathjax", "rehype-slug",
43  /// "rehype-autolink-headings".
44  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  /// Drops JS plugins owned by an in-process transformer. When the
84  /// matching feature is off, the plugin stays in the list and the
85  /// sidecar runs it.
86  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  /// Per-file config: native HTML off when sidecar will run.
135  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  /// `path` resolves relative assets for `copy-linked-files`.
142  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    // Drop the native transformer when the user prefers the JS plugin.
155    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
180/// Bare string or unified `[name, options]` array.
181fn 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  /// One-shot compile with default pipeline. Use
215  /// [`Self::compile_with_pipeline`] for real spans against a path.
216  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    // Rewrite `$...$` / `$$...$$` to `<MathMl/>` so the parser does not
228    // treat `_` / `^` inside math as emphasis markers.
229    #[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  /// Per-sink `DiagnosticEngine`s merge into the caller's engine after
250  /// the walk (avoids `RefCell` overhead on every sink emit).
251  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/// `reading_time` in minutes (min 1).
298#[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/// `url` is `#<heading-slug>`.
306#[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/// Compiled `.mdx` output. All fields always populated; serialised camelCase.
392#[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}