penguin/flatgit

flatgit showcase

commit 393d1b05164d0fb560d2a32df47d91f8a959ec59

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-14T05:16:55Z
subjectAllow md links
commit 393d1b05164d0fb560d2a32df47d91f8a959ec59
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-14T05:16:55Z

    Allow md links
---
 internal/buildinfo/buildinfo_gen.go |  4 +--
 internal/render/markdown.go         | 40 +++++++++++++++++++-------
 internal/render/markdown_test.go    | 56 ++++++++++++++++++++++++++++++++++++-
 internal/render/render.go           |  2 +-
 internal/render/rtree.go            |  5 +++-
 5 files changed, 92 insertions(+), 15 deletions(-)

diff --git a/internal/buildinfo/buildinfo_gen.go b/internal/buildinfo/buildinfo_gen.go
index da365ca..ebaf7fb 100644
--- a/internal/buildinfo/buildinfo_gen.go
+++ b/internal/buildinfo/buildinfo_gen.go
@@ -2,6 +2,6 @@ package buildinfo
 
 const (
     Version     = "dev"
-    GitRevision = "0bbb52a7252c199f9cebc6fb42d219a14c073a3b"
-    Timestamp   = "20261013.132630"
+    GitRevision = "e9582f881b4cc2462eb5e02b88174c52d5894805"
+    Timestamp   = "20261014.051221"
 )
diff --git a/internal/render/markdown.go b/internal/render/markdown.go
index 0836d35..f79f1ac 100644
--- a/internal/render/markdown.go
+++ b/internal/render/markdown.go
@@ -15,8 +15,9 @@ import (
 	"github.com/yuin/goldmark/text"
 )
 
-// rawBase is the path from the rendered page to raw/<revision>.
-func renderMarkdownSafe(src []byte, markdownPath, rawBase string) ([]byte, error) {
+// rawBase and blobBase are paths from the rendered page to raw/<revision>
+// and blob/<revision>, respectively.
+func renderMarkdownSafe(src []byte, markdownPath, rawBase, blobBase string) ([]byte, error) {
 	md := goldmark.New(
 		goldmark.WithExtensions(extension.GFM),
 		goldmark.WithParserOptions(parser.WithAutoHeadingID()),
@@ -24,8 +25,13 @@ func renderMarkdownSafe(src []byte, markdownPath, rawBase string) ([]byte, error
 
 	doc := md.Parser().Parse(text.NewReader(src))
 	if err := ast.Walk(doc, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
-		if img, ok := node.(*ast.Image); ok && entering {
-			img.Destination = []byte(markdownImageURL(string(img.Destination), markdownPath, rawBase))
+		if entering {
+			switch n := node.(type) {
+			case *ast.Image:
+				n.Destination = []byte(markdownImageURL(string(n.Destination), markdownPath, rawBase))
+			case *ast.Link:
+				n.Destination = []byte(markdownLinkURL(string(n.Destination), markdownPath, blobBase))
+			}
 		}
 		return ast.WalkContinue, nil
 	}); err != nil {
@@ -42,19 +48,33 @@ func renderMarkdownSafe(src []byte, markdownPath, rawBase string) ([]byte, error
 }
 
 func markdownImageURL(destination, markdownPath, rawBase string) string {
+	return markdownRepositoryURL(destination, markdownPath, rawBase, false)
+}
+
+func markdownLinkURL(destination, markdownPath, blobBase string) string {
+	return markdownRepositoryURL(destination, markdownPath, blobBase, true)
+}
+
+func markdownRepositoryURL(destination, markdownPath, base string, markdownOnly bool) string {
 	u, err := url.Parse(destination)
 	if err != nil || u.IsAbs() || u.Host != "" || strings.HasPrefix(destination, "//") || u.Path == "" {
 		return destination
 	}
+	if markdownOnly && !strings.EqualFold(path.Ext(u.Path), ".md") {
+		return destination
+	}
 
 	// Resolve within the repository, including repository-root paths. Clamp
-	// parent traversal at its root so images cannot escape the raw revision.
-	imagePath := u.Path
-	if !strings.HasPrefix(imagePath, "/") {
-		imagePath = path.Dir(markdownPath) + "/" + imagePath
+	// parent traversal at its root so destinations cannot escape the revision.
+	repoPath := u.Path
+	if !strings.HasPrefix(repoPath, "/") {
+		repoPath = path.Dir(markdownPath) + "/" + repoPath
+	}
+	repoPath = strings.TrimPrefix(path.Clean("/"+repoPath), "/")
+	u.Path = path.Join(base, repoPath)
+	if markdownOnly {
+		u.Path += ".html"
 	}
-	imagePath = strings.TrimPrefix(path.Clean("/"+imagePath), "/")
-	u.Path = path.Join(rawBase, imagePath)
 	u.RawPath = ""
 	return u.String()
 }
diff --git a/internal/render/markdown_test.go b/internal/render/markdown_test.go
index 1cf6973..303f124 100644
--- a/internal/render/markdown_test.go
+++ b/internal/render/markdown_test.go
@@ -33,7 +33,7 @@ func TestMarkdownImageURL(t *testing.T) {
 
 func TestRenderMarkdownImages(t *testing.T) {
 	src := []byte("![inline](../images/logo.png)\n\n![reference][logo]\n\n[logo]: ./local.png \"Title\"\n\n[link](../images/logo.png)\n\n![external](https://example.com/logo.png)\n\n![unsafe](javascript:alert(1))\n\n`![code](local.png)`\n")
-	html, err := renderMarkdownSafe(src, "docs/guide.md", "../../../raw/master")
+	html, err := renderMarkdownSafe(src, "docs/guide.md", "../../../raw/master", "../../../blob/master")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -53,3 +53,57 @@ func TestRenderMarkdownImages(t *testing.T) {
 		t.Errorf("unsafe image URL survived sanitization: %s", html)
 	}
 }
+
+func TestMarkdownLinkURL(t *testing.T) {
+	tests := []struct {
+		name, destination, markdownPath, blobBase, want string
+	}{
+		{"summary", "docs/guide.md", "README.md", "blob/master", "blob/master/docs/guide.md.html"},
+		{"blob", "guide.md", "README.md", "../../blob/master", "../../blob/master/guide.md.html"},
+		{"nested", "./intro.md", "docs/guide.md", "../../../blob/master", "../../../blob/master/docs/intro.md.html"},
+		{"parent", "../README.md#setup", "docs/guide.md", "../../../blob/master", "../../../blob/master/README.md.html#setup"},
+		{"root", "/README.md", "docs/guide.md", "../../../blob/master", "../../../blob/master/README.md.html"},
+		{"commit", "intro.md", "docs/guide.md", "../../../blob/abc123", "../../../blob/abc123/docs/intro.md.html"},
+		{"escaping", "my%20guide%23%3F.md?v=1#setup", "README.md", "blob/master", "blob/master/my%20guide%23%3F.md.html?v=1#setup"},
+		{"uppercase", "GUIDE.MD", "README.md", "blob/master", "blob/master/GUIDE.MD.html"},
+		{"traversal", "../../../guide.md", "docs/guide.md", "../../../blob/master", "../../../blob/master/guide.md.html"},
+		{"https", "https://example.com/guide.md", "README.md", "blob/master", "https://example.com/guide.md"},
+		{"protocol relative", "//example.com/guide.md", "README.md", "blob/master", "//example.com/guide.md"},
+		{"fragment", "#setup", "README.md", "blob/master", "#setup"},
+		{"query only", "?file=guide.md", "README.md", "blob/master", "?file=guide.md"},
+		{"non markdown", "image.png", "README.md", "blob/master", "image.png"},
+		{"already rendered", "guide.md.html", "README.md", "blob/master", "guide.md.html"},
+		{"invalid", "bad%ZZ.md", "README.md", "blob/master", "bad%ZZ.md"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := markdownLinkURL(tt.destination, tt.markdownPath, tt.blobBase); got != tt.want {
+				t.Fatalf("got %q, want %q", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestRenderMarkdownLinks(t *testing.T) {
+	src := []byte("[inline](../README.md#setup)\n\n[reference][guide]\n\n[guide]: ./intro.md?v=1#heading \"Guide\"\n\n![image](./diagram.md)\n\n[external](https://example.com/guide.md)\n\n[anchor](#heading)\n\n[unsafe](javascript:alert(1))\n\n`[code](intro.md)`\n")
+	html, err := renderMarkdownSafe(src, "docs/guide.md", "../../../raw/master", "../../../blob/master")
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, want := range []string{
+		`href="../../../blob/master/README.md.html#setup"`,
+		`href="../../../blob/master/docs/intro.md.html?v=1#heading"`,
+		`title="Guide"`,
+		`src="../../../raw/master/docs/diagram.md"`,
+		`href="https://example.com/guide.md"`,
+		`href="#heading"`,
+		`<code>[code](intro.md)</code>`,
+	} {
+		if !strings.Contains(string(html), want) {
+			t.Errorf("missing %q in %s", want, html)
+		}
+	}
+	if strings.Contains(string(html), "javascript:") {
+		t.Errorf("unsafe link URL survived sanitization: %s", html)
+	}
+}
diff --git a/internal/render/render.go b/internal/render/render.go
index 990b957..948ea07 100644
--- a/internal/render/render.go
+++ b/internal/render/render.go
@@ -228,7 +228,7 @@ func (r *Renderer) renderReadmeHTML(
 		return "", err
 	}
 
-	htmlBytes, err := renderMarkdownSafe(content, readmePath, "raw/"+refSlug(repo.DefaultBranch))
+	htmlBytes, err := renderMarkdownSafe(content, readmePath, "raw/"+refSlug(repo.DefaultBranch), "blob/"+refSlug(repo.DefaultBranch))
 	if err != nil {
 		return "", err
 	}
diff --git a/internal/render/rtree.go b/internal/render/rtree.go
index 24c9647..7ba091a 100644
--- a/internal/render/rtree.go
+++ b/internal/render/rtree.go
@@ -170,7 +170,10 @@ func (r *Renderer) renderBlobEntry(
 	}
 
 	if !blob.Binary && isMarkdownPath(blob.Path) {
-		htmlBytes, err := renderMarkdownSafe(content, e.Path, relPath(filepath.Dir(blobPath), filepath.Join(next, "raw", revSlug)))
+		htmlBytes, err := renderMarkdownSafe(content, e.Path,
+			relPath(filepath.Dir(blobPath), filepath.Join(next, "raw", revSlug)),
+			relPath(filepath.Dir(blobPath), filepath.Join(next, "blob", revSlug)),
+		)
 		if err != nil {
 			return err
 		}