Commit c2d8110534

c2d81105344db93058ba50f63e5e81c49abd4b7f

parent: 8eea89a307

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-03 01:05 UTC

web: markdown and org files render on the blob page

Opening a markdown or org file showed its source with highlighting;
only a README was rendered, and only on the directory page.

A .md, .markdown or .org blob now renders the way a README does, through
the same renderer with relative links resolved against the file's
directory, and the action bar offers the source one click away;
?view=source shows the text as before. Every other file is unchanged.

Closes #88

Layout: unified · split

e2e/blobrender_test.go added +55
@@ -0,0 +1,55 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10// Markdown and org files render on the blob page, with the source one
11// click away; everything else is unchanged.
12func TestBlobRendersMarkup(t *testing.T) {
13 inst := startInstance(t)
14 aliceKey := inst.newKey(t, "alice")
15 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
16 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/docs"); code != 0 {
17 t.Fatal("repo create failed")
18 }
19 work := t.TempDir()
20 env := inst.gitEnv(aliceKey)
21 mustGit(t, work, env, "clone", inst.sshURL("alice/docs"), "w")
22 dir := filepath.Join(work, "w")
23 os.MkdirAll(filepath.Join(dir, "docs"), 0o755)
24 os.WriteFile(filepath.Join(dir, "docs", "notes.md"), []byte("# Field notes\n\nSee [the plan](plan.org).\n"), 0o644)
25 os.WriteFile(filepath.Join(dir, "docs", "plan.org"), []byte("* The plan\n\nA *bold* claim.\n"), 0o644)
26 os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0o644)
27 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
28 mustGit(t, dir, env, "add", ".")
29 mustGit(t, dir, env, "commit", "-q", "-m", "docs")
30 mustGit(t, dir, env, "push", "-q", "origin", "main")
31
32 status, body := inst.get(t, "/alice/docs/blob/main/docs/notes.md")
33 if status != 200 || !strings.Contains(body, "<h1") || !strings.Contains(body, "Field notes</h1>") {
34 t.Fatalf("markdown not rendered: %d\n%s", status, body)
35 }
36 if !strings.Contains(body, `href="?view=source"`) || !strings.Contains(body, "<strong>rendered</strong>") {
37 t.Fatalf("no source toggle:\n%s", body)
38 }
39 // A relative link resolves against the file's directory, as in a README.
40 if !strings.Contains(body, "/alice/docs/blob/main/docs/plan.org") {
41 t.Fatalf("relative link not rewritten:\n%s", body)
42 }
43 _, body = inst.get(t, "/alice/docs/blob/main/docs/notes.md?view=source")
44 if !strings.Contains(body, `class="code"`) || strings.Contains(body, "Field notes</h1>") || !strings.Contains(body, `href="?"`) {
45 t.Fatalf("source view wrong:\n%s", body)
46 }
47 _, body = inst.get(t, "/alice/docs/blob/main/docs/plan.org")
48 if !strings.Contains(body, "The plan") || !strings.Contains(body, "<strong>bold</strong>") || !strings.Contains(body, `href="?view=source"`) {
49 t.Fatalf("org not rendered:\n%s", body)
50 }
51 _, body = inst.get(t, "/alice/docs/blob/main/main.go")
52 if strings.Contains(body, "view=source") || !strings.Contains(body, `class="code"`) {
53 t.Fatalf("plain file gained a toggle or lost its code view:\n%s", body)
54 }
55}
internal/httpd/web.go +29 −14
@@ -547,6 +547,18 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
547 if !binary && !image { 547 if !binary && !image {
548 codeHTML = highlight(filePath, data) 548 codeHTML = highlight(filePath, data)
549 } 549 }
550 // Markdown and org render like a README, with the source one click
551 // away; ?view=source shows the text instead.
552 renderable := false
553 switch path.Ext(strings.ToLower(filePath)) {
554 case ".md", ".markdown", ".org":
555 renderable = !binary
556 }
557 var renderedHTML template.HTML
558 rendered := renderable && r.URL.Query().Get("view") != "source"
559 if rendered {
560 renderedHTML = rewriteRelativeLinks(renderReadme(path.Base(filePath), data), p, path.Dir(filePath))
561 }
550 cs := crumbs(p, "blob", filePath) 562 cs := crumbs(p, "blob", filePath)
551 base := "" 563 base := ""
552 if len(cs) > 0 { 564 if len(cs) > 0 {
@@ -566,21 +578,24 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
566 entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath) 578 entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath)
567 s.render(w, "blob.html", struct { 579 s.render(w, "blob.html", struct {
568 repoPage 580 repoPage
569 Crumbs []crumb 581 Crumbs []crumb
570 Base string 582 Base string
571 Path string 583 Path string
572 DirPath string 584 DirPath string
573 RefKind string 585 RefKind string
574 Binary bool 586 Binary bool
575 Image bool 587 Image bool
576 Size int 588 Size int
577 Lines int 589 Lines int
578 Exec bool 590 Exec bool
579 Symlink bool 591 Symlink bool
580 Branches []gitutil.Ref 592 Branches []gitutil.Ref
581 CodeHTML template.HTML 593 CodeHTML template.HTML
594 Renderable bool // markdown or org: the toggle is offered
595 Rendered bool // this response shows the rendering
596 RenderedHTML template.HTML
582 }{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines, 597 }{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines,
583 entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML}) 598 entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML, renderable, rendered, renderedHTML})
584} 599}
585 600
586// releases lists tag-anchored releases with notes and assets. 601// releases lists tag-anchored releases with notes and assets.
internal/web/templates/blob.html +2 −1
@@ -5,10 +5,11 @@
5 {{template "refmenu" .}} 5 {{template "refmenu" .}}
6 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span> 6 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span>
7 <span class="spacer"></span> 7 <span class="spacer"></span>
8 <span class="actions"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}?path={{.Path}}">history</a> · {{if not .Binary}}<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blame/{{.Ref}}/{{.Path}}">blame</a> · {{end}}<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">raw</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}">edit</a>{{end}}</span> 8 <span class="actions">{{if .Renderable}}{{if .Rendered}}<strong>rendered</strong> · <a href="?view=source">source</a>{{else}}<a href="?">rendered</a> · <strong>source</strong>{{end}} · {{end}}<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}?path={{.Path}}">history</a> · {{if not .Binary}}<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blame/{{.Ref}}/{{.Path}}">blame</a> · {{end}}<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">raw</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}">edit</a>{{end}}</span>
9</div> 9</div>
10<p class="filefacts">{{if .Lines}}{{.Lines}} lines · {{end}}{{.Size}} bytes{{if .Exec}} · executable{{end}}{{if .Symlink}} · symlink{{end}}</p> 10<p class="filefacts">{{if .Lines}}{{.Lines}} lines · {{end}}{{.Size}} bytes{{if .Exec}} · executable{{end}}{{if .Symlink}} · symlink{{end}}</p>
11{{if .Image}}<div class="blobimage"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}"><img src="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}" alt="{{.Base}}"></a><p class="meta">{{.Size}} bytes</p></div> 11{{if .Image}}<div class="blobimage"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}"><img src="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}" alt="{{.Base}}"></a><p class="meta">{{.Size}} bytes</p></div>
12{{else if .Binary}}<p class="empty-note">binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p> 12{{else if .Binary}}<p class="empty-note">binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p>
13{{else if .Rendered}}<section class="readme"><div class="rendered">{{.RenderedHTML}}</div></section>
13{{else}}<div class="code">{{.CodeHTML}}</div>{{end}} 14{{else}}<div class="code">{{.CodeHTML}}</div>{{end}}
14{{end}} 15{{end}}