Skip to main content

markdown/html/
mod.rs

1//! [`Doc`] → HTML.
2//!
3//! The markup is semantic HTML; every element this crate has a word of its own
4//! for carries a `md-` class, and the styling is the page's.
5//!
6//! A block stands where [`crate::serialize`] writes it: a blank paragraph is
7//! not written, an indent is clamped the same way, and a blockquote is the
8//! same run of blocks. A block nested under a list item goes inside its `<li>`;
9//! one nested under anything else goes in a `<div class="md-indent">`.
10//!
11//! A bookmark and a mention are links with their form as a class. Their
12//! previews come from the network in the app, and are not fetched here.
13//!
14//! A highlighted span of code is a `<span class="hl-…">` named by
15//! [`theme::HighlightKind::name`], and [`Html::stylesheet`] colors them.
16
17#[cfg(feature = "gpui")]
18use crate::fence::highlight::Highlighter;
19use crate::{
20    doc::{Align, BlockKind, Doc, Form, Mark, Text},
21    serialize::Layout,
22};
23
24#[cfg(feature = "gpui")]
25mod style;
26
27/// How a document is written as HTML: what its URLs become, and who renders a
28/// fenced block.
29#[derive(Default)]
30pub struct Html {
31    url: Option<UrlFn>,
32    fence: Option<FenceFn>,
33    #[cfg(feature = "gpui")]
34    highlighter: Option<Highlighter>,
35}
36
37type UrlFn = Box<dyn Fn(&str) -> String>;
38type FenceFn = Box<dyn Fn(&str, &str) -> Option<String>>;
39
40pub fn html(doc: &Doc) -> String {
41    Html::new().render(doc)
42}
43
44impl Html {
45    pub fn new() -> Self {
46        Self::default()
47    }
48
49    /// What every URL in the document — a link's, a mention's, a bookmark's, a
50    /// picture's — is written as. Without one, each is written as it is.
51    pub fn url(mut self, url: impl Fn(&str) -> String + 'static) -> Self {
52        self.url = Some(Box::new(url));
53        self
54    }
55
56    /// The markup for a fenced block, from its language and its code, or
57    /// `None` to write it as `<pre><code>`. The markup is written unescaped.
58    pub fn fence(mut self, fence: impl Fn(&str, &str) -> Option<String> + 'static) -> Self {
59        self.fence = Some(Box::new(fence));
60        self
61    }
62
63    /// What colors a fenced block [`Html::fence`] leaves as `<pre><code>`.
64    #[cfg(feature = "gpui")]
65    pub fn highlighter(mut self, highlighter: Highlighter) -> Self {
66        self.highlighter = Some(highlighter);
67        self
68    }
69
70    pub fn render(&self, doc: &Doc) -> String {
71        let layout = Layout::of(&doc.blocks);
72        let anchors = doc.anchors();
73        let mut anchors = anchors.iter().peekable();
74        let mut out = String::new();
75        let mut open: Vec<Frame> = Vec::new();
76        for (ix, (block, place)) in doc.blocks.iter().zip(&layout.at).enumerate() {
77            let Some(place) = *place else { continue };
78            let depth = place.indent;
79            let list = List::of(&block.kind);
80
81            if place.run.is_none_or(|run| run.first)
82                && let Some(quote) = open.iter().rposition(|frame| frame.kind == Kind::Quote)
83            {
84                close(&mut out, &mut open, quote);
85            }
86            while let Some(top) = open.last() {
87                let stays = match top.kind {
88                    Kind::Quote => true,
89                    Kind::Item | Kind::Indent => top.level < depth,
90                    Kind::List(kind) => {
91                        top.level < depth || (top.level == depth && list == Some(kind))
92                    }
93                };
94                if stays {
95                    break;
96                }
97                let last = open.len() - 1;
98                close(&mut out, &mut open, last);
99            }
100            if let Some(run) = place.run
101                && run.first
102            {
103                parent(&mut out, &mut open, depth);
104                let alert = block.quote.and_then(|quote| quote.alert);
105                match alert {
106                    Some(alert) => {
107                        let label = alert.label();
108                        out.push_str(&format!(
109                            "<blockquote class=\"md-alert md-alert-{}\"><p class=\"md-alert-title\">{label}</p>",
110                            label.to_lowercase()
111                        ));
112                    }
113                    None => out.push_str("<blockquote>"),
114                }
115                open.push(Frame {
116                    kind: Kind::Quote,
117                    level: depth,
118                });
119            }
120            parent(&mut out, &mut open, depth);
121            if let Some(kind) = list {
122                if open
123                    .last()
124                    .is_none_or(|top| top.kind != Kind::List(kind) || top.level != depth)
125                {
126                    match (kind, &block.kind) {
127                        (List::Ordered, BlockKind::Ordered { number, .. }) if *number != 1 => {
128                            out.push_str(&format!("<ol start=\"{number}\">"))
129                        }
130                        (List::Ordered, _) => out.push_str("<ol>"),
131                        (List::Bullet, _) => out.push_str("<ul>"),
132                        (List::Task, _) => out.push_str("<ul class=\"md-tasks\">"),
133                    }
134                    open.push(Frame {
135                        kind: Kind::List(kind),
136                        level: depth,
137                    });
138                }
139                open.push(Frame {
140                    kind: Kind::Item,
141                    level: depth,
142                });
143            }
144            let anchor = anchors
145                .next_if(|(_, block)| *block == ix)
146                .map(|(anchor, _)| anchor.as_str());
147            self.block(&mut out, &block.kind, anchor);
148        }
149        close(&mut out, &mut open, 0);
150        out
151    }
152
153    fn block(&self, out: &mut String, kind: &BlockKind, anchor: Option<&str>) {
154        match kind {
155            BlockKind::Paragraph(text) => {
156                out.push_str("<p>");
157                self.inline(out, text);
158                out.push_str("</p>");
159            }
160            BlockKind::Heading { level, text } => {
161                let level = (*level).clamp(1, 6);
162                out.push_str(&format!("<h{level}"));
163                if let Some(anchor) = anchor {
164                    out.push_str(" id=\"");
165                    escape(out, anchor);
166                    out.push('"');
167                }
168                out.push('>');
169                self.inline(out, text);
170                out.push_str(&format!("</h{level}>"));
171            }
172            BlockKind::Bullet(text) | BlockKind::Ordered { text, .. } => {
173                out.push_str("<li>");
174                self.inline(out, text);
175            }
176            BlockKind::Task { checked, text } => {
177                out.push_str("<li class=\"md-task\"><input");
178                if *checked {
179                    out.push_str(" checked=\"\"");
180                }
181                out.push_str(" disabled=\"\" type=\"checkbox\"> ");
182                self.inline(out, text);
183            }
184            BlockKind::Code {
185                language,
186                code,
187                height,
188            } => {
189                let fenced = language.as_deref().and_then(|language| {
190                    let fence = self.fence.as_ref()?(language, &code.text)?;
191                    Some((language, fence))
192                });
193                match fenced {
194                    Some((language, fence)) => {
195                        out.push_str("<div class=\"md-fence\" data-language=\"");
196                        escape(out, language);
197                        out.push('"');
198                        if let Some(height) = height {
199                            out.push_str(&format!(" style=\"height:{height}px\""));
200                        }
201                        out.push('>');
202                        out.push_str(&fence);
203                        out.push_str("</div>");
204                    }
205                    None => {
206                        out.push_str("<pre><code");
207                        if let Some(language) = language {
208                            out.push_str(" class=\"language-");
209                            escape(out, language);
210                            out.push('"');
211                        }
212                        out.push('>');
213                        self.code(out, language.as_deref(), &code.text);
214                        if !code.text.is_empty() {
215                            out.push('\n');
216                        }
217                        out.push_str("</code></pre>");
218                    }
219                }
220            }
221            BlockKind::Image { url, alt, width } => {
222                out.push_str("<figure class=\"md-image\"><img src=\"");
223                self.write_url(out, url);
224                out.push_str("\" alt=\"");
225                escape(out, &alt.text);
226                out.push('"');
227                if let Some(width) = width {
228                    out.push_str(&format!(" width=\"{width}\""));
229                }
230                out.push('>');
231                if !alt.is_empty() {
232                    out.push_str("<figcaption>");
233                    escape(out, &alt.text);
234                    out.push_str("</figcaption>");
235                }
236                out.push_str("</figure>");
237            }
238            BlockKind::Bookmark { url, form } => {
239                let class = match form {
240                    Form::Auto => "md-bookmark",
241                    Form::Chip => "md-bookmark md-chip",
242                    Form::Embed(_) => "md-bookmark md-embed",
243                };
244                out.push_str(&format!("<p><a class=\"{class}\" href=\""));
245                self.write_url(out, url);
246                out.push('"');
247                if let Form::Embed(Some(height)) = form {
248                    out.push_str(&format!(" style=\"height:{height}px\""));
249                }
250                out.push('>');
251                escape(out, url);
252                out.push_str("</a></p>");
253            }
254            BlockKind::Table {
255                align,
256                header,
257                rows,
258            } => {
259                let columns = align.len().max(header.len());
260                let row = |out: &mut String, cells: &[Text], tag: &str| {
261                    out.push_str("<tr>");
262                    for column in 0..columns {
263                        out.push_str(&format!("<{tag}"));
264                        match align.get(column).copied().unwrap_or_default() {
265                            Align::Left => {}
266                            Align::Center => out.push_str(" style=\"text-align:center\""),
267                            Align::Right => out.push_str(" style=\"text-align:right\""),
268                        }
269                        out.push('>');
270                        if let Some(cell) = cells.get(column) {
271                            self.inline(out, cell);
272                        }
273                        out.push_str(&format!("</{tag}>"));
274                    }
275                    out.push_str("</tr>");
276                };
277                out.push_str("<table>");
278                if !header.is_empty() {
279                    out.push_str("<thead>");
280                    row(out, header, "th");
281                    out.push_str("</thead>");
282                }
283                if !rows.is_empty() {
284                    out.push_str("<tbody>");
285                    for cells in rows {
286                        row(out, cells, "td");
287                    }
288                    out.push_str("</tbody>");
289                }
290                out.push_str("</table>");
291            }
292            BlockKind::Rule => out.push_str("<hr>"),
293        }
294    }
295
296    /// `code`, its highlighted spans wrapped. A span overlapping the one
297    /// before it is dropped.
298    fn code(&self, out: &mut String, language: Option<&str>, code: &str) {
299        #[cfg(feature = "gpui")]
300        if let Some(spans) = self
301            .highlighter
302            .zip(language)
303            .and_then(|(highlighter, language)| highlighter(language, code))
304        {
305            let mut cursor = 0;
306            for (range, kind) in spans {
307                let (Some(before), Some(inside)) =
308                    (code.get(cursor..range.start), code.get(range.clone()))
309                else {
310                    continue;
311                };
312                escape(out, before);
313                out.push_str("<span class=\"hl-");
314                out.push_str(kind.name());
315                out.push_str("\">");
316                escape(out, inside);
317                out.push_str("</span>");
318                cursor = range.end;
319            }
320            escape(out, &code[cursor..]);
321            return;
322        }
323        #[cfg(not(feature = "gpui"))]
324        let _ = language;
325        escape(out, code);
326    }
327
328    /// Marks open outermost first. Where two overlap without nesting, the
329    /// inner one closes at the outer one's edge and opens again past it.
330    /// A newline is a `<br>`.
331    fn inline(&self, out: &mut String, text: &Text) {
332        let mut boundaries: Vec<usize> = text
333            .marks
334            .iter()
335            .flat_map(|span| [span.range.start, span.range.end])
336            .chain([0, text.text.len()])
337            .filter(|&at| at <= text.text.len())
338            .collect();
339        boundaries.sort_unstable();
340        boundaries.dedup();
341
342        let mut open: Vec<usize> = Vec::new();
343        for pair in boundaries.windows(2) {
344            let (start, end) = (pair[0], pair[1]);
345            let active: Vec<usize> = (0..text.marks.len())
346                .filter(|&ix| {
347                    let range = &text.marks[ix].range;
348                    range.start <= start && end <= range.end
349                })
350                .collect();
351            let kept = open
352                .iter()
353                .zip(&active)
354                .take_while(|(open, active)| open == active)
355                .count();
356            while open.len() > kept {
357                let ix = open.pop().expect("a mark past the kept ones");
358                close_mark(out, &text.marks[ix].mark);
359            }
360            for &ix in &active[kept..] {
361                self.open_mark(out, &text.marks[ix].mark);
362                open.push(ix);
363            }
364            // A picture's text is its alt, written at its start and nowhere
365            // else.
366            let mut inside_image = false;
367            for span in &text.marks {
368                let Mark::Image(url) = &span.mark else {
369                    continue;
370                };
371                if span.range.start == start {
372                    self.image(out, url, &text.text[span.range.clone()]);
373                }
374                inside_image |= span.range.start <= start && end <= span.range.end;
375            }
376            if inside_image {
377                continue;
378            }
379            for (line, part) in text.text[start..end].split('\n').enumerate() {
380                if line > 0 {
381                    out.push_str("<br>");
382                }
383                escape(out, part);
384            }
385        }
386        while let Some(ix) = open.pop() {
387            close_mark(out, &text.marks[ix].mark);
388        }
389        // A picture over nothing has no segment to stand before.
390        for span in &text.marks {
391            if let Mark::Image(url) = &span.mark
392                && span.range.is_empty()
393                && span.range.start == text.text.len()
394            {
395                self.image(out, url, "");
396            }
397        }
398    }
399
400    fn image(&self, out: &mut String, url: &str, alt: &str) {
401        out.push_str("<img src=\"");
402        self.write_url(out, url);
403        out.push_str("\" alt=\"");
404        escape(out, alt);
405        out.push_str("\">");
406    }
407
408    fn open_mark(&self, out: &mut String, mark: &Mark) {
409        match mark {
410            Mark::Bold => out.push_str("<strong>"),
411            Mark::Italic => out.push_str("<em>"),
412            Mark::Strike => out.push_str("<del>"),
413            Mark::Code => out.push_str("<code>"),
414            Mark::Link(url) => {
415                out.push_str("<a href=\"");
416                self.write_url(out, url);
417                out.push_str("\">");
418            }
419            Mark::Mention { url, form } => {
420                let class = match form {
421                    Form::Auto => "md-mention",
422                    Form::Chip => "md-mention md-chip",
423                    Form::Embed(_) => "md-mention md-embed",
424                };
425                out.push_str(&format!("<a class=\"{class}\" href=\""));
426                self.write_url(out, url);
427                out.push_str("\">");
428            }
429            Mark::Custom(name) => {
430                out.push_str("<span class=\"md-mark\" data-mark=\"");
431                escape(out, name);
432                out.push_str("\">");
433            }
434            Mark::Image(_) => {}
435        }
436    }
437
438    /// `url` resolved, then percent-encoded where an `href` cannot hold it as
439    /// written. A `javascript:`, `vbscript:` or `file:` URL, or a `data:` URL
440    /// that is not an image, is written empty.
441    fn write_url(&self, out: &mut String, url: &str) {
442        let resolved = match &self.url {
443            Some(resolve) => resolve(url),
444            None => url.to_owned(),
445        };
446        if unsafe_url(&resolved) {
447            return;
448        }
449        let mut encoded = String::with_capacity(resolved.len());
450        for c in resolved.chars() {
451            if c.is_ascii_alphanumeric() || HREF_SAFE.contains(c) {
452                encoded.push(c);
453            } else {
454                for byte in c.encode_utf8(&mut [0; 4]).bytes() {
455                    encoded.push_str(&format!("%{byte:02X}"));
456                }
457            }
458        }
459        escape(out, &encoded);
460    }
461}
462
463/// What an `href` holds as written besides ASCII letters and digits: cmark's
464/// set, which leaves an existing `%XX` alone.
465const HREF_SAFE: &str = "-_.+!*'(),%#@?=;:/&$~";
466
467fn unsafe_url(url: &str) -> bool {
468    let scheme = url
469        .split_once(':')
470        .map(|(scheme, _)| scheme.trim().to_ascii_lowercase());
471    match scheme.as_deref() {
472        Some("javascript" | "vbscript" | "file") => true,
473        Some("data") => ![
474            "data:image/png",
475            "data:image/gif",
476            "data:image/jpeg",
477            "data:image/webp",
478        ]
479        .iter()
480        .any(|image| url.trim_start().to_ascii_lowercase().starts_with(image)),
481        _ => false,
482    }
483}
484
485fn close_mark(out: &mut String, mark: &Mark) {
486    out.push_str(match mark {
487        Mark::Bold => "</strong>",
488        Mark::Italic => "</em>",
489        Mark::Strike => "</del>",
490        Mark::Code => "</code>",
491        Mark::Link(_) | Mark::Mention { .. } => "</a>",
492        Mark::Custom(_) => "</span>",
493        Mark::Image(_) => "",
494    });
495}
496
497/// An element left open while the blocks after it may still go inside it.
498#[derive(Clone, Copy, PartialEq, Eq)]
499struct Frame {
500    kind: Kind,
501    level: u8,
502}
503
504#[derive(Clone, Copy, PartialEq, Eq)]
505enum Kind {
506    Quote,
507    List(List),
508    Item,
509    Indent,
510}
511
512/// Which list a marker block belongs to: adjacent markers of two kinds are two
513/// lists.
514#[derive(Clone, Copy, PartialEq, Eq)]
515enum List {
516    Bullet,
517    Ordered,
518    Task,
519}
520
521impl List {
522    fn of(kind: &BlockKind) -> Option<Self> {
523        match kind {
524            BlockKind::Bullet(_) => Some(Self::Bullet),
525            BlockKind::Ordered { .. } => Some(Self::Ordered),
526            BlockKind::Task { .. } => Some(Self::Task),
527            _ => None,
528        }
529    }
530}
531
532/// Close `open[from..]`, innermost first.
533fn close(out: &mut String, open: &mut Vec<Frame>, from: usize) {
534    while open.len() > from {
535        let frame = open.pop().expect("a frame past `from`");
536        out.push_str(match frame.kind {
537            Kind::Quote => "</blockquote>",
538            Kind::List(List::Ordered) => "</ol>",
539            Kind::List(_) => "</ul>",
540            Kind::Item => "</li>",
541            Kind::Indent => "</div>",
542        });
543    }
544}
545
546/// Open a `<div class="md-indent">` for a block at `depth` that has nothing
547/// open one level up to stand in.
548fn parent(out: &mut String, open: &mut Vec<Frame>, depth: u8) {
549    let Some(up) = depth.checked_sub(1) else {
550        return;
551    };
552    let held = open.last().is_some_and(|top| match top.kind {
553        Kind::Item | Kind::Indent => top.level == up,
554        Kind::Quote | Kind::List(_) => top.level == depth,
555    });
556    if !held {
557        out.push_str("<div class=\"md-indent\">");
558        open.push(Frame {
559            kind: Kind::Indent,
560            level: up,
561        });
562    }
563}
564
565fn escape(out: &mut String, text: &str) {
566    for c in text.chars() {
567        match c {
568            '&' => out.push_str("&amp;"),
569            '<' => out.push_str("&lt;"),
570            '>' => out.push_str("&gt;"),
571            '"' => out.push_str("&quot;"),
572            c => out.push(c),
573        }
574    }
575}