Skip to main content

rac_engine/
mdhtml.rs

1//! CommonMark -> HTML rendering for `export` `body_html`, byte-matching
2//! markdown-it-py 4.2.0's `"commonmark"` preset with `{"html": False}`.
3//!
4//! The commonmark preset pins `xhtmlOut: true` (`<hr />`, `<br />`),
5//! `langPrefix: "language-"`, typographer/linkify off. `html: False`
6//! disables the raw-HTML block/inline rules entirely, so HTML-looking
7//! source arrives as escaped text. The `markdown-it` crate's `cmark`
8//! plugin set matches that rule set exactly when the `html` plugin is
9//! not installed; `xrender` selects the XHTML dialect.
10
11use std::sync::OnceLock;
12
13use markdown_it::parser::block::builtin::BlockParserRule;
14use markdown_it::parser::core::CoreRule;
15use markdown_it::parser::inline::builtin::InlineParserRule;
16use markdown_it::parser::inline::InlineRoot;
17use markdown_it::plugins::cmark::block::fence::CodeFence;
18use markdown_it::plugins::cmark::block::heading::ATXHeading;
19use markdown_it::plugins::cmark::block::lheading::SetextHeader;
20use markdown_it::plugins::cmark::block::list::ListItem;
21use markdown_it::plugins::cmark::block::paragraph::Paragraph;
22use markdown_it::{MarkdownIt, Node};
23
24/// markdown-it-py computes heading/lheading/paragraph token content as
25/// `<lines>.strip()` — CPython `str.strip()`, whose whitespace set includes
26/// `\x0b \x0c \x1c-\x1f \x85 \xa0` and more — while the markdown-it crate
27/// only trims spaces/tabs around inline content. Re-strip those blocks'
28/// pending inline content with Python semantics BEFORE inline parsing runs.
29/// Trimming shifts inline srcmaps by the
30/// leading cut; nothing in the export renderer consumes inline srcmaps.
31///
32/// Tight lists need the same treatment via `ListItem`: markdown-it-py keeps
33/// the paragraph tokens (merely hidden), so their content is still
34/// `str.strip()`-ed, while the markdown-it crate SPLICES tight paragraphs'
35/// children directly into the list item (`mark_tight_paragraphs`), leaving
36/// `InlineRoot` nodes whose parent is the `ListItem` itself.
37struct PyStripInlineRule;
38
39impl CoreRule for PyStripInlineRule {
40    fn run(root: &mut Node, _md: &MarkdownIt) {
41        fn walk(node: &mut Node) {
42            let strip_it = node.is::<ATXHeading>()
43                || node.is::<SetextHeader>()
44                || node.is::<Paragraph>()
45                || node.is::<ListItem>();
46            for child in node.children.iter_mut() {
47                if strip_it {
48                    if let Some(inline) = child.cast_mut::<InlineRoot>() {
49                        let stripped = crate::pycompat::py_strip(&inline.content);
50                        if stripped.len() != inline.content.len() {
51                            inline.content = stripped.to_string();
52                        }
53                    }
54                }
55                walk(child);
56            }
57        }
58        walk(root);
59    }
60}
61
62fn parser() -> &'static MarkdownIt {
63    static PARSER: OnceLock<MarkdownIt> = OnceLock::new();
64    PARSER.get_or_init(|| {
65        let mut md = MarkdownIt::new();
66        markdown_it::plugins::cmark::add(&mut md);
67        md.add_rule::<PyStripInlineRule>()
68            .after::<BlockParserRule>()
69            .before::<InlineParserRule>();
70        md
71    })
72}
73
74/// Render a Markdown body to HTML (raw HTML disabled -> escaped as text).
75pub fn render(body: &str) -> String {
76    let mut ast = parser().parse(body);
77    if !body.ends_with('\n') {
78        fix_eof_fence_content(&mut ast, body);
79    }
80    ast.xrender()
81}
82
83/// markdown-it-py builds fence content by SLICING the source (`src[first:
84/// eMark+1]`, which silently truncates at EOF), so an UNCLOSED fence whose
85/// last content line is the document's final line — in a document with no
86/// trailing newline — has NO trailing `\n` in its content. The markdown-it
87/// crate's `get_lines` appends `\n` unconditionally. Strip that synthetic
88/// newline to match.
89///
90/// Unclosed-at-EOF is detected structurally: the fence's source span ends at
91/// EOF and holds exactly `1 + content lines` source lines (a CLOSED fence
92/// spans one more line — its end marker — and its last content line then has
93/// a real newline in the source). This holds inside containers too, since
94/// container prefixes change line content but not line counts.
95fn fix_eof_fence_content(node: &mut Node, body: &str) {
96    for child in node.children.iter_mut() {
97        fix_eof_fence_content(child, body);
98    }
99    let Some(map) = node.srcmap else { return };
100    let (start, end) = map.get_byte_offsets();
101    if end != body.len() {
102        return;
103    }
104    if let Some(fence) = node.cast_mut::<CodeFence>() {
105        if fence.content.ends_with('\n') {
106            let span_lines = body[start..end].lines().count();
107            let content_lines = fence.content.lines().count();
108            if span_lines == content_lines + 1 {
109                fence.content.pop();
110            }
111        }
112    }
113}