Gitfed
bastien-mrq/gitfed/ Commits/ 5f7a65b

Fix relative markdown links in rendered README/blob pages

Rendered README/LICENSE/blob markdown never rewrote relative link destinations, so a link like "docs/HOW_IT_WORKS.md" resolved against the current page URL in the browser instead of the repo's file tree, and 404'd. renderRepoMarkdown now walks the goldmark AST and rewrites relative link/image destinations to gitfed's own /repo-blob and /r routes, resolved against the linking file's directory.

bastien-mrq 2026-07-28 19:23 commit 5f7a65b7ce6ba61c4aa86b5db0b8b694c812320f parent 823b111ec0c29957901a7e1c8a3076da840d868b
2 files changed +80 −11
M cmd/gitfed-web/handlers_repo.go +7 −7
M cmd/gitfed-web/render.go +73 −4
cmd/gitfed-web/handlers_repo.go
diff --git a/cmd/gitfed-web/handlers_repo.go b/cmd/gitfed-web/handlers_repo.go index 0f1ccea..7c9caca 100644 --- a/cmd/gitfed-web/handlers_repo.go +++ b/cmd/gitfed-web/handlers_repo.go @@ -191,7 +191,7 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) { return } if readmeFound { - readmeHTML, err = renderFileContent("README.md", readmeContent) + readmeHTML, err = renderFileContent(name, "", "README.md", readmeContent) if err != nil { s.serverError(w, r, err) return @@ -205,7 +205,7 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) { } if licenseFound { licenseFile = foundLicenseFile - licenseHTML, err = renderFileContent(licenseFile, licenseContent) + licenseHTML, err = renderFileContent(name, "", licenseFile, licenseContent) if err != nil { s.serverError(w, r, err) return @@ -291,11 +291,11 @@ func (s *server) handleRepoBlob(w http.ResponseWriter, r *http.Request) { if strings.Contains(content, "\x00") { rendered = template.HTML(`<p class="muted">` + template.HTMLEscapeString(i18n.T(lang, "repo.binary_file")) + `</p>`) } else { - fileName := path + fileName, dir := path, "" if i := strings.LastIndex(fileName, "/"); i >= 0 { - fileName = fileName[i+1:] + dir, fileName = fileName[:i], fileName[i+1:] } - rendered, err = renderFileContent(fileName, content) + rendered, err = renderFileContent(name, dir, fileName, content) if err != nil { s.serverError(w, r, err) return @@ -313,9 +313,9 @@ func (s *server) handleRepoBlob(w http.ResponseWriter, r *http.Request) { s.render(w, r, path+" — "+name, "home", template.HTML(buf.String())) } -func renderFileContent(filename, content string) (template.HTML, error) { +func renderFileContent(repo, dir, filename, content string) (template.HTML, error) { if strings.HasSuffix(strings.ToLower(filename), ".md") || strings.HasSuffix(strings.ToLower(filename), ".markdown") { - return renderMarkdown(content) + return renderRepoMarkdown(repo, dir, content) } var buf bytes.Buffer if err := plainTpl.Execute(&buf, content); err != nil {
cmd/gitfed-web/render.go
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go index 9b879a5..6cac065 100644 --- a/cmd/gitfed-web/render.go +++ b/cmd/gitfed-web/render.go @@ -5,11 +5,14 @@ import ( "html/template" "log" "net/http" + "path" "strings" "unicode" "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/text" "gitfed/internal/i18n" "gitfed/internal/version" @@ -31,10 +34,12 @@ func newTpl(name, src string) *template.Template { var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM)) -// renderMarkdown converts src to HTML. goldmark escapes any raw HTML found -// in the source by default (we never enable html.WithUnsafe) — README/ -// LICENSE content comes from whoever can push to the repo, not necessarily -// someone the reader trusts, so treat it as untrusted input. +// renderMarkdown converts src to HTML with no link rewriting — for content +// that isn't rooted in a specific repo path (currently just the changelog). +// goldmark escapes any raw HTML found in the source by default (we never +// enable html.WithUnsafe) — README/LICENSE content comes from whoever can +// push to the repo, not necessarily someone the reader trusts, so treat it +// as untrusted input. func renderMarkdown(src string) (template.HTML, error) { var buf bytes.Buffer if err := markdown.Convert([]byte(src), &buf); err != nil { @@ -43,6 +48,70 @@ func renderMarkdown(src string) (template.HTML, error) { return template.HTML(buf.String()), nil } +// renderRepoMarkdown converts src to HTML the same way renderMarkdown does, +// but additionally rewrites every relative link/image so it resolves inside +// gitfed's own repo browser instead of against the current page URL — a +// plain "docs/HOW_IT_WORKS.md" link in a rendered README would otherwise +// resolve relative to /r/{repo} in the browser and 404, since gitfed's repo +// pages aren't a real directory hierarchy the way raw files on disk are. +// dir is the directory (repo-relative, no leading/trailing slash, "" for +// root) that src itself lives in, used to resolve "../" and sibling links. +func renderRepoMarkdown(repo, dir, src string) (template.HTML, error) { + reader := text.NewReader([]byte(src)) + doc := markdown.Parser().Parse(reader) + ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + switch link := n.(type) { + case *ast.Link: + link.Destination = []byte(resolveRepoLink(repo, dir, string(link.Destination))) + case *ast.Image: + link.Destination = []byte(resolveRepoLink(repo, dir, string(link.Destination))) + } + return ast.WalkContinue, nil + }) + var buf bytes.Buffer + if err := markdown.Renderer().Render(&buf, []byte(src), doc); err != nil { + return "", err + } + return template.HTML(buf.String()), nil +} + +// resolveRepoLink rewrites a markdown link destination found in a file at +// dir (repo-relative) so it points at gitfed's own /repo-blob route instead +// of a bare relative path. Absolute URLs, mailto:, protocol-relative and +// pure same-page fragments are left untouched. +func resolveRepoLink(repo, dir, dest string) string { + if dest == "" || strings.HasPrefix(dest, "#") || strings.HasPrefix(dest, "//") || + strings.HasPrefix(dest, "mailto:") || strings.Contains(dest, "://") { + return dest + } + + target, fragment := dest, "" + if i := strings.IndexByte(target, '#'); i >= 0 { + target, fragment = target[:i], target[i:] + } + if target == "" { + return dest + } + + if strings.HasPrefix(target, "/") { + target = strings.TrimPrefix(target, "/") + } else { + target = path.Join(dir, target) + } + target = path.Clean(target) + if target == "." || target == "" { + return "/r/" + repo + fragment + } + // Left un-percent-encoded here, same as every other "?path=" link this + // app builds via templates (e.g. repoTpl's {{.FullPath}}) — goldmark's + // own HTML renderer still escapes the destination for the href + // attribute, it just doesn't percent-encode "/", which is what we want. + return "/repo-blob/" + repo + "?path=" + target + fragment +} + // brandMark is the "Branch Blocks" logo — three square-cornered rectangles // forming a git fork, no gradient or curve. Used inline in the header (via // currentColor, so it follows the link color) and, percent-encoded, as the