Commit b446157

mo khan <mo@mokhan.ca>
2026-01-31 05:54:35
refactor: remove duplicate, upgrade go and document routes
1 parent 94476e4
internal/generator/index.go
@@ -1,130 +0,0 @@
-package generator
-
-import (
-	"os"
-	"path/filepath"
-	"sort"
-	"strings"
-
-	"mokhan.ca/antonmedv/gitmal/internal/git"
-	"mokhan.ca/antonmedv/gitmal/internal/links"
-	"mokhan.ca/antonmedv/gitmal/internal/templates"
-)
-
-func GenerateIndex(files []git.Blob, params Params) error {
-	// Build directory indexes
-	type dirInfo struct {
-		subdirs map[string]struct{}
-		files   []git.Blob
-	}
-	dirs := map[string]*dirInfo{}
-
-	ensureDir := func(p string) *dirInfo {
-		if di, ok := dirs[p]; ok {
-			return di
-		}
-		di := &dirInfo{subdirs: map[string]struct{}{}, files: []git.Blob{}}
-		dirs[p] = di
-		return di
-	}
-
-	dirsSet := links.BuildDirSet(files)
-	filesSet := links.BuildFileSet(files)
-
-	for _, b := range files {
-		// Normalize to forward slash paths for URL construction
-		p := b.Path
-		parts := strings.Split(p, "/")
-		// walk directories
-		cur := ""
-		for i := 0; i < len(parts)-1; i++ {
-			child := parts[i]
-			ensureDir(cur).subdirs[child] = struct{}{}
-			if cur == "" {
-				cur = child
-			} else {
-				cur = cur + "/" + child
-			}
-			ensureDir(cur) // ensure it exists
-		}
-		ensureDir(cur).files = append(ensureDir(cur).files, b)
-	}
-
-	di := dirs[""] // root
-
-	outDir := params.OutputDir
-	if err := os.MkdirAll(outDir, 0o755); err != nil {
-		return err
-	}
-
-	// Build entries
-	dirNames := make([]string, 0, len(di.subdirs))
-	for name := range di.subdirs {
-		dirNames = append(dirNames, name)
-	}
-	// Sort for stable output
-	sort.Strings(dirNames)
-	sort.Slice(di.files, func(i, j int) bool {
-		return di.files[i].FileName < di.files[j].FileName
-	})
-
-	subdirEntries := make([]templates.ListEntry, 0, len(dirNames))
-	for _, name := range dirNames {
-		subdirEntries = append(subdirEntries, templates.ListEntry{
-			Name:  name + "/",
-			Href:  "blob/" + params.Ref.DirName() + "/" + name + "/index.html",
-			IsDir: true,
-		})
-	}
-
-	fileEntries := make([]templates.ListEntry, 0, len(di.files))
-	for _, b := range di.files {
-		fileEntries = append(fileEntries, templates.ListEntry{
-			Name: b.FileName + "",
-			Href: "blob/" + params.Ref.DirName() + "/" + b.FileName + ".html",
-			Mode: b.Mode,
-			Size: humanizeSize(b.Size),
-		})
-	}
-
-	// Title and current path label
-	title := params.Name
-
-	f, err := os.Create(filepath.Join(outDir, "index.html"))
-	if err != nil {
-		return err
-	}
-
-	rootHref := "./"
-	readmeHTML := readme(di.files, dirsSet, filesSet, params, rootHref)
-	hasReadme := readmeHTML != ""
-
-	err = templates.ListTemplate.ExecuteTemplate(f, "layout.gohtml", templates.ListParams{
-		LayoutParams: templates.LayoutParams{
-			Title:            title,
-			Name:             params.Name,
-			RootHref:         rootHref,
-			CurrentRefDir:    params.Ref.DirName(),
-			Selected:         "code",
-			NeedsMarkdownCSS: hasReadme,
-			NeedsSyntaxCSS:   hasReadme,
-		},
-		HeaderParams: templates.HeaderParams{
-			Ref:         params.Ref,
-			Breadcrumbs: breadcrumbs(params.Name, "", false),
-		},
-		Ref:    params.Ref,
-		Dirs:   subdirEntries,
-		Files:  fileEntries,
-		Readme: readmeHTML,
-	})
-	if err != nil {
-		_ = f.Close()
-		return err
-	}
-	if err := f.Close(); err != nil {
-		return err
-	}
-
-	return nil
-}
internal/generator/list.go
@@ -13,12 +13,12 @@ import (
 	"mokhan.ca/antonmedv/gitmal/internal/templates"
 )
 
-func GenerateLists(files []git.Blob, params Params) error {
-	// Build directory indexes
-	type dirInfo struct {
-		subdirs map[string]struct{}
-		files   []git.Blob
-	}
+type dirInfo struct {
+	subdirs map[string]struct{}
+	files   []git.Blob
+}
+
+func buildDirTree(files []git.Blob) map[string]*dirInfo {
 	dirs := map[string]*dirInfo{}
 
 	ensureDir := func(p string) *dirInfo {
@@ -30,14 +30,9 @@ func GenerateLists(files []git.Blob, params Params) error {
 		return di
 	}
 
-	dirsSet := links.BuildDirSet(files)
-	filesSet := links.BuildFileSet(files)
-
 	for _, b := range files {
-		// Normalize to forward slash paths for URL construction
 		p := b.Path
 		parts := strings.Split(p, "/")
-		// walk directories
 		cur := ""
 		for i := 0; i < len(parts)-1; i++ {
 			child := parts[i]
@@ -47,11 +42,96 @@ func GenerateLists(files []git.Blob, params Params) error {
 			} else {
 				cur = cur + "/" + child
 			}
-			ensureDir(cur) // ensure it exists
+			ensureDir(cur)
 		}
 		ensureDir(cur).files = append(ensureDir(cur).files, b)
 	}
 
+	return dirs
+}
+
+func GenerateIndex(files []git.Blob, params Params) error {
+	dirs := buildDirTree(files)
+	dirsSet := links.BuildDirSet(files)
+	filesSet := links.BuildFileSet(files)
+
+	di := dirs[""]
+
+	outDir := params.OutputDir
+	if err := os.MkdirAll(outDir, 0o755); err != nil {
+		return err
+	}
+
+	dirNames := make([]string, 0, len(di.subdirs))
+	for name := range di.subdirs {
+		dirNames = append(dirNames, name)
+	}
+	sort.Strings(dirNames)
+	sort.Slice(di.files, func(i, j int) bool {
+		return di.files[i].FileName < di.files[j].FileName
+	})
+
+	subdirEntries := make([]templates.ListEntry, 0, len(dirNames))
+	for _, name := range dirNames {
+		subdirEntries = append(subdirEntries, templates.ListEntry{
+			Name:  name + "/",
+			Href:  "blob/" + params.Ref.DirName() + "/" + name + "/index.html",
+			IsDir: true,
+		})
+	}
+
+	fileEntries := make([]templates.ListEntry, 0, len(di.files))
+	for _, b := range di.files {
+		fileEntries = append(fileEntries, templates.ListEntry{
+			Name: b.FileName,
+			Href: "blob/" + params.Ref.DirName() + "/" + b.FileName + ".html",
+			Mode: b.Mode,
+			Size: humanizeSize(b.Size),
+		})
+	}
+
+	title := params.Name
+
+	f, err := os.Create(filepath.Join(outDir, "index.html"))
+	if err != nil {
+		return err
+	}
+
+	rootHref := "./"
+	readmeHTML := readme(di.files, dirsSet, filesSet, params, rootHref)
+	hasReadme := readmeHTML != ""
+
+	err = templates.ListTemplate.ExecuteTemplate(f, "layout.gohtml", templates.ListParams{
+		LayoutParams: templates.LayoutParams{
+			Title:            title,
+			Name:             params.Name,
+			RootHref:         rootHref,
+			CurrentRefDir:    params.Ref.DirName(),
+			Selected:         "code",
+			NeedsMarkdownCSS: hasReadme,
+			NeedsSyntaxCSS:   hasReadme,
+		},
+		HeaderParams: templates.HeaderParams{
+			Ref:         params.Ref,
+			Breadcrumbs: breadcrumbs(params.Name, "", false),
+		},
+		Ref:    params.Ref,
+		Dirs:   subdirEntries,
+		Files:  fileEntries,
+		Readme: readmeHTML,
+	})
+	if err != nil {
+		_ = f.Close()
+		return err
+	}
+	return f.Close()
+}
+
+func GenerateLists(files []git.Blob, params Params) error {
+	dirs := buildDirTree(files)
+	dirsSet := links.BuildDirSet(files)
+	filesSet := links.BuildFileSet(files)
+
 	type job struct {
 		dirPath string
 		di      *dirInfo
@@ -96,7 +176,7 @@ func GenerateLists(files []git.Blob, params Params) error {
 		fileEntries := make([]templates.ListEntry, 0, len(di.files))
 		for _, b := range di.files {
 			fileEntries = append(fileEntries, templates.ListEntry{
-				Name: b.FileName + "",
+				Name: b.FileName,
 				Href: b.FileName + ".html",
 				Mode: b.Mode,
 				Size: humanizeSize(b.Size),
internal/generator/tags_atom.go
@@ -8,23 +8,27 @@ import (
 	"mokhan.ca/antonmedv/gitmal/internal/git"
 )
 
-func GenerateTagsAtom(tags []git.Tag, params Params) error {
+type tagAtomConfig struct {
+	idPrefix    string
+	titleSuffix string
+	filename    string
+	contentFn   func(git.Tag) string
+}
+
+func generateTagAtomFeed(tags []git.Tag, params Params, cfg tagAtomConfig) error {
 	if len(tags) == 0 {
 		return nil
 	}
 
-	var updated string
-	if len(tags) > 0 {
-		updated = tags[0].Date.Format("2006-01-02T15:04:05Z")
-	}
+	updated := tags[0].Date.Format("2006-01-02T15:04:05Z")
 
 	entries := make([]AtomEntry, len(tags))
 	for i, t := range tags {
 		entries[i] = AtomEntry{
-			ID:      "urn:tag:" + t.Name,
+			ID:      cfg.idPrefix + t.Name,
 			Title:   t.Name,
 			Updated: t.Date.Format("2006-01-02T15:04:05Z"),
-			Content: "Tag " + t.Name + " pointing to " + t.CommitHash[:7],
+			Content: cfg.contentFn(t),
 			Link: AtomLink{
 				Rel:  "alternate",
 				Type: "text/html",
@@ -35,16 +39,16 @@ func GenerateTagsAtom(tags []git.Tag, params Params) error {
 
 	feed := AtomFeed{
 		XMLNS:   "http://www.w3.org/2005/Atom",
-		ID:      "urn:gitmal:" + params.Name + ":tags",
-		Title:   params.Name + " tags",
+		ID:      "urn:gitmal:" + params.Name + ":" + cfg.filename[:len(cfg.filename)-5],
+		Title:   params.Name + cfg.titleSuffix,
 		Updated: updated,
 		Link: []AtomLink{
-			{Rel: "self", Type: "application/atom+xml", Href: "tags.atom"},
+			{Rel: "self", Type: "application/atom+xml", Href: cfg.filename},
 		},
 		Entries: entries,
 	}
 
-	outPath := filepath.Join(params.OutputDir, "tags.atom")
+	outPath := filepath.Join(params.OutputDir, cfg.filename)
 	f, err := os.Create(outPath)
 	if err != nil {
 		return err
@@ -59,53 +63,24 @@ func GenerateTagsAtom(tags []git.Tag, params Params) error {
 	return encoder.Encode(feed)
 }
 
-func GenerateReleasesAtom(tags []git.Tag, params Params) error {
-	if len(tags) == 0 {
-		return nil
-	}
-
-	var updated string
-	if len(tags) > 0 {
-		updated = tags[0].Date.Format("2006-01-02T15:04:05Z")
-	}
-
-	entries := make([]AtomEntry, len(tags))
-	for i, t := range tags {
-		entries[i] = AtomEntry{
-			ID:      "urn:release:" + t.Name,
-			Title:   t.Name,
-			Updated: t.Date.Format("2006-01-02T15:04:05Z"),
-			Content: "Release " + t.Name,
-			Link: AtomLink{
-				Rel:  "alternate",
-				Type: "text/html",
-				Href: "commit/" + t.CommitHash + ".html",
-			},
-		}
-	}
-
-	feed := AtomFeed{
-		XMLNS:   "http://www.w3.org/2005/Atom",
-		ID:      "urn:gitmal:" + params.Name + ":releases",
-		Title:   params.Name + " releases",
-		Updated: updated,
-		Link: []AtomLink{
-			{Rel: "self", Type: "application/atom+xml", Href: "releases.atom"},
+func GenerateTagsAtom(tags []git.Tag, params Params) error {
+	return generateTagAtomFeed(tags, params, tagAtomConfig{
+		idPrefix:    "urn:tag:",
+		titleSuffix: " tags",
+		filename:    "tags.atom",
+		contentFn: func(t git.Tag) string {
+			return "Tag " + t.Name + " pointing to " + t.CommitHash[:7]
 		},
-		Entries: entries,
-	}
-
-	outPath := filepath.Join(params.OutputDir, "releases.atom")
-	f, err := os.Create(outPath)
-	if err != nil {
-		return err
-	}
-	defer f.Close()
+	})
+}
 
-	if _, err := f.WriteString(xml.Header); err != nil {
-		return err
-	}
-	encoder := xml.NewEncoder(f)
-	encoder.Indent("", "  ")
-	return encoder.Encode(feed)
+func GenerateReleasesAtom(tags []git.Tag, params Params) error {
+	return generateTagAtomFeed(tags, params, tagAtomConfig{
+		idPrefix:    "urn:release:",
+		titleSuffix: " releases",
+		filename:    "releases.atom",
+		contentFn: func(t git.Tag) string {
+			return "Release " + t.Name
+		},
+	})
 }
internal/templates/css/layout.css
@@ -174,26 +174,6 @@ footer {
   text-align: center;
 }
 
-.header-container {
-  container-type: scroll-state;
-  position: sticky;
-  top: 0;
-}
-
-.header-container {
-  @container scroll-state(stuck: top) {
-    header {
-      border-top: none;
-      border-top-left-radius: 0;
-      border-top-right-radius: 0;
-    }
-
-    .goto-top {
-      display: flex;
-    }
-  }
-}
-
 header {
   display: flex;
   flex-direction: row;
@@ -238,20 +218,3 @@ header .path {
 .breadcrumbs a {
   word-break: break-all;
 }
-
-.goto-top {
-  display: none;
-  margin-left: auto;
-  padding: 6px 10px;
-  background: none;
-  border: none;
-  border-radius: 6px;
-  gap: 4px;
-  align-items: center;
-  color: var(--c-text-1);
-  cursor: pointer;
-}
-
-.goto-top:hover {
-  background: var(--c-bg-elv);
-}
internal/templates/commit.gohtml
@@ -281,21 +281,13 @@
         <div class="files">
             {{ range .FileViews }}
                 <section id="{{ .Path }}" class="file-section">
-                    <div class="header-container">
-                        <header class="file-header">
-                            {{ if .IsRename }}
-                                <div class="path">{{ .OldName }} → {{ .NewName }}</div>
-                            {{ else }}
-                                <div class="path">{{ .Path }}</div>
-                            {{ end }}
-                            <button class="goto-top" onclick="window.scrollTo({top: 0, behavior: 'smooth'})">
-                                <svg aria-hidden="true" focusable="false" width="16" height="16">
-                                    <use xlink:href="#arrow-top"></use>
-                                </svg>
-                                Top
-                            </button>
-                        </header>
-                    </div>
+                    <header class="file-header">
+                        {{ if .IsRename }}
+                            <div class="path">{{ .OldName }} → {{ .NewName }}</div>
+                        {{ else }}
+                            <div class="path">{{ .Path }}</div>
+                        {{ end }}
+                    </header>
                     {{ if .IsBinary }}
                         <div class="binary-file border">Binary file</div>
                     {{ else if (and .IsRename (not .HasChanges)) }}
internal/templates/header.gohtml
@@ -1,35 +1,27 @@
 {{- /*gotype: mokhan.ca/antonmedv/gitmal/pkg/templates.HeaderParams*/ -}}
 {{ define "header" }}
-    <div class="header-container">
-        <header>
-            {{ if .Ref }}
-                <div class="header-ref" aria-label="Ref">{{ .Ref }}</div>
-            {{ end }}
-            {{ if .Header }}
-                <h1 class="title">{{ .Header }}</h1>
-            {{ else if .Breadcrumbs }}
-                <nav class="breadcrumbs" aria-label="Breadcrumbs">
-                    {{ range $i, $b := .Breadcrumbs }}
-                        {{ if gt $i 0 }}
+    <header>
+        {{ if .Ref }}
+            <div class="header-ref" aria-label="Ref">{{ .Ref }}</div>
+        {{ end }}
+        {{ if .Header }}
+            <h1 class="title">{{ .Header }}</h1>
+        {{ else if .Breadcrumbs }}
+            <nav class="breadcrumbs" aria-label="Breadcrumbs">
+                {{ range $i, $b := .Breadcrumbs }}
+                    {{ if gt $i 0 }}
+                        <div>/</div>
+                    {{ end }}
+                    {{ if $b.Href }}
+                        <a href="{{$b.Href}}">{{$b.Name}}</a>
+                    {{ else }}
+                        <h1>{{$b.Name}}</h1>
+                        {{ if $b.IsDir}}
                             <div>/</div>
-                        {{ end }}
-                        {{ if $b.Href }}
-                            <a href="{{$b.Href}}">{{$b.Name}}</a>
-                        {{ else }}
-                            <h1>{{$b.Name}}</h1>
-                            {{ if $b.IsDir}}
-                                <div>/</div>
-                            {{ end}}
-                        {{ end }}
+                        {{ end}}
                     {{ end }}
-                </nav>
-            {{ end }}
-            <button class="goto-top" onclick="window.scrollTo({top: 0, behavior: 'smooth'})">
-                <svg aria-hidden="true" focusable="false" width="16" height="16">
-                    <use xlink:href="#arrow-top"></use>
-                </svg>
-                Top
-            </button>
-        </header>
-    </div>
+                {{ end }}
+            </nav>
+        {{ end }}
+    </header>
 {{ end }}
go.mod
@@ -1,6 +1,6 @@
 module mokhan.ca/antonmedv/gitmal
 
-go 1.24.0
+go 1.25.1
 
 require (
 	github.com/alecthomas/chroma/v2 v2.20.0
mise.toml
@@ -0,0 +1,2 @@
+[tools]
+go = "1.25.1"
README.md
@@ -28,6 +28,31 @@ Generates a static site with:
 - **Branches & Tags** - Navigation between refs
 - **Atom feeds** - For commits, tags, and releases
 
+## URL Structure
+
+Generated paths follow GitHub repository conventions:
+
+| Path | Description |
+|------|-------------|
+| `/index.html` | Repository root (file listing) |
+| `/blob/<ref>/<path>/index.html` | Directory listing |
+| `/blob/<ref>/<file>.html` | File view (syntax highlighted) |
+| `/commit/<hash>.html` | Commit diff view |
+| `/commits/<ref>/index.html` | Commit history (page 1) |
+| `/commits/<ref>/page-N.html` | Commit history (page N) |
+| `/branches.html` | Branches list |
+| `/tags.html` | Tags list |
+
+### Feeds & API
+
+| Path | Description |
+|------|-------------|
+| `/commits/<ref>.atom` | Commits Atom feed |
+| `/commits/<ref>.json` | Commits JSON |
+| `/tags.atom` | Tags Atom feed |
+| `/releases.atom` | Releases Atom feed |
+| `/branches.json` | Branches JSON |
+
 ## Building
 
 ```bash