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 }