mdforge 0.1.0

Define, validate, and render typed Markdown extensions for LLM-generated content.
Documentation
use pulldown_cmark::{html, Options, Parser};

use crate::{BlockNode, Diagnostic, Document, ErrorCode, InlineExt, Level, MdEvent, Node};

use super::{args::parse_inline_exts, EvalContext, Forge};

/// Renderer used by [`crate::Forge::render_html`] for mdforge extensions.
///
/// Normal Markdown text is rendered with `pulldown-cmark`; this trait is only
/// responsible for registered mdforge block and inline extensions.
pub trait HtmlRenderer {
    /// Render a block extension with its body already converted to HTML.
    fn render_block(&self, block: &BlockNode, ctx: &EvalContext, children_html: String) -> String;

    /// Render an inline extension to an HTML fragment.
    fn render_inline(&self, inline: &InlineExt, ctx: &EvalContext) -> String;
}

pub(super) fn render_html_document(
    forge: &Forge,
    doc: &Document,
    ctx: &EvalContext,
    renderer: &dyn HtmlRenderer,
) -> Result<String, Vec<Diagnostic>> {
    let mut diagnostics = Vec::new();
    let html = render_nodes(forge, &doc.nodes, ctx, renderer, &mut diagnostics);
    if diagnostics.is_empty() {
        Ok(html)
    } else {
        Err(diagnostics)
    }
}

fn render_nodes(
    forge: &Forge,
    nodes: &[Node],
    ctx: &EvalContext,
    renderer: &dyn HtmlRenderer,
    diagnostics: &mut Vec<Diagnostic>,
) -> String {
    let mut out = String::new();
    for node in nodes {
        match node {
            Node::Markdown(events) => {
                for event in events {
                    let MdEvent::Text(text) = event;
                    out.push_str(&render_markdown_text(
                        forge,
                        text,
                        ctx,
                        renderer,
                        diagnostics,
                    ));
                }
            }
            Node::Block(block) => {
                if forge.blocks.iter().any(|b| b.name == block.name) {
                    let children = render_nodes(forge, &block.body, ctx, renderer, diagnostics);
                    out.push_str(&renderer.render_block(block, ctx, children));
                } else {
                    diagnostics.push(Diagnostic {
                        level: Level::Error,
                        code: ErrorCode::UnknownBlock,
                        message: format!("unknown block '{}'", block.name),
                        span: block.span.clone(),
                        suggestion: None,
                    });
                }
            }
        }
    }
    out
}

fn render_markdown_text(
    forge: &Forge,
    text: &str,
    ctx: &EvalContext,
    renderer: &dyn HtmlRenderer,
    diagnostics: &mut Vec<Diagnostic>,
) -> String {
    let mut markdown = String::new();
    let mut replacements = Vec::new();
    let mut last = 0;
    for (index, inline) in parse_inline_exts(text).into_iter().enumerate() {
        let local_start = inline.span.start;
        let local_end = inline.span.end;
        if local_start > last {
            markdown.push_str(&text[last..local_start]);
        }

        let placeholder = format!("%%MDFORGE_INLINE_{}%%", index);
        markdown.push_str(&placeholder);

        if forge.inlines.iter().any(|i| i.name == inline.name) {
            replacements.push((placeholder, renderer.render_inline(&inline, ctx)));
        } else {
            diagnostics.push(Diagnostic {
                level: Level::Error,
                code: ErrorCode::UnknownInline,
                message: format!("unknown inline '{}'", inline.name),
                span: inline.span.clone(),
                suggestion: None,
            });
            replacements.push((placeholder, escape_html(&text[local_start..local_end])));
        }
        last = local_end;
    }
    if last < text.len() {
        markdown.push_str(&text[last..]);
    }

    let mut out = markdown_to_html(&markdown);
    for (placeholder, html) in replacements {
        out = out.replace(&placeholder, &html);
    }
    out
}

fn markdown_to_html(markdown: &str) -> String {
    let parser = Parser::new_ext(markdown, Options::all());
    let mut out = String::new();
    html::push_html(&mut out, parser);
    out
}

fn escape_html(value: &str) -> String {
    value
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}