package httpd
import (
"embed"
"fmt"
"html/template"
"net/http"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/barerepo/server/internal/cache"
"github.com/barerepo/server/internal/gitread"
)
//go:embed templates
var templateFS embed.FS
// Version is the only number the interface shows about itself, per chapter 25.
const Version = "0.1.0"
// Source is where this program is kept, and it is absolute because an install elsewhere does not hold a copy of it.
const Source = "https://barerepo.com/barerepo/server"
// pages are parsed once at start, each defining "body" and "footleft" for layout.html.
var pages = map[string]*template.Template{}
func init() {
layout := must(templateFS.ReadFile("templates/layout.html"))
entries := must(templateFS.ReadDir("templates"))
for _, e := range entries {
if e.Name() == "layout.html" {
continue
}
body := must(templateFS.ReadFile("templates/" + e.Name()))
name := strings.TrimSuffix(e.Name(), ".html")
pages[name] = template.Must(template.New(name).Parse(string(layout) + string(body)))
}
}
func must[T any](v T, err error) T {
if err != nil {
panic(err)
}
return v
}
// render builds into memory first, because a template that fails mid-write cannot become an error page.
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data any) {
t, ok := pages[name]
if !ok {
s.oops(w, r, fmt.Errorf("no template %q", name))
return
}
var buf strings.Builder
if err := t.ExecuteTemplate(&buf, "layout", data); err != nil {
s.oops(w, r, err)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(buf.String()))
}
// chrome is what every page needs and no page thinks about.
type chrome struct {
Title string
Summary string
Version string
Source string
// Share is what a link preview shows, because the summary above describes the page to a screen reader and reads as a spec anywhere else.
Share string
// Site is the absolute origin, because a share card cannot follow a relative image.
Site string
// Card is the share image for this page, and empty means the one for the whole site.
Card string
// Assets is what the stylesheet url carries, so a changed file is a url no browser has kept.
Assets string
}
// Site is the origin a share card quotes, set once at start from the external url.
var Site string
func newChrome(title, summary string) chrome {
return chrome{Title: title, Summary: summary, Version: Version, Source: Source, Assets: assetTag, Site: Site}
}
// newCardChrome is a page whose share image says whose page it is.
func newCardChrome(title, summary, card string) chrome {
c := newChrome(title, summary)
c.Card = card
return c
}
// bit is one item in a headline, linked where barerepo has somewhere to send it and plain where it does not.
type bit struct {
Text string
Href string
// Danger is the one colour the mockups use in a headline, and only a failed build earns it.
Danger bool
}
// plain is a bit that goes nowhere, which is most of a headline.
func plain(text string) bit { return bit{Text: text} }
type tab struct {
Label string
Href string
On bool
// A tab that is not ready still shows but does not link, because a 404 is worse than nothing.
Ready bool
}
// built lists the views that exist, and is the only place that decides whether a tab links.
var built = map[string]bool{
"log": true,
"files": true,
"config": true,
"threads": true,
"runs": true,
"releases": true,
}
// repoTabs is the row under the breadcrumb, where config is barely a special case. Chapter 24.
func repoTabs(owner, name, active, branch string, threads int) []tab {
base := "/" + owner + "/" + name
if branch == "" {
branch = "HEAD"
}
labels := []struct{ key, label, href string }{
{"log", "log", base},
{"files", "files", base + "/files"},
{"threads", threadLabel(threads), base + "/threads"},
{"runs", "runs", base + "/runs"},
// The mockups draw five tabs, and none of them links to releases, so its page had no way in.
{"releases", "releases", base + "/releases"},
{"config", "config", base + "/config"},
}
out := make([]tab, 0, len(labels))
for _, l := range labels {
out = append(out, tab{
Label: l.label,
Href: l.href,
On: l.key == active,
Ready: built[l.key],
})
}
return out
}
func threadLabel(n int) string {
if n == 0 {
return "threads"
}
return fmt.Sprintf("threads %d", n)
}
// commitView is one entry on the log page.
type commitView struct {
gitread.Commit
Ago string
Summary string
Href string
// AuthorHref is the account page, where a name that is not an account still reads as a name.
AuthorHref string
// Diff says there is something to open, so a commit with no inlined hunk gets no disclosure.
Diff bool
Files []fileView
// SigNote is what the mark means, in the same words the commit page uses.
SigNote string
}
type fileView struct {
gitread.FileDiff
Hunks []hunkView
}
type hunkView struct {
Header string
Lines []lineView
}
// lineView carries the prefix as data, so the template never decides what a diff line means.
type lineView struct {
Kind byte
Prefix string
Text string
New int
CommentHref string
}
// Cache keeps rendered diffs, which chapter 25 asks for by name, keyed by the commit that made them.
var Cache *cache.Disk
func newCommitView(owner, name string, c gitread.Commit) commitView {
return commitView{
Commit: c,
Ago: ago(c.When),
Summary: changeSummary(c),
Href: "/" + owner + "/" + name + "/commit/" + c.SHA,
}
}
// SignatureNote is what the commit page says about a signature, and empty means there is none.
func SignatureNote(c gitread.Commit, account string) (string, bool) {
if !c.Signed {
return "", false
}
switch c.SigState {
case "G", "U":
// The account that published the key, because the name inside a key is typed by its owner.
if account != "" {
return "signed by " + account, false
}
return "signed by " + c.Signer, false
case "B":
return "the signature does not match this commit", true
case "X", "Y", "R":
return "signed on a key that is expired or revoked", true
}
// Nobody has published the key this was signed with, so barerepo says so and claims nothing.
return "signed", false
}
// fillDiff builds the line views, which is the work a cached body exists to avoid.
func fillDiff(v *commitView) {
v.Files = viewFiles(v.Commit.Files, nil)
for _, f := range v.Files {
if len(f.Hunks) > 0 {
v.Diff = true
return
}
}
}
func viewCommits(owner, name string, commits []gitread.Commit) []commitView {
out := make([]commitView, 0, len(commits))
for _, c := range commits {
v := newCommitView(owner, name, c)
fillDiff(&v)
out = append(out, v)
}
return out
}
// viewLog is one row per commit, every row the same, because this page draws no diff. Chapter 24.
func viewLog(owner, name string, commits []gitread.Commit) []commitView {
out := make([]commitView, 0, len(commits))
for _, c := range commits {
out = append(out, newCommitView(owner, name, c))
}
return out
}
// linkAuthors resolves the names on a page in one query, because a git name is not always an account.
func (s *Server) linkAuthors(r *http.Request, views []commitView) {
names := authorNames(views)
if len(names) == 0 || s.DB == nil {
return
}
accounts, err := s.DB.AccountsExist(r.Context(), names)
if err != nil {
// A name left plain is a smaller failure than a page that will not render.
return
}
fillAuthorHrefs(views, accounts)
}
// fillAuthorHrefs fills in the account pages, and leaves a name that is not an account as plain text.
func fillAuthorHrefs(views []commitView, accounts map[string]bool) {
for i := range views {
if accounts[views[i].Author] {
views[i].AuthorHref = "/" + views[i].Author
}
// The same words the commit page uses, so a mark never claims more than was checked.
signer := ""
if accounts[views[i].Signer] {
signer = views[i].Signer
}
views[i].SigNote, _ = SignatureNote(views[i].Commit, signer)
}
}
// authorNames is the distinct set to look up, because one query beats one per row.
func authorNames(views []commitView) []string {
seen := map[string]bool{}
var out []string
for _, v := range views {
if v.Author != "" && !seen[v.Author] {
seen[v.Author] = true
out = append(out, v.Author)
}
}
return out
}
func prefixOf(kind byte) string {
switch kind {
case '+':
return "+ "
case '-':
return "- "
default:
return " "
}
}
// changeSummary names which files changed and by how much, while few enough fit.
func changeSummary(c gitread.Commit) string {
if len(c.Files) == 0 {
return "no changes"
}
var what string
if len(c.Files) <= 2 {
names := make([]string, len(c.Files))
for i, f := range c.Files {
names[i] = f.Path
}
what = strings.Join(names, ", ")
} else {
what = fmt.Sprintf("%d files", len(c.Files))
}
return fmt.Sprintf("%s ยท +%d -%d", what, c.Add, c.Del)
}
// monthYear is the mockups' lowercase "mar 2026". Go's reference month is Jan, and a lowercase one is a literal.
const monthYear = "Jan 2006"
// month is that date, lowercased, because the mockups have no capital letters anywhere.
func month(t time.Time) string {
if t.IsZero() {
return ""
}
return strings.ToLower(t.Format(monthYear))
}
// ago is the mockups' short form, 2h or 3d, because a column of numbers scans and prose does not.
func ago(t time.Time) string {
if t.IsZero() {
return ""
}
d := time.Since(t)
switch {
case d < time.Minute:
return "now"
case d < time.Hour:
return fmt.Sprintf("%dm", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh", int(d.Hours()))
case d < 7*24*time.Hour:
return fmt.Sprintf("%dd", int(d.Hours()/24))
case d < 31*24*time.Hour:
// The profile mockup writes three weeks as 3w, and a month is where the mockups stop counting them.
return fmt.Sprintf("%dw", int(d.Hours()/24/7))
case d < 365*24*time.Hour:
return fmt.Sprintf("%dmo", int(d.Hours()/24/30))
default:
return fmt.Sprintf("%dy", int(d.Hours()/24/365))
}
}
// spoken is the sentence form the mockups use outside a column, where 40m becomes 40m ago, and now is left alone because nobody says now ago.
func spoken(since string) string {
if since == "" || since == "now" {
return since
}
return since + " ago"
}
// entryView is one row of the file tree.
type entryView struct {
gitread.Entry
Ago string
Href string
}
func viewEntries(owner, name, ref string, entries []gitread.Entry) []entryView {
base := "/" + owner + "/" + name
out := make([]entryView, 0, len(entries))
for _, e := range entries {
kind := "/file/"
if e.Dir {
kind = "/files/"
}
out = append(out, entryView{
Entry: e,
Ago: ago(e.When),
Href: base + kind + ref + "/" + e.Path,
})
}
return out
}
// fileLineView is one row of the file view: blame, number, code.
type fileLineView struct {
Number int
Text string
Blame string
}
func viewFileLines(lines []gitread.FileLine) []fileLineView {
out := make([]fileLineView, 0, len(lines))
for _, l := range lines {
blame := ""
if l.Short != "" {
blame = l.Short + " " + l.Author + " " + ago(l.When)
}
out = append(out, fileLineView{Number: l.Number, Text: l.Text, Blame: blame})
}
return out
}
// size renders bytes the way the mockups do: 1.4kb, 8.1mb.
func size(n int64) string {
switch {
case n < 1024:
return fmt.Sprintf("%db", n)
case n < 1024*1024:
return unit(float64(n)/1024, "kb")
default:
return unit(float64(n)/(1024*1024), "mb")
}
}
// unit drops the decimal once the number carries itself, which is how the mockups write 210kb and 88kb beside 1.4kb and 4.1mb.
func unit(v float64, suffix string) string {
if v < 10 {
return fmt.Sprintf("%.1f%s", v, suffix)
}
return fmt.Sprintf("%.0f%s", v, suffix)
}
// viewFiles turns diffs into rows a template need not think about, and commentOn may be nil.
func viewFiles(files []gitread.FileDiff, commentOn func(path string, line int) string) []fileView {
out := make([]fileView, 0, len(files))
for _, f := range files {
fv := fileView{FileDiff: f}
for _, h := range f.Hunks {
hv := hunkView{Header: h.Header}
for _, l := range h.Lines {
lv := lineView{Kind: l.Kind, Prefix: prefixOf(l.Kind), Text: l.Text, New: l.New}
if commentOn != nil && l.New > 0 {
lv.CommentHref = commentOn(f.Path, l.New)
}
hv.Lines = append(hv.Lines, lv)
}
fv.Hunks = append(fv.Hunks, hv)
}
out = append(out, fv)
}
return out
}
// highlight escapes first and marks after, or a source file could write markup into a result.
func highlight(text, query string) template.HTML {
var b strings.Builder
for text != "" {
// Offsets come from the text itself, because lowercasing it first moves them. U+023A shrinks.
i, n := foldIndex(text, query)
if i < 0 {
break
}
b.WriteString(template.HTMLEscapeString(text[:i]))
b.WriteString(``)
b.WriteString(template.HTMLEscapeString(text[i : i+n]))
b.WriteString(``)
text = text[i+n:]
}
b.WriteString(template.HTMLEscapeString(text))
return template.HTML(b.String())
}
// foldIndex finds query in text ignoring case, and answers in the byte offsets text uses.
func foldIndex(text, query string) (int, int) {
if query == "" {
return -1, 0
}
// A range over a string yields rune starts, so every offset returned is a whole character.
for i := range text {
if n := foldPrefix(text[i:], query); n > 0 {
return i, n
}
}
return -1, 0
}
// foldPrefix reports how many bytes of s match query ignoring case, which need not be len(query).
func foldPrefix(s, query string) int {
used := 0
for _, q := range query {
r, n := utf8.DecodeRuneInString(s[used:])
if n == 0 || unicode.ToLower(r) != unicode.ToLower(q) {
return 0
}
used += n
}
return used
}