repos / pgit

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

Eric Bower  ·  2024-12-18

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
  78// revision data
  79type RevData struct {
  80	id     string
  81	name   string
  82	Config *Config
  83}
  84
  85func (r *RevData) ID() string {
  86	return r.id
  87}
  88
  89func (r *RevData) Name() string {
  90	return r.name
  91}
  92
  93func (r *RevData) TreeURL() template.URL {
  94	return r.Config.getTreeURL(r)
  95}
  96
  97func (r *RevData) LogURL() template.URL {
  98	return r.Config.getLogsURL(r)
  99}
 100
 101type TagData struct {
 102	Name string
 103	URL  template.URL
 104}
 105
 106type CommitData struct {
 107	SummaryStr string
 108	URL        template.URL
 109	WhenStr    string
 110	AuthorStr  string
 111	ShortID    string
 112	ParentID   string
 113	Refs       []*RefInfo
 114	*git.Commit
 115}
 116
 117type TreeItem struct {
 118	IsTextFile bool
 119	IsDir      bool
 120	Size       string
 121	NumLines   int
 122	Name       string
 123	Icon       string
 124	Path       string
 125	URL        template.URL
 126	CommitID   string
 127	CommitURL  template.URL
 128	Summary    string
 129	When       string
 130	Author     *git.Signature
 131	Entry      *git.TreeEntry
 132	Crumbs     []*Breadcrumb
 133}
 134
 135type DiffRender struct {
 136	NumFiles       int
 137	TotalAdditions int
 138	TotalDeletions int
 139	Files          []*DiffRenderFile
 140}
 141
 142type DiffRenderFile struct {
 143	FileType     string
 144	OldMode      git.EntryMode
 145	OldName      string
 146	Mode         git.EntryMode
 147	Name         string
 148	Content      template.HTML
 149	NumAdditions int
 150	NumDeletions int
 151}
 152
 153type RefInfo struct {
 154	ID      string
 155	Refspec string
 156	URL     template.URL
 157}
 158
 159type BranchOutput struct {
 160	Readme     string
 161	LastCommit *git.Commit
 162}
 163
 164type SiteURLs struct {
 165	HomeURL    template.URL
 166	CloneURL   template.URL
 167	SummaryURL template.URL
 168	RefsURL    template.URL
 169}
 170
 171type PageData struct {
 172	Repo     *Config
 173	SiteURLs *SiteURLs
 174	RevData  *RevData
 175}
 176
 177type SummaryPageData struct {
 178	*PageData
 179	Readme template.HTML
 180}
 181
 182type TreePageData struct {
 183	*PageData
 184	Tree *TreeRoot
 185}
 186
 187type LogPageData struct {
 188	*PageData
 189	NumCommits int
 190	Logs       []*CommitData
 191}
 192
 193type FilePageData struct {
 194	*PageData
 195	Contents template.HTML
 196	Item     *TreeItem
 197}
 198
 199type CommitPageData struct {
 200	*PageData
 201	CommitMsg template.HTML
 202	CommitID  string
 203	Commit    *CommitData
 204	Diff      *DiffRender
 205	Parent    string
 206	ParentURL template.URL
 207	CommitURL template.URL
 208}
 209
 210type RefPageData struct {
 211	*PageData
 212	Refs []*RefInfo
 213}
 214
 215type WriteData struct {
 216	Template string
 217	Filename string
 218	Subdir   string
 219	Data     interface{}
 220}
 221
 222func bail(err error) {
 223	if err != nil {
 224		panic(err)
 225	}
 226}
 227
 228func diffFileType(_type git.DiffFileType) string {
 229	if _type == git.DiffFileAdd {
 230		return "A"
 231	} else if _type == git.DiffFileChange {
 232		return "M"
 233	} else if _type == git.DiffFileDelete {
 234		return "D"
 235	} else if _type == git.DiffFileRename {
 236		return "R"
 237	}
 238
 239	return ""
 240}
 241
 242// converts contents of files in git tree to pretty formatted code
 243func (c *Config) parseText(filename string, text string) (string, error) {
 244	lexer := lexers.Match(filename)
 245	if lexer == nil {
 246		lexer = lexers.Analyse(text)
 247	}
 248	if lexer == nil {
 249		lexer = lexers.Get("plaintext")
 250	}
 251	iterator, err := lexer.Tokenise(nil, text)
 252	if err != nil {
 253		return text, err
 254	}
 255	var buf bytes.Buffer
 256	err = c.Formatter.Format(&buf, c.Theme, iterator)
 257	if err != nil {
 258		return text, err
 259	}
 260	return buf.String(), nil
 261}
 262
 263// isText reports whether a significant prefix of s looks like correct UTF-8;
 264// that is, if it is likely that s is human-readable text.
 265func isText(s string) bool {
 266	const max = 1024 // at least utf8.UTFMax
 267	if len(s) > max {
 268		s = s[0:max]
 269	}
 270	for i, c := range s {
 271		if i+utf8.UTFMax > len(s) {
 272			// last char may be incomplete - ignore
 273			break
 274		}
 275		if c == 0xFFFD || c < ' ' && c != '\n' && c != '\t' && c != '\f' && c != '\r' {
 276			// decoding error or control character - not a text file
 277			return false
 278		}
 279	}
 280	return true
 281}
 282
 283// isTextFile reports whether the file has a known extension indicating
 284// a text file, or if a significant chunk of the specified file looks like
 285// correct UTF-8; that is, if it is likely that the file contains human-
 286// readable text.
 287func isTextFile(text string) bool {
 288	num := math.Min(float64(len(text)), 1024)
 289	return isText(text[0:int(num)])
 290}
 291
 292func toPretty(b int64) string {
 293	return humanize.Bytes(uint64(b))
 294}
 295
 296func repoName(root string) string {
 297	_, file := filepath.Split(root)
 298	return file
 299}
 300
 301func readmeFile(repo *Config) string {
 302	if repo.Readme == "" {
 303		return "readme.md"
 304	}
 305
 306	return strings.ToLower(repo.Readme)
 307}
 308
 309func (c *Config) writeHtml(writeData *WriteData) {
 310	ts, err := template.ParseFS(
 311		efs,
 312		writeData.Template,
 313		"html/header.partial.tmpl",
 314		"html/footer.partial.tmpl",
 315		"html/base.layout.tmpl",
 316	)
 317	bail(err)
 318
 319	dir := filepath.Join(c.Outdir, writeData.Subdir)
 320	err = os.MkdirAll(dir, os.ModePerm)
 321	bail(err)
 322
 323	fp := filepath.Join(dir, writeData.Filename)
 324	c.Logger.Info("writing", "filepath", fp)
 325
 326	w, err := os.OpenFile(fp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
 327	bail(err)
 328
 329	err = ts.Execute(w, writeData.Data)
 330	bail(err)
 331}
 332
 333func (c *Config) copyStatic(dir string) error {
 334	entries, err := efs.ReadDir(dir)
 335	bail(err)
 336
 337	for _, e := range entries {
 338		infp := filepath.Join(dir, e.Name())
 339		if e.IsDir() {
 340			continue
 341		}
 342
 343		w, err := efs.ReadFile(infp)
 344		bail(err)
 345		fp := filepath.Join(c.Outdir, e.Name())
 346		c.Logger.Info("writing", "filepath", fp)
 347		os.WriteFile(fp, w, 0644)
 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	mainOutput := &BranchOutput{}
 629	claimed := false
 630	for _, revData := range revs {
 631		refInfoMap[revData.Name()] = &RefInfo{
 632			ID:      revData.ID(),
 633			Refspec: revData.Name(),
 634			URL:     revData.TreeURL(),
 635		}
 636	}
 637
 638	// loop through ALL refs that don't have URLs
 639	// and add them to the map
 640	for _, ref := range refs {
 641		refspec := git.RefShortName(ref.Refspec)
 642		if refInfoMap[refspec] != nil {
 643			continue
 644		}
 645
 646		refInfoMap[refspec] = &RefInfo{
 647			ID:      ref.ID,
 648			Refspec: refspec,
 649		}
 650	}
 651
 652	// gather lists of refs to display on refs.html page
 653	refInfoList := []*RefInfo{}
 654	for _, val := range refInfoMap {
 655		refInfoList = append(refInfoList, val)
 656	}
 657	sort.Slice(refInfoList, func(i, j int) bool {
 658		urlI := refInfoList[i].URL
 659		urlJ := refInfoList[j].URL
 660		refI := refInfoList[i].Refspec
 661		refJ := refInfoList[j].Refspec
 662		if urlI == urlJ {
 663			return refI < refJ
 664		}
 665		return urlI > urlJ
 666	})
 667
 668	for _, revData := range revs {
 669		c.Logger.Info("writing revision", "revision", revData.Name())
 670		data := &PageData{
 671			Repo:     c,
 672			RevData:  revData,
 673			SiteURLs: c.getURLs(),
 674		}
 675
 676		if claimed {
 677			go func() {
 678				c.writeRevision(repo, data, refInfoList)
 679			}()
 680		} else {
 681			branchOutput := c.writeRevision(repo, data, refInfoList)
 682			mainOutput = branchOutput
 683			claimed = true
 684		}
 685	}
 686
 687	// use the first revision in our list to generate
 688	// the root summary, logs, and tree the user can click
 689	revData := &RevData{
 690		id:     first.ID(),
 691		name:   first.Name(),
 692		Config: c,
 693	}
 694
 695	data := &PageData{
 696		RevData:  revData,
 697		Repo:     c,
 698		SiteURLs: c.getURLs(),
 699	}
 700	c.writeRefs(data, refInfoList)
 701	c.writeRootSummary(data, template.HTML(mainOutput.Readme))
 702	return mainOutput
 703}
 704
 705type TreeRoot struct {
 706	Path   string
 707	Items  []*TreeItem
 708	Crumbs []*Breadcrumb
 709}
 710
 711type TreeWalker struct {
 712	treeItem           chan *TreeItem
 713	tree               chan *TreeRoot
 714	HideTreeLastCommit bool
 715	PageData           *PageData
 716	Repo               *git.Repository
 717	Config             *Config
 718}
 719
 720type Breadcrumb struct {
 721	Text   string
 722	URL    template.URL
 723	IsLast bool
 724}
 725
 726func (tw *TreeWalker) calcBreadcrumbs(curpath string) []*Breadcrumb {
 727	if curpath == "" {
 728		return []*Breadcrumb{}
 729	}
 730	parts := strings.Split(curpath, string(os.PathSeparator))
 731	rootURL := tw.Config.compileURL(
 732		getTreeBaseDir(tw.PageData.RevData),
 733		"index.html",
 734	)
 735
 736	crumbs := make([]*Breadcrumb, len(parts)+1)
 737	crumbs[0] = &Breadcrumb{
 738		URL:  rootURL,
 739		Text: tw.PageData.Repo.RepoName,
 740	}
 741
 742	cur := ""
 743	for idx, d := range parts {
 744		crumb := filepath.Join(getFileBaseDir(tw.PageData.RevData), cur, d)
 745		crumbUrl := tw.Config.compileURL(crumb, "index.html")
 746		crumbs[idx+1] = &Breadcrumb{
 747			Text: d,
 748			URL:  crumbUrl,
 749		}
 750		if idx == len(parts)-1 {
 751			crumbs[idx+1].IsLast = true
 752		}
 753		cur = filepath.Join(cur, d)
 754	}
 755
 756	return crumbs
 757}
 758
 759func FilenameToDevIcon(filename string) string {
 760	ext := filepath.Ext(filename)
 761	extMappr := map[string]string{
 762		".html": "html5",
 763		".go":   "go",
 764		".py":   "python",
 765		".css":  "css3",
 766		".js":   "javascript",
 767		".md":   "markdown",
 768		".ts":   "typescript",
 769		".tsx":  "react",
 770		".jsx":  "react",
 771	}
 772
 773	nameMappr := map[string]string{
 774		"Makefile":   "cmake",
 775		"Dockerfile": "docker",
 776	}
 777
 778	icon := extMappr[ext]
 779	if icon == "" {
 780		icon = nameMappr[filename]
 781	}
 782
 783	return fmt.Sprintf("devicon-%s-original", icon)
 784}
 785
 786func (tw *TreeWalker) NewTreeItem(entry *git.TreeEntry, curpath string, crumbs []*Breadcrumb) *TreeItem {
 787	typ := entry.Type()
 788	fname := filepath.Join(curpath, entry.Name())
 789	item := &TreeItem{
 790		Size:   toPretty(entry.Size()),
 791		Name:   entry.Name(),
 792		Path:   fname,
 793		Entry:  entry,
 794		URL:    tw.Config.getFileURL(tw.PageData.RevData, fname),
 795		Crumbs: crumbs,
 796	}
 797
 798	// `git rev-list` is pretty expensive here, so we have a flag to disable
 799	if tw.HideTreeLastCommit {
 800		// c.Logger.Info("skipping the process of finding the last commit for each file")
 801	} else {
 802		id := tw.PageData.RevData.ID()
 803		lastCommits, err := tw.Repo.RevList([]string{id}, git.RevListOptions{
 804			Path:           item.Path,
 805			CommandOptions: git.CommandOptions{Args: []string{"-1"}},
 806		})
 807		bail(err)
 808
 809		var lc *git.Commit
 810		if len(lastCommits) > 0 {
 811			lc = lastCommits[0]
 812		}
 813		item.CommitURL = tw.Config.getCommitURL(lc.ID.String())
 814		item.CommitID = getShortID(lc.ID.String())
 815		item.Summary = lc.Summary()
 816		item.When = lc.Author.When.Format(time.DateOnly)
 817		item.Author = lc.Author
 818	}
 819
 820	fpath := tw.Config.getFileURL(tw.PageData.RevData, fmt.Sprintf("%s.html", fname))
 821	if typ == git.ObjectTree {
 822		item.IsDir = true
 823		fpath = tw.Config.compileURL(
 824			filepath.Join(
 825				getFileBaseDir(tw.PageData.RevData),
 826				curpath,
 827				entry.Name(),
 828			),
 829			"index.html",
 830		)
 831	} else if typ == git.ObjectBlob {
 832		item.Icon = FilenameToDevIcon(item.Name)
 833	}
 834	item.URL = fpath
 835
 836	return item
 837}
 838
 839func (tw *TreeWalker) walk(tree *git.Tree, curpath string) {
 840	entries, err := tree.Entries()
 841	bail(err)
 842
 843	crumbs := tw.calcBreadcrumbs(curpath)
 844	treeEntries := []*TreeItem{}
 845	for _, entry := range entries {
 846		typ := entry.Type()
 847		item := tw.NewTreeItem(entry, curpath, crumbs)
 848
 849		if typ == git.ObjectTree {
 850			item.IsDir = true
 851			re, _ := tree.Subtree(entry.Name())
 852			tw.walk(re, item.Path)
 853			treeEntries = append(treeEntries, item)
 854			tw.treeItem <- item
 855		} else if typ == git.ObjectBlob {
 856			treeEntries = append(treeEntries, item)
 857			tw.treeItem <- item
 858		}
 859	}
 860
 861	sort.Slice(treeEntries, func(i, j int) bool {
 862		nameI := treeEntries[i].Name
 863		nameJ := treeEntries[j].Name
 864		if treeEntries[i].IsDir && treeEntries[j].IsDir {
 865			return nameI < nameJ
 866		}
 867
 868		if treeEntries[i].IsDir && !treeEntries[j].IsDir {
 869			return true
 870		}
 871
 872		if !treeEntries[i].IsDir && treeEntries[j].IsDir {
 873			return false
 874		}
 875
 876		return nameI < nameJ
 877	})
 878
 879	fpath := filepath.Join(
 880		getFileBaseDir(tw.PageData.RevData),
 881		curpath,
 882	)
 883	// root gets a special spot outside of `item` subdir
 884	if curpath == "" {
 885		fpath = getTreeBaseDir(tw.PageData.RevData)
 886	}
 887
 888	tw.tree <- &TreeRoot{
 889		Path:   fpath,
 890		Items:  treeEntries,
 891		Crumbs: crumbs,
 892	}
 893
 894	if curpath == "" {
 895		close(tw.tree)
 896		close(tw.treeItem)
 897	}
 898}
 899
 900func (c *Config) writeRevision(repo *git.Repository, pageData *PageData, refs []*RefInfo) *BranchOutput {
 901	c.Logger.Info(
 902		"compiling revision",
 903		"repoName", c.RepoName,
 904		"revision", pageData.RevData.Name(),
 905	)
 906
 907	output := &BranchOutput{}
 908
 909	var wg sync.WaitGroup
 910
 911	wg.Add(1)
 912	go func() {
 913		defer wg.Done()
 914
 915		pageSize := pageData.Repo.MaxCommits
 916		if pageSize == 0 {
 917			pageSize = 5000
 918		}
 919		commits, err := repo.CommitsByPage(pageData.RevData.ID(), 0, pageSize)
 920		bail(err)
 921
 922		logs := []*CommitData{}
 923		for i, commit := range commits {
 924			if i == 0 {
 925				output.LastCommit = commit
 926			}
 927
 928			tags := []*RefInfo{}
 929			for _, ref := range refs {
 930				if commit.ID.String() == ref.ID {
 931					tags = append(tags, ref)
 932				}
 933			}
 934
 935			parentSha, _ := commit.ParentID(0)
 936			parentID := ""
 937			if parentSha == nil {
 938				parentID = commit.ID.String()
 939			} else {
 940				parentID = parentSha.String()
 941			}
 942			logs = append(logs, &CommitData{
 943				ParentID:   parentID,
 944				URL:        c.getCommitURL(commit.ID.String()),
 945				ShortID:    getShortID(commit.ID.String()),
 946				SummaryStr: commit.Summary(),
 947				AuthorStr:  commit.Author.Name,
 948				WhenStr:    commit.Author.When.Format(time.DateOnly),
 949				Commit:     commit,
 950				Refs:       tags,
 951			})
 952		}
 953
 954		c.writeLog(pageData, logs)
 955
 956		for _, cm := range logs {
 957			wg.Add(1)
 958			go func(commit *CommitData) {
 959				defer wg.Done()
 960				c.writeLogDiff(repo, pageData, commit)
 961			}(cm)
 962		}
 963	}()
 964
 965	tree, err := repo.LsTree(pageData.RevData.ID())
 966	bail(err)
 967
 968	readme := ""
 969	entries := make(chan *TreeItem)
 970	subtrees := make(chan *TreeRoot)
 971	tw := &TreeWalker{
 972		Config:   c,
 973		PageData: pageData,
 974		Repo:     repo,
 975		treeItem: entries,
 976		tree:     subtrees,
 977	}
 978	wg.Add(1)
 979	go func() {
 980		defer wg.Done()
 981		tw.walk(tree, "")
 982	}()
 983
 984	wg.Add(1)
 985	go func() {
 986		defer wg.Done()
 987		for e := range entries {
 988			wg.Add(1)
 989			go func(entry *TreeItem) {
 990				defer wg.Done()
 991				if entry.IsDir {
 992					return
 993				}
 994
 995				readmeStr := c.writeHTMLTreeFile(pageData, entry)
 996				if readmeStr != "" {
 997					readme = readmeStr
 998				}
 999			}(e)
1000		}
1001	}()
1002
1003	wg.Add(1)
1004	go func() {
1005		defer wg.Done()
1006		for t := range subtrees {
1007			wg.Add(1)
1008			go func(tree *TreeRoot) {
1009				defer wg.Done()
1010				c.writeTree(pageData, tree)
1011			}(t)
1012		}
1013	}()
1014
1015	wg.Wait()
1016
1017	c.Logger.Info(
1018		"compilation complete branch",
1019		"repoName", c.RepoName,
1020		"revision", pageData.RevData.Name(),
1021	)
1022
1023	output.Readme = readme
1024	return output
1025}
1026
1027func style(theme chroma.Style) string {
1028	bg := theme.Get(chroma.Background)
1029	txt := theme.Get(chroma.Text)
1030	kw := theme.Get(chroma.Keyword)
1031	nv := theme.Get(chroma.NameVariable)
1032	cm := theme.Get(chroma.Comment)
1033	ln := theme.Get(chroma.LiteralNumber)
1034	return fmt.Sprintf(`:root {
1035  --bg-color: %s;
1036  --text-color: %s;
1037  --border: %s;
1038  --link-color: %s;
1039  --hover: %s;
1040  --visited: %s;
1041}`,
1042		bg.Background.String(),
1043		txt.Colour.String(),
1044		cm.Colour.String(),
1045		nv.Colour.String(),
1046		kw.Colour.String(),
1047		ln.Colour.String(),
1048	)
1049}
1050
1051func main() {
1052	var outdir = flag.String("out", "./public", "output directory")
1053	var rpath = flag.String("repo", ".", "path to git repo")
1054	var revsFlag = flag.String("revs", "HEAD", "list of revs to generate logs and tree (e.g. main,v1,c69f86f,HEAD)")
1055	var themeFlag = flag.String("theme", "dracula", "theme to use for site")
1056	var labelFlag = flag.String("label", "", "pretty name for the subdir where we create the repo, default is last folder in --repo")
1057	var cloneFlag = flag.String("clone-url", "", "git clone URL")
1058	var homeFlag = flag.String("home-url", "", "URL for breadcumbs to get to list of repositories")
1059	var descFlag = flag.String("desc", "", "description for repo")
1060	var rootRelativeFlag = flag.String("root-relative", "/", "html root relative")
1061	var maxCommitsFlag = flag.Int("max-commits", 0, "maximum number of commits to generate")
1062	var hideTreeLastCommitFlag = flag.Bool("hide-tree-last-commit", false, "dont calculate last commit for each file in the tree")
1063
1064	flag.Parse()
1065
1066	out, err := filepath.Abs(*outdir)
1067	bail(err)
1068	repoPath, err := filepath.Abs(*rpath)
1069	bail(err)
1070
1071	theme := styles.Get(*themeFlag)
1072
1073	logger := slog.Default()
1074
1075	label := repoName(repoPath)
1076	if *labelFlag != "" {
1077		label = *labelFlag
1078	}
1079
1080	revs := strings.Split(*revsFlag, ",")
1081	if len(revs) == 1 && revs[0] == "" {
1082		revs = []string{}
1083	}
1084
1085	formatter := formatterHtml.New(
1086		formatterHtml.WithLineNumbers(true),
1087		formatterHtml.WithLinkableLineNumbers(true, ""),
1088		formatterHtml.WithClasses(true),
1089	)
1090
1091	config := &Config{
1092		Outdir:             out,
1093		RepoPath:           repoPath,
1094		RepoName:           label,
1095		Cache:              make(map[string]bool),
1096		Revs:               revs,
1097		Theme:              theme,
1098		Logger:             logger,
1099		CloneURL:           template.URL(*cloneFlag),
1100		HomeURL:            template.URL(*homeFlag),
1101		Desc:               *descFlag,
1102		MaxCommits:         *maxCommitsFlag,
1103		HideTreeLastCommit: *hideTreeLastCommitFlag,
1104		RootRelative:       *rootRelativeFlag,
1105		Formatter:          formatter,
1106	}
1107	config.Logger.Info("config", "config", config)
1108
1109	if len(revs) == 0 {
1110		bail(fmt.Errorf("you must provide --revs"))
1111	}
1112
1113	config.writeRepo()
1114	config.copyStatic("static")
1115
1116	styles := style(*theme)
1117	fmt.Println(styles)
1118	err = os.WriteFile(filepath.Join(out, "vars.css"), []byte(styles), 0644)
1119	if err != nil {
1120		panic(err)
1121	}
1122
1123	fp := filepath.Join(out, "syntax.css")
1124	w, err := os.OpenFile(fp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
1125	if err != nil {
1126		bail(err)
1127	}
1128	err = formatter.WriteCSS(w, theme)
1129	if err != nil {
1130		bail(err)
1131	}
1132
1133	url := filepath.Join("/", "index.html")
1134	config.Logger.Info("root url", "url", url)
1135}