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) {
547547 if !binary && !image {
548548 codeHTML = highlight(filePath, data)
549549 }
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 }
550562 cs := crumbs(p, "blob", filePath)
551563 base := ""
552564 if len(cs) > 0 {
@@ -566,21 +578,24 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
566578 entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath)
567579 s.render(w, "blob.html", struct {
568580 repoPage
569 Crumbs []crumb
570 Base string
571 Path string
572 DirPath string
573 RefKind string
574 Binary bool
575 Image bool
576 Size int
577 Lines int
578 Exec bool
579 Symlink bool
580 Branches []gitutil.Ref
581 CodeHTML template.HTML
581 Crumbs []crumb
582 Base string
583 Path string
584 DirPath string
585 RefKind string
586 Binary bool
587 Image bool
588 Size int
589 Lines int
590 Exec bool
591 Symlink bool
592 Branches []gitutil.Ref
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
582597 }{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})
584599}
585600
586601// releases lists tag-anchored releases with notes and assets.
internal/web/templates/blob.html +2 −1
@@ -5,10 +5,11 @@
55 {{template "refmenu" .}}
66 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span>
77 <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>
99</div>
1010<p class="filefacts">{{if .Lines}}{{.Lines}} lines · {{end}}{{.Size}} bytes{{if .Exec}} · executable{{end}}{{if .Symlink}} · symlink{{end}}</p>
1111{{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>
1212{{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>
1314{{else}}<div class="code">{{.CodeHTML}}</div>{{end}}
1415{{end}}