major reorg to split up main.go and use cmd/ build structure
15 files changed,  +1680, -1722
M .gitignore
+0, -1
1@@ -1,4 +1,3 @@
2-pgit
3 *.swp
4 *.log
5 public/
M Makefile
+2, -2
 1@@ -10,7 +10,7 @@ clean:
 2 .PHONY: clean
 3 
 4 build:
 5-	go build -o pgit .
 6+	go build -o pgit ./cmd/pgit
 7 .PHONY: build
 8 
 9 img:
10@@ -32,7 +32,7 @@ test:
11 test-site:
12 	mkdir -p testdata.site
13 	rm -rf testdata.site/*
14-	go run . \
15+	go run ./cmd/pgit \
16 		--repo ./testdata \
17 		--out testdata.site \
18 		--clone-url "https://test.com/test/test2" \
A cmd/pgit/main.go
+171, -0
  1@@ -0,0 +1,171 @@
  2+package main
  3+
  4+import (
  5+	"fmt"
  6+	"log/slog"
  7+	"os"
  8+	"path/filepath"
  9+	"strings"
 10+
 11+	formatterHtml "github.com/alecthomas/chroma/v2/formatters/html"
 12+	"github.com/alecthomas/chroma/v2/styles"
 13+	"github.com/picosh/pgit"
 14+	"github.com/spf13/cobra"
 15+	"html/template"
 16+)
 17+
 18+func main() {
 19+	var rootCmd = &cobra.Command{
 20+		Use:   "pgit",
 21+		Short: "Static site generator for git repositories",
 22+		Long: `pgit generates a static website from a git repository,
 23+including commit history, file browser, and syntax-highlighted code views.`,
 24+		RunE: runGenerate,
 25+	}
 26+
 27+	rootCmd.Flags().String("repo", ".", "path to git repo")
 28+	rootCmd.Flags().String("out", "./public", "output directory")
 29+	rootCmd.Flags().String("revs", "HEAD", "list of revs to generate logs and tree (e.g. main,v1,c69f86f,HEAD)")
 30+	rootCmd.Flags().String("theme", "dracula", "theme to use for site")
 31+	rootCmd.Flags().String("label", "", "pretty name for the subdir where we create the repo, default is last folder in --repo")
 32+	rootCmd.Flags().String("clone-url", "", "git clone URL for upstream")
 33+	rootCmd.Flags().String("home-url", "", "URL for breadcrumbs to go to root page, hidden if empty")
 34+	rootCmd.Flags().String("desc", "", "description for repo")
 35+	rootCmd.Flags().String("root-relative", "/", "html root relative")
 36+	rootCmd.Flags().Int("max-commits", 0, "maximum number of commits to generate")
 37+	rootCmd.Flags().Bool("hide-tree-last-commit", false, "don't calculate last commit for each file in the tree")
 38+	rootCmd.Flags().Bool("issues", false, "enable git-bug issue generation")
 39+
 40+	var hookInstallCmd = &cobra.Command{
 41+		Use:   "hook-install",
 42+		Short: "Install the post-commit hook",
 43+		Long:  "Installs the pgit post-commit hook to automatically close git-bug issues",
 44+		RunE: func(cmd *cobra.Command, args []string) error {
 45+			repoPath, _ := cmd.Flags().GetString("repo")
 46+			if err := pgit.InstallHook(repoPath); err != nil {
 47+				fmt.Fprintf(os.Stderr, "Error: %v\n", err)
 48+				os.Exit(1)
 49+			}
 50+			return nil
 51+		},
 52+	}
 53+	hookInstallCmd.Flags().String("repo", ".", "path to git repo")
 54+
 55+	var hookUninstallCmd = &cobra.Command{
 56+		Use:   "hook-uninstall",
 57+		Short: "Uninstall the post-commit hook",
 58+		Long:  "Removes the pgit post-commit hook and restores any backup",
 59+		RunE: func(cmd *cobra.Command, args []string) error {
 60+			repoPath, _ := cmd.Flags().GetString("repo")
 61+			if err := pgit.UninstallHook(repoPath); err != nil {
 62+				fmt.Fprintf(os.Stderr, "Error: %v\n", err)
 63+				os.Exit(1)
 64+			}
 65+			return nil
 66+		},
 67+	}
 68+	hookUninstallCmd.Flags().String("repo", ".", "path to git repo")
 69+
 70+	rootCmd.AddCommand(hookInstallCmd)
 71+	rootCmd.AddCommand(hookUninstallCmd)
 72+
 73+	if err := rootCmd.Execute(); err != nil {
 74+		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
 75+		os.Exit(1)
 76+	}
 77+}
 78+
 79+func runGenerate(cmd *cobra.Command, args []string) error {
 80+	outdir, _ := cmd.Flags().GetString("out")
 81+	rpath, _ := cmd.Flags().GetString("repo")
 82+	revsFlag, _ := cmd.Flags().GetString("revs")
 83+	themeFlag, _ := cmd.Flags().GetString("theme")
 84+	labelFlag, _ := cmd.Flags().GetString("label")
 85+	cloneFlag, _ := cmd.Flags().GetString("clone-url")
 86+	homeFlag, _ := cmd.Flags().GetString("home-url")
 87+	descFlag, _ := cmd.Flags().GetString("desc")
 88+	rootRelativeFlag, _ := cmd.Flags().GetString("root-relative")
 89+	maxCommitsFlag, _ := cmd.Flags().GetInt("max-commits")
 90+	hideTreeLastCommitFlag, _ := cmd.Flags().GetBool("hide-tree-last-commit")
 91+	issuesFlag, _ := cmd.Flags().GetBool("issues")
 92+
 93+	out, err := filepath.Abs(outdir)
 94+	if err != nil {
 95+		return err
 96+	}
 97+	repoPath, err := filepath.Abs(rpath)
 98+	if err != nil {
 99+		return err
100+	}
101+
102+	theme := styles.Get(themeFlag)
103+	logger := slog.Default()
104+
105+	label := pgit.RepoName(repoPath)
106+	if labelFlag != "" {
107+		label = labelFlag
108+	}
109+
110+	revs := strings.Split(revsFlag, ",")
111+	if len(revs) == 1 && revs[0] == "" {
112+		revs = []string{}
113+	}
114+
115+	formatter := formatterHtml.New(
116+		formatterHtml.WithLineNumbers(true),
117+		formatterHtml.WithLinkableLineNumbers(true, ""),
118+		formatterHtml.WithClasses(true),
119+	)
120+
121+	tempConfig := &pgit.Config{
122+		Theme:    theme,
123+		Logger:   logger,
124+		RepoPath: repoPath,
125+	}
126+
127+	var descHTML template.HTML
128+	if descFlag != "" {
129+		descHTML = tempConfig.RenderMarkdown(descFlag)
130+	} else {
131+		gitDesc := pgit.ReadDescription(repoPath)
132+		if gitDesc != "" {
133+			descHTML = tempConfig.RenderMarkdown(gitDesc)
134+		}
135+	}
136+
137+	config := &pgit.Config{
138+		Outdir:             out,
139+		RepoPath:           repoPath,
140+		RepoName:           label,
141+		Cache:              make(map[string]bool),
142+		Revs:               revs,
143+		Theme:              theme,
144+		Logger:             logger,
145+		CloneURL:           template.URL(cloneFlag),
146+		HomeURL:            template.URL(homeFlag),
147+		Desc:               descHTML,
148+		MaxCommits:         maxCommitsFlag,
149+		HideTreeLastCommit: hideTreeLastCommitFlag,
150+		Issues:             issuesFlag,
151+		RootRelative:       rootRelativeFlag,
152+		Formatter:          formatter,
153+	}
154+	config.Logger.Info("config", "config", config)
155+
156+	if len(revs) == 0 {
157+		return fmt.Errorf("you must provide --revs")
158+	}
159+
160+	cssFile, err := config.BundleCSS()
161+	if err != nil {
162+		return err
163+	}
164+	config.CSSFile = cssFile
165+
166+	config.WriteRepo()
167+
168+	url := filepath.Join("/", "index.html")
169+	config.Logger.Info("root url", "url", url)
170+
171+	return nil
172+}
A config.go
+367, -0
  1@@ -0,0 +1,367 @@
  2+package pgit
  3+
  4+import (
  5+	"embed"
  6+	"html/template"
  7+	"log"
  8+	"log/slog"
  9+	"path/filepath"
 10+	"regexp"
 11+	"strings"
 12+	"sync"
 13+	"time"
 14+	"unicode/utf8"
 15+
 16+	"github.com/alecthomas/chroma/v2"
 17+	formatterHtml "github.com/alecthomas/chroma/v2/formatters/html"
 18+	"github.com/dustin/go-humanize"
 19+	git "github.com/gogs/git-module"
 20+)
 21+
 22+//go:embed html/*.tmpl
 23+//go:embed static/*
 24+//go:embed hooks/*.bash
 25+var EmbedFS embed.FS
 26+
 27+//go:embed static/*
 28+var StaticFS embed.FS
 29+
 30+type Config struct {
 31+	Outdir   string
 32+	RepoPath string
 33+	Revs     []string
 34+	Desc     template.HTML
 35+
 36+	MaxCommits         int
 37+	Readme             string
 38+	HideTreeLastCommit bool
 39+	Issues             bool
 40+
 41+	HomeURL  template.URL
 42+	CloneURL template.URL
 43+
 44+	RootRelative string
 45+
 46+	Cache    map[string]bool
 47+	Mutex    sync.RWMutex
 48+	RepoName string
 49+	Logger   *slog.Logger
 50+
 51+	Theme     *chroma.Style
 52+	Formatter *formatterHtml.Formatter
 53+	CSSFile   string
 54+}
 55+
 56+type RevInfo interface {
 57+	ID() string
 58+	Name() string
 59+}
 60+
 61+type RevData struct {
 62+	id     string
 63+	name   string
 64+	Config *Config
 65+}
 66+
 67+func (r *RevData) ID() string {
 68+	return r.id
 69+}
 70+
 71+func (r *RevData) Name() string {
 72+	return r.name
 73+}
 74+
 75+func (r *RevData) TreeURL() template.URL {
 76+	return r.Config.GetTreeURL(r)
 77+}
 78+
 79+func (r *RevData) LogURL() template.URL {
 80+	return r.Config.GetLogsURL(r)
 81+}
 82+
 83+type TagData struct {
 84+	Name string
 85+	URL  template.URL
 86+}
 87+
 88+type Trailer struct {
 89+	Key   string
 90+	Value string
 91+}
 92+
 93+type CommitData struct {
 94+	SummaryStr      string
 95+	URL             template.URL
 96+	WhenStr         string
 97+	WhenISO         string
 98+	WhenDisplay     string
 99+	AuthorStr       string
100+	ShortID         string
101+	ParentID        string
102+	Refs            []*RefInfo
103+	Trailers        []Trailer
104+	MessageBodyOnly string
105+	*git.Commit
106+}
107+
108+type TreeItem struct {
109+	IsTextFile  bool
110+	IsDir       bool
111+	Size        string
112+	NumLines    int
113+	Name        string
114+	Icon        string
115+	Path        string
116+	URL         template.URL
117+	CommitID    string
118+	CommitURL   template.URL
119+	Summary     string
120+	When        string
121+	WhenISO     string
122+	WhenDisplay string
123+	Author      *git.Signature
124+	Entry       *git.TreeEntry
125+	Crumbs      []*Breadcrumb
126+}
127+
128+type DiffRender struct {
129+	NumFiles       int
130+	TotalAdditions int
131+	TotalDeletions int
132+	Files          []*DiffRenderFile
133+}
134+
135+type DiffRenderFile struct {
136+	FileType     string
137+	OldMode      git.EntryMode
138+	OldName      string
139+	Mode         git.EntryMode
140+	Name         string
141+	Content      template.HTML
142+	NumAdditions int
143+	NumDeletions int
144+}
145+
146+type RefInfo struct {
147+	ID      string
148+	Refspec string
149+	URL     template.URL
150+}
151+
152+type BranchOutput struct {
153+	Readme     string
154+	LastCommit *CommitData
155+}
156+
157+type SiteURLs struct {
158+	HomeURL    template.URL
159+	CloneURL   template.URL
160+	SummaryURL template.URL
161+	RefsURL    template.URL
162+	IssuesURL  template.URL
163+}
164+
165+type PageData struct {
166+	Repo     *Config
167+	SiteURLs *SiteURLs
168+	RevData  *RevInfo
169+	Refs     []*RefInfo
170+}
171+
172+type SummaryPageData struct {
173+	*PageData
174+	Readme     template.HTML
175+	LastCommit *CommitData
176+}
177+
178+type TreePageData struct {
179+	*PageData
180+	Tree *TreeRoot
181+}
182+
183+type LogPageData struct {
184+	*PageData
185+	NumCommits int
186+	Logs       []*CommitData
187+}
188+
189+type FilePageData struct {
190+	*PageData
191+	Contents template.HTML
192+	Item     *TreeItem
193+}
194+
195+type CommitPageData struct {
196+	*PageData
197+	CommitMsg template.HTML
198+	CommitID  string
199+	Commit    *CommitData
200+	Diff      *DiffRender
201+	Parent    string
202+	ParentURL template.URL
203+	CommitURL template.URL
204+}
205+
206+type RefPageData struct {
207+	*PageData
208+	Refs []*RefInfo
209+}
210+
211+type WriteData struct {
212+	Template string
213+	Filename string
214+	Subdir   string
215+	Data     any
216+}
217+
218+func (s *SummaryPageData) Active() string { return "summary" }
219+func (s *TreePageData) Active() string    { return "code" }
220+func (s *FilePageData) Active() string    { return "code" }
221+func (s *LogPageData) Active() string     { return "commits" }
222+func (s *CommitPageData) Active() string  { return "commits" }
223+func (s *RefPageData) Active() string     { return "refs" }
224+
225+func Bail(err error) {
226+	if err != nil {
227+		log.Fatalf("Error: %v", err)
228+	}
229+}
230+
231+func DiffFileType(_type git.DiffFileType) string {
232+	switch _type {
233+	case git.DiffFileAdd:
234+		return "A"
235+	case git.DiffFileChange:
236+		return "M"
237+	case git.DiffFileDelete:
238+		return "D"
239+	case git.DiffFileRename:
240+		return "R"
241+	default:
242+		return ""
243+	}
244+}
245+
246+func ToPretty(b int64) string {
247+	return humanize.Bytes(uint64(b))
248+}
249+
250+func FormatDateForDisplay(t time.Time) string {
251+	if time.Since(t).Hours() > 365*24 {
252+		return t.Format("Jan 2006")
253+	}
254+	return t.Format("Jan 2")
255+}
256+
257+func RepoName(root string) string {
258+	_, file := filepath.Split(root)
259+	return file
260+}
261+
262+func ReadmeFile(repo *Config) string {
263+	if repo.Readme == "" {
264+		return "readme.md"
265+	}
266+	return strings.ToLower(repo.Readme)
267+}
268+
269+func ReadDescription(repoPath string) string {
270+	descPath := filepath.Join(repoPath, ".git", "description")
271+	data, err := EmbedFS.ReadFile(descPath)
272+	if err != nil {
273+		descPath = filepath.Join(repoPath, "description")
274+		data, err = EmbedFS.ReadFile(descPath)
275+		if err != nil {
276+			return ""
277+		}
278+	}
279+
280+	desc := strings.TrimSpace(string(data))
281+
282+	if desc == "Unnamed repository; edit this file 'description' to name the repository." {
283+		return ""
284+	}
285+
286+	return desc
287+}
288+
289+func GetShortID(id string) string {
290+	return id[:7]
291+}
292+
293+func ParseCommitMessage(message string) ([]Trailer, string) {
294+	var trailers []Trailer
295+
296+	trailerRe := regexp.MustCompile(`^([A-Za-z0-9-]+): (.+)$`)
297+
298+	message = strings.TrimRight(message, "\n")
299+	lines := strings.Split(message, "\n")
300+
301+	trailerStartIdx := -1
302+
303+	for i := len(lines) - 1; i >= 0; i-- {
304+		line := strings.TrimSpace(lines[i])
305+
306+		if line == "" {
307+			trailerStartIdx = i + 1
308+			break
309+		}
310+
311+		matches := trailerRe.FindStringSubmatch(line)
312+		if matches != nil {
313+			trailers = append([]Trailer{
314+				{Key: matches[1], Value: matches[2]},
315+			}, trailers...)
316+		} else {
317+			trailerStartIdx = i + 1
318+			break
319+		}
320+	}
321+
322+	bodyLines := lines
323+	if len(trailers) > 0 && trailerStartIdx > 0 {
324+		bodyLines = lines[:trailerStartIdx]
325+	}
326+
327+	bodyEndIdx := len(bodyLines)
328+	for i := len(bodyLines) - 1; i >= 0; i-- {
329+		if strings.TrimSpace(bodyLines[i]) != "" {
330+			bodyEndIdx = i + 1
331+			break
332+		}
333+	}
334+	bodyLines = bodyLines[:bodyEndIdx]
335+
336+	messageBodyOnly := ""
337+	if len(bodyLines) > 1 {
338+		bodyOnlyLines := bodyLines[1:]
339+		startIdx := 0
340+		for i, line := range bodyOnlyLines {
341+			if strings.TrimSpace(line) != "" {
342+				startIdx = i
343+				break
344+			}
345+		}
346+		if startIdx < len(bodyOnlyLines) {
347+			messageBodyOnly = strings.Join(bodyOnlyLines[startIdx:], "\n")
348+		}
349+	}
350+
351+	return trailers, messageBodyOnly
352+}
353+
354+func IsText(s string) bool {
355+	const max = 1024
356+	if len(s) > max {
357+		s = s[0:max]
358+	}
359+	for i, c := range s {
360+		if i+utf8.UTFMax > len(s) {
361+			break
362+		}
363+		if c == 0xFFFD || c < ' ' && c != '\n' && c != '\t' && c != '\f' && c != '\r' {
364+			return false
365+		}
366+	}
367+	return true
368+}
A css.go
+84, -0
 1@@ -0,0 +1,84 @@
 2+package pgit
 3+
 4+import (
 5+	"bytes"
 6+	"crypto/sha256"
 7+	"encoding/hex"
 8+	"fmt"
 9+	"os"
10+	"path/filepath"
11+
12+	"github.com/alecthomas/chroma/v2"
13+	"github.com/tdewolff/minify/v2"
14+	"github.com/tdewolff/minify/v2/css"
15+)
16+
17+func Style(theme chroma.Style) string {
18+	bg := theme.Get(chroma.Background)
19+	txt := theme.Get(chroma.Text)
20+	kw := theme.Get(chroma.Keyword)
21+	nv := theme.Get(chroma.NameVariable)
22+	cm := theme.Get(chroma.Comment)
23+	return fmt.Sprintf(`:root {
24+  --bg-color: %s;
25+  --text-color: %s;
26+  --border: %s;
27+  --link-color: %s;
28+  --hover: %s;
29+  --visited: %s;
30+}`,
31+		bg.Background.String(),
32+		txt.Colour.String(),
33+		cm.Colour.String(),
34+		nv.Colour.String(),
35+		kw.Colour.String(),
36+		nv.Colour.String(),
37+	)
38+}
39+
40+func (c *Config) BundleCSS() (string, error) {
41+	c.Logger.Info("bundling CSS files")
42+
43+	m := minify.New()
44+	m.AddFunc("text/css", css.Minify)
45+
46+	var buf bytes.Buffer
47+
48+	pgitCSS, err := StaticFS.ReadFile("static/pgit.css")
49+	if err != nil {
50+		return "", fmt.Errorf("failed to read pgit.css: %w", err)
51+	}
52+	buf.Write(pgitCSS)
53+	buf.WriteString("\n")
54+
55+	varsCSS := Style(*c.Theme)
56+	buf.WriteString(varsCSS)
57+	buf.WriteString("\n")
58+
59+	var syntaxBuf bytes.Buffer
60+	err = c.Formatter.WriteCSS(&syntaxBuf, c.Theme)
61+	if err != nil {
62+		return "", fmt.Errorf("failed to generate syntax.css: %w", err)
63+	}
64+	buf.Write(syntaxBuf.Bytes())
65+
66+	minified, err := m.Bytes("text/css", buf.Bytes())
67+	if err != nil {
68+		return "", fmt.Errorf("failed to minify CSS: %w", err)
69+	}
70+
71+	hash := sha256.Sum256(minified)
72+	hashStr := hex.EncodeToString(hash[:8])
73+
74+	filename := fmt.Sprintf("styles.%s.css", hashStr)
75+
76+	outPath := filepath.Join(c.Outdir, filename)
77+	err = os.WriteFile(outPath, minified, 0644)
78+	if err != nil {
79+		return "", fmt.Errorf("failed to write CSS bundle: %w", err)
80+	}
81+
82+	c.Logger.Info("CSS bundle created", "filename", filename, "size", len(minified))
83+
84+	return filename, nil
85+}
A generator.go
+443, -0
  1@@ -0,0 +1,443 @@
  2+package pgit
  3+
  4+import (
  5+	"fmt"
  6+	"html/template"
  7+	"path/filepath"
  8+	"sort"
  9+	"strings"
 10+	"sync"
 11+	"time"
 12+
 13+	git "github.com/gogs/git-module"
 14+)
 15+
 16+func (c *Config) WriteRootSummary(data *PageData, readme template.HTML, lastCommit *CommitData) {
 17+	c.Logger.Info("writing root html", "repoPath", c.RepoPath)
 18+	c.WriteHTML(&WriteData{
 19+		Filename: "index.html",
 20+		Template: "html/summary.page.tmpl",
 21+		Data: &SummaryPageData{
 22+			PageData:   data,
 23+			Readme:     readme,
 24+			LastCommit: lastCommit,
 25+		},
 26+	})
 27+}
 28+
 29+func (c *Config) WriteTree(data *PageData, tree *TreeRoot) {
 30+	c.Logger.Info("writing tree", "treePath", tree.Path)
 31+	c.WriteHTML(&WriteData{
 32+		Filename: "index.html",
 33+		Subdir:   tree.Path,
 34+		Template: "html/tree.page.tmpl",
 35+		Data: &TreePageData{
 36+			PageData: data,
 37+			Tree:     tree,
 38+		},
 39+	})
 40+}
 41+
 42+func (c *Config) WriteLog(data *PageData, logs []*CommitData) {
 43+	c.Logger.Info("writing log file", "revision", (*data.RevData).Name())
 44+	c.WriteHTML(&WriteData{
 45+		Filename: "index.html",
 46+		Subdir:   GetLogBaseDir(*data.RevData),
 47+		Template: "html/log.page.tmpl",
 48+		Data: &LogPageData{
 49+			PageData:   data,
 50+			NumCommits: len(logs),
 51+			Logs:       logs,
 52+		},
 53+	})
 54+}
 55+
 56+func (c *Config) WriteRefs(data *PageData, refs []*RefInfo) {
 57+	c.Logger.Info("writing refs", "repoPath", c.RepoPath)
 58+	c.WriteHTML(&WriteData{
 59+		Filename: "refs.html",
 60+		Template: "html/refs.page.tmpl",
 61+		Data: &RefPageData{
 62+			PageData: data,
 63+			Refs:     refs,
 64+		},
 65+	})
 66+}
 67+
 68+func (c *Config) WriteHTMLTreeFile(pageData *PageData, treeItem *TreeItem) string {
 69+	readme := ""
 70+	b, err := treeItem.Entry.Blob().Bytes()
 71+	Bail(err)
 72+	str := string(b)
 73+
 74+	treeItem.IsTextFile = IsText(str)
 75+
 76+	contents := "binary file, cannot display"
 77+	if treeItem.IsTextFile {
 78+		treeItem.NumLines = len(strings.Split(str, "\n"))
 79+		if IsMarkdownFile(treeItem.Entry.Name()) {
 80+			contents = string(c.RenderMarkdown(str))
 81+		} else {
 82+			contents, err = c.ParseText(treeItem.Entry.Name(), string(b))
 83+			Bail(err)
 84+		}
 85+	}
 86+
 87+	d := filepath.Dir(treeItem.Path)
 88+
 89+	nameLower := strings.ToLower(treeItem.Entry.Name())
 90+	summary := ReadmeFile(pageData.Repo)
 91+	if d == "." && nameLower == summary {
 92+		readme = str
 93+	}
 94+
 95+	c.WriteHTML(&WriteData{
 96+		Filename: fmt.Sprintf("%s.html", treeItem.Entry.Name()),
 97+		Template: "html/file.page.tmpl",
 98+		Data: &FilePageData{
 99+			PageData: pageData,
100+			Contents: template.HTML(contents),
101+			Item:     treeItem,
102+		},
103+		Subdir: GetFileDir(*pageData.RevData, d),
104+	})
105+	return readme
106+}
107+
108+func (c *Config) WriteLogDiff(repo *git.Repository, pageData *PageData, commit *CommitData) {
109+	commitID := commit.ID.String()
110+
111+	c.Mutex.RLock()
112+	hasCommit := c.Cache[commitID]
113+	c.Mutex.RUnlock()
114+
115+	if hasCommit {
116+		c.Logger.Info("commit file already generated, skipping", "commitID", GetShortID(commitID))
117+		return
118+	}
119+	c.Mutex.Lock()
120+	c.Cache[commitID] = true
121+	c.Mutex.Unlock()
122+
123+	diff, err := repo.Diff(commitID, 0, 0, 0, git.DiffOptions{})
124+	Bail(err)
125+
126+	rnd := &DiffRender{
127+		NumFiles:       diff.NumFiles(),
128+		TotalAdditions: diff.TotalAdditions(),
129+		TotalDeletions: diff.TotalDeletions(),
130+	}
131+	fls := []*DiffRenderFile{}
132+	for _, file := range diff.Files {
133+		fl := &DiffRenderFile{
134+			FileType:     DiffFileType(file.Type),
135+			OldMode:      file.OldMode(),
136+			OldName:      file.OldName(),
137+			Mode:         file.Mode(),
138+			Name:         file.Name,
139+			NumAdditions: file.NumAdditions(),
140+			NumDeletions: file.NumDeletions(),
141+		}
142+		content := ""
143+		for _, section := range file.Sections {
144+			for _, line := range section.Lines {
145+				content += fmt.Sprintf("%s\n", line.Content)
146+			}
147+		}
148+		finContent, err := c.ParseText("commit.diff", content)
149+		Bail(err)
150+
151+		fl.Content = template.HTML(finContent)
152+		fls = append(fls, fl)
153+	}
154+	rnd.Files = fls
155+
156+	parentSha, _ := commit.Commit.ParentID(0)
157+	parentID := ""
158+	if parentSha == nil {
159+		parentID = commit.ID.String()
160+	} else {
161+		parentID = parentSha.String()
162+	}
163+
164+	commitData := &CommitPageData{
165+		PageData:  pageData,
166+		Commit:    commit,
167+		CommitID:  GetShortID(commitID),
168+		Diff:      rnd,
169+		Parent:    GetShortID(parentID),
170+		CommitURL: c.GetCommitURL(commitID),
171+		ParentURL: c.GetCommitURL(parentID),
172+	}
173+
174+	c.WriteHTML(&WriteData{
175+		Filename: fmt.Sprintf("%s.html", commitID),
176+		Template: "html/commit.page.tmpl",
177+		Subdir:   "commits",
178+		Data:     commitData,
179+	})
180+}
181+
182+func (c *Config) WriteRepo() *BranchOutput {
183+	c.Logger.Info("writing repo", "repoPath", c.RepoPath)
184+	repo, err := git.Open(c.RepoPath)
185+	Bail(err)
186+
187+	refs, err := repo.ShowRef(git.ShowRefOptions{Heads: true, Tags: true})
188+	Bail(err)
189+
190+	var first *RevData
191+	revs := []*RevData{}
192+	for _, revStr := range c.Revs {
193+		fullRevID, err := repo.RevParse(revStr)
194+		Bail(err)
195+
196+		revID := GetShortID(fullRevID)
197+		revName := revID
198+		for _, ref := range refs {
199+			if revStr == git.RefShortName(ref.Refspec) || revStr == ref.Refspec {
200+				revName = revStr
201+				break
202+			}
203+		}
204+
205+		data := &RevData{
206+			id:     fullRevID,
207+			name:   revName,
208+			Config: c,
209+		}
210+
211+		if first == nil {
212+			first = data
213+		}
214+		revs = append(revs, data)
215+	}
216+
217+	if first == nil {
218+		Bail(fmt.Errorf("could not find a git reference that matches criteria"))
219+	}
220+
221+	refInfoMap := map[string]*RefInfo{}
222+	for _, revData := range revs {
223+		refInfoMap[revData.Name()] = &RefInfo{
224+			ID:      revData.ID(),
225+			Refspec: revData.Name(),
226+			URL:     revData.TreeURL(),
227+		}
228+	}
229+
230+	for _, ref := range refs {
231+		refspec := git.RefShortName(ref.Refspec)
232+		if refInfoMap[refspec] != nil {
233+			continue
234+		}
235+
236+		refInfoMap[refspec] = &RefInfo{
237+			ID:      ref.ID,
238+			Refspec: refspec,
239+		}
240+	}
241+
242+	refInfoList := []*RefInfo{}
243+	for _, val := range refInfoMap {
244+		refInfoList = append(refInfoList, val)
245+	}
246+	sort.Slice(refInfoList, func(i, j int) bool {
247+		urlI := refInfoList[i].URL
248+		urlJ := refInfoList[j].URL
249+		refI := refInfoList[i].Refspec
250+		refJ := refInfoList[j].Refspec
251+		if urlI == urlJ {
252+			return refI < refJ
253+		}
254+		return urlI > urlJ
255+	})
256+
257+	mainOutput := &BranchOutput{}
258+	var wg sync.WaitGroup
259+	for i, revData := range revs {
260+		c.Logger.Info("writing revision", "revision", revData.Name())
261+		revInfo := RevInfo(revData)
262+		data := &PageData{
263+			Repo:     c,
264+			RevData:  &revInfo,
265+			SiteURLs: c.GetURLs(),
266+			Refs:     refInfoList,
267+		}
268+
269+		if i == 0 {
270+			branchOutput := c.WriteRevision(repo, data, refInfoList)
271+			mainOutput = branchOutput
272+		} else {
273+			wg.Add(1)
274+			go func() {
275+				defer wg.Done()
276+				c.WriteRevision(repo, data, refInfoList)
277+			}()
278+		}
279+	}
280+	wg.Wait()
281+
282+	revData := &RevData{
283+		id:     first.ID(),
284+		name:   first.Name(),
285+		Config: c,
286+	}
287+
288+	revInfo := RevInfo(revData)
289+	data := &PageData{
290+		RevData:  &revInfo,
291+		Repo:     c,
292+		SiteURLs: c.GetURLs(),
293+		Refs:     refInfoList,
294+	}
295+
296+	if c.Issues {
297+		err := c.WriteIssues(data)
298+		if err != nil {
299+			c.Logger.Warn("failed to write issues", "error", err)
300+		}
301+	}
302+
303+	c.WriteRefs(data, refInfoList)
304+	var readmeHTML template.HTML
305+	if IsMarkdownFile(ReadmeFile(c)) {
306+		readmeHTML = c.RenderMarkdown(mainOutput.Readme)
307+	} else {
308+		readmeHTML = template.HTML(mainOutput.Readme)
309+	}
310+	readmeHTML = template.HTML(`<div class="readme">` + string(readmeHTML) + `</div>`)
311+	c.WriteRootSummary(data, readmeHTML, mainOutput.LastCommit)
312+	return mainOutput
313+}
314+
315+func (c *Config) WriteRevision(repo *git.Repository, pageData *PageData, refs []*RefInfo) *BranchOutput {
316+	c.Logger.Info(
317+		"compiling revision",
318+		"repoName", c.RepoName,
319+		"revision", (*pageData.RevData).Name(),
320+	)
321+
322+	output := &BranchOutput{}
323+	var wg sync.WaitGroup
324+
325+	wg.Add(1)
326+	go func() {
327+		defer wg.Done()
328+
329+		pageSize := pageData.Repo.MaxCommits
330+		if pageSize == 0 {
331+			pageSize = 5000
332+		}
333+		commits, err := repo.CommitsByPage((*pageData.RevData).ID(), 0, pageSize)
334+		Bail(err)
335+
336+		logs := []*CommitData{}
337+		for i, commit := range commits {
338+			tags := []*RefInfo{}
339+			for _, ref := range refs {
340+				if commit.ID.String() == ref.ID {
341+					tags = append(tags, ref)
342+				}
343+			}
344+
345+			parentSha, _ := commit.ParentID(0)
346+			parentID := ""
347+			if parentSha == nil {
348+				parentID = commit.ID.String()
349+			} else {
350+				parentID = parentSha.String()
351+			}
352+			trailers, messageBodyOnly := ParseCommitMessage(commit.Message)
353+			cd := &CommitData{
354+				ParentID:        parentID,
355+				URL:             c.GetCommitURL(commit.ID.String()),
356+				ShortID:         GetShortID(commit.ID.String()),
357+				SummaryStr:      commit.Summary(),
358+				AuthorStr:       commit.Author.Name,
359+				WhenStr:         commit.Author.When.Format(time.DateOnly),
360+				WhenISO:         commit.Author.When.UTC().Format(time.RFC3339),
361+				WhenDisplay:     FormatDateForDisplay(commit.Author.When),
362+				Commit:          commit,
363+				Refs:            tags,
364+				Trailers:        trailers,
365+				MessageBodyOnly: messageBodyOnly,
366+			}
367+			logs = append(logs, cd)
368+			if i == 0 {
369+				output.LastCommit = cd
370+			}
371+		}
372+
373+		c.WriteLog(pageData, logs)
374+
375+		for _, cm := range logs {
376+			wg.Add(1)
377+			go func(commit *CommitData) {
378+				defer wg.Done()
379+				c.WriteLogDiff(repo, pageData, commit)
380+			}(cm)
381+		}
382+	}()
383+
384+	tree, err := repo.LsTree((*pageData.RevData).ID())
385+	Bail(err)
386+
387+	readme := ""
388+	entries := make(chan *TreeItem)
389+	subtrees := make(chan *TreeRoot)
390+	tw := &TreeWalker{
391+		Config:   c,
392+		PageData: pageData,
393+		Repo:     repo,
394+		treeItem: entries,
395+		tree:     subtrees,
396+	}
397+	wg.Add(1)
398+	go func() {
399+		defer wg.Done()
400+		tw.Walk(tree, "")
401+	}()
402+
403+	wg.Add(1)
404+	go func() {
405+		defer wg.Done()
406+		for e := range entries {
407+			wg.Add(1)
408+			go func(entry *TreeItem) {
409+				defer wg.Done()
410+				if entry.IsDir {
411+					return
412+				}
413+
414+				readmeStr := c.WriteHTMLTreeFile(pageData, entry)
415+				if readmeStr != "" {
416+					readme = readmeStr
417+				}
418+			}(e)
419+		}
420+	}()
421+
422+	wg.Add(1)
423+	go func() {
424+		defer wg.Done()
425+		for t := range subtrees {
426+			wg.Add(1)
427+			go func(tree *TreeRoot) {
428+				defer wg.Done()
429+				c.WriteTree(pageData, tree)
430+			}(t)
431+		}
432+	}()
433+
434+	wg.Wait()
435+
436+	c.Logger.Info(
437+		"compilation complete",
438+		"repoName", c.RepoName,
439+		"revision", (*pageData.RevData).Name(),
440+	)
441+
442+	output.Readme = readme
443+	return output
444+}
M go.mod
+3, -0
 1@@ -46,6 +46,7 @@ require (
 2 	github.com/golang/protobuf v1.5.4 // indirect
 3 	github.com/golang/snappy v1.0.0 // indirect
 4 	github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
 5+	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 6 	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
 7 	github.com/kevinburke/ssh_config v1.6.0 // indirect
 8 	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
 9@@ -59,6 +60,8 @@ require (
10 	github.com/pmezard/go-difflib v1.0.0 // indirect
11 	github.com/sergi/go-diff v1.4.0 // indirect
12 	github.com/skeema/knownhosts v1.3.2 // indirect
13+	github.com/spf13/cobra v1.10.2 // indirect
14+	github.com/spf13/pflag v1.0.9 // indirect
15 	github.com/steveyen/gtreap v0.1.0 // indirect
16 	github.com/stretchr/testify v1.11.1 // indirect
17 	github.com/tdewolff/parse/v2 v2.8.11 // indirect
M go.sum
+9, -0
 1@@ -62,6 +62,7 @@ github.com/couchbase/moss v0.1.0/go.mod h1:9MaHIaRuy9pvLPUJxB8sh8OrLfyDczECVL37g
 2 github.com/couchbase/vellum v1.0.2 h1:BrbP0NKiyDdndMPec8Jjhy0U47CZ0Lgx3xUC2r9rZqw=
 3 github.com/couchbase/vellum v1.0.2/go.mod h1:FcwrEivFpNi24R3jLOs3n+fs5RnuQnQqCLBJ1uAg1W4=
 4 github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
 5+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
 6 github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
 7 github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
 8 github.com/cznic/b v0.0.0-20181122101859-a26611c4d92d h1:SwD98825d6bdB+pEuTxWOXiSjBrHdOl/UVp75eI7JT8=
 9@@ -130,6 +131,8 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo
10 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
11 github.com/ikawaha/kagome.ipadic v1.1.2/go.mod h1:DPSBbU0czaJhAb/5uKQZHMc9MTVRpDugJfX+HddPHHg=
12 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
13+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
14+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
15 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
16 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
17 github.com/jmhodges/levigo v1.0.0 h1:q5EC36kV79HWeTBWsod3mG11EgStG3qArTKcvlksN1U=
18@@ -180,6 +183,7 @@ github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qq
19 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
20 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
21 github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
22+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
23 github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
24 github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
25 github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
26@@ -188,8 +192,12 @@ github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqR
27 github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
28 github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
29 github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
30+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
31+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
32 github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
33 github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
34+github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
35+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
36 github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
37 github.com/steveyen/gtreap v0.1.0 h1:CjhzTa274PyJLJuMZwIzCO1PfC00oRa8d1Kc78bFXJM=
38 github.com/steveyen/gtreap v0.1.0/go.mod h1:kl/5J7XbrOmlIbYIXdRHDDE5QxHqpk0cmkT7Z4dM9/Y=
39@@ -223,6 +231,7 @@ github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:
40 go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
41 go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
42 go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
43+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
44 golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
45 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
46 golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
A hooks.go
+96, -0
 1@@ -0,0 +1,96 @@
 2+package pgit
 3+
 4+import (
 5+	"fmt"
 6+	"os"
 7+	"path/filepath"
 8+	"strings"
 9+)
10+
11+func InstallHook(repoPath string) error {
12+	absRepoPath, err := filepath.Abs(repoPath)
13+	if err != nil {
14+		return fmt.Errorf("could not resolve repo path: %w", err)
15+	}
16+
17+	hookContent, err := EmbedFS.ReadFile("hooks/post-commit.bash")
18+	if err != nil {
19+		return fmt.Errorf("could not read embedded hook: %w", err)
20+	}
21+
22+	hooksDir := filepath.Join(absRepoPath, ".git", "hooks")
23+	if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
24+		bareHooksDir := filepath.Join(repoPath, "hooks")
25+		if _, err := os.Stat(bareHooksDir); err == nil {
26+			hooksDir = bareHooksDir
27+			fmt.Printf("Detected bare repository, installing to %s\n", hooksDir)
28+		}
29+	}
30+
31+	postCommitHook := filepath.Join(hooksDir, "post-commit")
32+
33+	if err := os.MkdirAll(hooksDir, 0755); err != nil {
34+		return fmt.Errorf("could not create hooks directory: %w", err)
35+	}
36+
37+	if _, err := os.Stat(postCommitHook); err == nil {
38+		backupPath := postCommitHook + ".backup"
39+		if err := os.Rename(postCommitHook, backupPath); err != nil {
40+			return fmt.Errorf("failed to backup existing hook: %w", err)
41+		}
42+		fmt.Printf("Backed up existing post-commit hook to %s\n", backupPath)
43+	}
44+
45+	if err := os.WriteFile(postCommitHook, hookContent, 0755); err != nil {
46+		return fmt.Errorf("failed to write hook: %w", err)
47+	}
48+
49+	fmt.Printf("Installed post-commit hook to %s\n", postCommitHook)
50+	fmt.Println("Commits with issue references (e.g., 'fixes #872a52d') will now automatically close git-bug issues")
51+	return nil
52+}
53+
54+func UninstallHook(repoPath string) error {
55+	absRepoPath, err := filepath.Abs(repoPath)
56+	if err != nil {
57+		return fmt.Errorf("could not resolve repo path: %w", err)
58+	}
59+
60+	hooksDir := filepath.Join(absRepoPath, ".git", "hooks")
61+	if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
62+		bareHooksDir := filepath.Join(repoPath, "hooks")
63+		if _, err := os.Stat(bareHooksDir); err == nil {
64+			hooksDir = bareHooksDir
65+		}
66+	}
67+
68+	postCommitHook := filepath.Join(hooksDir, "post-commit")
69+
70+	data, err := os.ReadFile(postCommitHook)
71+	if err != nil {
72+		if os.IsNotExist(err) {
73+			fmt.Println("No post-commit hook found")
74+			return nil
75+		}
76+		return fmt.Errorf("failed to read hook: %w", err)
77+	}
78+
79+	if !strings.Contains(string(data), "pgit post-commit hook") {
80+		return fmt.Errorf("post-commit hook is not a pgit hook (not removing)")
81+	}
82+
83+	if err := os.Remove(postCommitHook); err != nil {
84+		return fmt.Errorf("failed to remove hook: %w", err)
85+	}
86+
87+	backupPath := postCommitHook + ".backup"
88+	if _, err := os.Stat(backupPath); err == nil {
89+		if err := os.Rename(backupPath, postCommitHook); err != nil {
90+			return fmt.Errorf("failed to restore backup: %w", err)
91+		}
92+		fmt.Printf("Restored backup hook from %s\n", backupPath)
93+	}
94+
95+	fmt.Println("Uninstalled pgit post-commit hook")
96+	return nil
97+}
A html.go
+52, -0
 1@@ -0,0 +1,52 @@
 2+package pgit
 3+
 4+import (
 5+	"html/template"
 6+	"os"
 7+	"path/filepath"
 8+)
 9+
10+func (c *Config) WriteHTML(writeData *WriteData) {
11+	ts, err := template.ParseFS(
12+		EmbedFS,
13+		writeData.Template,
14+		"html/header.partial.tmpl",
15+		"html/footer.partial.tmpl",
16+		"html/base.layout.tmpl",
17+	)
18+	Bail(err)
19+
20+	dir := filepath.Join(c.Outdir, writeData.Subdir)
21+	err = os.MkdirAll(dir, os.ModePerm)
22+	Bail(err)
23+
24+	fp := filepath.Join(dir, writeData.Filename)
25+	c.Logger.Info("writing", "filepath", fp)
26+
27+	w, err := os.OpenFile(fp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
28+	Bail(err)
29+
30+	err = ts.Execute(w, writeData.Data)
31+	Bail(err)
32+}
33+
34+func (c *Config) CopyStatic(dir string) error {
35+	entries, err := StaticFS.ReadDir(dir)
36+	Bail(err)
37+
38+	for _, e := range entries {
39+		infp := filepath.Join(dir, e.Name())
40+		if e.IsDir() {
41+			continue
42+		}
43+
44+		w, err := StaticFS.ReadFile(infp)
45+		Bail(err)
46+		fp := filepath.Join(c.Outdir, e.Name())
47+		c.Logger.Info("writing", "filepath", fp)
48+		err = os.WriteFile(fp, w, 0644)
49+		Bail(err)
50+	}
51+
52+	return nil
53+}
M issues.go
+23, -64
  1@@ -1,4 +1,4 @@
  2-package main
  3+package pgit
  4 
  5 import (
  6 	"fmt"
  7@@ -12,52 +12,42 @@ import (
  8 	"github.com/git-bug/git-bug/repository"
  9 )
 10 
 11-// commitRefRegex matches "Fixed in commit [40-char-hash]" or similar patterns
 12-// Captures the full 40-character commit hash
 13-// (?i) makes it case-insensitive, \b ensures word boundaries
 14 var commitRefRegex = regexp.MustCompile(`(?i)\bFixed in commit ([a-f0-9]{40})\b`)
 15 
 16-// transformCommitRefs converts plain text commit references in issue comments
 17-// into clickable links using pgit's URL scheme
 18 func (c *Config) transformCommitRefs(content template.HTML) template.HTML {
 19 	contentStr := string(content)
 20 
 21 	result := commitRefRegex.ReplaceAllStringFunc(contentStr, func(match string) string {
 22-		// Extract hash by position: the hash is always exactly 40 hex chars at the end
 23-		// This avoids the double regex execution (FindStringSubmatch inside ReplaceAllStringFunc)
 24 		if len(match) < 40 {
 25 			return match
 26 		}
 27 		hash := match[len(match)-40:]
 28 		prefix := match[:len(match)-40]
 29-		shortHash := getShortID(hash)
 30-		commitURL := c.getCommitURL(hash)
 31+		shortHash := GetShortID(hash)
 32+		commitURL := c.GetCommitURL(hash)
 33 
 34-		// Return the linked version preserving original casing
 35 		return fmt.Sprintf(`%s<a href="%s">%s</a>`, prefix, commitURL, shortHash)
 36 	})
 37 
 38 	return template.HTML(result)
 39 }
 40 
 41-// IssueData represents a git-bug issue for templates
 42 type IssueData struct {
 43 	ID            string
 44 	FullID        string
 45 	Title         string
 46-	Status        string // "open" or "closed"
 47+	Status        string
 48 	Author        string
 49 	CreatedAt     string
 50 	CreatedAtISO  string
 51 	CreatedAtDisp string
 52 	Labels        []string
 53-	CommentCount  int // excludes original description
 54+	CommentCount  int
 55 	Description   template.HTML
 56 	Comments      []CommentData
 57 	URL           template.URL
 58 }
 59 
 60-// CommentData represents a comment on an issue
 61 type CommentData struct {
 62 	Author        string
 63 	CreatedAt     string
 64@@ -66,40 +56,34 @@ type CommentData struct {
 65 	Body          template.HTML
 66 }
 67 
 68-// IssuesListPageData for list pages (open, closed, by label)
 69 type IssuesListPageData struct {
 70 	*PageData
 71-	Filter          string // "open", "closed", or "label"
 72+	Filter          string
 73 	OpenCount       int
 74 	ClosedCount     int
 75 	Issues          []*IssueData
 76-	Label           string       // set when Filter == "label"
 77-	AllLabels       []string     // all unique labels from open issues
 78-	OpenIssuesURL   template.URL // URL to open issues list
 79-	ClosedIssuesURL template.URL // URL to closed issues list
 80+	Label           string
 81+	AllLabels       []string
 82+	OpenIssuesURL   template.URL
 83+	ClosedIssuesURL template.URL
 84 }
 85 
 86-// IssueDetailPageData for individual issue pages
 87 type IssueDetailPageData struct {
 88 	*PageData
 89 	Issue *IssueData
 90 }
 91 
 92-// Active returns the active navigation item
 93 func (i *IssuesListPageData) Active() string  { return "issues" }
 94 func (i *IssueDetailPageData) Active() string { return "issues" }
 95 
 96-// loadIssues reads all issues from git-bug in the repository
 97 func (c *Config) loadIssues() ([]*IssueData, error) {
 98 	c.Logger.Info("loading issues from git-bug", "repoPath", c.RepoPath)
 99 
100-	// Open the repository with git-bug's repo opener
101 	repo, err := repository.OpenGoGitRepo(c.RepoPath, "", nil)
102 	if err != nil {
103 		return nil, fmt.Errorf("failed to open repository: %w", err)
104 	}
105 
106-	// Read all bugs directly from the repository (without cache)
107 	var issues []*IssueData
108 
109 	for streamedBug := range bug.ReadAll(repo) {
110@@ -111,58 +95,52 @@ func (c *Config) loadIssues() ([]*IssueData, error) {
111 		b := streamedBug.Entity
112 		snap := b.Compile()
113 
114-		// Count comments (excluding the original description)
115 		commentCount := len(snap.Comments) - 1
116 		if commentCount < 0 {
117 			commentCount = 0
118 		}
119 
120-		// Build labels slice
121 		labels := make([]string, len(snap.Labels))
122 		for i, label := range snap.Labels {
123 			labels[i] = string(label)
124 		}
125 
126-		// Build comments (skip first comment which is the description)
127 		var comments []CommentData
128 		for i, comment := range snap.Comments {
129 			if i == 0 {
130-				// Skip the original description
131 				continue
132 			}
133-			// Parse RFC1123 format and convert to ISO 8601 (UTC) for JavaScript
134 			createdAtTime, _ := time.Parse("Mon Jan 2 15:04:05 2006 -0700", comment.FormatTime())
135 			comments = append(comments, CommentData{
136 				Author:        comment.Author.Name(),
137 				CreatedAt:     comment.FormatTime(),
138 				CreatedAtISO:  createdAtTime.UTC().Format(time.RFC3339),
139-				CreatedAtDisp: formatDateForDisplay(createdAtTime),
140-				Body:          c.transformCommitRefs(c.renderMarkdown(comment.Message)),
141+				CreatedAtDisp: FormatDateForDisplay(createdAtTime),
142+				Body:          c.transformCommitRefs(c.RenderMarkdown(comment.Message)),
143 			})
144 		}
145 
146-		// Get description from first comment
147 		var description template.HTML
148 		if len(snap.Comments) > 0 {
149-			description = c.renderMarkdown(snap.Comments[0].Message)
150+			description = c.RenderMarkdown(snap.Comments[0].Message)
151 			description = c.transformCommitRefs(description)
152 		}
153 
154 		fullID := b.Id().String()
155 		issue := &IssueData{
156-			ID:            getShortID(fullID),
157+			ID:            GetShortID(fullID),
158 			FullID:        fullID,
159 			Title:         snap.Title,
160 			Status:        snap.Status.String(),
161 			Author:        snap.Author.Name(),
162 			CreatedAt:     snap.CreateTime.Format("Mon Jan 2 15:04:05 2006 -0700"),
163 			CreatedAtISO:  snap.CreateTime.UTC().Format(time.RFC3339),
164-			CreatedAtDisp: formatDateForDisplay(snap.CreateTime),
165+			CreatedAtDisp: FormatDateForDisplay(snap.CreateTime),
166 			Labels:        labels,
167 			CommentCount:  commentCount,
168 			Description:   description,
169 			Comments:      comments,
170-			URL:           c.getIssueURL(fullID),
171+			URL:           c.GetIssueURL(fullID),
172 		}
173 		issues = append(issues, issue)
174 	}
175@@ -170,29 +148,20 @@ func (c *Config) loadIssues() ([]*IssueData, error) {
176 	return issues, nil
177 }
178 
179-// getIssueURL generates the URL for an issue detail page
180-func (c *Config) getIssueURL(issueID string) template.URL {
181-	url := fmt.Sprintf("%sissues/%s.html", c.RootRelative, issueID)
182-	return template.URL(url)
183-}
184-
185-// getIssuesListURL generates URL for issues list pages
186-func (c *Config) getIssuesListURL(filter string, label string) template.URL {
187+func (c *Config) GetIssuesListURL(filter string, label string) template.URL {
188 	var path string
189 	switch filter {
190 	case "open", "closed":
191 		path = filepath.Join("issues", filter, "index.html")
192 	case "label":
193-		// URL-encode the label
194 		encodedLabel := url.PathEscape(label)
195 		path = filepath.Join("issues", "label", encodedLabel, "index.html")
196 	default:
197 		path = filepath.Join("issues", "open", "index.html")
198 	}
199-	return c.compileURL("/", path)
200+	return c.CompileURL("/", path)
201 }
202 
203-// writeIssueListPage generates an issues list page
204 func (c *Config) writeIssueListPage(data *PageData, filter string, label string, issues []*IssueData, openCount, closedCount int, allLabels []string) {
205 	c.Logger.Info("writing issues list", "filter", filter, "label", label, "count", len(issues))
206 
207@@ -204,8 +173,8 @@ func (c *Config) writeIssueListPage(data *PageData, filter string, label string,
208 		Issues:          issues,
209 		Label:           label,
210 		AllLabels:       allLabels,
211-		OpenIssuesURL:   c.getIssuesListURL("open", ""),
212-		ClosedIssuesURL: c.getIssuesListURL("closed", ""),
213+		OpenIssuesURL:   c.GetIssuesListURL("open", ""),
214+		ClosedIssuesURL: c.GetIssuesListURL("closed", ""),
215 	}
216 
217 	var subdir string
218@@ -219,7 +188,7 @@ func (c *Config) writeIssueListPage(data *PageData, filter string, label string,
219 		subdir = filepath.Join("issues/label", encodedLabel)
220 	}
221 
222-	c.writeHtml(&WriteData{
223+	c.WriteHTML(&WriteData{
224 		Filename: "index.html",
225 		Subdir:   subdir,
226 		Template: "html/issues_list.page.tmpl",
227@@ -227,7 +196,6 @@ func (c *Config) writeIssueListPage(data *PageData, filter string, label string,
228 	})
229 }
230 
231-// writeIssueDetailPage generates an individual issue page
232 func (c *Config) writeIssueDetailPage(data *PageData, issue *IssueData) {
233 	c.Logger.Info("writing issue detail", "id", issue.ID, "title", issue.Title)
234 
235@@ -236,7 +204,7 @@ func (c *Config) writeIssueDetailPage(data *PageData, issue *IssueData) {
236 		Issue:    issue,
237 	}
238 
239-	c.writeHtml(&WriteData{
240+	c.WriteHTML(&WriteData{
241 		Filename: fmt.Sprintf("%s.html", issue.FullID),
242 		Subdir:   "issues",
243 		Template: "html/issue_detail.page.tmpl",
244@@ -244,15 +212,12 @@ func (c *Config) writeIssueDetailPage(data *PageData, issue *IssueData) {
245 	})
246 }
247 
248-// writeIssues generates all issue-related pages
249-func (c *Config) writeIssues(pageData *PageData) error {
250-	// Load all issues from git-bug
251+func (c *Config) WriteIssues(pageData *PageData) error {
252 	issues, err := c.loadIssues()
253 	if err != nil {
254 		return fmt.Errorf("failed to load issues: %w", err)
255 	}
256 
257-	// If no issues, skip generation
258 	if len(issues) == 0 {
259 		c.Logger.Info("no git-bug issues found, skipping issue generation")
260 		return nil
261@@ -260,7 +225,6 @@ func (c *Config) writeIssues(pageData *PageData) error {
262 
263 	c.Logger.Info("loaded issues", "count", len(issues))
264 
265-	// Categorize issues
266 	var openIssues, closedIssues []*IssueData
267 	labelIssues := make(map[string][]*IssueData)
268 	allLabels := make(map[string]bool)
269@@ -268,7 +232,6 @@ func (c *Config) writeIssues(pageData *PageData) error {
270 	for _, issue := range issues {
271 		if issue.Status == "open" {
272 			openIssues = append(openIssues, issue)
273-			// Collect labels from open issues
274 			for _, label := range issue.Labels {
275 				allLabels[label] = true
276 				labelIssues[label] = append(labelIssues[label], issue)
277@@ -281,22 +244,18 @@ func (c *Config) writeIssues(pageData *PageData) error {
278 	openCount := len(openIssues)
279 	closedCount := len(closedIssues)
280 
281-	// Build sorted label list
282 	var sortedLabels []string
283 	for label := range allLabels {
284 		sortedLabels = append(sortedLabels, label)
285 	}
286 
287-	// Generate individual issue pages for all issues
288 	for _, issue := range issues {
289 		c.writeIssueDetailPage(pageData, issue)
290 	}
291 
292-	// Generate list pages
293 	c.writeIssueListPage(pageData, "open", "", openIssues, openCount, closedCount, sortedLabels)
294 	c.writeIssueListPage(pageData, "closed", "", closedIssues, openCount, closedCount, sortedLabels)
295 
296-	// Generate label filter pages (only for open issues)
297 	for label, issues := range labelIssues {
298 		c.writeIssueListPage(pageData, "label", label, issues, openCount, closedCount, sortedLabels)
299 	}
D main.go
+0, -1655
   1@@ -1,1655 +0,0 @@
   2-package main
   3-
   4-import (
   5-	"bytes"
   6-	"crypto/sha256"
   7-	"embed"
   8-	"encoding/hex"
   9-	"flag"
  10-	"fmt"
  11-	"html/template"
  12-	"io"
  13-	"log/slog"
  14-	"math"
  15-	"os"
  16-	"path/filepath"
  17-	"regexp"
  18-	"sort"
  19-	"strings"
  20-	"sync"
  21-	"time"
  22-	"unicode/utf8"
  23-
  24-	"github.com/alecthomas/chroma/v2"
  25-	"github.com/alecthomas/chroma/v2/formatters"
  26-	formatterHtml "github.com/alecthomas/chroma/v2/formatters/html"
  27-	"github.com/alecthomas/chroma/v2/lexers"
  28-	"github.com/alecthomas/chroma/v2/styles"
  29-	"github.com/dustin/go-humanize"
  30-	git "github.com/gogs/git-module"
  31-	"github.com/gomarkdown/markdown"
  32-	"github.com/gomarkdown/markdown/ast"
  33-	"github.com/gomarkdown/markdown/html"
  34-	"github.com/gomarkdown/markdown/parser"
  35-	"github.com/tdewolff/minify/v2"
  36-	"github.com/tdewolff/minify/v2/css"
  37-)
  38-
  39-//go:embed html/*.tmpl
  40-//go:embed static/*
  41-//go:embed hooks/*.bash
  42-var embedFS embed.FS
  43-
  44-//go:embed static/*
  45-var staticFS embed.FS
  46-
  47-type Config struct {
  48-	// required params
  49-	Outdir string
  50-	// abs path to git repo
  51-	RepoPath string
  52-
  53-	// optional params
  54-	// generate logs anad tree based on the git revisions provided
  55-	Revs []string
  56-	// description of repo used in the header of site (HTML)
  57-	Desc template.HTML
  58-	// maximum number of commits that we will process in descending order
  59-	MaxCommits int
  60-	// name of the readme file
  61-	Readme string
  62-	// In order to get the latest commit per file we do a `git rev-list {ref} {file}`
  63-	// which is n+1 where n is a file in the tree.
  64-	// We offer a way to disable showing the latest commit in the output
  65-	// for those who want a faster build time
  66-	HideTreeLastCommit bool
  67-	// enable git-bug issue generation
  68-	Issues bool
  69-
  70-	// user-defined urls
  71-	HomeURL  template.URL
  72-	CloneURL template.URL
  73-
  74-	// https://developer.mozilla.org/en-US/docs/Web/API/URL_API/Resolving_relative_references#root_relative
  75-	RootRelative string
  76-
  77-	// computed
  78-	// cache for skipping commits, trees, etc.
  79-	Cache map[string]bool
  80-	// mutex for Cache
  81-	Mutex sync.RWMutex
  82-	// pretty name for the repo
  83-	RepoName string
  84-	// logger
  85-	Logger *slog.Logger
  86-	// chroma style
  87-	Theme     *chroma.Style
  88-	Formatter *formatterHtml.Formatter
  89-	// CSS bundle filename (with content hash)
  90-	CSSFile string
  91-}
  92-
  93-type RevInfo interface {
  94-	ID() string
  95-	Name() string
  96-}
  97-
  98-type RevData struct {
  99-	id     string
 100-	name   string
 101-	Config *Config
 102-}
 103-
 104-func (r *RevData) ID() string {
 105-	return r.id
 106-}
 107-
 108-func (r *RevData) Name() string {
 109-	return r.name
 110-}
 111-
 112-func (r *RevData) TreeURL() template.URL {
 113-	return r.Config.getTreeURL(r)
 114-}
 115-
 116-func (r *RevData) LogURL() template.URL {
 117-	return r.Config.getLogsURL(r)
 118-}
 119-
 120-type TagData struct {
 121-	Name string
 122-	URL  template.URL
 123-}
 124-
 125-type Trailer struct {
 126-	Key   string
 127-	Value string
 128-}
 129-
 130-type CommitData struct {
 131-	SummaryStr  string
 132-	URL         template.URL
 133-	WhenStr     string
 134-	WhenISO     string
 135-	WhenDisplay string
 136-	AuthorStr   string
 137-	ShortID     string
 138-	ParentID    string
 139-	Refs        []*RefInfo
 140-	Trailers    []Trailer
 141-	// MessageBodyOnly is the commit message body without summary line and trailers
 142-	MessageBodyOnly string
 143-	*git.Commit
 144-}
 145-
 146-type TreeItem struct {
 147-	IsTextFile  bool
 148-	IsDir       bool
 149-	Size        string
 150-	NumLines    int
 151-	Name        string
 152-	Icon        string
 153-	Path        string
 154-	URL         template.URL
 155-	CommitID    string
 156-	CommitURL   template.URL
 157-	Summary     string
 158-	When        string
 159-	WhenISO     string
 160-	WhenDisplay string
 161-	Author      *git.Signature
 162-	Entry       *git.TreeEntry
 163-	Crumbs      []*Breadcrumb
 164-}
 165-
 166-type DiffRender struct {
 167-	NumFiles       int
 168-	TotalAdditions int
 169-	TotalDeletions int
 170-	Files          []*DiffRenderFile
 171-}
 172-
 173-type DiffRenderFile struct {
 174-	FileType     string
 175-	OldMode      git.EntryMode
 176-	OldName      string
 177-	Mode         git.EntryMode
 178-	Name         string
 179-	Content      template.HTML
 180-	NumAdditions int
 181-	NumDeletions int
 182-}
 183-
 184-type RefInfo struct {
 185-	ID      string
 186-	Refspec string
 187-	URL     template.URL
 188-}
 189-
 190-type BranchOutput struct {
 191-	Readme     string
 192-	LastCommit *CommitData
 193-}
 194-
 195-type SiteURLs struct {
 196-	HomeURL    template.URL
 197-	CloneURL   template.URL
 198-	SummaryURL template.URL
 199-	RefsURL    template.URL
 200-	IssuesURL  template.URL
 201-}
 202-
 203-type PageData struct {
 204-	Repo     *Config
 205-	SiteURLs *SiteURLs
 206-	RevData  *RevData
 207-	Refs     []*RefInfo
 208-}
 209-
 210-type SummaryPageData struct {
 211-	*PageData
 212-	Readme     template.HTML
 213-	LastCommit *CommitData
 214-}
 215-
 216-type TreePageData struct {
 217-	*PageData
 218-	Tree *TreeRoot
 219-}
 220-
 221-type LogPageData struct {
 222-	*PageData
 223-	NumCommits int
 224-	Logs       []*CommitData
 225-}
 226-
 227-type FilePageData struct {
 228-	*PageData
 229-	Contents template.HTML
 230-	Item     *TreeItem
 231-}
 232-
 233-type CommitPageData struct {
 234-	*PageData
 235-	CommitMsg template.HTML
 236-	CommitID  string
 237-	Commit    *CommitData
 238-	Diff      *DiffRender
 239-	Parent    string
 240-	ParentURL template.URL
 241-	CommitURL template.URL
 242-}
 243-
 244-type RefPageData struct {
 245-	*PageData
 246-	Refs []*RefInfo
 247-}
 248-
 249-func (s *SummaryPageData) Active() string { return "summary" }
 250-func (s *TreePageData) Active() string    { return "code" }
 251-func (s *FilePageData) Active() string    { return "code" }
 252-func (s *LogPageData) Active() string     { return "commits" }
 253-func (s *CommitPageData) Active() string  { return "commits" }
 254-func (s *RefPageData) Active() string     { return "refs" }
 255-
 256-type WriteData struct {
 257-	Template string
 258-	Filename string
 259-	Subdir   string
 260-	Data     any
 261-}
 262-
 263-func bail(err error) {
 264-	if err != nil {
 265-		panic(err)
 266-	}
 267-}
 268-
 269-func diffFileType(_type git.DiffFileType) string {
 270-	switch _type {
 271-	case git.DiffFileAdd:
 272-		return "A"
 273-	case git.DiffFileChange:
 274-		return "M"
 275-	case git.DiffFileDelete:
 276-		return "D"
 277-	case git.DiffFileRename:
 278-		return "R"
 279-	default:
 280-		return ""
 281-	}
 282-}
 283-
 284-// converts contents of files in git tree to pretty formatted code.
 285-func (c *Config) parseText(filename string, text string) (string, error) {
 286-	lexer := lexers.Match(filename)
 287-	if lexer == nil {
 288-		lexer = lexers.Analyse(text)
 289-	}
 290-	if lexer == nil {
 291-		lexer = lexers.Get("plaintext")
 292-	}
 293-	iterator, err := lexer.Tokenise(nil, text)
 294-	if err != nil {
 295-		return text, err
 296-	}
 297-	var buf bytes.Buffer
 298-	err = c.Formatter.Format(&buf, c.Theme, iterator)
 299-	if err != nil {
 300-		return text, err
 301-	}
 302-	return buf.String(), nil
 303-}
 304-
 305-// isText reports whether a significant prefix of s looks like correct UTF-8;
 306-// that is, if it is likely that s is human-readable text.
 307-func isText(s string) bool {
 308-	const max = 1024 // at least utf8.UTFMax
 309-	if len(s) > max {
 310-		s = s[0:max]
 311-	}
 312-	for i, c := range s {
 313-		if i+utf8.UTFMax > len(s) {
 314-			// last char may be incomplete - ignore
 315-			break
 316-		}
 317-		if c == 0xFFFD || c < ' ' && c != '\n' && c != '\t' && c != '\f' && c != '\r' {
 318-			// decoding error or control character - not a text file
 319-			return false
 320-		}
 321-	}
 322-	return true
 323-}
 324-
 325-// isTextFile reports whether the file has a known extension indicating
 326-// a text file, or if a significant chunk of the specified file looks like
 327-// correct UTF-8; that is, if it is likely that the file contains human-
 328-// readable text.
 329-func isTextFile(text string) bool {
 330-	num := math.Min(float64(len(text)), 1024)
 331-	return isText(text[0:int(num)])
 332-}
 333-
 334-// isMarkdownFile reports whether the file has a .md extension (case-insensitive).
 335-func isMarkdownFile(filename string) bool {
 336-	ext := strings.ToLower(filepath.Ext(filename))
 337-	return ext == ".md"
 338-}
 339-
 340-// chromaMarkdownRenderer is a custom HTML renderer that applies Chroma syntax highlighting to code blocks
 341-type chromaMarkdownRenderer struct {
 342-	defaultRenderer *html.Renderer
 343-	theme           *chroma.Style
 344-}
 345-
 346-func (r *chromaMarkdownRenderer) RenderNode(w io.Writer, node ast.Node, entering bool) ast.WalkStatus {
 347-	// Only process code blocks when entering (not when exiting)
 348-	if entering {
 349-		if codeBlock, ok := node.(*ast.CodeBlock); ok {
 350-			// Extract language from Info field (e.g., "go" from "```go")
 351-			lang := extractLanguage(codeBlock.Info)
 352-
 353-			// Get the code content from Literal
 354-			code := string(codeBlock.Literal)
 355-
 356-			// Use Chroma to highlight
 357-			highlighted, err := r.highlightCode(lang, code)
 358-			if err == nil {
 359-				w.Write([]byte(highlighted))
 360-				return ast.SkipChildren
 361-			}
 362-			// Fall back to default rendering if highlighting fails
 363-		}
 364-	}
 365-
 366-	// Use default renderer for all other nodes
 367-	return r.defaultRenderer.RenderNode(w, node, entering)
 368-}
 369-
 370-func (r *chromaMarkdownRenderer) RenderHeader(w io.Writer, ast ast.Node) {
 371-	r.defaultRenderer.RenderHeader(w, ast)
 372-}
 373-
 374-func (r *chromaMarkdownRenderer) RenderFooter(w io.Writer, ast ast.Node) {
 375-	r.defaultRenderer.RenderFooter(w, ast)
 376-}
 377-
 378-func (r *chromaMarkdownRenderer) highlightCode(lang, code string) (string, error) {
 379-	// Get appropriate lexer
 380-	var lexer chroma.Lexer
 381-	if lang != "" {
 382-		lexer = lexers.Get(lang)
 383-	}
 384-	if lexer == nil {
 385-		lexer = lexers.Analyse(code)
 386-	}
 387-	if lexer == nil {
 388-		lexer = lexers.Get("plaintext")
 389-	}
 390-
 391-	// Tokenize
 392-	iterator, err := lexer.Tokenise(nil, code)
 393-	if err != nil {
 394-		return "", err
 395-	}
 396-
 397-	// Create formatter WITHOUT line numbers for markdown code blocks
 398-	formatter := formatters.Get("html")
 399-	if formatter == nil {
 400-		return "", fmt.Errorf("failed to get HTML formatter")
 401-	}
 402-
 403-	// Format with Chroma
 404-	var buf bytes.Buffer
 405-	err = formatter.Format(&buf, r.theme, iterator)
 406-	if err != nil {
 407-		return "", err
 408-	}
 409-
 410-	// Chroma's HTML formatter wraps output in <pre class="chroma"><code>...</code></pre>
 411-	// We need to strip the outer <pre> wrapper since gomarkdown already adds it,
 412-	// but keep the <code class="chroma"> tag for styling
 413-	highlighted := buf.String()
 414-
 415-	// Remove Chroma's <pre class="chroma"> prefix and </pre> suffix
 416-	// This leaves us with <code class="chroma">...</code> which is what we want
 417-	highlighted = strings.TrimPrefix(highlighted, "<pre class=\"chroma\">")
 418-	highlighted = strings.TrimSuffix(highlighted, "</pre>")
 419-
 420-	return highlighted, nil
 421-}
 422-
 423-func extractLanguage(info []byte) string {
 424-	if len(info) == 0 {
 425-		return "" // No language specified
 426-	}
 427-	// Info may contain additional text after the language (e.g., "go filename.go")
 428-	parts := strings.Fields(string(info))
 429-	if len(parts) > 0 {
 430-		return parts[0] // First part is the language
 431-	}
 432-	return ""
 433-}
 434-
 435-// renderMarkdown converts markdown text to HTML with Chroma syntax highlighting for code blocks.
 436-func (c *Config) renderMarkdown(mdText string) template.HTML {
 437-	// Parse markdown with fenced code block support
 438-	extensions := parser.CommonExtensions | parser.FencedCode
 439-	p := parser.NewWithExtensions(extensions)
 440-	doc := p.Parse([]byte(mdText))
 441-
 442-	// Create default HTML renderer
 443-	htmlFlags := html.CommonFlags
 444-	opts := html.RendererOptions{Flags: htmlFlags}
 445-	defaultRenderer := html.NewRenderer(opts)
 446-
 447-	// Create custom renderer with Chroma support
 448-	customRenderer := &chromaMarkdownRenderer{
 449-		defaultRenderer: defaultRenderer,
 450-		theme:           c.Theme,
 451-	}
 452-
 453-	// Render to HTML
 454-	htmlBytes := markdown.Render(doc, customRenderer)
 455-	return template.HTML(htmlBytes)
 456-}
 457-
 458-func toPretty(b int64) string {
 459-	return humanize.Bytes(uint64(b))
 460-}
 461-
 462-// formatDateForDisplay returns a static date format for non-JS display:
 463-// "Jan 2" for dates less than a year ago, "Jan 2006" for older dates
 464-func formatDateForDisplay(t time.Time) string {
 465-	if time.Since(t).Hours() > 365*24 {
 466-		return t.Format("Jan 2006")
 467-	}
 468-	return t.Format("Jan 2")
 469-}
 470-
 471-func repoName(root string) string {
 472-	_, file := filepath.Split(root)
 473-	return file
 474-}
 475-
 476-func readmeFile(repo *Config) string {
 477-	if repo.Readme == "" {
 478-		return "readme.md"
 479-	}
 480-
 481-	return strings.ToLower(repo.Readme)
 482-}
 483-
 484-// readDescription reads the repository description from .git/description (cloned repo)
 485-// or ./description (bare repo). Returns empty string if not found.
 486-func readDescription(repoPath string) string {
 487-	// Try .git/description first (cloned repo)
 488-	descPath := filepath.Join(repoPath, ".git", "description")
 489-	data, err := os.ReadFile(descPath)
 490-	if err != nil {
 491-		// Try ./description (bare repo)
 492-		descPath = filepath.Join(repoPath, "description")
 493-		data, err = os.ReadFile(descPath)
 494-		if err != nil {
 495-			return ""
 496-		}
 497-	}
 498-
 499-	desc := strings.TrimSpace(string(data))
 500-
 501-	// Filter out the default git description
 502-	if desc == "Unnamed repository; edit this file 'description' to name the repository." {
 503-		return ""
 504-	}
 505-
 506-	return desc
 507-}
 508-
 509-func (c *Config) writeHtml(writeData *WriteData) {
 510-	ts, err := template.ParseFS(
 511-		embedFS,
 512-		writeData.Template,
 513-		"html/header.partial.tmpl",
 514-		"html/footer.partial.tmpl",
 515-		"html/base.layout.tmpl",
 516-	)
 517-	bail(err)
 518-
 519-	dir := filepath.Join(c.Outdir, writeData.Subdir)
 520-	err = os.MkdirAll(dir, os.ModePerm)
 521-	bail(err)
 522-
 523-	fp := filepath.Join(dir, writeData.Filename)
 524-	c.Logger.Info("writing", "filepath", fp)
 525-
 526-	w, err := os.OpenFile(fp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
 527-	bail(err)
 528-
 529-	err = ts.Execute(w, writeData.Data)
 530-	bail(err)
 531-}
 532-
 533-func (c *Config) copyStatic(dir string) error {
 534-	entries, err := staticFS.ReadDir(dir)
 535-	bail(err)
 536-
 537-	for _, e := range entries {
 538-		infp := filepath.Join(dir, e.Name())
 539-		if e.IsDir() {
 540-			continue
 541-		}
 542-
 543-		w, err := staticFS.ReadFile(infp)
 544-		bail(err)
 545-		fp := filepath.Join(c.Outdir, e.Name())
 546-		c.Logger.Info("writing", "filepath", fp)
 547-		err = os.WriteFile(fp, w, 0644)
 548-		bail(err)
 549-	}
 550-
 551-	return nil
 552-}
 553-
 554-func (c *Config) writeRootSummary(data *PageData, readme template.HTML, lastCommit *CommitData) {
 555-	c.Logger.Info("writing root html", "repoPath", c.RepoPath)
 556-	c.writeHtml(&WriteData{
 557-		Filename: "index.html",
 558-		Template: "html/summary.page.tmpl",
 559-		Data: &SummaryPageData{
 560-			PageData:   data,
 561-			Readme:     readme,
 562-			LastCommit: lastCommit,
 563-		},
 564-	})
 565-}
 566-
 567-func (c *Config) writeTree(data *PageData, tree *TreeRoot) {
 568-	c.Logger.Info("writing tree", "treePath", tree.Path)
 569-	c.writeHtml(&WriteData{
 570-		Filename: "index.html",
 571-		Subdir:   tree.Path,
 572-		Template: "html/tree.page.tmpl",
 573-		Data: &TreePageData{
 574-			PageData: data,
 575-			Tree:     tree,
 576-		},
 577-	})
 578-}
 579-
 580-func (c *Config) writeLog(data *PageData, logs []*CommitData) {
 581-	c.Logger.Info("writing log file", "revision", data.RevData.Name())
 582-	c.writeHtml(&WriteData{
 583-		Filename: "index.html",
 584-		Subdir:   getLogBaseDir(data.RevData),
 585-		Template: "html/log.page.tmpl",
 586-		Data: &LogPageData{
 587-			PageData:   data,
 588-			NumCommits: len(logs),
 589-			Logs:       logs,
 590-		},
 591-	})
 592-}
 593-
 594-func (c *Config) writeRefs(data *PageData, refs []*RefInfo) {
 595-	c.Logger.Info("writing refs", "repoPath", c.RepoPath)
 596-	c.writeHtml(&WriteData{
 597-		Filename: "refs.html",
 598-		Template: "html/refs.page.tmpl",
 599-		Data: &RefPageData{
 600-			PageData: data,
 601-			Refs:     refs,
 602-		},
 603-	})
 604-}
 605-
 606-func (c *Config) writeHTMLTreeFile(pageData *PageData, treeItem *TreeItem) string {
 607-	readme := ""
 608-	b, err := treeItem.Entry.Blob().Bytes()
 609-	bail(err)
 610-	str := string(b)
 611-
 612-	treeItem.IsTextFile = isTextFile(str)
 613-
 614-	contents := "binary file, cannot display"
 615-	if treeItem.IsTextFile {
 616-		treeItem.NumLines = len(strings.Split(str, "\n"))
 617-		// Use markdown rendering for .md files, syntax highlighting for others
 618-		if isMarkdownFile(treeItem.Entry.Name()) {
 619-			contents = string(c.renderMarkdown(str))
 620-		} else {
 621-			contents, err = c.parseText(treeItem.Entry.Name(), string(b))
 622-			bail(err)
 623-		}
 624-	}
 625-
 626-	d := filepath.Dir(treeItem.Path)
 627-
 628-	nameLower := strings.ToLower(treeItem.Entry.Name())
 629-	summary := readmeFile(pageData.Repo)
 630-	if d == "." && nameLower == summary {
 631-		// Capture raw markdown content for summary page
 632-		readme = str
 633-	}
 634-
 635-	c.writeHtml(&WriteData{
 636-		Filename: fmt.Sprintf("%s.html", treeItem.Entry.Name()),
 637-		Template: "html/file.page.tmpl",
 638-		Data: &FilePageData{
 639-			PageData: pageData,
 640-			Contents: template.HTML(contents),
 641-			Item:     treeItem,
 642-		},
 643-		Subdir: getFileDir(pageData.RevData, d),
 644-	})
 645-	return readme
 646-}
 647-
 648-func (c *Config) writeLogDiff(repo *git.Repository, pageData *PageData, commit *CommitData) {
 649-	commitID := commit.ID.String()
 650-
 651-	c.Mutex.RLock()
 652-	hasCommit := c.Cache[commitID]
 653-	c.Mutex.RUnlock()
 654-
 655-	if hasCommit {
 656-		c.Logger.Info("commit file already generated, skipping", "commitID", getShortID(commitID))
 657-		return
 658-	} else {
 659-		c.Mutex.Lock()
 660-		c.Cache[commitID] = true
 661-		c.Mutex.Unlock()
 662-	}
 663-
 664-	diff, err := repo.Diff(commitID, 0, 0, 0, git.DiffOptions{})
 665-	bail(err)
 666-
 667-	rnd := &DiffRender{
 668-		NumFiles:       diff.NumFiles(),
 669-		TotalAdditions: diff.TotalAdditions(),
 670-		TotalDeletions: diff.TotalDeletions(),
 671-	}
 672-	fls := []*DiffRenderFile{}
 673-	for _, file := range diff.Files {
 674-		fl := &DiffRenderFile{
 675-			FileType:     diffFileType(file.Type),
 676-			OldMode:      file.OldMode(),
 677-			OldName:      file.OldName(),
 678-			Mode:         file.Mode(),
 679-			Name:         file.Name,
 680-			NumAdditions: file.NumAdditions(),
 681-			NumDeletions: file.NumDeletions(),
 682-		}
 683-		content := ""
 684-		for _, section := range file.Sections {
 685-			for _, line := range section.Lines {
 686-				content += fmt.Sprintf("%s\n", line.Content)
 687-			}
 688-		}
 689-		// set filename to something our `ParseText` recognizes (e.g. `.diff`)
 690-		finContent, err := c.parseText("commit.diff", content)
 691-		bail(err)
 692-
 693-		fl.Content = template.HTML(finContent)
 694-		fls = append(fls, fl)
 695-	}
 696-	rnd.Files = fls
 697-
 698-	commitData := &CommitPageData{
 699-		PageData:  pageData,
 700-		Commit:    commit,
 701-		CommitID:  getShortID(commitID),
 702-		Diff:      rnd,
 703-		Parent:    getShortID(commit.ParentID),
 704-		CommitURL: c.getCommitURL(commitID),
 705-		ParentURL: c.getCommitURL(commit.ParentID),
 706-	}
 707-
 708-	c.writeHtml(&WriteData{
 709-		Filename: fmt.Sprintf("%s.html", commitID),
 710-		Template: "html/commit.page.tmpl",
 711-		Subdir:   "commits",
 712-		Data:     commitData,
 713-	})
 714-}
 715-
 716-func (c *Config) getSummaryURL() template.URL {
 717-	url := c.RootRelative + "index.html"
 718-	return template.URL(url)
 719-}
 720-
 721-func (c *Config) getRefsURL() template.URL {
 722-	url := c.RootRelative + "refs.html"
 723-	return template.URL(url)
 724-}
 725-
 726-// controls the url for trees and logs
 727-// - /logs/getRevIDForURL()/index.html
 728-// - /tree/getRevIDForURL()/item/file.x.html.
 729-func getRevIDForURL(info RevInfo) string {
 730-	return info.Name()
 731-}
 732-
 733-func getTreeBaseDir(info RevInfo) string {
 734-	subdir := getRevIDForURL(info)
 735-	return filepath.Join("/", "tree", subdir)
 736-}
 737-
 738-func getLogBaseDir(info RevInfo) string {
 739-	subdir := getRevIDForURL(info)
 740-	return filepath.Join("/", "logs", subdir)
 741-}
 742-
 743-func getFileBaseDir(info RevInfo) string {
 744-	return filepath.Join(getTreeBaseDir(info), "item")
 745-}
 746-
 747-func getFileDir(info RevInfo, fname string) string {
 748-	return filepath.Join(getFileBaseDir(info), fname)
 749-}
 750-
 751-func (c *Config) getFileURL(info RevInfo, fname string) template.URL {
 752-	return c.compileURL(getFileBaseDir(info), fname)
 753-}
 754-
 755-func (c *Config) compileURL(dir, fname string) template.URL {
 756-	purl := c.RootRelative + strings.TrimPrefix(dir, "/")
 757-	url := filepath.Join(purl, fname)
 758-	return template.URL(url)
 759-}
 760-
 761-func (c *Config) getTreeURL(info RevInfo) template.URL {
 762-	dir := getTreeBaseDir(info)
 763-	return c.compileURL(dir, "index.html")
 764-}
 765-
 766-func (c *Config) getLogsURL(info RevInfo) template.URL {
 767-	dir := getLogBaseDir(info)
 768-	return c.compileURL(dir, "index.html")
 769-}
 770-
 771-func (c *Config) getCommitURL(commitID string) template.URL {
 772-	url := fmt.Sprintf("%scommits/%s.html", c.RootRelative, commitID)
 773-	return template.URL(url)
 774-}
 775-
 776-// parseCommitMessage extracts git trailer lines from a commit message and returns
 777-// the trailers and the message body without summary line and trailers.
 778-// Trailers are lines at the end of the message in "Key: value" format.
 779-func parseCommitMessage(message string) ([]Trailer, string) {
 780-	var trailers []Trailer
 781-
 782-	// Trailer pattern: key with alphanumeric/hyphens, colon, space, value
 783-	// Examples: "Signed-off-by: John Doe", "Co-authored-by: Jane Smith"
 784-	trailerRe := regexp.MustCompile(`^([A-Za-z0-9-]+): (.+)$`)
 785-
 786-	// Trim trailing newlines to avoid empty last line
 787-	message = strings.TrimRight(message, "\n")
 788-	lines := strings.Split(message, "\n")
 789-
 790-	// Find where trailers start (index of first trailer line)
 791-	trailerStartIdx := -1
 792-
 793-	// Collect trailer lines from the end of the message
 794-	for i := len(lines) - 1; i >= 0; i-- {
 795-		line := strings.TrimSpace(lines[i])
 796-
 797-		// Stop at empty line (separator between message body and trailers)
 798-		if line == "" {
 799-			trailerStartIdx = i + 1
 800-			break
 801-		}
 802-
 803-		matches := trailerRe.FindStringSubmatch(line)
 804-		if matches != nil {
 805-			trailers = append([]Trailer{
 806-				{Key: matches[1], Value: matches[2]},
 807-			}, trailers...)
 808-		} else {
 809-			// Not a trailer line, stop collecting
 810-			trailerStartIdx = i + 1
 811-			break
 812-		}
 813-	}
 814-
 815-	// If no trailers found, treat entire message as body
 816-	bodyLines := lines
 817-	if len(trailers) > 0 && trailerStartIdx > 0 {
 818-		// trailerStartIdx is the first line of trailers, so body is everything before it
 819-		bodyLines = lines[:trailerStartIdx]
 820-	}
 821-
 822-	// Remove trailing empty lines from body
 823-	bodyEndIdx := len(bodyLines)
 824-	for i := len(bodyLines) - 1; i >= 0; i-- {
 825-		if strings.TrimSpace(bodyLines[i]) != "" {
 826-			bodyEndIdx = i + 1
 827-			break
 828-		}
 829-	}
 830-	bodyLines = bodyLines[:bodyEndIdx]
 831-
 832-	// Extract body without summary (everything after first line)
 833-	messageBodyOnly := ""
 834-	if len(bodyLines) > 1 {
 835-		bodyOnlyLines := bodyLines[1:]
 836-		// Remove leading empty lines
 837-		startIdx := 0
 838-		for i, line := range bodyOnlyLines {
 839-			if strings.TrimSpace(line) != "" {
 840-				startIdx = i
 841-				break
 842-			}
 843-		}
 844-		if startIdx < len(bodyOnlyLines) {
 845-			messageBodyOnly = strings.Join(bodyOnlyLines[startIdx:], "\n")
 846-		}
 847-	}
 848-
 849-	return trailers, messageBodyOnly
 850-}
 851-
 852-func (c *Config) getURLs() *SiteURLs {
 853-	return &SiteURLs{
 854-		HomeURL:    c.HomeURL,
 855-		CloneURL:   c.CloneURL,
 856-		RefsURL:    c.getRefsURL(),
 857-		SummaryURL: c.getSummaryURL(),
 858-		IssuesURL:  c.getIssuesURL(),
 859-	}
 860-}
 861-
 862-func (c *Config) getIssuesURL() template.URL {
 863-	url := c.RootRelative + "issues/open/index.html"
 864-	return template.URL(url)
 865-}
 866-
 867-func getShortID(id string) string {
 868-	return id[:7]
 869-}
 870-
 871-func (c *Config) writeRepo() *BranchOutput {
 872-	c.Logger.Info("writing repo", "repoPath", c.RepoPath)
 873-	repo, err := git.Open(c.RepoPath)
 874-	bail(err)
 875-
 876-	refs, err := repo.ShowRef(git.ShowRefOptions{Heads: true, Tags: true})
 877-	bail(err)
 878-
 879-	var first *RevData
 880-	revs := []*RevData{}
 881-	for _, revStr := range c.Revs {
 882-		fullRevID, err := repo.RevParse(revStr)
 883-		bail(err)
 884-
 885-		revID := getShortID(fullRevID)
 886-		revName := revID
 887-		// if it's a reference then label it as such
 888-		for _, ref := range refs {
 889-			if revStr == git.RefShortName(ref.Refspec) || revStr == ref.Refspec {
 890-				revName = revStr
 891-				break
 892-			}
 893-		}
 894-
 895-		data := &RevData{
 896-			id:     fullRevID,
 897-			name:   revName,
 898-			Config: c,
 899-		}
 900-
 901-		if first == nil {
 902-			first = data
 903-		}
 904-		revs = append(revs, data)
 905-	}
 906-
 907-	if first == nil {
 908-		bail(fmt.Errorf("could find find a git reference that matches criteria"))
 909-	}
 910-
 911-	refInfoMap := map[string]*RefInfo{}
 912-	for _, revData := range revs {
 913-		refInfoMap[revData.Name()] = &RefInfo{
 914-			ID:      revData.ID(),
 915-			Refspec: revData.Name(),
 916-			URL:     revData.TreeURL(),
 917-		}
 918-	}
 919-
 920-	// loop through ALL refs that don't have URLs
 921-	// and add them to the map
 922-	for _, ref := range refs {
 923-		refspec := git.RefShortName(ref.Refspec)
 924-		if refInfoMap[refspec] != nil {
 925-			continue
 926-		}
 927-
 928-		refInfoMap[refspec] = &RefInfo{
 929-			ID:      ref.ID,
 930-			Refspec: refspec,
 931-		}
 932-	}
 933-
 934-	// gather lists of refs to display on refs.html page
 935-	refInfoList := []*RefInfo{}
 936-	for _, val := range refInfoMap {
 937-		refInfoList = append(refInfoList, val)
 938-	}
 939-	sort.Slice(refInfoList, func(i, j int) bool {
 940-		urlI := refInfoList[i].URL
 941-		urlJ := refInfoList[j].URL
 942-		refI := refInfoList[i].Refspec
 943-		refJ := refInfoList[j].Refspec
 944-		if urlI == urlJ {
 945-			return refI < refJ
 946-		}
 947-		return urlI > urlJ
 948-	})
 949-
 950-	// we assume the first revision in the list is the "main" revision which mostly
 951-	// means that's the README we use for the default summary page.
 952-	mainOutput := &BranchOutput{}
 953-	var wg sync.WaitGroup
 954-	for i, revData := range revs {
 955-		c.Logger.Info("writing revision", "revision", revData.Name())
 956-		data := &PageData{
 957-			Repo:     c,
 958-			RevData:  revData,
 959-			SiteURLs: c.getURLs(),
 960-			Refs:     refInfoList,
 961-		}
 962-
 963-		if i == 0 {
 964-			branchOutput := c.writeRevision(repo, data, refInfoList)
 965-			mainOutput = branchOutput
 966-		} else {
 967-			wg.Add(1)
 968-			go func() {
 969-				defer wg.Done()
 970-				c.writeRevision(repo, data, refInfoList)
 971-			}()
 972-		}
 973-	}
 974-	wg.Wait()
 975-
 976-	// use the first revision in our list to generate
 977-	// the root summary, logs, and tree the user can click
 978-	revData := &RevData{
 979-		id:     first.ID(),
 980-		name:   first.Name(),
 981-		Config: c,
 982-	}
 983-
 984-	data := &PageData{
 985-		RevData:  revData,
 986-		Repo:     c,
 987-		SiteURLs: c.getURLs(),
 988-		Refs:     refInfoList,
 989-	}
 990-
 991-	// Generate issue pages if enabled
 992-	if c.Issues {
 993-		err := c.writeIssues(data)
 994-		if err != nil {
 995-			c.Logger.Warn("failed to write issues", "error", err)
 996-		}
 997-	}
 998-
 999-	c.writeRefs(data, refInfoList)
1000-	// Convert README markdown to HTML for summary page
1001-	var readmeHTML template.HTML
1002-	if isMarkdownFile(readmeFile(c)) {
1003-		readmeHTML = c.renderMarkdown(mainOutput.Readme)
1004-	} else {
1005-		readmeHTML = template.HTML(mainOutput.Readme)
1006-	}
1007-	// Wrap README in a div with class for CSS styling
1008-	readmeHTML = template.HTML(`<div class="readme">` + string(readmeHTML) + `</div>`)
1009-	c.writeRootSummary(data, readmeHTML, mainOutput.LastCommit)
1010-	return mainOutput
1011-}
1012-
1013-type TreeRoot struct {
1014-	Path   string
1015-	Items  []*TreeItem
1016-	Crumbs []*Breadcrumb
1017-}
1018-
1019-type TreeWalker struct {
1020-	treeItem           chan *TreeItem
1021-	tree               chan *TreeRoot
1022-	HideTreeLastCommit bool
1023-	PageData           *PageData
1024-	Repo               *git.Repository
1025-	Config             *Config
1026-}
1027-
1028-type Breadcrumb struct {
1029-	Text   string
1030-	URL    template.URL
1031-	IsLast bool
1032-}
1033-
1034-func (tw *TreeWalker) calcBreadcrumbs(curpath string) []*Breadcrumb {
1035-	if curpath == "" {
1036-		return []*Breadcrumb{}
1037-	}
1038-	parts := strings.Split(curpath, string(os.PathSeparator))
1039-	rootURL := tw.Config.compileURL(
1040-		getTreeBaseDir(tw.PageData.RevData),
1041-		"index.html",
1042-	)
1043-
1044-	crumbs := make([]*Breadcrumb, len(parts)+1)
1045-	crumbs[0] = &Breadcrumb{
1046-		URL:  rootURL,
1047-		Text: tw.PageData.Repo.RepoName,
1048-	}
1049-
1050-	cur := ""
1051-	for idx, d := range parts {
1052-		crumb := filepath.Join(getFileBaseDir(tw.PageData.RevData), cur, d)
1053-		crumbUrl := tw.Config.compileURL(crumb, "index.html")
1054-		crumbs[idx+1] = &Breadcrumb{
1055-			Text: d,
1056-			URL:  crumbUrl,
1057-		}
1058-		if idx == len(parts)-1 {
1059-			crumbs[idx+1].IsLast = true
1060-		}
1061-		cur = filepath.Join(cur, d)
1062-	}
1063-
1064-	return crumbs
1065-}
1066-
1067-func filenameToDevIcon(filename string) string {
1068-	ext := filepath.Ext(filename)
1069-	extMappr := map[string]string{
1070-		".html": "html5",
1071-		".go":   "go",
1072-		".py":   "python",
1073-		".css":  "css3",
1074-		".js":   "javascript",
1075-		".md":   "markdown",
1076-		".ts":   "typescript",
1077-		".tsx":  "react",
1078-		".jsx":  "react",
1079-	}
1080-
1081-	nameMappr := map[string]string{
1082-		"Makefile":   "cmake",
1083-		"Dockerfile": "docker",
1084-	}
1085-
1086-	icon := extMappr[ext]
1087-	if icon == "" {
1088-		icon = nameMappr[filename]
1089-	}
1090-
1091-	return fmt.Sprintf("devicon-%s-original", icon)
1092-}
1093-
1094-func (tw *TreeWalker) NewTreeItem(entry *git.TreeEntry, curpath string, crumbs []*Breadcrumb) *TreeItem {
1095-	typ := entry.Type()
1096-	fname := filepath.Join(curpath, entry.Name())
1097-	item := &TreeItem{
1098-		Size:   toPretty(entry.Size()),
1099-		Name:   entry.Name(),
1100-		Path:   fname,
1101-		Entry:  entry,
1102-		URL:    tw.Config.getFileURL(tw.PageData.RevData, fname),
1103-		Crumbs: crumbs,
1104-		Author: &git.Signature{
1105-			Name: "unknown",
1106-		},
1107-	}
1108-
1109-	// `git rev-list` is pretty expensive here, so we have a flag to disable
1110-	if tw.HideTreeLastCommit {
1111-		// c.Logger.Info("skipping the process of finding the last commit for each file")
1112-	} else {
1113-		id := tw.PageData.RevData.ID()
1114-		lastCommits, err := tw.Repo.RevList([]string{id}, git.RevListOptions{
1115-			Path:           item.Path,
1116-			CommandOptions: git.CommandOptions{Args: []string{"-1"}},
1117-		})
1118-		bail(err)
1119-
1120-		if len(lastCommits) > 0 {
1121-			lc := lastCommits[0]
1122-			item.CommitURL = tw.Config.getCommitURL(lc.ID.String())
1123-			item.CommitID = getShortID(lc.ID.String())
1124-			item.Summary = lc.Summary()
1125-			item.When = lc.Author.When.Format(time.DateOnly)
1126-			item.WhenISO = lc.Author.When.UTC().Format(time.RFC3339)
1127-			item.WhenDisplay = formatDateForDisplay(lc.Author.When)
1128-			item.Author = lc.Author
1129-		}
1130-	}
1131-
1132-	fpath := tw.Config.getFileURL(tw.PageData.RevData, fmt.Sprintf("%s.html", fname))
1133-	switch typ {
1134-	case git.ObjectTree:
1135-		item.IsDir = true
1136-		fpath = tw.Config.compileURL(
1137-			filepath.Join(
1138-				getFileBaseDir(tw.PageData.RevData),
1139-				curpath,
1140-				entry.Name(),
1141-			),
1142-			"index.html",
1143-		)
1144-	case git.ObjectBlob:
1145-		item.Icon = filenameToDevIcon(item.Name)
1146-	}
1147-	item.URL = fpath
1148-
1149-	return item
1150-}
1151-
1152-func (tw *TreeWalker) walk(tree *git.Tree, curpath string) {
1153-	entries, err := tree.Entries()
1154-	bail(err)
1155-
1156-	crumbs := tw.calcBreadcrumbs(curpath)
1157-	treeEntries := []*TreeItem{}
1158-	for _, entry := range entries {
1159-		typ := entry.Type()
1160-		item := tw.NewTreeItem(entry, curpath, crumbs)
1161-
1162-		switch typ {
1163-		case git.ObjectTree:
1164-			item.IsDir = true
1165-			re, _ := tree.Subtree(entry.Name())
1166-			tw.walk(re, item.Path)
1167-			treeEntries = append(treeEntries, item)
1168-			tw.treeItem <- item
1169-		case git.ObjectBlob:
1170-			treeEntries = append(treeEntries, item)
1171-			tw.treeItem <- item
1172-		}
1173-	}
1174-
1175-	sort.Slice(treeEntries, func(i, j int) bool {
1176-		nameI := treeEntries[i].Name
1177-		nameJ := treeEntries[j].Name
1178-		if treeEntries[i].IsDir && treeEntries[j].IsDir {
1179-			return nameI < nameJ
1180-		}
1181-
1182-		if treeEntries[i].IsDir && !treeEntries[j].IsDir {
1183-			return true
1184-		}
1185-
1186-		if !treeEntries[i].IsDir && treeEntries[j].IsDir {
1187-			return false
1188-		}
1189-
1190-		return nameI < nameJ
1191-	})
1192-
1193-	fpath := filepath.Join(
1194-		getFileBaseDir(tw.PageData.RevData),
1195-		curpath,
1196-	)
1197-	// root gets a special spot outside of `item` subdir
1198-	if curpath == "" {
1199-		fpath = getTreeBaseDir(tw.PageData.RevData)
1200-	}
1201-
1202-	tw.tree <- &TreeRoot{
1203-		Path:   fpath,
1204-		Items:  treeEntries,
1205-		Crumbs: crumbs,
1206-	}
1207-
1208-	if curpath == "" {
1209-		close(tw.tree)
1210-		close(tw.treeItem)
1211-	}
1212-}
1213-
1214-func (c *Config) writeRevision(repo *git.Repository, pageData *PageData, refs []*RefInfo) *BranchOutput {
1215-	c.Logger.Info(
1216-		"compiling revision",
1217-		"repoName", c.RepoName,
1218-		"revision", pageData.RevData.Name(),
1219-	)
1220-
1221-	output := &BranchOutput{}
1222-
1223-	var wg sync.WaitGroup
1224-
1225-	wg.Add(1)
1226-	go func() {
1227-		defer wg.Done()
1228-
1229-		pageSize := pageData.Repo.MaxCommits
1230-		if pageSize == 0 {
1231-			pageSize = 5000
1232-		}
1233-		commits, err := repo.CommitsByPage(pageData.RevData.ID(), 0, pageSize)
1234-		bail(err)
1235-
1236-		logs := []*CommitData{}
1237-		for i, commit := range commits {
1238-			tags := []*RefInfo{}
1239-			for _, ref := range refs {
1240-				if commit.ID.String() == ref.ID {
1241-					tags = append(tags, ref)
1242-				}
1243-			}
1244-
1245-			parentSha, _ := commit.ParentID(0)
1246-			parentID := ""
1247-			if parentSha == nil {
1248-				parentID = commit.ID.String()
1249-			} else {
1250-				parentID = parentSha.String()
1251-			}
1252-			trailers, messageBodyOnly := parseCommitMessage(commit.Message)
1253-			cd := &CommitData{
1254-				ParentID:        parentID,
1255-				URL:             c.getCommitURL(commit.ID.String()),
1256-				ShortID:         getShortID(commit.ID.String()),
1257-				SummaryStr:      commit.Summary(),
1258-				AuthorStr:       commit.Author.Name,
1259-				WhenStr:         commit.Author.When.Format(time.DateOnly),
1260-				WhenISO:         commit.Author.When.UTC().Format(time.RFC3339),
1261-				WhenDisplay:     formatDateForDisplay(commit.Author.When),
1262-				Commit:          commit,
1263-				Refs:            tags,
1264-				Trailers:        trailers,
1265-				MessageBodyOnly: messageBodyOnly,
1266-			}
1267-			logs = append(logs, cd)
1268-			if i == 0 {
1269-				output.LastCommit = cd
1270-			}
1271-		}
1272-
1273-		c.writeLog(pageData, logs)
1274-
1275-		for _, cm := range logs {
1276-			wg.Add(1)
1277-			go func(commit *CommitData) {
1278-				defer wg.Done()
1279-				c.writeLogDiff(repo, pageData, commit)
1280-			}(cm)
1281-		}
1282-	}()
1283-
1284-	tree, err := repo.LsTree(pageData.RevData.ID())
1285-	bail(err)
1286-
1287-	readme := ""
1288-	entries := make(chan *TreeItem)
1289-	subtrees := make(chan *TreeRoot)
1290-	tw := &TreeWalker{
1291-		Config:   c,
1292-		PageData: pageData,
1293-		Repo:     repo,
1294-		treeItem: entries,
1295-		tree:     subtrees,
1296-	}
1297-	wg.Add(1)
1298-	go func() {
1299-		defer wg.Done()
1300-		tw.walk(tree, "")
1301-	}()
1302-
1303-	wg.Add(1)
1304-	go func() {
1305-		defer wg.Done()
1306-		for e := range entries {
1307-			wg.Add(1)
1308-			go func(entry *TreeItem) {
1309-				defer wg.Done()
1310-				if entry.IsDir {
1311-					return
1312-				}
1313-
1314-				readmeStr := c.writeHTMLTreeFile(pageData, entry)
1315-				if readmeStr != "" {
1316-					readme = readmeStr
1317-				}
1318-			}(e)
1319-		}
1320-	}()
1321-
1322-	wg.Add(1)
1323-	go func() {
1324-		defer wg.Done()
1325-		for t := range subtrees {
1326-			wg.Add(1)
1327-			go func(tree *TreeRoot) {
1328-				defer wg.Done()
1329-				c.writeTree(pageData, tree)
1330-			}(t)
1331-		}
1332-	}()
1333-
1334-	wg.Wait()
1335-
1336-	c.Logger.Info(
1337-		"compilation complete",
1338-		"repoName", c.RepoName,
1339-		"revision", pageData.RevData.Name(),
1340-	)
1341-
1342-	output.Readme = readme
1343-	return output
1344-}
1345-
1346-func style(theme chroma.Style) string {
1347-	bg := theme.Get(chroma.Background)
1348-	txt := theme.Get(chroma.Text)
1349-	kw := theme.Get(chroma.Keyword)
1350-	nv := theme.Get(chroma.NameVariable)
1351-	cm := theme.Get(chroma.Comment)
1352-	return fmt.Sprintf(`:root {
1353-  --bg-color: %s;
1354-  --text-color: %s;
1355-  --border: %s;
1356-  --link-color: %s;
1357-  --hover: %s;
1358-  --visited: %s;
1359-}`,
1360-		bg.Background.String(),
1361-		txt.Colour.String(),
1362-		cm.Colour.String(),
1363-		nv.Colour.String(),
1364-		kw.Colour.String(),
1365-		nv.Colour.String(),
1366-	)
1367-}
1368-
1369-// bundleCSS concatenates, minifies, and hashes all CSS files
1370-// Returns the filename of the bundled CSS (e.g., "styles.a3f7b2c1.css")
1371-func (c *Config) bundleCSS() (string, error) {
1372-	c.Logger.Info("bundling CSS files")
1373-
1374-	// Initialize minifier
1375-	m := minify.New()
1376-	m.AddFunc("text/css", css.Minify)
1377-
1378-	var buf bytes.Buffer
1379-
1380-	// 1. Read pgit.css from embedded static FS
1381-	pgitCSS, err := staticFS.ReadFile("static/pgit.css")
1382-	if err != nil {
1383-		return "", fmt.Errorf("failed to read pgit.css: %w", err)
1384-	}
1385-	buf.Write(pgitCSS)
1386-	buf.WriteString("\n")
1387-
1388-	// 2. Generate vars.css content
1389-	varsCSS := style(*c.Theme)
1390-	buf.WriteString(varsCSS)
1391-	buf.WriteString("\n")
1392-
1393-	// 3. Generate syntax.css content
1394-	var syntaxBuf bytes.Buffer
1395-	err = c.Formatter.WriteCSS(&syntaxBuf, c.Theme)
1396-	if err != nil {
1397-		return "", fmt.Errorf("failed to generate syntax.css: %w", err)
1398-	}
1399-	buf.Write(syntaxBuf.Bytes())
1400-
1401-	// 4. Minify the concatenated CSS
1402-	minified, err := m.Bytes("text/css", buf.Bytes())
1403-	if err != nil {
1404-		return "", fmt.Errorf("failed to minify CSS: %w", err)
1405-	}
1406-
1407-	// 5. Generate content hash (first 8 chars of SHA256)
1408-	hash := sha256.Sum256(minified)
1409-	hashStr := hex.EncodeToString(hash[:8])
1410-
1411-	// 6. Create filename with hash
1412-	filename := fmt.Sprintf("styles.%s.css", hashStr)
1413-
1414-	// 7. Write to output directory
1415-	outPath := filepath.Join(c.Outdir, filename)
1416-	err = os.WriteFile(outPath, minified, 0644)
1417-	if err != nil {
1418-		return "", fmt.Errorf("failed to write CSS bundle: %w", err)
1419-	}
1420-
1421-	c.Logger.Info("CSS bundle created", "filename", filename, "size", len(minified))
1422-
1423-	return filename, nil
1424-}
1425-
1426-func main() {
1427-	var outdir = flag.String("out", "./public", "output directory")
1428-	var rpath = flag.String("repo", ".", "path to git repo")
1429-	var revsFlag = flag.String("revs", "HEAD", "list of revs to generate logs and tree (e.g. main,v1,c69f86f,HEAD)")
1430-	var themeFlag = flag.String("theme", "dracula", "theme to use for site")
1431-	var labelFlag = flag.String("label", "", "pretty name for the subdir where we create the repo, default is last folder in --repo")
1432-	var cloneFlag = flag.String("clone-url", "", "git clone URL for upstream")
1433-	var homeFlag = flag.String("home-url", "", "URL for breadcumbs to go to root page, hidden if empty")
1434-	var descFlag = flag.String("desc", "", "description for repo")
1435-	var rootRelativeFlag = flag.String("root-relative", "/", "html root relative")
1436-	var maxCommitsFlag = flag.Int("max-commits", 0, "maximum number of commits to generate")
1437-	var hideTreeLastCommitFlag = flag.Bool("hide-tree-last-commit", false, "dont calculate last commit for each file in the tree")
1438-	var issuesFlag = flag.Bool("issues", false, "enable git-bug issue generation")
1439-
1440-	// Pre-parse for hook commands and their --repo flag
1441-	// This is needed because flag.Parse() stops at non-flag args
1442-	repoPathForHook := "."
1443-	for i, arg := range os.Args[1:] {
1444-		if arg == "install-hook" || arg == "uninstall-hook" {
1445-			// Look for --repo flag in remaining args (after the command)
1446-			// i is the index in os.Args[1:], so actual index in os.Args is i+1
1447-			// We need to check os.Args[i+2:] for flags after the command
1448-			for j := i + 2; j < len(os.Args); j++ {
1449-				if strings.HasPrefix(os.Args[j], "--repo=") {
1450-					repoPathForHook = strings.TrimPrefix(os.Args[j], "--repo=")
1451-					break
1452-				}
1453-			}
1454-			// Execute hook command
1455-			if arg == "install-hook" {
1456-				installHook(repoPathForHook)
1457-			} else {
1458-				uninstallHook(repoPathForHook)
1459-			}
1460-			return
1461-		}
1462-	}
1463-
1464-	flag.Parse()
1465-
1466-	out, err := filepath.Abs(*outdir)
1467-	bail(err)
1468-	repoPath, err := filepath.Abs(*rpath)
1469-	bail(err)
1470-
1471-	theme := styles.Get(*themeFlag)
1472-
1473-	logger := slog.Default()
1474-
1475-	label := repoName(repoPath)
1476-	if *labelFlag != "" {
1477-		label = *labelFlag
1478-	}
1479-
1480-	revs := strings.Split(*revsFlag, ",")
1481-	if len(revs) == 1 && revs[0] == "" {
1482-		revs = []string{}
1483-	}
1484-
1485-	formatter := formatterHtml.New(
1486-		formatterHtml.WithLineNumbers(true),
1487-		formatterHtml.WithLinkableLineNumbers(true, ""),
1488-		formatterHtml.WithClasses(true),
1489-	)
1490-
1491-	// Create a temporary config to use for rendering markdown
1492-	tempConfig := &Config{
1493-		Theme:    theme,
1494-		Logger:   logger,
1495-		RepoPath: repoPath,
1496-	}
1497-
1498-	// Determine description: --desc flag overrides git description
1499-	var descHTML template.HTML
1500-	if *descFlag != "" {
1501-		// Use --desc flag value, process as markdown
1502-		descHTML = tempConfig.renderMarkdown(*descFlag)
1503-	} else {
1504-		// Try to read from git description file
1505-		gitDesc := readDescription(repoPath)
1506-		if gitDesc != "" {
1507-			descHTML = tempConfig.renderMarkdown(gitDesc)
1508-		}
1509-	}
1510-
1511-	config := &Config{
1512-		Outdir:             out,
1513-		RepoPath:           repoPath,
1514-		RepoName:           label,
1515-		Cache:              make(map[string]bool),
1516-		Revs:               revs,
1517-		Theme:              theme,
1518-		Logger:             logger,
1519-		CloneURL:           template.URL(*cloneFlag),
1520-		HomeURL:            template.URL(*homeFlag),
1521-		Desc:               descHTML,
1522-		MaxCommits:         *maxCommitsFlag,
1523-		HideTreeLastCommit: *hideTreeLastCommitFlag,
1524-		Issues:             *issuesFlag,
1525-		RootRelative:       *rootRelativeFlag,
1526-		Formatter:          formatter,
1527-	}
1528-	config.Logger.Info("config", "config", config)
1529-
1530-	if len(revs) == 0 {
1531-		bail(fmt.Errorf("you must provide --revs"))
1532-	}
1533-
1534-	// Bundle CSS files before generating site
1535-	cssFile, err := config.bundleCSS()
1536-	bail(err)
1537-	config.CSSFile = cssFile
1538-
1539-	config.writeRepo()
1540-
1541-	url := filepath.Join("/", "index.html")
1542-	config.Logger.Info("root url", "url", url)
1543-}
1544-
1545-// installHook installs the post-commit hook into the repository
1546-func installHook(repoPath string) {
1547-	// Convert to absolute path
1548-	absRepoPath, err := filepath.Abs(repoPath)
1549-	if err != nil {
1550-		fmt.Fprintf(os.Stderr, "Error: Could not resolve repo path: %v\n", err)
1551-		os.Exit(1)
1552-	}
1553-
1554-	// Find the embedded hook script
1555-	hookContent, err := embedFS.ReadFile("hooks/post-commit.bash")
1556-	if err != nil {
1557-		fmt.Fprintf(os.Stderr, "Error: Could not read embedded hook: %v\n", err)
1558-		os.Exit(1)
1559-	}
1560-
1561-	// Determine hooks directory: .git/hooks for normal repos, hooks/ for bare repos
1562-	hooksDir := filepath.Join(absRepoPath, ".git", "hooks")
1563-	if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1564-		// Check for bare repo (hooks/ at root)
1565-		bareHooksDir := filepath.Join(repoPath, "hooks")
1566-		if _, err := os.Stat(bareHooksDir); err == nil {
1567-			hooksDir = bareHooksDir
1568-			fmt.Printf("Detected bare repository, installing to %s\n", hooksDir)
1569-		}
1570-	}
1571-
1572-	postCommitHook := filepath.Join(hooksDir, "post-commit")
1573-
1574-	// Ensure hooks directory exists
1575-	if err := os.MkdirAll(hooksDir, 0755); err != nil {
1576-		fmt.Fprintf(os.Stderr, "Error: Could not create hooks directory: %v\n", err)
1577-		os.Exit(1)
1578-	}
1579-
1580-	// Check if a post-commit hook already exists
1581-	if _, err := os.Stat(postCommitHook); err == nil {
1582-		// Backup existing hook
1583-		backupPath := postCommitHook + ".backup"
1584-		if err := os.Rename(postCommitHook, backupPath); err != nil {
1585-			fmt.Fprintf(os.Stderr, "Error: Failed to backup existing hook: %v\n", err)
1586-			os.Exit(1)
1587-		}
1588-		fmt.Printf("Backed up existing post-commit hook to %s\n", backupPath)
1589-	}
1590-
1591-	// Write the hook
1592-	if err := os.WriteFile(postCommitHook, hookContent, 0755); err != nil {
1593-		fmt.Fprintf(os.Stderr, "Error: Failed to write hook: %v\n", err)
1594-		os.Exit(1)
1595-	}
1596-
1597-	fmt.Printf("Installed post-commit hook to %s\n", postCommitHook)
1598-	fmt.Println("Commits with issue references (e.g., 'fixes #872a52d') will now automatically close git-bug issues")
1599-}
1600-
1601-// uninstallHook removes the pgit post-commit hook
1602-func uninstallHook(repoPath string) {
1603-	// Convert to absolute path
1604-	absRepoPath, err := filepath.Abs(repoPath)
1605-	if err != nil {
1606-		fmt.Fprintf(os.Stderr, "Error: Could not resolve repo path: %v\n", err)
1607-		os.Exit(1)
1608-	}
1609-
1610-	// Determine hooks directory: .git/hooks for normal repos, hooks/ for bare repos
1611-	hooksDir := filepath.Join(absRepoPath, ".git", "hooks")
1612-	if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1613-		// Check for bare repo (hooks/ at root)
1614-		bareHooksDir := filepath.Join(repoPath, "hooks")
1615-		if _, err := os.Stat(bareHooksDir); err == nil {
1616-			hooksDir = bareHooksDir
1617-		}
1618-	}
1619-
1620-	postCommitHook := filepath.Join(hooksDir, "post-commit")
1621-
1622-	// Check if hook exists
1623-	data, err := os.ReadFile(postCommitHook)
1624-	if err != nil {
1625-		if os.IsNotExist(err) {
1626-			fmt.Println("No post-commit hook found")
1627-			return
1628-		}
1629-		fmt.Fprintf(os.Stderr, "Error: Failed to read hook: %v\n", err)
1630-		os.Exit(1)
1631-	}
1632-
1633-	// Check if it's our hook
1634-	if !strings.Contains(string(data), "pgit post-commit hook") {
1635-		fmt.Fprintf(os.Stderr, "Error: post-commit hook is not a pgit hook (not removing)\n")
1636-		os.Exit(1)
1637-	}
1638-
1639-	// Remove the hook
1640-	if err := os.Remove(postCommitHook); err != nil {
1641-		fmt.Fprintf(os.Stderr, "Error: Failed to remove hook: %v\n", err)
1642-		os.Exit(1)
1643-	}
1644-
1645-	// Restore backup if exists
1646-	backupPath := postCommitHook + ".backup"
1647-	if _, err := os.Stat(backupPath); err == nil {
1648-		if err := os.Rename(backupPath, postCommitHook); err != nil {
1649-			fmt.Fprintf(os.Stderr, "Error: Failed to restore backup: %v\n", err)
1650-			os.Exit(1)
1651-		}
1652-		fmt.Printf("Restored backup hook from %s\n", backupPath)
1653-	}
1654-
1655-	fmt.Println("Uninstalled pgit post-commit hook")
1656-}
A markdown.go
+135, -0
  1@@ -0,0 +1,135 @@
  2+package pgit
  3+
  4+import (
  5+	"bytes"
  6+	"fmt"
  7+	"html/template"
  8+	"io"
  9+	"path/filepath"
 10+	"strings"
 11+
 12+	"github.com/alecthomas/chroma/v2"
 13+	"github.com/alecthomas/chroma/v2/formatters"
 14+	"github.com/alecthomas/chroma/v2/lexers"
 15+	"github.com/gomarkdown/markdown"
 16+	"github.com/gomarkdown/markdown/ast"
 17+	"github.com/gomarkdown/markdown/html"
 18+	"github.com/gomarkdown/markdown/parser"
 19+)
 20+
 21+func IsMarkdownFile(filename string) bool {
 22+	ext := strings.ToLower(filepath.Ext(filename))
 23+	return ext == ".md"
 24+}
 25+
 26+func (c *Config) ParseText(filename string, text string) (string, error) {
 27+	lexer := lexers.Match(filename)
 28+	if lexer == nil {
 29+		lexer = lexers.Analyse(text)
 30+	}
 31+	if lexer == nil {
 32+		lexer = lexers.Get("plaintext")
 33+	}
 34+	iterator, err := lexer.Tokenise(nil, text)
 35+	if err != nil {
 36+		return text, err
 37+	}
 38+	var buf bytes.Buffer
 39+	err = c.Formatter.Format(&buf, c.Theme, iterator)
 40+	if err != nil {
 41+		return text, err
 42+	}
 43+	return buf.String(), nil
 44+}
 45+
 46+type chromaMarkdownRenderer struct {
 47+	defaultRenderer *html.Renderer
 48+	theme           *chroma.Style
 49+}
 50+
 51+func (r *chromaMarkdownRenderer) RenderNode(w io.Writer, node ast.Node, entering bool) ast.WalkStatus {
 52+	if entering {
 53+		if codeBlock, ok := node.(*ast.CodeBlock); ok {
 54+			lang := extractLanguage(codeBlock.Info)
 55+			code := string(codeBlock.Literal)
 56+			highlighted, err := r.highlightCode(lang, code)
 57+			if err == nil {
 58+				w.Write([]byte(highlighted))
 59+				return ast.SkipChildren
 60+			}
 61+		}
 62+	}
 63+	return r.defaultRenderer.RenderNode(w, node, entering)
 64+}
 65+
 66+func (r *chromaMarkdownRenderer) RenderHeader(w io.Writer, ast ast.Node) {
 67+	r.defaultRenderer.RenderHeader(w, ast)
 68+}
 69+
 70+func (r *chromaMarkdownRenderer) RenderFooter(w io.Writer, ast ast.Node) {
 71+	r.defaultRenderer.RenderFooter(w, ast)
 72+}
 73+
 74+func (r *chromaMarkdownRenderer) highlightCode(lang, code string) (string, error) {
 75+	var lexer chroma.Lexer
 76+	if lang != "" {
 77+		lexer = lexers.Get(lang)
 78+	}
 79+	if lexer == nil {
 80+		lexer = lexers.Analyse(code)
 81+	}
 82+	if lexer == nil {
 83+		lexer = lexers.Get("plaintext")
 84+	}
 85+
 86+	iterator, err := lexer.Tokenise(nil, code)
 87+	if err != nil {
 88+		return "", err
 89+	}
 90+
 91+	formatter := formatters.Get("html")
 92+	if formatter == nil {
 93+		return "", fmt.Errorf("failed to get HTML formatter")
 94+	}
 95+
 96+	var buf bytes.Buffer
 97+	err = formatter.Format(&buf, r.theme, iterator)
 98+	if err != nil {
 99+		return "", err
100+	}
101+
102+	highlighted := buf.String()
103+	highlighted = strings.TrimPrefix(highlighted, "<pre class=\"chroma\">")
104+	highlighted = strings.TrimSuffix(highlighted, "</pre>")
105+
106+	return highlighted, nil
107+}
108+
109+func extractLanguage(info []byte) string {
110+	if len(info) == 0 {
111+		return ""
112+	}
113+	parts := strings.Fields(string(info))
114+	if len(parts) > 0 {
115+		return parts[0]
116+	}
117+	return ""
118+}
119+
120+func (c *Config) RenderMarkdown(mdText string) template.HTML {
121+	extensions := parser.CommonExtensions | parser.FencedCode
122+	p := parser.NewWithExtensions(extensions)
123+	doc := p.Parse([]byte(mdText))
124+
125+	htmlFlags := html.CommonFlags
126+	opts := html.RendererOptions{Flags: htmlFlags}
127+	defaultRenderer := html.NewRenderer(opts)
128+
129+	customRenderer := &chromaMarkdownRenderer{
130+		defaultRenderer: defaultRenderer,
131+		theme:           c.Theme,
132+	}
133+
134+	htmlBytes := markdown.Render(doc, customRenderer)
135+	return template.HTML(htmlBytes)
136+}
A tree.go
+210, -0
  1@@ -0,0 +1,210 @@
  2+package pgit
  3+
  4+import (
  5+	"fmt"
  6+	"html/template"
  7+	"os"
  8+	"path/filepath"
  9+	"sort"
 10+	"strings"
 11+	"time"
 12+
 13+	git "github.com/gogs/git-module"
 14+)
 15+
 16+type TreeRoot struct {
 17+	Path   string
 18+	Items  []*TreeItem
 19+	Crumbs []*Breadcrumb
 20+}
 21+
 22+type TreeWalker struct {
 23+	treeItem           chan *TreeItem
 24+	tree               chan *TreeRoot
 25+	HideTreeLastCommit bool
 26+	PageData           *PageData
 27+	Repo               *git.Repository
 28+	Config             *Config
 29+}
 30+
 31+type Breadcrumb struct {
 32+	Text   string
 33+	URL    template.URL
 34+	IsLast bool
 35+}
 36+
 37+func (tw *TreeWalker) CalcBreadcrumbs(curpath string) []*Breadcrumb {
 38+	if curpath == "" {
 39+		return []*Breadcrumb{}
 40+	}
 41+	parts := strings.Split(curpath, string(os.PathSeparator))
 42+	rootURL := tw.Config.CompileURL(
 43+		GetTreeBaseDir(*tw.PageData.RevData),
 44+		"index.html",
 45+	)
 46+
 47+	crumbs := make([]*Breadcrumb, len(parts)+1)
 48+	crumbs[0] = &Breadcrumb{
 49+		URL:  rootURL,
 50+		Text: tw.PageData.Repo.RepoName,
 51+	}
 52+
 53+	cur := ""
 54+	for idx, d := range parts {
 55+		crumb := filepath.Join(GetFileBaseDir(*tw.PageData.RevData), cur, d)
 56+		crumbUrl := tw.Config.CompileURL(crumb, "index.html")
 57+		crumbs[idx+1] = &Breadcrumb{
 58+			Text: d,
 59+			URL:  crumbUrl,
 60+		}
 61+		if idx == len(parts)-1 {
 62+			crumbs[idx+1].IsLast = true
 63+		}
 64+		cur = filepath.Join(cur, d)
 65+	}
 66+
 67+	return crumbs
 68+}
 69+
 70+func FilenameToDevIcon(filename string) string {
 71+	ext := filepath.Ext(filename)
 72+	extMapper := map[string]string{
 73+		".html": "html5",
 74+		".go":   "go",
 75+		".py":   "python",
 76+		".css":  "css3",
 77+		".js":   "javascript",
 78+		".md":   "markdown",
 79+		".ts":   "typescript",
 80+		".tsx":  "react",
 81+		".jsx":  "react",
 82+	}
 83+
 84+	nameMapper := map[string]string{
 85+		"Makefile":   "cmake",
 86+		"Dockerfile": "docker",
 87+	}
 88+
 89+	icon := extMapper[ext]
 90+	if icon == "" {
 91+		icon = nameMapper[filename]
 92+	}
 93+
 94+	return fmt.Sprintf("devicon-%s-original", icon)
 95+}
 96+
 97+func (tw *TreeWalker) NewTreeItem(entry *git.TreeEntry, curpath string, crumbs []*Breadcrumb) *TreeItem {
 98+	typ := entry.Type()
 99+	fname := filepath.Join(curpath, entry.Name())
100+	item := &TreeItem{
101+		Size:   ToPretty(entry.Size()),
102+		Name:   entry.Name(),
103+		Path:   fname,
104+		Entry:  entry,
105+		URL:    tw.Config.GetFileURL(*tw.PageData.RevData, fname),
106+		Crumbs: crumbs,
107+		Author: &git.Signature{
108+			Name: "unknown",
109+		},
110+	}
111+
112+	if !tw.HideTreeLastCommit {
113+		id := (*tw.PageData.RevData).ID()
114+		lastCommits, err := tw.Repo.RevList([]string{id}, git.RevListOptions{
115+			Path:           item.Path,
116+			CommandOptions: git.CommandOptions{Args: []string{"-1"}},
117+		})
118+		Bail(err)
119+
120+		if len(lastCommits) > 0 {
121+			lc := lastCommits[0]
122+			item.CommitURL = tw.Config.GetCommitURL(lc.ID.String())
123+			item.CommitID = GetShortID(lc.ID.String())
124+			item.Summary = lc.Summary()
125+			item.When = lc.Author.When.Format(time.DateOnly)
126+			item.WhenISO = lc.Author.When.UTC().Format(time.RFC3339)
127+			item.WhenDisplay = FormatDateForDisplay(lc.Author.When)
128+			item.Author = lc.Author
129+		}
130+	}
131+
132+	fpath := tw.Config.GetFileURL(*tw.PageData.RevData, fmt.Sprintf("%s.html", fname))
133+	switch typ {
134+	case git.ObjectTree:
135+		item.IsDir = true
136+		fpath = tw.Config.CompileURL(
137+			filepath.Join(
138+				GetFileBaseDir(*tw.PageData.RevData),
139+				curpath,
140+				entry.Name(),
141+			),
142+			"index.html",
143+		)
144+	case git.ObjectBlob:
145+		item.Icon = FilenameToDevIcon(item.Name)
146+	}
147+	item.URL = fpath
148+
149+	return item
150+}
151+
152+func (tw *TreeWalker) Walk(tree *git.Tree, curpath string) {
153+	entries, err := tree.Entries()
154+	Bail(err)
155+
156+	crumbs := tw.CalcBreadcrumbs(curpath)
157+	treeEntries := []*TreeItem{}
158+	for _, entry := range entries {
159+		typ := entry.Type()
160+		item := tw.NewTreeItem(entry, curpath, crumbs)
161+
162+		switch typ {
163+		case git.ObjectTree:
164+			item.IsDir = true
165+			re, _ := tree.Subtree(entry.Name())
166+			tw.Walk(re, item.Path)
167+			treeEntries = append(treeEntries, item)
168+			tw.treeItem <- item
169+		case git.ObjectBlob:
170+			treeEntries = append(treeEntries, item)
171+			tw.treeItem <- item
172+		}
173+	}
174+
175+	sort.Slice(treeEntries, func(i, j int) bool {
176+		nameI := treeEntries[i].Name
177+		nameJ := treeEntries[j].Name
178+		if treeEntries[i].IsDir && treeEntries[j].IsDir {
179+			return nameI < nameJ
180+		}
181+
182+		if treeEntries[i].IsDir && !treeEntries[j].IsDir {
183+			return true
184+		}
185+
186+		if !treeEntries[i].IsDir && treeEntries[j].IsDir {
187+			return false
188+		}
189+
190+		return nameI < nameJ
191+	})
192+
193+	fpath := filepath.Join(
194+		GetFileBaseDir(*tw.PageData.RevData),
195+		curpath,
196+	)
197+	if curpath == "" {
198+		fpath = GetTreeBaseDir(*tw.PageData.RevData)
199+	}
200+
201+	tw.tree <- &TreeRoot{
202+		Path:   fpath,
203+		Items:  treeEntries,
204+		Crumbs: crumbs,
205+	}
206+
207+	if curpath == "" {
208+		close(tw.tree)
209+		close(tw.treeItem)
210+	}
211+}
A urls.go
+85, -0
 1@@ -0,0 +1,85 @@
 2+package pgit
 3+
 4+import (
 5+	"fmt"
 6+	"html/template"
 7+	"path/filepath"
 8+	"strings"
 9+)
10+
11+func (c *Config) GetSummaryURL() template.URL {
12+	url := c.RootRelative + "index.html"
13+	return template.URL(url)
14+}
15+
16+func (c *Config) GetRefsURL() template.URL {
17+	url := c.RootRelative + "refs.html"
18+	return template.URL(url)
19+}
20+
21+func GetRevIDForURL(info RevInfo) string {
22+	return info.Name()
23+}
24+
25+func GetTreeBaseDir(info RevInfo) string {
26+	subdir := GetRevIDForURL(info)
27+	return filepath.Join("/", "tree", subdir)
28+}
29+
30+func GetLogBaseDir(info RevInfo) string {
31+	subdir := GetRevIDForURL(info)
32+	return filepath.Join("/", "logs", subdir)
33+}
34+
35+func GetFileBaseDir(info RevInfo) string {
36+	return filepath.Join(GetTreeBaseDir(info), "item")
37+}
38+
39+func GetFileDir(info RevInfo, fname string) string {
40+	return filepath.Join(GetFileBaseDir(info), fname)
41+}
42+
43+func (c *Config) GetFileURL(info RevInfo, fname string) template.URL {
44+	return c.CompileURL(GetFileBaseDir(info), fname)
45+}
46+
47+func (c *Config) CompileURL(dir, fname string) template.URL {
48+	purl := c.RootRelative + strings.TrimPrefix(dir, "/")
49+	url := filepath.Join(purl, fname)
50+	return template.URL(url)
51+}
52+
53+func (c *Config) GetTreeURL(info RevInfo) template.URL {
54+	dir := GetTreeBaseDir(info)
55+	return c.CompileURL(dir, "index.html")
56+}
57+
58+func (c *Config) GetLogsURL(info RevInfo) template.URL {
59+	dir := GetLogBaseDir(info)
60+	return c.CompileURL(dir, "index.html")
61+}
62+
63+func (c *Config) GetCommitURL(commitID string) template.URL {
64+	url := fmt.Sprintf("%scommits/%s.html", c.RootRelative, commitID)
65+	return template.URL(url)
66+}
67+
68+func (c *Config) GetURLs() *SiteURLs {
69+	return &SiteURLs{
70+		HomeURL:    c.HomeURL,
71+		CloneURL:   c.CloneURL,
72+		RefsURL:    c.GetRefsURL(),
73+		SummaryURL: c.GetSummaryURL(),
74+		IssuesURL:  c.GetIssuesURL(),
75+	}
76+}
77+
78+func (c *Config) GetIssuesURL() template.URL {
79+	url := c.RootRelative + "issues/open/index.html"
80+	return template.URL(url)
81+}
82+
83+func (c *Config) GetIssueURL(issueID string) template.URL {
84+	url := fmt.Sprintf("%sissues/%s.html", c.RootRelative, issueID)
85+	return template.URL(url)
86+}