1use pulldown_cmark::{Options, Parser, html};
11
12use crate::links::LinkTable;
13use crate::{DocFile, DocItem};
14
15const STYLE: &str = include_str!("style.css");
16
17pub 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
31pub 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
52pub fn render_index_html(file: &DocFile) -> String {
54 render_index_html_with(file, &LinkTable::new())
55}
56
57pub 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
75pub fn render_site_index_html(files: &[DocFile]) -> String {
77 render_site_index_html_with_title(files, "Documentation")
78}
79
80pub 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("<"),
154 '>' => out.push_str(">"),
155 '&' => out.push_str("&"),
156 '"' => out.push_str("""),
157 '\'' => out.push_str("'"),
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 assert!(html.contains("<strong>the</strong>"));
191 }
192
193 #[test]
194 fn html_escapes_specials_in_title_attrs() {
195 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("<weird>"));
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}