Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
crates/web/src/markdown.rs +148 −5
| @@ -21,18 +21,118 @@ use maud::{Markup, PreEscaped}; | |||
| 21 | /// Render `source` markdown to sanitized, syntax-highlighted HTML. | 21 | /// Render `source` markdown to sanitized, syntax-highlighted HTML. |
| 22 | #[must_use] | 22 | #[must_use] |
| 23 | pub fn render(source: &str) -> Markup { | 23 | pub fn render(source: &str) -> Markup { |
| 24 | PreEscaped(render_html(source)) | 24 | PreEscaped(render_html(source, None)) |
| 25 | } | 25 | } |
| 26 | 26 | ||
| 27 | /// Like [`render`], but also linkifies `#123` issue/PR references against | 27 | /// Like [`render`], but also linkifies `#123` issue/PR references against |
| 28 | /// `repo_base` (e.g. `/owner/repo`). Used for issue/PR descriptions and comments. | 28 | /// `repo_base` (e.g. `/owner/repo`). Used for issue/PR descriptions and comments. |
| 29 | #[must_use] | 29 | #[must_use] |
| 30 | pub fn render_with_refs(source: &str, repo_base: &str) -> Markup { | 30 | pub fn render_with_refs(source: &str, repo_base: &str) -> Markup { |
| 31 | PreEscaped(linkify_refs(&render_html(source), repo_base)) | 31 | PreEscaped(linkify_refs(&render_html(source, None), repo_base)) |
| 32 | } | 32 | } |
| 33 | 33 | ||
| 34 | /// The shared comrak + ammonia pipeline, producing sanitized HTML. | 34 | /// Render a README or in-repo markdown file, rewriting **relative** links and |
| 35 | fn render_html(source: &str) -> String { | 35 | /// images so they point into the repository at `ref_name`. `base` is |
| 36 | /// `/owner/repo`; `dir` is the directory the document lives in (empty for the | ||
| 37 | /// repo root), used to resolve `./` and `../` paths. Also linkifies `#123` | ||
| 38 | /// references. Absolute URLs, `//host`, `mailto:`, and `#anchor` are left alone. | ||
| 39 | #[must_use] | ||
| 40 | pub fn render_repo(source: &str, base: &str, ref_name: &str, dir: &str) -> Markup { | ||
| 41 | let repo = RepoUrls { | ||
| 42 | base, | ||
| 43 | ref_name, | ||
| 44 | dir, | ||
| 45 | }; | ||
| 46 | PreEscaped(linkify_refs(&render_html(source, Some(&repo)), base)) | ||
| 47 | } | ||
| 48 | |||
| 49 | /// The context for rewriting a repository document's relative URLs. | ||
| 50 | struct RepoUrls<'a> { | ||
| 51 | base: &'a str, | ||
| 52 | ref_name: &'a str, | ||
| 53 | dir: &'a str, | ||
| 54 | } | ||
| 55 | |||
| 56 | impl RepoUrls<'_> { | ||
| 57 | /// Rewrite `url` to point into the repo, or `None` to leave it unchanged. | ||
| 58 | /// Links target the blob view; images target the raw view. | ||
| 59 | fn resolve(&self, url: &str, image: bool) -> Option<String> { | ||
| 60 | let url = url.trim(); | ||
| 61 | if url.is_empty() | ||
| 62 | || url.starts_with('#') | ||
| 63 | || url.starts_with("//") | ||
| 64 | || url.starts_with("mailto:") | ||
| 65 | { | ||
| 66 | return None; | ||
| 67 | } | ||
| 68 | // A scheme (`https:`, `data:`, …) marks an absolute URL — leave it be. | ||
| 69 | if let Some(idx) = url.find(':') | ||
| 70 | && url[..idx] | ||
| 71 | .chars() | ||
| 72 | .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) | ||
| 73 | && !url[..idx].is_empty() | ||
| 74 | { | ||
| 75 | return None; | ||
| 76 | } | ||
| 77 | // Split off a trailing `?query` / `#fragment` to reattach after resolving. | ||
| 78 | let (path, suffix) = url | ||
| 79 | .find(['?', '#']) | ||
| 80 | .map_or((url, ""), |i| (&url[..i], &url[i..])); | ||
| 81 | let joined = if let Some(root_rel) = path.strip_prefix('/') { | ||
| 82 | root_rel.to_string() | ||
| 83 | } else if self.dir.is_empty() { | ||
| 84 | path.to_string() | ||
| 85 | } else { | ||
| 86 | format!("{}/{path}", self.dir) | ||
| 87 | }; | ||
| 88 | let resolved = normalize_path(&joined); | ||
| 89 | let view = if image { "raw" } else { "blob" }; | ||
| 90 | Some(format!( | ||
| 91 | "{}/-/{view}/{}/{resolved}{suffix}", | ||
| 92 | self.base, self.ref_name | ||
| 93 | )) | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 97 | /// Resolve `.`/`..`/empty segments in a slash path (no leading slash on output). | ||
| 98 | fn normalize_path(path: &str) -> String { | ||
| 99 | let mut out: Vec<&str> = Vec::new(); | ||
| 100 | for seg in path.split('/') { | ||
| 101 | match seg { | ||
| 102 | "" | "." => {} | ||
| 103 | ".." => { | ||
| 104 | out.pop(); | ||
| 105 | } | ||
| 106 | other => out.push(other), | ||
| 107 | } | ||
| 108 | } | ||
| 109 | out.join("/") | ||
| 110 | } | ||
| 111 | |||
| 112 | /// Rewrite every relative link and image URL in the AST subtree. | ||
| 113 | fn rewrite_urls<'a>(node: &'a comrak::nodes::AstNode<'a>, repo: &RepoUrls) { | ||
| 114 | for child in node.children() { | ||
| 115 | rewrite_urls(child, repo); | ||
| 116 | } | ||
| 117 | let mut data = node.data.borrow_mut(); | ||
| 118 | match &mut data.value { | ||
| 119 | comrak::nodes::NodeValue::Link(link) => { | ||
| 120 | if let Some(url) = repo.resolve(&link.url, false) { | ||
| 121 | link.url = url; | ||
| 122 | } | ||
| 123 | } | ||
| 124 | comrak::nodes::NodeValue::Image(link) => { | ||
| 125 | if let Some(url) = repo.resolve(&link.url, true) { | ||
| 126 | link.url = url; | ||
| 127 | } | ||
| 128 | } | ||
| 129 | _ => {} | ||
| 130 | } | ||
| 131 | } | ||
| 132 | |||
| 133 | /// The shared comrak + ammonia pipeline, producing sanitized HTML. When `repo` | ||
| 134 | /// is set, relative links/images are rewritten into the repository first. | ||
| 135 | fn render_html(source: &str, repo: Option<&RepoUrls>) -> String { | ||
| 36 | let mut options = comrak::Options::default(); | 136 | let mut options = comrak::Options::default(); |
| 37 | options.extension.table = true; | 137 | options.extension.table = true; |
| 38 | options.extension.strikethrough = true; | 138 | options.extension.strikethrough = true; |
| @@ -44,7 +144,17 @@ fn render_html(source: &str) -> String { | |||
| 44 | let mut plugins = comrak::options::Plugins::default(); | 144 | let mut plugins = comrak::options::Plugins::default(); |
| 45 | plugins.render.codefence_syntax_highlighter = Some(&adapter); | 145 | plugins.render.codefence_syntax_highlighter = Some(&adapter); |
| 46 | 146 | ||
| 47 | let unsafe_html = comrak::markdown_to_html_with_plugins(source, &options, &plugins); | 147 | let unsafe_html = match repo { |
| 148 | None => comrak::markdown_to_html_with_plugins(source, &options, &plugins), | ||
| 149 | Some(repo) => { | ||
| 150 | let arena = comrak::Arena::new(); | ||
| 151 | let root = comrak::parse_document(&arena, source, &options); | ||
| 152 | rewrite_urls(root, repo); | ||
| 153 | let mut out = String::new(); | ||
| 154 | let _ = comrak::format_html_with_plugins(root, &options, &mut out, &plugins); | ||
| 155 | out | ||
| 156 | } | ||
| 157 | }; | ||
| 48 | sanitizer().clean(&unsafe_html).to_string() | 158 | sanitizer().clean(&unsafe_html).to_string() |
| 49 | } | 159 | } |
| 50 | 160 | ||
| @@ -305,6 +415,39 @@ mod tests { | |||
| 305 | } | 415 | } |
| 306 | 416 | ||
| 307 | #[test] | 417 | #[test] |
| 418 | fn rewrites_relative_repo_links_and_images() { | ||
| 419 | let src = "[cfg](fabrica.example.toml)  \ | ||
| 420 | [abs](https://x.test/y) [anchor](#s)"; | ||
| 421 | let html = render_repo(src, "/hanna/fabrica", "main", "").into_string(); | ||
| 422 | // A relative link points at the blob view on the current ref. | ||
| 423 | assert!( | ||
| 424 | html.contains(r#"href="/hanna/fabrica/-/blob/main/fabrica.example.toml""#), | ||
| 425 | "relative link rewritten: {html}" | ||
| 426 | ); | ||
| 427 | // A relative image points at the raw view. | ||
| 428 | assert!( | ||
| 429 | html.contains(r#"src="/hanna/fabrica/-/raw/main/docs/logo.png""#), | ||
| 430 | "relative image rewritten: {html}" | ||
| 431 | ); | ||
| 432 | // Absolute URLs and bare anchors are left untouched. | ||
| 433 | assert!( | ||
| 434 | html.contains(r#"href="https://x.test/y""#), | ||
| 435 | "abs kept: {html}" | ||
| 436 | ); | ||
| 437 | assert!(html.contains(r##"href="#s""##), "anchor kept: {html}"); | ||
| 438 | } | ||
| 439 | |||
| 440 | #[test] | ||
| 441 | fn resolves_relative_paths_from_the_document_directory() { | ||
| 442 | // A link in docs/guide.md to ../README.md resolves to the repo root. | ||
| 443 | let html = render_repo("[up](../README.md)", "/o/r", "v1.0", "docs").into_string(); | ||
| 444 | assert!( | ||
| 445 | html.contains(r#"href="/o/r/-/blob/v1.0/README.md""#), | ||
| 446 | "../ resolved against dir: {html}" | ||
| 447 | ); | ||
| 448 | } | ||
| 449 | |||
| 450 | #[test] | ||
| 308 | fn highlights_fenced_code() { | 451 | fn highlights_fenced_code() { |
| 309 | let html = render("```rust\nfn main() {}\n```").into_string(); | 452 | let html = render("```rust\nfn main() {}\n```").into_string(); |
| 310 | // A keyword span survives sanitizing with its highlight class. | 453 | // A keyword span survives sanitizing with its highlight class. |
crates/web/src/repo.rs +11 −5
| @@ -827,12 +827,13 @@ async fn landing_body( | |||
| 827 | rev: &git::Resolved, | 827 | rev: &git::Resolved, |
| 828 | branches: &[String], | 828 | branches: &[String], |
| 829 | ) -> AppResult<Markup> { | 829 | ) -> AppResult<Markup> { |
| 830 | let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); | ||
| 830 | let entries = git_read(state, &ctx.repo.id, { | 831 | let entries = git_read(state, &ctx.repo.id, { |
| 831 | let rev = rev.clone(); | 832 | let rev = rev.clone(); |
| 832 | move |repo| repo.tree_entries(&rev, FsPath::new("")) | 833 | move |repo| repo.tree_entries(&rev, FsPath::new("")) |
| 833 | }) | 834 | }) |
| 834 | .await?; | 835 | .await?; |
| 835 | let readme = load_readme(state, &ctx.repo.id, rev, &entries).await; | 836 | let readme = load_readme(state, &ctx.repo.id, rev, &entries, &base, rev_name).await; |
| 836 | let annotations = git_read(state, &ctx.repo.id, { | 837 | let annotations = git_read(state, &ctx.repo.id, { |
| 837 | let rev = rev.clone(); | 838 | let rev = rev.clone(); |
| 838 | move |repo| repo.tree_annotations(&rev, FsPath::new("")) | 839 | move |repo| repo.tree_annotations(&rev, FsPath::new("")) |
| @@ -847,7 +848,6 @@ async fn landing_body( | |||
| 847 | .await | 848 | .await |
| 848 | .map(|files| crate::languages::breakdown(&files)) | 849 | .map(|files| crate::languages::breakdown(&files)) |
| 849 | .unwrap_or_default(); | 850 | .unwrap_or_default(); |
| 850 | let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); | ||
| 851 | Ok(html! { | 851 | Ok(html! { |
| 852 | (repo_header(state, ctx, Tab::Code, rev_name, branches)) | 852 | (repo_header(state, ctx, Tab::Code, rev_name, branches)) |
| 853 | form class="search-row repo-codesearch" method="get" action=(format!("{base}/-/search")) { | 853 | form class="search-row repo-codesearch" method="get" action=(format!("{base}/-/search")) { |
| @@ -865,12 +865,15 @@ async fn landing_body( | |||
| 865 | }) | 865 | }) |
| 866 | } | 866 | } |
| 867 | 867 | ||
| 868 | /// Find and render the first matching README in the root tree. | 868 | /// Find and render the first matching README in the root tree, rewriting its |
| 869 | /// relative links/images into the repo at `ref_name`. | ||
| 869 | async fn load_readme( | 870 | async fn load_readme( |
| 870 | state: &AppState, | 871 | state: &AppState, |
| 871 | repo_id: &str, | 872 | repo_id: &str, |
| 872 | rev: &git::Resolved, | 873 | rev: &git::Resolved, |
| 873 | entries: &[git::TreeEntry], | 874 | entries: &[git::TreeEntry], |
| 875 | base: &str, | ||
| 876 | ref_name: &str, | ||
| 874 | ) -> Option<Markup> { | 877 | ) -> Option<Markup> { |
| 875 | let name = README_NAMES.iter().find(|candidate| { | 878 | let name = README_NAMES.iter().find(|candidate| { |
| 876 | entries | 879 | entries |
| @@ -889,7 +892,8 @@ async fn load_readme( | |||
| 889 | return None; | 892 | return None; |
| 890 | } | 893 | } |
| 891 | let text = String::from_utf8_lossy(&blob.content); | 894 | let text = String::from_utf8_lossy(&blob.content); |
| 892 | Some(markdown::render(&text)) | 895 | // The README lives at the repo root, so relative paths resolve from "". |
| 896 | Some(markdown::render_repo(&text, base, ref_name, "")) | ||
| 893 | } | 897 | } |
| 894 | 898 | ||
| 895 | /// The "empty repository" landing with clone instructions. | 899 | /// The "empty repository" landing with clone instructions. |
| @@ -1112,8 +1116,10 @@ fn render_blob( | |||
| 1112 | p { a class="btn" href=(raw_url) { "Download" } } | 1116 | p { a class="btn" href=(raw_url) { "Download" } } |
| 1113 | } | 1117 | } |
| 1114 | } @else if show_preview { | 1118 | } @else if show_preview { |
| 1119 | // Relative links in the file resolve from its own directory. | ||
| 1120 | @let dir = path.rsplit_once('/').map_or("", |(d, _)| d); | ||
| 1115 | div class="blob-preview markdown" { | 1121 | div class="blob-preview markdown" { |
| 1116 | (markdown::render(&String::from_utf8_lossy(&blob.content))) | 1122 | (markdown::render_repo(&String::from_utf8_lossy(&blob.content), &base, rev_name, dir)) |
| 1117 | } | 1123 | } |
| 1118 | } @else { | 1124 | } @else { |
| 1119 | (highlighted_code(FsPath::new(path), &blob.content, attr_lang)) | 1125 | (highlighted_code(FsPath::new(path), &blob.content, attr_lang)) |