Commit c2fd341

Nick Faro committed on
Expand the static repository browser
commit c2fd3411a3e77ed7b8201ebd02849ff1cb6aff29 parent d916b11
9 changed files +850−151
ModifiedREADME.md +19−1
@@ -78,7 +78,8 @@ The repository browser is served from that exact URL. Git appends
78 5. Upload the immutable `.pack` and `.idx`. 78 5. Upload the immutable `.pack` and `.idx`.
79 6. Publish `objects/info/packs`, individual refs, `HEAD`, and finally 79 6. Publish `objects/info/packs`, individual refs, `HEAD`, and finally
80 `info/refs`. 80 `info/refs`.
81 7. Generate changed source/raw pages and affected ancestor tree pages. 81 7. Generate changed source/raw pages, affected ancestor tree pages, and the
82 branch's commit and repository-summary pages.
82 83
83 Git data is never rebuilt on an ordinary push. Two small commits produce two 84 Git data is never rebuilt on an ordinary push. Two small commits produce two
84 small packs. The mutable dumb-HTTP indexes are tiny and served with revalidation; 85 small packs. The mutable dumb-HTTP indexes are tiny and served with revalidation;
@@ -93,8 +94,25 @@ The generated browser includes:
93 - syntax highlighting and linked line numbers; 94 - syntax highlighting and linked line numbers;
94 - raw file URLs and image previews; 95 - raw file URLs and image previews;
95 - branch-aware paths; 96 - branch-aware paths;
97 - full commit history with contributor Gravatars;
98 - top contributors and Linguist-compatible language percentages;
99 - a clone popover with the public HTTPS URL;
100 - an About panel derived from package metadata, the README, and the upstream
101 remote;
96 - incremental regeneration based on `git diff --name-status`. 102 - incremental regeneration based on `git diff --name-status`.
97 103
104 GitHub's repository description is not part of Git data. Override the inferred
105 About metadata in the local repository when needed:
106
107 ```sh
108 git config dumbforge.description "A short repository description"
109 git config dumbforge.homepage "https://example.com/project"
110 ```
111
112 Contributor avatars are loaded from Gravatar using the normalized SHA-256 hash
113 of each commit author's email address. Gravatar's generated initials are used
114 when an author has no uploaded image.
115
98 The UI is an original implementation using familiar source-hosting patterns; 116 The UI is an original implementation using familiar source-hosting patterns;
99 it contains no copied GitHub HTML, CSS, logos, or assets. 117 it contains no copied GitHub HTML, CSS, logos, or assets.
100 118
Modifiedgo.mod +11−6
@@ -3,10 +3,17 @@ module github.com/npfaro/dumbforge
3 go 1.26 3 go 1.26
4 4
5 require ( 5 require (
6 github.com/alecthomas/chroma/v2 v2.27.0 // indirect 6 github.com/alecthomas/chroma/v2 v2.27.0
7 github.com/aws/aws-sdk-go-v2 v1.43.0 // indirect 7 github.com/aws/aws-sdk-go-v2 v1.43.0
8 github.com/aws/aws-sdk-go-v2/config v1.32.31
9 github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0
10 github.com/aws/smithy-go v1.27.4
11 github.com/go-enry/go-enry/v2 v2.9.6
12 github.com/yuin/goldmark v1.8.4
13 )
14
15 require (
8 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect 16 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
9 github.com/aws/aws-sdk-go-v2/config v1.32.31 // indirect
10 github.com/aws/aws-sdk-go-v2/credentials v1.19.30 // indirect 17 github.com/aws/aws-sdk-go-v2/credentials v1.19.30 // indirect
11 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 // indirect 18 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 // indirect
12 github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 // indirect 19 github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 // indirect
@@ -16,12 +23,10 @@ require (
16 github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 // indirect 23 github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 // indirect
17 github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 // indirect 24 github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 // indirect
18 github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 // indirect 25 github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 // indirect
19 github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0 // indirect
20 github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 // indirect 26 github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 // indirect
21 github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 // indirect 27 github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 // indirect
22 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 // indirect 28 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 // indirect
23 github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 // indirect 29 github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 // indirect
24 github.com/aws/smithy-go v1.27.4 // indirect
25 github.com/dlclark/regexp2/v2 v2.2.1 // indirect 30 github.com/dlclark/regexp2/v2 v2.2.1 // indirect
26 github.com/yuin/goldmark v1.8.4 // indirect 31 github.com/go-enry/go-oniguruma v1.2.1 // indirect
27 ) 32 )
Modifiedgo.sum +27−0
@@ -1,5 +1,9 @@
1 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
2 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
1 github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= 3 github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
2 github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= 4 github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
5 github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
6 github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
3 github.com/aws/aws-sdk-go-v2 v1.43.0 h1:fharf/WhbRAVZ1du0QL7roNFxZ6T/sWr+4Ni617bwSI= 7 github.com/aws/aws-sdk-go-v2 v1.43.0 h1:fharf/WhbRAVZ1du0QL7roNFxZ6T/sWr+4Ni617bwSI=
4 github.com/aws/aws-sdk-go-v2 v1.43.0/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= 8 github.com/aws/aws-sdk-go-v2 v1.43.0/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
5 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY= 9 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
@@ -36,7 +40,30 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 h1:Pd6PNlp4t8PTXxqzstICl52Wsy78
36 github.com/aws/aws-sdk-go-v2/service/sts v1.45.0/go.mod h1:rmQ0TnHzuLPmabgjPcsywhsSOmaBDgzR4zvDxSPsGdg= 40 github.com/aws/aws-sdk-go-v2/service/sts v1.45.0/go.mod h1:rmQ0TnHzuLPmabgjPcsywhsSOmaBDgzR4zvDxSPsGdg=
37 github.com/aws/smithy-go v1.27.4 h1:JQcphmBN4f0q/sPqXqROIItRNV/hy10cgu7CsFy616M= 41 github.com/aws/smithy-go v1.27.4 h1:JQcphmBN4f0q/sPqXqROIItRNV/hy10cgu7CsFy616M=
38 github.com/aws/smithy-go v1.27.4/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= 42 github.com/aws/smithy-go v1.27.4/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
43 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
44 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
45 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
39 github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= 46 github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
40 github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= 47 github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
48 github.com/go-enry/go-enry/v2 v2.9.6 h1:np63eOtMV56zfYDHnFVgpEVOk8fr2kmylcMnAZUDbSs=
49 github.com/go-enry/go-enry/v2 v2.9.6/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8=
50 github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo=
51 github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4=
52 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
53 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
54 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
55 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
56 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
57 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
58 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
59 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
60 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
61 github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
62 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
41 github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA= 63 github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
42 github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 64 github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
65 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
66 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
67 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
68 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
69 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Addedinternal/site/icons.go +27−0
@@ -0,0 +1,27 @@
1 package site
2
3 import "html/template"
4
5 func icon(name string) template.HTML {
6 icons := map[string]string{
7 "repo": `<svg class="icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 2.25A1.25 1.25 0 0 1 4.25 1h8.25v12H5a2 2 0 0 0-2 2V2.25Z"/><path d="M5 12.75V1M6.75 4h3.75"/></svg>`,
8 "code": `<svg class="icon" viewBox="0 0 16 16" aria-hidden="true"><path d="m5.25 3.5-4 4.5 4 4.5M10.75 3.5l4 4.5-4 4.5M9.25 2 6.75 14"/></svg>`,
9 "history": `<svg class="icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M2.25 5.25V1.5M2.25 1.5H6M2.25 1.5l2.3 2.3A6 6 0 1 1 2 8.7M8 4.75V8l2.25 1.5"/></svg>`,
10 "branch": `<svg class="icon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="4" cy="3" r="1.75"/><circle cx="4" cy="13" r="1.75"/><circle cx="12" cy="5" r="1.75"/><path d="M4 4.75v6.5M5.75 11.25h.75A5.5 5.5 0 0 0 12 5.75"/></svg>`,
11 "chevron": `<svg class="icon icon-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg>`,
12 "download": `<svg class="icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.5v8M4.75 6.5 8 9.75l3.25-3.25M2.5 11v2.5h11V11"/></svg>`,
13 "copy": `<svg class="icon" viewBox="0 0 16 16" aria-hidden="true"><rect x="5" y="5" width="8.5" height="8.5" rx="1.25"/><path d="M11 5V2.5H2.5V11H5"/></svg>`,
14 "file": `<svg class="icon file-svg" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 1.5h6l4 4v9H3v-13Z"/><path d="M9 1.5v4h4"/></svg>`,
15 "folder": `<svg class="icon folder-svg" viewBox="0 0 16 16" aria-hidden="true"><path d="M1.5 3.25h5l1.25 1.5h6.75v8.75h-13V3.25Z"/></svg>`,
16 "back": `<svg class="icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M6.5 3 2 7.5 6.5 12M2.5 7.5H14"/></svg>`,
17 "book": `<svg class="icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M2 2.5h4.25A1.75 1.75 0 0 1 8 4.25V14a2 2 0 0 0-2-2H2V2.5ZM14 2.5H9.75A1.75 1.75 0 0 0 8 4.25V14a2 2 0 0 1 2-2h4V2.5Z"/></svg>`,
18 "link": `<svg class="icon" viewBox="0 0 16 16" aria-hidden="true"><path d="m6.25 9.75 3.5-3.5M5.2 11.9l-1.1 1.1A2.9 2.9 0 0 1 0 8.9l2.1-2.1a2.9 2.9 0 0 1 4.1 0M10.8 4.1l1.1-1.1A2.9 2.9 0 1 1 16 7.1l-2.1 2.1a2.9 2.9 0 0 1-4.1 0"/></svg>`,
19 "people": `<svg class="icon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="6" cy="5" r="2.5"/><path d="M1.5 14a4.5 4.5 0 0 1 9 0M11 3.25a2.25 2.25 0 0 1 0 4.5M11.5 10a4 4 0 0 1 3 4"/></svg>`,
20 "external": `<svg class="icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M9 2h5v5M14 2 8 8M7 3H2.5v10.5H13V9"/></svg>`,
21 }
22 value, ok := icons[name]
23 if !ok {
24 return ""
25 }
26 return template.HTML(value)
27 }
Addedinternal/site/metadata.go +418−0
@@ -0,0 +1,418 @@
1 package site
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "fmt"
9 "html/template"
10 "net/url"
11 "regexp"
12 "sort"
13 "strings"
14 "time"
15
16 enry "github.com/go-enry/go-enry/v2"
17 )
18
19 type CommitGroup struct {
20 Date string
21 Commits []Commit
22 }
23
24 type Contributor struct {
25 Name string
26 Email string
27 AvatarURL string
28 Commits int
29 }
30
31 type LanguageStat struct {
32 Name string
33 Bytes int64
34 Percent string
35 Style template.CSS
36 }
37
38 type repositoryMeta struct {
39 Owner string
40 Description string
41 Homepage string
42 HomepageLabel string
43 SourceURL string
44 Languages []LanguageStat
45 Contributors []Contributor
46 ContributorCount int
47 }
48
49 type branchSummary struct {
50 Latest Commit
51 Count int
52 CommitsURL string
53 Groups []CommitGroup
54 Repository repositoryMeta
55 }
56
57 type packageMetadata struct {
58 Description string `json:"description"`
59 Homepage string `json:"homepage"`
60 Repository json.RawMessage `json:"repository"`
61 }
62
63 func (g *Generator) branchSummary(ctx context.Context, branch, oid string) (branchSummary, error) {
64 commits, err := g.commits(ctx, oid)
65 if err != nil {
66 return branchSummary{}, err
67 }
68 if len(commits) == 0 {
69 return branchSummary{}, fmt.Errorf("branch %s has no commits", branch)
70 }
71 repository, err := g.repositoryMetadata(ctx, oid, commits)
72 if err != nil {
73 return branchSummary{}, err
74 }
75 return branchSummary{
76 Latest: commits[0],
77 Count: len(commits),
78 CommitsURL: routeURL(g.BaseURL, "commits/"+branch),
79 Groups: groupCommits(commits),
80 Repository: repository,
81 }, nil
82 }
83
84 func (g *Generator) commits(ctx context.Context, oid string) ([]Commit, error) {
85 out, err := g.Git.Run(ctx, "log", "--format=%H%x00%h%x00%an%x00%ae%x00%aI%x00%s%x00", oid)
86 if err != nil {
87 return nil, err
88 }
89 fields := strings.Split(string(out), "\x00")
90 if len(fields) > 0 && strings.TrimSpace(fields[len(fields)-1]) == "" {
91 fields = fields[:len(fields)-1]
92 }
93 if len(fields)%6 != 0 {
94 return nil, fmt.Errorf("unexpected git log output")
95 }
96 result := make([]Commit, 0, len(fields)/6)
97 for i := 0; i < len(fields); i += 6 {
98 author := strings.TrimSpace(fields[i+2])
99 email := strings.TrimSpace(fields[i+3])
100 dateTime := strings.TrimSpace(fields[i+4])
101 result = append(result, Commit{
102 OID: strings.TrimSpace(fields[i]),
103 ShortOID: strings.TrimSpace(fields[i+1]),
104 Author: author,
105 Email: email,
106 Date: displayDate(dateTime),
107 DateTime: dateTime,
108 Message: strings.TrimSpace(fields[i+5]),
109 Initials: initials(author),
110 AvatarURL: gravatarURL(email, author),
111 })
112 }
113 return result, nil
114 }
115
116 func groupCommits(commits []Commit) []CommitGroup {
117 var groups []CommitGroup
118 for _, commit := range commits {
119 if len(groups) == 0 || groups[len(groups)-1].Date != commit.Date {
120 groups = append(groups, CommitGroup{Date: commit.Date})
121 }
122 groups[len(groups)-1].Commits = append(groups[len(groups)-1].Commits, commit)
123 }
124 return groups
125 }
126
127 func displayDate(value string) string {
128 parsed, err := time.Parse(time.RFC3339, value)
129 if err != nil {
130 return value
131 }
132 return parsed.Format("Jan 2, 2006")
133 }
134
135 func gravatarURL(email, name string) string {
136 normalized := strings.ToLower(strings.TrimSpace(email))
137 hash := sha256.Sum256([]byte(normalized))
138 query := url.Values{
139 "d": {"initials"},
140 "name": {name},
141 "r": {"g"},
142 "s": {"64"},
143 }
144 return "https://gravatar.com/avatar/" + hex.EncodeToString(hash[:]) + "?" + query.Encode()
145 }
146
147 func (g *Generator) repositoryMetadata(ctx context.Context, oid string, commits []Commit) (repositoryMeta, error) {
148 meta := repositoryMeta{Owner: g.Owner}
149 if value := g.gitConfig(ctx, "dumbforge.description"); value != "" {
150 meta.Description = value
151 }
152 if value := g.gitConfig(ctx, "dumbforge.homepage"); value != "" {
153 meta.Homepage = value
154 }
155
156 packageInfo := g.packageMetadata(ctx, oid)
157 if meta.Description == "" {
158 meta.Description = packageInfo.Description
159 }
160 if meta.Homepage == "" {
161 meta.Homepage = packageInfo.Homepage
162 }
163 meta.SourceURL = packageRepositoryURL(packageInfo.Repository)
164 if meta.SourceURL == "" {
165 meta.SourceURL = normalizeRepositoryURL(g.gitConfig(ctx, "remote.origin.url"))
166 }
167
168 readme := g.rootReadme(ctx, oid)
169 if meta.Description == "" {
170 meta.Description = readmeDescription(readme)
171 }
172 if meta.Homepage == "" {
173 meta.Homepage = readmeHomepage(readme)
174 }
175 if meta.Homepage == "" {
176 meta.Homepage = meta.SourceURL
177 }
178 meta.HomepageLabel = linkLabel(meta.Homepage)
179 if owner := repositoryOwner(meta.SourceURL); owner != "" {
180 meta.Owner = owner
181 }
182
183 meta.Contributors, meta.ContributorCount = topContributors(commits, 6)
184 languages, err := g.languageStats(ctx, oid)
185 if err != nil {
186 return repositoryMeta{}, err
187 }
188 meta.Languages = languages
189 return meta, nil
190 }
191
192 func (g *Generator) gitConfig(ctx context.Context, key string) string {
193 out, err := g.Git.Run(ctx, "config", "--get", key)
194 if err != nil {
195 return ""
196 }
197 return strings.TrimSpace(string(out))
198 }
199
200 func (g *Generator) packageMetadata(ctx context.Context, oid string) packageMetadata {
201 out, err := g.Git.Run(ctx, "show", oid+":package.json")
202 if err != nil {
203 return packageMetadata{}
204 }
205 var result packageMetadata
206 if json.Unmarshal(out, &result) != nil {
207 return packageMetadata{}
208 }
209 return result
210 }
211
212 func packageRepositoryURL(raw json.RawMessage) string {
213 if len(raw) == 0 {
214 return ""
215 }
216 var value string
217 if json.Unmarshal(raw, &value) == nil {
218 return normalizeRepositoryURL(value)
219 }
220 var object struct {
221 URL string `json:"url"`
222 }
223 if json.Unmarshal(raw, &object) == nil {
224 return normalizeRepositoryURL(object.URL)
225 }
226 return ""
227 }
228
229 func normalizeRepositoryURL(value string) string {
230 value = strings.TrimSpace(strings.TrimPrefix(value, "git+"))
231 if strings.HasPrefix(value, "git@") {
232 parts := strings.SplitN(strings.TrimPrefix(value, "git@"), ":", 2)
233 if len(parts) == 2 {
234 value = "https://" + parts[0] + "/" + parts[1]
235 }
236 }
237 if strings.HasPrefix(value, "ssh://git@") {
238 value = "https://" + strings.TrimPrefix(value, "ssh://git@")
239 }
240 return strings.TrimSuffix(value, ".git")
241 }
242
243 func repositoryOwner(value string) string {
244 parsed, err := url.Parse(value)
245 if err != nil || parsed.Host == "" {
246 return ""
247 }
248 parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
249 if len(parts) < 2 {
250 return ""
251 }
252 return parts[len(parts)-2]
253 }
254
255 func linkLabel(value string) string {
256 parsed, err := url.Parse(value)
257 if err != nil || parsed.Host == "" {
258 return value
259 }
260 return strings.TrimSuffix(parsed.Host+parsed.EscapedPath(), "/")
261 }
262
263 func (g *Generator) rootReadme(ctx context.Context, oid string) []byte {
264 entries, err := g.listTree(ctx, oid, "")
265 if err != nil {
266 return nil
267 }
268 for _, entry := range entries {
269 if entry.Type == "blob" && strings.HasPrefix(strings.ToLower(entry.Name), "readme") {
270 content, err := g.Git.Run(ctx, "show", oid+":"+entry.Name)
271 if err == nil {
272 return content
273 }
274 }
275 }
276 return nil
277 }
278
279 func readmeDescription(content []byte) string {
280 lines := strings.Split(string(content), "\n")
281 var paragraph []string
282 inFence := false
283 for _, raw := range lines {
284 line := strings.TrimSpace(raw)
285 if strings.HasPrefix(line, "```") || strings.HasPrefix(line, "~~~") {
286 inFence = !inFence
287 continue
288 }
289 if inFence {
290 continue
291 }
292 if line == "" {
293 if len(paragraph) > 0 {
294 break
295 }
296 continue
297 }
298 if len(paragraph) == 0 && (strings.HasPrefix(line, "#") || strings.HasPrefix(line, "![") || isRule(line) || strings.HasPrefix(line, "<")) {
299 continue
300 }
301 paragraph = append(paragraph, line)
302 }
303 return cleanMarkdown(strings.Join(paragraph, " "))
304 }
305
306 func isRule(line string) bool {
307 compact := strings.ReplaceAll(strings.ReplaceAll(line, " ", ""), "\t", "")
308 return len(compact) >= 3 && (strings.Trim(compact, "-") == "" || strings.Trim(compact, "*") == "" || strings.Trim(compact, "_") == "")
309 }
310
311 var (
312 markdownLinkPattern = regexp.MustCompile(`!?\[([^]]*)\]\([^)]+\)`)
313 markdownMarkPattern = regexp.MustCompile("[`*_~]+")
314 homepagePattern = regexp.MustCompile(`(?i)\[([^]]*homepage[^]]*)\]\((https?://[^ )]+)\)`)
315 )
316
317 func cleanMarkdown(value string) string {
318 value = markdownLinkPattern.ReplaceAllString(value, "$1")
319 value = markdownMarkPattern.ReplaceAllString(value, "")
320 return strings.TrimSpace(value)
321 }
322
323 func readmeHomepage(content []byte) string {
324 match := homepagePattern.FindSubmatch(content)
325 if len(match) != 3 {
326 return ""
327 }
328 return string(match[2])
329 }
330
331 func topContributors(commits []Commit, limit int) ([]Contributor, int) {
332 contributors := map[string]*Contributor{}
333 for _, commit := range commits {
334 key := strings.ToLower(strings.TrimSpace(commit.Email))
335 if key == "" {
336 key = "name:" + strings.ToLower(commit.Author)
337 }
338 contributor := contributors[key]
339 if contributor == nil {
340 contributor = &Contributor{Name: commit.Author, Email: commit.Email, AvatarURL: commit.AvatarURL}
341 contributors[key] = contributor
342 }
343 contributor.Commits++
344 }
345 result := make([]Contributor, 0, len(contributors))
346 for _, contributor := range contributors {
347 result = append(result, *contributor)
348 }
349 sort.Slice(result, func(i, j int) bool {
350 if result[i].Commits != result[j].Commits {
351 return result[i].Commits > result[j].Commits
352 }
353 return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name)
354 })
355 if len(result) > limit {
356 result = result[:limit]
357 }
358 return result, len(contributors)
359 }
360
361 func (g *Generator) languageStats(ctx context.Context, oid string) ([]LanguageStat, error) {
362 entries, err := g.listTreeRecursive(ctx, oid)
363 if err != nil {
364 return nil, err
365 }
366 bytesByLanguage := map[string]int64{}
367 var total int64
368 for _, entry := range entries {
369 if entry.Type != "blob" || entry.Size <= 0 || enry.IsVendor(entry.Name) || enry.IsDocumentation(entry.Name) || enry.IsConfiguration(entry.Name) || enry.IsImage(entry.Name) {
370 continue
371 }
372 language, safe := enry.GetLanguageByFilename(entry.Name)
373 if !safe || language == "" {
374 language, safe = enry.GetLanguageByExtension(entry.Name)
375 }
376 if !safe || language == "" {
377 continue
378 }
379 languageType := enry.GetLanguageType(language)
380 if languageType != enry.Programming && languageType != enry.Markup {
381 continue
382 }
383 if group := enry.GetLanguageGroup(language); group != "" {
384 language = group
385 }
386 bytesByLanguage[language] += entry.Size
387 total += entry.Size
388 }
389 if total == 0 {
390 return nil, nil
391 }
392 result := make([]LanguageStat, 0, len(bytesByLanguage))
393 for language, size := range bytesByLanguage {
394 percentage := float64(size) * 100 / float64(total)
395 color := enry.GetColor(language)
396 if color == "" {
397 color = fallbackLanguageColor(language)
398 }
399 result = append(result, LanguageStat{
400 Name: language,
401 Bytes: size,
402 Percent: fmt.Sprintf("%.1f", percentage),
403 Style: template.CSS(fmt.Sprintf("--language-color:%s;--language-width:%.4f%%", color, percentage)),
404 })
405 }
406 sort.Slice(result, func(i, j int) bool {
407 if result[i].Bytes != result[j].Bytes {
408 return result[i].Bytes > result[j].Bytes
409 }
410 return result[i].Name < result[j].Name
411 })
412 return result, nil
413 }
414
415 func fallbackLanguageColor(language string) string {
416 hash := sha256.Sum256([]byte(language))
417 return fmt.Sprintf("#%02x%02x%02x", 72+hash[0]%128, 72+hash[1]%128, 72+hash[2]%128)
418 }
Modifiedinternal/site/site.go +95−91
@@ -54,12 +54,15 @@ type Generator struct {
54 } 54 }
55 55
56 type Commit struct { 56 type Commit struct {
57 OID string 57 OID string
58 ShortOID string 58 ShortOID string
59 Author string 59 Author string
60 Date string 60 Email string
61 Message string 61 Date string
62 Initials string 62 DateTime string
63 Message string
64 Initials string
65 AvatarURL string
63 } 66 }
64 67
65 type TreeEntry struct { 68 type TreeEntry struct {
@@ -77,26 +80,35 @@ type Crumb struct {
77 } 80 }
78 81
79 type pageData struct { 82 type pageData struct {
80 Kind string 83 Kind string
81 Title string 84 Title string
82 BaseURL string 85 BaseURL string
83 RepoName string 86 RepoName string
84 Owner string 87 Owner string
85 CloneURL string 88 CloneURL string
86 Branch string 89 Branch string
87 Path string 90 Path string
88 ParentURL string 91 ParentURL string
89 Breadcrumbs []Crumb 92 Breadcrumbs []Crumb
90 Entries []TreeEntry 93 Entries []TreeEntry
91 Commit Commit 94 Commit Commit
92 CommitCount int 95 CommitCount int
93 ReadmeName string 96 CommitsURL string
94 Readme template.HTML 97 CommitGroups []CommitGroup
95 Code template.HTML 98 Description string
96 RawURL string 99 Homepage string
97 Language string 100 HomepageLabel string
98 FileSize int64 101 SourceURL string
99 IsImage bool 102 Languages []LanguageStat
103 Contributors []Contributor
104 ContributorCount int
105 ReadmeName string
106 Readme template.HTML
107 Code template.HTML
108 RawURL string
109 Language string
110 FileSize int64
111 IsImage bool
100 } 112 }
101 113
102 func New(git gitutil.Git, baseURL, repoName, owner string) (*Generator, error) { 114 func New(git gitutil.Git, baseURL, repoName, owner string) (*Generator, error) {
@@ -105,6 +117,7 @@ func New(git gitutil.Git, baseURL, repoName, owner string) (*Generator, error) {
105 } 117 }
106 tmpl, err := template.New("page").Funcs(template.FuncMap{ 118 tmpl, err := template.New("page").Funcs(template.FuncMap{
107 "humanSize": humanSize, 119 "humanSize": humanSize,
120 "icon": icon,
108 }).Parse(pageTemplate) 121 }).Parse(pageTemplate)
109 if err != nil { 122 if err != nil {
110 return nil, err 123 return nil, err
@@ -123,11 +136,7 @@ func New(git gitutil.Git, baseURL, repoName, owner string) (*Generator, error) {
123 // pushes it only regenerates changed blobs and the directory pages that contain 136 // pushes it only regenerates changed blobs and the directory pages that contain
124 // them. The repository landing page is refreshed when isHead is true. 137 // them. The repository landing page is refreshed when isHead is true.
125 func (g *Generator) BuildBranch(ctx context.Context, branch, oldOID, newOID string, isHead bool) (Build, error) { 138 func (g *Generator) BuildBranch(ctx context.Context, branch, oldOID, newOID string, isHead bool) (Build, error) {
126 commit, err := g.commit(ctx, newOID) 139 summary, err := g.branchSummary(ctx, branch, newOID)
127 if err != nil {
128 return Build{}, err
129 }
130 commitCount, err := g.commitCount(ctx, newOID)
131 if err != nil { 140 if err != nil {
132 return Build{}, err 141 return Build{}, err
133 } 142 }
@@ -138,6 +147,11 @@ func (g *Generator) BuildBranch(ctx context.Context, branch, oldOID, newOID stri
138 ContentType: "text/css; charset=utf-8", 147 ContentType: "text/css; charset=utf-8",
139 Cache: siteCache, 148 Cache: siteCache,
140 }) 149 })
150 commitsPage, err := g.commitsPage(branch, summary)
151 if err != nil {
152 return Build{}, err
153 }
154 result.Pages = append(result.Pages, commitsPage)
141 155
142 if oldOID == "" { 156 if oldOID == "" {
143 entries, err := g.listTreeRecursive(ctx, newOID) 157 entries, err := g.listTreeRecursive(ctx, newOID)
@@ -150,7 +164,7 @@ func (g *Generator) BuildBranch(ctx context.Context, branch, oldOID, newOID stri
150 dirs[entry.Name] = struct{}{} 164 dirs[entry.Name] = struct{}{}
151 continue 165 continue
152 } 166 }
153 pages, err := g.blobPages(ctx, branch, newOID, entry, commit, commitCount) 167 pages, err := g.blobPages(ctx, branch, newOID, entry, summary)
154 if err != nil { 168 if err != nil {
155 return Build{}, err 169 return Build{}, err
156 } 170 }
@@ -160,7 +174,7 @@ func (g *Generator) BuildBranch(ctx context.Context, branch, oldOID, newOID stri
160 if dir == "" && isHead { 174 if dir == "" && isHead {
161 continue 175 continue
162 } 176 }
163 page, err := g.treePage(ctx, branch, newOID, dir, commit, commitCount, false) 177 page, err := g.treePage(ctx, branch, newOID, dir, summary, false)
164 if err != nil { 178 if err != nil {
165 return Build{}, err 179 return Build{}, err
166 } 180 }
@@ -193,7 +207,7 @@ func (g *Generator) BuildBranch(ctx context.Context, branch, oldOID, newOID stri
193 if err != nil { 207 if err != nil {
194 return Build{}, err 208 return Build{}, err
195 } 209 }
196 pages, err := g.blobPages(ctx, branch, newOID, entry, commit, commitCount) 210 pages, err := g.blobPages(ctx, branch, newOID, entry, summary)
197 if err != nil { 211 if err != nil {
198 return Build{}, err 212 return Build{}, err
199 } 213 }
@@ -212,7 +226,7 @@ func (g *Generator) BuildBranch(ctx context.Context, branch, oldOID, newOID stri
212 result.Deletes = append(result.Deletes, path.Join("tree", branch, dir)) 226 result.Deletes = append(result.Deletes, path.Join("tree", branch, dir))
213 continue 227 continue
214 } 228 }
215 page, err := g.treePage(ctx, branch, newOID, dir, commit, commitCount, false) 229 page, err := g.treePage(ctx, branch, newOID, dir, summary, false)
216 if err != nil { 230 if err != nil {
217 return Build{}, err 231 return Build{}, err
218 } 232 }
@@ -221,7 +235,7 @@ func (g *Generator) BuildBranch(ctx context.Context, branch, oldOID, newOID stri
221 } 235 }
222 236
223 if isHead { 237 if isHead {
224 landing, err := g.treePage(ctx, branch, newOID, "", commit, commitCount, true) 238 landing, err := g.treePage(ctx, branch, newOID, "", summary, true)
225 if err != nil { 239 if err != nil {
226 return Build{}, err 240 return Build{}, err
227 } 241 }
@@ -284,7 +298,7 @@ sendPages:
284 return nil 298 return nil
285 } 299 }
286 300
287 func (g *Generator) treePage(ctx context.Context, branch, oid, dir string, commit Commit, commitCount int, root bool) (Page, error) { 301 func (g *Generator) treePage(ctx context.Context, branch, oid, dir string, summary branchSummary, root bool) (Page, error) {
288 entries, err := g.listTree(ctx, oid, dir) 302 entries, err := g.listTree(ctx, oid, dir)
289 if err != nil { 303 if err != nil {
290 return Page{}, err 304 return Page{}, err
@@ -297,20 +311,10 @@ func (g *Generator) treePage(ctx context.Context, branch, oid, dir string, commi
297 entries[i].URL = routeURL(g.BaseURL, path.Join("blob", branch, fullPath)) 311 entries[i].URL = routeURL(g.BaseURL, path.Join("blob", branch, fullPath))
298 } 312 }
299 } 313 }
300 data := pageData{ 314 data := g.pageData("tree", g.RepoName, branch, summary)
301 Kind: "tree", 315 data.Path = dir
302 Title: g.RepoName, 316 data.Breadcrumbs = g.breadcrumbs(branch, dir)
303 BaseURL: g.BaseURL, 317 data.Entries = entries
304 RepoName: g.RepoName,
305 Owner: g.Owner,
306 CloneURL: g.CloneURL,
307 Branch: branch,
308 Path: dir,
309 Breadcrumbs: g.breadcrumbs(branch, dir),
310 Entries: entries,
311 Commit: commit,
312 CommitCount: commitCount,
313 }
314 if dir != "" { 318 if dir != "" {
315 data.Title = path.Base(dir) + " · " + g.RepoName 319 data.Title = path.Base(dir) + " · " + g.RepoName
316 data.ParentURL = routeURL(g.BaseURL, path.Join("tree", branch, path.Dir(dir))) 320 data.ParentURL = routeURL(g.BaseURL, path.Join("tree", branch, path.Dir(dir)))
@@ -334,7 +338,7 @@ func (g *Generator) treePage(ctx context.Context, branch, oid, dir string, commi
334 return Page{Route: path.Join("tree", branch, dir), Body: body, ContentType: "text/html; charset=utf-8", Cache: siteCache}, nil 338 return Page{Route: path.Join("tree", branch, dir), Body: body, ContentType: "text/html; charset=utf-8", Cache: siteCache}, nil
335 } 339 }
336 340
337 func (g *Generator) blobPages(ctx context.Context, branch, oid string, entry TreeEntry, commit Commit, commitCount int) ([]Page, error) { 341 func (g *Generator) blobPages(ctx context.Context, branch, oid string, entry TreeEntry, summary branchSummary) ([]Page, error) {
338 var content []byte 342 var content []byte
339 var err error 343 var err error
340 if entry.Type == "commit" { 344 if entry.Type == "commit" {
@@ -350,22 +354,12 @@ func (g *Generator) blobPages(ctx context.Context, branch, oid string, entry Tre
350 if contentType == "" { 354 if contentType == "" {
351 contentType = "application/octet-stream" 355 contentType = "application/octet-stream"
352 } 356 }
353 data := pageData{ 357 data := g.pageData("blob", path.Base(entry.Name)+" · "+g.RepoName, branch, summary)
354 Kind: "blob", 358 data.Path = entry.Name
355 Title: path.Base(entry.Name) + " · " + g.RepoName, 359 data.Breadcrumbs = g.breadcrumbs(branch, entry.Name)
356 BaseURL: g.BaseURL, 360 data.RawURL = routeURL(g.BaseURL, rawRoute)
357 RepoName: g.RepoName, 361 data.FileSize = int64(len(content))
358 Owner: g.Owner, 362 data.IsImage = strings.HasPrefix(contentType, "image/")
359 CloneURL: g.CloneURL,
360 Branch: branch,
361 Path: entry.Name,
362 Breadcrumbs: g.breadcrumbs(branch, entry.Name),
363 Commit: commit,
364 CommitCount: commitCount,
365 RawURL: routeURL(g.BaseURL, rawRoute),
366 FileSize: int64(len(content)),
367 IsImage: strings.HasPrefix(contentType, "image/"),
368 }
369 if data.IsImage { 363 if data.IsImage {
370 data.Language = "Image" 364 data.Language = "Image"
371 } else if bytes.IndexByte(content, 0) >= 0 || !utf8.Valid(content) { 365 } else if bytes.IndexByte(content, 0) >= 0 || !utf8.Valid(content) {
@@ -454,31 +448,41 @@ func (g *Generator) render(data pageData) ([]byte, error) {
454 return result.Bytes(), nil 448 return result.Bytes(), nil
455 } 449 }
456 450
457 func (g *Generator) commit(ctx context.Context, oid string) (Commit, error) { 451 func (g *Generator) pageData(kind, title, branch string, summary branchSummary) pageData {
458 out, err := g.Git.Run(ctx, "show", "-s", "--format=%H%x00%h%x00%an%x00%cs%x00%s", oid) 452 return pageData{
459 if err != nil { 453 Kind: kind,
460 return Commit{}, err 454 Title: title,
461 } 455 BaseURL: g.BaseURL,
462 parts := strings.Split(strings.TrimSpace(string(out)), "\x00") 456 RepoName: g.RepoName,
463 if len(parts) != 5 { 457 Owner: summary.Repository.Owner,
464 return Commit{}, fmt.Errorf("unexpected git show output") 458 CloneURL: g.CloneURL,
465 } 459 Branch: branch,
466 return Commit{ 460 Commit: summary.Latest,
467 OID: parts[0], 461 CommitCount: summary.Count,
468 ShortOID: parts[1], 462 CommitsURL: summary.CommitsURL,
469 Author: parts[2], 463 CommitGroups: summary.Groups,
470 Date: parts[3], 464 Description: summary.Repository.Description,
471 Message: parts[4], 465 Homepage: summary.Repository.Homepage,
472 Initials: initials(parts[2]), 466 HomepageLabel: summary.Repository.HomepageLabel,
473 }, nil 467 SourceURL: summary.Repository.SourceURL,
474 } 468 Languages: summary.Repository.Languages,
475 469 Contributors: summary.Repository.Contributors,
476 func (g *Generator) commitCount(ctx context.Context, oid string) (int, error) { 470 ContributorCount: summary.Repository.ContributorCount,
477 out, err := g.Git.Run(ctx, "rev-list", "--count", oid) 471 }
472 }
473
474 func (g *Generator) commitsPage(branch string, summary branchSummary) (Page, error) {
475 data := g.pageData("commits", "Commits · "+g.RepoName, branch, summary)
476 body, err := g.render(data)
478 if err != nil { 477 if err != nil {
479 return 0, err 478 return Page{}, err
480 } 479 }
481 return strconv.Atoi(strings.TrimSpace(string(out))) 480 return Page{
481 Route: path.Join("commits", branch),
482 Body: body,
483 ContentType: "text/html; charset=utf-8",
484 Cache: siteCache,
485 }, nil
482 } 486 }
483 487
484 func (g *Generator) listTree(ctx context.Context, oid, dir string) ([]TreeEntry, error) { 488 func (g *Generator) listTree(ctx context.Context, oid, dir string) ([]TreeEntry, error) {
Modifiedinternal/site/site_test.go +29−2
@@ -31,8 +31,14 @@ func TestFullAndIncrementalBuild(t *testing.T) {
31 t.Fatal(err) 31 t.Fatal(err)
32 } 32 }
33 assertPage(t, full, "", "Hello bucket") 33 assertPage(t, full, "", "Hello bucket")
34 assertPage(t, full, "", "Clone with HTTPS")
35 assertPage(t, full, "", "Languages")
36 assertPage(t, full, "", "Contributors")
37 assertPage(t, full, "", "folder-svg")
34 assertPage(t, full, "blob/main/src/main.go", "chroma") 38 assertPage(t, full, "blob/main/src/main.go", "chroma")
35 assertPage(t, full, "tree/main/src", "main.go") 39 assertPage(t, full, "tree/main/src", "main.go")
40 assertPage(t, full, "commits/main", "Commit history")
41 assertPage(t, full, "commits/main", "gravatar.com/avatar")
36 42
37 write(t, dir, "src/main.go", "package main\n\nfunc main() { println(\"changed\") }\n") 43 write(t, dir, "src/main.go", "package main\n\nfunc main() { println(\"changed\") }\n")
38 runGit(t, dir, "add", "src/main.go") 44 runGit(t, dir, "add", "src/main.go")
@@ -42,11 +48,32 @@ func TestFullAndIncrementalBuild(t *testing.T) {
42 if err != nil { 48 if err != nil {
43 t.Fatal(err) 49 t.Fatal(err)
44 } 50 }
45 if len(incremental.Pages) != 5 { 51 if len(incremental.Pages) != 6 {
46 t.Fatalf("incremental build generated %d pages, want 5", len(incremental.Pages)) 52 t.Fatalf("incremental build generated %d pages, want 6", len(incremental.Pages))
47 } 53 }
48 assertPage(t, incremental, "blob/main/src/main.go", "changed") 54 assertPage(t, incremental, "blob/main/src/main.go", "changed")
49 assertPage(t, incremental, "tree/main/src", "change one file") 55 assertPage(t, incremental, "tree/main/src", "change one file")
56 assertPage(t, incremental, "commits/main", "change one file")
57 }
58
59 func TestReadmeMetadataFallbacks(t *testing.T) {
60 readme := []byte("# Project\n\n![badge](badge.svg)\n\nA **small** [Git host](https://example.invalid).\n\nVisit the [project homepage](https://project.example/docs).\n")
61 if got := readmeDescription(readme); got != "A small Git host." {
62 t.Fatalf("description = %q", got)
63 }
64 if got := readmeHomepage(readme); got != "https://project.example/docs" {
65 t.Fatalf("homepage = %q", got)
66 }
67 }
68
69 func TestGravatarUsesNormalizedSHA256(t *testing.T) {
70 url := gravatarURL(" [email protected] ", "User Example")
71 if !strings.Contains(url, "https://gravatar.com/avatar/b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514") {
72 t.Fatalf("unexpected Gravatar URL %q", url)
73 }
74 if !strings.Contains(url, "d=initials") || !strings.Contains(url, "name=User+Example") {
75 t.Fatalf("Gravatar URL has no initials fallback: %q", url)
76 }
50 } 77 }
51 78
52 func assertPage(t *testing.T, build Build, route, contains string) { 79 func assertPage(t *testing.T, build Build, route, contains string) {
Modifiedinternal/site/style.go +99−24
@@ -9,6 +9,7 @@ const siteCSS = `:root {
9 --muted: #656d76; 9 --muted: #656d76;
10 --blue: #0969da; 10 --blue: #0969da;
11 --green: #1f883d; 11 --green: #1f883d;
12 --green-hover: #1a7f37;
12 --nav: #25292e; 13 --nav: #25292e;
13 --shadow: 0 1px 0 rgba(31,35,40,.04); 14 --shadow: 0 1px 0 rgba(31,35,40,.04);
14 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 15 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
@@ -16,10 +17,13 @@ const siteCSS = `:root {
16 background: var(--bg); 17 background: var(--bg);
17 } 18 }
18 * { box-sizing: border-box; } 19 * { box-sizing: border-box; }
20 [hidden] { display: none !important; }
19 body { margin: 0; min-width: 320px; background: var(--bg); font-size: 14px; line-height: 1.5; } 21 body { margin: 0; min-width: 320px; background: var(--bg); font-size: 14px; line-height: 1.5; }
20 a { color: var(--blue); text-decoration: none; } 22 a { color: var(--blue); text-decoration: none; }
21 a:hover { text-decoration: underline; } 23 a:hover { text-decoration: underline; }
22 button { font: inherit; } 24 button, input { font: inherit; }
25 .icon { width: 16px; height: 16px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.35; stroke-linecap: round; stroke-linejoin: round; vertical-align: text-bottom; }
26 .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
23 .topbar { height: 64px; padding: 0 28px; display: flex; align-items: center; gap: 18px; background: var(--nav); color: white; } 27 .topbar { height: 64px; padding: 0 28px; display: flex; align-items: center; gap: 18px; background: var(--nav); color: white; }
24 .brand { display: inline-flex; align-items: center; gap: 9px; color: white; font-size: 16px; font-weight: 650; letter-spacing: -.2px; } 28 .brand { display: inline-flex; align-items: center; gap: 9px; color: white; font-size: 16px; font-weight: 650; letter-spacing: -.2px; }
25 .brand:hover { text-decoration: none; } 29 .brand:hover { text-decoration: none; }
@@ -28,7 +32,8 @@ button { font: inherit; }
28 .top-link { margin-left: auto; color: white; font-weight: 600; } 32 .top-link { margin-left: auto; color: white; font-weight: 600; }
29 .repo-head { background: var(--canvas); border-bottom: 1px solid var(--border); padding: 22px 32px 0; } 33 .repo-head { background: var(--canvas); border-bottom: 1px solid var(--border); padding: 22px 32px 0; }
30 .repo-title { max-width: 1280px; margin: 0 auto; display: flex; align-items: center; gap: 7px; font-size: 20px; } 34 .repo-title { max-width: 1280px; margin: 0 auto; display: flex; align-items: center; gap: 7px; font-size: 20px; }
31 .repo-icon { color: var(--muted); font-size: 20px; margin-right: 2px; } 35 .repo-icon { color: var(--muted); display: inline-flex; margin-right: 2px; }
36 .repo-icon .icon { width: 20px; height: 20px; }
32 .repo-name { font-weight: 650; } 37 .repo-name { font-weight: 650; }
33 .slash { color: var(--muted); } 38 .slash { color: var(--muted); }
34 .visibility { color: var(--muted); border: 1px solid var(--border); border-radius: 999px; padding: 1px 8px; font-size: 12px; font-weight: 600; margin-left: 5px; } 39 .visibility { color: var(--muted); border: 1px solid var(--border); border-radius: 999px; padding: 1px 8px; font-size: 12px; font-weight: 600; margin-left: 5px; }
@@ -37,32 +42,49 @@ button { font: inherit; }
37 .tab:hover { text-decoration: none; background: rgba(175,184,193,.16); border-radius: 6px 6px 0 0; } 42 .tab:hover { text-decoration: none; background: rgba(175,184,193,.16); border-radius: 6px 6px 0 0; }
38 .tab.active { font-weight: 650; } 43 .tab.active { font-weight: 650; }
39 .tab.active:after { content: ""; height: 2px; background: #fd8c73; position: absolute; left: 8px; right: 8px; bottom: -1px; } 44 .tab.active:after { content: ""; height: 2px; background: #fd8c73; position: absolute; left: 8px; right: 8px; bottom: -1px; }
45 .counter { display: inline-flex; align-items: center; justify-content: center; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px; background: rgba(175,184,193,.25); color: var(--text); font-size: 12px; font-weight: 600; }
40 .content-shell { max-width: 1280px; margin: 24px auto 64px; padding: 0 24px; } 46 .content-shell { max-width: 1280px; margin: 24px auto 64px; padding: 0 24px; }
41 .repo-grid { display: grid; grid-template-columns: minmax(0, 1fr) 270px; gap: 28px; } 47 .repo-grid { display: grid; grid-template-columns: minmax(0, 1fr) 285px; gap: 30px; }
42 .primary { min-width: 0; } 48 .primary { min-width: 0; }
43 .full-width { width: 100%; } 49 .full-width { width: 100%; }
44 .toolbar { min-height: 38px; margin-bottom: 14px; display: flex; align-items: center; gap: 10px; } 50 .toolbar { min-height: 38px; margin-bottom: 14px; display: flex; align-items: center; gap: 10px; }
45 .branch-button, .clone-button, .blob-actions a, .blob-actions button { min-height: 32px; border: 1px solid rgba(31,35,40,.15); border-radius: 6px; background: #f6f8fa; box-shadow: var(--shadow); color: var(--text); display: inline-flex; align-items: center; gap: 7px; padding: 5px 12px; font-weight: 600; font-size: 13px; } 51 .branch-button, .clone-button, .blob-actions a, .blob-actions button { min-height: 32px; border: 1px solid rgba(31,35,40,.15); border-radius: 6px; background: #f6f8fa; box-shadow: var(--shadow); color: var(--text); display: inline-flex; align-items: center; gap: 7px; padding: 5px 12px; font-weight: 600; font-size: 13px; }
46 .branch-icon { font-size: 17px; line-height: 1; } 52 .branch-button .icon-chevron { width: 12px; margin-left: 3px; color: var(--muted); }
47 .chevron { color: var(--muted); margin-left: 4px; } 53 .clone-menu { position: relative; margin-left: auto; }
48 .clone-button { margin-left: auto; background: var(--green); border-color: rgba(31,35,40,.15); color: white; cursor: pointer; } 54 .clone-button { background: var(--green); border-color: rgba(31,35,40,.15); color: white; cursor: pointer; }
55 .clone-button:hover { background: var(--green-hover); }
56 .clone-button .icon-chevron { width: 12px; }
57 .clone-popover { position: absolute; z-index: 10; top: calc(100% + 7px); right: 0; width: 380px; padding: 16px; border: 1px solid var(--border); border-radius: 8px; background: white; box-shadow: 0 8px 24px rgba(140,149,159,.35); }
58 .clone-popover:before { content: ""; position: absolute; top: -6px; right: 22px; width: 10px; height: 10px; transform: rotate(45deg); border-left: 1px solid var(--border); border-top: 1px solid var(--border); background: white; }
59 .clone-popover h3 { margin: 0; font-size: 14px; }
60 .clone-popover p { margin: 3px 0 12px; color: var(--muted); font-size: 12px; }
61 .clone-input-group { display: flex; }
62 .clone-input-group input { min-width: 0; flex: 1; height: 34px; padding: 5px 9px; border: 1px solid var(--border); border-radius: 6px 0 0 6px; color: var(--text); background: var(--canvas); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }
63 .clone-input-group button { width: 38px; display: grid; place-items: center; border: 1px solid var(--border); border-left: 0; border-radius: 0 6px 6px 0; color: var(--text); background: var(--canvas); cursor: pointer; }
64 .clone-input-group button:hover { background: #eef1f4; }
65 .clone-popover > code { display: block; margin-top: 12px; padding: 9px 10px; overflow: hidden; border-radius: 6px; color: var(--muted); background: var(--canvas); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
49 .toolbar-path { border-bottom: 1px solid var(--border-muted); padding-bottom: 14px; } 66 .toolbar-path { border-bottom: 1px solid var(--border-muted); padding-bottom: 14px; }
50 .breadcrumbs { min-width: 0; display: flex; align-items: center; gap: 6px; font-size: 16px; overflow: hidden; } 67 .breadcrumbs { min-width: 0; display: flex; align-items: center; gap: 6px; font-size: 16px; overflow: hidden; }
51 .breadcrumbs a { color: var(--text); white-space: nowrap; } 68 .breadcrumbs a { color: var(--text); white-space: nowrap; }
52 .breadcrumbs a:last-child { font-weight: 650; } 69 .breadcrumbs a:last-child { font-weight: 650; }
53 .breadcrumbs span { color: var(--muted); } 70 .breadcrumbs span { color: var(--muted); }
54 .tree-card, .readme-card, .blob-card { border: 1px solid var(--border); border-radius: 6px; overflow: hidden; background: var(--bg); } 71 .tree-card, .readme-card, .blob-card { border: 1px solid var(--border); border-radius: 6px; overflow: hidden; background: var(--bg); }
55 .commit-row { min-height: 54px; display: grid; grid-template-columns: 32px auto minmax(80px, 1fr) auto auto; align-items: center; gap: 9px; padding: 9px 16px; background: #f6f8fa; border-bottom: 1px solid var(--border); } 72 .commit-row { min-height: 54px; display: grid; grid-template-columns: 32px minmax(120px,1fr) auto auto auto; align-items: center; gap: 9px; padding: 9px 14px; background: var(--canvas); border-bottom: 1px solid var(--border); }
56 .avatar { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 50%; color: white; background: linear-gradient(135deg,#8250df,#0969da); font-size: 10px; font-weight: 700; } 73 .avatar { width: 28px; height: 28px; display: block; border-radius: 50%; object-fit: cover; background: #eaeef2; }
57 .commit-message { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 74 .latest-commit { min-width: 0; display: flex; align-items: baseline; gap: 9px; }
75 .latest-commit strong { flex: none; }
76 .commit-message { min-width: 0; overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; }
58 .commit-hash, .commit-row time { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; } 77 .commit-hash, .commit-row time { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }
59 .commit-row time { font-family: inherit; white-space: nowrap; } 78 .commit-row time { font-family: inherit; white-space: nowrap; }
79 .commit-count-link { min-height: 30px; display: inline-flex; align-items: center; gap: 5px; padding: 4px 9px; border-left: 1px solid var(--border-muted); color: var(--text); white-space: nowrap; }
80 .commit-count-link:hover { color: var(--blue); text-decoration: none; }
60 .tree-rows { width: 100%; } 81 .tree-rows { width: 100%; }
61 .tree-row { min-height: 42px; display: grid; grid-template-columns: 24px minmax(180px, 2fr) minmax(120px, 3fr) 75px; align-items: center; gap: 8px; padding: 6px 16px; color: var(--text); border-top: 1px solid var(--border-muted); } 82 .tree-row { min-height: 42px; display: grid; grid-template-columns: 24px minmax(180px, 2fr) minmax(120px, 3fr) 75px; align-items: center; gap: 8px; padding: 6px 16px; color: var(--text); border-top: 1px solid var(--border-muted); }
62 .tree-row:first-child { border-top: 0; } 83 .tree-row:first-child { border-top: 0; }
63 .tree-row:hover { background: #f6f8fa; text-decoration: none; } 84 .tree-row:hover { background: var(--canvas); text-decoration: none; }
64 .file-icon { color: var(--muted); font-size: 16px; } 85 .file-icon { display: inline-flex; color: #59636e; }
65 .file-icon.folder { color: #54aeff; } 86 .file-icon.folder { color: #54aeff; }
87 .file-icon .icon { width: 17px; height: 17px; stroke-width: 1.25; }
66 .file-name { color: var(--blue); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 88 .file-name { color: var(--blue); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
67 .row-message { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 89 .row-message { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
68 .file-size { color: var(--muted); text-align: right; font-size: 12px; white-space: nowrap; } 90 .file-size { color: var(--muted); text-align: right; font-size: 12px; white-space: nowrap; }
@@ -83,13 +105,28 @@ button { font: inherit; }
83 .markdown-body pre code { padding: 0; background: transparent; } 105 .markdown-body pre code { padding: 0; background: transparent; }
84 .markdown-body blockquote { margin-left: 0; padding-left: 1em; color: var(--muted); border-left: .25em solid var(--border); } 106 .markdown-body blockquote { margin-left: 0; padding-left: 1em; color: var(--muted); border-left: .25em solid var(--border); }
85 .sidebar { padding-top: 48px; } 107 .sidebar { padding-top: 48px; }
86 .sidebar h2 { font-size: 16px; margin: 0 0 10px; } 108 .sidebar-section { padding: 0 0 20px; margin: 0 0 20px; border-bottom: 1px solid var(--border-muted); }
87 .sidebar h3 { font-size: 13px; margin: 0 0 8px; } 109 .sidebar-section:last-child { border-bottom: 0; }
88 .sidebar p { color: var(--muted); margin: 0; } 110 .sidebar h2 { margin: 0 0 10px; font-size: 16px; }
89 .sidebar-rule { height: 1px; background: var(--border-muted); margin: 20px 0; } 111 .section-title { display: flex; align-items: center; justify-content: space-between; }
90 .clone-url { display: block; max-width: 100%; padding: 8px 10px; border-radius: 6px; background: var(--canvas); overflow: hidden; text-overflow: ellipsis; font-size: 11px; white-space: nowrap; } 112 .section-title > span:first-child { display: inline-flex; align-items: center; gap: 7px; }
91 .stat { display: flex; gap: 6px; padding: 3px 0; } 113 .about-description { margin: 0 0 12px; color: var(--text); font-size: 16px; line-height: 1.45; }
92 .stat span { color: var(--muted); } 114 .about-description.muted { color: var(--muted); }
115 .about-link { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 4px 0; color: var(--blue); font-weight: 600; }
116 .about-link span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
117 .contributors-list { display: flex; flex-direction: column; gap: 8px; }
118 .contributor { display: grid; grid-template-columns: 32px minmax(0,1fr) auto; align-items: center; gap: 8px; }
119 .contributor-avatar { width: 32px; height: 32px; }
120 .contributor-name { overflow: hidden; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
121 .contributor-count { color: var(--muted); font-size: 12px; }
122 .language-bar { height: 10px; display: flex; overflow: hidden; margin: 12px 0 13px; border-radius: 999px; background: #eaeef2; }
123 .language-segment { width: var(--language-width); min-width: 2px; background: var(--language-color); }
124 .language-segment + .language-segment { border-left: 2px solid white; }
125 .language-list { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 6px 12px; padding: 0; margin: 0; list-style: none; }
126 .language-list li { min-width: 0; display: grid; grid-template-columns: 10px minmax(0,1fr) auto; align-items: center; gap: 6px; font-size: 12px; }
127 .language-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
128 .language-list li > span:last-child { color: var(--muted); }
129 .language-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--language-color); }
93 .blob-card { border-radius: 6px; } 130 .blob-card { border-radius: 6px; }
94 .blob-heading { min-height: 54px; padding: 10px 14px; display: flex; align-items: center; gap: 12px; background: var(--canvas); border-bottom: 1px solid var(--border); } 131 .blob-heading { min-height: 54px; padding: 10px 14px; display: flex; align-items: center; gap: 12px; background: var(--canvas); border-bottom: 1px solid var(--border); }
95 .blob-heading > div:first-child { min-width: 0; display: flex; flex-direction: column; } 132 .blob-heading > div:first-child { min-width: 0; display: flex; flex-direction: column; }
@@ -97,6 +134,7 @@ button { font: inherit; }
97 .blob-heading span { color: var(--muted); font-size: 12px; } 134 .blob-heading span { color: var(--muted); font-size: 12px; }
98 .blob-actions { margin-left: auto; display: flex; gap: 6px; } 135 .blob-actions { margin-left: auto; display: flex; gap: 6px; }
99 .blob-actions a, .blob-actions button { min-height: 28px; padding: 3px 10px; cursor: pointer; } 136 .blob-actions a, .blob-actions button { min-height: 28px; padding: 3px 10px; cursor: pointer; }
137 .blob-actions button .icon { width: 14px; height: 14px; }
100 .code-view { overflow: auto; font: 12px/1.5 ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; } 138 .code-view { overflow: auto; font: 12px/1.5 ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; }
101 .code-view .chroma { margin: 0; padding: 0; background: white; } 139 .code-view .chroma { margin: 0; padding: 0; background: white; }
102 .code-view .chroma table { width: 100%; border-spacing: 0; } 140 .code-view .chroma table { width: 100%; border-spacing: 0; }
@@ -106,18 +144,55 @@ button { font: inherit; }
106 .image-preview { padding: 32px; display: grid; place-items: center; min-height: 260px; background: repeating-conic-gradient(#f6f8fa 0 25%, #fff 0 50%) 50%/20px 20px; } 144 .image-preview { padding: 32px; display: grid; place-items: center; min-height: 260px; background: repeating-conic-gradient(#f6f8fa 0 25%, #fff 0 50%) 50%/20px 20px; }
107 .image-preview img { max-width: 100%; max-height: 70vh; } 145 .image-preview img { max-width: 100%; max-height: 70vh; }
108 .binary-notice { padding: 70px 20px; display: flex; flex-direction: column; align-items: center; color: var(--muted); } 146 .binary-notice { padding: 70px 20px; display: flex; flex-direction: column; align-items: center; color: var(--muted); }
109 .binary-notice > span { font-size: 44px; } 147 .binary-notice > span .icon { width: 44px; height: 44px; }
110 .binary-notice strong { color: var(--text); font-size: 16px; } 148 .binary-notice strong { color: var(--text); font-size: 16px; }
111 .binary-notice p { margin: 5px 0 14px; } 149 .binary-notice p { margin: 5px 0 14px; }
150 .history-toolbar { justify-content: space-between; padding-bottom: 14px; border-bottom: 1px solid var(--border-muted); }
151 .history-total { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); }
152 .history-heading { margin: 22px 0 24px; }
153 .history-heading h1 { margin: 0; font-size: 24px; }
154 .history-heading p { margin: 4px 0 0; color: var(--muted); }
155 .commit-group { margin: 0 0 28px; }
156 .commit-group > h2 { margin: 0 0 9px; font-size: 15px; font-weight: 600; }
157 .commit-list { overflow: hidden; border: 1px solid var(--border); border-radius: 7px; }
158 .commit-list-row { min-height: 67px; display: grid; grid-template-columns: 38px minmax(0,1fr) auto; align-items: center; gap: 11px; padding: 10px 14px; border-top: 1px solid var(--border-muted); }
159 .commit-list-row:first-child { border-top: 0; }
160 .commit-list-row:target { background: #fff8c5; }
161 .commit-list-row:hover { background: var(--canvas); }
162 .commit-list-row:target:hover { background: #fff8c5; }
163 .commit-list-row .commit-avatar { width: 34px; height: 34px; }
164 .commit-list-body { min-width: 0; display: flex; flex-direction: column; }
165 .commit-list-message { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
166 .commit-list-body > span { color: var(--muted); font-size: 12px; }
167 .commit-list-body > span strong { color: var(--text); }
168 .commit-sha { padding: 4px 8px; border: 1px solid var(--border); border-radius: 6px; color: var(--text); background: var(--canvas); font: 12px ui-monospace, SFMono-Regular, Consolas, monospace; }
169 .commit-sha:hover { color: var(--blue); text-decoration: none; }
112 footer { min-height: 90px; max-width: 1232px; margin: 0 auto; border-top: 1px solid var(--border-muted); color: var(--muted); display: flex; justify-content: center; gap: 28px; align-items: center; font-size: 12px; } 170 footer { min-height: 90px; max-width: 1232px; margin: 0 auto; border-top: 1px solid var(--border-muted); color: var(--muted); display: flex; justify-content: center; gap: 28px; align-items: center; font-size: 12px; }
113 @media (max-width: 800px) { 171 @media (max-width: 900px) {
114 .topbar { padding: 0 16px; }.top-search,.top-link { display: none; } 172 .repo-grid { grid-template-columns: 1fr; }
173 .sidebar { padding-top: 0; display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 0 24px; }
174 .about-section { grid-column: 1 / -1; }
175 .commit-row { grid-template-columns: 32px minmax(0,1fr) auto; }
176 .commit-hash, .commit-row time { display: none; }
177 }
178 @media (max-width: 650px) {
179 .topbar { padding: 0 16px; }
180 .top-search, .top-link { display: none; }
115 .repo-head { padding-left: 16px; padding-right: 16px; } 181 .repo-head { padding-left: 16px; padding-right: 16px; }
182 .repo-title { font-size: 17px; }
116 .content-shell { padding: 0 12px; margin-top: 16px; } 183 .content-shell { padding: 0 12px; margin-top: 16px; }
117 .repo-grid { grid-template-columns: 1fr; }.sidebar { padding-top: 0; } 184 .sidebar { display: block; }
118 .commit-row { grid-template-columns: 32px auto 1fr; }.commit-hash,.commit-row time { display: none; } 185 .commit-row { grid-template-columns: 32px minmax(0,1fr) auto; }
119 .tree-row { grid-template-columns: 22px minmax(100px,1fr) 70px; }.row-message { display: none; } 186 .commit-count-link { border-left: 0; padding-right: 0; }
187 .commit-count-link strong { display: none; }
188 .latest-commit strong { display: none; }
189 .tree-row { grid-template-columns: 22px minmax(100px,1fr) 70px; }
190 .row-message { display: none; }
120 .markdown-body { padding: 22px 18px; } 191 .markdown-body { padding: 22px 18px; }
121 .toolbar-path { align-items: flex-start; flex-direction: column; } 192 .toolbar-path { align-items: flex-start; flex-direction: column; }
193 .clone-popover { position: fixed; top: 74px; left: 12px; right: 12px; width: auto; }
194 .clone-popover:before { display: none; }
195 .commit-list-row { grid-template-columns: 34px minmax(0,1fr); }
196 .commit-sha { display: none; }
122 footer { flex-direction: column; gap: 4px; } 197 footer { flex-direction: column; gap: 4px; }
123 }` 198 }`
Modifiedinternal/site/template.go +125−27
@@ -21,13 +21,13 @@ const pageTemplate = `<!doctype html>
21 <main> 21 <main>
22 <section class="repo-head"> 22 <section class="repo-head">
23 <div class="repo-title"> 23 <div class="repo-title">
24 <span class="repo-icon"></span> 24 <span class="repo-icon">{{icon "repo"}}</span>
25 <a href="{{.BaseURL}}">{{.Owner}}</a><span class="slash">/</span><a class="repo-name" href="{{.BaseURL}}">{{.RepoName}}</a> 25 <a href="{{.BaseURL}}">{{.Owner}}</a><span class="slash">/</span><a class="repo-name" href="{{.BaseURL}}">{{.RepoName}}</a>
26 <span class="visibility">Public</span> 26 <span class="visibility">Public</span>
27 </div> 27 </div>
28 <nav class="tabs"> 28 <nav class="tabs" aria-label="Repository">
29 <a class="tab active" href="{{.BaseURL}}"><span>‹›</span> Code</a> 29 <a class="tab {{if ne .Kind "commits"}}active{{end}}" href="{{.BaseURL}}">{{icon "code"}} Code</a>
30 <a class="tab" href="{{.BaseURL}}"><span></span> {{.CommitCount}} commits</a> 30 <a class="tab {{if eq .Kind "commits"}}active{{end}}" href="{{.CommitsURL}}">{{icon "history"}} Commits <span class="counter">{{.CommitCount}}</span></a>
31 </nav> 31 </nav>
32 </section> 32 </section>
33 33
@@ -36,73 +36,171 @@ const pageTemplate = `<!doctype html>
36 <div class="repo-grid"> 36 <div class="repo-grid">
37 <section class="primary"> 37 <section class="primary">
38 <div class="toolbar"> 38 <div class="toolbar">
39 <span class="branch-button"><span class="branch-icon"></span>{{.Branch}}<span class="chevron"></span></span> 39 <span class="branch-button">{{icon "branch"}}{{.Branch}}{{icon "chevron"}}</span>
40 <button class="clone-button" data-copy="{{.CloneURL}}"><span></span> Clone</button> 40 {{template "clone-menu" .}}
41 </div> 41 </div>
42 {{template "tree" .}} 42 {{template "tree" .}}
43 {{if .Readme}} 43 {{if .Readme}}
44 <article class="readme-card"> 44 <article class="readme-card">
45 <div class="card-heading"><span></span>{{.ReadmeName}}</div> 45 <div class="card-heading">{{icon "book"}}{{.ReadmeName}}</div>
46 <div class="markdown-body">{{.Readme}}</div> 46 <div class="markdown-body">{{.Readme}}</div>
47 </article> 47 </article>
48 {{end}} 48 {{end}}
49 </section> 49 </section>
50 <aside class="sidebar"> 50 <aside class="sidebar">
51 <h2>About</h2> 51 <section class="sidebar-section about-section">
52 <p>A Git repository hosted directly from an object-storage bucket.</p> 52 <h2>About</h2>
53 <div class="sidebar-rule"></div> 53 {{if .Description}}<p class="about-description">{{.Description}}</p>{{else}}<p class="about-description muted">No description provided.</p>{{end}}
54 <h3>Clone over HTTPS</h3> 54 {{if .Homepage}}<a class="about-link" href="{{.Homepage}}">{{icon "link"}}<span>{{.HomepageLabel}}</span></a>{{end}}
55 <code class="clone-url">{{.CloneURL}}</code> 55 {{if and .SourceURL (ne .SourceURL .Homepage)}}<a class="about-link" href="{{.SourceURL}}">{{icon "external"}}<span>Upstream repository</span></a>{{end}}
56 <div class="sidebar-rule"></div> 56 </section>
57 <div class="stat"><strong>{{.CommitCount}}</strong><span>commits</span></div> 57 {{if .Contributors}}
58 <div class="stat"><strong>{{len .Entries}}</strong><span>top-level items</span></div> 58 <section class="sidebar-section">
59 <h2 class="section-title"><span>{{icon "people"}} Contributors</span><span class="counter">{{.ContributorCount}}</span></h2>
60 <div class="contributors-list">
61 {{range .Contributors}}<div class="contributor">
62 <img class="avatar contributor-avatar" src="{{.AvatarURL}}" alt="" loading="lazy" referrerpolicy="no-referrer">
63 <span class="contributor-name">{{.Name}}</span>
64 <span class="contributor-count">{{.Commits}}</span>
65 </div>{{end}}
66 </div>
67 </section>
68 {{end}}
69 {{if .Languages}}
70 <section class="sidebar-section">
71 <h2>Languages</h2>
72 <div class="language-bar" aria-label="Repository languages">
73 {{range .Languages}}<span class="language-segment" style="{{.Style}}" title="{{.Name}} {{.Percent}}%"></span>{{end}}
74 </div>
75 <ul class="language-list">
76 {{range .Languages}}<li style="{{.Style}}"><span class="language-dot"></span><strong>{{.Name}}</strong><span>{{.Percent}}%</span></li>{{end}}
77 </ul>
78 </section>
79 {{end}}
59 </aside> 80 </aside>
60 </div> 81 </div>
61 {{else if eq .Kind "tree"}} 82 {{else if eq .Kind "tree"}}
62 <section class="primary full-width"> 83 <section class="primary full-width">
63 <div class="toolbar toolbar-path"> 84 <div class="toolbar toolbar-path">
64 <span class="branch-button"><span class="branch-icon"></span>{{.Branch}}<span class="chevron"></span></span> 85 <span class="branch-button">{{icon "branch"}}{{.Branch}}{{icon "chevron"}}</span>
65 <div class="breadcrumbs">{{range $i, $crumb := .Breadcrumbs}}{{if $i}}<span>/</span>{{end}}<a href="{{$crumb.URL}}">{{$crumb.Name}}</a>{{end}}</div> 86 <div class="breadcrumbs">{{range $i, $crumb := .Breadcrumbs}}{{if $i}}<span>/</span>{{end}}<a href="{{$crumb.URL}}">{{$crumb.Name}}</a>{{end}}</div>
66 </div> 87 </div>
67 {{template "tree" .}} 88 {{template "tree" .}}
68 </section> 89 </section>
90 {{else if eq .Kind "commits"}}
91 <section class="primary full-width commit-history">
92 <div class="toolbar history-toolbar">
93 <span class="branch-button">{{icon "branch"}}{{.Branch}}{{icon "chevron"}}</span>
94 <span class="history-total">{{icon "history"}} {{.CommitCount}} commits</span>
95 </div>
96 <div class="history-heading"><h1>Commit history</h1><p>Browse every commit reachable from <strong>{{.Branch}}</strong>.</p></div>
97 {{range .CommitGroups}}<section class="commit-group">
98 <h2>Commits on {{.Date}}</h2>
99 <div class="commit-list">
100 {{range .Commits}}<article class="commit-list-row" id="{{.OID}}">
101 <img class="avatar commit-avatar" src="{{.AvatarURL}}" alt="" loading="lazy" referrerpolicy="no-referrer">
102 <div class="commit-list-body">
103 <strong class="commit-list-message">{{.Message}}</strong>
104 <span><strong>{{.Author}}</strong> committed on <time datetime="{{.DateTime}}">{{.Date}}</time></span>
105 </div>
106 <a class="commit-sha" href="#{{.OID}}">{{.ShortOID}}</a>
107 </article>{{end}}
108 </div>
109 </section>{{end}}
110 </section>
69 {{else}} 111 {{else}}
70 <section class="primary full-width"> 112 <section class="primary full-width">
71 <div class="toolbar toolbar-path"> 113 <div class="toolbar toolbar-path">
72 <span class="branch-button"><span class="branch-icon"></span>{{.Branch}}<span class="chevron"></span></span> 114 <span class="branch-button">{{icon "branch"}}{{.Branch}}{{icon "chevron"}}</span>
73 <div class="breadcrumbs">{{range $i, $crumb := .Breadcrumbs}}{{if $i}}<span>/</span>{{end}}<a href="{{$crumb.URL}}">{{$crumb.Name}}</a>{{end}}</div> 115 <div class="breadcrumbs">{{range $i, $crumb := .Breadcrumbs}}{{if $i}}<span>/</span>{{end}}<a href="{{$crumb.URL}}">{{$crumb.Name}}</a>{{end}}</div>
74 </div> 116 </div>
75 <div class="blob-card"> 117 <div class="blob-card">
76 <div class="blob-heading"> 118 <div class="blob-heading">
77 <div><strong>{{.Path}}</strong><span>{{humanSize .FileSize}} · {{.Language}}</span></div> 119 <div><strong>{{.Path}}</strong><span>{{humanSize .FileSize}} · {{.Language}}</span></div>
78 <div class="blob-actions"><a href="{{.RawURL}}">Raw</a><button data-copy="{{.RawURL}}">Copy link</button></div> 120 <div class="blob-actions"><a href="{{.RawURL}}">Raw</a><button type="button" data-copy="{{.RawURL}}">{{icon "copy"}}<span data-copy-label>Copy link</span></button></div>
79 </div> 121 </div>
80 {{if .IsImage}}<div class="image-preview"><img src="{{.RawURL}}" alt="{{.Path}}"></div> 122 {{if .IsImage}}<div class="image-preview"><img src="{{.RawURL}}" alt="{{.Path}}"></div>
81 {{else if .Code}}<div class="code-view">{{.Code}}</div> 123 {{else if .Code}}<div class="code-view">{{.Code}}</div>
82 {{else}}<div class="binary-notice"><span></span><strong>Preview unavailable</strong><p>This file is binary or too large to render safely.</p><a href="{{.RawURL}}">Download raw file</a></div>{{end}} 124 {{else}}<div class="binary-notice"><span>{{icon "file"}}</span><strong>Preview unavailable</strong><p>This file is binary or too large to render safely.</p><a href="{{.RawURL}}">Download raw file</a></div>{{end}}
83 </div> 125 </div>
84 </section> 126 </section>
85 {{end}} 127 {{end}}
86 </div> 128 </div>
87 </main> 129 </main>
88 <footer><span>Forged from a static bucket.</span><span>Git data and pages share one URL.</span></footer> 130 <footer><span>Forged from a static bucket.</span><span>Git data and pages share one URL.</span></footer>
89 <script>document.querySelectorAll('[data-copy]').forEach(function(el){el.addEventListener('click',function(){navigator.clipboard.writeText(el.getAttribute('data-copy'));var old=el.textContent;el.textContent='Copied';setTimeout(function(){el.textContent=old},1200)})})</script> 131 <script>
132 (function () {
133 function copyText(value, button) {
134 var done = function () {
135 var label = button.querySelector('[data-copy-label]');
136 if (!label) return;
137 var old = label.textContent;
138 label.textContent = 'Copied!';
139 setTimeout(function () { label.textContent = old; }, 1200);
140 };
141 if (navigator.clipboard && window.isSecureContext) {
142 navigator.clipboard.writeText(value).then(done);
143 return;
144 }
145 var input = document.createElement('textarea');
146 input.value = value;
147 input.style.position = 'fixed';
148 input.style.opacity = '0';
149 document.body.appendChild(input);
150 input.select();
151 document.execCommand('copy');
152 input.remove();
153 done();
154 }
155 document.querySelectorAll('[data-copy]').forEach(function (button) {
156 button.addEventListener('click', function () { copyText(button.getAttribute('data-copy'), button); });
157 });
158 var menu = document.querySelector('[data-clone-menu]');
159 if (!menu) return;
160 var toggle = menu.querySelector('[data-clone-toggle]');
161 var popover = menu.querySelector('[data-clone-popover]');
162 var close = function () { popover.hidden = true; toggle.setAttribute('aria-expanded', 'false'); };
163 toggle.addEventListener('click', function () {
164 var opening = !popover.hidden;
165 popover.hidden = opening;
166 toggle.setAttribute('aria-expanded', opening ? 'false' : 'true');
167 if (!opening) popover.querySelector('input').select();
168 });
169 document.addEventListener('click', function (event) { if (!menu.contains(event.target)) close(); });
170 document.addEventListener('keydown', function (event) { if (event.key === 'Escape') { close(); toggle.focus(); } });
171 }());
172 </script>
90 </body> 173 </body>
91 </html> 174 </html>
92 175
176 {{define "clone-menu"}}
177 <div class="clone-menu" data-clone-menu>
178 <button class="clone-button" type="button" data-clone-toggle aria-expanded="false">{{icon "download"}} Clone {{icon "chevron"}}</button>
179 <div class="clone-popover" data-clone-popover hidden>
180 <h3>Clone with HTTPS</h3>
181 <p>Use Git without installing Dumbforge.</p>
182 <div class="clone-input-group">
183 <input type="text" value="{{.CloneURL}}" readonly aria-label="Clone URL">
184 <button type="button" data-copy="{{.CloneURL}}" aria-label="Copy clone URL">{{icon "copy"}}<span class="sr-only" data-copy-label>Copy</span></button>
185 </div>
186 <code>git clone {{.CloneURL}}</code>
187 </div>
188 </div>
189 {{end}}
190
93 {{define "tree"}} 191 {{define "tree"}}
94 <div class="tree-card"> 192 <div class="tree-card">
95 <div class="commit-row"> 193 <div class="commit-row">
96 <span class="avatar">{{.Commit.Initials}}</span> 194 <img class="avatar commit-avatar" src="{{.Commit.AvatarURL}}" alt="" referrerpolicy="no-referrer">
97 <strong>{{.Commit.Author}}</strong> 195 <div class="latest-commit"><strong>{{.Commit.Author}}</strong><span class="commit-message">{{.Commit.Message}}</span></div>
98 <span class="commit-message">{{.Commit.Message}}</span> 196 <a class="commit-hash" href="{{.CommitsURL}}#{{.Commit.OID}}">{{.Commit.ShortOID}}</a>
99 <a class="commit-hash" href="#">{{.Commit.ShortOID}}</a> 197 <time datetime="{{.Commit.DateTime}}">{{.Commit.Date}}</time>
100 <time>{{.Commit.Date}}</time> 198 <a class="commit-count-link" href="{{.CommitsURL}}">{{icon "history"}}<strong>{{.CommitCount}}</strong> commits</a>
101 </div> 199 </div>
102 <div class="tree-rows"> 200 <div class="tree-rows">
103 {{if .ParentURL}}<a class="tree-row parent-row" href="{{.ParentURL}}"><span class="file-icon folder"></span><span class="file-name">..</span><span></span><span></span></a>{{end}} 201 {{if .ParentURL}}<a class="tree-row parent-row" href="{{.ParentURL}}"><span class="file-icon">{{icon "back"}}</span><span class="file-name">..</span><span></span><span></span></a>{{end}}
104 {{range .Entries}}<a class="tree-row" href="{{.URL}}"> 202 {{range .Entries}}<a class="tree-row" href="{{.URL}}">
105 {{if eq .Type "tree"}}<span class="file-icon folder"></span>{{else}}<span class="file-icon"></span>{{end}} 203 {{if eq .Type "tree"}}<span class="file-icon folder">{{icon "folder"}}</span>{{else}}<span class="file-icon">{{icon "file"}}</span>{{end}}
106 <span class="file-name">{{.Name}}</span> 204 <span class="file-name">{{.Name}}</span>
107 <span class="row-message">{{$.Commit.Message}}</span> 205 <span class="row-message">{{$.Commit.Message}}</span>
108 <span class="file-size">{{humanSize .Size}}</span> 206 <span class="file-size">{{humanSize .Size}}</span>