summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/showcase/gemtext_format.go237
-rw-r--r--internal/showcase/gemtext_sanitize.go330
-rw-r--r--internal/showcase/showcase.go556
3 files changed, 567 insertions, 556 deletions
diff --git a/internal/showcase/gemtext_format.go b/internal/showcase/gemtext_format.go
new file mode 100644
index 0000000..11935e7
--- /dev/null
+++ b/internal/showcase/gemtext_format.go
@@ -0,0 +1,237 @@
+package showcase
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+)
+
+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")
+
+ // Generated date at the top
+ builder.WriteString(fmt.Sprintf("Generated on: %s\n\n", time.Now().Format("2006-01-02")))
+
+ // Link to the interactive SVG rank history graph โ€” placed at the very top
+ // so it is the first thing a reader sees after the date line.
+ builder.WriteString("=> showcase-rank-history.svg Interactive Project Rank History Graph (SVG)\n\n")
+
+ // Introduction paragraph
+ builder.WriteString("This page showcases my side projects, providing an overview of what each project does, its technical implementation, and key metrics. Each project summary includes information about the programming languages used, development activity, releases, and licensing. The projects are ranked by score, which combines recent activity, project size, tag history, and whether the project has shipped a release.\n\n")
+
+ // Template inline TOC
+ builder.WriteString("<< template::inline::toc\n\n")
+}
+
+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 {
+ continue
+ }
+
+ stats.totalCommits += summary.Metadata.CommitCount
+ stats.totalLOC += summary.Metadata.LinesOfCode
+ stats.totalDocs += summary.Metadata.LinesOfDocs
+
+ if summary.Metadata.HasReleases {
+ stats.releasedCount++
+ }
+
+ for _, lang := range summary.Metadata.Languages {
+ languageTotals[lang.Name] += lang.Lines
+ }
+
+ for _, doc := range summary.Metadata.Documentation {
+ docTotals[doc.Name] += doc.Lines
+ }
+ }
+
+ stats.languageStats = buildSortedPercentageStats(languageTotals, stats.totalLOC)
+ stats.docStats = buildSortedPercentageStats(docTotals, stats.totalDocs)
+ return stats
+}
+
+func buildSortedPercentageStats(lineTotalsByName map[string]int, totalLines int) []LanguageStats {
+ stats := make([]LanguageStats, 0, len(lineTotalsByName))
+ for name, lines := range lineTotalsByName {
+ percentage := 0.0
+ if totalLines > 0 {
+ percentage = float64(lines) * 100.0 / float64(totalLines)
+ }
+ stats = append(stats, LanguageStats{
+ Name: name,
+ Lines: lines,
+ Percentage: percentage,
+ })
+ }
+
+ sort.Slice(stats, func(i, j int) bool {
+ return stats[i].Percentage > stats[j].Percentage
+ })
+ return stats
+}
+
+func writeShowcaseOverviewStats(builder *strings.Builder, stats showcaseOverviewStats) {
+ builder.WriteString("## Overall Statistics\n\n")
+ 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(stats.languageStats) > 0 {
+ builder.WriteString(fmt.Sprintf("* ๐Ÿ’ป Languages: %s\n", FormatLanguagesWithPercentages(stats.languageStats)))
+ }
+ if len(stats.docStats) > 0 {
+ builder.WriteString(fmt.Sprintf("* ๐Ÿ“š Documentation: %s\n", FormatLanguagesWithPercentages(stats.docStats)))
+ }
+
+ experimentalCount, releasedPercentage, experimentalPercentage := releaseStatusBreakdown(stats.totalProjects, stats.releasedCount)
+ builder.WriteString(fmt.Sprintf("* ๐Ÿš€ Release Status: %d released, %d experimental (%.1f%% with releases, %.1f%% experimental)\n",
+ 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
+}
+
+func (g *Generator) writeProjectsGemtext(builder *strings.Builder, summaries []ProjectSummary) {
+ builder.WriteString("## Projects\n\n")
+
+ for i, summary := range summaries {
+ if i > 0 {
+ builder.WriteString("\n---\n\n")
+ }
+
+ g.writeProjectGemtext(builder, i, summary)
+ }
+}
+
+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)
+}
+
+func writeProjectMetadata(builder *strings.Builder, metadata *RepoMetadata) {
+ if metadata == nil {
+ return
+ }
+
+ 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))
+
+ // 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")
+ }
+
+ // 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")
+}
+
+func writeProjectSummaryContent(builder *strings.Builder, summary ProjectSummary) {
+ paragraphs := splitSummaryParagraphs(sanitizeSummaryForGemtext(summary.Summary))
+
+ 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))
+
+ // First paragraph.
+ if len(paragraphs) > 0 {
+ builder.WriteString(fmt.Sprintf("%s\n\n", strings.TrimSpace(paragraphs[0])))
+ }
+
+ // 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))
+ }
+
+ // 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)))
+ }
+}
+
+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))
+ }
+}
diff --git a/internal/showcase/gemtext_sanitize.go b/internal/showcase/gemtext_sanitize.go
new file mode 100644
index 0000000..12d7419
--- /dev/null
+++ b/internal/showcase/gemtext_sanitize.go
@@ -0,0 +1,330 @@
+package showcase
+
+import "strings"
+
+func extractUsefulSummary(text string, maxParagraphs int) string {
+ if maxParagraphs <= 0 {
+ maxParagraphs = 1
+ }
+
+ parts := splitSummaryParagraphs(text)
+ useful := make([]string, 0, maxParagraphs)
+
+ for _, part := range parts {
+ part = normalizeSummaryParagraph(part)
+ if part == "" {
+ continue
+ }
+
+ useful = append(useful, part)
+ if len(useful) >= maxParagraphs {
+ break
+ }
+ }
+
+ return strings.Join(useful, "\n\n")
+}
+
+func normalizeSummaryParagraph(paragraph string) string {
+ rawParagraph := strings.TrimSpace(paragraph)
+ switch {
+ case isHeadingOnlyParagraph(rawParagraph):
+ return ""
+ case isImageOnlyParagraph(rawParagraph):
+ return ""
+ case isHTMLOnlyParagraph(rawParagraph):
+ return ""
+ case isTOCParagraph(rawParagraph):
+ return ""
+ case isListOnlyParagraph(rawParagraph):
+ return ""
+ case isBadgeParagraph(rawParagraph):
+ return ""
+ }
+
+ paragraph = sanitizeSummaryForGemtext(paragraph)
+ if paragraph == "" {
+ return ""
+ }
+
+ if normalized, ok := normalizeManpageParagraph(paragraph); ok {
+ paragraph = normalized
+ }
+
+ if isLabelOnlyParagraph(paragraph) {
+ return ""
+ }
+
+ return paragraph
+}
+
+func splitSummaryParagraphs(text string) []string {
+ text = strings.ReplaceAll(text, "\r\n", "\n")
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return nil
+ }
+
+ rawParts := strings.Split(text, "\n\n")
+ parts := make([]string, 0, len(rawParts))
+ for _, part := range rawParts {
+ part = strings.TrimSpace(part)
+ if part == "" {
+ continue
+ }
+ parts = append(parts, part)
+ }
+
+ return parts
+}
+
+func sanitizeSummaryForGemtext(summary string) string {
+ summary = strings.ReplaceAll(summary, "\r\n", "\n")
+ summary = strings.TrimSpace(summary)
+ if summary == "" {
+ return ""
+ }
+
+ lines := strings.Split(summary, "\n")
+ cleaned := make([]string, 0, len(lines))
+ inCodeFence := false
+
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" {
+ if inCodeFence {
+ continue
+ }
+ cleaned = append(cleaned, "")
+ continue
+ }
+
+ if strings.HasPrefix(trimmed, "```") {
+ inCodeFence = !inCodeFence
+ continue
+ }
+ if inCodeFence {
+ continue
+ }
+
+ if isHTMLOnlyLine(trimmed) || isMarkdownImageLine(trimmed) {
+ continue
+ }
+
+ if isSetextUnderline(trimmed) && len(cleaned) > 0 && strings.TrimSpace(cleaned[len(cleaned)-1]) != "" {
+ continue
+ }
+
+ if heading, ok := trimMarkdownHeading(trimmed); ok {
+ if heading != "" {
+ cleaned = append(cleaned, heading)
+ }
+ continue
+ }
+
+ cleaned = append(cleaned, strings.TrimRight(line, " \t"))
+ }
+
+ return strings.TrimSpace(strings.Join(cleaned, "\n"))
+}
+
+func isHeadingOnlyParagraph(paragraph string) bool {
+ lines := strings.Split(strings.TrimSpace(strings.ReplaceAll(paragraph, "\r\n", "\n")), "\n")
+ if len(lines) == 1 {
+ _, ok := trimMarkdownHeading(strings.TrimSpace(lines[0]))
+ return ok
+ }
+ if len(lines) == 2 {
+ return strings.TrimSpace(lines[0]) != "" && isSetextUnderline(strings.TrimSpace(lines[1]))
+ }
+ return false
+}
+
+func isImageOnlyParagraph(paragraph string) bool {
+ trimmed := strings.TrimSpace(paragraph)
+ if trimmed == "" || strings.Contains(trimmed, "\n") {
+ return false
+ }
+ return strings.HasPrefix(trimmed, "<img") || strings.HasPrefix(trimmed, "![")
+}
+
+func isHTMLOnlyParagraph(paragraph string) bool {
+ lines := strings.Split(strings.TrimSpace(paragraph), "\n")
+ if len(lines) == 0 {
+ return false
+ }
+
+ seen := false
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" {
+ continue
+ }
+ if !isHTMLOnlyLine(trimmed) {
+ return false
+ }
+ seen = true
+ }
+
+ return seen
+}
+
+func isHTMLOnlyLine(line string) bool {
+ return strings.HasPrefix(line, "<") && strings.HasSuffix(line, ">")
+}
+
+func isMarkdownImageLine(line string) bool {
+ return strings.HasPrefix(line, "![") && strings.Contains(line, "](")
+}
+
+func isTOCParagraph(paragraph string) bool {
+ lines := strings.Split(strings.TrimSpace(paragraph), "\n")
+ if len(lines) == 0 {
+ return false
+ }
+
+ first := strings.TrimSpace(lines[0])
+ if !strings.EqualFold(first, "toc:") && !strings.EqualFold(first, "table of contents:") {
+ return false
+ }
+
+ for _, line := range lines[1:] {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" {
+ continue
+ }
+ if !isOrderedListLine(trimmed) {
+ return false
+ }
+ }
+
+ return true
+}
+
+func isListOnlyParagraph(paragraph string) bool {
+ lines := strings.Split(strings.TrimSpace(paragraph), "\n")
+ if len(lines) == 0 {
+ return false
+ }
+
+ seen := false
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" {
+ continue
+ }
+ if !isListLine(trimmed) {
+ return false
+ }
+ seen = true
+ }
+
+ return seen
+}
+
+func isListLine(line string) bool {
+ return strings.HasPrefix(line, "* ") || strings.HasPrefix(line, "- ") || isOrderedListLine(line)
+}
+
+func isOrderedListLine(line string) bool {
+ if line == "" || line[0] < '0' || line[0] > '9' {
+ return false
+ }
+
+ i := 0
+ for i < len(line) && line[i] >= '0' && line[i] <= '9' {
+ i++
+ }
+ if i == 0 || i >= len(line) {
+ return false
+ }
+ if (line[i] != '.' && line[i] != ')') || i+1 >= len(line) || line[i+1] != ' ' {
+ return false
+ }
+
+ return true
+}
+
+func isLabelOnlyParagraph(paragraph string) bool {
+ lines := strings.Split(strings.TrimSpace(paragraph), "\n")
+ if len(lines) != 1 {
+ return false
+ }
+
+ line := strings.TrimSpace(lines[0])
+ if line == "" {
+ return false
+ }
+ if strings.HasSuffix(line, ":") && len(strings.Fields(line)) <= 5 {
+ return true
+ }
+
+ return line == strings.ToUpper(line) && len(strings.Fields(line)) <= 4
+}
+
+func isBadgeParagraph(paragraph string) bool {
+ lines := strings.Split(strings.TrimSpace(paragraph), "\n")
+ if len(lines) != 1 {
+ return false
+ }
+
+ line := strings.TrimSpace(lines[0])
+ if line == "" {
+ return false
+ }
+
+ markerCount := strings.Count(line, "](") + strings.Count(line, "![")
+ return markerCount >= 2
+}
+
+func normalizeManpageParagraph(paragraph string) (string, bool) {
+ lines := strings.Split(strings.TrimSpace(paragraph), "\n")
+ if len(lines) < 2 {
+ return "", false
+ }
+ if strings.TrimSpace(lines[0]) != "NAME" {
+ return "", false
+ }
+
+ body := make([]string, 0, len(lines)-1)
+ for _, line := range lines[1:] {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" {
+ continue
+ }
+ body = append(body, trimmed)
+ }
+ if len(body) == 0 {
+ return "", false
+ }
+
+ return strings.Join(body, " "), true
+}
+
+func trimMarkdownHeading(line string) (string, bool) {
+ if line == "" || !strings.HasPrefix(line, "#") {
+ return "", false
+ }
+
+ level := 0
+ for level < len(line) && line[level] == '#' {
+ level++
+ }
+ if level == 0 || level > 6 {
+ return "", false
+ }
+ if level < len(line) && line[level] != ' ' && line[level] != '\t' {
+ return "", false
+ }
+
+ heading := strings.TrimSpace(line[level:])
+ heading = strings.TrimSpace(strings.TrimRight(heading, "#"))
+ return heading, true
+}
+
+func isSetextUnderline(line string) bool {
+ if len(line) < 3 {
+ return false
+ }
+ return strings.Trim(line, "=") == "" || strings.Trim(line, "-") == ""
+}
diff --git a/internal/showcase/showcase.go b/internal/showcase/showcase.go
index ea9b82e..936c9f4 100644
--- a/internal/showcase/showcase.go
+++ b/internal/showcase/showcase.go
@@ -296,333 +296,6 @@ func fallbackSummary(repoName string, readmeContent []byte, readmeFound bool) st
return fmt.Sprintf("%s: source code repository.", repoName)
}
-func extractUsefulSummary(text string, maxParagraphs int) string {
- if maxParagraphs <= 0 {
- maxParagraphs = 1
- }
-
- parts := splitSummaryParagraphs(text)
- useful := make([]string, 0, maxParagraphs)
-
- for _, part := range parts {
- part = normalizeSummaryParagraph(part)
- if part == "" {
- continue
- }
-
- useful = append(useful, part)
- if len(useful) >= maxParagraphs {
- break
- }
- }
-
- return strings.Join(useful, "\n\n")
-}
-
-func normalizeSummaryParagraph(paragraph string) string {
- rawParagraph := strings.TrimSpace(paragraph)
- switch {
- case isHeadingOnlyParagraph(rawParagraph):
- return ""
- case isImageOnlyParagraph(rawParagraph):
- return ""
- case isHTMLOnlyParagraph(rawParagraph):
- return ""
- case isTOCParagraph(rawParagraph):
- return ""
- case isListOnlyParagraph(rawParagraph):
- return ""
- case isBadgeParagraph(rawParagraph):
- return ""
- }
-
- paragraph = sanitizeSummaryForGemtext(paragraph)
- if paragraph == "" {
- return ""
- }
-
- if normalized, ok := normalizeManpageParagraph(paragraph); ok {
- paragraph = normalized
- }
-
- if isLabelOnlyParagraph(paragraph) {
- return ""
- }
-
- return paragraph
-}
-
-func splitSummaryParagraphs(text string) []string {
- text = strings.ReplaceAll(text, "\r\n", "\n")
- text = strings.TrimSpace(text)
- if text == "" {
- return nil
- }
-
- rawParts := strings.Split(text, "\n\n")
- parts := make([]string, 0, len(rawParts))
- for _, part := range rawParts {
- part = strings.TrimSpace(part)
- if part == "" {
- continue
- }
- parts = append(parts, part)
- }
-
- return parts
-}
-
-func sanitizeSummaryForGemtext(summary string) string {
- summary = strings.ReplaceAll(summary, "\r\n", "\n")
- summary = strings.TrimSpace(summary)
- if summary == "" {
- return ""
- }
-
- lines := strings.Split(summary, "\n")
- cleaned := make([]string, 0, len(lines))
- inCodeFence := false
-
- for _, line := range lines {
- trimmed := strings.TrimSpace(line)
- if trimmed == "" {
- if inCodeFence {
- continue
- }
- cleaned = append(cleaned, "")
- continue
- }
-
- if strings.HasPrefix(trimmed, "```") {
- inCodeFence = !inCodeFence
- continue
- }
- if inCodeFence {
- continue
- }
-
- if isHTMLOnlyLine(trimmed) || isMarkdownImageLine(trimmed) {
- continue
- }
-
- if isSetextUnderline(trimmed) && len(cleaned) > 0 && strings.TrimSpace(cleaned[len(cleaned)-1]) != "" {
- continue
- }
-
- if heading, ok := trimMarkdownHeading(trimmed); ok {
- if heading != "" {
- cleaned = append(cleaned, heading)
- }
- continue
- }
-
- cleaned = append(cleaned, strings.TrimRight(line, " \t"))
- }
-
- return strings.TrimSpace(strings.Join(cleaned, "\n"))
-}
-
-func isHeadingOnlyParagraph(paragraph string) bool {
- lines := strings.Split(strings.TrimSpace(strings.ReplaceAll(paragraph, "\r\n", "\n")), "\n")
- if len(lines) == 1 {
- _, ok := trimMarkdownHeading(strings.TrimSpace(lines[0]))
- return ok
- }
- if len(lines) == 2 {
- return strings.TrimSpace(lines[0]) != "" && isSetextUnderline(strings.TrimSpace(lines[1]))
- }
- return false
-}
-
-func isImageOnlyParagraph(paragraph string) bool {
- trimmed := strings.TrimSpace(paragraph)
- if trimmed == "" || strings.Contains(trimmed, "\n") {
- return false
- }
- return strings.HasPrefix(trimmed, "<img") || strings.HasPrefix(trimmed, "![")
-}
-
-func isHTMLOnlyParagraph(paragraph string) bool {
- lines := strings.Split(strings.TrimSpace(paragraph), "\n")
- if len(lines) == 0 {
- return false
- }
-
- seen := false
- for _, line := range lines {
- trimmed := strings.TrimSpace(line)
- if trimmed == "" {
- continue
- }
- if !isHTMLOnlyLine(trimmed) {
- return false
- }
- seen = true
- }
-
- return seen
-}
-
-func isHTMLOnlyLine(line string) bool {
- return strings.HasPrefix(line, "<") && strings.HasSuffix(line, ">")
-}
-
-func isMarkdownImageLine(line string) bool {
- return strings.HasPrefix(line, "![") && strings.Contains(line, "](")
-}
-
-func isTOCParagraph(paragraph string) bool {
- lines := strings.Split(strings.TrimSpace(paragraph), "\n")
- if len(lines) == 0 {
- return false
- }
-
- first := strings.TrimSpace(lines[0])
- if !strings.EqualFold(first, "toc:") && !strings.EqualFold(first, "table of contents:") {
- return false
- }
-
- for _, line := range lines[1:] {
- trimmed := strings.TrimSpace(line)
- if trimmed == "" {
- continue
- }
- if !isOrderedListLine(trimmed) {
- return false
- }
- }
-
- return true
-}
-
-func isListOnlyParagraph(paragraph string) bool {
- lines := strings.Split(strings.TrimSpace(paragraph), "\n")
- if len(lines) == 0 {
- return false
- }
-
- seen := false
- for _, line := range lines {
- trimmed := strings.TrimSpace(line)
- if trimmed == "" {
- continue
- }
- if !isListLine(trimmed) {
- return false
- }
- seen = true
- }
-
- return seen
-}
-
-func isListLine(line string) bool {
- return strings.HasPrefix(line, "* ") || strings.HasPrefix(line, "- ") || isOrderedListLine(line)
-}
-
-func isOrderedListLine(line string) bool {
- if line == "" || line[0] < '0' || line[0] > '9' {
- return false
- }
-
- i := 0
- for i < len(line) && line[i] >= '0' && line[i] <= '9' {
- i++
- }
- if i == 0 || i >= len(line) {
- return false
- }
- if (line[i] != '.' && line[i] != ')') || i+1 >= len(line) || line[i+1] != ' ' {
- return false
- }
-
- return true
-}
-
-func isLabelOnlyParagraph(paragraph string) bool {
- lines := strings.Split(strings.TrimSpace(paragraph), "\n")
- if len(lines) != 1 {
- return false
- }
-
- line := strings.TrimSpace(lines[0])
- if line == "" {
- return false
- }
- if strings.HasSuffix(line, ":") && len(strings.Fields(line)) <= 5 {
- return true
- }
-
- return line == strings.ToUpper(line) && len(strings.Fields(line)) <= 4
-}
-
-func isBadgeParagraph(paragraph string) bool {
- lines := strings.Split(strings.TrimSpace(paragraph), "\n")
- if len(lines) != 1 {
- return false
- }
-
- line := strings.TrimSpace(lines[0])
- if line == "" {
- return false
- }
-
- markerCount := strings.Count(line, "](") + strings.Count(line, "![")
- return markerCount >= 2
-}
-
-func normalizeManpageParagraph(paragraph string) (string, bool) {
- lines := strings.Split(strings.TrimSpace(paragraph), "\n")
- if len(lines) < 2 {
- return "", false
- }
- if strings.TrimSpace(lines[0]) != "NAME" {
- return "", false
- }
-
- body := make([]string, 0, len(lines)-1)
- for _, line := range lines[1:] {
- trimmed := strings.TrimSpace(line)
- if trimmed == "" {
- continue
- }
- body = append(body, trimmed)
- }
- if len(body) == 0 {
- return "", false
- }
-
- return strings.Join(body, " "), true
-}
-
-func trimMarkdownHeading(line string) (string, bool) {
- if line == "" || !strings.HasPrefix(line, "#") {
- return "", false
- }
-
- level := 0
- for level < len(line) && line[level] == '#' {
- level++
- }
- if level == 0 || level > 6 {
- return "", false
- }
- if level < len(line) && line[level] != ' ' && line[level] != '\t' {
- return "", false
- }
-
- heading := strings.TrimSpace(line[level:])
- heading = strings.TrimSpace(strings.TrimRight(heading, "#"))
- return heading, true
-}
-
-func isSetextUnderline(line string) bool {
- if len(line) < 3 {
- return false
- }
- return strings.Trim(line, "=") == "" || strings.Trim(line, "-") == ""
-}
-
// getRepositories returns a list of repository directories in the work directory
func (g *Generator) getRepositories() ([]string, error) {
return localrepos.ListLocalReposWithGitDir(g.workDir)
@@ -841,235 +514,6 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool
return projectSummary, nil
}
-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")
-
- // Generated date at the top
- builder.WriteString(fmt.Sprintf("Generated on: %s\n\n", time.Now().Format("2006-01-02")))
-
- // Link to the interactive SVG rank history graph โ€” placed at the very top
- // so it is the first thing a reader sees after the date line.
- builder.WriteString("=> showcase-rank-history.svg Interactive Project Rank History Graph (SVG)\n\n")
-
- // Introduction paragraph
- builder.WriteString("This page showcases my side projects, providing an overview of what each project does, its technical implementation, and key metrics. Each project summary includes information about the programming languages used, development activity, releases, and licensing. The projects are ranked by score, which combines recent activity, project size, tag history, and whether the project has shipped a release.\n\n")
-
- // Template inline TOC
- builder.WriteString("<< template::inline::toc\n\n")
-}
-
-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 {
- continue
- }
-
- stats.totalCommits += summary.Metadata.CommitCount
- stats.totalLOC += summary.Metadata.LinesOfCode
- stats.totalDocs += summary.Metadata.LinesOfDocs
-
- if summary.Metadata.HasReleases {
- stats.releasedCount++
- }
-
- for _, lang := range summary.Metadata.Languages {
- languageTotals[lang.Name] += lang.Lines
- }
-
- for _, doc := range summary.Metadata.Documentation {
- docTotals[doc.Name] += doc.Lines
- }
- }
-
- stats.languageStats = buildSortedPercentageStats(languageTotals, stats.totalLOC)
- stats.docStats = buildSortedPercentageStats(docTotals, stats.totalDocs)
- return stats
-}
-
-func buildSortedPercentageStats(lineTotalsByName map[string]int, totalLines int) []LanguageStats {
- stats := make([]LanguageStats, 0, len(lineTotalsByName))
- for name, lines := range lineTotalsByName {
- percentage := 0.0
- if totalLines > 0 {
- percentage = float64(lines) * 100.0 / float64(totalLines)
- }
- stats = append(stats, LanguageStats{
- Name: name,
- Lines: lines,
- Percentage: percentage,
- })
- }
-
- sort.Slice(stats, func(i, j int) bool {
- return stats[i].Percentage > stats[j].Percentage
- })
- return stats
-}
-
-func writeShowcaseOverviewStats(builder *strings.Builder, stats showcaseOverviewStats) {
- builder.WriteString("## Overall Statistics\n\n")
- 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(stats.languageStats) > 0 {
- builder.WriteString(fmt.Sprintf("* ๐Ÿ’ป Languages: %s\n", FormatLanguagesWithPercentages(stats.languageStats)))
- }
- if len(stats.docStats) > 0 {
- builder.WriteString(fmt.Sprintf("* ๐Ÿ“š Documentation: %s\n", FormatLanguagesWithPercentages(stats.docStats)))
- }
-
- experimentalCount, releasedPercentage, experimentalPercentage := releaseStatusBreakdown(stats.totalProjects, stats.releasedCount)
- builder.WriteString(fmt.Sprintf("* ๐Ÿš€ Release Status: %d released, %d experimental (%.1f%% with releases, %.1f%% experimental)\n",
- 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
-}
-
-func (g *Generator) writeProjectsGemtext(builder *strings.Builder, summaries []ProjectSummary) {
- builder.WriteString("## Projects\n\n")
-
- for i, summary := range summaries {
- if i > 0 {
- builder.WriteString("\n---\n\n")
- }
-
- g.writeProjectGemtext(builder, i, summary)
- }
-}
-
-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)
-}
-
-func writeProjectMetadata(builder *strings.Builder, metadata *RepoMetadata) {
- if metadata == nil {
- return
- }
-
- 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))
-
- // 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")
- }
-
- // 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")
-}
-
-func writeProjectSummaryContent(builder *strings.Builder, summary ProjectSummary) {
- paragraphs := splitSummaryParagraphs(sanitizeSummaryForGemtext(summary.Summary))
-
- 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))
-
- // First paragraph.
- if len(paragraphs) > 0 {
- builder.WriteString(fmt.Sprintf("%s\n\n", strings.TrimSpace(paragraphs[0])))
- }
-
- // 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))
- }
-
- // 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)))
- }
-}
-
-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))
- }