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 | 21 | /// Render `source` markdown to sanitized, syntax-highlighted HTML. | |
| 22 | 22 | #[must_use] | |
| 23 | 23 | pub fn render(source: &str) -> Markup { | |
| 24 | - | PreEscaped(render_html(source)) | |
| 24 | + | PreEscaped(render_html(source, None)) | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | 27 | /// Like [`render`], but also linkifies `#123` issue/PR references against | |
| 28 | 28 | /// `repo_base` (e.g. `/owner/repo`). Used for issue/PR descriptions and comments. | |
| 29 | 29 | #[must_use] | |
| 30 | 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. | |
| 35 | - | fn render_html(source: &str) -> String { | |
| 34 | + | /// Render a README or in-repo markdown file, rewriting **relative** links and | |
| 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 | 136 | let mut options = comrak::Options::default(); | |
| 37 | 137 | options.extension.table = true; | |
| 38 | 138 | options.extension.strikethrough = true; | |
| @@ -44,7 +144,17 @@ fn render_html(source: &str) -> String { | |||
| 44 | 144 | let mut plugins = comrak::options::Plugins::default(); | |
| 45 | 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 | 158 | sanitizer().clean(&unsafe_html).to_string() | |
| 49 | 159 | } | |
| 50 | 160 | ||
| @@ -305,6 +415,39 @@ mod tests { | |||
| 305 | 415 | } | |
| 306 | 416 | ||
| 307 | 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 | 451 | fn highlights_fenced_code() { | |
| 309 | 452 | let html = render("```rust\nfn main() {}\n```").into_string(); | |
| 310 | 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 | 827 | rev: &git::Resolved, | |
| 828 | 828 | branches: &[String], | |
| 829 | 829 | ) -> AppResult<Markup> { | |
| 830 | + | let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); | |
| 830 | 831 | let entries = git_read(state, &ctx.repo.id, { | |
| 831 | 832 | let rev = rev.clone(); | |
| 832 | 833 | move |repo| repo.tree_entries(&rev, FsPath::new("")) | |
| 833 | 834 | }) | |
| 834 | 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 | 837 | let annotations = git_read(state, &ctx.repo.id, { | |
| 837 | 838 | let rev = rev.clone(); | |
| 838 | 839 | move |repo| repo.tree_annotations(&rev, FsPath::new("")) | |
| @@ -847,7 +848,6 @@ async fn landing_body( | |||
| 847 | 848 | .await | |
| 848 | 849 | .map(|files| crate::languages::breakdown(&files)) | |
| 849 | 850 | .unwrap_or_default(); | |
| 850 | - | let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); | |
| 851 | 851 | Ok(html! { | |
| 852 | 852 | (repo_header(state, ctx, Tab::Code, rev_name, branches)) | |
| 853 | 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 | 870 | async fn load_readme( | |
| 870 | 871 | state: &AppState, | |
| 871 | 872 | repo_id: &str, | |
| 872 | 873 | rev: &git::Resolved, | |
| 873 | 874 | entries: &[git::TreeEntry], | |
| 875 | + | base: &str, | |
| 876 | + | ref_name: &str, | |
| 874 | 877 | ) -> Option<Markup> { | |
| 875 | 878 | let name = README_NAMES.iter().find(|candidate| { | |
| 876 | 879 | entries | |
| @@ -889,7 +892,8 @@ async fn load_readme( | |||
| 889 | 892 | return None; | |
| 890 | 893 | } | |
| 891 | 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 | 899 | /// The "empty repository" landing with clone instructions. | |
| @@ -1112,8 +1116,10 @@ fn render_blob( | |||
| 1112 | 1116 | p { a class="btn" href=(raw_url) { "Download" } } | |
| 1113 | 1117 | } | |
| 1114 | 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 | 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 | 1124 | } @else { | |
| 1119 | 1125 | (highlighted_code(FsPath::new(path), &blob.content, attr_lang)) | |