main
1package generator
2
3import (
4 "bytes"
5 "fmt"
6 "html/template"
7 "os"
8 "path/filepath"
9 "sort"
10 "strings"
11
12 "github.com/alecthomas/chroma/v2/formatters/html"
13 "github.com/alecthomas/chroma/v2/lexers"
14 "github.com/alecthomas/chroma/v2/styles"
15
16 "github.com/bluekeyes/go-gitdiff/gitdiff"
17 "mokhan.ca/xlgmokha/gitmal/internal/git"
18 "mokhan.ca/xlgmokha/gitmal/internal/pool"
19 "mokhan.ca/xlgmokha/gitmal/internal/templates"
20)
21
22func GenerateCommits(commits map[string]git.Commit, params Params) error {
23 outDir := filepath.Join(params.OutputDir, "commit")
24 if err := os.MkdirAll(outDir, 0o755); err != nil {
25 return err
26 }
27
28 list := make([]git.Commit, 0, len(commits))
29 for _, c := range commits {
30 list = append(list, c)
31 }
32
33 return pool.Run(list, func(c git.Commit) error {
34 return generateCommitPage(c, params)
35 })
36}
37
38func generateCommitPage(commit git.Commit, params Params) error {
39 diff, err := git.CommitDiff(commit.Hash, params.RepoDir)
40 if err != nil {
41 return err
42 }
43
44 files, _, err := gitdiff.Parse(strings.NewReader(diff))
45 if err != nil {
46 return err
47 }
48
49 formatter := html.New(
50 html.WithClasses(true),
51 html.WithCSSComments(false),
52 )
53
54 lightStyle := styles.Get("github")
55 lexer := lexers.Get("diff")
56 if lexer == nil {
57 return fmt.Errorf("failed to get lexer for diff")
58 }
59
60 outPath := filepath.Join(params.OutputDir, "commit", commit.Hash+".html")
61
62 f, err := os.Create(outPath)
63 if err != nil {
64 return err
65 }
66 rootHref := filepath.ToSlash("../")
67
68 fileTree := buildFileTree(files)
69
70 // Create a stable order for files that matches the file tree traversal
71 // so that the per-file views appear in the same order as the sidebar tree.
72 fileOrder := make(map[string]int)
73 {
74 // Preorder traversal (dirs first, then files), respecting sortNode ordering
75 var idx int
76 var walk func(nodes []*templates.FileTree)
77 walk = func(nodes []*templates.FileTree) {
78 for _, n := range nodes {
79 if n.IsDir {
80 // Children are already sorted by sortNode
81 walk(n.Children)
82 continue
83 }
84 if n.Path == "" {
85 continue
86 }
87 if _, ok := fileOrder[n.Path]; !ok {
88 fileOrder[n.Path] = idx
89 idx++
90 }
91 }
92 }
93 walk(fileTree)
94 }
95
96 // Prepare per-file views
97 var filesViews []templates.FileView
98 for _, f := range files {
99 path := f.NewName
100 if f.IsDelete {
101 path = f.OldName
102 }
103 if path == "" {
104 continue
105 }
106
107 var fileDiff strings.Builder
108 for _, frag := range f.TextFragments {
109 fileDiff.WriteString(frag.String())
110 }
111
112 it, err := lexer.Tokenise(nil, fileDiff.String())
113 if err != nil {
114 return err
115 }
116 var buf bytes.Buffer
117 if err := formatter.Format(&buf, lightStyle, it); err != nil {
118 return err
119 }
120
121 filesViews = append(filesViews, templates.FileView{
122 Path: path,
123 OldName: f.OldName,
124 NewName: f.NewName,
125 IsNew: f.IsNew,
126 IsDelete: f.IsDelete,
127 IsRename: f.IsRename,
128 IsBinary: f.IsBinary,
129 HasChanges: f.TextFragments != nil,
130 HTML: template.HTML(buf.String()),
131 })
132 }
133
134 // Sort file views to match the file tree order. If for some reason a path
135 // is missing in the order map (shouldn't happen), fall back to case-insensitive
136 // alphabetical order by full path.
137 sort.Slice(filesViews, func(i, j int) bool {
138 oi, iok := fileOrder[filesViews[i].Path]
139 oj, jok := fileOrder[filesViews[j].Path]
140 if iok && jok {
141 return oi < oj
142 }
143 if iok != jok {
144 return iok // known order first
145 }
146 return filesViews[i].Path < filesViews[j].Path
147 })
148
149 currentRef := params.DefaultRef
150 if !commit.Branch.IsEmpty() {
151 currentRef = commit.Branch
152 }
153
154 err = templates.CommitTemplate.ExecuteTemplate(f, "layout.gohtml", templates.CommitParams{
155 LayoutParams: templates.LayoutParams{
156 Title: fmt.Sprintf("%s %s %s@%s", commit.Subject, Dot, params.Name, commit.ShortHash),
157 Name: params.Name,
158 RootHref: rootHref,
159 CurrentRefDir: currentRef.DirName(),
160 Selected: "commits",
161 NeedsSyntaxCSS: true,
162 Year: currentYear(),
163 },
164 Commit: commit,
165 FileTree: fileTree,
166 FileViews: filesViews,
167 })
168 if err != nil {
169 _ = f.Close()
170 return err
171 }
172 if err := f.Close(); err != nil {
173 return err
174 }
175 return nil
176}
177
178func buildFileTree(files []*gitdiff.File) []*templates.FileTree {
179 // Use a synthetic root (not rendered), collect top-level nodes in a map first.
180 root := &templates.FileTree{IsDir: true, Name: "", Path: "", Children: nil}
181
182 for _, f := range files {
183 path := f.NewName
184 if f.IsDelete {
185 path = f.OldName
186 }
187
188 path = filepath.ToSlash(strings.TrimPrefix(path, "./"))
189 if path == "" {
190 continue
191 }
192 parts := strings.Split(path, "/")
193
194 parent := root
195 accum := ""
196 if len(parts) > 1 {
197 for i := 0; i < len(parts)-1; i++ {
198 if accum == "" {
199 accum = parts[i]
200 } else {
201 accum = accum + "/" + parts[i]
202 }
203 parent = findOrCreateDir(parent, parts[i], accum)
204 }
205 }
206
207 fileName := parts[len(parts)-1]
208 node := &templates.FileTree{
209 Name: fileName,
210 Path: path,
211 IsDir: false,
212 IsNew: f.IsNew,
213 IsDelete: f.IsDelete,
214 IsRename: f.IsRename,
215 OldName: f.OldName,
216 NewName: f.NewName,
217 }
218 parent.Children = append(parent.Children, node)
219 }
220
221 sortNode(root)
222 return root.Children
223}
224
225func findOrCreateDir(parent *templates.FileTree, name, path string) *templates.FileTree {
226 for _, ch := range parent.Children {
227 if ch.IsDir && ch.Name == name {
228 return ch
229 }
230 }
231 node := &templates.FileTree{IsDir: true, Name: name, Path: path}
232 parent.Children = append(parent.Children, node)
233 return node
234}
235
236func sortNode(n *templates.FileTree) {
237 if len(n.Children) == 0 {
238 return
239 }
240 sort.Slice(n.Children, func(i, j int) bool {
241 a, b := n.Children[i], n.Children[j]
242 if a.IsDir != b.IsDir {
243 return a.IsDir && !b.IsDir // dirs first
244 }
245 return strings.ToLower(a.Name) < strings.ToLower(b.Name)
246 })
247 for _, ch := range n.Children {
248 if ch.IsDir {
249 sortNode(ch)
250 }
251 }
252}