pages.go
package main
import (
"bytes"
"errors"
"fmt"
"html/template"
"log"
"mime"
"net/http"
"path"
"sort"
"strings"
"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 display.PageData.Content,
// with chrome, crumbs, and theme coming 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
// socleCSS is appended after display.StyleCSS in /-/style.css: just enough for
// socle's own tables and file views, staying in vitrine's visual family.
const socleCSS = `
/* socle additions: listing and log tables, file views. */
article table.tree td, article table.tree th,
article table.log td, article table.log th { border: none; padding: .15rem .6rem; }
article table.tree tbody tr:hover, article table.log tbody tr:hover { background: var(--well); }
article th { text-align: left; }
/* Table links skip the underline: in a column of links it is pure noise;
the accent color and the row hover carry the affordance. Hover restores it. */
article table.tree a, article table.log a { text-decoration: none; }
article table.tree a:hover, article table.log a:hover { text-decoration: underline; }
article td.name { white-space: nowrap; }
article td.size { text-align: right; color: var(--ink-soft); white-space: nowrap; }
article td.date { color: var(--ink-soft); white-space: nowrap; }
article td.soft { color: var(--ink-soft); }
article td.hash a { font-family: monospace; }
article p.clone code { user-select: all; }
article img.file { max-width: 100%; }
article pre.file { tab-size: 4; }
`
// renderPage fills in the site-wide theme and writes the full page.
func (s *server) renderPage(w http.ResponseWriter, pd display.PageData) {
pd.ThemeName = s.theme.Name
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := display.Page(w, pd); err != nil {
// Headers are gone already; leave a trace in the body and the log.
log.Print("socle: ", err)
fmt.Fprintf(w, "\n<!-- template error: %v -->", err)
}
}
// 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 display.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 ----
var indexTmpl = template.Must(template.New("index").Parse(`<h1>repositories</h1>
<table class="log">
<tbody>
{{range .}}<tr><td class="name"><a href="/{{.Name}}/">{{.Name}}</a></td><td>{{.Description}}</td><td class="soft">{{.Head}}</td></tr>
{{end}}</tbody>
</table>
`))
type indexRow struct {
Name string
Description string
Head string // subject of the newest commit on the default branch; empty if none
}
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}
if cs, err := rp.commits(rp.defaultBranch(), 1); err == nil && len(cs) > 0 {
row.Head = cs[0].subject
}
rows = append(rows, row)
}
s.render(w, indexTmpl, rows, display.PageData{
Title: siteName,
Crumbs: []display.Crumb{{Name: siteName}},
})
}
// ---- repo home ----
var homeTmpl = template.Must(template.New("home").Parse(`{{if .Description}}<p>{{.Description}}</p>
{{end}}<p class="clone">clone: <code>git clone {{.CloneURL}}</code></p>
{{if .Empty}}<p>This repository has no commits yet.</p>
{{else}}<p><a href="{{.FilesHref}}">files</a> · <a href="{{.LogHref}}">log</a>
· 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}}{{.Readme}}`))
type refLink struct {
Name string
Href string
}
type homeData struct {
Description string
CloneURL string
Empty bool // no commits yet: nothing to browse
FilesHref string
LogHref string
Branches []refLink
Tags []refLink
Readme template.HTML
}
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,
FilesHref: treeHref(rp.name, db, "") + "/",
LogHref: "/" + rp.name + "/log/" + 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:
d.Readme, err = s.renderReadme(rp, db, sha)
if err != nil {
httpError(w, err)
return
}
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
}
s.render(w, homeTmpl, d, display.PageData{
Title: rp.name,
Crumbs: repoCrumbs(rp.name),
})
}
// 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, error) {
entries, err := rp.lsTree(sha, "")
if err != nil {
return "", err
}
name, markdown := "", false
for _, e := range entries {
if e.typ != "blob" || e.size > display.MaxRenderSize {
continue
}
switch strings.ToLower(e.name) {
case "readme.md":
name, markdown = e.name, true
case "readme", "readme.txt":
if name == "" {
name = e.name
}
}
}
if name == "" {
return "", nil
}
var buf bytes.Buffer
if err := rp.catBlob(sha, name, &buf); err != nil {
return "", err
}
if markdown {
return display.Markdown(buf.Bytes())
}
var out bytes.Buffer
if err := preTmpl.Execute(&out, buf.String()); err != nil {
return "", err
}
return template.HTML(out.String()), nil
}
// ---- tree listing ----
var treeTmpl = template.Must(template.New("tree").Parse(`<h1>{{.Title}}</h1>
<table class="tree">
<tbody>
{{range .Rows}}<tr><td class="name">{{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
}
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) + "/"
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)
}
s.render(w, treeTmpl, d, display.PageData{
Title: pageTitle(rp.name, ref, pth),
Crumbs: treeCrumbs(rp.name, ref, pth),
})
}
// 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 ----
var (
preTmpl = template.Must(template.New("pre").Parse(`<pre class="file">{{.}}</pre>`))
imageTmpl = template.Must(template.New("image").Parse(`<h1>{{.Name}}</h1>
<p><img class="file" src="{{.RawHref}}" alt="{{.Name}}"></p>
`))
fileTmpl = template.Must(template.New("file").Parse(`<h1>{{.Name}}</h1>
{{.Body}}`))
rawLinkTmpl = template.Must(template.New("rawlink").Parse(`<h1>{{.Name}}</h1>
<p>{{.Size}} bytes of {{.Kind}}; <a href="{{.RawHref}}">view raw</a>.</p>
`))
)
// 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 := display.PageData{
Title: pageTitle(rp.name, ref, pth),
Crumbs: treeCrumbs(rp.name, ref, parentDir(pth), crumbLink{Name: name}),
RawHref: raw,
}
if isImagePath(pth) {
s.render(w, imageTmpl, struct{ Name, RawHref string }{name, raw}, pd)
return
}
size, err := rp.blobSize(sha, pth)
if err != nil {
httpError(w, err)
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
}{name, body}, 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
}{name, template.HTML(pre.String())}, 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 ----
var logTmpl = template.Must(template.New("log").Parse(`<h1>log: {{.Title}}</h1>
<table class="log">
<thead><tr><th>commit</th><th>date</th><th>author</th><th>subject</th></tr></thead>
<tbody>
{{range .Rows}}<tr><td class="hash"><a href="{{.Href}}">{{.Sha}}</a></td><td class="date">{{.Date}}</td><td>{{.Author}}</td><td>{{.Subject}}</td></tr>
{{end}}</tbody>
</table>
{{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: treeHref(rp.name, c.hash, "") + "/",
Date: c.time.Format("2006-01-02"),
Author: c.author,
Subject: c.subject,
})
}
s.render(w, logTmpl, d, display.PageData{
Title: rp.name + " log @ " + shortSha(ref),
Crumbs: repoCrumbs(rp.name, crumbLink{Name: "log"}, crumbLink{Name: shortSha(ref)}),
})
}
pages.go · 15.1 KiB · 30b113ea56