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    let mut list_open = false;
27    let mut in_code_block = false;
28    let mut parser = Parser::new_ext(markdown, options);
29
30    while let Some(event) = parser.next() {
31        match event {
32            Event::Start(Tag::Heading { level, .. }) => {
33                // pulldown's `level` Display already renders "h1".."h6".
34                headings.push(level as u8);
35                let _ = write!(out, "<{level}>");
36            }
37            Event::End(TagEnd::Heading(_)) => {
38                let level = headings.pop().unwrap_or(1);
39                let _ = write!(out, "</h{level}>");
40            }
41            Event::Start(Tag::Paragraph) => {
42                if !list_open {
43                    out.push_str("<p>");
44                }
45            }
46            Event::End(TagEnd::Paragraph) => {
47                if !list_open {
48                    out.push_str("</p>");
49                }
50            }
51            Event::Start(Tag::Emphasis) => out.push_str("<em>"),
52            Event::End(TagEnd::Emphasis) => out.push_str("</em>"),
53            Event::Start(Tag::Strong) => out.push_str("<strong>"),
54            Event::End(TagEnd::Strong) => out.push_str("</strong>"),
55            Event::Start(Tag::Strikethrough) => out.push_str("<del>"),
56            Event::End(TagEnd::Strikethrough) => out.push_str("</del>"),
57            Event::Code(code) => {
58                let _ = write!(out, "<code>{}</code>", escape_html(&code));
59            }
60            Event::Start(Tag::Link { dest_url, .. }) => {
61                let _ = write!(out, "<a href=\"{}\">", escape_attr(&dest_url));
62            }
63            Event::End(TagEnd::Link) => out.push_str("</a>"),
64            Event::Start(Tag::Image { dest_url, title, .. }) => {
65                let _ = write!(
66                    out,
67                    "<img src=\"{}\" alt=\"\"{}>",
68                    escape_attr(&dest_url),
69                    if title.is_empty() {
70                        String::new()
71                    } else {
72                        format!(" title=\"{}\"", escape_attr(&title))
73                    }
74                );
75            }
76            Event::End(TagEnd::Image) => {}
77            Event::Start(Tag::BlockQuote(_)) => out.push_str("<blockquote>"),
78            Event::End(TagEnd::BlockQuote(_)) => out.push_str("</blockquote>"),
79            Event::Start(Tag::CodeBlock(kind)) => match kind {
80                CodeBlockKind::Fenced(info) if info.starts_with("island") => {
81                    // Island props are raw JSON, not prose: consume the fence
82                    // body verbatim, emit the custom element, and swallow the
83                    // matching End event.
84                    let mut body = String::new();
85                    for inner in parser.by_ref() {
86                        match inner {
87                            Event::Text(t) => {
88                                body.push_str(&t);
89                                break;
90                            }
91                            Event::End(TagEnd::CodeBlock) => break,
92                            _ => {}
93                        }
94                    }
95                    let island = Island::parse(&info, &body)?;
96                    out.push_str(&island.to_html(base));
97                    let _ = parser.next(); // the End(CodeBlock) we did not consume
98                }
99                _ => {
100                    in_code_block = true;
101                    out.push_str("<pre><code>");
102                }
103            },
104            Event::End(TagEnd::CodeBlock) => {
105                if in_code_block {
106                    in_code_block = false;
107                    out.push_str("</code></pre>");
108                }
109            }
110            Event::Start(Tag::List(start)) => {
111                let ordered = start.is_some();
112                let _ = write!(out, "<{}>", if ordered { "ol" } else { "ul" });
113                list_open = true;
114            }
115            Event::End(TagEnd::List(_)) => {
116                list_open = false;
117                // We open <ul>/<ol> but the closing tag depends on which; the
118                // renderer tracks it through the last open marker.
119                if out.contains("<ol><li") || out.contains("<ol>\n<li") {
120                    out = out.replacen("<ol>", "</li></ol>", 1);
121                } else if out.contains("<ul><li") {
122                    out = out.replacen("<ul>", "</li></ul>", 1);
123                }
124            }
125            Event::Start(Tag::Item) => out.push_str("<li>"),
126            Event::End(TagEnd::Item) => out.push_str("</li>"),
127            Event::TaskListMarker(checked) => {
128                let _ = write!(
129                    out,
130                    "<input type=\"checkbox\" disabled{}>",
131                    if checked { " checked" } else { "" }
132                );
133            }
134            Event::Rule => out.push_str("<hr>"),
135            // Standard Markdown policy: raw HTML (block *and* inline) passes
136            // through — docs content is first-party repository content, the
137            // same contract mdBook/Zola/Starlight ship. Text nodes are
138            // escaped. If content ever becomes untrusted, sanitize the
139            // *output* with `ammonia` (Wave 2, §Scope in lib.rs) — do not
140            // silently narrow here.
141            Event::Text(text) => out.push_str(&escape_html(&text)),
142            Event::Html(text) | Event::InlineHtml(text) => out.push_str(&text),
143            Event::SoftBreak => out.push('\n'),
144            Event::HardBreak => out.push_str("<br>"),
145            _ => {}
146        }
147    }
148    Ok(out)
149}
150
151fn escape_html(src: &str) -> String {
152    src.replace('&', "&amp;")
153        .replace('<', "&lt;")
154        .replace('>', "&gt;")
155}
156
157fn escape_attr(src: &str) -> String {
158    escape_html(src).replace('"', "&quot;")
159}