mdpdf-core 0.0.0-alpha.0

Markdown parsing, frontmatter extraction, and syntax highlighting for mdpdf
Documentation
use comrak::{parse_document, format_html_with_plugins, Arena, Options, Plugins};
use comrak::nodes::AstNode;

use mdpdf_theme::{Layout, Theme};

use crate::directives;
use crate::frontmatter::parse_frontmatter;
use crate::highlight::SyntectHighlighter;

/// Result of rendering markdown to HTML.
pub struct RenderResult {
    pub html: String,
    pub layout: Layout,
}

/// Render a markdown source string to a complete HTML document.
///
/// Pipeline:
///   1. Extract frontmatter (title, subtitle, mode, toc)
///   2. Parse body into comrak AST
///   3. Walk AST — transform directive HTML comments into layout HTML
///   4. Render AST to HTML with syntax highlighting
///   5. Wrap in themed CSS + header
pub fn render_to_html(src: &str, theme: Theme, dense: bool) -> RenderResult {
    let (fm, body) = parse_frontmatter(src);
    let layout = if dense || fm.is_dense() {
        Layout::Dense
    } else {
        Layout::Normal
    };

    let highlighter = SyntectHighlighter::new(theme.syntect_theme());

    let mut options = Options::default();
    options.extension.table = true;
    options.extension.strikethrough = true;
    options.extension.autolink = true;
    options.render.unsafe_ = true; // allow raw HTML passthrough

    let mut plugins = Plugins::default();
    plugins.render.codefence_syntax_highlighter = Some(&highlighter);

    // Parse → transform directives → render
    let arena = Arena::new();
    let root = parse_document(&arena, body, &options);
    directives::transform(root);
    let rendered = render_ast(root, &options, &plugins);

    let css = mdpdf_theme::generate_css(&theme, &layout);

    // Header from frontmatter
    let h1_font_size = match layout {
        Layout::Dense => "18pt",
        Layout::Normal => "22pt",
    };
    let header_html = match &fm.title {
        Some(title) => {
            let subtitle = fm.subtitle.as_deref().map(|s| {
                format!("<div class=\"subtitle\">{s}</div>")
            }).unwrap_or_default();
            let date = fm.date.as_deref().map(|d| {
                format!("<div class=\"date\">{d}</div>")
            }).unwrap_or_default();
            format!(
                r#"<div class="doc-header">
                    <h1 style="border:none;margin:0;padding:0;font-size:{h1_font_size};color:var(--header-fg)">{title}</h1>
                    {subtitle}
                    {date}
                </div>"#,
            )
        }
        None => String::new(),
    };

    let html = format!(
        r#"<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <style>{css}</style>
</head>
<body>
  {header_html}
  {rendered}
</body>
</html>"#,
    );

    RenderResult { html, layout }
}

fn render_ast<'a>(root: &'a AstNode<'a>, options: &Options, plugins: &Plugins<'_>) -> String {
    let mut output = Vec::new();
    format_html_with_plugins(root, options, &mut output, plugins)
        .expect("HTML rendering failed");
    String::from_utf8(output).expect("HTML output was not valid UTF-8")
}