web frontend for git repositories, written in Go git.pocka.jp/legit.git
3

Configure Feed

Select the types of activity you want to include in your feed.

Fix directory links in Markdown preview is incorrect

README and preview code uses each own rendering logic, caused diverged
output. An internal directory link inside preview HTML links to 404
(server returns 500) page.

This patch refactors HTML rendering into interface-d one and let both
endpoint use that. The original motivation for this refactor was to
cache rendered HTML, but turns out refactor alone is significant so I
gave this a dedicated commit.

Shota FUJI (Jul 15, 2026, 12:10 PM +0900) 62b73d36 a4dcd129

+256 -195
+4 -14
git/git.go
··· 127 127 return c, nil 128 128 } 129 129 130 - // FileContent returns the content of a file at the "path" and 131 - // whether the file is binary or not. 132 - func (g *GitRepo) FileContent(path string) (string, bool, error) { 130 + func (g *GitRepo) File(path string) (*object.File, error) { 133 131 c, err := g.r.CommitObject(g.h) 134 132 if err != nil { 135 - return "", false, fmt.Errorf("commit object: %w", err) 133 + return nil, fmt.Errorf("commit object: %w", err) 136 134 } 137 135 138 136 tree, err := c.Tree() 139 137 if err != nil { 140 - return "", false, fmt.Errorf("file tree: %w", err) 141 - } 142 - 143 - file, err := tree.File(path) 144 - if err != nil { 145 - return "", false, err 138 + return nil, fmt.Errorf("file tree: %w", err) 146 139 } 147 140 148 - isbin, _ := file.IsBinary() 149 - contents, err := file.Contents() 150 - 151 - return contents, isbin, err 141 + return tree.File(path) 152 142 } 153 143 154 144 func (g *GitRepo) Tags() ([]*TagReference, error) {
+75
renderer/html/markdown.go
··· 1 + // Copyright 2026 Shota FUJI <pockawoooh@gmail.com> 2 + // SPDX-License-Identifier: MIT 3 + 4 + package html 5 + 6 + import ( 7 + "bytes" 8 + "net/url" 9 + 10 + "github.com/microcosm-cc/bluemonday" 11 + "github.com/russross/blackfriday/v2" 12 + ) 13 + 14 + // MarkdownRenderer is a renderer that takes Markdown code and emits HTML. 15 + // Use "NewMarkdownRenderer" to instantiate. 16 + type MarkdownRenderer struct { 17 + // policy is a sanitization policy. 18 + policy *bluemonday.Policy 19 + } 20 + 21 + func NewMarkdownRenderer(policy *bluemonday.Policy) MarkdownRenderer { 22 + p := policy 23 + if p == nil { 24 + p = bluemonday.UGCPolicy() 25 + } 26 + 27 + return MarkdownRenderer{ 28 + policy: p, 29 + } 30 + } 31 + 32 + func (r *MarkdownRenderer) Render(code []byte, transformer Transformer) ([]byte, error) { 33 + parser := blackfriday.New(blackfriday.WithExtensions(blackfriday.CommonExtensions)) 34 + tree := parser.Parse(code) 35 + 36 + if transformer != nil { 37 + tree.Walk(func(node *blackfriday.Node, entering bool) blackfriday.WalkStatus { 38 + if !entering { 39 + return blackfriday.GoToNext 40 + } 41 + 42 + if node.Type == blackfriday.Link || node.Type == blackfriday.Image { 43 + href := string(node.LinkData.Destination) 44 + parsedURL, err := url.Parse(href) 45 + if err == nil && parsedURL.Host != "" { 46 + // Full URL, skip. 47 + return blackfriday.GoToNext 48 + } 49 + 50 + if node.Type == blackfriday.Image { 51 + node.LinkData.Destination = []byte(transformer.RewriteInternalMediaSource(href)) 52 + } else { 53 + node.LinkData.Destination = []byte(transformer.RewriteInternalLink(href)) 54 + } 55 + 56 + } 57 + 58 + return blackfriday.GoToNext 59 + }) 60 + } 61 + 62 + var writer bytes.Buffer 63 + renderer := blackfriday.NewHTMLRenderer(blackfriday.HTMLRendererParameters{}) 64 + renderer.RenderHeader(&writer, tree) 65 + tree.Walk(func(node *blackfriday.Node, entering bool) blackfriday.WalkStatus { 66 + return renderer.RenderNode(&writer, node, entering) 67 + }) 68 + renderer.RenderFooter(&writer, tree) 69 + 70 + unsafe := blackfriday.Run( 71 + writer.Bytes(), 72 + blackfriday.WithExtensions(blackfriday.CommonExtensions), 73 + ) 74 + return r.policy.SanitizeBytes(unsafe), nil 75 + }
+33
renderer/html/plaintext.go
··· 1 + // Copyright 2026 Shota FUJI <pockawoooh@gmail.com> 2 + // SPDX-License-Identifier: MIT 3 + 4 + package html 5 + 6 + import ( 7 + "fmt" 8 + 9 + "github.com/microcosm-cc/bluemonday" 10 + ) 11 + 12 + // PlaintextRenderer renders plaintext in a single "<pre>" element. 13 + // Use "NewPlaintextRenderer" to instantiate. 14 + type PlaintextRenderer struct { 15 + policy *bluemonday.Policy 16 + } 17 + 18 + func NewPlaintextRenderer(policy *bluemonday.Policy) PlaintextRenderer { 19 + p := policy 20 + if p == nil { 21 + p = bluemonday.UGCPolicy() 22 + } 23 + 24 + return PlaintextRenderer{ 25 + policy: p, 26 + } 27 + } 28 + 29 + func (r *PlaintextRenderer) Render(code []byte, _ Transformer) ([]byte, error) { 30 + safe := r.policy.SanitizeBytes(code) 31 + 32 + return fmt.Appendf([]byte{}, "<pre>%s</pre>", safe), nil 33 + }
+18
renderer/html/renderer.go
··· 1 + // Copyright 2026 Shota FUJI <pockawoooh@gmail.com> 2 + // SPDX-License-Identifier: MIT 3 + 4 + package html 5 + 6 + type Renderer interface { 7 + // Render processes "code" then returns HTML text. 8 + Render(code []byte, transformer Transformer) ([]byte, error) 9 + } 10 + 11 + type Transformer interface { 12 + // RewriteInternalMediaSource takes a site internal link to a media file 13 + // then returns a new link. 14 + RewriteInternalMediaSource(src string) string 15 + 16 + // RewriteInternalLink takes a site internal link then returns a new link. 17 + RewriteInternalLink(href string) string 18 + }
+5 -1
routes/handler.go
··· 7 7 8 8 "github.com/microcosm-cc/bluemonday" 9 9 "github.com/pocka/legit/config" 10 + "github.com/pocka/legit/renderer/html" 10 11 ) 11 12 12 13 // Checks for gitprotocol-http(5) specific smells; if found, passes ··· 39 40 40 41 func Handlers(c *config.Config, staticDir fs.FS, templatesDir fs.FS) *http.ServeMux { 41 42 mux := http.NewServeMux() 43 + ugcPolicy := bluemonday.UGCPolicy() 44 + 42 45 d := deps{ 43 46 c: c, 44 47 staticDir: staticDir, 45 48 templatesDir: templatesDir, 46 - ugcPolicy: bluemonday.UGCPolicy(), 49 + markdown: html.NewMarkdownRenderer(ugcPolicy), 50 + plaintext: html.NewPlaintextRenderer(ugcPolicy), 47 51 } 48 52 49 53 if !c.CompileTemplatesOnRequest {
+60
routes/html.go
··· 1 + // Copyright 2026 Shota FUJI <pockawoooh@gmail.com> 2 + // SPDX-License-Identifier: MIT 3 + 4 + package routes 5 + 6 + import ( 7 + "fmt" 8 + "path/filepath" 9 + "strings" 10 + 11 + "github.com/go-git/go-git/v5/plumbing/object" 12 + "github.com/pocka/legit/renderer/html" 13 + ) 14 + 15 + type RepoLinkTransformer struct { 16 + repoName string 17 + ref string 18 + } 19 + 20 + func NewRepoLinkTransformer(repoName string, ref string) *RepoLinkTransformer { 21 + return &RepoLinkTransformer{ 22 + repoName: repoName, 23 + ref: ref, 24 + } 25 + } 26 + 27 + func (t *RepoLinkTransformer) RewriteInternalMediaSource(src string) string { 28 + href := src 29 + 30 + if strings.IndexByte(href, '/') == 0 { 31 + // Repository index page is the "root" for repository's README file. 32 + href = "." + href 33 + } 34 + 35 + return fmt.Sprintf("/%s/blob/%s/%s?raw", t.repoName, t.ref, href) 36 + } 37 + 38 + func (t *RepoLinkTransformer) RewriteInternalLink(link string) string { 39 + href := link 40 + 41 + if strings.IndexByte(href, '/') == 0 { 42 + // Repository index page is the "root" for repository's README file. 43 + href = "." + href 44 + } 45 + 46 + if strings.LastIndexByte(href, '/') == len(href)-1 { 47 + return fmt.Sprintf("/%s/tree/%s/%s", t.repoName, t.ref, href[:len(href)-1]) 48 + } else { 49 + return fmt.Sprintf("/%s/blob/%s/%s", t.repoName, t.ref, href) 50 + } 51 + } 52 + 53 + func (d *deps) htmlRenderer(file *object.File) html.Renderer { 54 + switch filepath.Ext(file.Name) { 55 + case ".md", ".mkd", ".markdown": 56 + return &d.markdown 57 + default: 58 + return nil 59 + } 60 + }
-79
routes/preview/renderer.go
··· 1 - // This file contains preview renderers. 2 - // 3 - // Copyright 2025 Shota FUJI <pockawoooh@gmail.com> 4 - // SPDX-License-Identifier: MIT 5 - 6 - package preview 7 - 8 - import ( 9 - "bytes" 10 - "net/url" 11 - "path/filepath" 12 - 13 - "github.com/microcosm-cc/bluemonday" 14 - "github.com/russross/blackfriday/v2" 15 - ) 16 - 17 - type Renderer interface { 18 - GetPreviewType() string 19 - 20 - Render(code []byte) ([]byte, error) 21 - } 22 - 23 - type MarkdownToHtmlRenderer struct { 24 - policy *bluemonday.Policy 25 - } 26 - 27 - func (r MarkdownToHtmlRenderer) GetPreviewType() string { 28 - return "html" 29 - } 30 - 31 - func (r MarkdownToHtmlRenderer) Render(code []byte) ([]byte, error) { 32 - parser := blackfriday.New(blackfriday.WithExtensions(blackfriday.CommonExtensions)) 33 - 34 - tree := parser.Parse(code) 35 - tree.Walk(func(node *blackfriday.Node, entering bool) blackfriday.WalkStatus { 36 - if !entering { 37 - return blackfriday.GoToNext 38 - } 39 - 40 - if node.Type == blackfriday.Image { 41 - src := string(node.LinkData.Destination) 42 - parsedURL, err := url.Parse(src) 43 - if err == nil && parsedURL.Host != "" { 44 - // External URL, skip. 45 - return blackfriday.GoToNext 46 - } 47 - 48 - queries := parsedURL.Query() 49 - queries.Add("raw", "") 50 - parsedURL.RawQuery = queries.Encode() 51 - 52 - node.LinkData.Destination = []byte(parsedURL.String()) 53 - } 54 - 55 - return blackfriday.GoToNext 56 - }) 57 - 58 - var writer bytes.Buffer 59 - renderer := blackfriday.NewHTMLRenderer(blackfriday.HTMLRendererParameters{}) 60 - renderer.RenderHeader(&writer, tree) 61 - tree.Walk(func(node *blackfriday.Node, entering bool) blackfriday.WalkStatus { 62 - return renderer.RenderNode(&writer, node, entering) 63 - }) 64 - renderer.RenderFooter(&writer, tree) 65 - 66 - unsafe := blackfriday.Run(writer.Bytes(), blackfriday.WithExtensions(blackfriday.CommonExtensions)) 67 - return r.policy.SanitizeBytes(unsafe), nil 68 - } 69 - 70 - func GetPreviewRenderers(fileName string, bmPolicy *bluemonday.Policy) []Renderer { 71 - ext := filepath.Ext(fileName) 72 - 73 - switch ext { 74 - case ".md", ".mkd", ".markdown": 75 - return []Renderer{MarkdownToHtmlRenderer{bmPolicy}} 76 - default: 77 - return []Renderer{} 78 - } 79 - }
+60 -100
routes/routes.go
··· 1 1 package routes 2 2 3 3 import ( 4 - "bytes" 5 4 "compress/gzip" 6 5 "fmt" 7 6 "html/template" 8 7 "io/fs" 9 8 "log" 10 9 "net/http" 11 - "net/url" 12 10 "os" 13 11 "path/filepath" 14 12 "sort" ··· 16 14 17 15 securejoin "github.com/cyphar/filepath-securejoin" 18 16 "github.com/dustin/go-humanize" 19 - "github.com/microcosm-cc/bluemonday" 20 17 "github.com/pocka/legit/config" 21 18 "github.com/pocka/legit/git" 22 - "github.com/pocka/legit/routes/preview" 23 - "github.com/russross/blackfriday/v2" 19 + "github.com/pocka/legit/renderer/html" 24 20 ) 25 21 26 22 type deps struct { ··· 36 32 // Do not use this; use deps.Template() instead. 37 33 t *template.Template 38 34 39 - // ugcPolicy is a bluemonday policy for user generated content. 40 - ugcPolicy *bluemonday.Policy 35 + markdown html.MarkdownRenderer 36 + plaintext html.PlaintextRenderer 41 37 } 42 38 43 39 func (d *deps) Index(w http.ResponseWriter, r *http.Request) { ··· 134 130 return 135 131 } 136 132 133 + transformer := NewRepoLinkTransformer(name, mainBranch) 134 + 137 135 var readmeContent template.HTML 138 136 for _, readme := range d.c.Repo.Readme { 139 - ext := filepath.Ext(readme) 140 - 141 - // Getting a binary here is unlikely and we can't guess the repo owner's 142 - // intention in that case. So we process the file with Markdown processor 143 - // anyway, even if that produces nonsential garbage. 144 - content, _, _ := gr.FileContent(readme) 145 - if len(content) > 0 { 146 - switch ext { 147 - case ".md", ".mkd", ".markdown": 148 - parser := blackfriday.New(blackfriday.WithExtensions(blackfriday.CommonExtensions)) 149 - tree := parser.Parse([]byte(content)) 150 - tree.Walk(func(node *blackfriday.Node, entering bool) blackfriday.WalkStatus { 151 - if !entering { 152 - return blackfriday.GoToNext 153 - } 154 - 155 - if node.Type == blackfriday.Link || node.Type == blackfriday.Image { 156 - href := string(node.LinkData.Destination) 157 - parsedURL, err := url.Parse(href) 158 - if err == nil && parsedURL.Host != "" { 159 - // Full URL, skip. 160 - return blackfriday.GoToNext 161 - } 162 - 163 - if strings.IndexByte(href, '/') == 0 { 164 - // Repository index page is the "root" for repository's README file. 165 - href = "." + href 166 - } 167 - 168 - if node.Type == blackfriday.Image { 169 - node.LinkData.Destination = fmt.Appendf([]byte{}, "/%s/blob/%s/%s?raw", name, mainBranch, href) 170 - } else if strings.LastIndexByte(href, '/') == len(href)-1 { 171 - node.LinkData.Destination = fmt.Appendf([]byte{}, "/%s/tree/%s/%s", name, mainBranch, href) 172 - node.LinkData.Destination = node.LinkData.Destination[:len(node.LinkData.Destination)-1] 173 - } else { 174 - node.LinkData.Destination = fmt.Appendf([]byte{}, "/%s/blob/%s/%s", name, mainBranch, href) 175 - } 176 - 177 - } 137 + file, _ := gr.File(readme) 138 + if file == nil { 139 + continue 140 + } 178 141 179 - return blackfriday.GoToNext 180 - }) 142 + content, _ := file.Contents() 181 143 182 - var writer bytes.Buffer 183 - renderer := blackfriday.NewHTMLRenderer(blackfriday.HTMLRendererParameters{}) 184 - renderer.RenderHeader(&writer, tree) 185 - tree.Walk(func(node *blackfriday.Node, entering bool) blackfriday.WalkStatus { 186 - return renderer.RenderNode(&writer, node, entering) 187 - }) 188 - renderer.RenderFooter(&writer, tree) 144 + // Skip empty files. 145 + if len(content) > 0 { 146 + renderer := d.htmlRenderer(file) 147 + if renderer == nil { 148 + renderer = &d.plaintext 149 + } 189 150 190 - unsafe := blackfriday.Run( 191 - writer.Bytes(), 192 - blackfriday.WithExtensions(blackfriday.CommonExtensions), 193 - ) 194 - html := d.ugcPolicy.SanitizeBytes(unsafe) 195 - readmeContent = template.HTML(html) 196 - default: 197 - safe := d.ugcPolicy.SanitizeBytes([]byte(content)) 198 - readmeContent = template.HTML( 199 - fmt.Sprintf(`<pre>%s</pre>`, safe), 200 - ) 151 + result, err := renderer.Render([]byte(content), transformer) 152 + if err != nil { 153 + log.Printf("Unable to render %s/%s, skipping.", name, readme) 154 + continue 201 155 } 156 + 157 + readmeContent = template.HTML(result) 202 158 break 203 159 } 204 160 } ··· 304 260 return 305 261 } 306 262 307 - contents, isBinary, err := gr.FileContent(treePath) 263 + file, err := gr.File(treePath) 264 + if err != nil { 265 + d.Write500(w) 266 + return 267 + } 268 + 269 + contents, err := file.Contents() 308 270 if err != nil { 309 271 d.Write500(w) 310 272 return ··· 317 279 return 318 280 } 319 281 320 - if isBinary { 282 + if isBin, _ := file.IsBinary(); isBin { 321 283 contents = "Not displaying binary file" 322 284 } 323 285 ··· 336 298 if r.URL.Query().Has("preview") { 337 299 previewType := r.URL.Query().Get("preview") 338 300 339 - for _, renderer := range preview.GetPreviewRenderers(treePath, d.ugcPolicy) { 340 - resolvedPreviewType := renderer.GetPreviewType() 341 - 342 - if previewType != "" && resolvedPreviewType != previewType { 343 - continue 301 + switch previewType { 302 + case "html": 303 + renderer := d.htmlRenderer(file) 304 + if renderer == nil { 305 + log.Printf("Requested HTML preview for %s/%s, but the filetype has no HTML renderer", name, treePath) 306 + d.Write404(w) 307 + return 344 308 } 345 309 346 - switch resolvedPreviewType { 347 - case "html": 348 - html, err := renderer.Render([]byte(contents)) 349 - if err != nil { 350 - log.Printf("Failed to render HTML preview: %s", err) 351 - d.Write500(w) 352 - return 353 - } 354 - 355 - data := repoBlobRefHTMLPreviewData{ 356 - Config: d.c, 357 - Meta: meta, 358 - Path: relpath, 359 - Content: template.HTML(html), 360 - } 310 + html, err := renderer.Render([]byte(contents), NewRepoLinkTransformer(name, ref)) 311 + if err != nil { 312 + log.Printf("Failed to render HTML preview: %s", err) 313 + d.Write500(w) 314 + return 315 + } 361 316 362 - if err := d.Template().ExecuteTemplate(w, "repo-blob-ref-html-preview", data); err != nil { 363 - log.Println(err) 364 - return 365 - } 317 + data := repoBlobRefHTMLPreviewData{ 318 + Config: d.c, 319 + Meta: meta, 320 + Path: relpath, 321 + Content: template.HTML(html), 322 + } 366 323 324 + if err := d.Template().ExecuteTemplate(w, "repo-blob-ref-html-preview", data); err != nil { 325 + log.Println(err) 367 326 return 368 327 } 328 + 329 + return 330 + default: 331 + log.Printf("Got ?preview=%s, but not preview renderer is available for the type", previewType) 332 + d.Write404(w) 333 + return 369 334 } 370 - 371 - log.Printf("Got ?preview=%s, but not preview renderer is available for the type", previewType) 372 - d.Write404(w) 373 - return 374 335 } 375 336 376 337 lc, err := countLines(strings.NewReader(contents)) ··· 389 350 lines[i] = uint(i + 1) 390 351 } 391 352 392 - renderers := preview.GetPreviewRenderers(treePath, d.ugcPolicy) 393 - previewTypes := make([]string, len(renderers)) 394 - for i, renderer := range renderers { 395 - previewTypes[i] = renderer.GetPreviewType() 353 + previewTypes := make([]string, 0, 1) 354 + if d.htmlRenderer(file) != nil { 355 + previewTypes = append(previewTypes, "html") 396 356 } 397 357 398 358 data := repoBlobRefData{
+1 -1
routes/util.go
··· 19 19 ) 20 20 21 21 func isGoModule(gr *git.GitRepo) bool { 22 - _, _, err := gr.FileContent("go.mod") 22 + _, err := gr.File("go.mod") 23 23 return err == nil 24 24 } 25 25