Commit 7b6134a766
Verified · cmc ci/build: success
e2e/facts_test.go added +96
| @@ -0,0 +1,96 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "os" | |
| 5 | "path/filepath" | |
| 6 | "strings" | |
| 7 | "testing" | |
| 8 | ) | |
| 9 | ||
| 10 | // TestRepoFacts covers the repository summary: counts, license, languages, | |
| 11 | // and contributors resolved to accounts where the email is verified. | |
| 12 | func TestRepoFacts(t *testing.T) { | |
| 13 | inst := startInstance(t) | |
| 14 | aliceKey := inst.newKey(t, "alice") | |
| 15 | inst.admin(t, "admin", "user", "create", "alice", | |
| 16 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | |
| 17 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 18 | t.Fatalf("repo create: %s", errOut) | |
| 19 | } | |
| 20 | ||
| 21 | work := t.TempDir() | |
| 22 | env := inst.gitEnv(aliceKey) | |
| 23 | aliceEnv := append(append([]string{}, env...), | |
| 24 | "GIT_AUTHOR_NAME=Alice", "GIT_AUTHOR_EMAIL=alice@example.test") | |
| 25 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | |
| 26 | dir := filepath.Join(work, "w") | |
| 27 | ||
| 28 | os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644) | |
| 29 | os.WriteFile(filepath.Join(dir, "run.sh"), []byte("#!/bin/sh\necho hi\n"), 0o644) | |
| 30 | os.WriteFile(filepath.Join(dir, "LICENSE"), []byte(zeroBSD), 0o644) | |
| 31 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 32 | mustGit(t, dir, env, "add", ".") | |
| 33 | mustGit(t, dir, aliceEnv, "commit", "-q", "-m", "one") | |
| 34 | // A second commit by someone with no account here. | |
| 35 | os.WriteFile(filepath.Join(dir, "extra.go"), []byte("package main\n"), 0o644) | |
| 36 | mustGit(t, dir, env, "add", ".") | |
| 37 | mustGit(t, dir, env, "commit", "-q", "-m", "two") | |
| 38 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 39 | mustGit(t, dir, env, "tag", "v0.1.0") | |
| 40 | mustGit(t, dir, env, "push", "-q", "origin", "v0.1.0") | |
| 41 | ||
| 42 | status, body := inst.get(t, "/alice/app") | |
| 43 | if status != 200 { | |
| 44 | t.Fatalf("repo home: %d", status) | |
| 45 | } | |
| 46 | for _, want := range []string{ | |
| 47 | "<strong>2</strong> commit", // both commits counted | |
| 48 | "<strong>1</strong> branch", | |
| 49 | "<strong>1</strong> tag", | |
| 50 | "0BSD", // license detected and surfaced | |
| 51 | "Go", // language census | |
| 52 | "Shell", // and it is not single-language | |
| 53 | "2 contributors", | |
| 54 | } { | |
| 55 | if !strings.Contains(body, want) { | |
| 56 | t.Errorf("repo home missing %q", want) | |
| 57 | } | |
| 58 | } | |
| 59 | // The contributor with a verified email links to their profile; the | |
| 60 | // other is named without inventing an account for them. | |
| 61 | if !strings.Contains(body, `href="/alice"`) { | |
| 62 | t.Error("verified contributor not linked to their profile") | |
| 63 | } | |
| 64 | if strings.Contains(body, `href="/t"`) { | |
| 65 | t.Error("unknown contributor linked to a profile that does not exist") | |
| 66 | } | |
| 67 | ||
| 68 | // Subdirectory listings are about the directory, not the repository. | |
| 69 | mustGit(t, dir, env, "rm", "-q", "extra.go") | |
| 70 | os.MkdirAll(filepath.Join(dir, "sub"), 0o755) | |
| 71 | os.WriteFile(filepath.Join(dir, "sub", "x.go"), []byte("package sub\n"), 0o644) | |
| 72 | mustGit(t, dir, env, "add", "-A") | |
| 73 | mustGit(t, dir, env, "commit", "-q", "-m", "sub") | |
| 74 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 75 | if _, body := inst.get(t, "/alice/app/tree/main/sub"); strings.Contains(body, "contributor") { | |
| 76 | t.Error("facts bar rendered on a subdirectory listing") | |
| 77 | } | |
| 78 | ||
| 79 | // A repository with no commits has no facts to state, and must still | |
| 80 | // render — the empty case renders through the same page type. | |
| 81 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/empty"); code != 0 { | |
| 82 | t.Fatal("repo create failed") | |
| 83 | } | |
| 84 | if status, body := inst.get(t, "/alice/empty"); status != 200 { | |
| 85 | t.Fatalf("empty repo home: %d", status) | |
| 86 | } else if strings.Contains(body, "contributor") { | |
| 87 | t.Error("empty repository claims contributors") | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | const zeroBSD = `Permission to use, copy, modify, and/or distribute this software for any | |
| 92 | purpose with or without fee is hereby granted. | |
| 93 | ||
| 94 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES | |
| 95 | WITH REGARD TO THIS SOFTWARE. | |
| 96 | ` | |
internal/gitutil/facts.go added +114
| @@ -0,0 +1,114 @@ | ||
| 1 | package gitutil | |
| 2 | ||
| 3 | import ( | |
| 4 | "os/exec" | |
| 5 | "sort" | |
| 6 | "strconv" | |
| 7 | "strings" | |
| 8 | ) | |
| 9 | ||
| 10 | // CountCommits returns the number of commits reachable from ref, or 0 when | |
| 11 | // the ref does not resolve (an empty repository). | |
| 12 | func CountCommits(dir, ref string) int { | |
| 13 | out, err := exec.Command("git", "-C", dir, "rev-list", "--count", ref).Output() | |
| 14 | if err != nil { | |
| 15 | return 0 | |
| 16 | } | |
| 17 | n, _ := strconv.Atoi(strings.TrimSpace(string(out))) | |
| 18 | return n | |
| 19 | } | |
| 20 | ||
| 21 | // Contributor is one author of a repository's history. | |
| 22 | type Contributor struct { | |
| 23 | Name string | |
| 24 | Email string | |
| 25 | Commits int | |
| 26 | } | |
| 27 | ||
| 28 | // Contributors summarises authorship reachable from ref, most commits | |
| 29 | // first. Identities are keyed by email, since that is what the forge can | |
| 30 | // tie back to an account. | |
| 31 | func Contributors(dir, ref string, max int) []Contributor { | |
| 32 | out, err := exec.Command("git", "-C", dir, "log", "--format=%an%x01%ae", ref).Output() | |
| 33 | if err != nil { | |
| 34 | return nil | |
| 35 | } | |
| 36 | byEmail := map[string]*Contributor{} | |
| 37 | var order []string | |
| 38 | for _, line := range strings.Split(string(out), "\n") { | |
| 39 | name, email, ok := strings.Cut(line, "\x01") | |
| 40 | if !ok || email == "" { | |
| 41 | continue | |
| 42 | } | |
| 43 | c, seen := byEmail[email] | |
| 44 | if !seen { | |
| 45 | byEmail[email] = &Contributor{Name: name, Email: email, Commits: 1} | |
| 46 | order = append(order, email) | |
| 47 | continue | |
| 48 | } | |
| 49 | c.Commits++ | |
| 50 | } | |
| 51 | list := make([]Contributor, 0, len(order)) | |
| 52 | for _, e := range order { | |
| 53 | list = append(list, *byEmail[e]) | |
| 54 | } | |
| 55 | sort.SliceStable(list, func(i, j int) bool { return list[i].Commits > list[j].Commits }) | |
| 56 | if max > 0 && len(list) > max { | |
| 57 | list = list[:max] | |
| 58 | } | |
| 59 | return list | |
| 60 | } | |
| 61 | ||
| 62 | // Languages reports the byte share of each language in the tree at ref, | |
| 63 | // largest first, keyed by the extension map the caller supplies. Only | |
| 64 | // blobs count; git's own metadata does not. | |
| 65 | func Languages(dir, ref string, lang func(path string) string) []Language { | |
| 66 | out, err := exec.Command("git", "-C", dir, "ls-tree", "-r", "-l", "--full-name", ref).Output() | |
| 67 | if err != nil { | |
| 68 | return nil | |
| 69 | } | |
| 70 | bytesBy := map[string]int64{} | |
| 71 | var total int64 | |
| 72 | for _, line := range strings.Split(string(out), "\n") { | |
| 73 | // <mode> blob <sha> <size>\t<path> | |
| 74 | meta, path, ok := strings.Cut(line, "\t") | |
| 75 | if !ok { | |
| 76 | continue | |
| 77 | } | |
| 78 | f := strings.Fields(meta) | |
| 79 | if len(f) < 4 || f[1] != "blob" { | |
| 80 | continue | |
| 81 | } | |
| 82 | size, err := strconv.ParseInt(f[3], 10, 64) | |
| 83 | if err != nil { | |
| 84 | continue | |
| 85 | } | |
| 86 | name := lang(path) | |
| 87 | if name == "" { | |
| 88 | continue | |
| 89 | } | |
| 90 | bytesBy[name] += size | |
| 91 | total += size | |
| 92 | } | |
| 93 | if total == 0 { | |
| 94 | return nil | |
| 95 | } | |
| 96 | langs := make([]Language, 0, len(bytesBy)) | |
| 97 | for name, b := range bytesBy { | |
| 98 | langs = append(langs, Language{Name: name, Bytes: b, | |
| 99 | Percent: float64(b) * 100 / float64(total)}) | |
| 100 | } | |
| 101 | sort.Slice(langs, func(i, j int) bool { | |
| 102 | if langs[i].Bytes != langs[j].Bytes { | |
| 103 | return langs[i].Bytes > langs[j].Bytes | |
| 104 | } | |
| 105 | return langs[i].Name < langs[j].Name | |
| 106 | }) | |
| 107 | return langs | |
| 108 | } | |
| 109 | ||
| 110 | type Language struct { | |
| 111 | Name string | |
| 112 | Bytes int64 | |
| 113 | Percent float64 | |
| 114 | } | |
internal/httpd/facts.go added +57
| @@ -0,0 +1,57 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "gitbay.org/gitbay/internal/gitutil" | |
| 5 | ) | |
| 6 | ||
| 7 | // repoFacts is the "what is this repository" summary on a repo home: the | |
| 8 | // counts a visitor uses to size up a project before reading any code. | |
| 9 | type repoFacts struct { | |
| 10 | Commits int | |
| 11 | Branches int | |
| 12 | Tags int | |
| 13 | Contributors []factContributor | |
| 14 | Languages []gitutil.Language | |
| 15 | License string | |
| 16 | Release string // newest release tag, if any | |
| 17 | Build string // latest build status on the default branch | |
| 18 | } | |
| 19 | ||
| 20 | type factContributor struct { | |
| 21 | Name string | |
| 22 | User string // account, when the email is verified here | |
| 23 | Email string | |
| 24 | Commits int | |
| 25 | } | |
| 26 | ||
| 27 | const maxContributors = 12 | |
| 28 | ||
| 29 | func (s *Server) factsFor(p repoPage) repoFacts { | |
| 30 | f := repoFacts{ | |
| 31 | Commits: gitutil.CountCommits(p.Dir, p.Ref), | |
| 32 | License: detectLicense(p.Dir, p.Ref), | |
| 33 | } | |
| 34 | if heads, err := gitutil.Refs(p.Dir, "heads"); err == nil { | |
| 35 | f.Branches = len(heads) | |
| 36 | } | |
| 37 | if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil { | |
| 38 | f.Tags = len(tags) | |
| 39 | } | |
| 40 | f.Languages = gitutil.Languages(p.Dir, p.Ref, langOf) | |
| 41 | ||
| 42 | names := s.authorNames() | |
| 43 | for _, c := range gitutil.Contributors(p.Dir, p.Ref, maxContributors) { | |
| 44 | user, _ := names.account(c.Email) | |
| 45 | f.Contributors = append(f.Contributors, factContributor{ | |
| 46 | Name: names.name(c.Email, c.Name), User: user, Email: c.Email, Commits: c.Commits, | |
| 47 | }) | |
| 48 | } | |
| 49 | ||
| 50 | if rels, err := s.st.ListReleases(p.Repo.ID); err == nil && len(rels) > 0 { | |
| 51 | f.Release = rels[0].Tag | |
| 52 | } | |
| 53 | if builds, err := s.st.ListBuilds(p.Repo.ID, 1); err == nil && len(builds) > 0 { | |
| 54 | f.Build = builds[0].Status | |
| 55 | } | |
| 56 | return f | |
| 57 | } | |
internal/httpd/langs.go added +42
| @@ -0,0 +1,42 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "path" | |
| 5 | "strings" | |
| 6 | ) | |
| 7 | ||
| 8 | // langByExt names the languages worth reporting on a repository home. It is | |
| 9 | // deliberately short: the point is "what is this written in", not a | |
| 10 | // linguist-grade census, and an unlisted extension simply does not count. | |
| 11 | var langByExt = map[string]string{ | |
| 12 | ".go": "Go", ".rs": "Rust", ".c": "C", ".h": "C", ".cc": "C++", | |
| 13 | ".cpp": "C++", ".hpp": "C++", ".cs": "C#", ".java": "Java", | |
| 14 | ".kt": "Kotlin", ".swift": "Swift", ".m": "Objective-C", | |
| 15 | ".py": "Python", ".rb": "Ruby", ".pl": "Perl", ".php": "PHP", | |
| 16 | ".js": "JavaScript", ".mjs": "JavaScript", ".jsx": "JavaScript", | |
| 17 | ".ts": "TypeScript", ".tsx": "TypeScript", | |
| 18 | ".sh": "Shell", ".bash": "Shell", ".zsh": "Shell", ".fish": "Shell", | |
| 19 | ".lua": "Lua", ".el": "Emacs Lisp", ".lisp": "Common Lisp", | |
| 20 | ".clj": "Clojure", ".ex": "Elixir", ".exs": "Elixir", ".erl": "Erlang", | |
| 21 | ".hs": "Haskell", ".ml": "OCaml", ".scala": "Scala", ".dart": "Dart", | |
| 22 | ".zig": "Zig", ".nim": "Nim", ".jl": "Julia", ".r": "R", | |
| 23 | ".sql": "SQL", ".html": "HTML", ".css": "CSS", ".scss": "SCSS", | |
| 24 | ".vim": "Vim script", ".tf": "HCL", ".nix": "Nix", | |
| 25 | ".org": "Org", ".md": "Markdown", ".tex": "TeX", | |
| 26 | } | |
| 27 | ||
| 28 | // langOf maps a path to a language name, or "" when it is not code we | |
| 29 | // count. Vendored and generated trees are excluded: they describe someone | |
| 30 | // else's work, and including them makes the bar meaningless. | |
| 31 | func langOf(p string) string { | |
| 32 | for _, seg := range strings.Split(p, "/") { | |
| 33 | switch seg { | |
| 34 | case "vendor", "node_modules", "third_party", "testdata", "dist": | |
| 35 | return "" | |
| 36 | } | |
| 37 | } | |
| 38 | if strings.HasSuffix(p, ".min.js") || strings.HasSuffix(p, ".min.css") { | |
| 39 | return "" | |
| 40 | } | |
| 41 | return langByExt[strings.ToLower(path.Ext(p))] | |
| 42 | } | |
internal/httpd/web.go +26 −27
| @@ -425,22 +425,27 @@ func (s *Server) tree(w http.ResponseWriter, r *http.Request) { | ||
| 425 | 425 | s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/")) |
| 426 | 426 | } |
| 427 | 427 | |
| 428 | // treePage is shared by the populated and empty-repository renders: two | |
| 429 | // anonymous structs drifted apart once already. | |
| 430 | type treePage struct { | |
| 431 | repoPage | |
| 432 | Crumbs []crumb | |
| 433 | Prefix string | |
| 434 | DirPath string | |
| 435 | RefKind string | |
| 436 | Entries []gitutil.TreeEntry | |
| 437 | Branches []gitutil.Ref | |
| 438 | ReadmeName string | |
| 439 | ReadmeHTML template.HTML | |
| 440 | LastCommits map[string]namedCommit | |
| 441 | Tip namedCommit | |
| 442 | Facts repoFacts | |
| 443 | } | |
| 444 | ||
| 428 | 445 | func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) { |
| 429 | 446 | if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil { |
| 430 | 447 | // Empty repo: render the page with no entries rather than 404. |
| 431 | s.render(w, "tree.html", struct { | |
| 432 | repoPage | |
| 433 | Crumbs []crumb | |
| 434 | Prefix string | |
| 435 | DirPath string | |
| 436 | RefKind string | |
| 437 | Entries []gitutil.TreeEntry | |
| 438 | Branches []gitutil.Ref | |
| 439 | ReadmeName string | |
| 440 | ReadmeHTML template.HTML | |
| 441 | LastCommits map[string]namedCommit | |
| 442 | Tip namedCommit | |
| 443 | }{repoPage: p, RefKind: "tree"}) | |
| 448 | s.render(w, "tree.html", treePage{repoPage: p, RefKind: "tree"}) | |
| 444 | 449 | return |
| 445 | 450 | } |
| 446 | 451 | entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath) |
| @@ -472,22 +477,16 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, | ||
| 472 | 477 | for _, e := range entries { |
| 473 | 478 | names = append(names, e.Name) |
| 474 | 479 | } |
| 475 | s.render(w, "tree.html", struct { | |
| 476 | repoPage | |
| 477 | Crumbs []crumb | |
| 478 | Prefix string | |
| 479 | DirPath string | |
| 480 | RefKind string | |
| 481 | Entries []gitutil.TreeEntry | |
| 482 | Branches []gitutil.Ref | |
| 483 | ReadmeName string | |
| 484 | ReadmeHTML template.HTML | |
| 485 | LastCommits map[string]namedCommit | |
| 486 | Tip namedCommit | |
| 487 | }{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches, | |
| 480 | // The facts bar is about the repository, not this directory, so it is | |
| 481 | // computed once at the root and left off subdirectory listings. | |
| 482 | var facts repoFacts | |
| 483 | if dirPath == "" { | |
| 484 | facts = s.factsFor(p) | |
| 485 | } | |
| 486 | s.render(w, "tree.html", treePage{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches, | |
| 488 | 487 | readmeName, readmeHTML, |
| 489 | 488 | s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)), |
| 490 | s.namedTip(gitutil.TipCommit(p.Dir, p.Ref))}) | |
| 489 | s.namedTip(gitutil.TipCommit(p.Dir, p.Ref)), facts}) | |
| 491 | 490 | } |
| 492 | 491 | |
| 493 | 492 | func (s *Server) blob(w http.ResponseWriter, r *http.Request) { |
internal/web/static/style.css +49
| @@ -794,6 +794,55 @@ pre.message { | ||
| 794 | 794 | overflow-wrap: anywhere; |
| 795 | 795 | } |
| 796 | 796 | |
| 797 | /* repo facts: the counts a visitor sizes a project up with */ | |
| 798 | .facts { margin: 0 0 var(--sp-4); } | |
| 799 | .facts .counts { | |
| 800 | display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-4); | |
| 801 | margin: 0 0 var(--sp-3); font-size: var(--fs-2); | |
| 802 | } | |
| 803 | .facts .counts a { color: var(--fg); text-decoration: none; } | |
| 804 | .facts .counts a:hover { color: var(--accent); text-decoration: underline; } | |
| 805 | .facts .counts .fact { color: var(--muted); } | |
| 806 | .facts .counts strong { font-weight: 600; } | |
| 807 | ||
| 808 | .langbar { | |
| 809 | display: flex; height: 0.5rem; margin: 0 0 var(--sp-2); | |
| 810 | border-radius: var(--r-sm); overflow: hidden; background: var(--faint); | |
| 811 | } | |
| 812 | .langbar .lang { display: block; min-width: 2px; } | |
| 813 | .langs { | |
| 814 | display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-4); | |
| 815 | margin: 0 0 var(--sp-3); font-size: var(--fs-1); | |
| 816 | } | |
| 817 | .langs .lang-name { display: inline-flex; align-items: center; gap: var(--sp-1); } | |
| 818 | .langs .muted { color: var(--muted); } | |
| 819 | .langs .dot { width: 0.6rem; height: 0.6rem; border-radius: 50%; display: inline-block; } | |
| 820 | /* named where it reads as the language's own color, neutral otherwise */ | |
| 821 | .lang { background: var(--neutral); } | |
| 822 | .lang-go { background: #00add8; } | |
| 823 | .lang-rust { background: #dea584; } | |
| 824 | .lang-c, .lang-c- { background: #555555; } | |
| 825 | .lang-python { background: #3572a5; } | |
| 826 | .lang-javascript { background: #f1e05a; } | |
| 827 | .lang-typescript { background: #3178c6; } | |
| 828 | .lang-shell { background: #89e051; } | |
| 829 | .lang-ruby { background: #701516; } | |
| 830 | .lang-java { background: #b07219; } | |
| 831 | .lang-swift { background: #f05138; } | |
| 832 | .lang-html { background: #e34c26; } | |
| 833 | .lang-css { background: #563d7c; } | |
| 834 | .lang-org { background: #77aa99; } | |
| 835 | .lang-markdown { background: #083fa1; } | |
| 836 | .lang-emacs-lisp { background: #c065db; } | |
| 837 | .lang-nix { background: #7e7eff; } | |
| 838 | .lang-sql { background: #e38c00; } | |
| 839 | ||
| 840 | .contribs { | |
| 841 | display: flex; flex-wrap: wrap; align-items: baseline; | |
| 842 | gap: var(--sp-1) var(--sp-3); margin: 0; font-size: var(--fs-1); | |
| 843 | } | |
| 844 | .contribs .label { color: var(--muted); } | |
| 845 | ||
| 797 | 846 | /* diffs: one foldable section per file, a table so the line-number |
| 798 | 847 | gutters stay put while the code scrolls */ |
| 799 | 848 | details.difffold { |
internal/web/templates/tree.html +13
| @@ -10,6 +10,19 @@ | ||
| 10 | 10 | <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">Download</a> |
| 11 | 11 | </div> |
| 12 | 12 | {{if .Entries}}<p class="clone">Clone: <code>git clone {{.CloneURL}}</code></p>{{end}} |
| 13 | {{if .Facts.Commits}}{{$r := printf "/%s/%s" .Repo.OwnerName .Repo.Name}}<div class="facts"> | |
| 14 | <p class="counts"> | |
| 15 | <a href="{{$r}}/log/{{.Ref}}"><strong>{{.Facts.Commits}}</strong> commit{{if ne .Facts.Commits 1}}s{{end}}</a> | |
| 16 | <a href="{{$r}}/refs"><strong>{{.Facts.Branches}}</strong> branch{{if ne .Facts.Branches 1}}es{{end}}</a> | |
| 17 | <a href="{{$r}}/refs"><strong>{{.Facts.Tags}}</strong> tag{{if ne .Facts.Tags 1}}s{{end}}</a> | |
| 18 | {{with .Facts.License}}<span class="fact">{{.}}</span>{{end}} | |
| 19 | {{with .Facts.Release}}<a href="{{$r}}/releases">latest <strong>{{.}}</strong></a>{{end}} | |
| 20 | {{with .Facts.Build}}<a href="{{$r}}/builds">build <span class="badge badge-{{.}}">{{.}}</span></a>{{end}} | |
| 21 | </p> | |
| 22 | {{with .Facts.Languages}}<p class="langbar" aria-hidden="true">{{range .}}<span class="lang lang-{{slug .Name}}" style="width:{{pct .Percent}}%"></span>{{end}}</p> | |
| 23 | <p class="langs">{{range .}}<span class="lang-name"><span class="dot lang-{{slug .Name}}"></span>{{.Name}} <span class="muted">{{pct .Percent}}%</span></span>{{end}}</p>{{end}} | |
| 24 | {{with .Facts.Contributors}}<p class="contribs"><span class="label">{{len .}} contributor{{if ne (len .) 1}}s{{end}}</span>{{range .}}{{template "authorname" dict "Name" .Name "User" .User "Email" (printf "%s · %d commits" .Email .Commits)}}{{end}}</p>{{end}} | |
| 25 | </div>{{end}} | |
| 13 | 26 | {{with .Tip}}{{if .SHA}}<div class="tipbar"> |
| 14 | 27 | <span class="who">{{template "authorname" dict "Name" .Author "User" .User "Email" .Email}}</span> |
| 15 | 28 | <a class="subject" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a> |
internal/web/web.go +20
| @@ -166,6 +166,26 @@ var funcs = template.FuncMap{ | ||
| 166 | 166 | }, |
| 167 | 167 | // ago renders a time as a coarse relative age, which is what a |
| 168 | 168 | // listing column is actually read for. The zero time yields "". |
| 169 | // slug turns a language name into a CSS class suffix, so the palette | |
| 170 | // lives in the stylesheet rather than in inline styles the CSP would | |
| 171 | // have to allow. | |
| 172 | "slug": func(s string) string { | |
| 173 | var b strings.Builder | |
| 174 | for _, r := range strings.ToLower(s) { | |
| 175 | switch { | |
| 176 | case r >= 'a' && r <= 'z', r >= '0' && r <= '9': | |
| 177 | b.WriteRune(r) | |
| 178 | default: | |
| 179 | b.WriteByte('-') | |
| 180 | } | |
| 181 | } | |
| 182 | return b.String() | |
| 183 | }, | |
| 184 | // pct renders a share to one decimal, dropping a trailing ".0". | |
| 185 | "pct": func(f float64) string { | |
| 186 | s := strconv.FormatFloat(f, 'f', 1, 64) | |
| 187 | return strings.TrimSuffix(s, ".0") | |
| 188 | }, | |
| 169 | 189 | "ago": func(t time.Time) string { |
| 170 | 190 | if t.IsZero() { |
| 171 | 191 | return "" |