From 61e2133048486e7b50cbc7be51572faf9ea00cd7 Mon Sep 17 00:00:00 2001 From: Brian Ketelsen Date: Wed, 7 Jan 2026 11:39:08 -0500 Subject: [PATCH 1/2] feat: user friendly html index pages Signed-off-by: Brian Ketelsen --- internal/cli/add.go | 5 + internal/cli/index.go | 7 +- internal/cli/init.go | 4 + internal/repo/html.go | 226 +++++++++++++++++++++++++++++++++++++ internal/repo/html_test.go | 135 ++++++++++++++++++++++ 5 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 internal/repo/html.go create mode 100644 internal/repo/html_test.go diff --git a/internal/cli/add.go b/internal/cli/add.go index 141e160..c4e9d16 100644 --- a/internal/cli/add.go +++ b/internal/cli/add.go @@ -57,6 +57,11 @@ and optionally prunes old versions.`, } fmt.Printf(" Updated Release for %s\n", addDist) + // Generate HTML indexes for browsing + if err := r.GenerateHTMLIndexes(); err != nil { + return fmt.Errorf("generate HTML indexes: %w", err) + } + return nil }, } diff --git a/internal/cli/index.go b/internal/cli/index.go index 4a891ff..b0e7b02 100644 --- a/internal/cli/index.go +++ b/internal/cli/index.go @@ -14,7 +14,7 @@ var ( var indexCmd = &cobra.Command{ Use: "index", Short: "Regenerate repository index files", - Long: `Regenerates the Packages, Packages.gz, Packages.xz, and Release files for a distribution.`, + Long: `Regenerates the Packages, Packages.gz, Packages.xz, and Release files for a distribution, and generates HTML index pages for browser-friendly navigation.`, RunE: func(cmd *cobra.Command, args []string) error { cfg := repo.DefaultConfig() r := repo.New(repoRoot, cfg) @@ -29,6 +29,11 @@ var indexCmd = &cobra.Command{ } fmt.Printf("Generated Release for %s\n", indexDist) + if err := r.GenerateHTMLIndexes(); err != nil { + return fmt.Errorf("generate HTML indexes: %w", err) + } + fmt.Println("Generated HTML index pages") + return nil }, } diff --git a/internal/cli/init.go b/internal/cli/init.go index 25ab268..9f4de7f 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -19,6 +19,10 @@ var initCmd = &cobra.Command{ return fmt.Errorf("initialize repository: %w", err) } + if err := r.GenerateHTMLIndexes(); err != nil { + return fmt.Errorf("generate HTML indexes: %w", err) + } + fmt.Println("Repository initialized successfully") fmt.Printf(" Root: %s\n", repoRoot) fmt.Printf(" Distributions: %v\n", cfg.Distributions) diff --git a/internal/repo/html.go b/internal/repo/html.go new file mode 100644 index 0000000..50264d8 --- /dev/null +++ b/internal/repo/html.go @@ -0,0 +1,226 @@ +// Package repo manages Debian repository structure and metadata. +package repo + +import ( + "fmt" + "html/template" + "os" + "path/filepath" + "sort" + "strings" +) + +const htmlTemplate = ` + + + + + Index of {{.Path}} + + + +

Index of {{.Path}}

+ + + + + + + + + {{if .ShowParent}} + + + + + {{end}} + {{range .Directories}} + + + + + {{end}} + {{range .Files}} + + + + + {{end}} + +
NameSize
📁../-
📁{{.Name}}/-
{{.Icon}}{{.Name}}{{.Size}}
+ + +` + +// DirectoryEntry represents a subdirectory in the index. +type DirectoryEntry struct { + Name string +} + +// FileEntry represents a file in the index. +type FileEntry struct { + Name string + Size string + Icon string +} + +// IndexData holds data for rendering an HTML index page. +type IndexData struct { + Path string + ShowParent bool + Directories []DirectoryEntry + Files []FileEntry +} + +// GenerateHTMLIndexes creates index.html files in all repository directories +// to enable browser-friendly navigation. +func (r *Repository) GenerateHTMLIndexes() error { + // Walk the entire repository and generate index.html for each directory + return filepath.Walk(r.Root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if !info.IsDir() { + return nil + } + + // Skip hidden directories (like .git) + if strings.HasPrefix(info.Name(), ".") && path != r.Root { + return filepath.SkipDir + } + + return r.generateIndexForDirectory(path) + }) +} + +func (r *Repository) generateIndexForDirectory(dirPath string) error { + entries, err := os.ReadDir(dirPath) + if err != nil { + return fmt.Errorf("read directory %s: %w", dirPath, err) + } + + var directories []DirectoryEntry + var files []FileEntry + + for _, entry := range entries { + name := entry.Name() + + // Skip hidden files and the index.html we're generating + if strings.HasPrefix(name, ".") || name == "index.html" { + continue + } + + if entry.IsDir() { + directories = append(directories, DirectoryEntry{Name: name}) + } else { + info, err := entry.Info() + if err != nil { + continue + } + files = append(files, FileEntry{ + Name: name, + Size: formatSize(info.Size()), + Icon: iconForFile(name), + }) + } + } + + // Sort alphabetically + sort.Slice(directories, func(i, j int) bool { + return directories[i].Name < directories[j].Name + }) + sort.Slice(files, func(i, j int) bool { + return files[i].Name < files[j].Name + }) + + // Calculate relative path for display + relPath, err := filepath.Rel(r.Root, dirPath) + if err != nil { + relPath = dirPath + } + if relPath == "." { + relPath = "/" + } else { + relPath = "/" + relPath + "/" + } + + // Determine if we should show parent link + showParent := dirPath != r.Root + + data := IndexData{ + Path: relPath, + ShowParent: showParent, + Directories: directories, + Files: files, + } + + // Parse and execute template + tmpl, err := template.New("index").Parse(htmlTemplate) + if err != nil { + return fmt.Errorf("parse template: %w", err) + } + + indexPath := filepath.Join(dirPath, "index.html") + f, err := os.Create(indexPath) + if err != nil { + return fmt.Errorf("create index.html: %w", err) + } + defer f.Close() //nolint:errcheck + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute template: %w", err) + } + + return nil +} + +func formatSize(size int64) string { + const ( + KB = 1024 + MB = 1024 * KB + GB = 1024 * MB + ) + + switch { + case size >= GB: + return fmt.Sprintf("%.1f GB", float64(size)/float64(GB)) + case size >= MB: + return fmt.Sprintf("%.1f MB", float64(size)/float64(MB)) + case size >= KB: + return fmt.Sprintf("%.1f KB", float64(size)/float64(KB)) + default: + return fmt.Sprintf("%d B", size) + } +} + +func iconForFile(name string) string { + lower := strings.ToLower(name) + + switch { + case strings.HasSuffix(lower, ".deb"): + return "📦" + case strings.HasSuffix(lower, ".gz") || strings.HasSuffix(lower, ".xz"): + return "🗜️" + case strings.HasSuffix(lower, ".gpg") || strings.HasSuffix(lower, ".key"): + return "🔑" + case strings.HasSuffix(lower, ".html"): + return "🌐" + case strings.Contains(lower, "release") || strings.Contains(lower, "packages"): + return "📄" + default: + return "📄" + } +} diff --git a/internal/repo/html_test.go b/internal/repo/html_test.go new file mode 100644 index 0000000..4d8c856 --- /dev/null +++ b/internal/repo/html_test.go @@ -0,0 +1,135 @@ +package repo + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenerateHTMLIndexes(t *testing.T) { + // Create temp directory + tmpDir, err := os.MkdirTemp("", "plow-html-test") + if err != nil { + t.Fatalf("create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + // Create repository structure + r := New(tmpDir, DefaultConfig()) + if err := r.Init(); err != nil { + t.Fatalf("init repo: %v", err) + } + + // Create some test files + testDeb := filepath.Join(tmpDir, "pool", "main", "t", "testpkg", "testpkg_1.0.0_amd64.deb") + if err := os.MkdirAll(filepath.Dir(testDeb), 0755); err != nil { + t.Fatalf("create pool dir: %v", err) + } + if err := os.WriteFile(testDeb, []byte("fake deb content"), 0644); err != nil { + t.Fatalf("write test deb: %v", err) + } + + // Generate HTML indexes + if err := r.GenerateHTMLIndexes(); err != nil { + t.Fatalf("generate HTML indexes: %v", err) + } + + // Check that index.html files were created + expectedIndexes := []string{ + filepath.Join(tmpDir, "index.html"), + filepath.Join(tmpDir, "pool", "index.html"), + filepath.Join(tmpDir, "pool", "main", "index.html"), + filepath.Join(tmpDir, "pool", "main", "t", "index.html"), + filepath.Join(tmpDir, "pool", "main", "t", "testpkg", "index.html"), + filepath.Join(tmpDir, "dists", "index.html"), + filepath.Join(tmpDir, "dists", "stable", "index.html"), + filepath.Join(tmpDir, "dists", "testing", "index.html"), + } + + for _, indexPath := range expectedIndexes { + if _, err := os.Stat(indexPath); os.IsNotExist(err) { + t.Errorf("expected index.html at %s", indexPath) + } + } + + // Check content of a generated index + rootIndex, err := os.ReadFile(filepath.Join(tmpDir, "index.html")) + if err != nil { + t.Fatalf("read root index: %v", err) + } + + content := string(rootIndex) + if !strings.Contains(content, "Index of /") { + t.Error("root index missing title") + } + if !strings.Contains(content, "pool/") { + t.Error("root index missing pool link") + } + if !strings.Contains(content, "dists/") { + t.Error("root index missing dists link") + } + + // Check package directory index + pkgIndex, err := os.ReadFile(filepath.Join(tmpDir, "pool", "main", "t", "testpkg", "index.html")) + if err != nil { + t.Fatalf("read package index: %v", err) + } + + pkgContent := string(pkgIndex) + if !strings.Contains(pkgContent, "testpkg_1.0.0_amd64.deb") { + t.Error("package index missing deb file") + } + if !strings.Contains(pkgContent, "../") { + t.Error("package index missing parent link") + } + if !strings.Contains(pkgContent, "📦") { + t.Error("package index missing deb icon") + } +} + +func TestFormatSize(t *testing.T) { + tests := []struct { + size int64 + expected string + }{ + {0, "0 B"}, + {512, "512 B"}, + {1024, "1.0 KB"}, + {1536, "1.5 KB"}, + {1048576, "1.0 MB"}, + {1572864, "1.5 MB"}, + {1073741824, "1.0 GB"}, + } + + for _, tt := range tests { + result := formatSize(tt.size) + if result != tt.expected { + t.Errorf("formatSize(%d) = %s, want %s", tt.size, result, tt.expected) + } + } +} + +func TestIconForFile(t *testing.T) { + tests := []struct { + name string + expected string + }{ + {"package.deb", "📦"}, + {"Packages.gz", "🗜️"}, + {"Packages.xz", "🗜️"}, + {"Release.gpg", "🔑"}, + {"public.key", "🔑"}, + {"index.html", "🌐"}, + {"Release", "📄"}, + {"Packages", "📄"}, + {"random.txt", "📄"}, + } + + for _, tt := range tests { + result := iconForFile(tt.name) + if result != tt.expected { + t.Errorf("iconForFile(%s) = %s, want %s", tt.name, result, tt.expected) + } + } +} From 0fedacdabc7e46527cd50118ce893902219f0057 Mon Sep 17 00:00:00 2001 From: Brian Ketelsen Date: Wed, 7 Jan 2026 11:48:29 -0500 Subject: [PATCH 2/2] fix: copilot suggestions Signed-off-by: Brian Ketelsen --- internal/cli/add.go | 1 + internal/repo/html.go | 27 +++++++++++++++++---------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/internal/cli/add.go b/internal/cli/add.go index c4e9d16..16e754b 100644 --- a/internal/cli/add.go +++ b/internal/cli/add.go @@ -61,6 +61,7 @@ and optionally prunes old versions.`, if err := r.GenerateHTMLIndexes(); err != nil { return fmt.Errorf("generate HTML indexes: %w", err) } + fmt.Println(" Generated HTML index pages") return nil }, diff --git a/internal/repo/html.go b/internal/repo/html.go index 50264d8..b166862 100644 --- a/internal/repo/html.go +++ b/internal/repo/html.go @@ -8,8 +8,21 @@ import ( "path/filepath" "sort" "strings" + "sync" ) +var ( + htmlTmpl *template.Template + htmlTmplOnce sync.Once +) + +func getHTMLTemplate() *template.Template { + htmlTmplOnce.Do(func() { + htmlTmpl = template.Must(template.New("index").Parse(htmlTemplate)) + }) + return htmlTmpl +} + const htmlTemplate = ` @@ -146,7 +159,7 @@ func (r *Repository) generateIndexForDirectory(dirPath string) error { return files[i].Name < files[j].Name }) - // Calculate relative path for display + // Calculate relative path for display (use forward slashes for URLs) relPath, err := filepath.Rel(r.Root, dirPath) if err != nil { relPath = dirPath @@ -154,7 +167,7 @@ func (r *Repository) generateIndexForDirectory(dirPath string) error { if relPath == "." { relPath = "/" } else { - relPath = "/" + relPath + "/" + relPath = "/" + filepath.ToSlash(relPath) + "/" } // Determine if we should show parent link @@ -167,20 +180,14 @@ func (r *Repository) generateIndexForDirectory(dirPath string) error { Files: files, } - // Parse and execute template - tmpl, err := template.New("index").Parse(htmlTemplate) - if err != nil { - return fmt.Errorf("parse template: %w", err) - } - indexPath := filepath.Join(dirPath, "index.html") f, err := os.Create(indexPath) if err != nil { return fmt.Errorf("create index.html: %w", err) } - defer f.Close() //nolint:errcheck + defer f.Close() //nolint:errcheck // Write errors caught by template.Execute - if err := tmpl.Execute(f, data); err != nil { + if err := getHTMLTemplate().Execute(f, data); err != nil { return fmt.Errorf("execute template: %w", err) }