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