pages.go
package main
import (
"bytes"
"errors"
"fmt"
"html/template"
"mime"
"net/http"
"path"
"sort"
"strings"
"github.com/go-enry/go-enry/v2"
"vitrine/display"
)
// This file builds the browse pages: HTML content fragments (html/template throughout,
// so everything that came out of a repo is escaped) placed into pageData.Content,
// with the gallery-sketchbook chrome coming from chrome.go and markdown rendering
// from the display package.
const siteName = "socle"
// logLimit caps the commit log page; history older than this is reachable by
// following parent shas (and a real pager can come later if it's ever missed).
const logLimit = 100
// render executes one content-fragment template; a failure is an internal error (500).
func (s *server) render(w http.ResponseWriter, tmpl *template.Template, data any, pd pageData) {
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
httpError(w, err)
return
}
pd.Content = template.HTML(buf.String())
s.renderPage(w, pd)
}
// treeHref builds the browse URL for a path at a ref; dirs get a trailing slash by the caller.
// Escaping is left to html/template's URL normalization in the templates.
func treeHref(name, ref, pth string) string {
h := "/" + name + "/tree/" + ref
if pth != "" {
h += "/" + pth
}
return h
}
func rawHref(name, ref, pth string) string {
return "/" + name + "/raw/" + ref + "/" + pth
}
// isImagePath reports whether a filename looks like an image a browser renders natively:
// those inline via the raw URL instead of getting a text or download treatment.
func isImagePath(pth string) bool {
switch strings.ToLower(path.Ext(pth)) {
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".ico":
return true
}
return false
}
// rawContentType picks the Content-Type for a raw blob response, by extension only
// (blob content is not consulted). Markdown is forced to text/plain so browsers display
// rather than download it. Empty means "let net/http sniff the first bytes".
func rawContentType(pth string) string {
ext := strings.ToLower(path.Ext(pth))
if ext == ".md" {
return "text/plain; charset=utf-8"
}
return mime.TypeByExtension(ext)
}
// crumbLink is one segment of the breadcrumb nav; an empty Href renders as plain text.
type crumbLink = display.Crumb
// repoCrumbs starts a breadcrumb trail: site, then the repo (its full name as one crumb,
// slashes and all: intermediate name segments are not pages).
func repoCrumbs(name string, more ...crumbLink) []display.Crumb {
cs := []display.Crumb{{Name: siteName, Href: "/"}, {Name: name, Href: "/" + name + "/"}}
cs = append(cs, more...)
cs[len(cs)-1].Href = "" // the last crumb is the page being viewed
return cs
}
// ---- index ----
// The entrance: every repo is an exhibit stand -- the name atop a pedestal
// (the ground glyph's family), the description as its placard, and the newest
// commit subject as a one-line "now showing" caption in soft ink.
var indexTmpl = template.Must(template.New("index").Parse(`<h1>repositories</h1>
<ul class="entrance">
{{range .}}<li class="stand">
<p class="name"><a href="/{{.Name}}/">{{.Name}}</a></p>
<div class="pedestal"></div>
{{if or .Description .LangColor}}<p class="placard">{{if .LangColor}}<span class="swatch" style="background:{{.LangColor}}" title="{{.Lang}}"></span>{{end}}{{.Description}}</p>
{{end}}{{if .Head}}<p class="showing" title="latest commit on {{.Branch}}">{{.Head}}</p>
{{end}}</li>
{{end}}</ul>
`))
type indexRow struct {
Name string
Description string
Branch string // the default branch the caption was read from
Head string // subject of the newest commit on the default branch; empty if none
Lang string // dominant language at that commit; empty if nothing classified
LangColor string // its linguist color; the placard dot only shows when one exists
}
func (s *server) serveIndex(w http.ResponseWriter, r *http.Request) {
var rows []indexRow
for _, name := range sortedNames(s.repos) {
rp := s.repos[name]
row := indexRow{Name: name, Description: rp.description, Branch: rp.defaultBranch()}
// Enrichment is best-effort, matching the caption's existing stance:
// the entrance still stands when one repo cannot answer.
if cs, err := rp.commits(row.Branch, 1); err == nil && len(cs) > 0 {
row.Head = cs[0].subject
if stats, err := s.langStats(rp, cs[0].hash); err == nil && len(stats) > 0 {
row.Lang = stats[0].name
row.LangColor = enry.GetColor(stats[0].name)
}
}
rows = append(rows, row)
}
s.render(w, indexTmpl, rows, pageData{
Title: siteName,
Crumbs: []display.Crumb{{Name: siteName}},
})
}
// ---- repo home ----
// The repo home: the README is the exhibit, framed with a plaque beneath;
// the clone line and the branch list stand below it as the arc's jump targets.
var homeTmpl = template.Must(template.New("home").Parse(`{{if .Description}}<p class="tagline">{{.Description}}</p>
{{end}}{{if .Empty}}<p>This repository has no commits yet.</p>
<p class="clone" id="clone">clone: <code>git clone {{.CloneURL}}</code></p>
{{else}}{{if .Readme}}<div class="exhibit">{{.Readme}}</div>
<p class="plaque">{{.ReadmeName}} · {{.ReadmeSize}} · {{.Ref}} = <code>{{.ShortSha}}</code></p>
{{end}}{{if .Langs}}<div class="langmeter boxed">
<div class="langbar">{{range .Langs}}<span class="seg{{if not .Color}} other{{end}}" style="flex-grow:{{.Pct}}{{if .Color}};background:{{.Color}}{{end}}" title="{{.Name}} {{.Pct}}%"></span>{{end}}</div>
<p class="langchips">{{range .Langs}}<span class="chip"><span class="swatch{{if not .Color}} other{{end}}"{{if .Color}} style="background:{{.Color}}"{{end}}></span>{{.Name}} {{.Pct}}%</span>{{end}}</p>
</div>
{{end}}<p class="clone boxed" id="clone">clone: <code>git clone {{.CloneURL}}</code></p>
<p id="branches">branches: {{range $i, $b := .Branches}}{{if $i}}, {{end}}<a href="{{$b.Href}}">{{$b.Name}}</a>{{end}}{{if .Tags}}
· tags: {{range $i, $t := .Tags}}{{if $i}}, {{end}}<a href="{{$t.Href}}">{{$t.Name}}</a>{{end}}{{end}}</p>
{{end}}`))
type refLink struct {
Name string
Href string
}
type homeData struct {
Description string
CloneURL string
Empty bool // no commits yet: nothing to browse
Branches []refLink
Tags []refLink
Readme template.HTML
ReadmeName string
ReadmeSize string // human-readable; the plaque is a label, not a listing
Ref string
ShortSha string
Langs []langSeg
}
// meterMax caps the bar's segments; everything past it folds into "other".
const meterMax = 8
// langSeg is one segment of the language meter, as the template wants it.
type langSeg struct {
Name string
Pct string // share of counted bytes, one decimal; doubles as the segment's flex-grow
Color string // linguist's conventional hex, a swatch only; empty wears the neutral class
}
// langSegments shapes language stats for the meter: the top languages by bytes,
// with the tail -- and anything whose share rounds below a visible 0.1% -- folded
// into one trailing "other" segment; an "other" that itself rounds invisible is
// dropped rather than shown as a 0.0% chip. No classified bytes means no meter.
func langSegments(stats []langStat) []langSeg {
var total int64
for _, st := range stats {
total += st.bytes
}
if total == 0 {
return nil
}
pct := func(bytes int64) string {
return fmt.Sprintf("%.1f", float64(bytes)*100/float64(total))
}
var segs []langSeg
var other int64
for i, st := range stats {
if i >= meterMax-1 && len(stats) > meterMax || pct(st.bytes) == "0.0" {
other += st.bytes
continue
}
segs = append(segs, langSeg{Name: st.name, Pct: pct(st.bytes), Color: enry.GetColor(st.name)})
}
if p := pct(other); other > 0 && p != "0.0" {
segs = append(segs, langSeg{Name: "other", Pct: p})
}
return segs
}
func (s *server) serveHome(w http.ResponseWriter, r *http.Request, rp *repo) {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
db := rp.defaultBranch()
d := homeData{
Description: rp.description,
CloneURL: scheme + "://" + r.Host + "/" + rp.name,
Ref: db,
}
branches, tags, err := rp.refs()
if err != nil {
httpError(w, err)
return
}
for _, b := range branches {
d.Branches = append(d.Branches, refLink{b.name, treeHref(rp.name, b.name, "") + "/"})
}
for _, t := range tags {
d.Tags = append(d.Tags, refLink{t.name, treeHref(rp.name, t.name, "") + "/"})
}
sha, err := rp.resolveCommit("HEAD")
switch {
case err == nil:
var rm readmeInfo
d.Readme, rm, err = s.renderReadme(rp, db, sha)
if err != nil {
httpError(w, err)
return
}
d.ReadmeName, d.ReadmeSize, d.ShortSha = rm.name, humanSize(rm.size), shortSha(sha)
stats, err := s.langStats(rp, sha)
if err != nil {
httpError(w, err)
return
}
d.Langs = langSegments(stats)
case errors.Is(err, errNotFound):
d.Empty = true // an unborn HEAD: a freshly-init'd repo is fine to show, just bare
default:
httpError(w, err)
return
}
pd := pageData{
Title: rp.name,
Crumbs: repoCrumbs(rp.name),
}
if !d.Empty {
pd.Dawn = true
pd.Arc = dawnArc([]arcButton{
{Label: "files", Title: "browse the tree at " + db, Href: treeHref(rp.name, db, "") + "/"},
{Label: "log", Title: "commit log of " + db, Href: "/" + rp.name + "/log/" + db},
{Label: "branches", Title: "branches and tags", Href: "#branches"},
{Label: "clone", Title: "the clone command", Href: "#clone"},
})
}
s.render(w, homeTmpl, d, pd)
}
// readmeInfo is what the home plaque says about the README it accompanies.
type readmeInfo struct {
name string
size int64
}
// renderReadme finds and renders the README blob in the root tree at a commit, if any:
// the first root entry named readme.md (any case) renders as markdown;
// failing that, readme or readme.txt (any case) shows preformatted. No README is fine.
// ref is used for link targets so browsing stays on the branch the viewer arrived at.
func (s *server) renderReadme(rp *repo, ref, sha string) (template.HTML, readmeInfo, error) {
entries, err := rp.lsTree(sha, "")
if err != nil {
return "", readmeInfo{}, err
}
var info readmeInfo
markdown := false
for _, e := range entries {
if e.typ != "blob" || e.size > display.MaxRenderSize {
continue
}
switch strings.ToLower(e.name) {
case "readme.md":
info, markdown = readmeInfo{e.name, e.size}, true
case "readme", "readme.txt":
if info.name == "" {
info = readmeInfo{e.name, e.size}
}
}
}
if info.name == "" {
return "", info, nil
}
var buf bytes.Buffer
if err := rp.catBlob(sha, info.name, &buf); err != nil {
return "", info, err
}
if markdown {
html, err := display.Markdown(buf.Bytes())
return html, info, err
}
var out bytes.Buffer
if err := preTmpl.Execute(&out, buf.String()); err != nil {
return "", info, err
}
return template.HTML(out.String()), info, nil
}
// ---- tree listing ----
// The catalogue: listings keep their tabular bones, with light hand-ruled row
// separators and a drawer-tab mark ahead of directory names (the mark is the
// link's own background, so it is part of the tap target).
var treeTmpl = template.Must(template.New("tree").Parse(`<h1>{{.Title}}</h1>
<table class="tree">
<tbody>
{{range .Rows}}<tr><td class="name{{if .Dir}} dir{{end}}">{{if .Href}}<a href="{{.Href}}">{{.Name}}</a>{{else}}{{.Name}}{{end}}</td><td class="size">{{.Size}}</td></tr>
{{end}}</tbody>
</table>
`))
type treeRow struct {
Name string
Href string // empty renders unlinked (submodules)
Size string // blob size in bytes; empty for non-blobs
Dir bool // directories wear the drawer-tab mark; the ".." row does not (it is nav, not contents)
}
type treeData struct {
Title string
Rows []treeRow
}
func (s *server) serveTreeListing(w http.ResponseWriter, rp *repo, ref, sha, pth string) {
entries, err := rp.lsTree(sha, pth)
if err != nil {
httpError(w, err)
return
}
d := treeData{Title: pth}
if pth == "" {
d.Title = rp.name
}
if pth != "" {
d.Rows = append(d.Rows, treeRow{Name: "..", Href: treeHref(rp.name, ref, parentDir(pth)) + "/"})
}
// Directories list before files (the usual forge convention), each group in git's own order.
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].typ == "tree" && entries[j].typ != "tree"
})
for _, e := range entries {
full := e.name
if pth != "" {
full = pth + "/" + e.name
}
row := treeRow{Name: e.name}
switch e.typ {
case "tree":
row.Name += "/"
row.Href = treeHref(rp.name, ref, full) + "/"
row.Dir = true
case "blob":
row.Href = treeHref(rp.name, ref, full)
row.Size = humanSize(e.size)
default: // a submodule pointer: nothing here to link to
row.Name += " @ " + shortSha(e.hash)
}
d.Rows = append(d.Rows, row)
}
btns := []arcButton{}
if pth != "" {
btns = append(btns, arcButton{Label: "up", Title: "parent directory", Href: treeHref(rp.name, ref, parentDir(pth)) + "/"})
}
btns = append(btns,
arcButton{Label: "log", Title: "commit log of " + shortSha(ref), Href: "/" + rp.name + "/log/" + ref},
arcButton{Label: "home", Title: "repo home", Href: "/" + rp.name + "/"},
)
s.render(w, treeTmpl, d, pageData{
Title: pageTitle(rp.name, ref, pth),
Crumbs: treeCrumbs(rp.name, ref, pth),
Arc: cornerArc(btns),
})
}
// treeCrumbs: site / repo / ref / path segments, each linking to its listing.
func treeCrumbs(name, ref, pth string, more ...crumbLink) []display.Crumb {
cs := []crumbLink{{Name: ref, Href: treeHref(name, ref, "") + "/"}}
if pth != "" {
segs := strings.Split(pth, "/")
for i, seg := range segs {
cs = append(cs, crumbLink{Name: seg, Href: treeHref(name, ref, strings.Join(segs[:i+1], "/")) + "/"})
}
}
return repoCrumbs(name, append(cs, more...)...)
}
// humanSize renders a byte count the way a listing wants it: exact while it is small,
// then one decimal of the binary unit. (The raw view's Content-Length keeps the exact number.)
func humanSize(n int64) string {
if n < 1024 {
return fmt.Sprintf("%d B", n)
}
v, unit := float64(n)/1024, "KiB"
for _, u := range []string{"MiB", "GiB", "TiB"} {
if v < 1024 {
break
}
v, unit = v/1024, u
}
return fmt.Sprintf("%.1f %s", v, unit)
}
// parentDir is path.Dir for tree paths: "" (the root), not ".", for a top-level entry.
func parentDir(pth string) string {
if d := path.Dir(pth); d != "." {
return d
}
return ""
}
func pageTitle(name, ref, pth string) string {
t := name
if pth != "" {
t += "/" + pth
}
return t + " @ " + shortSha(ref)
}
// shortSha abbreviates full hex shas for display; anything else (branch names) passes through.
func shortSha(ref string) string {
if len(ref) == 40 && !strings.ContainsFunc(ref, func(c rune) bool { return !isHex(c) }) {
return ref[:10]
}
return ref
}
func isHex(c rune) bool {
return c >= '0' && c <= '9' || c >= 'a' && c <= 'f'
}
// ---- file views ----
// File views are exhibits too: content in a rough frame, the plaque beneath carrying
// path, size, and the commit the blob was read at.
var (
preTmpl = template.Must(template.New("pre").Parse(`<pre class="file">{{.}}</pre>`))
plaqueTmpl = template.Must(template.New("plaque").Parse(`<p class="plaque">{{.Path}} · {{.Size}} · <code>{{.ShortSha}}</code></p>
`))
imageTmpl = template.Must(template.New("image").Parse(`<h1>{{.Name}}</h1>
<div class="exhibit"><p><img class="file" src="{{.RawHref}}" alt="{{.Name}}"></p></div>
{{template "plaque" .Plaque}}`))
fileTmpl = template.Must(template.New("file").Parse(`<h1>{{.Name}}</h1>
<div class="exhibit">{{.Body}}</div>
{{template "plaque" .Plaque}}`))
rawLinkTmpl = template.Must(template.New("rawlink").Parse(`<h1>{{.Name}}</h1>
<p>{{.Size}} bytes of {{.Kind}}; <a href="{{.RawHref}}">view raw</a>.</p>
`))
)
func init() {
// The plaque fragment is shared by the file-view templates.
for _, t := range []*template.Template{imageTmpl, fileTmpl} {
template.Must(t.AddParseTree("plaque", plaqueTmpl.Tree))
}
}
// plaqueData is the museum label under a file exhibit.
type plaqueData struct {
Path string
Size string
ShortSha string
}
// serveFileView shows a blob as a page: markdown rendered, text preformatted, images inlined,
// and anything binary or oversized as a link to the raw view.
func (s *server) serveFileView(w http.ResponseWriter, rp *repo, ref, sha, pth string) {
name := path.Base(pth)
raw := rawHref(rp.name, ref, pth)
pd := pageData{
Title: pageTitle(rp.name, ref, pth),
Crumbs: treeCrumbs(rp.name, ref, parentDir(pth), crumbLink{Name: name}),
Arc: cornerArc([]arcButton{
{Label: "raw", Title: "the raw bytes", Href: raw},
{Label: "up", Title: "containing directory", Href: treeHref(rp.name, ref, parentDir(pth)) + "/"},
{Label: "log", Title: "commit log of " + shortSha(ref), Href: "/" + rp.name + "/log/" + ref},
{Label: "home", Title: "repo home", Href: "/" + rp.name + "/"},
}),
}
size, err := rp.blobSize(sha, pth)
if err != nil {
httpError(w, err)
return
}
plq := plaqueData{Path: pth, Size: humanSize(size), ShortSha: shortSha(sha)}
if isImagePath(pth) {
s.render(w, imageTmpl, struct {
Name, RawHref string
Plaque plaqueData
}{name, raw, plq}, pd)
return
}
rawLink := func(kind string) {
s.render(w, rawLinkTmpl, struct {
Name, Kind, RawHref string
Size int64
}{name, kind, raw, size}, pd)
}
if size > display.MaxRenderSize {
rawLink("file, too large to display inline")
return
}
var buf bytes.Buffer
if err := rp.catBlob(sha, pth, &buf); err != nil {
httpError(w, err)
return
}
if looksBinary(buf.Bytes()) {
rawLink("binary data")
return
}
if strings.ToLower(path.Ext(pth)) == ".md" {
body, err := display.Markdown(buf.Bytes())
if err != nil {
httpError(w, err)
return
}
s.render(w, fileTmpl, struct {
Name string
Body template.HTML
Plaque plaqueData
}{name, body, plq}, pd)
return
}
var pre bytes.Buffer
if err := preTmpl.Execute(&pre, buf.String()); err != nil {
httpError(w, err)
return
}
s.render(w, fileTmpl, struct {
Name string
Body template.HTML
Plaque plaqueData
}{name, template.HTML(pre.String()), plq}, pd)
}
// looksBinary applies the classic cheap heuristic: a NUL byte in the leading bytes means
// "not text". Content that dodges this (a NUL late in a big file) still renders;
// it is just ugly, not harmful, since everything goes through template escaping anyway.
func looksBinary(b []byte) bool {
if len(b) > 8000 {
b = b[:8000]
}
return bytes.IndexByte(b, 0) >= 0
}
// ---- log ----
// The provenance ledger: no table. Each commit is a row under a light hand-ruled
// line -- the subject as prose, free to wrap, and a soft second line carrying the
// sha (a chip-outlined button to the commit page), author, and absolute date.
// This shape is also why the page survives phone widths.
var logTmpl = template.Must(template.New("log").Parse(`<h1>log: {{.Title}}</h1>
<p class="provenance">provenance · newest first</p>
<ul class="ledger">
{{range .Rows}}<li>
<p class="subject"><a href="{{.Href}}">{{.Subject}}</a></p>
<p class="attrib"><a class="sha" href="{{.Href}}">{{.Sha}}</a> {{.Author}} · {{.Date}}</p>
</li>
{{end}}</ul>
{{if .Capped}}<p>Showing the {{.Limit}} most recent commits.</p>
{{end}}`))
type logRow struct {
Sha string
Href string
Date string
Author string
Subject string
}
type logData struct {
Title string
Rows []logRow
Capped bool
Limit int
}
// renderLog writes the log page. Commit shas link to the tree at that commit:
// commit-diff pages are a later stretch goal, and browsing the snapshot is useful today.
func (s *server) renderLog(w http.ResponseWriter, rp *repo, ref string, cs []commitInfo) {
d := logData{Title: shortSha(ref), Capped: len(cs) == logLimit, Limit: logLimit}
for _, c := range cs {
d.Rows = append(d.Rows, logRow{
Sha: shortSha(c.hash),
Href: "/" + rp.name + "/commit/" + c.hash,
Date: c.time.Format("2006-01-02"),
Author: c.author,
Subject: c.subject,
})
}
s.render(w, logTmpl, d, pageData{
Title: rp.name + " log @ " + shortSha(ref),
Crumbs: repoCrumbs(rp.name, crumbLink{Name: "log"}, crumbLink{Name: shortSha(ref)}),
Arc: cornerArc([]arcButton{
{Label: "files", Title: "browse the tree at " + shortSha(ref), Href: treeHref(rp.name, ref, "") + "/"},
{Label: "home", Title: "repo home", Href: "/" + rp.name + "/"},
}),
})
}
// ---- commit ----
var commitTmpl = template.Must(template.New("commit").Parse(`<h1>{{.Subject}}</h1>
<p>commit <code>{{.Sha}}</code>{{range .Parents}} · parent <a href="{{.Href}}"><code>{{.Name}}</code></a>{{end}}
· <a href="{{.TreeHref}}">browse this tree</a><br>
{{.Author}}, {{.Date}}</p>
{{if .Rest}}<pre class="file">{{.Rest}}</pre>
{{end}}{{if .Diff}}<pre class="diff">{{.Diff}}</pre>
{{else}}<p>No changes against the first parent.</p>
{{end}}{{if .Truncated}}<p>The diff is larger than shown; it was cut off here.</p>
{{end}}`))
type commitData struct {
Subject string
Rest string // the message past its subject line, if any
Sha string
Author string
Date string
Parents []refLink
TreeHref string
Diff template.HTML
Truncated bool
}
// renderCommit writes the commit page: subject, metadata, the rest of the message, the diff.
func (s *server) renderCommit(w http.ResponseWriter, rp *repo, c commitDetail, diff []byte) {
subject, rest, _ := strings.Cut(c.message, "\n")
d := commitData{
Subject: subject,
Rest: strings.TrimSpace(rest),
Sha: c.hash,
Author: c.author,
Date: c.time.Format("2006-01-02 15:04:05 UTC"),
TreeHref: treeHref(rp.name, c.hash, "") + "/",
}
for _, p := range c.parents {
d.Parents = append(d.Parents, refLink{shortSha(p), "/" + rp.name + "/commit/" + p})
}
d.Diff, d.Truncated = diffHTML(diff)
s.render(w, commitTmpl, d, pageData{
Title: rp.name + " commit " + shortSha(c.hash),
Crumbs: repoCrumbs(rp.name, crumbLink{Name: "commit"}, crumbLink{Name: shortSha(c.hash)}),
Arc: cornerArc([]arcButton{
{Label: "files", Title: "browse the tree at this commit", Href: treeHref(rp.name, c.hash, "") + "/"},
{Label: "log", Title: "the log from this commit", Href: "/" + rp.name + "/log/" + c.hash},
{Label: "home", Title: "repo home", Href: "/" + rp.name + "/"},
}),
})
}
// diffHTML escapes a unified diff and wraps its notable lines in classed spans for color:
// added, removed, hunk headers, and everything else that isn't patch body (per-file header
// material -- in a unified diff, body lines only ever start with '+', '-', ' ', or '\\').
// A diff beyond MaxRenderSize is cut at a line boundary and flagged truncated.
func diffHTML(diff []byte) (html template.HTML, truncated bool) {
if len(diff) > display.MaxRenderSize {
cut := bytes.LastIndexByte(diff[:display.MaxRenderSize], '\n')
if cut < 0 {
cut = 0
}
diff, truncated = diff[:cut], true
}
var sb strings.Builder
for _, line := range strings.Split(strings.TrimSuffix(string(diff), "\n"), "\n") {
class := ""
switch {
case strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---"):
class = "d-file"
case strings.HasPrefix(line, "@@"):
class = "d-hunk"
case strings.HasPrefix(line, "+"):
class = "d-add"
case strings.HasPrefix(line, "-"):
class = "d-del"
case line == "" || line[0] == ' ' || line[0] == '\\':
// patch body context: no class
default:
class = "d-file"
}
if class != "" {
sb.WriteString(`<span class="` + class + `">`)
sb.WriteString(template.HTMLEscapeString(line))
sb.WriteString("</span>\n")
} else {
sb.WriteString(template.HTMLEscapeString(line))
sb.WriteString("\n")
}
}
return template.HTML(strings.TrimSuffix(sb.String(), "\n")), truncated
}
pages.go · 23.2 KiB · 94a8b54dd5