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.
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
@@ -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
@@ -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