summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-29 10:17:46 +0300
committerPaul Buetow <paul@buetow.org>2026-05-29 10:17:46 +0300
commit2b60fb406a178edb1822fb62b28b4e07e77dc0ed (patch)
treeb677f69bf7ede39643b82bab28de4c439b97422f
parent2d62ca0240f985703f98c430ff5f16214c41f708 (diff)
refactor(showcase): decompose formatGemtext and guard zero totals (oq)
-rw-r--r--internal/showcase/showcase.go292
-rw-r--r--internal/showcase/showcase_test.go40
2 files changed, 197 insertions, 135 deletions
diff --git a/internal/showcase/showcase.go b/internal/showcase/showcase.go
index 7b80de6..90a4dc7 100644
--- a/internal/showcase/showcase.go
+++ b/internal/showcase/showcase.go
@@ -860,10 +860,29 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool
return projectSummary, nil
}
-// formatGemtext formats the summaries as Gemini Gemtext
+type showcaseOverviewStats struct {
+ totalProjects int
+ totalCommits int
+ totalLOC int
+ totalDocs int
+ releasedCount int
+ languageStats []LanguageStats
+ docStats []LanguageStats
+}
+
+// formatGemtext formats the summaries as Gemini Gemtext.
func (g *Generator) formatGemtext(summaries []ProjectSummary) string {
var builder strings.Builder
+ writeGemtextHeader(&builder)
+ stats := collectShowcaseOverviewStats(summaries)
+ writeShowcaseOverviewStats(&builder, stats)
+ g.writeProjectsGemtext(&builder, summaries)
+
+ return builder.String()
+}
+
+func writeGemtextHeader(builder *strings.Builder) {
// Header
builder.WriteString("# Project Showcase\n\n")
@@ -879,192 +898,195 @@ func (g *Generator) formatGemtext(summaries []ProjectSummary) string {
// Template inline TOC
builder.WriteString("<< template::inline::toc\n\n")
+}
- // Calculate total stats
- totalProjects := len(summaries)
- totalCommits := 0
- totalLOC := 0
- totalDocs := 0
- releasedCount := 0
+func collectShowcaseOverviewStats(summaries []ProjectSummary) showcaseOverviewStats {
+ stats := showcaseOverviewStats{
+ totalProjects: len(summaries),
+ }
languageTotals := make(map[string]int)
docTotals := make(map[string]int)
for _, summary := range summaries {
- if summary.Metadata != nil {
- totalCommits += summary.Metadata.CommitCount
- totalLOC += summary.Metadata.LinesOfCode
- totalDocs += summary.Metadata.LinesOfDocs
+ if summary.Metadata == nil {
+ continue
+ }
- // Count projects with releases
- if summary.Metadata.HasReleases {
- releasedCount++
- }
+ stats.totalCommits += summary.Metadata.CommitCount
+ stats.totalLOC += summary.Metadata.LinesOfCode
+ stats.totalDocs += summary.Metadata.LinesOfDocs
- // Aggregate language statistics
- for _, lang := range summary.Metadata.Languages {
- languageTotals[lang.Name] += lang.Lines
- }
+ if summary.Metadata.HasReleases {
+ stats.releasedCount++
+ }
- // Aggregate documentation statistics
- for _, doc := range summary.Metadata.Documentation {
- docTotals[doc.Name] += doc.Lines
- }
+ for _, lang := range summary.Metadata.Languages {
+ languageTotals[lang.Name] += lang.Lines
}
- }
- // Calculate language percentages
- var languageStats []LanguageStats
- for name, lines := range languageTotals {
- percentage := 0.0
- if totalLOC > 0 {
- percentage = float64(lines) * 100.0 / float64(totalLOC)
+ for _, doc := range summary.Metadata.Documentation {
+ docTotals[doc.Name] += doc.Lines
}
- languageStats = append(languageStats, LanguageStats{
- Name: name,
- Lines: lines,
- Percentage: percentage,
- })
}
- // Sort languages by percentage
- sort.Slice(languageStats, func(i, j int) bool {
- return languageStats[i].Percentage > languageStats[j].Percentage
- })
+ stats.languageStats = buildSortedPercentageStats(languageTotals, stats.totalLOC)
+ stats.docStats = buildSortedPercentageStats(docTotals, stats.totalDocs)
+ return stats
+}
- // Calculate documentation percentages
- var docStats []LanguageStats
- for name, lines := range docTotals {
+func buildSortedPercentageStats(lineTotalsByName map[string]int, totalLines int) []LanguageStats {
+ stats := make([]LanguageStats, 0, len(lineTotalsByName))
+ for name, lines := range lineTotalsByName {
percentage := 0.0
- if totalDocs > 0 {
- percentage = float64(lines) * 100.0 / float64(totalDocs)
+ if totalLines > 0 {
+ percentage = float64(lines) * 100.0 / float64(totalLines)
}
- docStats = append(docStats, LanguageStats{
+ stats = append(stats, LanguageStats{
Name: name,
Lines: lines,
Percentage: percentage,
})
}
- // Sort documentation by percentage
- sort.Slice(docStats, func(i, j int) bool {
- return docStats[i].Percentage > docStats[j].Percentage
+ sort.Slice(stats, func(i, j int) bool {
+ return stats[i].Percentage > stats[j].Percentage
})
+ return stats
+}
- // Write total stats section
+func writeShowcaseOverviewStats(builder *strings.Builder, stats showcaseOverviewStats) {
builder.WriteString("## Overall Statistics\n\n")
- builder.WriteString(fmt.Sprintf("* ๐Ÿ“ฆ Total Projects: %d\n", totalProjects))
- builder.WriteString(fmt.Sprintf("* ๐Ÿ“Š Total Commits: %s\n", formatNumber(totalCommits)))
- builder.WriteString(fmt.Sprintf("* ๐Ÿ“ˆ Total Lines of Code: %s\n", formatNumber(totalLOC)))
- if totalDocs > 0 {
- builder.WriteString(fmt.Sprintf("* ๐Ÿ“„ Total Lines of Documentation: %s\n", formatNumber(totalDocs)))
+ builder.WriteString(fmt.Sprintf("* ๐Ÿ“ฆ Total Projects: %d\n", stats.totalProjects))
+ builder.WriteString(fmt.Sprintf("* ๐Ÿ“Š Total Commits: %s\n", formatNumber(stats.totalCommits)))
+ builder.WriteString(fmt.Sprintf("* ๐Ÿ“ˆ Total Lines of Code: %s\n", formatNumber(stats.totalLOC)))
+ if stats.totalDocs > 0 {
+ builder.WriteString(fmt.Sprintf("* ๐Ÿ“„ Total Lines of Documentation: %s\n", formatNumber(stats.totalDocs)))
}
- if len(languageStats) > 0 {
- builder.WriteString(fmt.Sprintf("* ๐Ÿ’ป Languages: %s\n", FormatLanguagesWithPercentages(languageStats)))
+ if len(stats.languageStats) > 0 {
+ builder.WriteString(fmt.Sprintf("* ๐Ÿ’ป Languages: %s\n", FormatLanguagesWithPercentages(stats.languageStats)))
}
- if len(docStats) > 0 {
- builder.WriteString(fmt.Sprintf("* ๐Ÿ“š Documentation: %s\n", FormatLanguagesWithPercentages(docStats)))
+ if len(stats.docStats) > 0 {
+ builder.WriteString(fmt.Sprintf("* ๐Ÿ“š Documentation: %s\n", FormatLanguagesWithPercentages(stats.docStats)))
}
- experimentalCount := totalProjects - releasedCount
+
+ experimentalCount, releasedPercentage, experimentalPercentage := releaseStatusBreakdown(stats.totalProjects, stats.releasedCount)
builder.WriteString(fmt.Sprintf("* ๐Ÿš€ Release Status: %d released, %d experimental (%.1f%% with releases, %.1f%% experimental)\n",
- releasedCount, experimentalCount,
- float64(releasedCount)*100/float64(totalProjects),
- float64(experimentalCount)*100/float64(totalProjects)))
+ stats.releasedCount, experimentalCount, releasedPercentage, experimentalPercentage))
builder.WriteString("\n")
+}
+
+func releaseStatusBreakdown(totalProjects, releasedCount int) (experimentalCount int, releasedPercentage, experimentalPercentage float64) {
+ experimentalCount = totalProjects - releasedCount
+ if totalProjects == 0 {
+ return experimentalCount, 0, 0
+ }
+ releasedPercentage = float64(releasedCount) * 100 / float64(totalProjects)
+ experimentalPercentage = float64(experimentalCount) * 100 / float64(totalProjects)
+ return experimentalCount, releasedPercentage, experimentalPercentage
+}
- // Add Projects section
+func (g *Generator) writeProjectsGemtext(builder *strings.Builder, summaries []ProjectSummary) {
builder.WriteString("## Projects\n\n")
- // Add each project
for i, summary := range summaries {
if i > 0 {
builder.WriteString("\n---\n\n")
}
- builder.WriteString(fmt.Sprintf("### %d. %s%s\n\n", i+1, summary.Name, formatRankHistoryForHeader(summary.RankHistory)))
+ g.writeProjectGemtext(builder, i, summary)
+ }
+}
- // Add metadata if available
- if summary.Metadata != nil {
- if len(summary.Metadata.Languages) > 0 {
- builder.WriteString(fmt.Sprintf("* ๐Ÿ’ป Languages: %s\n", FormatLanguagesWithPercentages(summary.Metadata.Languages)))
- }
- if len(summary.Metadata.Documentation) > 0 {
- builder.WriteString(fmt.Sprintf("* ๐Ÿ“š Documentation: %s\n", FormatLanguagesWithPercentages(summary.Metadata.Documentation)))
- }
- builder.WriteString(fmt.Sprintf("* ๐Ÿ“Š Commits: %d\n", summary.Metadata.CommitCount))
- builder.WriteString(fmt.Sprintf("* ๐Ÿ“ˆ Lines of Code: %d\n", summary.Metadata.LinesOfCode))
- if summary.Metadata.LinesOfDocs > 0 {
- builder.WriteString(fmt.Sprintf("* ๐Ÿ“„ Lines of Documentation: %d\n", summary.Metadata.LinesOfDocs))
- }
- builder.WriteString(fmt.Sprintf("* ๐Ÿท๏ธ Tags: %d\n", summary.Metadata.TagCount))
- builder.WriteString(fmt.Sprintf("* ๐Ÿ“… Development Period: %s to %s\n", summary.Metadata.FirstCommitDate, summary.Metadata.LastCommitDate))
- builder.WriteString(fmt.Sprintf("* ๐Ÿ† Score: %.1f (combines recent activity, code size, tags, and release status)\n", summary.Metadata.Score))
- builder.WriteString(fmt.Sprintf("* โš–๏ธ License: %s\n", summary.Metadata.License))
-
- // Add release information or experimental status
- if summary.Metadata.HasReleases && summary.Metadata.LatestTag != "" {
- if summary.Metadata.LatestTagDate != "" {
- builder.WriteString(fmt.Sprintf("* ๐Ÿท๏ธ Latest Release: %s (%s)\n", summary.Metadata.LatestTag, summary.Metadata.LatestTagDate))
- } else {
- builder.WriteString(fmt.Sprintf("* ๐Ÿท๏ธ Latest Release: %s\n", summary.Metadata.LatestTag))
- }
- } else {
- builder.WriteString("* ๐Ÿงช Status: Experimental (no releases yet)\n")
- }
+func (g *Generator) writeProjectGemtext(builder *strings.Builder, index int, summary ProjectSummary) {
+ builder.WriteString(fmt.Sprintf("### %d. %s%s\n\n", index+1, summary.Name, formatRankHistoryForHeader(summary.RankHistory)))
+ writeProjectMetadata(builder, summary.Metadata)
+ writeProjectSummaryContent(builder, summary)
+ writeProjectLinks(builder, summary)
+}
- // Mark as inactive when the average age of the last 42 commits exceeds
- // 730 days (~2 years). A single recent commit (e.g. a deprecation
- // notice) does not rescue a dormant project โ€” ~42 recent commits are
- // required to move the average below the threshold. This matches the
- // grey-line rule used in the interactive rank-history SVG.
- if summary.Metadata.AvgCommitAge > 730 {
- builder.WriteString("\nโš ๏ธ **Notice**: This project appears to be inactive or no longer maintained. The average age of its last 42 commits exceeds 2 years. Use at your own risk.")
- }
- builder.WriteString("\n\n")
- }
+func writeProjectMetadata(builder *strings.Builder, metadata *RepoMetadata) {
+ if metadata == nil {
+ return
+ }
- // Handle images and paragraphs
- paragraphs := splitSummaryParagraphs(sanitizeSummaryForGemtext(summary.Summary))
+ if len(metadata.Languages) > 0 {
+ builder.WriteString(fmt.Sprintf("* ๐Ÿ’ป Languages: %s\n", FormatLanguagesWithPercentages(metadata.Languages)))
+ }
+ if len(metadata.Documentation) > 0 {
+ builder.WriteString(fmt.Sprintf("* ๐Ÿ“š Documentation: %s\n", FormatLanguagesWithPercentages(metadata.Documentation)))
+ }
+ builder.WriteString(fmt.Sprintf("* ๐Ÿ“Š Commits: %d\n", metadata.CommitCount))
+ builder.WriteString(fmt.Sprintf("* ๐Ÿ“ˆ Lines of Code: %d\n", metadata.LinesOfCode))
+ if metadata.LinesOfDocs > 0 {
+ builder.WriteString(fmt.Sprintf("* ๐Ÿ“„ Lines of Documentation: %d\n", metadata.LinesOfDocs))
+ }
+ builder.WriteString(fmt.Sprintf("* ๐Ÿท๏ธ Tags: %d\n", metadata.TagCount))
+ builder.WriteString(fmt.Sprintf("* ๐Ÿ“… Development Period: %s to %s\n", metadata.FirstCommitDate, metadata.LastCommitDate))
+ builder.WriteString(fmt.Sprintf("* ๐Ÿ† Score: %.1f (combines recent activity, code size, tags, and release status)\n", metadata.Score))
+ builder.WriteString(fmt.Sprintf("* โš–๏ธ License: %s\n", metadata.License))
- // If we have images, distribute them nicely
- if len(summary.Images) > 0 {
- // First image after metadata, before text
- builder.WriteString(fmt.Sprintf("=> %s %s screenshot\n\n", summary.Images[0], summary.Name))
+ // Add release information or experimental status.
+ if metadata.HasReleases && metadata.LatestTag != "" {
+ if metadata.LatestTagDate != "" {
+ builder.WriteString(fmt.Sprintf("* ๐Ÿท๏ธ Latest Release: %s (%s)\n", metadata.LatestTag, metadata.LatestTagDate))
+ } else {
+ builder.WriteString(fmt.Sprintf("* ๐Ÿท๏ธ Latest Release: %s\n", metadata.LatestTag))
+ }
+ } else {
+ builder.WriteString("* ๐Ÿงช Status: Experimental (no releases yet)\n")
+ }
- // First paragraph
- if len(paragraphs) > 0 {
- builder.WriteString(fmt.Sprintf("%s\n\n", strings.TrimSpace(paragraphs[0])))
- }
+ // Mark as inactive when the average age of the last 42 commits exceeds
+ // 730 days (~2 years). A single recent commit (e.g. a deprecation
+ // notice) does not rescue a dormant project โ€” ~42 recent commits are
+ // required to move the average below the threshold. This matches the
+ // grey-line rule used in the interactive rank-history SVG.
+ if metadata.AvgCommitAge > 730 {
+ builder.WriteString("\nโš ๏ธ **Notice**: This project appears to be inactive or no longer maintained. The average age of its last 42 commits exceeds 2 years. Use at your own risk.")
+ }
+ builder.WriteString("\n\n")
+}
- // Second image after first paragraph (if we have 2 images and multiple paragraphs)
- if len(summary.Images) > 1 && len(paragraphs) > 1 {
- builder.WriteString(fmt.Sprintf("=> %s %s screenshot\n\n", summary.Images[1], summary.Name))
- }
+func writeProjectSummaryContent(builder *strings.Builder, summary ProjectSummary) {
+ paragraphs := splitSummaryParagraphs(sanitizeSummaryForGemtext(summary.Summary))
- // Remaining paragraphs
- for i := 1; i < len(paragraphs); i++ {
- builder.WriteString(fmt.Sprintf("%s\n\n", strings.TrimSpace(paragraphs[i])))
- }
- } else {
- // No images - just add all paragraphs
- for _, para := range paragraphs {
- builder.WriteString(fmt.Sprintf("%s\n\n", strings.TrimSpace(para)))
- }
- }
+ if len(summary.Images) > 0 {
+ // First image after metadata, before text.
+ builder.WriteString(fmt.Sprintf("=> %s %s screenshot\n\n", summary.Images[0], summary.Name))
- // Add links
- if summary.CodebergURL != "" {
- builder.WriteString(fmt.Sprintf("=> %s View on Codeberg\n", summary.CodebergURL))
+ // First paragraph.
+ if len(paragraphs) > 0 {
+ builder.WriteString(fmt.Sprintf("%s\n\n", strings.TrimSpace(paragraphs[0])))
}
- if summary.GitHubURL != "" {
- builder.WriteString(fmt.Sprintf("=> %s View on GitHub\n", summary.GitHubURL))
+
+ // Second image after first paragraph (if we have 2 images and multiple paragraphs).
+ if len(summary.Images) > 1 && len(paragraphs) > 1 {
+ builder.WriteString(fmt.Sprintf("=> %s %s screenshot\n\n", summary.Images[1], summary.Name))
}
- if summary.CgitURL != "" {
- builder.WriteString(fmt.Sprintf("=> %s View in cgit\n", summary.CgitURL))
+
+ // Remaining paragraphs.
+ for i := 1; i < len(paragraphs); i++ {
+ builder.WriteString(fmt.Sprintf("%s\n\n", strings.TrimSpace(paragraphs[i])))
}
+ return
+ }
+ // No images - just add all paragraphs.
+ for _, para := range paragraphs {
+ builder.WriteString(fmt.Sprintf("%s\n\n", strings.TrimSpace(para)))
}
+}
- return builder.String()
+func writeProjectLinks(builder *strings.Builder, summary ProjectSummary) {
+ if summary.CodebergURL != "" {
+ builder.WriteString(fmt.Sprintf("=> %s View on Codeberg\n", summary.CodebergURL))
+ }
+ if summary.GitHubURL != "" {
+ builder.WriteString(fmt.Sprintf("=> %s View on GitHub\n", summary.GitHubURL))
+ }
+ if summary.CgitURL != "" {
+ builder.WriteString(fmt.Sprintf("=> %s View in cgit\n", summary.CgitURL))
+ }
}
// showcaseOutputDir returns the canonical directory where showcase output files
diff --git a/internal/showcase/showcase_test.go b/internal/showcase/showcase_test.go
index ca40293..6341e41 100644
--- a/internal/showcase/showcase_test.go
+++ b/internal/showcase/showcase_test.go
@@ -167,6 +167,46 @@ func TestFormatGemtext_IncludesCgitLink(t *testing.T) {
}
}
+func TestFormatGemtext_ZeroProjectsReleasePercentagesAreZero(t *testing.T) {
+ t.Parallel()
+
+ g := &Generator{config: &config.Config{}}
+ content := g.formatGemtext(nil)
+
+ if !strings.Contains(content, "* ๐Ÿš€ Release Status: 0 released, 0 experimental (0.0% with releases, 0.0% experimental)\n") {
+ t.Fatalf("unexpected release status for zero projects: %s", content)
+ }
+ if strings.Contains(content, "NaN") || strings.Contains(content, "Inf") {
+ t.Fatalf("release status should not include NaN/Inf: %s", content)
+ }
+}
+
+func TestFormatGemtext_ReleaseStatusPercentagesForNonEmptySummaries(t *testing.T) {
+ t.Parallel()
+
+ g := &Generator{config: &config.Config{}}
+ content := g.formatGemtext([]ProjectSummary{
+ {
+ Name: "released",
+ Summary: "released summary",
+ Metadata: &RepoMetadata{
+ HasReleases: true,
+ },
+ },
+ {
+ Name: "experimental",
+ Summary: "experimental summary",
+ Metadata: &RepoMetadata{
+ HasReleases: false,
+ },
+ },
+ })
+
+ if !strings.Contains(content, "* ๐Ÿš€ Release Status: 1 released, 1 experimental (50.0% with releases, 50.0% experimental)\n") {
+ t.Fatalf("unexpected release status for non-empty summaries: %s", content)
+ }
+}
+
func TestFindReadmeContent_UsesRepoPathWithoutChangingCWD(t *testing.T) {
t.Parallel()