Eric Bower
·
2025-08-10
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
29var embedFS embed.FS
30
31//go:embed static/*
32var staticFS embed.FS
33
34type Config struct {
35 // required params
36 Outdir string
37 // abs path to git repo
38 RepoPath string
39
40 // optional params
41 // generate logs anad tree based on the git revisions provided
42 Revs []string
43 // description of repo used in the header of site
44 Desc string
45 // maximum number of commits that we will process in descending order
46 MaxCommits int
47 // name of the readme file
48 Readme string
49 // In order to get the latest commit per file we do a `git rev-list {ref} {file}`
50 // which is n+1 where n is a file in the tree.
51 // We offer a way to disable showing the latest commit in the output
52 // for those who want a faster build time
53 HideTreeLastCommit bool
54
55 // user-defined urls
56 HomeURL template.URL
57 CloneURL template.URL
58
59 // https://developer.mozilla.org/en-US/docs/Web/API/URL_API/Resolving_relative_references#root_relative
60 RootRelative string
61
62 // computed
63 // cache for skipping commits, trees, etc.
64 Cache map[string]bool
65 // mutex for Cache
66 Mutex sync.RWMutex
67 // pretty name for the repo
68 RepoName string
69 // logger
70 Logger *slog.Logger
71 // chroma style
72 Theme *chroma.Style
73 Formatter *formatterHtml.Formatter
74}
75
76type RevInfo interface {
77 ID() string
78 Name() string
79}
80
81type RevData struct {
82 id string
83 name string
84 Config *Config
85}
86
87func (r *RevData) ID() string {
88 return r.id
89}
90
91func (r *RevData) Name() string {
92 return r.name
93}
94
95func (r *RevData) TreeURL() template.URL {
96 return r.Config.getTreeURL(r)
97}
98
99func (r *RevData) LogURL() template.URL {
100 return r.Config.getLogsURL(r)
101}
102
103type TagData struct {
104 Name string
105 URL template.URL
106}
107
108type CommitData struct {
109 SummaryStr string
110 URL template.URL
111 WhenStr string
112 AuthorStr string
113 ShortID string
114 ParentID string
115 Refs []*RefInfo
116 *git.Commit
117}
118
119type TreeItem struct {
120 IsTextFile bool
121 IsDir bool
122 Size string
123 NumLines int
124 Name string
125 Icon string
126 Path string
127 URL template.URL
128 CommitID string
129 CommitURL template.URL
130 Summary string
131 When string
132 Author *git.Signature
133 Entry *git.TreeEntry
134 Crumbs []*Breadcrumb
135}
136
137type DiffRender struct {
138 NumFiles int
139 TotalAdditions int
140 TotalDeletions int
141 Files []*DiffRenderFile
142}
143
144type DiffRenderFile struct {
145 FileType string
146 OldMode git.EntryMode
147 OldName string
148 Mode git.EntryMode
149 Name string
150 Content template.HTML
151 NumAdditions int
152 NumDeletions int
153}
154
155type RefInfo struct {
156 ID string
157 Refspec string
158 URL template.URL
159}
160
161type BranchOutput struct {
162 Readme string
163 LastCommit *git.Commit
164}
165
166type SiteURLs struct {
167 HomeURL template.URL
168 CloneURL template.URL
169 SummaryURL template.URL
170 RefsURL template.URL
171}
172
173type PageData struct {
174 Repo *Config
175 SiteURLs *SiteURLs
176 RevData *RevData
177}
178
179type SummaryPageData struct {
180 *PageData
181 Readme template.HTML
182}
183
184type TreePageData struct {
185 *PageData
186 Tree *TreeRoot
187}
188
189type LogPageData struct {
190 *PageData
191 NumCommits int
192 Logs []*CommitData
193}
194
195type FilePageData struct {
196 *PageData
197 Contents template.HTML
198 Item *TreeItem
199}
200
201type CommitPageData struct {
202 *PageData
203 CommitMsg template.HTML
204 CommitID string
205 Commit *CommitData
206 Diff *DiffRender
207 Parent string
208 ParentURL template.URL
209 CommitURL template.URL
210}
211
212type RefPageData struct {
213 *PageData
214 Refs []*RefInfo
215}
216
217type WriteData struct {
218 Template string
219 Filename string
220 Subdir string
221 Data any
222}
223
224func bail(err error) {
225 if err != nil {
226 panic(err)
227 }
228}
229
230func diffFileType(_type git.DiffFileType) string {
231 switch _type {
232 case git.DiffFileAdd:
233 return "A"
234 case git.DiffFileChange:
235 return "M"
236 case git.DiffFileDelete:
237 return "D"
238 case git.DiffFileRename:
239 return "R"
240 default:
241 return ""
242 }
243}
244
245// converts contents of files in git tree to pretty formatted code.
246func (c *Config) parseText(filename string, text string) (string, error) {
247 lexer := lexers.Match(filename)
248 if lexer == nil {
249 lexer = lexers.Analyse(text)
250 }
251 if lexer == nil {
252 lexer = lexers.Get("plaintext")
253 }
254 iterator, err := lexer.Tokenise(nil, text)
255 if err != nil {
256 return text, err
257 }
258 var buf bytes.Buffer
259 err = c.Formatter.Format(&buf, c.Theme, iterator)
260 if err != nil {
261 return text, err
262 }
263 return buf.String(), nil
264}
265
266// isText reports whether a significant prefix of s looks like correct UTF-8;
267// that is, if it is likely that s is human-readable text.
268func isText(s string) bool {
269 const max = 1024 // at least utf8.UTFMax
270 if len(s) > max {
271 s = s[0:max]
272 }
273 for i, c := range s {
274 if i+utf8.UTFMax > len(s) {
275 // last char may be incomplete - ignore
276 break
277 }
278 if c == 0xFFFD || c < ' ' && c != '\n' && c != '\t' && c != '\f' && c != '\r' {
279 // decoding error or control character - not a text file
280 return false
281 }
282 }
283 return true
284}
285
286// isTextFile reports whether the file has a known extension indicating
287// a text file, or if a significant chunk of the specified file looks like
288// correct UTF-8; that is, if it is likely that the file contains human-
289// readable text.
290func isTextFile(text string) bool {
291 num := math.Min(float64(len(text)), 1024)
292 return isText(text[0:int(num)])
293}
294
295func toPretty(b int64) string {
296 return humanize.Bytes(uint64(b))
297}
298
299func repoName(root string) string {
300 _, file := filepath.Split(root)
301 return file
302}
303
304func readmeFile(repo *Config) string {
305 if repo.Readme == "" {
306 return "readme.md"
307 }
308
309 return strings.ToLower(repo.Readme)
310}
311
312func (c *Config) writeHtml(writeData *WriteData) {
313 ts, err := template.ParseFS(
314 embedFS,
315 writeData.Template,
316 "html/header.partial.tmpl",
317 "html/footer.partial.tmpl",
318 "html/base.layout.tmpl",
319 )
320 bail(err)
321
322 dir := filepath.Join(c.Outdir, writeData.Subdir)
323 err = os.MkdirAll(dir, os.ModePerm)
324 bail(err)
325
326 fp := filepath.Join(dir, writeData.Filename)
327 c.Logger.Info("writing", "filepath", fp)
328
329 w, err := os.OpenFile(fp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
330 bail(err)
331
332 err = ts.Execute(w, writeData.Data)
333 bail(err)
334}
335
336func (c *Config) copyStatic(dir string) error {
337 entries, err := staticFS.ReadDir(dir)
338 bail(err)
339
340 for _, e := range entries {
341 infp := filepath.Join(dir, e.Name())
342 if e.IsDir() {
343 continue
344 }
345
346 w, err := staticFS.ReadFile(infp)
347 bail(err)
348 fp := filepath.Join(c.Outdir, e.Name())
349 c.Logger.Info("writing", "filepath", fp)
350 err = os.WriteFile(fp, w, 0644)
351 bail(err)
352 }
353
354 return nil
355}
356
357func (c *Config) writeRootSummary(data *PageData, readme template.HTML) {
358 c.Logger.Info("writing root html", "repoPath", c.RepoPath)
359 c.writeHtml(&WriteData{
360 Filename: "index.html",
361 Template: "html/summary.page.tmpl",
362 Data: &SummaryPageData{
363 PageData: data,
364 Readme: readme,
365 },
366 })
367}
368
369func (c *Config) writeTree(data *PageData, tree *TreeRoot) {
370 c.Logger.Info("writing tree", "treePath", tree.Path)
371 c.writeHtml(&WriteData{
372 Filename: "index.html",
373 Subdir: tree.Path,
374 Template: "html/tree.page.tmpl",
375 Data: &TreePageData{
376 PageData: data,
377 Tree: tree,
378 },
379 })
380}
381
382func (c *Config) writeLog(data *PageData, logs []*CommitData) {
383 c.Logger.Info("writing log file", "revision", data.RevData.Name())
384 c.writeHtml(&WriteData{
385 Filename: "index.html",
386 Subdir: getLogBaseDir(data.RevData),
387 Template: "html/log.page.tmpl",
388 Data: &LogPageData{
389 PageData: data,
390 NumCommits: len(logs),
391 Logs: logs,
392 },
393 })
394}
395
396func (c *Config) writeRefs(data *PageData, refs []*RefInfo) {
397 c.Logger.Info("writing refs", "repoPath", c.RepoPath)
398 c.writeHtml(&WriteData{
399 Filename: "refs.html",
400 Template: "html/refs.page.tmpl",
401 Data: &RefPageData{
402 PageData: data,
403 Refs: refs,
404 },
405 })
406}
407
408func (c *Config) writeHTMLTreeFile(pageData *PageData, treeItem *TreeItem) string {
409 readme := ""
410 b, err := treeItem.Entry.Blob().Bytes()
411 bail(err)
412 str := string(b)
413
414 treeItem.IsTextFile = isTextFile(str)
415
416 contents := "binary file, cannot display"
417 if treeItem.IsTextFile {
418 treeItem.NumLines = len(strings.Split(str, "\n"))
419 contents, err = c.parseText(treeItem.Entry.Name(), string(b))
420 bail(err)
421 }
422
423 d := filepath.Dir(treeItem.Path)
424
425 nameLower := strings.ToLower(treeItem.Entry.Name())
426 summary := readmeFile(pageData.Repo)
427 if d == "." && nameLower == summary {
428 readme = contents
429 }
430
431 c.writeHtml(&WriteData{
432 Filename: fmt.Sprintf("%s.html", treeItem.Entry.Name()),
433 Template: "html/file.page.tmpl",
434 Data: &FilePageData{
435 PageData: pageData,
436 Contents: template.HTML(contents),
437 Item: treeItem,
438 },
439 Subdir: getFileDir(pageData.RevData, d),
440 })
441 return readme
442}
443
444func (c *Config) writeLogDiff(repo *git.Repository, pageData *PageData, commit *CommitData) {
445 commitID := commit.ID.String()
446
447 c.Mutex.RLock()
448 hasCommit := c.Cache[commitID]
449 c.Mutex.RUnlock()
450
451 if hasCommit {
452 c.Logger.Info("commit file already generated, skipping", "commitID", getShortID(commitID))
453 return
454 } else {
455 c.Mutex.Lock()
456 c.Cache[commitID] = true
457 c.Mutex.Unlock()
458 }
459
460 diff, err := repo.Diff(commitID, 0, 0, 0, git.DiffOptions{})
461 bail(err)
462
463 rnd := &DiffRender{
464 NumFiles: diff.NumFiles(),
465 TotalAdditions: diff.TotalAdditions(),
466 TotalDeletions: diff.TotalDeletions(),
467 }
468 fls := []*DiffRenderFile{}
469 for _, file := range diff.Files {
470 fl := &DiffRenderFile{
471 FileType: diffFileType(file.Type),
472 OldMode: file.OldMode(),
473 OldName: file.OldName(),
474 Mode: file.Mode(),
475 Name: file.Name,
476 NumAdditions: file.NumAdditions(),
477 NumDeletions: file.NumDeletions(),
478 }
479 content := ""
480 for _, section := range file.Sections {
481 for _, line := range section.Lines {
482 content += fmt.Sprintf("%s\n", line.Content)
483 }
484 }
485 // set filename to something our `ParseText` recognizes (e.g. `.diff`)
486 finContent, err := c.parseText("commit.diff", content)
487 bail(err)
488
489 fl.Content = template.HTML(finContent)
490 fls = append(fls, fl)
491 }
492 rnd.Files = fls
493
494 commitData := &CommitPageData{
495 PageData: pageData,
496 Commit: commit,
497 CommitID: getShortID(commitID),
498 Diff: rnd,
499 Parent: getShortID(commit.ParentID),
500 CommitURL: c.getCommitURL(commitID),
501 ParentURL: c.getCommitURL(commit.ParentID),
502 }
503
504 c.writeHtml(&WriteData{
505 Filename: fmt.Sprintf("%s.html", commitID),
506 Template: "html/commit.page.tmpl",
507 Subdir: "commits",
508 Data: commitData,
509 })
510}
511
512func (c *Config) getSummaryURL() template.URL {
513 url := c.RootRelative + "index.html"
514 return template.URL(url)
515}
516
517func (c *Config) getRefsURL() template.URL {
518 url := c.RootRelative + "refs.html"
519 return template.URL(url)
520}
521
522// controls the url for trees and logs
523// - /logs/getRevIDForURL()/index.html
524// - /tree/getRevIDForURL()/item/file.x.html.
525func getRevIDForURL(info RevInfo) string {
526 return info.Name()
527}
528
529func getTreeBaseDir(info RevInfo) string {
530 subdir := getRevIDForURL(info)
531 return filepath.Join("/", "tree", subdir)
532}
533
534func getLogBaseDir(info RevInfo) string {
535 subdir := getRevIDForURL(info)
536 return filepath.Join("/", "logs", subdir)
537}
538
539func getFileBaseDir(info RevInfo) string {
540 return filepath.Join(getTreeBaseDir(info), "item")
541}
542
543func getFileDir(info RevInfo, fname string) string {
544 return filepath.Join(getFileBaseDir(info), fname)
545}
546
547func (c *Config) getFileURL(info RevInfo, fname string) template.URL {
548 return c.compileURL(getFileBaseDir(info), fname)
549}
550
551func (c *Config) compileURL(dir, fname string) template.URL {
552 purl := c.RootRelative + strings.TrimPrefix(dir, "/")
553 url := filepath.Join(purl, fname)
554 return template.URL(url)
555}
556
557func (c *Config) getTreeURL(info RevInfo) template.URL {
558 dir := getTreeBaseDir(info)
559 return c.compileURL(dir, "index.html")
560}
561
562func (c *Config) getLogsURL(info RevInfo) template.URL {
563 dir := getLogBaseDir(info)
564 return c.compileURL(dir, "index.html")
565}
566
567func (c *Config) getCommitURL(commitID string) template.URL {
568 url := fmt.Sprintf("%scommits/%s.html", c.RootRelative, commitID)
569 return template.URL(url)
570}
571
572func (c *Config) getURLs() *SiteURLs {
573 return &SiteURLs{
574 HomeURL: c.HomeURL,
575 CloneURL: c.CloneURL,
576 RefsURL: c.getRefsURL(),
577 SummaryURL: c.getSummaryURL(),
578 }
579}
580
581func getShortID(id string) string {
582 return id[:7]
583}
584
585func (c *Config) writeRepo() *BranchOutput {
586 c.Logger.Info("writing repo", "repoPath", c.RepoPath)
587 repo, err := git.Open(c.RepoPath)
588 bail(err)
589
590 refs, err := repo.ShowRef(git.ShowRefOptions{Heads: true, Tags: true})
591 bail(err)
592
593 var first *RevData
594 revs := []*RevData{}
595 for _, revStr := range c.Revs {
596 fullRevID, err := repo.RevParse(revStr)
597 bail(err)
598
599 revID := getShortID(fullRevID)
600 revName := revID
601 // if it's a reference then label it as such
602 for _, ref := range refs {
603 if revStr == git.RefShortName(ref.Refspec) || revStr == ref.Refspec {
604 revName = revStr
605 break
606 }
607 }
608
609 data := &RevData{
610 id: fullRevID,
611 name: revName,
612 Config: c,
613 }
614
615 if first == nil {
616 first = data
617 }
618 revs = append(revs, data)
619 }
620
621 if first == nil {
622 bail(fmt.Errorf("could find find a git reference that matches criteria"))
623 }
624
625 refInfoMap := map[string]*RefInfo{}
626 for _, revData := range revs {
627 refInfoMap[revData.Name()] = &RefInfo{
628 ID: revData.ID(),
629 Refspec: revData.Name(),
630 URL: revData.TreeURL(),
631 }
632 }
633
634 // loop through ALL refs that don't have URLs
635 // and add them to the map
636 for _, ref := range refs {
637 refspec := git.RefShortName(ref.Refspec)
638 if refInfoMap[refspec] != nil {
639 continue
640 }
641
642 refInfoMap[refspec] = &RefInfo{
643 ID: ref.ID,
644 Refspec: refspec,
645 }
646 }
647
648 // gather lists of refs to display on refs.html page
649 refInfoList := []*RefInfo{}
650 for _, val := range refInfoMap {
651 refInfoList = append(refInfoList, val)
652 }
653 sort.Slice(refInfoList, func(i, j int) bool {
654 urlI := refInfoList[i].URL
655 urlJ := refInfoList[j].URL
656 refI := refInfoList[i].Refspec
657 refJ := refInfoList[j].Refspec
658 if urlI == urlJ {
659 return refI < refJ
660 }
661 return urlI > urlJ
662 })
663
664 // we assume the first revision in the list is the "main" revision which mostly
665 // means that's the README we use for the default summary page.
666 mainOutput := &BranchOutput{}
667 var wg sync.WaitGroup
668 for i, 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 i == 0 {
677 branchOutput := c.writeRevision(repo, data, refInfoList)
678 mainOutput = branchOutput
679 } else {
680 wg.Add(1)
681 go func() {
682 defer wg.Done()
683 c.writeRevision(repo, data, refInfoList)
684 }()
685 }
686 }
687 wg.Wait()
688
689 // use the first revision in our list to generate
690 // the root summary, logs, and tree the user can click
691 revData := &RevData{
692 id: first.ID(),
693 name: first.Name(),
694 Config: c,
695 }
696
697 data := &PageData{
698 RevData: revData,
699 Repo: c,
700 SiteURLs: c.getURLs(),
701 }
702 c.writeRefs(data, refInfoList)
703 c.writeRootSummary(data, template.HTML(mainOutput.Readme))
704 return mainOutput
705}
706
707type TreeRoot struct {
708 Path string
709 Items []*TreeItem
710 Crumbs []*Breadcrumb
711}
712
713type TreeWalker struct {
714 treeItem chan *TreeItem
715 tree chan *TreeRoot
716 HideTreeLastCommit bool
717 PageData *PageData
718 Repo *git.Repository
719 Config *Config
720}
721
722type Breadcrumb struct {
723 Text string
724 URL template.URL
725 IsLast bool
726}
727
728func (tw *TreeWalker) calcBreadcrumbs(curpath string) []*Breadcrumb {
729 if curpath == "" {
730 return []*Breadcrumb{}
731 }
732 parts := strings.Split(curpath, string(os.PathSeparator))
733 rootURL := tw.Config.compileURL(
734 getTreeBaseDir(tw.PageData.RevData),
735 "index.html",
736 )
737
738 crumbs := make([]*Breadcrumb, len(parts)+1)
739 crumbs[0] = &Breadcrumb{
740 URL: rootURL,
741 Text: tw.PageData.Repo.RepoName,
742 }
743
744 cur := ""
745 for idx, d := range parts {
746 crumb := filepath.Join(getFileBaseDir(tw.PageData.RevData), cur, d)
747 crumbUrl := tw.Config.compileURL(crumb, "index.html")
748 crumbs[idx+1] = &Breadcrumb{
749 Text: d,
750 URL: crumbUrl,
751 }
752 if idx == len(parts)-1 {
753 crumbs[idx+1].IsLast = true
754 }
755 cur = filepath.Join(cur, d)
756 }
757
758 return crumbs
759}
760
761func filenameToDevIcon(filename string) string {
762 ext := filepath.Ext(filename)
763 extMappr := map[string]string{
764 ".html": "html5",
765 ".go": "go",
766 ".py": "python",
767 ".css": "css3",
768 ".js": "javascript",
769 ".md": "markdown",
770 ".ts": "typescript",
771 ".tsx": "react",
772 ".jsx": "react",
773 }
774
775 nameMappr := map[string]string{
776 "Makefile": "cmake",
777 "Dockerfile": "docker",
778 }
779
780 icon := extMappr[ext]
781 if icon == "" {
782 icon = nameMappr[filename]
783 }
784
785 return fmt.Sprintf("devicon-%s-original", icon)
786}
787
788func (tw *TreeWalker) NewTreeItem(entry *git.TreeEntry, curpath string, crumbs []*Breadcrumb) *TreeItem {
789 typ := entry.Type()
790 fname := filepath.Join(curpath, entry.Name())
791 item := &TreeItem{
792 Size: toPretty(entry.Size()),
793 Name: entry.Name(),
794 Path: fname,
795 Entry: entry,
796 URL: tw.Config.getFileURL(tw.PageData.RevData, fname),
797 Crumbs: crumbs,
798 }
799
800 // `git rev-list` is pretty expensive here, so we have a flag to disable
801 if tw.HideTreeLastCommit {
802 // c.Logger.Info("skipping the process of finding the last commit for each file")
803 } else {
804 id := tw.PageData.RevData.ID()
805 lastCommits, err := tw.Repo.RevList([]string{id}, git.RevListOptions{
806 Path: item.Path,
807 CommandOptions: git.CommandOptions{Args: []string{"-1"}},
808 })
809 bail(err)
810
811 var lc *git.Commit
812 if len(lastCommits) > 0 {
813 lc = lastCommits[0]
814 }
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 fpath := tw.Config.getFileURL(tw.PageData.RevData, fmt.Sprintf("%s.html", fname))
823 switch typ {
824 case git.ObjectTree:
825 item.IsDir = true
826 fpath = tw.Config.compileURL(
827 filepath.Join(
828 getFileBaseDir(tw.PageData.RevData),
829 curpath,
830 entry.Name(),
831 ),
832 "index.html",
833 )
834 case git.ObjectBlob:
835 item.Icon = filenameToDevIcon(item.Name)
836 }
837 item.URL = fpath
838
839 return item
840}
841
842func (tw *TreeWalker) walk(tree *git.Tree, curpath string) {
843 entries, err := tree.Entries()
844 bail(err)
845
846 crumbs := tw.calcBreadcrumbs(curpath)
847 treeEntries := []*TreeItem{}
848 for _, entry := range entries {
849 typ := entry.Type()
850 item := tw.NewTreeItem(entry, curpath, crumbs)
851
852 switch typ {
853 case 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 case 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",
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 err = os.WriteFile(filepath.Join(out, "vars.css"), []byte(styles), 0644)
1123 if err != nil {
1124 panic(err)
1125 }
1126
1127 fp := filepath.Join(out, "syntax.css")
1128 w, err := os.OpenFile(fp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
1129 if err != nil {
1130 bail(err)
1131 }
1132 err = formatter.WriteCSS(w, theme)
1133 if err != nil {
1134 bail(err)
1135 }
1136
1137 url := filepath.Join("/", "index.html")
1138 config.Logger.Info("root url", "url", url)
1139}