//! comrak + ammonia markdown rendering (see docs/m2-interfaces.md). //! //! Pipeline: parse with comrak (GFM extensions, raw HTML passed through), //! optionally rewrite link and image URLs in the AST, render to HTML, then //! sanitize the whole output with a strict ammonia allowlist. Nothing that //! comrak emits reaches the page without going through ammonia. use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::ops::Range; use ammonia::{Builder, UrlRelative}; use comrak::nodes::{Ast, AstNode, NodeLink, NodeValue}; use comrak::{Arena, Options, format_html, parse_document}; use forge_core::issue_refs; use crate::render::emoji::emojify; /// Prefix applied to heading ids (and to in-page `#anchor` links that target them). const ID_PREFIX: &str = "user-content-"; /// Longest username an `@mention` can name. const MAX_MENTION_LEN: usize = 39; /// comrak cannot put a `class` on a link, so links made for issue refs and mentions carry one of /// these sentinel titles, which `apply_link_classes` turns into the class before sanitizing. const ISSUE_REF_TITLE: &str = "forge-issue-ref"; const USER_MENTION_TITLE: &str = "forge-user-mention"; /// `rel` added to every link, internal or external. The sanitizer sets it, so raw HTML links /// cannot skip it. const EXTERNAL_REL: &str = "nofollow noopener noreferrer"; /// Base URLs used to resolve relative links in a rendered file. /// /// Each base ends at the directory that contains the file being rendered, /// with no trailing slash, e.g. `https://host/o/r/blob/main/docs`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LinkContext { pub tree_base: String, pub blob_base: String, pub raw_base: String, /// Issues page of the repo, e.g. `/Tahinli/forge/issues`. When set, `#N` and `@user` in text /// are linked. `None` leaves them as plain text. pub issues_base: Option, } /// Renders markdown to sanitized HTML. /// /// With `ctx = None` no link rewriting happens: links and images keep their /// source URLs, and `#anchor` links are left alone. pub fn render_markdown(src: &str, ctx: Option<&LinkContext>) -> String { let options = markdown_options(); let arena = Arena::new(); let root = parse_document(&arena, src, &options); emojify_text(root); if let Some(ctx) = ctx { rewrite_links(root, &Resolver::new(ctx)); if let Some(issues_base) = &ctx.issues_base { link_refs(&arena, root, issues_base); } } let mut html = String::new(); if format_html(root, &options, &mut html).is_err() { return String::new(); } sanitizer().clean(&apply_link_classes(&html)).to_string() } fn markdown_options() -> Options<'static> { let mut options = Options::default(); options.extension.table = true; options.extension.tasklist = true; options.extension.strikethrough = true; options.extension.autolink = true; options.extension.footnotes = true; options.extension.alerts = true; options.extension.header_id_prefix = Some(ID_PREFIX.to_string()); // Raw HTML is passed through to ammonia, which removes anything not allowlisted. options.render.r#unsafe = true; options } /// Resolves relative URLs against the repo. /// /// The bases in a [`LinkContext`] all carry the directory of the rendered /// file after the ref, e.g. `.../blob/main/docs/guide`. The directory is the /// part after the ref segment, so `dir` is `["docs", "guide"]` here, and the /// repo root for each kind is the base with those trailing segments removed. /// /// Root-relative links (`/x`) resolve from the repo root, not from `dir`. /// Relative links (`x`, `../x`) resolve from `dir`. Both kinds clamp `..` at /// the root, so a link can never point above the repo. /// /// Limitation: the ref is assumed to be a single path segment. A branch name /// with a `/` in it makes the directory split wrong. struct Resolver { tree_root: String, blob_root: String, raw_root: String, dir: Vec, } impl Resolver { fn new(ctx: &LinkContext) -> Self { let blob = ctx.blob_base.trim_end_matches('/'); let depth = dir_depth(blob); let dir = blob .split('/') .rev() .take(depth) .filter(|seg| !seg.is_empty()) .map(str::to_string) .collect::>() .into_iter() .rev() .collect(); Self { tree_root: strip_segments(&ctx.tree_base, depth), blob_root: strip_segments(&ctx.blob_base, depth), raw_root: strip_segments(&ctx.raw_base, depth), dir, } } /// Returns the rewritten URL, or `None` to keep the original. fn rewrite(&self, url: &str, image: bool) -> Option { if url.is_empty() || url.starts_with("//") || has_scheme(url) { return None; } if let Some(fragment) = url.strip_prefix('#') { if fragment.is_empty() { return None; } return Some(format!("#{ID_PREFIX}{fragment}")); } let split = url.find(['?', '#']).unwrap_or(url.len()); let (path, suffix) = url.split_at(split); let start: &[String] = if path.starts_with('/') { &[] } else { &self.dir }; let segments = normalize(start, path); if image { return Some(join(&self.raw_root, &segments, suffix)); } if segments.is_empty() || path.ends_with('/') { Some(join(&self.tree_root, &segments, suffix)) } else { Some(join(&self.blob_root, &segments, suffix)) } } } /// Number of path segments after the ref in a blob/tree base, or 0 if there is none. /// /// The marker is searched from index 3 on so the scheme, host, owner and repo /// are skipped. fn dir_depth(base: &str) -> usize { let segments: Vec<&str> = base.split('/').collect(); segments .iter() .enumerate() .skip(3) .find(|(i, seg)| (**seg == "blob" || **seg == "tree") && i + 1 < segments.len()) .map_or(0, |(i, _)| segments.len() - (i + 2)) } /// Removes `n` trailing path segments from a base URL. fn strip_segments(base: &str, n: usize) -> String { let mut out = base.trim_end_matches('/'); for _ in 0..n { match out.rfind('/') { Some(i) => out = &out[..i], None => break, } } out.to_string() } /// Applies `path` to `start`, resolving `.` and `..`. `..` past the root is dropped. fn normalize(start: &[String], path: &str) -> Vec { let mut out = start.to_vec(); for seg in path.split('/') { match seg { "" | "." => {} ".." => { out.pop(); } other => out.push(other.to_string()), } } out } fn join(root: &str, segments: &[String], suffix: &str) -> String { if segments.is_empty() { format!("{root}{suffix}") } else { format!("{root}/{}{suffix}", segments.join("/")) } } /// True for `scheme:` prefixes such as `https:`, `mailto:` or `javascript:`. fn has_scheme(url: &str) -> bool { match url.find(':') { Some(i) if i > 0 => { let scheme = &url[..i]; scheme.starts_with(|c: char| c.is_ascii_alphabetic()) && scheme .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) } _ => false, } } /// Replaces `:shortcode:` in text nodes. Code spans and blocks keep their literals in their own /// node values, so they are never `Text`. Text inside a link (such as an autolink, whose /// visible text must match its href) is left alone. fn emojify_text<'a>(root: &'a AstNode<'a>) { for node in root.descendants() { let in_link = node .parent() .is_some_and(|parent| matches!(parent.data.borrow().value, NodeValue::Link(_))); if in_link { continue; } let mut ast = node.data.borrow_mut(); if let NodeValue::Text(text) = &mut ast.value { let replaced = match emojify(text.as_ref()) { Cow::Owned(s) => Some(s), Cow::Borrowed(_) => None, }; if let Some(s) = replaced { *text = s.into(); } } } } fn rewrite_links<'a>(root: &'a AstNode<'a>, resolver: &Resolver) { for node in root.descendants() { let mut ast = node.data.borrow_mut(); match &mut ast.value { NodeValue::Link(link) => { if let Some(url) = resolver.rewrite(&link.url, false) { link.url = url; } } NodeValue::Image(link) => { if let Some(url) = resolver.rewrite(&link.url, true) { link.url = url; } } _ => {} } } } /// Replaces the sentinel titles of issue-ref and mention links with their classes. `rel` is not /// handled here: the sanitizer adds it to every link. Text quotes are escaped by comrak, so the /// patterns can only occur in a tag. fn apply_link_classes(html: &str) -> String { html.replace( &format!("title=\"{ISSUE_REF_TITLE}\""), "class=\"issue-ref\"", ) .replace( &format!("title=\"{USER_MENTION_TITLE}\""), "class=\"user-mention\"", ) } /// Turns `#N` and `@user` in text nodes into link nodes. Text inside links and images is skipped. /// Code spans and blocks are not text nodes, so they are skipped too. Raw HTML blocks are split /// so that their plain text (outside tags, code, links and raw-text elements) is linked as well. fn link_refs<'a>(arena: &'a Arena<'a>, root: &'a AstNode<'a>, issues_base: &str) { let targets: Vec<&'a AstNode<'a>> = root .descendants() .filter(|node| { matches!( node.data.borrow().value, NodeValue::Text(_) | NodeValue::HtmlBlock(_) ) && !node.ancestors().skip(1).any(|ancestor| { matches!( ancestor.data.borrow().value, NodeValue::Link(_) | NodeValue::Image(_) ) }) }) .collect(); for node in targets { let (sourcepos, value) = { let ast = node.data.borrow(); (ast.sourcepos, ast.value.clone()) }; match value { NodeValue::Text(text) => { if let Some(nodes) = text_with_links(arena, sourcepos, &text, issues_base) { for new in nodes { node.insert_before(new); } node.detach(); } } NodeValue::HtmlBlock(block) => { let literal = block.literal.trim_end_matches('\n').to_string(); let mut children = Vec::new(); let mut linked = false; for piece in split_html(&literal) { match piece { HtmlPiece::Html(html) => { children.push(new_node(arena, sourcepos, NodeValue::HtmlInline(html))) } HtmlPiece::Text(raw) => { // Raw text may hold entities; comrak escapes Text nodes again, so decode // them first. let text = decode_entities(&raw); match text_with_links(arena, sourcepos, &text, issues_base) { Some(nodes) => { linked = true; children.extend(nodes); } None => children.push(new_node( arena, sourcepos, NodeValue::Text(text.into()), )), } } } } // Only rewrite the block when something was actually linked. if linked { node.data.borrow_mut().value = NodeValue::Paragraph; for child in children { node.append(child); } } } _ => {} } } } /// Splits `text` into link and text nodes for every `#N` and `@user` in it. Returns `None` when /// there is nothing to link. fn text_with_links<'a>( arena: &'a Arena<'a>, sourcepos: comrak::nodes::Sourcepos, text: &str, issues_base: &str, ) -> Option>> { let mut spans: Vec<(Range, Option)> = issue_refs::issue_ref_spans(text) .into_iter() .map(|(range, n)| (range, Some(n))) .chain(mention_spans(text).into_iter().map(|range| (range, None))) .collect(); if spans.is_empty() { return None; } spans.sort_by_key(|(range, _)| range.start); let mut out = Vec::new(); let mut cursor = 0; for (range, issue) in spans { if range.start > cursor { out.push(new_node( arena, sourcepos, NodeValue::Text(text[cursor..range.start].to_string().into()), )); } let label = &text[range.clone()]; let link = match issue { Some(n) => new_node( arena, sourcepos, NodeValue::Link(Box::new(NodeLink { url: format!("{issues_base}/{n}"), title: ISSUE_REF_TITLE.to_string(), })), ), None => new_node( arena, sourcepos, NodeValue::Link(Box::new(NodeLink { url: format!("/{}", &label[1..]), title: USER_MENTION_TITLE.to_string(), })), ), }; link.append(new_node( arena, sourcepos, NodeValue::Text(label.to_string().into()), )); out.push(link); cursor = range.end; } if cursor < text.len() { out.push(new_node( arena, sourcepos, NodeValue::Text(text[cursor..].to_string().into()), )); } Some(out) } /// A piece of a raw HTML block: markup that must stay as written, or plain text that may be linked. #[derive(Debug, PartialEq, Eq)] enum HtmlPiece { Html(String), Text(String), } /// Tags whose content is raw text (not markup) and is removed by the sanitizer. const RAW_TEXT_TAGS: &[&str] = &["script", "style", "textarea", "title"]; /// Tags inside which nothing may be linked. const NO_LINK_TAGS: &[&str] = &["a", "code", "pre"]; /// Splits raw HTML into markup and text. Text outside tags is `Text`, unless it sits inside a /// link, code or pre element, in which case it is `Html`. Raw-text elements are kept whole as `Html`. fn split_html(src: &str) -> Vec { let lower = src.to_ascii_lowercase(); let mut segments: Vec<(bool, Range)> = Vec::new(); let mut prev = 0; let mut depth = 0usize; let mut i = 0; while let Some(off) = src[i..].find('<') { let lt = i + off; let end = if src[lt..].starts_with("").map_or(src.len(), |p| lt + p + 3) } else { match src[lt..].find('>') { Some(p) => lt + p + 1, None => break, } }; let inner = src[lt + 1..end].trim_end_matches('>'); let closing = inner.starts_with('/'); let name: String = inner .trim_start_matches('/') .chars() .take_while(|c| c.is_ascii_alphanumeric()) .collect::() .to_ascii_lowercase(); if lt > prev { segments.push((depth == 0, prev..lt)); } let mut tag_end = end; if !closing && RAW_TEXT_TAGS.contains(&name.as_str()) { let close = format!("') .map_or(src.len(), |g| close_start + g + 1); } } segments.push((false, lt..tag_end)); if NO_LINK_TAGS.contains(&name.as_str()) { if closing { depth = depth.saturating_sub(1); } else { depth += 1; } } prev = tag_end; i = tag_end; } if prev < src.len() { segments.push((depth == 0, prev..src.len())); } let mut out: Vec = Vec::new(); for (is_text, range) in segments { let piece = &src[range]; let next = if is_text { HtmlPiece::Text(piece.to_string()) } else { HtmlPiece::Html(piece.to_string()) }; match (out.last_mut(), next) { (Some(HtmlPiece::Html(prev_html)), HtmlPiece::Html(html)) => prev_html.push_str(&html), (Some(HtmlPiece::Text(prev_text)), HtmlPiece::Text(text)) => prev_text.push_str(&text), (_, next) => out.push(next), } } out } /// Decodes the character references that raw HTML text may contain: the named entities `&`, /// `<`, `>`, `"`, `'`, ` ` and numeric `&#NN;` / `&#xHH;`. Anything else is /// kept literally. fn decode_entities(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut rest = text; while let Some(amp) = rest.find('&') { out.push_str(&rest[..amp]); let from_amp = &rest[amp..]; match decode_entity(from_amp) { Some((ch, len)) => { out.push(ch); rest = &from_amp[len..]; } None => { out.push('&'); rest = &from_amp[1..]; } } } out.push_str(rest); out } /// Decodes the entity at the start of `s` (which begins with `&`). Returns the character and the /// number of bytes the entity spans. fn decode_entity(s: &str) -> Option<(char, usize)> { let end = s.find(';')?; if end > 12 { return None; } let body = &s[1..end]; let ch = if let Some(hex) = body.strip_prefix("#x").or_else(|| body.strip_prefix("#X")) { if hex.is_empty() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { return None; } char::from_u32(u32::from_str_radix(hex, 16).ok()?)? } else if let Some(dec) = body.strip_prefix('#') { if dec.is_empty() || !dec.bytes().all(|b| b.is_ascii_digit()) { return None; } char::from_u32(dec.parse().ok()?)? } else { match body { "amp" => '&', "lt" => '<', "gt" => '>', "quot" => '"', "apos" => '\'', "nbsp" => '\u{a0}', _ => return None, } }; Some((ch, end + 1)) } fn new_node<'a>( arena: &'a Arena<'a>, sourcepos: comrak::nodes::Sourcepos, value: NodeValue, ) -> &'a AstNode<'a> { arena.alloc(comrak::arena_tree::Node::new(std::cell::RefCell::new( Ast::new_with_sourcepos(value, sourcepos), ))) } /// Byte ranges of `@username` mentions. The `@` must start the text or follow whitespace, and the /// name uses `[A-Za-z0-9._-]`, at most 39 chars, without trailing dots. fn mention_spans(text: &str) -> Vec> { let bytes = text.as_bytes(); let mut out = Vec::new(); for (at, _) in text.match_indices('@') { if text[..at] .chars() .next_back() .is_some_and(|c| !c.is_whitespace()) { continue; } let run = bytes[at + 1..] .iter() .take_while(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) .count(); let name = text[at + 1..at + 1 + run].trim_end_matches('.'); if name.is_empty() || name.len() > MAX_MENTION_LEN { continue; } out.push(at..at + 1 + name.len()); } out } /// Usernames `@mention`ed in markdown `text`, lowercased and deduplicated in first-seen order. /// /// Parses with the same options as [`render_markdown`] and walks the text nodes that the `@user` /// linking would touch: code spans and blocks, and text inside links and images, are skipped. /// Raw HTML blocks are split the same way the linker splits them. pub fn mentioned_usernames(text: &str) -> Vec { let options = markdown_options(); let arena = Arena::new(); let root = parse_document(&arena, text, &options); let mut found: Vec = Vec::new(); for node in root.descendants() { let ast = node.data.borrow(); let inside_link = node.ancestors().skip(1).any(|ancestor| { matches!( ancestor.data.borrow().value, NodeValue::Link(_) | NodeValue::Image(_) ) }); if inside_link { continue; } match &ast.value { NodeValue::Text(literal) => collect_mentions(literal, &mut found), NodeValue::HtmlBlock(block) => { let literal = block.literal.trim_end_matches('\n'); for piece in split_html(literal) { if let HtmlPiece::Text(raw) = piece { collect_mentions(&decode_entities(&raw), &mut found); } } } _ => {} } } found } /// Adds the lowercased names of the `@mention`s in one text run to `found`, skipping duplicates. fn collect_mentions(text: &str, found: &mut Vec) { for range in mention_spans(text) { let name = text[range.start + 1..range.end].to_ascii_lowercase(); if !found.contains(&name) { found.push(name); } } } const TAGS: &[&str] = &[ "h1", "h2", "h3", "h4", "h5", "h6", "p", "a", "img", "ul", "ol", "li", "input", "table", "thead", "tbody", "tr", "th", "td", "pre", "code", "blockquote", "hr", "br", "em", "strong", "del", "sup", "sub", "details", "summary", "kbd", "div", "span", "section", ]; const CLASSES: &[&str] = &[ "markdown-alert", "markdown-alert-note", "markdown-alert-tip", "markdown-alert-important", "markdown-alert-warning", "markdown-alert-caution", "markdown-alert-title", "footnote-ref", "footnote-backref", "footnotes", "task-list-item", "sr-only", "anchor", "issue-ref", "user-mention", ]; const SCRIPT_LIKE: &[&str] = &[ "script", "style", "iframe", "object", "embed", "noscript", "template", "textarea", "title", "svg", "math", "form", ]; /// The strict allowlist. Anything not listed here is removed, and so are all `style` /// and `on*` attributes, because they are not in any attribute list. fn sanitizer() -> Builder<'static> { let tags: HashSet<&'static str> = TAGS.iter().copied().collect(); let clean_content: HashSet<&'static str> = SCRIPT_LIKE.iter().copied().collect(); let classes: HashSet<&'static str> = CLASSES.iter().copied().collect(); let allowed_classes: HashMap<&'static str, HashSet<&'static str>> = TAGS.iter().map(|tag| (*tag, classes.clone())).collect(); let attrs: HashMap<&'static str, HashSet<&'static str>> = [ ("h1", &["id"][..]), ("h2", &["id"]), ("h3", &["id"]), ("h4", &["id"]), ("h5", &["id"]), ("h6", &["id"]), ( "a", &[ "href", "title", "id", "data-footnote-ref", "data-footnote-backref", ], ), ("img", &["src", "alt", "title", "width", "height"]), ("ol", &["start"]), ("li", &["id"]), ("sup", &["id"]), ("section", &["id", "data-footnotes"]), ("pre", &["lang"]), ("td", &["align"]), ("th", &["align"]), ("input", &["type", "checked", "disabled"]), ("details", &["open"]), ] .into_iter() .map(|(tag, names)| (tag, names.iter().copied().collect())) .collect(); let mut builder = Builder::empty(); builder .tags(tags) .clean_content_tags(clean_content) .tag_attributes(attrs) .generic_attributes(HashSet::from([ "aria-label", "aria-describedby", "aria-hidden", ])) .allowed_classes(allowed_classes) .url_schemes(HashSet::from(["http", "https", "mailto"])) .url_relative(UrlRelative::PassThrough) .link_rel(Some(EXTERNAL_REL)) .set_tag_attribute_value("input", "type", "checkbox"); builder } #[cfg(test)] mod tests { use super::*; fn ctx() -> LinkContext { LinkContext { tree_base: "https://forge.test/o/r/tree/main/guide".to_string(), blob_base: "https://forge.test/o/r/blob/main/guide".to_string(), raw_base: "https://raw.test/o/r/main/guide".to_string(), issues_base: None, } } fn issue_ctx() -> LinkContext { LinkContext { issues_base: Some("/o/r/issues".to_string()), ..ctx() } } #[test] fn issue_refs_and_mentions_are_linked() { let html = render_markdown("fixes #12, see @alice.\n\n- #3", Some(&issue_ctx())); assert!(html.contains("href=\"/o/r/issues/12\""), "{html}"); assert!(html.contains("class=\"issue-ref\""), "{html}"); assert!(html.contains(">#12"), "{html}"); assert!(html.contains("href=\"/o/r/issues/3\""), "{html}"); assert!(html.contains(">#3"), "{html}"); assert!(html.contains("href=\"/alice\""), "{html}"); assert!(html.contains("class=\"user-mention\""), "{html}"); assert!(html.contains(">@alice."), "{html}"); assert!(!html.contains("title="), "{html}"); } /// Every `')) { continue; } count += 1; let tag_end = html[at..].find('>').expect("unclosed tag"); let tag = &html[at..at + tag_end]; assert!( tag.contains("rel=\"nofollow noopener noreferrer\""), "{html}" ); } assert!(count > 0, "{html}"); } #[test] fn raw_html_links_always_get_rel() { let html = render_markdown( "s\n\n\ x\n\ni\n", Some(&ctx()), ); assert_all_links_have_rel(&html); assert!(html.contains("href=\"https://spam.example\""), "{html}"); assert_all_links_have_rel(&render_markdown( "s\n\nx", None, )); } #[test] fn html_block_entities_are_not_double_escaped() { let html = render_markdown("
Tom & Jerry #3
\n", Some(&issue_ctx())); assert!(html.contains("Tom & Jerry"), "{html}"); assert!(!html.contains("&amp;"), "{html}"); assert!(html.contains("class=\"issue-ref\""), "{html}"); assert!(html.contains(">#3"), "{html}"); } #[test] fn decode_entities_handles_known_and_unknown() { assert_eq!( decode_entities("a & b <'A>"), "a & b <'A>" ); assert_eq!(decode_entities(" "'"), "\u{a0}\"'"); assert_eq!( decode_entities("&bogus; & &#; &#xZZ; &"), "&bogus; & &#; &#xZZ; &" ); assert_eq!(decode_entities("�"), "\0"); } #[test] fn refs_in_code_are_not_linked() { let html = render_markdown( "`#12` and @bob\n\n```\n#13 @carol\n```\n", Some(&issue_ctx()), ); // `@bob` is plain text and is linked; the refs inside code are not. assert!(!html.contains("issue-ref"), "{html}"); assert_eq!(html.matches("user-mention").count(), 1, "{html}"); assert!(html.contains(">@bob"), "{html}"); assert!(!html.contains("@carol"), "{html}"); assert!(html.contains("#12"), "{html}"); } #[test] fn refs_inside_existing_links_are_not_doubled() { let html = render_markdown("[see #5 and @dan](https://example.com)", Some(&issue_ctx())); assert!(!html.contains("issue-ref"), "{html}"); assert!(!html.contains("user-mention"), "{html}"); assert_eq!(html.matches("alert(1) #7", Some(&issue_ctx())); assert!(!html.contains("script"), "{html}"); assert!(html.contains("class=\"issue-ref\""), "{html}"); assert!(html.contains(">#7"), "{html}"); } #[test] fn no_issues_base_means_no_linking() { let html = render_markdown("#12 @alice", Some(&ctx())); assert!(!html.contains("issue-ref"), "{html}"); assert!(!html.contains("user-mention"), "{html}"); assert!(html.contains("#12 @alice"), "{html}"); } #[test] fn email_is_not_a_mention() { let html = render_markdown("write to a@b.com or @ alone", Some(&issue_ctx())); assert!(!html.contains("user-mention"), "{html}"); assert!(html.contains("a@b.com"), "{html}"); } #[test] fn mention_names_follow_the_rules() { assert_eq!(mention_spans("@bob."), vec![0..4]); assert!(mention_spans(&format!("@{}", "a".repeat(40))).is_empty()); assert_eq!(mention_spans(&format!("@{}", "a".repeat(39))), vec![0..40]); assert!(mention_spans("@.").is_empty()); assert_eq!(mention_spans("x @a, @b_c-d"), vec![2..4, 6..12]); } #[test] fn gfm_tables_and_tasklists_render() { let html = render_markdown( "| a | b |\n|:-|-:|\n| 1 | 2 |\n\n- [x] done\n- [ ] todo\n", None, ); assert!(html.contains(""), "{html}"); assert!(html.contains("alert(1)\n\n[x](javascript:alert(1))\n\n\ \n\ns\n", None, ); assert!(!html.contains("script"), "{html}"); assert!(!html.contains("javascript:"), "{html}"); assert!(!html.contains("onerror"), "{html}"); assert!(!html.contains("style"), "{html}"); assert!(html.contains(">x"), "{html}"); } #[test] fn relative_page_link_goes_to_blob() { let html = render_markdown("[x](docs/a.md)", Some(&ctx())); assert!( html.contains("href=\"https://forge.test/o/r/blob/main/guide/docs/a.md\""), "{html}" ); } #[test] fn trailing_slash_and_dotdot_resolve() { let html = render_markdown("[d](../sub/)\n\n[e](../../../../x.md)", Some(&ctx())); assert!( html.contains("href=\"https://forge.test/o/r/tree/main/sub\""), "{html}" ); assert!( html.contains("href=\"https://forge.test/o/r/blob/main/x.md\""), "{html}" ); } #[test] fn relative_image_resolves_against_raw_and_clamps() { let html = render_markdown("![i](../img.png)", Some(&ctx())); assert!( html.contains("src=\"https://raw.test/o/r/main/img.png\""), "{html}" ); } #[test] fn root_relative_link_resolves_from_repo_root() { // The fragment belongs to the target file (e.g. #L12 on code), so it is left as is. let html = render_markdown("[r](/README.md#top) [s](/a/../b.md)", Some(&ctx())); assert!( html.contains("href=\"https://forge.test/o/r/blob/main/README.md#top\""), "{html}" ); assert!( html.contains("href=\"https://forge.test/o/r/blob/main/b.md\""), "{html}" ); } #[test] fn absolute_https_links_are_kept() { let html = render_markdown("[g](https://example.com/a?b=1)", Some(&ctx())); assert!( html.contains("href=\"https://example.com/a?b=1\""), "{html}" ); assert!( html.contains("rel=\"nofollow noopener noreferrer\""), "{html}" ); } #[test] fn heading_ids_are_prefixed() { let html = render_markdown("# Hello World", None); assert!(html.contains("id=\"user-content-hello-world\""), "{html}"); } #[test] fn anchor_links_match_heading_ids() { let html = render_markdown("[t](#hello-world)", Some(&ctx())); assert!( html.contains("href=\"#user-content-hello-world\""), "{html}" ); } #[test] fn details_and_summary_survive() { let html = render_markdown( "
More\n\nHidden text\n\n
\n", None, ); assert!(html.contains("
"), "{html}"); assert!(html.contains("More"), "{html}"); } #[test] fn emoji_shortcodes_convert_outside_code() { let html = render_markdown( "party :tada: and `:tada:` and\n\n```\n:tada:\n```\n\n:spark:\n", None, ); assert!(html.contains("party 🎉 and"), "{html}"); assert!(html.contains(":tada:"), "{html}"); assert!(html.contains("🎉"), "{html}"); } #[test] fn no_context_means_no_rewriting() { let html = render_markdown("[x](docs/a.md)", None); assert!(html.contains("href=\"docs/a.md\""), "{html}"); } }