Skip to main content

orbital_markdown/
citations.rs

1use regex::Regex;
2
3use crate::citation_style::CitationLinkStyle;
4use crate::links::ORBITAL_LINK_INLINE_CLASS;
5
6/// Citation id + 1-based display index for ref resolution.
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct CitationRef<'a> {
9    pub id: &'a str,
10    pub display_index: usize,
11}
12
13/// Replace `[^id]` in HTML output with superscript anchor links.
14pub fn replace_citation_refs(
15    html: &str,
16    citations: &[CitationRef<'_>],
17    style: CitationLinkStyle,
18) -> String {
19    if citations.is_empty() {
20        return html.to_string();
21    }
22
23    let mut result = html.to_string();
24    for citation in citations {
25        let pattern = format!(r"\[\^{}\]", regex::escape(citation.id));
26        let Ok(re) = Regex::new(&pattern) else {
27            continue;
28        };
29        let row_anchor = format!("{}{}", style.row_anchor_prefix, citation.id);
30        let ref_id = format!("{}{}", style.ref_id_prefix, citation.id);
31        let replacement = format!(
32            r##"<sup class="{class}"><a href="#{row_anchor}" id="{ref_id}" class="{link_class}" data-citation-id="{id}">{index}</a></sup>"##,
33            class = style.anchor_class,
34            link_class = ORBITAL_LINK_INLINE_CLASS,
35            row_anchor = row_anchor,
36            ref_id = ref_id,
37            id = citation.id,
38            index = citation.display_index,
39        );
40        result = re.replace_all(&result, replacement.as_str()).to_string();
41    }
42    result
43}
44
45/// Strip markdown image syntax for URLs already rendered as File parts.
46pub fn strip_duplicate_images(markdown: &str, attachment_urls: &[&str]) -> String {
47    if attachment_urls.is_empty() {
48        return markdown.to_string();
49    }
50
51    let mut result = markdown.to_string();
52    for url in attachment_urls {
53        let escaped = regex::escape(url);
54        let pattern = format!(r"!\[[^\]]*\]\({escaped}\)");
55        if let Ok(re) = Regex::new(&pattern) {
56            result = re.replace_all(&result, "").to_string();
57        }
58    }
59    result
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65
66    #[test]
67    fn replaces_discussion_citation_ref_in_html() {
68        let html = "<p>See [^cit-1] for details.</p>";
69        let citations = vec![CitationRef {
70            id: "cit-1",
71            display_index: 1,
72        }];
73        let out = replace_citation_refs(html, &citations, CitationLinkStyle::discussion());
74        assert!(out.contains("discussion-citation-row-cit-1"));
75        assert!(out.contains(">1</a>"));
76        assert!(!out.contains("[^cit-1]"));
77    }
78
79    #[test]
80    fn replaces_history_citation_ref_in_html() {
81        let html = "<p>See [^audit-1] for details.</p>";
82        let citations = vec![CitationRef {
83            id: "audit-1",
84            display_index: 2,
85        }];
86        let out = replace_citation_refs(html, &citations, CitationLinkStyle::history());
87        assert!(out.contains("history-citation-row-audit-1"));
88        assert!(out.contains("history-citation-ref-audit-1"));
89        assert!(out.contains("orbital-history__citation-ref"));
90        assert!(out.contains("orbital-link orbital-link--inline"));
91    }
92}