Commit c2d8110534
Verified · cmc ci/build: success ci/test: success ci/vuln: success
Layout: unified · split
e2e/blobrender_test.go added +55
| @@ -0,0 +1,55 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 12 | func 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 | 547 | if !binary && !image { |
| 548 | 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 | 562 | cs := crumbs(p, "blob", filePath) |
| 551 | 563 | base := "" |
| 552 | 564 | if len(cs) > 0 { |
| @@ -566,21 +578,24 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) { | ||
| 566 | 578 | entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath) |
| 567 | 579 | s.render(w, "blob.html", struct { |
| 568 | 580 | 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 | |
| 582 | 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 | 601 | // releases lists tag-anchored releases with notes and assets. |
internal/web/templates/blob.html +2 −1
| @@ -5,10 +5,11 @@ | ||
| 5 | 5 | {{template "refmenu" .}} |
| 6 | 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 | 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 | 9 | </div> |
| 10 | 10 | <p class="filefacts">{{if .Lines}}{{.Lines}} lines · {{end}}{{.Size}} bytes{{if .Exec}} · executable{{end}}{{if .Symlink}} · symlink{{end}}</p> |
| 11 | 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 | 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 | 14 | {{else}}<div class="code">{{.CodeHTML}}</div>{{end}} |
| 14 | 15 | {{end}} |