add pgit-index and update flake
feat: output structured JSON metadata with each site feat: create pgit-index command structure with repo discovery feat: add HTML template generation to pgit-index test: verify end-to-end workflow for index generation build: add pgit-index to build targets feat: output structured JSON metadata with each site feat: create pgit-index command with HTML generation
8 files changed,  +934, -1
M Makefile
+6, -0
 1@@ -6,6 +6,7 @@ build: clean
 2 		echo "Building with go..."; \
 3 		mkdir -p result/bin; \
 4 		go build -o result/bin/pgit ./cmd/pgit; \
 5+		go build -o result/bin/pgit-index ./cmd/pgit-index; \
 6 	fi
 7 .PHONY: build
 8 
 9@@ -27,6 +28,11 @@ test:
10 	go test ./...
11 .PHONY: test
12 
13+local:
14+	go build -o ~/bin/pgit ./cmd/pgit
15+	go build -o ~/bin/pgit-index ./cmd/pgit-index
16+.PHONY: local
17+
18 # Update flake.nix vendorHash when go.mod/go.sum changes
19 # This target depends on go.mod and go.sum, so it will only run when they change
20 update-vendor-hash: flake.nix go.mod go.sum
A cmd/pgit-index/main.go
+361, -0
  1@@ -0,0 +1,361 @@
  2+package main
  3+
  4+import (
  5+	"embed"
  6+	"encoding/json"
  7+	"fmt"
  8+	"html/template"
  9+	"os"
 10+	"path/filepath"
 11+	"time"
 12+
 13+	"github.com/spf13/cobra"
 14+)
 15+
 16+//go:embed static/logo.png
 17+var staticFS embed.FS
 18+
 19+type RepoInfo struct {
 20+	Name        string    `json:"name"`
 21+	Description string    `json:"description"`
 22+	LastUpdated time.Time `json:"last_updated"`
 23+	Path        string    // relative path from root
 24+}
 25+
 26+const indexTemplate = `<!doctype html>
 27+<html lang="en">
 28+<head>
 29+    <meta charset="utf-8">
 30+    <meta name="viewport" content="width=device-width, initial-scale=1">
 31+    <title>Git Repositories</title>
 32+    <style>
 33+        /* Critical CSS - inlined to prevent FOUC */
 34+        :root {
 35+            --line-height: 1.3rem;
 36+            --grid-height: 0.65rem;
 37+            --bg-color: #0d1117;
 38+            --text-color: #e6edf3;
 39+            --border: #6a708e;
 40+            --link-color: #79C0FF;
 41+            --hover: #ff79c6;
 42+            --visited: #79C0FF;
 43+            --white: #f2f2f2;
 44+            --grey: #414558;
 45+            --grey-light: #6a708e;
 46+            --code: #414558;
 47+            --pre: #252525;
 48+        }
 49+        html {
 50+            background-color: var(--bg-color);
 51+            color: var(--text-color);
 52+            font-size: 16px;
 53+            line-height: var(--line-height);
 54+            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", Arial, sans-serif;
 55+            -webkit-text-size-adjust: 100%;
 56+        }
 57+        body {
 58+            margin: 0 auto;
 59+            max-width: 900px;
 60+            padding: 0 var(--grid-height);
 61+        }
 62+        *, ::before, ::after { box-sizing: border-box; }
 63+
 64+        .site-header {
 65+            margin: 1rem auto;
 66+            display: flex;
 67+            flex-direction: row;
 68+            align-items: center;
 69+            gap: 1rem;
 70+        }
 71+
 72+        .site-header__logo {
 73+            width: 4rem;
 74+            height: 4rem;
 75+            flex-shrink: 0;
 76+        }
 77+
 78+        .site-header__content {
 79+            display: flex;
 80+            flex-direction: column;
 81+            justify-content: center;
 82+        }
 83+
 84+        .site-header__title {
 85+            font-size: 1rem;
 86+            font-weight: bold;
 87+            line-height: var(--line-height);
 88+            text-transform: uppercase;
 89+            margin: 0;
 90+        }
 91+
 92+        .site-header__desc {
 93+            font-size: 0.9rem;
 94+            color: var(--grey-light);
 95+            margin: 0;
 96+            line-height: var(--line-height);
 97+        }
 98+
 99+        .repo-grid {
100+            display: flex;
101+            flex-direction: column;
102+            gap: 0.5rem;
103+            margin: 1rem 0;
104+        }
105+
106+        .repo-card {
107+            display: block;
108+            border: 1px solid var(--border);
109+            border-radius: 4px;
110+            padding: 0.75rem 1rem;
111+            background-color: var(--pre);
112+            transition: border-color 0.2s ease;
113+            text-decoration: none;
114+            color: inherit;
115+            width: 100%;
116+        }
117+
118+        .repo-card:hover {
119+            border-color: var(--link-color);
120+        }
121+
122+        .repo-card__name {
123+            font-size: 1rem;
124+            font-weight: bold;
125+            margin: 0 0 0.25rem 0;
126+            text-transform: uppercase;
127+            color: var(--link-color);
128+        }
129+
130+        .repo-card__desc {
131+            font-size: 0.9rem;
132+            color: var(--grey-light);
133+            margin: 0 0 var(--grid-height) 0;
134+            line-height: var(--line-height);
135+        }
136+
137+        .repo-card__desc:empty {
138+            margin: 0;
139+            display: none;
140+        }
141+
142+        .repo-card__updated {
143+            font-size: 0.8rem;
144+            color: var(--grey-light);
145+            font-family: monospace;
146+        }
147+
148+        footer {
149+            text-align: center;
150+            margin: calc(var(--line-height) * 3) 0;
151+            color: var(--grey-light);
152+            font-size: 0.8rem;
153+        }
154+
155+        @media only screen and (max-width: 40em) {
156+            body {
157+                padding: 0 var(--grid-height);
158+            }
159+            .repo-card__updated {
160+                display: none;
161+            }
162+        }
163+    </style>
164+</head>
165+<body>
166+    <header class="site-header">
167+        <img src="logo.png" class="site-header__logo" alt="Logo" />
168+        <div class="site-header__content">
169+            <h1 class="site-header__title">Forged In Fire</h1>
170+            <p class="site-header__desc">Artisanally handcrafted code with only a little help from AI</p>
171+        </div>
172+    </header>
173+
174+    <main>
175+        <div class="repo-grid">
176+            {{range .}}
177+            <a href="{{.Path}}" class="repo-card">
178+                <div class="repo-card__name">{{.Name}}</div>
179+                <p class="repo-card__desc">{{.Description | safeHTML}}</p>
180+                <time class="repo-card__updated" data-time="{{.LastUpdated.UTC.Format "2006-01-02T15:04:05Z"}}">
181+                    {{.LastUpdated.Format "Jan 2, 2006"}}
182+                </time>
183+            </a>
184+            {{end}}
185+        </div>
186+    </main>
187+
188+    <footer>
189+        <p>Generated with pgit-index</p>
190+    </footer>
191+
192+    <script>
193+    (function() {
194+        var MINUTE_MS = 60000;
195+        var HOUR_MS = 3600000;
196+        var DAY_MS = 86400000;
197+        var MONTH_MS = 30 * DAY_MS;
198+
199+        function updateTimes() {
200+            var elements = document.querySelectorAll('[data-time]');
201+            var now = new Date();
202+            var minDiffMs = Infinity;
203+
204+            elements.forEach(function(el) {
205+                var date = new Date(el.getAttribute('data-time'));
206+                var diffMs = now - date;
207+                if (diffMs < minDiffMs && diffMs >= 0) {
208+                    minDiffMs = diffMs;
209+                }
210+                var diffMins = Math.floor(diffMs / MINUTE_MS);
211+                var diffHours = Math.floor(diffMs / HOUR_MS);
212+                var diffDays = Math.floor(diffMs / DAY_MS);
213+                var text;
214+                if (diffMins < 1) {
215+                    text = 'just now';
216+                } else if (diffMins < 60) {
217+                    text = diffMins + ' minute' + (diffMins === 1 ? '' : 's') + ' ago';
218+                } else if (diffHours < 24) {
219+                    text = diffHours + ' hour' + (diffHours === 1 ? '' : 's') + ' ago';
220+                } else if (diffDays < 30) {
221+                    text = diffDays + ' day' + (diffDays === 1 ? '' : 's') + ' ago';
222+                } else {
223+                    return;
224+                }
225+                el.textContent = text;
226+            });
227+            return minDiffMs;
228+        }
229+
230+        function scheduleUpdate() {
231+            var minDiffMs = updateTimes();
232+            var intervalMs;
233+            if (minDiffMs < HOUR_MS) {
234+                intervalMs = MINUTE_MS;
235+            } else if (minDiffMs < DAY_MS) {
236+                intervalMs = HOUR_MS;
237+            } else if (minDiffMs < MONTH_MS) {
238+                intervalMs = DAY_MS;
239+            } else {
240+                return;
241+            }
242+            setTimeout(scheduleUpdate, intervalMs);
243+        }
244+        scheduleUpdate();
245+    })();
246+    </script>
247+</body>
248+</html>`
249+
250+func main() {
251+	var rootCmd = &cobra.Command{
252+		Use:   "pgit-index",
253+		Short: "Generate an index page for multiple pgit sites",
254+		Long:  `Scans subdirectories for pgit.json files and generates an index.html aggregating all repository information.`,
255+		RunE:  runIndex,
256+	}
257+
258+	rootCmd.Flags().String("root", ".", "root directory containing pgit site subdirectories")
259+
260+	if err := rootCmd.Execute(); err != nil {
261+		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
262+		os.Exit(1)
263+	}
264+}
265+
266+func runIndex(cmd *cobra.Command, args []string) error {
267+	rootDir, _ := cmd.Flags().GetString("root")
268+
269+	root, err := filepath.Abs(rootDir)
270+	if err != nil {
271+		return fmt.Errorf("failed to resolve root path: %w", err)
272+	}
273+
274+	repos, err := discoverRepos(root)
275+	if err != nil {
276+		return fmt.Errorf("failed to discover repositories: %w", err)
277+	}
278+
279+	if len(repos) == 0 {
280+		return fmt.Errorf("no pgit.json files found in subdirectories of %s", root)
281+	}
282+
283+	return generateIndex(root, repos)
284+}
285+
286+func discoverRepos(root string) ([]RepoInfo, error) {
287+	var repos []RepoInfo
288+
289+	entries, err := os.ReadDir(root)
290+	if err != nil {
291+		return nil, err
292+	}
293+
294+	for _, entry := range entries {
295+		if !entry.IsDir() {
296+			continue
297+		}
298+
299+		jsonPath := filepath.Join(root, entry.Name(), "pgit.json")
300+		data, err := os.ReadFile(jsonPath)
301+		if err != nil {
302+			continue // Skip directories without pgit.json
303+		}
304+
305+		var repo RepoInfo
306+		if err := json.Unmarshal(data, &repo); err != nil {
307+			continue // Skip invalid JSON
308+		}
309+
310+		repo.Path = entry.Name()
311+		repos = append(repos, repo)
312+	}
313+
314+	return repos, nil
315+}
316+
317+func generateIndex(root string, repos []RepoInfo) error {
318+	// Copy logo.png to output directory
319+	if err := copyLogo(root); err != nil {
320+		return fmt.Errorf("failed to copy logo: %w", err)
321+	}
322+
323+	// Add safeHTML function to allow rendering HTML in descriptions
324+	funcMap := template.FuncMap{
325+		"safeHTML": func(s string) template.HTML { return template.HTML(s) },
326+	}
327+
328+	tmpl, err := template.New("index").Funcs(funcMap).Parse(indexTemplate)
329+	if err != nil {
330+		return fmt.Errorf("failed to parse template: %w", err)
331+	}
332+
333+	outputPath := filepath.Join(root, "index.html")
334+	file, err := os.Create(outputPath)
335+	if err != nil {
336+		return fmt.Errorf("failed to create index.html: %w", err)
337+	}
338+	defer file.Close()
339+
340+	if err := tmpl.Execute(file, repos); err != nil {
341+		return fmt.Errorf("failed to execute template: %w", err)
342+	}
343+
344+	fmt.Printf("Generated index.html at %s\n", outputPath)
345+	fmt.Printf("Found %d repositories\n", len(repos))
346+	return nil
347+}
348+
349+func copyLogo(root string) error {
350+	logoData, err := staticFS.ReadFile("static/logo.png")
351+	if err != nil {
352+		return fmt.Errorf("failed to read embedded logo: %w", err)
353+	}
354+
355+	logoPath := filepath.Join(root, "logo.png")
356+	if err := os.WriteFile(logoPath, logoData, 0644); err != nil {
357+		return fmt.Errorf("failed to write logo: %w", err)
358+	}
359+
360+	fmt.Printf("Copied logo.png to %s\n", logoPath)
361+	return nil
362+}
A cmd/pgit-index/static/logo.png
+0, -0
M config.go
+6, -0
 1@@ -52,6 +52,12 @@ type Config struct {
 2 	CSSFile   string
 3 }
 4 
 5+type RepoMetadata struct {
 6+	Name        string    `json:"name"`
 7+	Description string    `json:"description"`
 8+	LastUpdated time.Time `json:"last_updated"`
 9+}
10+
11 type RevInfo interface {
12 	ID() string
13 	Name() string
A docs/plan-89b0877.md
+530, -0
  1@@ -0,0 +1,530 @@
  2+# Implementation Plan: Output Structured Data and Create Index Page
  3+
  4+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
  5+
  6+**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.
  7+
  8+**Architecture:** 
  9+1. Extend pgit to output a `pgit.json` file alongside the generated site containing repo metadata
 10+2. Create a standalone `pgit-index` command that discovers these JSON files in subdirectories and renders an index page using the existing styling system
 11+
 12+**Tech Stack:** Go, HTML templates, embedded static files
 13+
 14+---
 15+
 16+## Task 1: Create RepoMetadata struct and JSON output function
 17+
 18+**Files:**
 19+- Modify: `config.go` (add struct)
 20+- Modify: `generator.go` (add JSON write function)
 21+- Modify: `cmd/pgit/main.go` (call JSON writer after site generation)
 22+
 23+**Step 1: Add RepoMetadata struct to config.go**
 24+
 25+Add after line 52 (after CSSFile field):
 26+
 27+```go
 28+type RepoMetadata struct {
 29+	Name        string    `json:"name"`
 30+	Description string    `json:"description"`
 31+	LastUpdated time.Time `json:"last_updated"`
 32+}
 33+```
 34+
 35+**Step 2: Add WriteRepoMetadata method to generator.go**
 36+
 37+Add after WriteRootSummary function (around line 26):
 38+
 39+```go
 40+func (c *Config) WriteRepoMetadata(lastCommit *CommitData) {
 41+	c.Logger.Info("writing repo metadata JSON", "repoPath", c.RepoPath)
 42+	
 43+	// Extract plain text description from HTML
 44+	desc := string(c.Desc)
 45+	// Remove HTML tags for plain text
 46+	desc = regexp.MustCompile(`<[^>]*>`).ReplaceAllString(desc, "")
 47+	
 48+	metadata := &RepoMetadata{
 49+		Name:        c.RepoName,
 50+		Description: desc,
 51+		LastUpdated: lastCommit.Author.When,
 52+	}
 53+	
 54+	data, err := json.MarshalIndent(metadata, "", "  ")
 55+	if err != nil {
 56+		c.Logger.Error("failed to marshal metadata", "error", err)
 57+		return
 58+	}
 59+	
 60+	fp := filepath.Join(c.Outdir, "pgit.json")
 61+	err = os.WriteFile(fp, data, 0644)
 62+	if err != nil {
 63+		c.Logger.Error("failed to write metadata file", "error", err)
 64+		return
 65+	}
 66+	c.Logger.Info("wrote metadata file", "filepath", fp)
 67+}
 68+```
 69+
 70+**Step 3: Add necessary imports to generator.go**
 71+
 72+Add to imports:
 73+```go
 74+"encoding/json"
 75+"os"
 76+"regexp"
 77+```
 78+
 79+**Step 4: Call WriteRepoMetadata in WriteRepo**
 80+
 81+In generator.go around line 310, after WriteRootSummary call:
 82+```go
 83+c.WriteRootSummary(data, readmeHTML, mainOutput.LastCommit)
 84+c.WriteRepoMetadata(mainOutput.LastCommit)  // ADD THIS LINE
 85+return mainOutput
 86+```
 87+
 88+**Step 5: Commit**
 89+
 90+```bash
 91+jj commit -m "feat: output structured JSON metadata with each site"
 92+```
 93+
 94+---
 95+
 96+## Task 2: Create pgit-index command structure
 97+
 98+**Files:**
 99+- Create: `cmd/pgit-index/main.go`
100+
101+**Step 1: Create command file**
102+
103+Create `cmd/pgit-index/main.go`:
104+
105+```go
106+package main
107+
108+import (
109+	"encoding/json"
110+	"fmt"
111+	"html/template"
112+	"os"
113+	"path/filepath"
114+	"time"
115+
116+	"github.com/spf13/cobra"
117+)
118+
119+type RepoInfo struct {
120+	Name        string    `json:"name"`
121+	Description string    `json:"description"`
122+	LastUpdated time.Time `json:"last_updated"`
123+	Path        string    // relative path from root
124+}
125+
126+func main() {
127+	var rootCmd = &cobra.Command{
128+		Use:   "pgit-index",
129+		Short: "Generate an index page for multiple pgit sites",
130+		Long:  `Scans subdirectories for pgit.json files and generates an index.html aggregating all repository information.`,
131+		RunE:  runIndex,
132+	}
133+
134+	rootCmd.Flags().String("root", ".", "root directory containing pgit site subdirectories")
135+
136+	if err := rootCmd.Execute(); err != nil {
137+		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
138+		os.Exit(1)
139+	}
140+}
141+
142+func runIndex(cmd *cobra.Command, args []string) error {
143+	rootDir, _ := cmd.Flags().GetString("root")
144+	
145+	root, err := filepath.Abs(rootDir)
146+	if err != nil {
147+		return fmt.Errorf("failed to resolve root path: %w", err)
148+	}
149+
150+	repos, err := discoverRepos(root)
151+	if err != nil {
152+		return fmt.Errorf("failed to discover repositories: %w", err)
153+	}
154+
155+	if len(repos) == 0 {
156+		return fmt.Errorf("no pgit.json files found in subdirectories of %s", root)
157+	}
158+
159+	return generateIndex(root, repos)
160+}
161+
162+func discoverRepos(root string) ([]RepoInfo, error) {
163+	var repos []RepoInfo
164+
165+	entries, err := os.ReadDir(root)
166+	if err != nil {
167+		return nil, err
168+	}
169+
170+	for _, entry := range entries {
171+		if !entry.IsDir() {
172+			continue
173+		}
174+
175+		jsonPath := filepath.Join(root, entry.Name(), "pgit.json")
176+		data, err := os.ReadFile(jsonPath)
177+		if err != nil {
178+			continue // Skip directories without pgit.json
179+		}
180+
181+		var repo RepoInfo
182+		if err := json.Unmarshal(data, &repo); err != nil {
183+			continue // Skip invalid JSON
184+		}
185+
186+		repo.Path = entry.Name()
187+		repos = append(repos, repo)
188+	}
189+
190+	return repos, nil
191+}
192+
193+func generateIndex(root string, repos []RepoInfo) error {
194+	// TODO: Implement HTML generation
195+	return nil
196+}
197+```
198+
199+**Step 2: Test the discovery logic**
200+
201+Run: `go run ./cmd/pgit-index --root ./testdata.site`
202+Expected: Error "no pgit.json files found" (since we haven't generated them yet)
203+
204+**Step 3: Commit**
205+
206+```bash
207+jj commit -m "feat: create pgit-index command structure with repo discovery"
208+```
209+
210+---
211+
212+## Task 3: Create index.html template for pgit-index
213+
214+**Files:**
215+- Create: `cmd/pgit-index/index.html.tmpl` (embedded template)
216+
217+**Step 1: Create HTML template string in main.go**
218+
219+Add before main() function:
220+
221+```go
222+const indexTemplate = `<!doctype html>
223+<html lang="en">
224+<head>
225+    <meta charset="utf-8">
226+    <meta name="viewport" content="width=device-width, initial-scale=1">
227+    <title>Git Repositories</title>
228+    <style>
229+        /* Critical CSS - inlined to prevent FOUC */
230+        :root {
231+            --line-height: 1.3rem;
232+            --grid-height: 0.65rem;
233+            --bg-color: #282a36;
234+            --text-color: #f8f8f2;
235+            --border: #6272a4;
236+            --link-color: #8be9fd;
237+            --hover: #ff79c6;
238+            --visited: #8be9fd;
239+            --white: #f2f2f2;
240+            --grey: #414558;
241+            --grey-light: #6a708e;
242+            --code: #414558;
243+            --pre: #252525;
244+        }
245+        html {
246+            background-color: var(--bg-color);
247+            color: var(--text-color);
248+            font-size: 16px;
249+            line-height: var(--line-height);
250+            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", Arial, sans-serif;
251+            -webkit-text-size-adjust: 100%;
252+        }
253+        body {
254+            margin: 0 auto;
255+            max-width: 900px;
256+            padding: 0 var(--grid-height);
257+        }
258+        *, ::before, ::after { box-sizing: border-box; }
259+        
260+        .site-header {
261+            margin: 1rem auto;
262+        }
263+        .site-header__title {
264+            font-size: 1rem;
265+            font-weight: bold;
266+            line-height: var(--line-height);
267+            text-transform: uppercase;
268+            margin: 0;
269+        }
270+        
271+        .repo-grid {
272+            display: grid;
273+            grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
274+            gap: 1rem;
275+            margin: 1.5rem 0;
276+        }
277+        
278+        .repo-card {
279+            border: 1px solid var(--border);
280+            border-radius: 4px;
281+            padding: 1rem;
282+            background-color: var(--pre);
283+            transition: border-color 0.2s ease;
284+        }
285+        
286+        .repo-card:hover {
287+            border-color: var(--link-color);
288+        }
289+        
290+        .repo-card__name {
291+            font-size: 1rem;
292+            font-weight: bold;
293+            margin: 0 0 var(--grid-height) 0;
294+            text-transform: uppercase;
295+        }
296+        
297+        .repo-card__name a {
298+            color: var(--link-color);
299+            text-decoration: none;
300+        }
301+        
302+        .repo-card__name a:hover {
303+            text-decoration: underline;
304+        }
305+        
306+        .repo-card__desc {
307+            font-size: 0.9rem;
308+            color: var(--grey-light);
309+            margin: 0 0 var(--line-height) 0;
310+            line-height: var(--line-height);
311+        }
312+        
313+        .repo-card__updated {
314+            font-size: 0.8rem;
315+            color: var(--grey-light);
316+            font-family: monospace;
317+        }
318+        
319+        footer {
320+            text-align: center;
321+            margin: calc(var(--line-height) * 3) 0;
322+            color: var(--grey-light);
323+            font-size: 0.8rem;
324+        }
325+        
326+        @media only screen and (max-width: 40em) {
327+            body {
328+                padding: 0 var(--grid-height);
329+            }
330+            .repo-grid {
331+                grid-template-columns: 1fr;
332+            }
333+            .repo-card__updated {
334+                display: none;
335+            }
336+        }
337+    </style>
338+</head>
339+<body>
340+    <header class="site-header">
341+        <h1 class="site-header__title">Git Repositories</h1>
342+    </header>
343+    
344+    <main>
345+        <div class="repo-grid">
346+            {{range .}}
347+            <article class="repo-card">
348+                <h2 class="repo-card__name"><a href="{{.Path}}">{{.Name}}</a></h2>
349+                <p class="repo-card__desc">{{.Description}}</p>
350+                <time class="repo-card__updated" data-time="{{.LastUpdated.UTC.Format \"2006-01-02T15:04:05Z\"}}">
351+                    {{.LastUpdated.Format "Jan 2, 2006"}}
352+                </time>
353+            </article>
354+            {{end}}
355+        </div>
356+    </main>
357+    
358+    <footer>
359+        <p>Generated with pgit-index</p>
360+    </footer>
361+    
362+    <script>
363+    (function() {
364+        var MINUTE_MS = 60000;
365+        var HOUR_MS = 3600000;
366+        var DAY_MS = 86400000;
367+        var MONTH_MS = 30 * DAY_MS;
368+
369+        function updateTimes() {
370+            var elements = document.querySelectorAll('[data-time]');
371+            var now = new Date();
372+            var minDiffMs = Infinity;
373+
374+            elements.forEach(function(el) {
375+                var date = new Date(el.getAttribute('data-time'));
376+                var diffMs = now - date;
377+                if (diffMs < minDiffMs && diffMs >= 0) {
378+                    minDiffMs = diffMs;
379+                }
380+                var diffMins = Math.floor(diffMs / MINUTE_MS);
381+                var diffHours = Math.floor(diffMs / HOUR_MS);
382+                var diffDays = Math.floor(diffMs / DAY_MS);
383+                var text;
384+                if (diffMins < 1) {
385+                    text = 'just now';
386+                } else if (diffMins < 60) {
387+                    text = diffMins + ' minute' + (diffMins === 1 ? '' : 's') + ' ago';
388+                } else if (diffHours < 24) {
389+                    text = diffHours + ' hour' + (diffHours === 1 ? '' : 's') + ' ago';
390+                } else if (diffDays < 30) {
391+                    text = diffDays + ' day' + (diffDays === 1 ? '' : 's') + ' ago';
392+                } else {
393+                    return;
394+                }
395+                el.textContent = text;
396+            });
397+            return minDiffMs;
398+        }
399+
400+        function scheduleUpdate() {
401+            var minDiffMs = updateTimes();
402+            var intervalMs;
403+            if (minDiffMs < HOUR_MS) {
404+                intervalMs = MINUTE_MS;
405+            } else if (minDiffMs < DAY_MS) {
406+                intervalMs = HOUR_MS;
407+            } else if (minDiffMs < MONTH_MS) {
408+                intervalMs = DAY_MS;
409+            } else {
410+                return;
411+            }
412+            setTimeout(scheduleUpdate, intervalMs);
413+        }
414+        scheduleUpdate();
415+    })();
416+    </script>
417+</body>
418+</html>`
419+```
420+
421+**Step 2: Implement generateIndex function**
422+
423+Replace the TODO generateIndex with:
424+
425+```go
426+func generateIndex(root string, repos []RepoInfo) error {
427+	tmpl, err := template.New("index").Parse(indexTemplate)
428+	if err != nil {
429+		return fmt.Errorf("failed to parse template: %w", err)
430+	}
431+
432+	outputPath := filepath.Join(root, "index.html")
433+	file, err := os.Create(outputPath)
434+	if err != nil {
435+		return fmt.Errorf("failed to create index.html: %w", err)
436+	}
437+	defer file.Close()
438+
439+	if err := tmpl.Execute(file, repos); err != nil {
440+		return fmt.Errorf("failed to execute template: %w", err)
441+	}
442+
443+	fmt.Printf("Generated index.html at %s\n", outputPath)
444+	fmt.Printf("Found %d repositories\n", len(repos))
445+	return nil
446+}
447+```
448+
449+**Step 3: Commit**
450+
451+```bash
452+jj commit -m "feat: add HTML template generation to pgit-index"
453+```
454+
455+---
456+
457+## Task 4: Test end-to-end workflow
458+
459+**Step 1: Generate a test site with JSON metadata**
460+
461+Run: `go run ./cmd/pgit --repo ./testdata.repo --out ./test-output --revs HEAD --label test-repo`
462+
463+**Step 2: Verify pgit.json was created**
464+
465+Run: `cat ./test-output/pgit.json`
466+Expected: Valid JSON with name, description, and last_updated fields
467+
468+**Step 3: Test pgit-index with the generated site**
469+
470+Run: 
471+```bash
472+mkdir -p ./test-root
473+cp -r ./test-output ./test-root/
474+go run ./cmd/pgit-index --root ./test-root
475+```
476+
477+**Step 4: Verify index.html was created**
478+
479+Run: `cat ./test-root/index.html`
480+Expected: Valid HTML with repo card, time data attribute, and JS shim
481+
482+**Step 5: Commit test artifacts (optional)**
483+
484+```bash
485+jj commit -m "test: verify end-to-end workflow for index generation"
486+```
487+
488+---
489+
490+## Task 5: Add pgit-index to build
491+
492+**Files:**
493+- Modify: `Makefile` (if exists)
494+
495+**Step 1: Check if Makefile has build targets**
496+
497+Run: `cat Makefile`
498+
499+**Step 2: Add pgit-index build target if needed**
500+
501+If there's a build target for pgit, add similar for pgit-index:
502+```makefile
503+build-pgit-index:
504+	go build -o bin/pgit-index ./cmd/pgit-index
505+```
506+
507+**Step 3: Commit**
508+
509+```bash
510+jj commit -m "build: add pgit-index to build targets"
511+```
512+
513+---
514+
515+## Summary of Files Changed
516+
517+**Modified:**
518+- `config.go` - Added RepoMetadata struct
519+- `generator.go` - Added WriteRepoMetadata function and JSON output
520+- `cmd/pgit/main.go` - Added call to WriteRepoMetadata
521+
522+**Created:**
523+- `cmd/pgit-index/main.go` - New command for generating index pages
524+
525+**Key Features:**
526+1. Each pgit site now outputs `pgit.json` with repo metadata
527+2. `pgit-index` command discovers all `pgit.json` files in subdirectories
528+3. Generated `index.html` uses consistent styling with existing pgit sites
529+4. Time humanization JS shim included inline (same as base layout)
530+5. Responsive design hides last-updated time on small screens (< 40em)
531+6. Grid layout adapts from single column (mobile) to multi-column (desktop)
M flake.nix
+1, -1
1@@ -24,7 +24,7 @@
2           # Run 'nix build' and it will fail with the expected hash
3           vendorHash = "sha256-95H+k3LHaB6WjnLpwjviCopwfO9MKbyiVKB5HWyNZgE=";
4 
5-          subPackages = [ "cmd/pgit" ];
6+          subPackages = [ "cmd/pgit" "cmd/pgit-index" ];
7 
8           meta = with pkgs.lib; {
9             description = "Static site generator for git repositories";
M generator.go
+30, -0
 1@@ -1,8 +1,10 @@
 2 package pgit
 3 
 4 import (
 5+	"encoding/json"
 6 	"fmt"
 7 	"html/template"
 8+	"os"
 9 	"path/filepath"
10 	"sort"
11 	"strings"
12@@ -25,6 +27,33 @@ func (c *Config) WriteRootSummary(data *PageData, readme template.HTML, lastComm
13 	})
14 }
15 
16+func (c *Config) WriteRepoMetadata(lastCommit *CommitData) {
17+	c.Logger.Info("writing repo metadata JSON", "repoPath", c.RepoPath)
18+
19+	// Keep HTML description for rendering in index
20+	desc := string(c.Desc)
21+
22+	metadata := &RepoMetadata{
23+		Name:        c.RepoName,
24+		Description: desc,
25+		LastUpdated: lastCommit.Author.When,
26+	}
27+
28+	data, err := json.MarshalIndent(metadata, "", "  ")
29+	if err != nil {
30+		c.Logger.Error("failed to marshal metadata", "error", err)
31+		return
32+	}
33+
34+	fp := filepath.Join(c.Outdir, "pgit.json")
35+	err = os.WriteFile(fp, data, 0644)
36+	if err != nil {
37+		c.Logger.Error("failed to write metadata file", "error", err)
38+		return
39+	}
40+	c.Logger.Info("wrote metadata file", "filepath", fp)
41+}
42+
43 func (c *Config) WriteTree(data *PageData, tree *TreeRoot) {
44 	c.Logger.Info("writing tree", "treePath", tree.Path)
45 	c.WriteHTML(&WriteData{
46@@ -308,6 +337,7 @@ func (c *Config) WriteRepo() *BranchOutput {
47 	}
48 	readmeHTML = template.HTML(`<div class="readme">` + string(readmeHTML) + `</div>`)
49 	c.WriteRootSummary(data, readmeHTML, mainOutput.LastCommit)
50+	c.WriteRepoMetadata(mainOutput.LastCommit)
51 	return mainOutput
52 }
53 
A static/logo.png
+0, -0