commit 393d1b05164d0fb560d2a32df47d91f8a959ec59
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-14T05:16:55Z |
| subject | Allow 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("\n\n![reference][logo]\n\n[logo]: ./local.png \"Title\"\n\n[link](../images/logo.png)\n\n\n\n)\n\n``\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\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
}