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}