1#[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#[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 pub fn url(mut self, url: impl Fn(&str) -> String + 'static) -> Self {
52 self.url = Some(Box::new(url));
53 self
54 }
55
56 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 #[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 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 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 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 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 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
463const 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#[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#[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
532fn 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
546fn 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("&"),
569 '<' => out.push_str("<"),
570 '>' => out.push_str(">"),
571 '"' => out.push_str("""),
572 c => out.push(c),
573 }
574 }
575}