repos / pgit

static site generator for git
git clone https://github.com/picosh/pgit.git

Eric Bower  ·  2025-03-23

main.go

   1package main
   2
   3import (
   4	"bytes"
   5	"embed"
   6	_ "embed"
   7	"flag"
   8	"fmt"
   9	"html/template"
  10	"log/slog"
  11	"math"
  12	"os"
  13	"path/filepath"
  14	"sort"
  15	"strings"
  16	"sync"
  17	"time"
  18	"unicode/utf8"
  19
  20	"github.com/alecthomas/chroma/v2"
  21	formatterHtml "github.com/alecthomas/chroma/v2/formatters/html"
  22	"github.com/alecthomas/chroma/v2/lexers"
  23	"github.com/alecthomas/chroma/v2/styles"
  24	"github.com/dustin/go-humanize"
  25	git "github.com/gogs/git-module"
  26)
  27
  28//go:embed html/*.tmpl static/*
  29var efs embed.FS
  30
  31type Config struct {
  32	// required params
  33	Outdir string
  34	// abs path to git repo
  35	RepoPath string
  36
  37	// optional params
  38	// generate logs anad tree based on the git revisions provided
  39	Revs []string
  40	// description of repo used in the header of site
  41	Desc string
  42	// maximum number of commits that we will process in descending order
  43	MaxCommits int
  44	// name of the readme file
  45	Readme string
  46	// In order to get the latest commit per file we do a `git rev-list {ref} {file}`
  47	// which is n+1 where n is a file in the tree.
  48	// We offer a way to disable showing the latest commit in the output
  49	// for those who want a faster build time
  50	HideTreeLastCommit bool
  51
  52	// user-defined urls
  53	HomeURL  template.URL
  54	CloneURL template.URL
  55
  56	// https://developer.mozilla.org/en-US/docs/Web/API/URL_API/Resolving_relative_references#root_relative
  57	RootRelative string
  58
  59	// computed
  60	// cache for skipping commits, trees, etc.
  61	Cache map[string]bool
  62	// mutex for Cache
  63	Mutex sync.RWMutex
  64	// pretty name for the repo
  65	RepoName string
  66	// logger
  67	Logger *slog.Logger
  68	// chroma style
  69	Theme     *chroma.Style
  70	Formatter *formatterHtml.Formatter
  71}
  72
  73type RevInfo interface {
  74	ID() string
  75	Name() string
  76}
  77
  78type RevData struct {
  79	id     string
  80	name   string
  81	Config *Config
  82}
  83
  84func (r *RevData) ID() string {
  85	return r.id
  86}
  87
  88func (r *RevData) Name() string {
  89	return r.name
  90}
  91
  92func (r *RevData) TreeURL() template.URL {
  93	return r.Config.getTreeURL(r)
  94}
  95
  96func (r *RevData) LogURL() template.URL {
  97	return r.Config.getLogsURL(r)
  98}
  99
 100type TagData struct {
 101	Name string
 102	URL  template.URL
 103}
 104
 105type CommitData struct {
 106	SummaryStr string
 107	URL        template.URL
 108	WhenStr    string
 109	AuthorStr  string
 110	ShortID    string
 111	ParentID   string
 112	Refs       []*RefInfo
 113	*git.Commit
 114}
 115
 116type TreeItem struct {
 117	IsTextFile bool
 118	IsDir      bool
 119	Size       string
 120	NumLines   int
 121	Name       string
 122	Icon       string
 123	Path       string
 124	URL        template.URL
 125	CommitID   string
 126	CommitURL  template.URL
 127	Summary    string
 128	When       string
 129	Author     *git.Signature
 130	Entry      *git.TreeEntry
 131	Crumbs     []*Breadcrumb
 132}
 133
 134type DiffRender struct {
 135	NumFiles       int
 136	TotalAdditions int
 137	TotalDeletions int
 138	Files          []*DiffRenderFile
 139}
 140
 141type DiffRenderFile struct {
 142	FileType     string
 143	OldMode      git.EntryMode
 144	OldName      string
 145	Mode         git.EntryMode
 146	Name         string
 147	Content      template.HTML
 148	NumAdditions int
 149	NumDeletions int
 150}
 151
 152type RefInfo struct {
 153	ID      string
 154	Refspec string
 155	URL     template.URL
 156}
 157
 158type BranchOutput struct {
 159	Readme     string
 160	LastCommit *git.Commit
 161}
 162
 163type SiteURLs struct {
 164	HomeURL    template.URL
 165	CloneURL   template.URL
 166	SummaryURL template.URL
 167	RefsURL    template.URL
 168}
 169
 170type PageData struct {
 171	Repo     *Config
 172	SiteURLs *SiteURLs
 173	RevData  *RevData
 174}
 175
 176type SummaryPageData struct {
 177	*PageData
 178	Readme template.HTML
 179}
 180
 181type TreePageData struct {
 182	*PageData
 183	Tree *TreeRoot
 184}
 185
 186type LogPageData struct {
 187	*PageData
 188	NumCommits int
 189	Logs       []*CommitData
 190}
 191
 192type FilePageData struct {
 193	*PageData
 194	Contents template.HTML
 195	Item     *TreeItem
 196}
 197
 198type CommitPageData struct {
 199	*PageData
 200	CommitMsg template.HTML
 201	CommitID  string
 202	Commit    *CommitData
 203	Diff      *DiffRender
 204	Parent    string
 205	ParentURL template.URL
 206	CommitURL template.URL
 207}
 208
 209type RefPageData struct {
 210	*PageData
 211	Refs []*RefInfo
 212}
 213
 214type WriteData struct {
 215	Template string
 216	Filename string
 217	Subdir   string
 218	Data     interface{}
 219}
 220
 221func bail(err error) {
 222	if err != nil {
 223		panic(err)
 224	}
 225}
 226
 227func diffFileType(_type git.DiffFileType) string {
 228	if _type == git.DiffFileAdd {
 229		return "A"
 230	} else if _type == git.DiffFileChange {
 231		return "M"
 232	} else if _type == git.DiffFileDelete {
 233		return "D"
 234	} else if _type == git.DiffFileRename {
 235		return "R"
 236	}
 237
 238	return ""
 239}
 240
 241// converts contents of files in git tree to pretty formatted code.
 242func (c *Config) parseText(filename string, text string) (string, error) {
 243	lexer := lexers.Match(filename)
 244	if lexer == nil {
 245		lexer = lexers.Analyse(text)
 246	}
 247	if lexer == nil {
 248		lexer = lexers.Get("plaintext")
 249	}
 250	iterator, err := lexer.Tokenise(nil, text)
 251	if err != nil {
 252		return text, err
 253	}
 254	var buf bytes.Buffer
 255	err = c.Formatter.Format(&buf, c.Theme, iterator)
 256	if err != nil {
 257		return text, err
 258	}
 259	return buf.String(), nil
 260}
 261
 262// isText reports whether a significant prefix of s looks like correct UTF-8;
 263// that is, if it is likely that s is human-readable text.
 264func isText(s string) bool {
 265	const max = 1024 // at least utf8.UTFMax
 266	if len(s) > max {
 267		s = s[0:max]
 268	}
 269	for i, c := range s {
 270		if i+utf8.UTFMax > len(s) {
 271			// last char may be incomplete - ignore
 272			break
 273		}
 274		if c == 0xFFFD || c < ' ' && c != '\n' && c != '\t' && c != '\f' && c != '\r' {
 275			// decoding error or control character - not a text file
 276			return false
 277		}
 278	}
 279	return true
 280}
 281
 282// isTextFile reports whether the file has a known extension indicating
 283// a text file, or if a significant chunk of the specified file looks like
 284// correct UTF-8; that is, if it is likely that the file contains human-
 285// readable text.
 286func isTextFile(text string) bool {
 287	num := math.Min(float64(len(text)), 1024)
 288	return isText(text[0:int(num)])
 289}
 290
 291func toPretty(b int64) string {
 292	return humanize.Bytes(uint64(b))
 293}
 294
 295func repoName(root string) string {
 296	_, file := filepath.Split(root)
 297	return file
 298}
 299
 300func readmeFile(repo *Config) string {
 301	if repo.Readme == "" {
 302		return "readme.md"
 303	}
 304
 305	return strings.ToLower(repo.Readme)
 306}
 307
 308func (c *Config) writeHtml(writeData *WriteData) {
 309	ts, err := template.ParseFS(
 310		efs,
 311		writeData.Template,
 312		"html/header.partial.tmpl",
 313		"html/footer.partial.tmpl",
 314		"html/base.layout.tmpl",
 315	)
 316	bail(err)
 317
 318	dir := filepath.Join(c.Outdir, writeData.Subdir)
 319	err = os.MkdirAll(dir, os.ModePerm)
 320	bail(err)
 321
 322	fp := filepath.Join(dir, writeData.Filename)
 323	c.Logger.Info("writing", "filepath", fp)
 324
 325	w, err := os.OpenFile(fp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
 326	bail(err)
 327
 328	err = ts.Execute(w, writeData.Data)
 329	bail(err)
 330}
 331
 332func (c *Config) copyStatic(dir string) error {
 333	entries, err := efs.ReadDir(dir)
 334	bail(err)
 335
 336	for _, e := range entries {
 337		infp := filepath.Join(dir, e.Name())
 338		if e.IsDir() {
 339			continue
 340		}
 341
 342		w, err := efs.ReadFile(infp)
 343		bail(err)
 344		fp := filepath.Join(c.Outdir, e.Name())
 345		c.Logger.Info("writing", "filepath", fp)
 346		err = os.WriteFile(fp, w, 0644)
 347		bail(err)
 348	}
 349
 350	return nil
 351}
 352
 353func (c *Config) writeRootSummary(data *PageData, readme template.HTML) {
 354	c.Logger.Info("writing root html", "repoPath", c.RepoPath)
 355	c.writeHtml(&WriteData{
 356		Filename: "index.html",
 357		Template: "html/summary.page.tmpl",
 358		Data: &SummaryPageData{
 359			PageData: data,
 360			Readme:   readme,
 361		},
 362	})
 363}
 364
 365func (c *Config) writeTree(data *PageData, tree *TreeRoot) {
 366	c.Logger.Info("writing tree", "treePath", tree.Path)
 367	c.writeHtml(&WriteData{
 368		Filename: "index.html",
 369		Subdir:   tree.Path,
 370		Template: "html/tree.page.tmpl",
 371		Data: &TreePageData{
 372			PageData: data,
 373			Tree:     tree,
 374		},
 375	})
 376}
 377
 378func (c *Config) writeLog(data *PageData, logs []*CommitData) {
 379	c.Logger.Info("writing log file", "revision", data.RevData.Name())
 380	c.writeHtml(&WriteData{
 381		Filename: "index.html",
 382		Subdir:   getLogBaseDir(data.RevData),
 383		Template: "html/log.page.tmpl",
 384		Data: &LogPageData{
 385			PageData:   data,
 386			NumCommits: len(logs),
 387			Logs:       logs,
 388		},
 389	})
 390}
 391
 392func (c *Config) writeRefs(data *PageData, refs []*RefInfo) {
 393	c.Logger.Info("writing refs", "repoPath", c.RepoPath)
 394	c.writeHtml(&WriteData{
 395		Filename: "refs.html",
 396		Template: "html/refs.page.tmpl",
 397		Data: &RefPageData{
 398			PageData: data,
 399			Refs:     refs,
 400		},
 401	})
 402}
 403
 404func (c *Config) writeHTMLTreeFile(pageData *PageData, treeItem *TreeItem) string {
 405	readme := ""
 406	b, err := treeItem.Entry.Blob().Bytes()
 407	bail(err)
 408	str := string(b)
 409
 410	treeItem.IsTextFile = isTextFile(str)
 411
 412	contents := "binary file, cannot display"
 413	if treeItem.IsTextFile {
 414		treeItem.NumLines = len(strings.Split(str, "\n"))
 415		contents, err = c.parseText(treeItem.Entry.Name(), string(b))
 416		bail(err)
 417	}
 418
 419	d := filepath.Dir(treeItem.Path)
 420
 421	nameLower := strings.ToLower(treeItem.Entry.Name())
 422	summary := readmeFile(pageData.Repo)
 423	if nameLower == summary {
 424		readme = contents
 425	}
 426
 427	c.writeHtml(&WriteData{
 428		Filename: fmt.Sprintf("%s.html", treeItem.Entry.Name()),
 429		Template: "html/file.page.tmpl",
 430		Data: &FilePageData{
 431			PageData: pageData,
 432			Contents: template.HTML(contents),
 433			Item:     treeItem,
 434		},
 435		Subdir: getFileDir(pageData.RevData, d),
 436	})
 437	return readme
 438}
 439
 440func (c *Config) writeLogDiff(repo *git.Repository, pageData *PageData, commit *CommitData) {
 441	commitID := commit.ID.String()
 442
 443	c.Mutex.RLock()
 444	hasCommit := c.Cache[commitID]
 445	c.Mutex.RUnlock()
 446
 447	if hasCommit {
 448		c.Logger.Info("commit file already generated, skipping", "commitID", getShortID(commitID))
 449		return
 450	} else {
 451		c.Mutex.Lock()
 452		c.Cache[commitID] = true
 453		c.Mutex.Unlock()
 454	}
 455
 456	diff, err := repo.Diff(
 457		commitID,
 458		0,
 459		0,
 460		0,
 461		git.DiffOptions{},
 462	)
 463	bail(err)
 464
 465	rnd := &DiffRender{
 466		NumFiles:       diff.NumFiles(),
 467		TotalAdditions: diff.TotalAdditions(),
 468		TotalDeletions: diff.TotalDeletions(),
 469	}
 470	fls := []*DiffRenderFile{}
 471	for _, file := range diff.Files {
 472		fl := &DiffRenderFile{
 473			FileType:     diffFileType(file.Type),
 474			OldMode:      file.OldMode(),
 475			OldName:      file.OldName(),
 476			Mode:         file.Mode(),
 477			Name:         file.Name,
 478			NumAdditions: file.NumAdditions(),
 479			NumDeletions: file.NumDeletions(),
 480		}
 481		content := ""
 482		for _, section := range file.Sections {
 483			for _, line := range section.Lines {
 484				content += fmt.Sprintf("%s\n", line.Content)
 485			}
 486		}
 487		// set filename to something our `ParseText` recognizes (e.g. `.diff`)
 488		finContent, err := c.parseText("commit.diff", content)
 489		bail(err)
 490
 491		fl.Content = template.HTML(finContent)
 492		fls = append(fls, fl)
 493	}
 494	rnd.Files = fls
 495
 496	commitData := &CommitPageData{
 497		PageData:  pageData,
 498		Commit:    commit,
 499		CommitID:  getShortID(commitID),
 500		Diff:      rnd,
 501		Parent:    getShortID(commit.ParentID),
 502		CommitURL: c.getCommitURL(commitID),
 503		ParentURL: c.getCommitURL(commit.ParentID),
 504	}
 505
 506	c.writeHtml(&WriteData{
 507		Filename: fmt.Sprintf("%s.html", commitID),
 508		Template: "html/commit.page.tmpl",
 509		Subdir:   "commits",
 510		Data:     commitData,
 511	})
 512}
 513
 514func (c *Config) getSummaryURL() template.URL {
 515	url := c.RootRelative + "index.html"
 516	return template.URL(url)
 517}
 518
 519func (c *Config) getRefsURL() template.URL {
 520	url := c.RootRelative + "refs.html"
 521	return template.URL(url)
 522}
 523
 524// controls the url for trees and logs
 525// - /logs/getRevIDForURL()/index.html
 526// - /tree/getRevIDForURL()/item/file.x.html.
 527func getRevIDForURL(info RevInfo) string {
 528	return info.Name()
 529}
 530
 531func getTreeBaseDir(info RevInfo) string {
 532	subdir := getRevIDForURL(info)
 533	return filepath.Join("/", "tree", subdir)
 534}
 535
 536func getLogBaseDir(info RevInfo) string {
 537	subdir := getRevIDForURL(info)
 538	return filepath.Join("/", "logs", subdir)
 539}
 540
 541func getFileBaseDir(info RevInfo) string {
 542	return filepath.Join(getTreeBaseDir(info), "item")
 543}
 544
 545func getFileDir(info RevInfo, fname string) string {
 546	return filepath.Join(getFileBaseDir(info), fname)
 547}
 548
 549func (c *Config) getFileURL(info RevInfo, fname string) template.URL {
 550	return c.compileURL(getFileBaseDir(info), fname)
 551}
 552
 553func (c *Config) compileURL(dir, fname string) template.URL {
 554	purl := c.RootRelative + strings.TrimPrefix(dir, "/")
 555	url := filepath.Join(purl, fname)
 556	return template.URL(url)
 557}
 558
 559func (c *Config) getTreeURL(info RevInfo) template.URL {
 560	dir := getTreeBaseDir(info)
 561	return c.compileURL(dir, "index.html")
 562}
 563
 564func (c *Config) getLogsURL(info RevInfo) template.URL {
 565	dir := getLogBaseDir(info)
 566	return c.compileURL(dir, "index.html")
 567}
 568
 569func (c *Config) getCommitURL(commitID string) template.URL {
 570	url := fmt.Sprintf("%scommits/%s.html", c.RootRelative, commitID)
 571	return template.URL(url)
 572}
 573
 574func (c *Config) getURLs() *SiteURLs {
 575	return &SiteURLs{
 576		HomeURL:    c.HomeURL,
 577		CloneURL:   c.CloneURL,
 578		RefsURL:    c.getRefsURL(),
 579		SummaryURL: c.getSummaryURL(),
 580	}
 581}
 582
 583func getShortID(id string) string {
 584	return id[:7]
 585}
 586
 587func (c *Config) writeRepo() *BranchOutput {
 588	c.Logger.Info("writing repo", "repoPath", c.RepoPath)
 589	repo, err := git.Open(c.RepoPath)
 590	bail(err)
 591
 592	refs, err := repo.ShowRef(git.ShowRefOptions{Heads: true, Tags: true})
 593	bail(err)
 594
 595	var first *RevData
 596	revs := []*RevData{}
 597	for _, revStr := range c.Revs {
 598		fullRevID, err := repo.RevParse(revStr)
 599		bail(err)
 600
 601		revID := getShortID(fullRevID)
 602		revName := revID
 603		// if it's a reference then label it as such
 604		for _, ref := range refs {
 605			if revStr == git.RefShortName(ref.Refspec) || revStr == ref.Refspec {
 606				revName = revStr
 607				break
 608			}
 609		}
 610
 611		data := &RevData{
 612			id:     fullRevID,
 613			name:   revName,
 614			Config: c,
 615		}
 616
 617		if first == nil {
 618			first = data
 619		}
 620		revs = append(revs, data)
 621	}
 622
 623	if first == nil {
 624		bail(fmt.Errorf("could find find a git reference that matches criteria"))
 625	}
 626
 627	refInfoMap := map[string]*RefInfo{}
 628	for _, revData := range revs {
 629		refInfoMap[revData.Name()] = &RefInfo{
 630			ID:      revData.ID(),
 631			Refspec: revData.Name(),
 632			URL:     revData.TreeURL(),
 633		}
 634	}
 635
 636	// loop through ALL refs that don't have URLs
 637	// and add them to the map
 638	for _, ref := range refs {
 639		refspec := git.RefShortName(ref.Refspec)
 640		if refInfoMap[refspec] != nil {
 641			continue
 642		}
 643
 644		refInfoMap[refspec] = &RefInfo{
 645			ID:      ref.ID,
 646			Refspec: refspec,
 647		}
 648	}
 649
 650	// gather lists of refs to display on refs.html page
 651	refInfoList := []*RefInfo{}
 652	for _, val := range refInfoMap {
 653		refInfoList = append(refInfoList, val)
 654	}
 655	sort.Slice(refInfoList, func(i, j int) bool {
 656		urlI := refInfoList[i].URL
 657		urlJ := refInfoList[j].URL
 658		refI := refInfoList[i].Refspec
 659		refJ := refInfoList[j].Refspec
 660		if urlI == urlJ {
 661			return refI < refJ
 662		}
 663		return urlI > urlJ
 664	})
 665
 666	// we assume the first revision in the list is the "main" revision which mostly
 667	// means that's the README we use for the default summary page.
 668	mainOutput := &BranchOutput{}
 669	var wg sync.WaitGroup
 670	for i, revData := range revs {
 671		c.Logger.Info("writing revision", "revision", revData.Name())
 672		data := &PageData{
 673			Repo:     c,
 674			RevData:  revData,
 675			SiteURLs: c.getURLs(),
 676		}
 677
 678		if i == 0 {
 679			branchOutput := c.writeRevision(repo, data, refInfoList)
 680			mainOutput = branchOutput
 681		} else {
 682			wg.Add(1)
 683			go func() {
 684				defer wg.Done()
 685				c.writeRevision(repo, data, refInfoList)
 686			}()
 687		}
 688	}
 689	wg.Wait()
 690
 691	// use the first revision in our list to generate
 692	// the root summary, logs, and tree the user can click
 693	revData := &RevData{
 694		id:     first.ID(),
 695		name:   first.Name(),
 696		Config: c,
 697	}
 698
 699	data := &PageData{
 700		RevData:  revData,
 701		Repo:     c,
 702		SiteURLs: c.getURLs(),
 703	}
 704	c.writeRefs(data, refInfoList)
 705	c.writeRootSummary(data, template.HTML(mainOutput.Readme))
 706	return mainOutput
 707}
 708
 709type TreeRoot struct {
 710	Path   string
 711	Items  []*TreeItem
 712	Crumbs []*Breadcrumb
 713}
 714
 715type TreeWalker struct {
 716	treeItem           chan *TreeItem
 717	tree               chan *TreeRoot
 718	HideTreeLastCommit bool
 719	PageData           *PageData
 720	Repo               *git.Repository
 721	Config             *Config
 722}
 723
 724type Breadcrumb struct {
 725	Text   string
 726	URL    template.URL
 727	IsLast bool
 728}
 729
 730func (tw *TreeWalker) calcBreadcrumbs(curpath string) []*Breadcrumb {
 731	if curpath == "" {
 732		return []*Breadcrumb{}
 733	}
 734	parts := strings.Split(curpath, string(os.PathSeparator))
 735	rootURL := tw.Config.compileURL(
 736		getTreeBaseDir(tw.PageData.RevData),
 737		"index.html",
 738	)
 739
 740	crumbs := make([]*Breadcrumb, len(parts)+1)
 741	crumbs[0] = &Breadcrumb{
 742		URL:  rootURL,
 743		Text: tw.PageData.Repo.RepoName,
 744	}
 745
 746	cur := ""
 747	for idx, d := range parts {
 748		crumb := filepath.Join(getFileBaseDir(tw.PageData.RevData), cur, d)
 749		crumbUrl := tw.Config.compileURL(crumb, "index.html")
 750		crumbs[idx+1] = &Breadcrumb{
 751			Text: d,
 752			URL:  crumbUrl,
 753		}
 754		if idx == len(parts)-1 {
 755			crumbs[idx+1].IsLast = true
 756		}
 757		cur = filepath.Join(cur, d)
 758	}
 759
 760	return crumbs
 761}
 762
 763func filenameToDevIcon(filename string) string {
 764	ext := filepath.Ext(filename)
 765	extMappr := map[string]string{
 766		".html": "html5",
 767		".go":   "go",
 768		".py":   "python",
 769		".css":  "css3",
 770		".js":   "javascript",
 771		".md":   "markdown",
 772		".ts":   "typescript",
 773		".tsx":  "react",
 774		".jsx":  "react",
 775	}
 776
 777	nameMappr := map[string]string{
 778		"Makefile":   "cmake",
 779		"Dockerfile": "docker",
 780	}
 781
 782	icon := extMappr[ext]
 783	if icon == "" {
 784		icon = nameMappr[filename]
 785	}
 786
 787	return fmt.Sprintf("devicon-%s-original", icon)
 788}
 789
 790func (tw *TreeWalker) NewTreeItem(entry *git.TreeEntry, curpath string, crumbs []*Breadcrumb) *TreeItem {
 791	typ := entry.Type()
 792	fname := filepath.Join(curpath, entry.Name())
 793	item := &TreeItem{
 794		Size:   toPretty(entry.Size()),
 795		Name:   entry.Name(),
 796		Path:   fname,
 797		Entry:  entry,
 798		URL:    tw.Config.getFileURL(tw.PageData.RevData, fname),
 799		Crumbs: crumbs,
 800	}
 801
 802	// `git rev-list` is pretty expensive here, so we have a flag to disable
 803	if tw.HideTreeLastCommit {
 804		// c.Logger.Info("skipping the process of finding the last commit for each file")
 805	} else {
 806		id := tw.PageData.RevData.ID()
 807		lastCommits, err := tw.Repo.RevList([]string{id}, git.RevListOptions{
 808			Path:           item.Path,
 809			CommandOptions: git.CommandOptions{Args: []string{"-1"}},
 810		})
 811		bail(err)
 812
 813		var lc *git.Commit
 814		if len(lastCommits) > 0 {
 815			lc = lastCommits[0]
 816		}
 817		item.CommitURL = tw.Config.getCommitURL(lc.ID.String())
 818		item.CommitID = getShortID(lc.ID.String())
 819		item.Summary = lc.Summary()
 820		item.When = lc.Author.When.Format(time.DateOnly)
 821		item.Author = lc.Author
 822	}
 823
 824	fpath := tw.Config.getFileURL(tw.PageData.RevData, fmt.Sprintf("%s.html", fname))
 825	if typ == git.ObjectTree {
 826		item.IsDir = true
 827		fpath = tw.Config.compileURL(
 828			filepath.Join(
 829				getFileBaseDir(tw.PageData.RevData),
 830				curpath,
 831				entry.Name(),
 832			),
 833			"index.html",
 834		)
 835	} else if typ == git.ObjectBlob {
 836		item.Icon = filenameToDevIcon(item.Name)
 837	}
 838	item.URL = fpath
 839
 840	return item
 841}
 842
 843func (tw *TreeWalker) walk(tree *git.Tree, curpath string) {
 844	entries, err := tree.Entries()
 845	bail(err)
 846
 847	crumbs := tw.calcBreadcrumbs(curpath)
 848	treeEntries := []*TreeItem{}
 849	for _, entry := range entries {
 850		typ := entry.Type()
 851		item := tw.NewTreeItem(entry, curpath, crumbs)
 852
 853		if typ == git.ObjectTree {
 854			item.IsDir = true
 855			re, _ := tree.Subtree(entry.Name())
 856			tw.walk(re, item.Path)
 857			treeEntries = append(treeEntries, item)
 858			tw.treeItem <- item
 859		} else if typ == git.ObjectBlob {
 860			treeEntries = append(treeEntries, item)
 861			tw.treeItem <- item
 862		}
 863	}
 864
 865	sort.Slice(treeEntries, func(i, j int) bool {
 866		nameI := treeEntries[i].Name
 867		nameJ := treeEntries[j].Name
 868		if treeEntries[i].IsDir && treeEntries[j].IsDir {
 869			return nameI < nameJ
 870		}
 871
 872		if treeEntries[i].IsDir && !treeEntries[j].IsDir {
 873			return true
 874		}
 875
 876		if !treeEntries[i].IsDir && treeEntries[j].IsDir {
 877			return false
 878		}
 879
 880		return nameI < nameJ
 881	})
 882
 883	fpath := filepath.Join(
 884		getFileBaseDir(tw.PageData.RevData),
 885		curpath,
 886	)
 887	// root gets a special spot outside of `item` subdir
 888	if curpath == "" {
 889		fpath = getTreeBaseDir(tw.PageData.RevData)
 890	}
 891
 892	tw.tree <- &TreeRoot{
 893		Path:   fpath,
 894		Items:  treeEntries,
 895		Crumbs: crumbs,
 896	}
 897
 898	if curpath == "" {
 899		close(tw.tree)
 900		close(tw.treeItem)
 901	}
 902}
 903
 904func (c *Config) writeRevision(repo *git.Repository, pageData *PageData, refs []*RefInfo) *BranchOutput {
 905	c.Logger.Info(
 906		"compiling revision",
 907		"repoName", c.RepoName,
 908		"revision", pageData.RevData.Name(),
 909	)
 910
 911	output := &BranchOutput{}
 912
 913	var wg sync.WaitGroup
 914
 915	wg.Add(1)
 916	go func() {
 917		defer wg.Done()
 918
 919		pageSize := pageData.Repo.MaxCommits
 920		if pageSize == 0 {
 921			pageSize = 5000
 922		}
 923		commits, err := repo.CommitsByPage(pageData.RevData.ID(), 0, pageSize)
 924		bail(err)
 925
 926		logs := []*CommitData{}
 927		for i, commit := range commits {
 928			if i == 0 {
 929				output.LastCommit = commit
 930			}
 931
 932			tags := []*RefInfo{}
 933			for _, ref := range refs {
 934				if commit.ID.String() == ref.ID {
 935					tags = append(tags, ref)
 936				}
 937			}
 938
 939			parentSha, _ := commit.ParentID(0)
 940			parentID := ""
 941			if parentSha == nil {
 942				parentID = commit.ID.String()
 943			} else {
 944				parentID = parentSha.String()
 945			}
 946			logs = append(logs, &CommitData{
 947				ParentID:   parentID,
 948				URL:        c.getCommitURL(commit.ID.String()),
 949				ShortID:    getShortID(commit.ID.String()),
 950				SummaryStr: commit.Summary(),
 951				AuthorStr:  commit.Author.Name,
 952				WhenStr:    commit.Author.When.Format(time.DateOnly),
 953				Commit:     commit,
 954				Refs:       tags,
 955			})
 956		}
 957
 958		c.writeLog(pageData, logs)
 959
 960		for _, cm := range logs {
 961			wg.Add(1)
 962			go func(commit *CommitData) {
 963				defer wg.Done()
 964				c.writeLogDiff(repo, pageData, commit)
 965			}(cm)
 966		}
 967	}()
 968
 969	tree, err := repo.LsTree(pageData.RevData.ID())
 970	bail(err)
 971
 972	readme := ""
 973	entries := make(chan *TreeItem)
 974	subtrees := make(chan *TreeRoot)
 975	tw := &TreeWalker{
 976		Config:   c,
 977		PageData: pageData,
 978		Repo:     repo,
 979		treeItem: entries,
 980		tree:     subtrees,
 981	}
 982	wg.Add(1)
 983	go func() {
 984		defer wg.Done()
 985		tw.walk(tree, "")
 986	}()
 987
 988	wg.Add(1)
 989	go func() {
 990		defer wg.Done()
 991		for e := range entries {
 992			wg.Add(1)
 993			go func(entry *TreeItem) {
 994				defer wg.Done()
 995				if entry.IsDir {
 996					return
 997				}
 998
 999				readmeStr := c.writeHTMLTreeFile(pageData, entry)
1000				if readmeStr != "" {
1001					readme = readmeStr
1002				}
1003			}(e)
1004		}
1005	}()
1006
1007	wg.Add(1)
1008	go func() {
1009		defer wg.Done()
1010		for t := range subtrees {
1011			wg.Add(1)
1012			go func(tree *TreeRoot) {
1013				defer wg.Done()
1014				c.writeTree(pageData, tree)
1015			}(t)
1016		}
1017	}()
1018
1019	wg.Wait()
1020
1021	c.Logger.Info(
1022		"compilation complete branch",
1023		"repoName", c.RepoName,
1024		"revision", pageData.RevData.Name(),
1025	)
1026
1027	output.Readme = readme
1028	return output
1029}
1030
1031func style(theme chroma.Style) string {
1032	bg := theme.Get(chroma.Background)
1033	txt := theme.Get(chroma.Text)
1034	kw := theme.Get(chroma.Keyword)
1035	nv := theme.Get(chroma.NameVariable)
1036	cm := theme.Get(chroma.Comment)
1037	ln := theme.Get(chroma.LiteralNumber)
1038	return fmt.Sprintf(`:root {
1039  --bg-color: %s;
1040  --text-color: %s;
1041  --border: %s;
1042  --link-color: %s;
1043  --hover: %s;
1044  --visited: %s;
1045}`,
1046		bg.Background.String(),
1047		txt.Colour.String(),
1048		cm.Colour.String(),
1049		nv.Colour.String(),
1050		kw.Colour.String(),
1051		ln.Colour.String(),
1052	)
1053}
1054
1055func main() {
1056	var outdir = flag.String("out", "./public", "output directory")
1057	var rpath = flag.String("repo", ".", "path to git repo")
1058	var revsFlag = flag.String("revs", "HEAD", "list of revs to generate logs and tree (e.g. main,v1,c69f86f,HEAD)")
1059	var themeFlag = flag.String("theme", "dracula", "theme to use for site")
1060	var labelFlag = flag.String("label", "", "pretty name for the subdir where we create the repo, default is last folder in --repo")
1061	var cloneFlag = flag.String("clone-url", "", "git clone URL for upstream")
1062	var homeFlag = flag.String("home-url", "", "URL for breadcumbs to go to root page, hidden if empty")
1063	var descFlag = flag.String("desc", "", "description for repo")
1064	var rootRelativeFlag = flag.String("root-relative", "/", "html root relative")
1065	var maxCommitsFlag = flag.Int("max-commits", 0, "maximum number of commits to generate")
1066	var hideTreeLastCommitFlag = flag.Bool("hide-tree-last-commit", false, "dont calculate last commit for each file in the tree")
1067
1068	flag.Parse()
1069
1070	out, err := filepath.Abs(*outdir)
1071	bail(err)
1072	repoPath, err := filepath.Abs(*rpath)
1073	bail(err)
1074
1075	theme := styles.Get(*themeFlag)
1076
1077	logger := slog.Default()
1078
1079	label := repoName(repoPath)
1080	if *labelFlag != "" {
1081		label = *labelFlag
1082	}
1083
1084	revs := strings.Split(*revsFlag, ",")
1085	if len(revs) == 1 && revs[0] == "" {
1086		revs = []string{}
1087	}
1088
1089	formatter := formatterHtml.New(
1090		formatterHtml.WithLineNumbers(true),
1091		formatterHtml.WithLinkableLineNumbers(true, ""),
1092		formatterHtml.WithClasses(true),
1093	)
1094
1095	config := &Config{
1096		Outdir:             out,
1097		RepoPath:           repoPath,
1098		RepoName:           label,
1099		Cache:              make(map[string]bool),
1100		Revs:               revs,
1101		Theme:              theme,
1102		Logger:             logger,
1103		CloneURL:           template.URL(*cloneFlag),
1104		HomeURL:            template.URL(*homeFlag),
1105		Desc:               *descFlag,
1106		MaxCommits:         *maxCommitsFlag,
1107		HideTreeLastCommit: *hideTreeLastCommitFlag,
1108		RootRelative:       *rootRelativeFlag,
1109		Formatter:          formatter,
1110	}
1111	config.Logger.Info("config", "config", config)
1112
1113	if len(revs) == 0 {
1114		bail(fmt.Errorf("you must provide --revs"))
1115	}
1116
1117	config.writeRepo()
1118	err = config.copyStatic("static")
1119	bail(err)
1120
1121	styles := style(*theme)
1122	fmt.Println(styles)
1123	err = os.WriteFile(filepath.Join(out, "vars.css"), []byte(styles), 0644)
1124	if err != nil {
1125		panic(err)
1126	}
1127
1128	fp := filepath.Join(out, "syntax.css")
1129	w, err := os.OpenFile(fp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
1130	if err != nil {
1131		bail(err)
1132	}
1133	err = formatter.WriteCSS(w, theme)
1134	if err != nil {
1135		bail(err)
1136	}
1137
1138	url := filepath.Join("/", "index.html")
1139	config.Logger.Info("root url", "url", url)
1140}