Abbaye

at 44279e3 Raw

use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd, html};

pub(crate) fn extract_local_refs(md: &str) -> Vec<String> {
    let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
    let mut refs = Vec::new();
    for event in Parser::new_ext(md, opts) {
        let url: Option<pulldown_cmark::CowStr> = match event {
            Event::Start(Tag::Image { dest_url, .. }) => Some(dest_url),
            Event::Start(Tag::Link { dest_url, .. }) => Some(dest_url),
            Event::End(TagEnd::Image | TagEnd::Link) => None,
            _ => None,
        };
        if let Some(url) = url {
            let s = url.as_ref();
            if !s.contains("://") && !s.starts_with('#') && !s.is_empty() {
                refs.push(s.to_owned());
            }
        }
    }
    refs
}

pub(crate) fn render_markdown_html(md: &str) -> String {
    let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
    let parser = Parser::new_ext(md, opts);
    let mut buf = String::new();
    html::push_html(&mut buf, parser);
    buf
}

pub(crate) fn render_markdown_gemtext(md: &str) -> String {
    let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
    let parser = Parser::new_ext(md, opts);

    let mut out: Vec<String> = Vec::new();
    let mut buf = String::new();
    let mut links: Vec<(String, String)> = Vec::new();
    let mut last_blank = false;

    enum Ctx {
        Paragraph,
        BlockQuote,
        ListItem,
        Heading(u8),
    }
    let mut stack: Vec<Ctx> = Vec::new();

    let mut in_link = false;
    let mut link_url = String::new();
    let mut link_text = String::new();

    let mut in_code = false;
    let mut code_kind = String::new();
    let mut code_body = String::new();

    fn in_ctx(stack: &[Ctx], target: Ctx) -> bool {
        stack
            .iter()
            .any(|c| std::mem::discriminant(c) == std::mem::discriminant(&target))
    }

    fn flush_line(
        out: &mut Vec<String>,
        buf: &mut String,
        links: &mut Vec<(String, String)>,
        stack: &[Ctx],
        _last_blank: &mut bool,
    ) {
        let text = buf.trim();
        if text.is_empty() && links.is_empty() {
            return;
        }

        let in_blockquote = in_ctx(stack, Ctx::BlockQuote);
        let in_list = in_ctx(stack, Ctx::ListItem);

        if let Some(Ctx::Heading(lvl)) = stack.last() {
            let prefix = match lvl {
                1 => "#",
                2 => "##",
                _ => "###",
            };
            if !text.is_empty() {
                out.push(format!("{prefix} {text}"));
            }
            for (url, t) in links.drain(..) {
                out.push(format!("=> {url} {t}"));
            }
            *_last_blank = false;
        } else if in_blockquote {
            if !text.is_empty() {
                for line in text.lines() {
                    out.push(format!("> {line}"));
                }
            }
            for (url, t) in links.drain(..) {
                out.push(format!("=> {url} {t}"));
            }
            *_last_blank = false;
        } else if in_list {
            if !text.is_empty() {
                out.push(format!("* {text}"));
            }
            for (url, t) in links.drain(..) {
                out.push(format!("=> {url} {t}"));
            }
            *_last_blank = false;
        } else {
            if !text.is_empty() {
                out.push(text.to_string());
            }
            for (url, t) in links.drain(..) {
                out.push(format!("=> {url} {t}"));
            }
            *_last_blank = false;
        }
        buf.clear();
    }

    for event in parser {
        match event {
            Event::Start(tag) => match tag {
                Tag::Heading { level, .. } => {
                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
                    let lvl = level as u8;
                    stack.push(Ctx::Heading(if lvl > 3 { 3 } else { lvl }));
                }
                Tag::Paragraph => {
                    stack.push(Ctx::Paragraph);
                }
                Tag::BlockQuote(_) => {
                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
                    stack.push(Ctx::BlockQuote);
                }
                Tag::CodeBlock(kind) => {
                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
                    in_code = true;
                    code_kind = match kind {
                        CodeBlockKind::Fenced(info) => info.to_string(),
                        CodeBlockKind::Indented => String::new(),
                    };
                    code_body.clear();
                }
                Tag::List { .. } => {}
                Tag::Item => {
                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
                    stack.push(Ctx::ListItem);
                }
                Tag::Link { dest_url, .. } => {
                    in_link = true;
                    link_url = dest_url.to_string();
                    link_text.clear();
                }
                Tag::Image { dest_url, .. } => {
                    in_link = true;
                    link_url = dest_url.to_string();
                    link_text.clear();
                }
                _ => {}
            },
            Event::End(tag) => match tag {
                TagEnd::Heading(_) => {
                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
                    stack.pop();
                }
                TagEnd::Paragraph => {
                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
                    if !in_ctx(&stack, Ctx::BlockQuote) && !in_ctx(&stack, Ctx::ListItem) {
                        out.push(String::new());
                        last_blank = true;
                    }
                    stack.pop();
                }
                TagEnd::BlockQuote(_) => {
                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
                    out.push(String::new());
                    last_blank = true;
                    stack.pop();
                }
                TagEnd::CodeBlock => {
                    if !code_body.is_empty() {
                        out.push(format!("```{}", code_kind));
                        for line in code_body.trim().lines() {
                            out.push(line.to_string());
                        }
                        out.push("```".to_string());
                        out.push(String::new());
                        last_blank = true;
                    }
                    in_code = false;
                    code_kind.clear();
                    code_body.clear();
                }
                TagEnd::List(_) => {
                    out.push(String::new());
                    last_blank = true;
                }
                TagEnd::Item => {
                    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);
                    stack.pop();
                }
                TagEnd::Link => {
                    let t = link_text.trim().to_string();
                    if in_link && !link_url.is_empty() && !t.is_empty() {
                        links.push((link_url.clone(), t));
                    }
                    in_link = false;
                    link_url.clear();
                    link_text.clear();
                }
                TagEnd::Image => {
                    let t = if link_text.trim().is_empty() {
                        "image".to_string()
                    } else {
                        link_text.trim().to_string()
                    };
                    if in_link {
                        out.push(format!("=> {} {}", link_url, t));
                    }
                    in_link = false;
                    link_url.clear();
                    link_text.clear();
                }
                _ => {}
            },
            Event::Text(text) => {
                if in_code {
                    code_body.push_str(&text);
                } else if in_link {
                    link_text.push_str(&text);
                } else {
                    buf.push_str(&text);
                }
            }
            Event::Code(text) => {
                if in_link {
                    link_text.push_str(&text);
                } else {
                    buf.push_str(&text);
                }
            }
            Event::SoftBreak | Event::HardBreak => {
                if in_code {
                    code_body.push('\n');
                } else {
                    buf.push(' ');
                }
            }
            _ => {}
        }
    }

    flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank);

    while out.last().is_some_and(|l| l.is_empty()) {
        out.pop();
    }

    out.join("\n") + "\n"
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_extract_local_refs_none() {
        let md = "# Hello\n\nJust text, no links.\n";
        assert!(extract_local_refs(md).is_empty());
    }

    #[test]
    fn test_extract_local_refs_absolute_url() {
        let md = "[foo](https://example.com)";
        assert!(extract_local_refs(md).is_empty());
    }

    #[test]
    fn test_extract_local_refs_relative() {
        let md = "[foo](/bar/baz)";
        assert_eq!(extract_local_refs(md), vec!["/bar/baz"]);
    }

    #[test]
    fn test_extract_local_refs_fragment() {
        let md = "[foo](#section)";
        assert!(extract_local_refs(md).is_empty());
    }

    #[test]
    fn test_render_markdown_html_basic() {
        let md = "# Hello\n\nThis is **bold**.";
        let html = render_markdown_html(md);
        assert!(html.contains("<h1>Hello</h1>"));
        assert!(html.contains("<strong>bold</strong>"));
    }

    #[test]
    fn test_render_markdown_html_table() {
        let md = "| A | B |\n|---|---|\n| 1 | 2 |";
        let html = render_markdown_html(md);
        assert!(html.contains("<table>"));
    }

    #[test]
    fn test_render_markdown_gemtext_heading() {
        let md = "# Title\n\n## Subtitle\n\n### H3\n\n#### H4 (clamped)";
        let gmi = render_markdown_gemtext(md);
        assert_eq!(gmi.lines().next(), Some("# Title"));
        assert!(gmi.contains("## Subtitle"));
        assert!(gmi.contains("### H3"));
        assert!(gmi.contains("### H4 (clamped)"));
    }

    #[test]
    fn test_render_markdown_gemtext_paragraph() {
        let md = "Hello, world!";
        let gmi = render_markdown_gemtext(md);
        assert!(gmi.starts_with("Hello, world!"));
    }

    #[test]
    fn test_render_markdown_gemtext_link() {
        let md = "[example](https://example.com)";
        let gmi = render_markdown_gemtext(md);
        assert!(gmi.contains("=> https://example.com example"));
    }

    #[test]
    fn test_render_markdown_gemtext_image() {
        let md = "![alt](img.png)";
        let gmi = render_markdown_gemtext(md);
        assert!(gmi.contains("=> img.png alt"));
    }

    #[test]
    fn test_render_markdown_gemtext_image_no_alt() {
        let md = "![](img.png)";
        let gmi = render_markdown_gemtext(md);
        assert!(gmi.contains("=> img.png image"));
    }

    #[test]
    fn test_render_markdown_gemtext_blockquote() {
        let md = "> A wise quote.";
        let gmi = render_markdown_gemtext(md);
        assert!(gmi.contains("> A wise quote."));
    }

    #[test]
    fn test_render_markdown_gemtext_list() {
        let md = "- one\n- two\n- three";
        let gmi = render_markdown_gemtext(md);
        assert!(gmi.contains("* one"));
        assert!(gmi.contains("* two"));
        assert!(gmi.contains("* three"));
    }

    #[test]
    fn test_render_markdown_gemtext_code_block() {
        let md = "```rust\nfn main() {}\n```";
        let gmi = render_markdown_gemtext(md);
        assert!(gmi.contains("```rust"));
        assert!(gmi.contains("fn main() {}"));
        assert!(gmi.contains("```"));
    }

    #[test]
    fn test_render_markdown_gemtext_empty_code_block() {
        let md = "```\n```";
        let gmi = render_markdown_gemtext(md);
        // Empty code blocks are silently dropped.
        assert!(!gmi.contains("```"));
    }

    #[test]
    fn test_render_markdown_gemtext_trailing_newline() {
        let md = "# Hi";
        let gmi = render_markdown_gemtext(md);
        assert!(gmi.ends_with('\n'));
    }

    #[test]
    fn test_render_markdown_gemtext_inline_code() {
        let md = "use `foo` here";
        let gmi = render_markdown_gemtext(md);
        assert!(gmi.contains("foo"));
    }

    #[test]
    fn test_render_markdown_gemtext_soft_break() {
        let md = "line1\nline2";
        let gmi = render_markdown_gemtext(md);
        assert!(gmi.contains("line1 line2") || gmi.contains("line1\nline2"));
    }

    #[test]
    fn test_render_markdown_gemtext_config_option() {
        // Ensure ENABLE_TABLES and ENABLE_STRIKETHROUGH are set
        let md = "~~strike~~";
        let gmi = render_markdown_gemtext(md);
        assert!(gmi.contains("strike"));
    }
}