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