package generator import ( "bytes" "fmt" "html/template" "os" "path/filepath" "sort" "strings" "github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" "github.com/bluekeyes/go-gitdiff/gitdiff" "mokhan.ca/xlgmokha/gitmal/internal/git" "mokhan.ca/xlgmokha/gitmal/internal/pool" "mokhan.ca/xlgmokha/gitmal/internal/templates" ) func GenerateCommits(commits map[string]git.Commit, params Params) error { outDir := filepath.Join(params.OutputDir, "commit") if err := os.MkdirAll(outDir, 0o755); err != nil { return err } list := make([]git.Commit, 0, len(commits)) for _, c := range commits { list = append(list, c) } return pool.Run(list, func(c git.Commit) error { return generateCommitPage(c, params) }) } func generateCommitPage(commit git.Commit, params Params) error { diff, err := git.CommitDiff(commit.Hash, params.RepoDir) if err != nil { return err } files, _, err := gitdiff.Parse(strings.NewReader(diff)) if err != nil { return err } formatter := html.New( html.WithClasses(true), html.WithCSSComments(false), ) lightStyle := styles.Get("github") lexer := lexers.Get("diff") if lexer == nil { return fmt.Errorf("failed to get lexer for diff") } outPath := filepath.Join(params.OutputDir, "commit", commit.Hash+".html") f, err := os.Create(outPath) if err != nil { return err } rootHref := filepath.ToSlash("../") fileTree := buildFileTree(files) // Create a stable order for files that matches the file tree traversal // so that the per-file views appear in the same order as the sidebar tree. fileOrder := make(map[string]int) { // Preorder traversal (dirs first, then files), respecting sortNode ordering var idx int var walk func(nodes []*templates.FileTree) walk = func(nodes []*templates.FileTree) { for _, n := range nodes { if n.IsDir { // Children are already sorted by sortNode walk(n.Children) continue } if n.Path == "" { continue } if _, ok := fileOrder[n.Path]; !ok { fileOrder[n.Path] = idx idx++ } } } walk(fileTree) } // Prepare per-file views var filesViews []templates.FileView for _, f := range files { path := f.NewName if f.IsDelete { path = f.OldName } if path == "" { continue } var fileDiff strings.Builder for _, frag := range f.TextFragments { fileDiff.WriteString(frag.String()) } it, err := lexer.Tokenise(nil, fileDiff.String()) if err != nil { return err } var buf bytes.Buffer if err := formatter.Format(&buf, lightStyle, it); err != nil { return err } filesViews = append(filesViews, templates.FileView{ Path: path, OldName: f.OldName, NewName: f.NewName, IsNew: f.IsNew, IsDelete: f.IsDelete, IsRename: f.IsRename, IsBinary: f.IsBinary, HasChanges: f.TextFragments != nil, HTML: template.HTML(buf.String()), }) } // Sort file views to match the file tree order. If for some reason a path // is missing in the order map (shouldn't happen), fall back to case-insensitive // alphabetical order by full path. sort.Slice(filesViews, func(i, j int) bool { oi, iok := fileOrder[filesViews[i].Path] oj, jok := fileOrder[filesViews[j].Path] if iok && jok { return oi < oj } if iok != jok { return iok // known order first } return filesViews[i].Path < filesViews[j].Path }) currentRef := params.DefaultRef if !commit.Branch.IsEmpty() { currentRef = commit.Branch } err = templates.CommitTemplate.ExecuteTemplate(f, "layout.gohtml", templates.CommitParams{ LayoutParams: templates.LayoutParams{ Title: fmt.Sprintf("%s %s %s@%s", commit.Subject, Dot, params.Name, commit.ShortHash), Name: params.Name, RootHref: rootHref, CurrentRefDir: currentRef.DirName(), Selected: "commits", NeedsSyntaxCSS: true, }, Commit: commit, FileTree: fileTree, FileViews: filesViews, }) if err != nil { _ = f.Close() return err } if err := f.Close(); err != nil { return err } return nil } func buildFileTree(files []*gitdiff.File) []*templates.FileTree { // Use a synthetic root (not rendered), collect top-level nodes in a map first. root := &templates.FileTree{IsDir: true, Name: "", Path: "", Children: nil} for _, f := range files { path := f.NewName if f.IsDelete { path = f.OldName } path = filepath.ToSlash(strings.TrimPrefix(path, "./")) if path == "" { continue } parts := strings.Split(path, "/") parent := root accum := "" if len(parts) > 1 { for i := 0; i < len(parts)-1; i++ { if accum == "" { accum = parts[i] } else { accum = accum + "/" + parts[i] } parent = findOrCreateDir(parent, parts[i], accum) } } fileName := parts[len(parts)-1] node := &templates.FileTree{ Name: fileName, Path: path, IsDir: false, IsNew: f.IsNew, IsDelete: f.IsDelete, IsRename: f.IsRename, OldName: f.OldName, NewName: f.NewName, } parent.Children = append(parent.Children, node) } sortNode(root) return root.Children } func findOrCreateDir(parent *templates.FileTree, name, path string) *templates.FileTree { for _, ch := range parent.Children { if ch.IsDir && ch.Name == name { return ch } } node := &templates.FileTree{IsDir: true, Name: name, Path: path} parent.Children = append(parent.Children, node) return node } func sortNode(n *templates.FileTree) { if len(n.Children) == 0 { return } sort.Slice(n.Children, func(i, j int) bool { a, b := n.Children[i], n.Children[j] if a.IsDir != b.IsDir { return a.IsDir && !b.IsDir // dirs first } return strings.ToLower(a.Name) < strings.ToLower(b.Name) }) for _, ch := range n.Children { if ch.IsDir { sortNode(ch) } } }