scooter  ·  2026-04-15

main.go

  1package main
  2
  3import (
  4	"embed"
  5	"encoding/json"
  6	"fmt"
  7	"html/template"
  8	"os"
  9	"path/filepath"
 10	"time"
 11
 12	"github.com/spf13/cobra"
 13)
 14
 15//go:embed static/logo.png
 16var staticFS embed.FS
 17
 18type RepoInfo struct {
 19	Name        string    `json:"name"`
 20	Description string    `json:"description"`
 21	LastUpdated time.Time `json:"last_updated"`
 22	Path        string    // relative path from root
 23}
 24
 25const indexTemplate = `<!doctype html>
 26<html lang="en">
 27<head>
 28    <meta charset="utf-8">
 29    <meta name="viewport" content="width=device-width, initial-scale=1">
 30    <title>Git Repositories</title>
 31    <style>
 32        /* Critical CSS - inlined to prevent FOUC */
 33        :root {
 34            --line-height: 1.3rem;
 35            --grid-height: 0.65rem;
 36            --bg-color: #0d1117;
 37            --text-color: #e6edf3;
 38            --border: #6a708e;
 39            --link-color: #79C0FF;
 40            --hover: #ff79c6;
 41            --visited: #79C0FF;
 42            --white: #f2f2f2;
 43            --grey: #414558;
 44            --grey-light: #6a708e;
 45            --code: #414558;
 46            --pre: #252525;
 47        }
 48        html {
 49            background-color: var(--bg-color);
 50            color: var(--text-color);
 51            font-size: 16px;
 52            line-height: var(--line-height);
 53            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", Arial, sans-serif;
 54            -webkit-text-size-adjust: 100%;
 55        }
 56        body {
 57            margin: 0 auto;
 58            max-width: 900px;
 59            padding: 0 var(--grid-height);
 60        }
 61        *, ::before, ::after { box-sizing: border-box; }
 62
 63        .site-header {
 64            margin: 1rem auto;
 65            display: flex;
 66            flex-direction: row;
 67            align-items: center;
 68            gap: 1rem;
 69        }
 70
 71        .site-header__logo {
 72            width: 4rem;
 73            height: 4rem;
 74            flex-shrink: 0;
 75        }
 76
 77        .site-header__content {
 78            display: flex;
 79            flex-direction: column;
 80            justify-content: center;
 81        }
 82
 83        .site-header__title {
 84            font-size: 1rem;
 85            font-weight: bold;
 86            line-height: var(--line-height);
 87            text-transform: uppercase;
 88            margin: 0;
 89        }
 90
 91        .site-header__desc {
 92            font-size: 0.9rem;
 93            color: var(--grey-light);
 94            margin: 0;
 95            line-height: var(--line-height);
 96        }
 97
 98        .repo-grid {
 99            display: flex;
100            flex-direction: column;
101            gap: 0.5rem;
102            margin: 1rem 0;
103        }
104
105        .repo-card {
106            display: block;
107            border: 1px solid var(--border);
108            border-radius: 4px;
109            padding: 0.75rem 1rem;
110            background-color: var(--pre);
111            transition: border-color 0.2s ease;
112            text-decoration: none;
113            color: inherit;
114            width: 100%;
115        }
116
117        .repo-card:hover {
118            border-color: var(--link-color);
119        }
120
121        .repo-card__name {
122            font-size: 1rem;
123            font-weight: bold;
124            margin: 0 0 0.25rem 0;
125            text-transform: uppercase;
126            color: var(--link-color);
127        }
128
129        .repo-card__desc {
130            font-size: 0.9rem;
131            color: var(--grey-light);
132            margin: 0 0 var(--grid-height) 0;
133            line-height: var(--line-height);
134        }
135
136        .repo-card__desc:empty {
137            margin: 0;
138            display: none;
139        }
140
141        .repo-card__updated {
142            font-size: 0.8rem;
143            color: var(--grey-light);
144            font-family: monospace;
145        }
146
147        footer {
148            text-align: center;
149            margin: calc(var(--line-height) * 3) 0;
150            color: var(--grey-light);
151            font-size: 0.8rem;
152        }
153
154        @media only screen and (max-width: 40em) {
155            body {
156                padding: 0 var(--grid-height);
157            }
158            .repo-card__updated {
159                display: none;
160            }
161        }
162    </style>
163</head>
164<body>
165    <header class="site-header">
166        <img src="logo.png" class="site-header__logo" alt="Logo" />
167        <div class="site-header__content">
168            <h1 class="site-header__title">Forged In Fire</h1>
169            <p class="site-header__desc">Artisanally handcrafted code with only a little help from AI</p>
170        </div>
171    </header>
172
173    <main>
174        <div class="repo-grid">
175            {{range .}}
176            <a href="{{.Path}}" class="repo-card">
177                <div class="repo-card__name">{{.Name}}</div>
178                <p class="repo-card__desc">{{.Description | safeHTML}}</p>
179                <time class="repo-card__updated" data-time="{{.LastUpdated.UTC.Format "2006-01-02T15:04:05Z"}}">
180                    {{.LastUpdated.Format "Jan 2, 2006"}}
181                </time>
182            </a>
183            {{end}}
184        </div>
185    </main>
186
187    <footer>
188        <p>Generated with pgit-index</p>
189    </footer>
190
191    <script>
192    (function() {
193        var MINUTE_MS = 60000;
194        var HOUR_MS = 3600000;
195        var DAY_MS = 86400000;
196        var MONTH_MS = 30 * DAY_MS;
197
198        function updateTimes() {
199            var elements = document.querySelectorAll('[data-time]');
200            var now = new Date();
201            var minDiffMs = Infinity;
202
203            elements.forEach(function(el) {
204                var date = new Date(el.getAttribute('data-time'));
205                var diffMs = now - date;
206                if (diffMs < minDiffMs && diffMs >= 0) {
207                    minDiffMs = diffMs;
208                }
209                var diffMins = Math.floor(diffMs / MINUTE_MS);
210                var diffHours = Math.floor(diffMs / HOUR_MS);
211                var diffDays = Math.floor(diffMs / DAY_MS);
212                var text;
213                if (diffMins < 1) {
214                    text = 'just now';
215                } else if (diffMins < 60) {
216                    text = diffMins + ' minute' + (diffMins === 1 ? '' : 's') + ' ago';
217                } else if (diffHours < 24) {
218                    text = diffHours + ' hour' + (diffHours === 1 ? '' : 's') + ' ago';
219                } else if (diffDays < 30) {
220                    text = diffDays + ' day' + (diffDays === 1 ? '' : 's') + ' ago';
221                } else {
222                    return;
223                }
224                el.textContent = text;
225            });
226            return minDiffMs;
227        }
228
229        function scheduleUpdate() {
230            var minDiffMs = updateTimes();
231            var intervalMs;
232            if (minDiffMs < HOUR_MS) {
233                intervalMs = MINUTE_MS;
234            } else if (minDiffMs < DAY_MS) {
235                intervalMs = HOUR_MS;
236            } else if (minDiffMs < MONTH_MS) {
237                intervalMs = DAY_MS;
238            } else {
239                return;
240            }
241            setTimeout(scheduleUpdate, intervalMs);
242        }
243        scheduleUpdate();
244    })();
245    </script>
246</body>
247</html>`
248
249func main() {
250	var rootCmd = &cobra.Command{
251		Use:   "pgit-index",
252		Short: "Generate an index page for multiple pgit sites",
253		Long:  `Scans subdirectories for pgit.json files and generates an index.html aggregating all repository information.`,
254		RunE:  runIndex,
255	}
256
257	rootCmd.Flags().String("root", ".", "root directory containing pgit site subdirectories")
258
259	if err := rootCmd.Execute(); err != nil {
260		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
261		os.Exit(1)
262	}
263}
264
265func runIndex(cmd *cobra.Command, args []string) error {
266	rootDir, _ := cmd.Flags().GetString("root")
267
268	root, err := filepath.Abs(rootDir)
269	if err != nil {
270		return fmt.Errorf("failed to resolve root path: %w", err)
271	}
272
273	repos, err := discoverRepos(root)
274	if err != nil {
275		return fmt.Errorf("failed to discover repositories: %w", err)
276	}
277
278	if len(repos) == 0 {
279		return fmt.Errorf("no pgit.json files found in subdirectories of %s", root)
280	}
281
282	return generateIndex(root, repos)
283}
284
285func discoverRepos(root string) ([]RepoInfo, error) {
286	var repos []RepoInfo
287
288	entries, err := os.ReadDir(root)
289	if err != nil {
290		return nil, err
291	}
292
293	for _, entry := range entries {
294		if !entry.IsDir() {
295			continue
296		}
297
298		jsonPath := filepath.Join(root, entry.Name(), "pgit.json")
299		data, err := os.ReadFile(jsonPath)
300		if err != nil {
301			continue // Skip directories without pgit.json
302		}
303
304		var repo RepoInfo
305		if err := json.Unmarshal(data, &repo); err != nil {
306			continue // Skip invalid JSON
307		}
308
309		repo.Path = entry.Name()
310		repos = append(repos, repo)
311	}
312
313	return repos, nil
314}
315
316func generateIndex(root string, repos []RepoInfo) error {
317	// Copy logo.png to output directory
318	if err := copyLogo(root); err != nil {
319		return fmt.Errorf("failed to copy logo: %w", err)
320	}
321
322	// Add safeHTML function to allow rendering HTML in descriptions
323	funcMap := template.FuncMap{
324		"safeHTML": func(s string) template.HTML { return template.HTML(s) },
325	}
326
327	tmpl, err := template.New("index").Funcs(funcMap).Parse(indexTemplate)
328	if err != nil {
329		return fmt.Errorf("failed to parse template: %w", err)
330	}
331
332	outputPath := filepath.Join(root, "index.html")
333	file, err := os.Create(outputPath)
334	if err != nil {
335		return fmt.Errorf("failed to create index.html: %w", err)
336	}
337	defer file.Close()
338
339	if err := tmpl.Execute(file, repos); err != nil {
340		return fmt.Errorf("failed to execute template: %w", err)
341	}
342
343	fmt.Printf("Generated index.html at %s\n", outputPath)
344	fmt.Printf("Found %d repositories\n", len(repos))
345	return nil
346}
347
348func copyLogo(root string) error {
349	logoData, err := staticFS.ReadFile("static/logo.png")
350	if err != nil {
351		return fmt.Errorf("failed to read embedded logo: %w", err)
352	}
353
354	logoPath := filepath.Join(root, "logo.png")
355	if err := os.WriteFile(logoPath, logoData, 0644); err != nil {
356		return fmt.Errorf("failed to write logo: %w", err)
357	}
358
359	fmt.Printf("Copied logo.png to %s\n", logoPath)
360	return nil
361}