Skip to main content

docs_core/
render.rs

1//! The renderer: CommonMark+GFM to static HTML, with island fences routed
2//! through [`crate::island`]. M1 scope is the element set the estate's docs
3//! actually use; the rest of the spec (search indexing, versioning, i18n)
4//! is the Wave-2 roadmap in the crate README, not silently absent.
5
6use crate::error::DocsError;
7use crate::island::Island;
8use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
9use std::fmt::Write as _;
10
11/// Render Markdown to HTML. Islands in ```` ```island ```` fences become
12/// astro-island-compatible custom elements (`base` prefixes component URLs).
13///
14/// # Errors
15/// [`DocsError`] for malformed island fences; Markdown itself never fails —
16/// unknown constructs render their inner content (documented M1 behaviour).
17pub fn render(markdown: &str, base: &str) -> Result<String, DocsError> {
18    let mut options = Options::empty();
19    options.insert(Options::ENABLE_TABLES);
20    options.insert(Options::ENABLE_STRIKETHROUGH);
21    options.insert(Options::ENABLE_TASKLISTS);
22    options.insert(Options::ENABLE_FOOTNOTES);
23
24    let mut out = String::new();
25    let mut headings: Vec<u8> = Vec::new();
26    // Open lists, innermost last, with their ordered flags — the closing tag
27    // must match the element actually opened, and paragraphs are suppressed
28    // only while *some* list is open.
29    let mut list_stack: Vec<bool> = Vec::new();
30    let mut in_code_block = false;
31    let mut parser = Parser::new_ext(markdown, options);
32
33    while let Some(event) = parser.next() {
34        match event {
35            Event::Start(Tag::Heading { level, .. }) => {
36                // pulldown's `level` Display already renders "h1".."h6".
37                headings.push(level as u8);
38                let _ = write!(out, "<{level}>");
39            }
40            Event::End(TagEnd::Heading(_)) => {
41                let level = headings.pop().unwrap_or(1);
42                let _ = write!(out, "</h{level}>");
43            }
44            Event::Start(Tag::Paragraph) => {
45                if list_stack.is_empty() {
46                    out.push_str("<p>");
47                }
48            }
49            Event::End(TagEnd::Paragraph) => {
50                if list_stack.is_empty() {
51                    out.push_str("</p>");
52                }
53            }
54            Event::Start(Tag::Emphasis) => out.push_str("<em>"),
55            Event::End(TagEnd::Emphasis) => out.push_str("</em>"),
56            Event::Start(Tag::Strong) => out.push_str("<strong>"),
57            Event::End(TagEnd::Strong) => out.push_str("</strong>"),
58            Event::Start(Tag::Strikethrough) => out.push_str("<del>"),
59            Event::End(TagEnd::Strikethrough) => out.push_str("</del>"),
60            Event::Code(code) => {
61                let _ = write!(out, "<code>{}</code>", escape_html(&code));
62            }
63            Event::Start(Tag::Link { dest_url, .. }) => {
64                let _ = write!(out, "<a href=\"{}\">", escape_attr(&dest_url));
65            }
66            Event::End(TagEnd::Link) => out.push_str("</a>"),
67            Event::Start(Tag::Image {
68                dest_url, title, ..
69            }) => {
70                // The alt text arrives as a Text event between Start and End;
71                // capture it into the attribute instead of leaking it into
72                // the output as visible body text.
73                let mut alt = String::new();
74                for inner in parser.by_ref() {
75                    match inner {
76                        Event::Text(t) => alt.push_str(&t),
77                        Event::End(TagEnd::Image) => break,
78                        _ => {}
79                    }
80                }
81                let _ = write!(
82                    out,
83                    "<img src=\"{}\" alt=\"{}\"{}>",
84                    escape_attr(&dest_url),
85                    escape_attr(&alt),
86                    if title.is_empty() {
87                        String::new()
88                    } else {
89                        format!(" title=\"{}\"", escape_attr(&title))
90                    }
91                );
92            }
93            Event::End(TagEnd::Image) => {}
94            Event::Start(Tag::BlockQuote(_)) => out.push_str("<blockquote>"),
95            Event::End(TagEnd::BlockQuote(_)) => out.push_str("</blockquote>"),
96            Event::Start(Tag::CodeBlock(kind)) => match kind {
97                CodeBlockKind::Fenced(info) if info.starts_with("island") => {
98                    // Island props are raw JSON, not prose: consume the fence
99                    // body verbatim, emit the custom element, and swallow the
100                    // matching End event.
101                    let mut body = String::new();
102                    for inner in parser.by_ref() {
103                        match inner {
104                            Event::Text(t) => {
105                                body.push_str(&t);
106                                break;
107                            }
108                            Event::End(TagEnd::CodeBlock) => break,
109                            _ => {}
110                        }
111                    }
112                    let island = Island::parse(&info, &body)?;
113                    out.push_str(&island.to_html(base));
114                    let _ = parser.next(); // the End(CodeBlock) we did not consume
115                }
116                _ => {
117                    in_code_block = true;
118                    out.push_str("<pre><code>");
119                }
120            },
121            Event::End(TagEnd::CodeBlock) => {
122                if in_code_block {
123                    in_code_block = false;
124                    out.push_str("</code></pre>");
125                }
126            }
127            Event::Start(Tag::List(start)) => {
128                let ordered = start.is_some();
129                let _ = write!(out, "<{}>", if ordered { "ol" } else { "ul" });
130                list_stack.push(ordered);
131            }
132            Event::End(TagEnd::List(_)) => {
133                // Close the element that was actually opened — the event does
134                // not carry the ordered flag, so the stack is the source of
135                // truth. Empty pop = unbalanced end; emit nothing.
136                match list_stack.pop() {
137                    Some(true) => out.push_str("</ol>"),
138                    Some(false) => out.push_str("</ul>"),
139                    None => {}
140                }
141            }
142            Event::Start(Tag::Item) => out.push_str("<li>"),
143            Event::End(TagEnd::Item) => out.push_str("</li>"),
144            Event::TaskListMarker(checked) => {
145                let _ = write!(
146                    out,
147                    "<input type=\"checkbox\" disabled{}>",
148                    if checked { " checked" } else { "" }
149                );
150            }
151            Event::Rule => out.push_str("<hr>"),
152            // Standard Markdown policy: raw HTML (block *and* inline) passes
153            // through — docs content is first-party repository content, the
154            // same contract mdBook/Zola/Starlight ship. Text nodes are
155            // escaped. If content ever becomes untrusted, sanitize the
156            // *output* with `ammonia` (Wave 2, §Scope in lib.rs) — do not
157            // silently narrow here.
158            Event::Text(text) => out.push_str(&escape_html(&text)),
159            Event::Html(text) | Event::InlineHtml(text) => out.push_str(&text),
160            Event::SoftBreak => out.push('\n'),
161            Event::HardBreak => out.push_str("<br>"),
162            _ => {}
163        }
164    }
165    Ok(out)
166}
167
168fn escape_html(src: &str) -> String {
169    src.replace('&', "&amp;")
170        .replace('<', "&lt;")
171        .replace('>', "&gt;")
172}
173
174fn escape_attr(src: &str) -> String {
175    escape_html(src).replace('"', "&quot;")
176}