Output structured data to create an index page

open
#89b0877 opened by scooter Apr 15

Plan a feature to output structured JSON data that includes the repo name passed in the –label argument, the repo description, and the last updated time in ISO8601. Then I want you to create a new command /cmd/pgit-index/main.go that when run in an output directory defined by –root or the current working directory will create an index.html file in the root. Under the root directory are several pgit output sites under a sub-path, built using the –root-relative pgit functionality such as /repo-a, /repo-b, etc. The command should walk the filepaths that exist under the root and find the structured JSON data that was output when the sub-path site was generated.

The index.html should use a simple grid structure that renders the label, description, and last updated time, one for each repo. For the time, use a span with attribute “data-time” and include the JS shim that already exists to humanize the time. On small screens, hide the last-updated time. Use the same background color, fonts, and accent colors already used for the rest of the site.

3 Comments

agent 8b9fbd0 Apr 15

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)

agent 8e9abb0 Apr 15

Implementation Complete

All tasks have been successfully implemented:

Completed Tasks

  1. Task 1: JSON Metadata Output

    • Added struct to
    • Created function in
    • pgit now outputs with name, description, and last_updated (ISO8601)
  2. Task 2: pgit-index Command

    • Created
    • Discovers repos by scanning subdirectories for
    • Uses cobra for CLI with flag
  3. Task 3: HTML Template

    • Grid layout with responsive design
    • Uses same CSS variables as pgit sites (–bg-color, –link-color, etc.)
    • Time humanization JS shim included
    • Hides last-updated on small screens (< 40em)
  4. Task 4: Testing

    • Verified pgit generates valid pgit.json
    • Verified pgit-index creates index.html with correct structure
    • Tested with multiple repos
  5. Task 5: Build Integration

    • Updated Makefile to build pgit-index
    • Updated flake.nix subPackages

Files Modified/Created

Modified: - - Added RepoMetadata struct - - Added WriteRepoMetadata function - - Added pgit-index build target - - Added cmd/pgit-index to subPackages

Created: - - New command for generating index pages

Usage

Generate a pgit site:

Generate index for multiple sites:

Commits

  • - feat: output structured JSON metadata with each site
  • - feat: create pgit-index command with HTML generation
agent 8393ba0 Apr 15

Update: Markdown/HTML Descriptions

The implementation now correctly handles markdown in descriptions and renders it as HTML in the index page.

Changes Made

  1. generator.go - No longer strips HTML tags from descriptions
  2. cmd/pgit-index/main.go - Added safeHTML template function to render description as HTML

Result

Markdown like bold and links in the –desc flag now renders properly in the index: - strong tags display as bold text - a tags display as clickable links

Example

pgit –desc “This is bold and a link” …

Renders in index.html as formatted HTML with bold text and clickable links.