scooter  ·  2026-04-15

plan-89b0877.md

Implementation Plan: Output Structured Data and Create Index Page

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add structured JSON output to pgit site generation and create a new pgit-index command that generates an index.html aggregating multiple repo sites.

Architecture: 1. Extend pgit to output a pgit.json file alongside the generated site containing repo metadata 2. Create a standalone pgit-index command that discovers these JSON files in subdirectories and renders an index page using the existing styling system

Tech Stack: Go, HTML templates, embedded static files


Task 1: Create RepoMetadata struct and JSON output function

Files: - Modify: config.go (add struct) - Modify: generator.go (add JSON write function) - Modify: cmd/pgit/main.go (call JSON writer after site generation)

Step 1: Add RepoMetadata struct to config.go

Add after line 52 (after CSSFile field):

type RepoMetadata struct {
	Name        string    `json:"name"`
	Description string    `json:"description"`
	LastUpdated time.Time `json:"last_updated"`
}

Step 2: Add WriteRepoMetadata method to generator.go

Add after WriteRootSummary function (around line 26):

func (c *Config) WriteRepoMetadata(lastCommit *CommitData) {
	c.Logger.Info("writing repo metadata JSON", "repoPath", c.RepoPath)
	
	// Extract plain text description from HTML
	desc := string(c.Desc)
	// Remove HTML tags for plain text
	desc = regexp.MustCompile(`<[^>]*>`).ReplaceAllString(desc, "")
	
	metadata := &RepoMetadata{
		Name:        c.RepoName,
		Description: desc,
		LastUpdated: lastCommit.Author.When,
	}
	
	data, err := json.MarshalIndent(metadata, "", "  ")
	if err != nil {
		c.Logger.Error("failed to marshal metadata", "error", err)
		return
	}
	
	fp := filepath.Join(c.Outdir, "pgit.json")
	err = os.WriteFile(fp, data, 0644)
	if err != nil {
		c.Logger.Error("failed to write metadata file", "error", err)
		return
	}
	c.Logger.Info("wrote metadata file", "filepath", fp)
}

Step 3: Add necessary imports to generator.go

Add to imports:

"encoding/json"
"os"
"regexp"

Step 4: Call WriteRepoMetadata in WriteRepo

In generator.go around line 310, after WriteRootSummary call:

c.WriteRootSummary(data, readmeHTML, mainOutput.LastCommit)
c.WriteRepoMetadata(mainOutput.LastCommit)  // ADD THIS LINE
return mainOutput

Step 5: Commit

jj commit -m "feat: output structured JSON metadata with each site"

Task 2: Create pgit-index command structure

Files: - Create: cmd/pgit-index/main.go

Step 1: Create command file

Create cmd/pgit-index/main.go:

package main

import (
	"encoding/json"
	"fmt"
	"html/template"
	"os"
	"path/filepath"
	"time"

	"github.com/spf13/cobra"
)

type RepoInfo struct {
	Name        string    `json:"name"`
	Description string    `json:"description"`
	LastUpdated time.Time `json:"last_updated"`
	Path        string    // relative path from root
}

func main() {
	var rootCmd = &cobra.Command{
		Use:   "pgit-index",
		Short: "Generate an index page for multiple pgit sites",
		Long:  `Scans subdirectories for pgit.json files and generates an index.html aggregating all repository information.`,
		RunE:  runIndex,
	}

	rootCmd.Flags().String("root", ".", "root directory containing pgit site subdirectories")

	if err := rootCmd.Execute(); err != nil {
		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
		os.Exit(1)
	}
}

func runIndex(cmd *cobra.Command, args []string) error {
	rootDir, _ := cmd.Flags().GetString("root")
	
	root, err := filepath.Abs(rootDir)
	if err != nil {
		return fmt.Errorf("failed to resolve root path: %w", err)
	}

	repos, err := discoverRepos(root)
	if err != nil {
		return fmt.Errorf("failed to discover repositories: %w", err)
	}

	if len(repos) == 0 {
		return fmt.Errorf("no pgit.json files found in subdirectories of %s", root)
	}

	return generateIndex(root, repos)
}

func discoverRepos(root string) ([]RepoInfo, error) {
	var repos []RepoInfo

	entries, err := os.ReadDir(root)
	if err != nil {
		return nil, err
	}

	for _, entry := range entries {
		if !entry.IsDir() {
			continue
		}

		jsonPath := filepath.Join(root, entry.Name(), "pgit.json")
		data, err := os.ReadFile(jsonPath)
		if err != nil {
			continue // Skip directories without pgit.json
		}

		var repo RepoInfo
		if err := json.Unmarshal(data, &repo); err != nil {
			continue // Skip invalid JSON
		}

		repo.Path = entry.Name()
		repos = append(repos, repo)
	}

	return repos, nil
}

func generateIndex(root string, repos []RepoInfo) error {
	// TODO: Implement HTML generation
	return nil
}

Step 2: Test the discovery logic

Run: go run ./cmd/pgit-index --root ./testdata.site Expected: Error “no pgit.json files found” (since we haven’t generated them yet)

Step 3: Commit

jj commit -m "feat: create pgit-index command structure with repo discovery"

Task 3: Create index.html template for pgit-index

Files: - Create: cmd/pgit-index/index.html.tmpl (embedded template)

Step 1: Create HTML template string in main.go

Add before main() function:

const indexTemplate = `<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Git Repositories</title>
    <style>
        /* Critical CSS - inlined to prevent FOUC */
        :root {
            --line-height: 1.3rem;
            --grid-height: 0.65rem;
            --bg-color: #282a36;
            --text-color: #f8f8f2;
            --border: #6272a4;
            --link-color: #8be9fd;
            --hover: #ff79c6;
            --visited: #8be9fd;
            --white: #f2f2f2;
            --grey: #414558;
            --grey-light: #6a708e;
            --code: #414558;
            --pre: #252525;
        }
        html {
            background-color: var(--bg-color);
            color: var(--text-color);
            font-size: 16px;
            line-height: var(--line-height);
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", Arial, sans-serif;
            -webkit-text-size-adjust: 100%;
        }
        body {
            margin: 0 auto;
            max-width: 900px;
            padding: 0 var(--grid-height);
        }
        *, ::before, ::after { box-sizing: border-box; }
        
        .site-header {
            margin: 1rem auto;
        }
        .site-header__title {
            font-size: 1rem;
            font-weight: bold;
            line-height: var(--line-height);
            text-transform: uppercase;
            margin: 0;
        }
        
        .repo-grid {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
            gap: 1rem;
            margin: 1.5rem 0;
        }
        
        .repo-card {
            border: 1px solid var(--border);
            border-radius: 4px;
            padding: 1rem;
            background-color: var(--pre);
            transition: border-color 0.2s ease;
        }
        
        .repo-card:hover {
            border-color: var(--link-color);
        }
        
        .repo-card__name {
            font-size: 1rem;
            font-weight: bold;
            margin: 0 0 var(--grid-height) 0;
            text-transform: uppercase;
        }
        
        .repo-card__name a {
            color: var(--link-color);
            text-decoration: none;
        }
        
        .repo-card__name a:hover {
            text-decoration: underline;
        }
        
        .repo-card__desc {
            font-size: 0.9rem;
            color: var(--grey-light);
            margin: 0 0 var(--line-height) 0;
            line-height: var(--line-height);
        }
        
        .repo-card__updated {
            font-size: 0.8rem;
            color: var(--grey-light);
            font-family: monospace;
        }
        
        footer {
            text-align: center;
            margin: calc(var(--line-height) * 3) 0;
            color: var(--grey-light);
            font-size: 0.8rem;
        }
        
        @media only screen and (max-width: 40em) {
            body {
                padding: 0 var(--grid-height);
            }
            .repo-grid {
                grid-template-columns: 1fr;
            }
            .repo-card__updated {
                display: none;
            }
        }
    </style>
</head>
<body>
    <header class="site-header">
        <h1 class="site-header__title">Git Repositories</h1>
    </header>
    
    <main>
        <div class="repo-grid">
            {{range .}}
            <article class="repo-card">
                <h2 class="repo-card__name"><a href="{{.Path}}">{{.Name}}</a></h2>
                <p class="repo-card__desc">{{.Description}}</p>
                <time class="repo-card__updated" data-time="{{.LastUpdated.UTC.Format \"2006-01-02T15:04:05Z\"}}">
                    {{.LastUpdated.Format "Jan 2, 2006"}}
                </time>
            </article>
            {{end}}
        </div>
    </main>
    
    <footer>
        <p>Generated with pgit-index</p>
    </footer>
    
    <script>
    (function() {
        var MINUTE_MS = 60000;
        var HOUR_MS = 3600000;
        var DAY_MS = 86400000;
        var MONTH_MS = 30 * DAY_MS;

        function updateTimes() {
            var elements = document.querySelectorAll('[data-time]');
            var now = new Date();
            var minDiffMs = Infinity;

            elements.forEach(function(el) {
                var date = new Date(el.getAttribute('data-time'));
                var diffMs = now - date;
                if (diffMs < minDiffMs && diffMs >= 0) {
                    minDiffMs = diffMs;
                }
                var diffMins = Math.floor(diffMs / MINUTE_MS);
                var diffHours = Math.floor(diffMs / HOUR_MS);
                var diffDays = Math.floor(diffMs / DAY_MS);
                var text;
                if (diffMins < 1) {
                    text = 'just now';
                } else if (diffMins < 60) {
                    text = diffMins + ' minute' + (diffMins === 1 ? '' : 's') + ' ago';
                } else if (diffHours < 24) {
                    text = diffHours + ' hour' + (diffHours === 1 ? '' : 's') + ' ago';
                } else if (diffDays < 30) {
                    text = diffDays + ' day' + (diffDays === 1 ? '' : 's') + ' ago';
                } else {
                    return;
                }
                el.textContent = text;
            });
            return minDiffMs;
        }

        function scheduleUpdate() {
            var minDiffMs = updateTimes();
            var intervalMs;
            if (minDiffMs < HOUR_MS) {
                intervalMs = MINUTE_MS;
            } else if (minDiffMs < DAY_MS) {
                intervalMs = HOUR_MS;
            } else if (minDiffMs < MONTH_MS) {
                intervalMs = DAY_MS;
            } else {
                return;
            }
            setTimeout(scheduleUpdate, intervalMs);
        }
        scheduleUpdate();
    })();
    </script>
</body>
</html>`

Step 2: Implement generateIndex function

Replace the TODO generateIndex with:

func generateIndex(root string, repos []RepoInfo) error {
	tmpl, err := template.New("index").Parse(indexTemplate)
	if err != nil {
		return fmt.Errorf("failed to parse template: %w", err)
	}

	outputPath := filepath.Join(root, "index.html")
	file, err := os.Create(outputPath)
	if err != nil {
		return fmt.Errorf("failed to create index.html: %w", err)
	}
	defer file.Close()

	if err := tmpl.Execute(file, repos); err != nil {
		return fmt.Errorf("failed to execute template: %w", err)
	}

	fmt.Printf("Generated index.html at %s\n", outputPath)
	fmt.Printf("Found %d repositories\n", len(repos))
	return nil
}

Step 3: Commit

jj commit -m "feat: add HTML template generation to pgit-index"

Task 4: Test end-to-end workflow

Step 1: Generate a test site with JSON metadata

Run: go run ./cmd/pgit --repo ./testdata.repo --out ./test-output --revs HEAD --label test-repo

Step 2: Verify pgit.json was created

Run: cat ./test-output/pgit.json Expected: Valid JSON with name, description, and last_updated fields

Step 3: Test pgit-index with the generated site

Run:

mkdir -p ./test-root
cp -r ./test-output ./test-root/
go run ./cmd/pgit-index --root ./test-root

Step 4: Verify index.html was created

Run: cat ./test-root/index.html Expected: Valid HTML with repo card, time data attribute, and JS shim

Step 5: Commit test artifacts (optional)

jj commit -m "test: verify end-to-end workflow for index generation"

Task 5: Add pgit-index to build

Files: - Modify: Makefile (if exists)

Step 1: Check if Makefile has build targets

Run: cat Makefile

Step 2: Add pgit-index build target if needed

If there’s a build target for pgit, add similar for pgit-index:

build-pgit-index:
	go build -o bin/pgit-index ./cmd/pgit-index

Step 3: Commit

jj commit -m "build: add pgit-index to build targets"

Summary of Files Changed

Modified: - config.go - Added RepoMetadata struct - generator.go - Added WriteRepoMetadata function and JSON output - cmd/pgit/main.go - Added call to WriteRepoMetadata

Created: - cmd/pgit-index/main.go - New command for generating index pages

Key Features: 1. Each pgit site now outputs pgit.json with repo metadata 2. pgit-index command discovers all pgit.json files in subdirectories 3. Generated index.html uses consistent styling with existing pgit sites 4. Time humanization JS shim included inline (same as base layout) 5. Responsive design hides last-updated time on small screens (< 40em) 6. Grid layout adapts from single column (mobile) to multi-column (desktop)