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};
2121 /// Render `source` markdown to sanitized, syntax-highlighted HTML.
2222 #[must_use]
2323 pub fn render(source: &str) -> Markup {
24- PreEscaped(render_html(source))
24+ PreEscaped(render_html(source, None))
2525 }
2626
2727 /// Like [`render`], but also linkifies `#123` issue/PR references against
2828 /// `repo_base` (e.g. `/owner/repo`). Used for issue/PR descriptions and comments.
2929 #[must_use]
3030 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))
3232 }
3333
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 {
36136 let mut options = comrak::Options::default();
37137 options.extension.table = true;
38138 options.extension.strikethrough = true;
@@ -44,7 +144,17 @@ fn render_html(source: &str) -> String {
44144 let mut plugins = comrak::options::Plugins::default();
45145 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+ };
48158 sanitizer().clean(&unsafe_html).to_string()
49159 }
50160
@@ -305,6 +415,39 @@ mod tests {
305415 }
306416
307417 #[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]
308451 fn highlights_fenced_code() {
309452 let html = render("```rust\nfn main() {}\n```").into_string();
310453 // A keyword span survives sanitizing with its highlight class.
crates/web/src/repo.rs +11 −5
@@ -827,12 +827,13 @@ async fn landing_body(
827827 rev: &git::Resolved,
828828 branches: &[String],
829829 ) -> AppResult<Markup> {
830+ let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
830831 let entries = git_read(state, &ctx.repo.id, {
831832 let rev = rev.clone();
832833 move |repo| repo.tree_entries(&rev, FsPath::new(""))
833834 })
834835 .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;
836837 let annotations = git_read(state, &ctx.repo.id, {
837838 let rev = rev.clone();
838839 move |repo| repo.tree_annotations(&rev, FsPath::new(""))
@@ -847,7 +848,6 @@ async fn landing_body(
847848 .await
848849 .map(|files| crate::languages::breakdown(&files))
849850 .unwrap_or_default();
850- let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
851851 Ok(html! {
852852 (repo_header(state, ctx, Tab::Code, rev_name, branches))
853853 form class="search-row repo-codesearch" method="get" action=(format!("{base}/-/search")) {
@@ -865,12 +865,15 @@ async fn landing_body(
865865 })
866866 }
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`.
869870 async fn load_readme(
870871 state: &AppState,
871872 repo_id: &str,
872873 rev: &git::Resolved,
873874 entries: &[git::TreeEntry],
875+ base: &str,
876+ ref_name: &str,
874877 ) -> Option<Markup> {
875878 let name = README_NAMES.iter().find(|candidate| {
876879 entries
@@ -889,7 +892,8 @@ async fn load_readme(
889892 return None;
890893 }
891894 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, ""))
893897 }
894898
895899 /// The "empty repository" landing with clone instructions.
@@ -1112,8 +1116,10 @@ fn render_blob(
11121116 p { a class="btn" href=(raw_url) { "Download" } }
11131117 }
11141118 } @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);
11151121 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))
11171123 }
11181124 } @else {
11191125 (highlighted_code(FsPath::new(path), &blob.content, attr_lang))