forge
ExploreLog in

Tahinli/forge

public
0 watching
โ˜… 0
34.2 KiB
1//! comrak + ammonia markdown rendering.
2//!
3//! Pipeline: parse with comrak (GFM extensions, raw HTML passed through),
4//! optionally rewrite link and image URLs in the AST, render to HTML, then
5//! sanitize the whole output with a strict ammonia allowlist. Nothing that
6//! comrak emits reaches the page without going through ammonia.
7
8use std::borrow::Cow;
9use std::collections::{HashMap, HashSet};
10use std::ops::Range;
11
12use ammonia::{Builder, UrlRelative};
13use comrak::nodes::{Ast, AstNode, NodeLink, NodeValue};
14use comrak::{Arena, Options, format_html, parse_document};
15use forge_core::issue_refs;
16
17use crate::render::emoji::emojify;
18
19/// Prefix applied to heading ids (and to in-page `#anchor` links that target them).
20const ID_PREFIX: &str = "user-content-";
21
22/// Longest username an `@mention` can name.
23const MAX_MENTION_LEN: usize = 39;
24
25/// comrak cannot put a `class` on a link, so links made for issue refs and mentions carry one of
26/// these sentinel titles, which `apply_link_classes` turns into the class before sanitizing.
27const ISSUE_REF_TITLE: &str = "forge-issue-ref";
28const USER_MENTION_TITLE: &str = "forge-user-mention";
29
30/// `rel` added to every link, internal or external. The sanitizer sets it, so raw HTML links
31/// cannot skip it.
32const EXTERNAL_REL: &str = "nofollow noopener noreferrer";
33
34/// Base URLs used to resolve relative links in a rendered file.
35///
36/// Each base ends at the directory that contains the file being rendered,
37/// with no trailing slash, e.g. `https://host/o/r/blob/main/docs`.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct LinkContext {
40 pub tree_base: String,
41 pub blob_base: String,
42 pub raw_base: String,
43 /// Issues page of the repo, e.g. `/Tahinli/forge/issues`. When set, `#N` and `@user` in text
44 /// are linked. `None` leaves them as plain text.
45 pub issues_base: Option<String>,
46}
47
48/// Renders markdown to sanitized HTML.
49///
50/// With `ctx = None` no link rewriting happens: links and images keep their
51/// source URLs, and `#anchor` links are left alone.
52pub fn render_markdown(src: &str, ctx: Option<&LinkContext>) -> String {
53 let options = markdown_options();
54 let arena = Arena::new();
55 let root = parse_document(&arena, src, &options);
56
57 emojify_text(root);
58 if let Some(ctx) = ctx {
59 rewrite_links(root, &Resolver::new(ctx));
60 if let Some(issues_base) = &ctx.issues_base {
61 link_refs(&arena, root, issues_base);
62 }
63 }
64
65 let mut html = String::new();
66 if format_html(root, &options, &mut html).is_err() {
67 return String::new();
68 }
69 sanitizer().clean(&apply_link_classes(&html)).to_string()
70}
71
72fn markdown_options() -> Options<'static> {
73 let mut options = Options::default();
74 options.extension.table = true;
75 options.extension.tasklist = true;
76 options.extension.strikethrough = true;
77 options.extension.autolink = true;
78 options.extension.footnotes = true;
79 options.extension.alerts = true;
80 options.extension.header_id_prefix = Some(ID_PREFIX.to_string());
81 // Raw HTML is passed through to ammonia, which removes anything not allowlisted.
82 options.render.r#unsafe = true;
83 options
84}
85
86/// Resolves relative URLs against the repo.
87///
88/// The bases in a [`LinkContext`] all carry the directory of the rendered
89/// file after the ref, e.g. `.../blob/main/docs/guide`. The directory is the
90/// part after the ref segment, so `dir` is `["docs", "guide"]` here, and the
91/// repo root for each kind is the base with those trailing segments removed.
92///
93/// Root-relative links (`/x`) resolve from the repo root, not from `dir`.
94/// Relative links (`x`, `../x`) resolve from `dir`. Both kinds clamp `..` at
95/// the root, so a link can never point above the repo.
96///
97/// Limitation: the ref is assumed to be a single path segment. A branch name
98/// with a `/` in it makes the directory split wrong.
99struct Resolver {
100 tree_root: String,
101 blob_root: String,
102 raw_root: String,
103 dir: Vec<String>,
104}
105
106impl Resolver {
107 fn new(ctx: &LinkContext) -> Self {
108 let blob = ctx.blob_base.trim_end_matches('/');
109 let depth = dir_depth(blob);
110 let dir = blob
111 .split('/')
112 .rev()
113 .take(depth)
114 .filter(|seg| !seg.is_empty())
115 .map(str::to_string)
116 .collect::<Vec<_>>()
117 .into_iter()
118 .rev()
119 .collect();
120 Self {
121 tree_root: strip_segments(&ctx.tree_base, depth),
122 blob_root: strip_segments(&ctx.blob_base, depth),
123 raw_root: strip_segments(&ctx.raw_base, depth),
124 dir,
125 }
126 }
127
128 /// Returns the rewritten URL, or `None` to keep the original.
129 fn rewrite(&self, url: &str, image: bool) -> Option<String> {
130 if url.is_empty() || url.starts_with("//") || has_scheme(url) {
131 return None;
132 }
133 if let Some(fragment) = url.strip_prefix('#') {
134 if fragment.is_empty() {
135 return None;
136 }
137 return Some(format!("#{ID_PREFIX}{fragment}"));
138 }
139
140 let split = url.find(['?', '#']).unwrap_or(url.len());
141 let (path, suffix) = url.split_at(split);
142 let start: &[String] = if path.starts_with('/') {
143 &[]
144 } else {
145 &self.dir
146 };
147 let segments = normalize(start, path);
148
149 if image {
150 return Some(join(&self.raw_root, &segments, suffix));
151 }
152 if segments.is_empty() || path.ends_with('/') {
153 Some(join(&self.tree_root, &segments, suffix))
154 } else {
155 Some(join(&self.blob_root, &segments, suffix))
156 }
157 }
158}
159
160/// Number of path segments after the ref in a blob/tree base, or 0 if there is none.
161///
162/// The marker is searched from index 3 on so the scheme, host, owner and repo
163/// are skipped.
164fn dir_depth(base: &str) -> usize {
165 let segments: Vec<&str> = base.split('/').collect();
166 segments
167 .iter()
168 .enumerate()
169 .skip(3)
170 .find(|(i, seg)| (**seg == "blob" || **seg == "tree") && i + 1 < segments.len())
171 .map_or(0, |(i, _)| segments.len() - (i + 2))
172}
173
174/// Removes `n` trailing path segments from a base URL.
175fn strip_segments(base: &str, n: usize) -> String {
176 let mut out = base.trim_end_matches('/');
177 for _ in 0..n {
178 match out.rfind('/') {
179 Some(i) => out = &out[..i],
180 None => break,
181 }
182 }
183 out.to_string()
184}
185
186/// Applies `path` to `start`, resolving `.` and `..`. `..` past the root is dropped.
187fn normalize(start: &[String], path: &str) -> Vec<String> {
188 let mut out = start.to_vec();
189 for seg in path.split('/') {
190 match seg {
191 "" | "." => {}
192 ".." => {
193 out.pop();
194 }
195 other => out.push(other.to_string()),
196 }
197 }
198 out
199}
200
201fn join(root: &str, segments: &[String], suffix: &str) -> String {
202 if segments.is_empty() {
203 format!("{root}{suffix}")
204 } else {
205 format!("{root}/{}{suffix}", segments.join("/"))
206 }
207}
208
209/// True for `scheme:` prefixes such as `https:`, `mailto:` or `javascript:`.
210fn has_scheme(url: &str) -> bool {
211 match url.find(':') {
212 Some(i) if i > 0 => {
213 let scheme = &url[..i];
214 scheme.starts_with(|c: char| c.is_ascii_alphabetic())
215 && scheme
216 .chars()
217 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
218 }
219 _ => false,
220 }
221}
222
223/// Replaces `:shortcode:` in text nodes. Code spans and blocks keep their literals in their own
224/// node values, so they are never `Text`. Text inside a link (such as an autolink, whose
225/// visible text must match its href) is left alone.
226fn emojify_text<'a>(root: &'a AstNode<'a>) {
227 for node in root.descendants() {
228 let in_link = node
229 .parent()
230 .is_some_and(|parent| matches!(parent.data.borrow().value, NodeValue::Link(_)));
231 if in_link {
232 continue;
233 }
234 let mut ast = node.data.borrow_mut();
235 if let NodeValue::Text(text) = &mut ast.value {
236 let replaced = match emojify(text.as_ref()) {
237 Cow::Owned(s) => Some(s),
238 Cow::Borrowed(_) => None,
239 };
240 if let Some(s) = replaced {
241 *text = s.into();
242 }
243 }
244 }
245}
246
247fn rewrite_links<'a>(root: &'a AstNode<'a>, resolver: &Resolver) {
248 for node in root.descendants() {
249 let mut ast = node.data.borrow_mut();
250 match &mut ast.value {
251 NodeValue::Link(link) => {
252 if let Some(url) = resolver.rewrite(&link.url, false) {
253 link.url = url;
254 }
255 }
256 NodeValue::Image(link) => {
257 if let Some(url) = resolver.rewrite(&link.url, true) {
258 link.url = url;
259 }
260 }
261 _ => {}
262 }
263 }
264}
265
266/// Replaces the sentinel titles of issue-ref and mention links with their classes. `rel` is not
267/// handled here: the sanitizer adds it to every link. Text quotes are escaped by comrak, so the
268/// patterns can only occur in a tag.
269fn apply_link_classes(html: &str) -> String {
270 html.replace(
271 &format!("title=\"{ISSUE_REF_TITLE}\""),
272 "class=\"issue-ref\"",
273 )
274 .replace(
275 &format!("title=\"{USER_MENTION_TITLE}\""),
276 "class=\"user-mention\"",
277 )
278}
279
280/// Turns `#N` and `@user` in text nodes into link nodes. Text inside links and images is skipped.
281/// Code spans and blocks are not text nodes, so they are skipped too. Raw HTML blocks are split
282/// so that their plain text (outside tags, code, links and raw-text elements) is linked as well.
283fn link_refs<'a>(arena: &'a Arena<'a>, root: &'a AstNode<'a>, issues_base: &str) {
284 let targets: Vec<&'a AstNode<'a>> = root
285 .descendants()
286 .filter(|node| {
287 matches!(
288 node.data.borrow().value,
289 NodeValue::Text(_) | NodeValue::HtmlBlock(_)
290 ) && !node.ancestors().skip(1).any(|ancestor| {
291 matches!(
292 ancestor.data.borrow().value,
293 NodeValue::Link(_) | NodeValue::Image(_)
294 )
295 })
296 })
297 .collect();
298
299 for node in targets {
300 let (sourcepos, value) = {
301 let ast = node.data.borrow();
302 (ast.sourcepos, ast.value.clone())
303 };
304 match value {
305 NodeValue::Text(text) => {
306 if let Some(nodes) = text_with_links(arena, sourcepos, &text, issues_base) {
307 for new in nodes {
308 node.insert_before(new);
309 }
310 node.detach();
311 }
312 }
313 NodeValue::HtmlBlock(block) => {
314 let literal = block.literal.trim_end_matches('\n').to_string();
315 let mut children = Vec::new();
316 let mut linked = false;
317 for piece in split_html(&literal) {
318 match piece {
319 HtmlPiece::Html(html) => {
320 children.push(new_node(arena, sourcepos, NodeValue::HtmlInline(html)))
321 }
322 HtmlPiece::Text(raw) => {
323 // Raw text may hold entities; comrak escapes Text nodes again, so decode
324 // them first.
325 let text = decode_entities(&raw);
326 match text_with_links(arena, sourcepos, &text, issues_base) {
327 Some(nodes) => {
328 linked = true;
329 children.extend(nodes);
330 }
331 None => children.push(new_node(
332 arena,
333 sourcepos,
334 NodeValue::Text(text.into()),
335 )),
336 }
337 }
338 }
339 }
340 // Only rewrite the block when something was actually linked.
341 if linked {
342 node.data.borrow_mut().value = NodeValue::Paragraph;
343 for child in children {
344 node.append(child);
345 }
346 }
347 }
348 _ => {}
349 }
350 }
351}
352
353/// Splits `text` into link and text nodes for every `#N` and `@user` in it. Returns `None` when
354/// there is nothing to link.
355fn text_with_links<'a>(
356 arena: &'a Arena<'a>,
357 sourcepos: comrak::nodes::Sourcepos,
358 text: &str,
359 issues_base: &str,
360) -> Option<Vec<&'a AstNode<'a>>> {
361 let mut spans: Vec<(Range<usize>, Option<i64>)> = issue_refs::issue_ref_spans(text)
362 .into_iter()
363 .map(|(range, n)| (range, Some(n)))
364 .chain(mention_spans(text).into_iter().map(|range| (range, None)))
365 .collect();
366 if spans.is_empty() {
367 return None;
368 }
369 spans.sort_by_key(|(range, _)| range.start);
370
371 let mut out = Vec::new();
372 let mut cursor = 0;
373 for (range, issue) in spans {
374 if range.start > cursor {
375 out.push(new_node(
376 arena,
377 sourcepos,
378 NodeValue::Text(text[cursor..range.start].to_string().into()),
379 ));
380 }
381 let label = &text[range.clone()];
382 let link = match issue {
383 Some(n) => new_node(
384 arena,
385 sourcepos,
386 NodeValue::Link(Box::new(NodeLink {
387 url: format!("{issues_base}/{n}"),
388 title: ISSUE_REF_TITLE.to_string(),
389 })),
390 ),
391 None => new_node(
392 arena,
393 sourcepos,
394 NodeValue::Link(Box::new(NodeLink {
395 url: format!("/{}", &label[1..]),
396 title: USER_MENTION_TITLE.to_string(),
397 })),
398 ),
399 };
400 link.append(new_node(
401 arena,
402 sourcepos,
403 NodeValue::Text(label.to_string().into()),
404 ));
405 out.push(link);
406 cursor = range.end;
407 }
408 if cursor < text.len() {
409 out.push(new_node(
410 arena,
411 sourcepos,
412 NodeValue::Text(text[cursor..].to_string().into()),
413 ));
414 }
415 Some(out)
416}
417
418/// A piece of a raw HTML block: markup that must stay as written, or plain text that may be linked.
419#[derive(Debug, PartialEq, Eq)]
420enum HtmlPiece {
421 Html(String),
422 Text(String),
423}
424
425/// Tags whose content is raw text (not markup) and is removed by the sanitizer.
426const RAW_TEXT_TAGS: &[&str] = &["script", "style", "textarea", "title"];
427
428/// Tags inside which nothing may be linked.
429const NO_LINK_TAGS: &[&str] = &["a", "code", "pre"];
430
431/// Splits raw HTML into markup and text. Text outside tags is `Text`, unless it sits inside a
432/// link, code or pre element, in which case it is `Html`. Raw-text elements are kept whole as `Html`.
433fn split_html(src: &str) -> Vec<HtmlPiece> {
434 let lower = src.to_ascii_lowercase();
435 let mut segments: Vec<(bool, Range<usize>)> = Vec::new();
436 let mut prev = 0;
437 let mut depth = 0usize;
438 let mut i = 0;
439 while let Some(off) = src[i..].find('<') {
440 let lt = i + off;
441 let end = if src[lt..].starts_with("<!--") {
442 src[lt..].find("-->").map_or(src.len(), |p| lt + p + 3)
443 } else {
444 match src[lt..].find('>') {
445 Some(p) => lt + p + 1,
446 None => break,
447 }
448 };
449 let inner = src[lt + 1..end].trim_end_matches('>');
450 let closing = inner.starts_with('/');
451 let name: String = inner
452 .trim_start_matches('/')
453 .chars()
454 .take_while(|c| c.is_ascii_alphanumeric())
455 .collect::<String>()
456 .to_ascii_lowercase();
457
458 if lt > prev {
459 segments.push((depth == 0, prev..lt));
460 }
461 let mut tag_end = end;
462 if !closing && RAW_TEXT_TAGS.contains(&name.as_str()) {
463 let close = format!("</{name}");
464 if let Some(p) = lower[end..].find(&close) {
465 let close_start = end + p;
466 tag_end = lower[close_start..]
467 .find('>')
468 .map_or(src.len(), |g| close_start + g + 1);
469 }
470 }
471 segments.push((false, lt..tag_end));
472 if NO_LINK_TAGS.contains(&name.as_str()) {
473 if closing {
474 depth = depth.saturating_sub(1);
475 } else {
476 depth += 1;
477 }
478 }
479 prev = tag_end;
480 i = tag_end;
481 }
482 if prev < src.len() {
483 segments.push((depth == 0, prev..src.len()));
484 }
485
486 let mut out: Vec<HtmlPiece> = Vec::new();
487 for (is_text, range) in segments {
488 let piece = &src[range];
489 let next = if is_text {
490 HtmlPiece::Text(piece.to_string())
491 } else {
492 HtmlPiece::Html(piece.to_string())
493 };
494 match (out.last_mut(), next) {
495 (Some(HtmlPiece::Html(prev_html)), HtmlPiece::Html(html)) => prev_html.push_str(&html),
496 (Some(HtmlPiece::Text(prev_text)), HtmlPiece::Text(text)) => prev_text.push_str(&text),
497 (_, next) => out.push(next),
498 }
499 }
500 out
501}
502
503/// Decodes the character references that raw HTML text may contain: the named entities `&amp;`,
504/// `&lt;`, `&gt;`, `&quot;`, `&apos;`, `&nbsp;` and numeric `&#NN;` / `&#xHH;`. Anything else is
505/// kept literally.
506fn decode_entities(text: &str) -> String {
507 let mut out = String::with_capacity(text.len());
508 let mut rest = text;
509 while let Some(amp) = rest.find('&') {
510 out.push_str(&rest[..amp]);
511 let from_amp = &rest[amp..];
512 match decode_entity(from_amp) {
513 Some((ch, len)) => {
514 out.push(ch);
515 rest = &from_amp[len..];
516 }
517 None => {
518 out.push('&');
519 rest = &from_amp[1..];
520 }
521 }
522 }
523 out.push_str(rest);
524 out
525}
526
527/// Decodes the entity at the start of `s` (which begins with `&`). Returns the character and the
528/// number of bytes the entity spans.
529fn decode_entity(s: &str) -> Option<(char, usize)> {
530 let end = s.find(';')?;
531 if end > 12 {
532 return None;
533 }
534 let body = &s[1..end];
535 let ch = if let Some(hex) = body.strip_prefix("#x").or_else(|| body.strip_prefix("#X")) {
536 if hex.is_empty() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
537 return None;
538 }
539 char::from_u32(u32::from_str_radix(hex, 16).ok()?)?
540 } else if let Some(dec) = body.strip_prefix('#') {
541 if dec.is_empty() || !dec.bytes().all(|b| b.is_ascii_digit()) {
542 return None;
543 }
544 char::from_u32(dec.parse().ok()?)?
545 } else {
546 match body {
547 "amp" => '&',
548 "lt" => '<',
549 "gt" => '>',
550 "quot" => '"',
551 "apos" => '\'',
552 "nbsp" => '\u{a0}',
553 _ => return None,
554 }
555 };
556 Some((ch, end + 1))
557}
558
559fn new_node<'a>(
560 arena: &'a Arena<'a>,
561 sourcepos: comrak::nodes::Sourcepos,
562 value: NodeValue,
563) -> &'a AstNode<'a> {
564 arena.alloc(comrak::arena_tree::Node::new(std::cell::RefCell::new(
565 Ast::new_with_sourcepos(value, sourcepos),
566 )))
567}
568
569/// Byte ranges of `@username` mentions. The `@` must start the text or follow whitespace, and the
570/// name uses `[A-Za-z0-9._-]`, at most 39 chars, without trailing dots.
571fn mention_spans(text: &str) -> Vec<Range<usize>> {
572 let bytes = text.as_bytes();
573 let mut out = Vec::new();
574 for (at, _) in text.match_indices('@') {
575 if text[..at]
576 .chars()
577 .next_back()
578 .is_some_and(|c| !c.is_whitespace())
579 {
580 continue;
581 }
582 let run = bytes[at + 1..]
583 .iter()
584 .take_while(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
585 .count();
586 let name = text[at + 1..at + 1 + run].trim_end_matches('.');
587 if name.is_empty() || name.len() > MAX_MENTION_LEN {
588 continue;
589 }
590 out.push(at..at + 1 + name.len());
591 }
592 out
593}
594
595/// Usernames `@mention`ed in markdown `text`, lowercased and deduplicated in first-seen order.
596///
597/// Parses with the same options as [`render_markdown`] and walks the text nodes that the `@user`
598/// linking would touch: code spans and blocks, and text inside links and images, are skipped.
599/// Raw HTML blocks are split the same way the linker splits them.
600pub fn mentioned_usernames(text: &str) -> Vec<String> {
601 let options = markdown_options();
602 let arena = Arena::new();
603 let root = parse_document(&arena, text, &options);
604
605 let mut found: Vec<String> = Vec::new();
606 for node in root.descendants() {
607 let ast = node.data.borrow();
608 let inside_link = node.ancestors().skip(1).any(|ancestor| {
609 matches!(
610 ancestor.data.borrow().value,
611 NodeValue::Link(_) | NodeValue::Image(_)
612 )
613 });
614 if inside_link {
615 continue;
616 }
617 match &ast.value {
618 NodeValue::Text(literal) => collect_mentions(literal, &mut found),
619 NodeValue::HtmlBlock(block) => {
620 let literal = block.literal.trim_end_matches('\n');
621 for piece in split_html(literal) {
622 if let HtmlPiece::Text(raw) = piece {
623 collect_mentions(&decode_entities(&raw), &mut found);
624 }
625 }
626 }
627 _ => {}
628 }
629 }
630 found
631}
632
633/// Adds the lowercased names of the `@mention`s in one text run to `found`, skipping duplicates.
634fn collect_mentions(text: &str, found: &mut Vec<String>) {
635 for range in mention_spans(text) {
636 let name = text[range.start + 1..range.end].to_ascii_lowercase();
637 if !found.contains(&name) {
638 found.push(name);
639 }
640 }
641}
642
643const TAGS: &[&str] = &[
644 "h1",
645 "h2",
646 "h3",
647 "h4",
648 "h5",
649 "h6",
650 "p",
651 "a",
652 "img",
653 "ul",
654 "ol",
655 "li",
656 "input",
657 "table",
658 "thead",
659 "tbody",
660 "tr",
661 "th",
662 "td",
663 "pre",
664 "code",
665 "blockquote",
666 "hr",
667 "br",
668 "em",
669 "strong",
670 "del",
671 "sup",
672 "sub",
673 "details",
674 "summary",
675 "kbd",
676 "div",
677 "span",
678 "section",
679];
680
681const CLASSES: &[&str] = &[
682 "markdown-alert",
683 "markdown-alert-note",
684 "markdown-alert-tip",
685 "markdown-alert-important",
686 "markdown-alert-warning",
687 "markdown-alert-caution",
688 "markdown-alert-title",
689 "footnote-ref",
690 "footnote-backref",
691 "footnotes",
692 "task-list-item",
693 "sr-only",
694 "anchor",
695 "issue-ref",
696 "user-mention",
697];
698
699const SCRIPT_LIKE: &[&str] = &[
700 "script", "style", "iframe", "object", "embed", "noscript", "template", "textarea", "title",
701 "svg", "math", "form",
702];
703
704/// The strict allowlist. Anything not listed here is removed, and so are all `style`
705/// and `on*` attributes, because they are not in any attribute list.
706fn sanitizer() -> Builder<'static> {
707 let tags: HashSet<&'static str> = TAGS.iter().copied().collect();
708 let clean_content: HashSet<&'static str> = SCRIPT_LIKE.iter().copied().collect();
709 let classes: HashSet<&'static str> = CLASSES.iter().copied().collect();
710 let allowed_classes: HashMap<&'static str, HashSet<&'static str>> =
711 TAGS.iter().map(|tag| (*tag, classes.clone())).collect();
712
713 let attrs: HashMap<&'static str, HashSet<&'static str>> = [
714 ("h1", &["id"][..]),
715 ("h2", &["id"]),
716 ("h3", &["id"]),
717 ("h4", &["id"]),
718 ("h5", &["id"]),
719 ("h6", &["id"]),
720 (
721 "a",
722 &[
723 "href",
724 "title",
725 "id",
726 "data-footnote-ref",
727 "data-footnote-backref",
728 ],
729 ),
730 ("img", &["src", "alt", "title", "width", "height"]),
731 ("ol", &["start"]),
732 ("li", &["id"]),
733 ("sup", &["id"]),
734 ("section", &["id", "data-footnotes"]),
735 ("pre", &["lang"]),
736 ("td", &["align"]),
737 ("th", &["align"]),
738 ("input", &["type", "checked", "disabled"]),
739 ("details", &["open"]),
740 ]
741 .into_iter()
742 .map(|(tag, names)| (tag, names.iter().copied().collect()))
743 .collect();
744
745 let mut builder = Builder::empty();
746 builder
747 .tags(tags)
748 .clean_content_tags(clean_content)
749 .tag_attributes(attrs)
750 .generic_attributes(HashSet::from([
751 "aria-label",
752 "aria-describedby",
753 "aria-hidden",
754 ]))
755 .allowed_classes(allowed_classes)
756 .url_schemes(HashSet::from(["http", "https", "mailto"]))
757 .url_relative(UrlRelative::PassThrough)
758 .link_rel(Some(EXTERNAL_REL))
759 .set_tag_attribute_value("input", "type", "checkbox");
760 builder
761}
762
763#[cfg(test)]
764mod tests {
765 use super::*;
766
767 fn ctx() -> LinkContext {
768 LinkContext {
769 tree_base: "https://forge.test/o/r/tree/main/guide".to_string(),
770 blob_base: "https://forge.test/o/r/blob/main/guide".to_string(),
771 raw_base: "https://raw.test/o/r/main/guide".to_string(),
772 issues_base: None,
773 }
774 }
775
776 fn issue_ctx() -> LinkContext {
777 LinkContext {
778 issues_base: Some("/o/r/issues".to_string()),
779 ..ctx()
780 }
781 }
782
783 #[test]
784 fn issue_refs_and_mentions_are_linked() {
785 let html = render_markdown("fixes #12, see @alice.\n\n- #3", Some(&issue_ctx()));
786 assert!(html.contains("href=\"/o/r/issues/12\""), "{html}");
787 assert!(html.contains("class=\"issue-ref\""), "{html}");
788 assert!(html.contains(">#12</a>"), "{html}");
789 assert!(html.contains("href=\"/o/r/issues/3\""), "{html}");
790 assert!(html.contains(">#3</a>"), "{html}");
791 assert!(html.contains("href=\"/alice\""), "{html}");
792 assert!(html.contains("class=\"user-mention\""), "{html}");
793 assert!(html.contains(">@alice</a>."), "{html}");
794 assert!(!html.contains("title="), "{html}");
795 }
796
797 /// Every `<a` start tag in `html` must carry the external `rel`.
798 fn assert_all_links_have_rel(html: &str) {
799 let mut count = 0;
800 for (at, _) in html.match_indices("<a") {
801 if !matches!(html[at + 2..].chars().next(), Some(' ' | '>')) {
802 continue;
803 }
804 count += 1;
805 let tag_end = html[at..].find('>').expect("unclosed tag");
806 let tag = &html[at..at + tag_end];
807 assert!(
808 tag.contains("rel=\"nofollow noopener noreferrer\""),
809 "{html}"
810 );
811 }
812 assert!(count > 0, "{html}");
813 }
814
815 #[test]
816 fn raw_html_links_always_get_rel() {
817 let html = render_markdown(
818 "<a title=\"x\" href='https://spam.example'>s</a>\n\n\
819 <a href=\"https://x.example\">x</a>\n\n<a href=\"/internal\">i</a>\n",
820 Some(&ctx()),
821 );
822 assert_all_links_have_rel(&html);
823 assert!(html.contains("href=\"https://spam.example\""), "{html}");
824 assert_all_links_have_rel(&render_markdown(
825 "<a title=\"x\" href='https://spam.example'>s</a>\n\n<a href=\"https://x.example\">x</a>",
826 None,
827 ));
828 }
829
830 #[test]
831 fn html_block_entities_are_not_double_escaped() {
832 let html = render_markdown("<div>Tom &amp; Jerry #3</div>\n", Some(&issue_ctx()));
833 assert!(html.contains("Tom &amp; Jerry"), "{html}");
834 assert!(!html.contains("&amp;amp;"), "{html}");
835 assert!(html.contains("class=\"issue-ref\""), "{html}");
836 assert!(html.contains(">#3</a>"), "{html}");
837 }
838
839 #[test]
840 fn decode_entities_handles_known_and_unknown() {
841 assert_eq!(
842 decode_entities("a &amp; b &lt;&#39;&#x41;&gt;"),
843 "a & b <'A>"
844 );
845 assert_eq!(decode_entities("&nbsp;&quot;&apos;"), "\u{a0}\"'");
846 assert_eq!(
847 decode_entities("&bogus; & &#; &#xZZ; &"),
848 "&bogus; & &#; &#xZZ; &"
849 );
850 assert_eq!(decode_entities("&#0;"), "\0");
851 }
852
853 #[test]
854 fn refs_in_code_are_not_linked() {
855 let html = render_markdown(
856 "`#12` and @bob\n\n```\n#13 @carol\n```\n",
857 Some(&issue_ctx()),
858 );
859 // `@bob` is plain text and is linked; the refs inside code are not.
860 assert!(!html.contains("issue-ref"), "{html}");
861 assert_eq!(html.matches("user-mention").count(), 1, "{html}");
862 assert!(html.contains(">@bob</a>"), "{html}");
863 assert!(!html.contains("@carol</a>"), "{html}");
864 assert!(html.contains("<code>#12</code>"), "{html}");
865 }
866
867 #[test]
868 fn refs_inside_existing_links_are_not_doubled() {
869 let html = render_markdown("[see #5 and @dan](https://example.com)", Some(&issue_ctx()));
870 assert!(!html.contains("issue-ref"), "{html}");
871 assert!(!html.contains("user-mention"), "{html}");
872 assert_eq!(html.matches("<a ").count(), 1, "{html}");
873 }
874
875 #[test]
876 fn script_is_still_stripped_with_linking_on() {
877 let html = render_markdown("<script>alert(1)</script> #7", Some(&issue_ctx()));
878 assert!(!html.contains("script"), "{html}");
879 assert!(html.contains("class=\"issue-ref\""), "{html}");
880 assert!(html.contains(">#7</a>"), "{html}");
881 }
882
883 #[test]
884 fn no_issues_base_means_no_linking() {
885 let html = render_markdown("#12 @alice", Some(&ctx()));
886 assert!(!html.contains("issue-ref"), "{html}");
887 assert!(!html.contains("user-mention"), "{html}");
888 assert!(html.contains("#12 @alice"), "{html}");
889 }
890
891 #[test]
892 fn email_is_not_a_mention() {
893 let html = render_markdown("write to a@b.com or @ alone", Some(&issue_ctx()));
894 assert!(!html.contains("user-mention"), "{html}");
895 assert!(html.contains("a@b.com"), "{html}");
896 }
897
898 #[test]
899 fn mention_names_follow_the_rules() {
900 assert_eq!(mention_spans("@bob."), vec![0..4]);
901 assert!(mention_spans(&format!("@{}", "a".repeat(40))).is_empty());
902 assert_eq!(mention_spans(&format!("@{}", "a".repeat(39))), vec![0..40]);
903 assert!(mention_spans("@.").is_empty());
904 assert_eq!(mention_spans("x @a, @b_c-d"), vec![2..4, 6..12]);
905 }
906
907 #[test]
908 fn gfm_tables_and_tasklists_render() {
909 let html = render_markdown(
910 "| a | b |\n|:-|-:|\n| 1 | 2 |\n\n- [x] done\n- [ ] todo\n",
911 None,
912 );
913 assert!(html.contains("<table>"), "{html}");
914 assert!(html.contains("<td"), "{html}");
915 assert!(html.contains("type=\"checkbox\""), "{html}");
916 assert!(html.contains("checked"), "{html}");
917 }
918
919 #[test]
920 fn dangerous_markup_is_stripped() {
921 let html = render_markdown(
922 "<script>alert(1)</script>\n\n[x](javascript:alert(1))\n\n\
923 <img src=x onerror=alert(1)>\n\n<span style=\"color:red\">s</span>\n",
924 None,
925 );
926 assert!(!html.contains("script"), "{html}");
927 assert!(!html.contains("javascript:"), "{html}");
928 assert!(!html.contains("onerror"), "{html}");
929 assert!(!html.contains("style"), "{html}");
930 assert!(html.contains(">x</a>"), "{html}");
931 }
932
933 #[test]
934 fn relative_page_link_goes_to_blob() {
935 let html = render_markdown("[x](docs/a.md)", Some(&ctx()));
936 assert!(
937 html.contains("href=\"https://forge.test/o/r/blob/main/guide/docs/a.md\""),
938 "{html}"
939 );
940 }
941
942 #[test]
943 fn trailing_slash_and_dotdot_resolve() {
944 let html = render_markdown("[d](../sub/)\n\n[e](../../../../x.md)", Some(&ctx()));
945 assert!(
946 html.contains("href=\"https://forge.test/o/r/tree/main/sub\""),
947 "{html}"
948 );
949 assert!(
950 html.contains("href=\"https://forge.test/o/r/blob/main/x.md\""),
951 "{html}"
952 );
953 }
954
955 #[test]
956 fn relative_image_resolves_against_raw_and_clamps() {
957 let html = render_markdown("![i](../img.png)", Some(&ctx()));
958 assert!(
959 html.contains("src=\"https://raw.test/o/r/main/img.png\""),
960 "{html}"
961 );
962 }
963
964 #[test]
965 fn root_relative_link_resolves_from_repo_root() {
966 // The fragment belongs to the target file (e.g. #L12 on code), so it is left as is.
967 let html = render_markdown("[r](/README.md#top) [s](/a/../b.md)", Some(&ctx()));
968 assert!(
969 html.contains("href=\"https://forge.test/o/r/blob/main/README.md#top\""),
970 "{html}"
971 );
972 assert!(
973 html.contains("href=\"https://forge.test/o/r/blob/main/b.md\""),
974 "{html}"
975 );
976 }
977
978 #[test]
979 fn absolute_https_links_are_kept() {
980 let html = render_markdown("[g](https://example.com/a?b=1)", Some(&ctx()));
981 assert!(
982 html.contains("href=\"https://example.com/a?b=1\""),
983 "{html}"
984 );
985 assert!(
986 html.contains("rel=\"nofollow noopener noreferrer\""),
987 "{html}"
988 );
989 }
990
991 #[test]
992 fn heading_ids_are_prefixed() {
993 let html = render_markdown("# Hello World", None);
994 assert!(html.contains("id=\"user-content-hello-world\""), "{html}");
995 }
996
997 #[test]
998 fn anchor_links_match_heading_ids() {
999 let html = render_markdown("[t](#hello-world)", Some(&ctx()));
1000 assert!(
1001 html.contains("href=\"#user-content-hello-world\""),
1002 "{html}"
1003 );
1004 }
1005
1006 #[test]
1007 fn details_and_summary_survive() {
1008 let html = render_markdown(
1009 "<details><summary>More</summary>\n\nHidden text\n\n</details>\n",
1010 None,
1011 );
1012 assert!(html.contains("<details>"), "{html}");
1013 assert!(html.contains("<summary>More</summary>"), "{html}");
1014 }
1015
1016 #[test]
1017 fn emoji_shortcodes_convert_outside_code() {
1018 let html = render_markdown(
1019 "party :tada: and `:tada:` and\n\n```\n:tada:\n```\n\n:spark:\n",
1020 None,
1021 );
1022 assert!(html.contains("party ๐ŸŽ‰ and"), "{html}");
1023 assert!(html.contains("<code>:tada:</code>"), "{html}");
1024 assert!(html.contains("<pre"), "{html}");
1025 assert!(html.contains(":spark:"), "{html}");
1026 assert!(!html.contains("<code>๐ŸŽ‰</code>"), "{html}");
1027 }
1028
1029 #[test]
1030 fn no_context_means_no_rewriting() {
1031 let html = render_markdown("[x](docs/a.md)", None);
1032 assert!(html.contains("href=\"docs/a.md\""), "{html}");
1033 }
1034}