fabrica

hanna/fabrica

fix(web): rewrite relative README/markdown links into the repo

f0db157 · hanna committed on 2026-07-26

Relative links and images in a rendered README (or a previewed markdown file)
resolved against the current page URL, so `[cfg](fabrica.example.toml)` pointed
at `/owner/fabrica.example.toml` instead of the file. Rewrite them via comrak's
AST: links target the blob view and images the raw view, on the current ref,
resolving `./` and `../` from the document's directory. Absolute URLs, `//host`,
`mailto:`, and bare `#anchor` are left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
2 files changed · +159 −10UnifiedSplit
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]
23pub fn render(source: &str) -> Markup {23pub fn render(source: &str) -> Markup {
24 PreEscaped(render_html(source))24 PreEscaped(render_html(source, None))
25}25}
2626
27/// Like [`render`], but also linkifies `#123` issue/PR references against27/// 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]
30pub fn render_with_refs(source: &str, repo_base: &str) -> Markup {30pub 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}
3333
34/// The shared comrak + ammonia pipeline, producing sanitized HTML.34/// Render a README or in-repo markdown file, rewriting **relative** links and
35fn 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]
40pub 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.
50struct RepoUrls<'a> {
51 base: &'a str,
52 ref_name: &'a str,
53 dir: &'a str,
54}
55
56impl 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).
98fn 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.
113fn 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.
135fn 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);
46146
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}
50160
@@ -305,6 +415,39 @@ mod tests {
305 }415 }
306416
307 #[test]417 #[test]
418 fn rewrites_relative_repo_links_and_images() {
419 let src = "[cfg](fabrica.example.toml) ![logo](docs/logo.png) \
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 .await848 .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}
867867
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`.
869async fn load_readme(870async 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 entries879 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}
894898
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))