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}