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
+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+}
+0,
-0
+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
+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)
+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";
+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
+0,
-0