package site import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "html/template" "net/url" "path" "regexp" "sort" "strings" "time" enry "github.com/go-enry/go-enry/v2" ) type CommitGroup struct { Date string Commits []Commit } type Contributor struct { Name string Email string AvatarURL string Commits int } type LanguageStat struct { Name string Bytes int64 Percent string Style template.CSS } type repositoryMeta struct { Owner string Description string Homepage string HomepageLabel string SourceURL string Languages []LanguageStat Contributors []Contributor ContributorCount int } type branchSummary struct { Latest Commit Count int CommitsURL string Groups []CommitGroup Repository repositoryMeta } type packageMetadata struct { Description string `json:"description"` Homepage string `json:"homepage"` Repository json.RawMessage `json:"repository"` } func (g *Generator) branchSummary(ctx context.Context, branch, oid string) (branchSummary, error) { commits, err := g.commits(ctx, oid) if err != nil { return branchSummary{}, err } if len(commits) == 0 { return branchSummary{}, fmt.Errorf("branch %s has no commits", branch) } repository, err := g.repositoryMetadata(ctx, oid, commits) if err != nil { return branchSummary{}, err } return branchSummary{ Latest: commits[0], Count: len(commits), CommitsURL: routeURL(g.BaseURL, path.Join("commits", branchRouteSegment(branch))), Groups: groupCommits(commits), Repository: repository, }, nil } func (g *Generator) commits(ctx context.Context, oid string) ([]Commit, error) { out, err := g.Git.Run(ctx, "log", "--format=%H%x00%h%x00%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%s%x00%b%x00%P%x00", oid) if err != nil { return nil, err } fields := strings.Split(string(out), "\x00") if len(fields) > 0 && strings.TrimSpace(fields[len(fields)-1]) == "" { fields = fields[:len(fields)-1] } if len(fields)%11 != 0 { return nil, fmt.Errorf("unexpected git log output") } result := make([]Commit, 0, len(fields)/11) for i := 0; i < len(fields); i += 11 { author := strings.TrimSpace(fields[i+2]) email := strings.TrimSpace(fields[i+3]) committer := strings.TrimSpace(fields[i+5]) committerEmail := strings.TrimSpace(fields[i+6]) dateTime := strings.TrimSpace(fields[i+7]) result = append(result, Commit{ OID: strings.TrimSpace(fields[i]), ShortOID: strings.TrimSpace(fields[i+1]), Author: author, Email: email, Committer: committer, CommitterEmail: committerEmail, Date: displayDate(dateTime), DateTime: dateTime, Message: strings.TrimSpace(fields[i+8]), Body: strings.TrimSpace(fields[i+9]), Initials: initials(author), AvatarURL: gravatarURL(email, author), CommitterAvatarURL: gravatarURL(committerEmail, committer), DistinctCommitter: !sameCommitIdentity(author, email, committer, committerEmail), URL: routeURL(g.BaseURL, path.Join("commit", strings.TrimSpace(fields[i]))), Parents: strings.Fields(fields[i+10]), }) } return result, nil } func sameCommitIdentity(author, authorEmail, committer, committerEmail string) bool { return strings.EqualFold(strings.TrimSpace(author), strings.TrimSpace(committer)) && strings.EqualFold(strings.TrimSpace(authorEmail), strings.TrimSpace(committerEmail)) } func groupCommits(commits []Commit) []CommitGroup { var groups []CommitGroup for _, commit := range commits { if len(groups) == 0 || groups[len(groups)-1].Date != commit.Date { groups = append(groups, CommitGroup{Date: commit.Date}) } groups[len(groups)-1].Commits = append(groups[len(groups)-1].Commits, commit) } return groups } func displayDate(value string) string { parsed, err := time.Parse(time.RFC3339, value) if err != nil { return value } return parsed.Format("Jan 2, 2006") } func gravatarURL(email, name string) string { normalized := strings.ToLower(strings.TrimSpace(email)) hash := sha256.Sum256([]byte(normalized)) query := url.Values{ "d": {"initials"}, "name": {name}, "r": {"g"}, "s": {"64"}, } return "https://gravatar.com/avatar/" + hex.EncodeToString(hash[:]) + "?" + query.Encode() } func (g *Generator) repositoryMetadata(ctx context.Context, oid string, commits []Commit) (repositoryMeta, error) { meta := repositoryMeta{Owner: g.Owner} if value := g.gitConfig(ctx, "dumbforge.description"); value != "" { meta.Description = value } if value := g.gitConfig(ctx, "dumbforge.homepage"); value != "" { meta.Homepage = value } packageInfo := g.packageMetadata(ctx, oid) if meta.Description == "" { meta.Description = packageInfo.Description } if meta.Homepage == "" { meta.Homepage = packageInfo.Homepage } meta.SourceURL = packageRepositoryURL(packageInfo.Repository) if meta.SourceURL == "" { meta.SourceURL = normalizeRepositoryURL(g.gitConfig(ctx, "remote.origin.url")) } readme := g.rootReadme(ctx, oid) if meta.Description == "" { meta.Description = readmeDescription(readme) } if meta.Homepage == "" { meta.Homepage = readmeHomepage(readme) } if meta.Homepage == "" { meta.Homepage = meta.SourceURL } meta.HomepageLabel = linkLabel(meta.Homepage) if owner := repositoryOwner(meta.SourceURL); owner != "" { meta.Owner = owner } meta.Contributors, meta.ContributorCount = topContributors(commits, 6) languages, err := g.languageStats(ctx, oid) if err != nil { return repositoryMeta{}, err } meta.Languages = languages return meta, nil } func (g *Generator) gitConfig(ctx context.Context, key string) string { out, err := g.Git.Run(ctx, "config", "--get", key) if err != nil { return "" } return strings.TrimSpace(string(out)) } func (g *Generator) packageMetadata(ctx context.Context, oid string) packageMetadata { out, err := g.Git.Run(ctx, "show", oid+":package.json") if err != nil { return packageMetadata{} } var result packageMetadata if json.Unmarshal(out, &result) != nil { return packageMetadata{} } return result } func packageRepositoryURL(raw json.RawMessage) string { if len(raw) == 0 { return "" } var value string if json.Unmarshal(raw, &value) == nil { return normalizeRepositoryURL(value) } var object struct { URL string `json:"url"` } if json.Unmarshal(raw, &object) == nil { return normalizeRepositoryURL(object.URL) } return "" } func normalizeRepositoryURL(value string) string { value = strings.TrimSpace(strings.TrimPrefix(value, "git+")) if strings.HasPrefix(value, "git@") { parts := strings.SplitN(strings.TrimPrefix(value, "git@"), ":", 2) if len(parts) == 2 { value = "https://" + parts[0] + "/" + parts[1] } } if strings.HasPrefix(value, "ssh://git@") { value = "https://" + strings.TrimPrefix(value, "ssh://git@") } parsed, err := url.Parse(value) if err == nil { switch strings.ToLower(parsed.Hostname()) { case "github.com", "gitlab.com", "bitbucket.org", "codeberg.org", "gitea.com": parsed.Path = strings.TrimSuffix(parsed.Path, ".git") return parsed.String() } } return value } func repositoryOwner(value string) string { parsed, err := url.Parse(value) if err != nil || parsed.Host == "" { return "" } parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") if len(parts) < 2 { return "" } return parts[len(parts)-2] } func linkLabel(value string) string { parsed, err := url.Parse(value) if err != nil || parsed.Host == "" { return value } return strings.TrimSuffix(parsed.Host+parsed.EscapedPath(), "/") } func (g *Generator) rootReadme(ctx context.Context, oid string) []byte { entries, err := g.listTree(ctx, oid, "") if err != nil { return nil } for _, entry := range entries { if entry.Type == "blob" && strings.HasPrefix(strings.ToLower(entry.Name), "readme") { content, err := g.Git.Run(ctx, "show", oid+":"+entry.Name) if err == nil { return content } } } return nil } func readmeDescription(content []byte) string { lines := strings.Split(string(content), "\n") var paragraph []string inFence := false for _, raw := range lines { line := strings.TrimSpace(raw) if strings.HasPrefix(line, "```") || strings.HasPrefix(line, "~~~") { inFence = !inFence continue } if inFence { continue } if line == "" { if len(paragraph) > 0 { break } continue } if len(paragraph) == 0 && (strings.HasPrefix(line, "#") || strings.HasPrefix(line, "![") || isRule(line) || strings.HasPrefix(line, "<")) { continue } paragraph = append(paragraph, line) } return cleanMarkdown(strings.Join(paragraph, " ")) } func isRule(line string) bool { compact := strings.ReplaceAll(strings.ReplaceAll(line, " ", ""), "\t", "") return len(compact) >= 3 && (strings.Trim(compact, "-") == "" || strings.Trim(compact, "*") == "" || strings.Trim(compact, "_") == "") } var ( markdownLinkPattern = regexp.MustCompile(`!?\[([^]]*)\]\([^)]+\)`) markdownMarkPattern = regexp.MustCompile("[`*_~]+") homepagePattern = regexp.MustCompile(`(?i)\[([^]]*homepage[^]]*)\]\((https?://[^ )]+)\)`) ) func cleanMarkdown(value string) string { value = markdownLinkPattern.ReplaceAllString(value, "$1") value = markdownMarkPattern.ReplaceAllString(value, "") return strings.TrimSpace(value) } func readmeHomepage(content []byte) string { match := homepagePattern.FindSubmatch(content) if len(match) != 3 { return "" } return string(match[2]) } func topContributors(commits []Commit, limit int) ([]Contributor, int) { contributors := map[string]*Contributor{} for _, commit := range commits { key := strings.ToLower(strings.TrimSpace(commit.Email)) if key == "" { key = "name:" + strings.ToLower(commit.Author) } contributor := contributors[key] if contributor == nil { contributor = &Contributor{Name: commit.Author, Email: commit.Email, AvatarURL: commit.AvatarURL} contributors[key] = contributor } contributor.Commits++ } result := make([]Contributor, 0, len(contributors)) for _, contributor := range contributors { result = append(result, *contributor) } sort.Slice(result, func(i, j int) bool { if result[i].Commits != result[j].Commits { return result[i].Commits > result[j].Commits } return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name) }) if len(result) > limit { result = result[:limit] } return result, len(contributors) } func (g *Generator) languageStats(ctx context.Context, oid string) ([]LanguageStat, error) { entries, err := g.listTreeRecursive(ctx, oid) if err != nil { return nil, err } bytesByLanguage := map[string]int64{} var total int64 for _, entry := range entries { if entry.Type != "blob" || entry.Size <= 0 || enry.IsVendor(entry.Name) || enry.IsDocumentation(entry.Name) || enry.IsConfiguration(entry.Name) || enry.IsImage(entry.Name) { continue } language, safe := enry.GetLanguageByFilename(entry.Name) if !safe || language == "" { language, safe = enry.GetLanguageByExtension(entry.Name) } if !safe || language == "" { continue } languageType := enry.GetLanguageType(language) if languageType != enry.Programming && languageType != enry.Markup { continue } if group := enry.GetLanguageGroup(language); group != "" { language = group } bytesByLanguage[language] += entry.Size total += entry.Size } if total == 0 { return nil, nil } result := make([]LanguageStat, 0, len(bytesByLanguage)) for language, size := range bytesByLanguage { percentage := float64(size) * 100 / float64(total) color := enry.GetColor(language) if color == "" { color = fallbackLanguageColor(language) } result = append(result, LanguageStat{ Name: language, Bytes: size, Percent: fmt.Sprintf("%.1f", percentage), // #nosec G203 -- color is from enry or a generated hex value; width is numeric. Style: template.CSS(fmt.Sprintf("--language-color:%s;--language-width:%.4f%%", color, percentage)), }) } sort.Slice(result, func(i, j int) bool { if result[i].Bytes != result[j].Bytes { return result[i].Bytes > result[j].Bytes } return result[i].Name < result[j].Name }) return result, nil } func fallbackLanguageColor(language string) string { hash := sha256.Sum256([]byte(language)) return fmt.Sprintf("#%02x%02x%02x", 72+hash[0]%128, 72+hash[1]%128, 72+hash[2]%128) }