From bb0446ab03b6e16a3082ef2c4a9222613ef8dc90 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 27 May 2026 09:50:41 +0300 Subject: feat(showcase): add interactive SVG rank history graph Generate rank-history.svg alongside showcase.gmi.tpl on every showcase run (full or single-repo update). The graph shows a Google-Trends-style polyline per project from oldest to newest; hovering highlights the selected project and shows a tooltip with rank and snapshot date for each recorded week. Key implementation details: - new file internal/showcase/rank_history_svg.go with GenerateRankHistorySVG - golden-ratio HSL colour spacing for visually distinct project lines - M/L SVG path commands with gaps for weeks with no snapshot data - PROJECTS JSON array embedded in CDATA script for JS tooltip rendering - rank-history.svg linked from the gemtext showcase header Changes to showcase.go: - updateShowcaseFile returns ([]ProjectSummary, error) so the caller can pass the full merged+sorted list to writeRankHistorySVGFile - extract showcaseOutputDir() helper to deduplicate the hardcoded ~/git/foo.zone-content/gemtext/about path used in four places - log a warning (instead of silently continuing) when getRepositories fails inside updateShowcaseFile so data loss is visible to the operator Bug fixes found during review: - off-by-bounds panic: reversal loop used revIdx=numPoints-1-j as source index but numPoints is a constant (5) not len(s.RankHistory); replaced with j as source and numPoints-1-j as destination so partial slices never panic - redundant getElementById in JS onEnter replaced with allPG[idx] which is already the correct element Co-Authored-By: Claude Sonnet 4.6 --- internal/cmd/showcase.go | 2 +- internal/showcase/rank_history_svg.go | 412 +++++++++++++++++++++++++++++ internal/showcase/rank_history_svg_test.go | 266 +++++++++++++++++++ internal/showcase/showcase.go | 108 +++++--- 4 files changed, 756 insertions(+), 32 deletions(-) create mode 100644 internal/showcase/rank_history_svg.go create mode 100644 internal/showcase/rank_history_svg_test.go (limited to 'internal') diff --git a/internal/cmd/showcase.go b/internal/cmd/showcase.go index d6d8faf..0433ad3 100644 --- a/internal/cmd/showcase.go +++ b/internal/cmd/showcase.go @@ -63,6 +63,6 @@ func init() { showcaseCmd.Flags().StringVarP(&outputPath, "output", "o", "", "custom output eath (default: ~/git/foo.zone-content/gemtext/about/showcase.gmi.tpl)") showcaseCmd.Flags().StringVar(&outputFormat, "format", "gemtext", "output format: gemtext, markdown, html") showcaseCmd.Flags().StringVar(&excludePattern, "exclude", "", "exclude repos matching pattern") - showcaseCmd.Flags().StringVar(&showcaseAITool, "ai-tool", "opencode", "AI tool for summaries: opencode, hexai, claude, amp, or claude-code (default tries opencode→hexai→claude→amp)") + showcaseCmd.Flags().StringVar(&showcaseAITool, "ai-tool", "opencode", "AI tool for summaries: opencode, hexai, claude, amp, or claude-code (default tries opencode→hexai→claude→amp)") showcaseCmd.Flags().StringVar(&showcaseRepo, "repo", "", "only generate showcase for a single repository") } diff --git a/internal/showcase/rank_history_svg.go b/internal/showcase/rank_history_svg.go new file mode 100644 index 0000000..43b2a66 --- /dev/null +++ b/internal/showcase/rank_history_svg.go @@ -0,0 +1,412 @@ +package showcase + +import ( + "encoding/json" + "fmt" + "math" + "strings" +) + +// SVG canvas and margin constants (pixels in viewBox coordinates). +const ( + svgViewWidth = 1000 + svgViewHeight = 560 + svgMarginLeft = 55 + svgMarginRight = 20 + svgMarginTop = 70 + svgMarginBottom = 50 +) + +// svgTimePoint is one weekly data snapshot for a project, embedded in the SVG +// for JavaScript tooltip rendering. +type svgTimePoint struct { + Label string `json:"label"` // "now", "1w", "2w", … + Spot int `json:"spot"` // 0 means no data for this week + Date string `json:"date,omitempty"` +} + +// svgProjectData carries per-project metadata used by the interactive JS layer. +type svgProjectData struct { + Name string `json:"name"` + Color string `json:"color"` + Points []svgTimePoint `json:"points"` +} + +// projectColor returns a visually distinct CSS hex color for project index i. +// It uses golden-ratio hue spacing so successive projects never look similar. +func projectColor(i int) string { + const golden = 0.618033988749895 + hue := math.Mod(float64(i)*golden*360, 360) + return hslToRGBHex(hue, 0.75, 0.62) +} + +// hslToRGBHex converts an HSL color (h in [0,360), s and l in [0,1]) to a +// CSS hex string like "#rrggbb". +func hslToRGBHex(h, s, l float64) string { + c := (1 - math.Abs(2*l-1)) * s + x := c * (1 - math.Abs(math.Mod(h/60, 2)-1)) + m := l - c/2 + + var r, g, b float64 + switch { + case h < 60: + r, g, b = c, x, 0 + case h < 120: + r, g, b = x, c, 0 + case h < 180: + r, g, b = 0, c, x + case h < 240: + r, g, b = 0, x, c + case h < 300: + r, g, b = x, 0, c + default: + r, g, b = c, 0, x + } + + ri := int(math.Round((r + m) * 255)) + gi := int(math.Round((g + m) * 255)) + bi := int(math.Round((b + m) * 255)) + return fmt.Sprintf("#%02x%02x%02x", ri, gi, bi) +} + +// GenerateRankHistorySVG creates an interactive inline SVG that shows a +// Google-Trends-style rank history graph for all projects. +// +// Layout: +// - X-axis: left = oldest snapshot, right = "now". +// - Y-axis: rank 1 at top (best), max rank at bottom. +// - Each project is one colored polyline; gaps occur when a week has no data. +// - Hovering a line highlights it, dims others, and shows a tooltip with the +// project name and rank at each recorded snapshot. +func GenerateRankHistorySVG(summaries []ProjectSummary) string { + numPoints := rankHistoryPoints // 5 weekly snapshots + + // Collect per-project data, reversing the history so oldest is on the left. + allProjects := make([]svgProjectData, 0, len(summaries)) + maxRank := 1 + colorIdx := 0 + + for _, s := range summaries { + if len(s.RankHistory) == 0 { + continue + } + + pts := make([]svgTimePoint, numPoints) + hasData := false + // RankHistory is newest-first (index 0 = "now", index len-1 = oldest). + // We want pts to be oldest-first so the left side of the graph is + // the most distant point. Place each source entry at its destination + // index (numPoints-1-j) so that "now" lands at pts[numPoints-1] and + // older entries land to the left. Entries for weeks with no snapshot + // stay as zero-value svgTimePoint (Spot=0 → no data for that column). + // Using j as the source index (not a reversed index based on numPoints) + // prevents an out-of-bounds panic when len(s.RankHistory) < numPoints. + for j := 0; j < len(s.RankHistory) && j < numPoints; j++ { + dstIdx := numPoints - 1 - j // "now" (j=0) maps to rightmost slot + h := s.RankHistory[j] + pts[dstIdx] = svgTimePoint{ + Label: h.Anchor, + Spot: h.Spot, + Date: h.SnapshotDate, + } + if h.Spot > maxRank { + maxRank = h.Spot + } + if h.Spot > 0 { + hasData = true + } + } + + if !hasData { + continue // skip projects that have never appeared in any snapshot + } + + allProjects = append(allProjects, svgProjectData{ + Name: s.Name, + Color: projectColor(colorIdx), + Points: pts, + }) + colorIdx++ + } + + // Human-readable X-axis labels (left = oldest, right = "now"). + // Position i is (numPoints-1-i) weeks ago; position numPoints-1 is "now". + xLabels := make([]string, numPoints) + for i := 0; i < numPoints; i++ { + weeksAgo := numPoints - 1 - i + if weeksAgo == 0 { + xLabels[i] = "now" + } else { + xLabels[i] = fmt.Sprintf("%dw ago", weeksAgo) + } + } + + // --- Layout helpers --- + plotW := svgViewWidth - svgMarginLeft - svgMarginRight + plotH := svgViewHeight - svgMarginTop - svgMarginBottom + + xPos := func(i int) float64 { + if numPoints <= 1 { + return float64(svgMarginLeft) + float64(plotW)/2 + } + return float64(svgMarginLeft) + float64(i)*float64(plotW)/float64(numPoints-1) + } + + // rank 1 → top of plot, maxRank → bottom of plot. + yPos := func(rank int) float64 { + if rank <= 0 { + return -999 // off-screen sentinel; caller should skip + } + if maxRank <= 1 { + return float64(svgMarginTop) + float64(plotH)/2 + } + ratio := float64(rank-1) / float64(maxRank-1) + return float64(svgMarginTop) + ratio*float64(plotH) + } + + // Embed project data as JSON for the JS tooltip layer. + projectsJSON, _ := json.Marshal(allProjects) + + // --- Build SVG sub-sections --- + + // Horizontal grid lines and Y-axis labels. + var gridBuf strings.Builder + yStep := gridStep(maxRank) + plotRight := float64(svgMarginLeft + plotW) + for r := 1; r <= maxRank; r += yStep { + y := yPos(r) + fmt.Fprintf(&gridBuf, + ``, + float64(svgMarginLeft), y, plotRight, y) + fmt.Fprintf(&gridBuf, + `%d`, + float64(svgMarginLeft)-6, y+4, r) + } + + // Vertical grid lines and X-axis labels. + var xAxisBuf strings.Builder + plotBottom := float64(svgMarginTop + plotH) + for i := 0; i < numPoints; i++ { + x := xPos(i) + fmt.Fprintf(&xAxisBuf, + ``, + x, svgMarginTop, x, plotBottom) + fmt.Fprintf(&xAxisBuf, + `%s`, + x, plotBottom+16, xLabels[i]) + } + + // Project lines and dot groups. + var linesBuf strings.Builder + for i, proj := range allProjects { + pathD := buildSVGPath(proj.Points, xPos, yPos) + if pathD == "" { + continue + } + + // One circle per valid data point so the tooltip hit-area is larger. + var circleBuf strings.Builder + for j, pt := range proj.Points { + if pt.Spot <= 0 { + continue + } + x := xPos(j) + y := yPos(pt.Spot) + if y < 0 { + continue + } + fmt.Fprintf(&circleBuf, ``, x, y) + } + + fmt.Fprintf(&linesBuf, + ``, + i, i) + fmt.Fprintf(&linesBuf, ``, pathD, proj.Color) + fmt.Fprintf(&linesBuf, `%s`, proj.Color, circleBuf.String()) + linesBuf.WriteString(``) + } + + // --- Assemble the full SVG --- + var svg strings.Builder + + fmt.Fprintf(&svg, + ``, + svgViewWidth, svgViewHeight, svgViewWidth, svgViewHeight) + + // Embedded CSS – kept compact but readable. + svg.WriteString(``) + + // Solid background rect (belt-and-suspenders for SVG viewers that ignore + // the CSS background property). + fmt.Fprintf(&svg, ``, svgViewWidth, svgViewHeight) + + // Title + subtitle. + fmt.Fprintf(&svg, `Project Rank History`, svgViewWidth/2) + fmt.Fprintf(&svg, + `rank 1 = highest score · hover a line to highlight · %d projects tracked`, + svgViewWidth/2, len(allProjects)) + + // Rotated Y-axis label. + cx := float64(svgMarginLeft) - 40 + cy := float64(svgMarginTop) + float64(plotH)/2 + fmt.Fprintf(&svg, + `Rank`, + cx, cy, cx, cy) + + // Grid, axes, and project lines. + svg.WriteString(gridBuf.String()) + svg.WriteString(xAxisBuf.String()) + svg.WriteString(linesBuf.String()) + + // Tooltip overlay (hidden until a project is hovered). + svg.WriteString(` + + + +`) + + // Inline JavaScript for interactivity. + // PROJECTS is the JSON array; each entry has name, color, and points[]. + fmt.Fprintf(&svg, ``, string(projectsJSON)) + + svg.WriteString(``) + return svg.String() +} + +// buildSVGPath converts a slice of time points into an SVG path string using +// M (moveto) at the start of each run of valid points and L (lineto) within. +// Gaps (Spot == 0) cause the pen to be lifted so the line breaks cleanly. +func buildSVGPath(points []svgTimePoint, xPos func(int) float64, yPos func(int) float64) string { + var parts []string + prevValid := false + + for i, pt := range points { + if pt.Spot <= 0 { + prevValid = false + continue + } + x := xPos(i) + y := yPos(pt.Spot) + if y < 0 { + prevValid = false + continue + } + cmd := "L" + if !prevValid { + cmd = "M" + } + parts = append(parts, fmt.Sprintf("%s%.1f %.1f", cmd, x, y)) + prevValid = true + } + + return strings.Join(parts, " ") +} + +// gridStep returns how many rank positions to skip between Y-axis grid lines, +// keeping the graph legible when there are many projects. +func gridStep(maxRank int) int { + switch { + case maxRank > 30: + return 5 + case maxRank > 15: + return 3 + case maxRank > 8: + return 2 + default: + return 1 + } +} diff --git a/internal/showcase/rank_history_svg_test.go b/internal/showcase/rank_history_svg_test.go new file mode 100644 index 0000000..06aab4c --- /dev/null +++ b/internal/showcase/rank_history_svg_test.go @@ -0,0 +1,266 @@ +package showcase + +import ( + "strings" + "testing" +) + +// makeTestSummaries builds a small slice of ProjectSummary with rank history +// suitable for unit-testing the SVG generator without touching the filesystem. +func makeTestSummaries() []ProjectSummary { + return []ProjectSummary{ + { + Name: "alpha", + RankHistory: []RepoRankHistory{ + {Spot: 1, Anchor: "now", SnapshotDate: "2026-05-27"}, + {Spot: 2, Anchor: "1w", SnapshotDate: "2026-05-20"}, + {Spot: 2, Anchor: "2w", SnapshotDate: "2026-05-13"}, + {Spot: 3, Anchor: "3w", SnapshotDate: "2026-05-06"}, + {Spot: 0, Anchor: "4w"}, // missing snapshot week + }, + }, + { + Name: "beta", + RankHistory: []RepoRankHistory{ + {Spot: 2, Anchor: "now", SnapshotDate: "2026-05-27"}, + {Spot: 1, Anchor: "1w", SnapshotDate: "2026-05-20"}, + {Spot: 1, Anchor: "2w", SnapshotDate: "2026-05-13"}, + {Spot: 0, Anchor: "3w"}, + {Spot: 0, Anchor: "4w"}, + }, + }, + { + // Project with no rank data at all should be skipped. + Name: "ghost", + RankHistory: []RepoRankHistory{{Spot: 0}, {Spot: 0}, {Spot: 0}, {Spot: 0}, {Spot: 0}}, + }, + } +} + +func TestGenerateRankHistorySVG_IsValidSVG(t *testing.T) { + t.Parallel() + + svg := GenerateRankHistorySVG(makeTestSummaries()) + + if !strings.HasPrefix(svg, "") { + t.Fatalf("expected SVG to end with '', got suffix: %.40s", svg[len(svg)-40:]) + } +} + +func TestGenerateRankHistorySVG_ContainsProjectNames(t *testing.T) { + t.Parallel() + + svg := GenerateRankHistorySVG(makeTestSummaries()) + + if !strings.Contains(svg, `"name":"alpha"`) { + t.Error("SVG JSON data missing project 'alpha'") + } + if !strings.Contains(svg, `"name":"beta"`) { + t.Error("SVG JSON data missing project 'beta'") + } + // 'ghost' has no valid data points and must be excluded. + if strings.Contains(svg, `"name":"ghost"`) { + t.Error("SVG JSON data should not contain 'ghost' (no rank data)") + } +} + +func TestGenerateRankHistorySVG_ContainsInteractiveJS(t *testing.T) { + t.Parallel() + + svg := GenerateRankHistorySVG(makeTestSummaries()) + + // Key JS functions must be present for interactivity. + for _, fn := range []string{"onEnter", "onLeave", "moveTT"} { + if !strings.Contains(svg, fn) { + t.Errorf("SVG JavaScript missing function %q", fn) + } + } + // Tooltip anchor elements must exist. + for _, id := range []string{"tt", "ttbg", "tttl", "ttbd"} { + if !strings.Contains(svg, `id="`+id+`"`) { + t.Errorf("SVG missing element id=%q", id) + } + } +} + +func TestGenerateRankHistorySVG_EmptySummaries(t *testing.T) { + t.Parallel() + + svg := GenerateRankHistorySVG([]ProjectSummary{}) + + // Should still produce a valid (empty-data) SVG with the subtitle showing 0 projects. + if !strings.Contains(svg, " tc.wantMax { + t.Errorf("gridStep(%d) = %d, want ≤ %d", tc.maxRank, got, tc.wantMax) + } + if got <= 0 { + t.Errorf("gridStep(%d) = %d, want > 0", tc.maxRank, got) + } + } +} diff --git a/internal/showcase/showcase.go b/internal/showcase/showcase.go index ebd48a7..b7c1f5b 100644 --- a/internal/showcase/showcase.go +++ b/internal/showcase/showcase.go @@ -156,17 +156,28 @@ func (g *Generator) GenerateShowcase(repoFilter []string, forceRegenerate bool) applyRankHistoryToSummaries(summaries, rankHistoryStore, anchorDate, rankHistoryPoints) - // When filtering (single repo), we need to update existing showcase + // When filtering (single repo), we need to update existing showcase. + // updateShowcaseFile merges the new summary with all cached ones and returns + // the complete, sorted list so we can regenerate the SVG consistently. + var allSummaries []ProjectSummary if len(repoFilter) > 0 { - if err := g.updateShowcaseFile(summaries); err != nil { + allSummaries, err = g.updateShowcaseFile(summaries) + if err != nil { return fmt.Errorf("failed to update showcase file: %w", err) } } else { - // Full regeneration - format as Gemtext and write + // Full regeneration - format as Gemtext and write. content := g.formatGemtext(summaries) if err := g.writeShowcaseFile(content); err != nil { return fmt.Errorf("failed to write showcase file: %w", err) } + allSummaries = summaries + } + + // Always regenerate the interactive SVG rank history alongside the text showcase. + if err := g.writeRankHistorySVGFile(allSummaries); err != nil { + // Non-fatal: log the warning but don't abort the showcase run. + fmt.Printf("Warning: failed to write rank history SVG: %v\n", err) } return nil @@ -840,11 +851,10 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool // Always extract images from README (not cached) fmt.Printf("Extracting images from README...\n") - home, err := os.UserHomeDir() + showcaseDir, err := showcaseOutputDir() if err != nil { - return nil, fmt.Errorf("failed to get home directory: %w", err) + return nil, err } - showcaseDir := filepath.Join(home, "git", "foo.zone-content", "gemtext", "about") images, err := extractImagesFromRepo(repoPath, repoName, showcaseDir) if err != nil { fmt.Printf("Warning: Failed to extract images: %v\n", err) @@ -898,6 +908,9 @@ func (g *Generator) formatGemtext(summaries []ProjectSummary) string { // 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") + // Link to the interactive SVG rank history graph generated alongside this file. + builder.WriteString("=> rank-history.svg Interactive Project Rank History Graph (SVG)\n\n") + // Template inline TOC builder.WriteString("<< template::inline::toc\n\n") @@ -1091,15 +1104,24 @@ func (g *Generator) formatGemtext(summaries []ProjectSummary) string { return builder.String() } +// showcaseOutputDir returns the canonical directory where showcase output files +// (Gemtext, SVG, images) are written. Centralised here so all writers agree on +// the path and a future change only needs to touch one place. +func showcaseOutputDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(home, "git", "foo.zone-content", "gemtext", "about"), nil +} + // writeShowcaseFile writes the showcase content to the target file func (g *Generator) writeShowcaseFile(content string) error { - // Build target path - home, err := os.UserHomeDir() + targetDir, err := showcaseOutputDir() if err != nil { - return fmt.Errorf("failed to get home directory: %w", err) + return err } - targetDir := filepath.Join(home, "git", "foo.zone-content", "gemtext", "about") targetFile := filepath.Join(targetDir, "showcase.gmi.tpl") // Create directory if it doesn't exist @@ -1116,21 +1138,51 @@ func (g *Generator) writeShowcaseFile(content string) error { return nil } -// updateShowcaseFile updates specific entries in an existing showcase file -func (g *Generator) updateShowcaseFile(newSummaries []ProjectSummary) error { - // Load existing summaries from cache files instead of parsing Gemtext +// writeRankHistorySVGFile generates an interactive SVG rank history graph and +// writes it to the same directory as the showcase Gemtext file. +func (g *Generator) writeRankHistorySVGFile(summaries []ProjectSummary) error { + targetDir, err := showcaseOutputDir() + if err != nil { + return err + } + + if err := os.MkdirAll(targetDir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", targetDir, err) + } + + svgContent := GenerateRankHistorySVG(summaries) + + targetFile := filepath.Join(targetDir, "rank-history.svg") + if err := os.WriteFile(targetFile, []byte(svgContent), 0644); err != nil { + return fmt.Errorf("failed to write rank history SVG: %w", err) + } + + fmt.Printf("Rank history SVG written to: %s\n", targetFile) + return nil +} + +// updateShowcaseFile merges newSummaries with all existing cached project +// summaries, re-sorts by score, applies rank history, regenerates the Gemtext +// showcase file, and returns the complete sorted summary list so the caller +// can also regenerate the SVG graph consistently. +func (g *Generator) updateShowcaseFile(newSummaries []ProjectSummary) ([]ProjectSummary, error) { + // Load all cached summaries from disk so the full project list is available. existingSummaries := make(map[string]ProjectSummary) - // Get all repositories in work directory to load their cached summaries + // Load all existing cached summaries so the single-repo update does not + // accidentally truncate the full showcase to just the one regenerated project. + // If getRepositories fails (e.g. work directory temporarily unavailable), + // log a warning and continue — the overlay below will still write newSummaries, + // which is better than silently producing an empty/truncated output file. repos, err := g.getRepositories() - if err == nil { + if err != nil { + fmt.Printf("Warning: failed to list repositories for showcase update: %v (cached repos may be missing from output)\n", err) + } else { cacheDir := filepath.Join(g.workDir, ".gitsyncer-showcase-cache") for _, repo := range repos { - // Skip excluded repos if g.isExcluded(repo) { continue } - cacheFile := filepath.Join(cacheDir, repo+".json") if cached, err := g.loadFromCache(cacheFile); err == nil { existingSummaries[repo] = *cached @@ -1138,44 +1190,40 @@ func (g *Generator) updateShowcaseFile(newSummaries []ProjectSummary) error { } } - // Update with new summaries + // Overlay the freshly generated summaries onto the cached set. for _, summary := range newSummaries { existingSummaries[summary.Name] = summary } - // Convert map to slice - var allSummaries []ProjectSummary + // Flatten map to slice and sort by score (highest first). + allSummaries := make([]ProjectSummary, 0, len(existingSummaries)) for _, summary := range existingSummaries { allSummaries = append(allSummaries, summary) } - - // Sort by score (highest first) sort.Slice(allSummaries, func(i, j int) bool { - // If metadata is missing, put at the end if allSummaries[i].Metadata == nil { return false } if allSummaries[j].Metadata == nil { return true } - // Higher score is better (combines LOC and recent activity) return allSummaries[i].Metadata.Score > allSummaries[j].Metadata.Score }) rankHistoryFile := filepath.Join(g.workDir, rankHistoryFilename) rankHistoryStore, err := loadRankHistory(rankHistoryFile) if err != nil { - return fmt.Errorf("failed to load rank history: %w", err) + return nil, fmt.Errorf("failed to load rank history: %w", err) } applyRankHistoryToSummaries(allSummaries, rankHistoryStore, time.Now(), rankHistoryPoints) - // Format and write + // Format and write the Gemtext showcase. content := g.formatGemtext(allSummaries) if err := g.writeShowcaseFile(content); err != nil { - return err + return nil, err } - return nil + return allSummaries, nil } // loadFromCache loads a project summary from cache @@ -1217,13 +1265,11 @@ func (g *Generator) verifyImages(summary *ProjectSummary) error { return nil } - home, err := os.UserHomeDir() + showcaseDir, err := showcaseOutputDir() if err != nil { return err } - showcaseDir := filepath.Join(home, "git", "foo.zone-content", "gemtext", "about") - for _, imgPath := range summary.Images { fullPath := filepath.Join(showcaseDir, imgPath) if _, err := os.Stat(fullPath); err != nil { -- cgit v1.2.3