1use kernel::{AxCode, AxError};
17
18use super::lowering;
19use super::tree::{Align, Block, Check, Inline, ListItem, Order, Row, Spacing};
20use crate::encoding::Encoding;
21use crate::span::offset;
22
23pub const EXPORT_BYTES_MAX: u64 = 4 * 1024 * 1024;
26
27const POLICY: &str = "default-src 'none'; style-src 'unsafe-inline'";
29
30const STYLE: &str = "body{max-width:42rem;margin:2rem auto;padding:0 1rem;font:16px/1.6 system-ui,sans-serif;color-scheme:light dark}pre,code{font-family:ui-monospace,monospace}pre{overflow-x:auto;padding:.5rem;border:1px solid GrayText}table{border-collapse:collapse}td,th{border:1px solid GrayText;padding:.25rem .5rem}blockquote{margin-left:0;padding-left:1rem;border-left:3px solid GrayText}.footnote{font-size:.9em}";
34
35const HEADINGS: [&str; 6] = ["h1", "h2", "h3", "h4", "h5", "h6"];
37
38pub fn export(title: &str, encoding: Encoding, bytes: &[u8]) -> Result<String, AxError> {
46 match encoding {
47 Encoding::Utf16Le | Encoding::Utf16Be => return Err(refused(title, "is UTF-16")),
48 Encoding::Utf8 | Encoding::Utf8Bom => {}
49 }
50 if offset(bytes.len()) > EXPORT_BYTES_MAX {
51 return Err(refused(title, "is longer than one export reads"));
52 }
53 let text = encoding.decode(bytes)?;
54 let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
55 let mut page = Page(String::with_capacity(text.len().saturating_mul(2)));
56 page.open(title);
57 page.blocks(&lowering::blocks(text, 0));
58 page.0.push_str("</body>\n</html>\n");
59 Ok(page.0)
60}
61
62fn refused(title: &str, why: &str) -> AxError {
63 AxError::failure(
64 AxCode::InvalidArgs,
65 "export a document",
66 format!("{title} {why}"),
67 )
68 .with_recovery("export a UTF-8 Markdown version of at most 4 MiB")
69}
70
71struct Page(String);
73
74impl Page {
75 fn open(&mut self, title: &str) {
76 self.0
77 .push_str("<!doctype html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n");
78 self.0
79 .push_str("<meta http-equiv=\"Content-Security-Policy\" content=\"");
80 self.0.push_str(POLICY);
81 self.0
82 .push_str("\">\n<meta name=\"viewport\" content=\"width=device-width\">\n<title>");
83 self.text(title);
84 self.0.push_str("</title>\n<style>");
85 self.0.push_str(STYLE);
86 self.0.push_str("</style>\n</head>\n<body>\n");
87 }
88
89 fn blocks(&mut self, blocks: &[Block]) {
90 for block in blocks {
91 self.block(block);
92 }
93 }
94
95 fn block(&mut self, block: &Block) {
96 match block {
97 Block::Heading { level, inline, .. } => {
98 let index = usize::from(level.saturating_sub(1));
99 let tag = HEADINGS.get(index).copied().unwrap_or("h6");
100 self.element(tag, |page| page.inlines(inline));
101 self.0.push('\n');
102 }
103 Block::Paragraph { inline, .. } => {
104 self.element("p", |page| page.inlines(inline));
105 self.0.push('\n');
106 }
107 Block::List {
108 order,
109 spacing,
110 items,
111 ..
112 } => self.list(*order, *spacing, items),
113 Block::Quote { blocks, .. } => {
114 self.0.push_str("<blockquote>\n");
115 self.blocks(blocks);
116 self.0.push_str("</blockquote>\n");
117 }
118 Block::Code { text, .. } => {
119 self.0.push_str("<pre><code>");
120 self.text(text);
121 self.0.push_str("</code></pre>\n");
122 }
123 Block::Table {
124 align, head, body, ..
125 } => self.table(align, head, body),
126 Block::Rule { .. } => self.0.push_str("<hr>\n"),
127 Block::Footnote { name, blocks, .. } => {
128 self.0.push_str("<div class=\"footnote\" id=\"fn-");
129 self.text(name);
130 self.0.push_str("\"><sup>");
131 self.text(name);
132 self.0.push_str("</sup>\n");
133 self.blocks(blocks);
134 self.0.push_str("</div>\n");
135 }
136 Block::Unsupported { source, .. } => {
137 self.0.push_str("<pre class=\"source\">");
138 self.text(source);
139 self.0.push_str("</pre>\n");
140 }
141 }
142 }
143
144 fn list(&mut self, order: Order, spacing: Spacing, items: &[ListItem]) {
145 let close = match order {
146 Order::Bullet => {
147 self.0.push_str("<ul>\n");
148 "</ul>\n"
149 }
150 Order::Ordered { start } => {
151 self.0.push_str("<ol start=\"");
152 self.0.push_str(&start.to_string());
153 self.0.push_str("\">\n");
154 "</ol>\n"
155 }
156 };
157 for item in items {
158 self.0.push_str("<li>");
159 match item.check {
160 Check::NotATask => {}
161 Check::Open => self.0.push_str("<input type=\"checkbox\" disabled> "),
162 Check::Done => self
163 .0
164 .push_str("<input type=\"checkbox\" disabled checked> "),
165 }
166 for block in &item.blocks {
167 match (spacing, block) {
168 (Spacing::Tight, Block::Paragraph { inline, .. }) => self.inlines(inline),
170 (Spacing::Tight | Spacing::Loose, other) => self.block(other),
171 }
172 }
173 self.0.push_str("</li>\n");
174 }
175 self.0.push_str(close);
176 }
177
178 fn table(&mut self, align: &[Align], head: &Row, body: &[Row]) {
179 self.0.push_str("<table>\n<thead>\n");
180 self.row("th", align, head);
181 self.0.push_str("</thead>\n<tbody>\n");
182 for row in body {
183 self.row("td", align, row);
184 }
185 self.0.push_str("</tbody>\n</table>\n");
186 }
187
188 fn row(&mut self, cell: &str, align: &[Align], row: &Row) {
189 self.0.push_str("<tr>");
190 for (column, content) in row.cells.iter().enumerate() {
191 let side = match align.get(column).copied().unwrap_or(Align::None) {
192 Align::None => "",
193 Align::Left => " style=\"text-align:left\"",
194 Align::Center => " style=\"text-align:center\"",
195 Align::Right => " style=\"text-align:right\"",
196 };
197 self.0.push('<');
198 self.0.push_str(cell);
199 self.0.push_str(side);
200 self.0.push('>');
201 self.inlines(content);
202 self.close(cell);
203 }
204 self.0.push_str("</tr>\n");
205 }
206
207 fn inlines(&mut self, inlines: &[Inline]) {
208 for inline in inlines {
209 self.inline(inline);
210 }
211 }
212
213 fn inline(&mut self, inline: &Inline) {
214 match inline {
215 Inline::Text(text) => self.text(text),
216 Inline::Code(code) => self.element("code", |page| page.text(code)),
217 Inline::Emphasis(inner) => self.element("em", |page| page.inlines(inner)),
218 Inline::Strong(inner) => self.element("strong", |page| page.inlines(inner)),
219 Inline::Strikethrough(inner) => self.element("del", |page| page.inlines(inner)),
220 Inline::Link {
221 target,
222 title,
223 content,
224 } => {
225 self.link(target, title);
226 self.inlines(content);
227 self.0.push_str("</a>");
228 }
229 Inline::Image { target, title, alt } => {
231 self.link(target, title);
232 self.text(alt);
233 self.0.push_str("</a>");
234 }
235 Inline::FootnoteReference { name } => {
236 self.0.push_str("<sup><a href=\"#fn-");
237 self.text(name);
238 self.0.push_str("\">");
239 self.text(name);
240 self.0.push_str("</a></sup>");
241 }
242 Inline::SoftBreak => self.0.push('\n'),
243 Inline::LineBreak => self.0.push_str("<br>\n"),
244 Inline::Unsupported { source, .. } => {
245 self.0.push_str("<code class=\"source\">");
246 self.text(source);
247 self.0.push_str("</code>");
248 }
249 }
250 }
251
252 fn link(&mut self, target: &str, title: &str) {
253 self.0.push_str("<a href=\"");
254 self.text(target);
255 self.0.push('"');
256 if !title.is_empty() {
257 self.0.push_str(" title=\"");
258 self.text(title);
259 self.0.push('"');
260 }
261 self.0.push('>');
262 }
263
264 fn element(&mut self, tag: &str, inner: impl FnOnce(&mut Page)) {
265 self.0.push('<');
266 self.0.push_str(tag);
267 self.0.push('>');
268 inner(self);
269 self.close(tag);
270 }
271
272 fn close(&mut self, tag: &str) {
273 self.0.push_str("</");
274 self.0.push_str(tag);
275 self.0.push('>');
276 }
277
278 fn text(&mut self, text: &str) {
281 for character in text.chars() {
282 match character {
283 '&' => self.0.push_str("&"),
284 '<' => self.0.push_str("<"),
285 '>' => self.0.push_str(">"),
286 '"' => self.0.push_str("""),
287 '\'' => self.0.push_str("'"),
288 other => self.0.push(other),
289 }
290 }
291 }
292}
293
294#[cfg(test)]
295#[allow(
296 clippy::unwrap_used,
297 clippy::expect_used,
298 clippy::panic,
299 clippy::indexing_slicing,
300 reason = "test code"
301)]
302mod tests {
303 use super::*;
304
305 const SOURCE: &str = "# Notes & 5 < 6\n\nA [link](https://example.org \"t\") and \
306 [bad](javascript:alert(1)) and .\n\n\
307 - [x] done\n- [ ] open\n\n\
308 | a | b |\n|:-|-:|\n| 1 | 2 |\n\n\
309 > quoted\n\n```rust\nfn main() {}\n```\n\n$x^2$ beside <b>raw</b>.\n\n\
310 Said[^n].\n\n[^n]: A note.\n";
311
312 #[test]
315 fn an_export_is_one_page_with_no_script_and_no_reach_outside() {
316 let html = export("Notes.md", Encoding::Utf8, SOURCE.as_bytes()).unwrap();
317 assert!(html.starts_with("<!doctype html>"), "{html}");
318 assert!(html.contains("<title>Notes.md</title>"), "{html}");
319 assert!(
320 html.contains("content=\"default-src 'none'; style-src 'unsafe-inline'\""),
321 "{html}"
322 );
323 assert!(html.contains("<h1>Notes & 5 < 6</h1>"), "{html}");
324 assert!(
325 html.contains("<a href=\"https://example.org\" title=\"t\">link</a>"),
326 "{html}"
327 );
328 assert!(
329 !html.contains("javascript:"),
330 "a refused scheme leaves its text: {html}"
331 );
332 assert!(html.contains("bad"), "{html}");
333 assert!(html.contains("<a href=\"cat.png\">a cat</a>"), "{html}");
334 assert!(!html.contains("<img"), "an image is never fetched: {html}");
335 assert!(
336 html.contains("<input type=\"checkbox\" disabled checked>"),
337 "{html}"
338 );
339 assert!(
340 html.contains("<input type=\"checkbox\" disabled>"),
341 "{html}"
342 );
343 assert!(
344 html.contains("<th style=\"text-align:left\">a</th>"),
345 "{html}"
346 );
347 assert!(
348 html.contains("<td style=\"text-align:right\">2</td>"),
349 "{html}"
350 );
351 assert!(html.contains("<blockquote>"), "{html}");
352 assert!(
353 html.contains("<pre><code>fn main() {}\n</code></pre>"),
354 "{html}"
355 );
356 assert!(
357 html.contains("<code class=\"source\">$x^2$</code>"),
358 "{html}"
359 );
360 assert!(
361 html.contains("<b>"),
362 "raw HTML is shown as its source: {html}"
363 );
364 assert!(html.contains("<a href=\"#fn-n\">n</a>"), "{html}");
365 assert!(html.contains("id=\"fn-n\""), "{html}");
366 assert!(!html.to_ascii_lowercase().contains("<script"), "{html}");
367 }
368
369 #[test]
372 fn a_marked_version_drops_its_mark_and_what_cannot_be_laid_is_refused() {
373 let marked = [b"\xEF\xBB\xBF".as_slice(), b"# Title\n"].concat();
374 let html = export("a.md", Encoding::Utf8Bom, &marked).unwrap();
375 assert!(html.contains("<h1>Title</h1>"), "{html}");
376 assert!(!html.contains('\u{feff}'), "{html}");
377 let refused = export("a.md", Encoding::Utf16Le, b"\xFF\xFEa\0").unwrap_err();
378 assert_eq!(refused.code(), &AxCode::InvalidArgs);
379 let long = vec![b'a'; usize::try_from(EXPORT_BYTES_MAX).unwrap() + 1];
380 let refused = export("a.md", Encoding::Utf8, &long).unwrap_err();
381 assert_eq!(refused.code(), &AxCode::InvalidArgs);
382 }
383}