Skip to main content

documents/markdown/
export.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! One Markdown version written as an HTML file that stands on its own
7//! (`crates/documents/Spec.lean` D33).
8//!
9//! The version is read by the one grammar into the tree a preview
10//! carries, so links were already judged (D23) and nesting already
11//! capped (D24); this module only writes that tree as elements. The file
12//! runs no script and reaches nothing outside itself: a content security
13//! policy opens it, the style is inline, and an image is a link to where
14//! it lives rather than a request made by whoever opens the file.
15
16use 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
23/// The longest version one export reads: an export's HTML, about twice
24/// its source, still travels as one answer.
25pub const EXPORT_BYTES_MAX: u64 = 4 * 1024 * 1024;
26
27/// What the file may load: its own inline style, and nothing else.
28const POLICY: &str = "default-src 'none'; style-src 'unsafe-inline'";
29
30/// A reading measure and the plainest marks, so the file reads without
31/// the page's stylesheet; the system's own colours, so it reads in either
32/// lighting and names no colour of its own.
33const 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
35/// The six heading elements, by level.
36const HEADINGS: [&str; 6] = ["h1", "h2", "h3", "h4", "h5", "h6"];
37
38/// `bytes`, a Markdown version in `encoding`, as one HTML file titled
39/// `title`.
40///
41/// # Errors
42/// `E_INVALID_ARGS` for a UTF-16 version, whose tree cannot be laid
43/// against its bytes (D25), for a version longer than
44/// [`EXPORT_BYTES_MAX`], and for bytes that are not text in `encoding`.
45pub 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
71/// The file as it is written, element by element.
72struct 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                    // A tight list's paragraphs are the item's own text.
169                    (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            // Never fetched: the file would reach outside itself (D33).
230            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    /// Text and attribute values alike: the five characters HTML reads
279    /// as markup are written as references.
280    fn text(&mut self, text: &str) {
281        for character in text.chars() {
282            match character {
283                '&' => self.0.push_str("&amp;"),
284                '<' => self.0.push_str("&lt;"),
285                '>' => self.0.push_str("&gt;"),
286                '"' => self.0.push_str("&quot;"),
287                '\'' => self.0.push_str("&#39;"),
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 ![a cat](cat.png).\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    /// Everything the grammar reads becomes an element, text is escaped,
313    /// and nothing in the file runs or reaches outside it.
314    #[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 &amp; 5 &lt; 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("&lt;b&gt;"),
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    /// The mark is not text, and a UTF-16 version or one too long to
370    /// answer in one export is refused rather than half written.
371    #[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}