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}