Eric Bower
·
19 Dec 24
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}