diff --git a/internal/cli/add.go b/internal/cli/add.go
index 141e160..16e754b 100644
--- a/internal/cli/add.go
+++ b/internal/cli/add.go
@@ -57,6 +57,12 @@ 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)
+ }
+ fmt.Println(" Generated HTML index pages")
+
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..b166862
--- /dev/null
+++ b/internal/repo/html.go
@@ -0,0 +1,233 @@
+// Package repo manages Debian repository structure and metadata.
+package repo
+
+import (
+ "fmt"
+ "html/template"
+ "os"
+ "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 = `
+
+
+
+
+ Index of {{.Path}}
+
+
+
+ Index of {{.Path}}
+
+
+
+ | Name |
+ Size |
+
+
+
+ {{if .ShowParent}}
+
+ | 📁../ |
+ - |
+
+ {{end}}
+ {{range .Directories}}
+
+ | 📁{{.Name}}/ |
+ - |
+
+ {{end}}
+ {{range .Files}}
+
+ | {{.Icon}}{{.Name}} |
+ {{.Size}} |
+
+ {{end}}
+
+
+
+
+`
+
+// 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 (use forward slashes for URLs)
+ relPath, err := filepath.Rel(r.Root, dirPath)
+ if err != nil {
+ relPath = dirPath
+ }
+ if relPath == "." {
+ relPath = "/"
+ } else {
+ relPath = "/" + filepath.ToSlash(relPath) + "/"
+ }
+
+ // Determine if we should show parent link
+ showParent := dirPath != r.Root
+
+ data := IndexData{
+ Path: relPath,
+ ShowParent: showParent,
+ Directories: directories,
+ Files: files,
+ }
+
+ 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 // Write errors caught by template.Execute
+
+ if err := getHTMLTemplate().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)
+ }
+ }
+}