Skip to main content

gruel_doc/
html.rs

1//! ADR-0089 Phase 4: HTML rendering via pulldown-cmark.
2//!
3//! The flow is: we already have Markdown for each page (from
4//! `markdown::render_markdown`), so HTML rendering is "feed it through
5//! pulldown-cmark and wrap the result in a minimal `<html>` skeleton".
6//! GFM extensions (tables, footnotes, strikethrough, task lists) are
7//! enabled — without these the output looks noticeably worse than what
8//! users expect from `cargo doc`.
9
10use pulldown_cmark::{Options, Parser, html};
11
12use crate::links::LinkTable;
13use crate::{DocFile, DocItem};
14
15const STYLE: &str = include_str!("style.css");
16
17/// Render a single item page as a standalone HTML document.
18///
19/// `siblings` is a list of `(slug, name)` pairs for the sidebar — pass an
20/// empty slice for no sidebar. `index_link` controls the "back to file
21/// index" link target at the top of the page.
22pub fn render_html(
23    item: &DocItem,
24    file_stem: &str,
25    siblings: &[(String, String)],
26    index_link: &str,
27) -> String {
28    render_html_with(item, file_stem, siblings, index_link, &LinkTable::new())
29}
30
31/// ADR-0089 Phase 5: render a single item page, rewriting intra-doc
32/// links against `table`. When called from the site driver, `table` is
33/// usually `DocSite::link_table()`.
34pub fn render_html_with(
35    item: &DocItem,
36    file_stem: &str,
37    siblings: &[(String, String)],
38    index_link: &str,
39    table: &LinkTable,
40) -> String {
41    let markdown = crate::markdown::render_markdown_with(item, table);
42    let body_html = markdown_to_html(&markdown);
43    wrap(
44        &format!("{} — {}", item.kind.label(), item.name),
45        file_stem,
46        siblings,
47        index_link,
48        &body_html,
49    )
50}
51
52/// Render the per-file index page (`<file>/index.html`).
53pub fn render_index_html(file: &DocFile) -> String {
54    render_index_html_with(file, &LinkTable::new())
55}
56
57/// Render the per-file index page with intra-doc link rewriting.
58pub fn render_index_html_with(file: &DocFile, table: &LinkTable) -> String {
59    let markdown = crate::markdown::render_index_with(file, table);
60    let body_html = markdown_to_html(&markdown);
61    let siblings: Vec<(String, String)> = file
62        .items
63        .iter()
64        .map(|i| (i.slug.clone(), format!("{} {}", i.kind.label(), i.name)))
65        .collect();
66    wrap(
67        &file.stem,
68        &file.stem,
69        &siblings,
70        "../index.html",
71        &body_html,
72    )
73}
74
75/// Render the top-level site index listing every file.
76pub fn render_site_index_html(files: &[DocFile]) -> String {
77    render_site_index_html_with_title(files, "Documentation")
78}
79
80/// Render the top-level site index with a custom page title (ADR-0092:
81/// `gruel doc` uses the manifest `name` here when one is present).
82pub fn render_site_index_html_with_title(files: &[DocFile], title: &str) -> String {
83    let mut body = format!("<h1>{}</h1>\n<ul>\n", escape_html(title));
84    for f in files {
85        body.push_str(&format!(
86            "  <li><a href=\"{stem}/index.html\">{stem}</a></li>\n",
87            stem = escape_html(&f.stem),
88        ));
89    }
90    body.push_str("</ul>\n");
91    wrap(title, "", &[], "", &body)
92}
93
94fn markdown_to_html(md: &str) -> String {
95    let mut options = Options::empty();
96    options.insert(Options::ENABLE_TABLES);
97    options.insert(Options::ENABLE_FOOTNOTES);
98    options.insert(Options::ENABLE_STRIKETHROUGH);
99    options.insert(Options::ENABLE_TASKLISTS);
100    let parser = Parser::new_ext(md, options);
101    let mut out = String::new();
102    html::push_html(&mut out, parser);
103    out
104}
105
106fn wrap(
107    title: &str,
108    file_stem: &str,
109    siblings: &[(String, String)],
110    index_link: &str,
111    body: &str,
112) -> String {
113    let mut out = String::new();
114    out.push_str("<!doctype html>\n<html lang=\"en\">\n<head>\n");
115    out.push_str("  <meta charset=\"utf-8\">\n");
116    out.push_str(&format!("  <title>{}</title>\n", escape_html(title)));
117    out.push_str("  <style>\n");
118    out.push_str(STYLE);
119    out.push_str("  </style>\n</head>\n<body>\n");
120    out.push_str("<div class=\"layout\">\n");
121
122    if !siblings.is_empty() {
123        out.push_str("<nav class=\"sidebar\">\n");
124        if !file_stem.is_empty() {
125            out.push_str(&format!(
126                "  <h2 class=\"sidebar-title\"><a href=\"{}\">{}</a></h2>\n",
127                escape_html(index_link),
128                escape_html(file_stem),
129            ));
130        }
131        out.push_str("  <ul>\n");
132        for (slug, name) in siblings {
133            out.push_str(&format!(
134                "    <li><a href=\"{slug}.html\">{name}</a></li>\n",
135                slug = escape_html(slug),
136                name = escape_html(name),
137            ));
138        }
139        out.push_str("  </ul>\n</nav>\n");
140    }
141
142    out.push_str("<main class=\"content\">\n");
143    out.push_str(body);
144    out.push_str("</main>\n");
145    out.push_str("</div>\n</body>\n</html>\n");
146    out
147}
148
149fn escape_html(s: &str) -> String {
150    let mut out = String::with_capacity(s.len());
151    for c in s.chars() {
152        match c {
153            '<' => out.push_str("&lt;"),
154            '>' => out.push_str("&gt;"),
155            '&' => out.push_str("&amp;"),
156            '"' => out.push_str("&quot;"),
157            '\'' => out.push_str("&#39;"),
158            _ => out.push(c),
159        }
160    }
161    out
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::{DocItem, ItemDetail, ItemKind};
168    use gruel_parser::ast::Doc;
169    use gruel_util::Span;
170
171    fn make_doc(body: &str) -> Doc {
172        Doc {
173            body: body.to_string(),
174            span: Span::default(),
175        }
176    }
177
178    #[test]
179    fn html_includes_title_and_body() {
180        let item = DocItem {
181            slug: "fn.foo".into(),
182            name: "foo".into(),
183            kind: ItemKind::Function,
184            doc: Some(make_doc("Does **the** foo.")),
185            detail: ItemDetail::default(),
186        };
187        let html = render_html(&item, "lib", &[], "../index.html");
188        assert!(html.contains("<title>fn — foo</title>"));
189        // pulldown-cmark turns `**the**` into `<strong>the</strong>`.
190        assert!(html.contains("<strong>the</strong>"));
191    }
192
193    #[test]
194    fn html_escapes_specials_in_title_attrs() {
195        // Item name is HTML-escaped in the <title> tag and other
196        // attribute-like positions so unusual identifiers don't break
197        // the page chrome. (pulldown-cmark itself passes through inline
198        // HTML in markdown bodies on purpose — that's its documented
199        // behavior, and docstrings come from trusted source code.)
200        let item = DocItem {
201            slug: "fn.<weird>".into(),
202            name: "<weird>".into(),
203            kind: ItemKind::Function,
204            doc: None,
205            detail: ItemDetail::default(),
206        };
207        let html = render_html(
208            &item,
209            "lib",
210            &[("fn.<weird>".into(), "fn <weird>".into())],
211            "../index.html",
212        );
213        assert!(html.contains("&lt;weird&gt;"));
214    }
215
216    #[test]
217    fn html_renders_table_from_gfm_extension() {
218        let item = DocItem {
219            slug: "fn.foo".into(),
220            name: "foo".into(),
221            kind: ItemKind::Function,
222            doc: Some(make_doc("| a | b |\n|---|---|\n| 1 | 2 |")),
223            detail: ItemDetail::default(),
224        };
225        let html = render_html(&item, "lib", &[], "../index.html");
226        assert!(html.contains("<table>"));
227    }
228}