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)