penguin/flatgit

flatgit showcase

internal/render/render.go

raw ยท 10685 bytes

package render

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"html/template"
	"io/fs"
	"net/url"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"
	"unicode/utf8"

	"github.com/tgckpg/flatgit/internal/config"
	"github.com/tgckpg/flatgit/internal/gitcmd"
)

const maxBlobHTMLBytes = 512 * 1024

type Renderer struct {
	Git        *gitcmd.Runner
	PublicURL  string
	MaxCommits int
}

type RefInfo struct {
	Name   string `json:"name"`
	Short  string `json:"short"`
	Kind   string `json:"kind"`
	Commit string `json:"commit"`
}

type CommitInfo struct {
	Hash    string `json:"hash"`
	Short   string `json:"short"`
	Author  string `json:"author"`
	Email   string `json:"email,omitempty"`
	Date    string `json:"date"`
	Subject string `json:"subject"`
}

type TreeEntry struct {
	Mode string `json:"mode"`
	Type string `json:"type"`
	Hash string `json:"hash"`
	Path string `json:"path"`
	Size int64  `json:"size,omitempty"`
}

func New(git *gitcmd.Runner, publicURL string, maxCommits int) *Renderer {
	if maxCommits <= 0 {
		maxCommits = 500
	}
	return &Renderer{Git: git, PublicURL: publicURL, MaxCommits: maxCommits}
}

func (r *Renderer) RenderRepo(ctx context.Context, repo config.Repo) error {
	if !isSafeOutput(repo.OutputDir) {
		return fmt.Errorf("unsafe output dir %q", repo.OutputDir)
	}

	commit, err := r.resolveDefaultCommit(ctx, repo)
	if err != nil {
		return err
	}
	refs, err := r.refs(ctx, repo)
	if err != nil {
		return err
	}
	commits, err := r.commits(ctx, repo, commit)
	if err != nil {
		return err
	}
	tree, err := r.tree(ctx, repo, commit)
	if err != nil {
		return err
	}

	next := repo.OutputDir + ".next"
	old := repo.OutputDir + ".old"
	_ = os.RemoveAll(next)
	_ = os.RemoveAll(old)
	if err := os.MkdirAll(next, 0o755); err != nil {
		return err
	}

	if err := writeStatic(next); err != nil {
		return err
	}

	manifest := NewManifest(repo, repo.DefaultBranch, commit)
	if err := writeJSON(filepath.Join(next, "manifest.json"), manifest); err != nil {
		return err
	}

	if err := writeJSON(filepath.Join(next, "refs.json"), refs); err != nil {
		return err
	}
	if err := writeJSON(filepath.Join(next, "commits.json"), commits); err != nil {
		return err
	}
	if err := writeJSON(filepath.Join(next, "tree.json"), tree); err != nil {
		return err
	}

	page := basePage{RepoManifest: manifest, GeneratedAt: manifest.GeneratedAt.Format(time.RFC3339)}
	if err := renderTemplate(filepath.Join(next, "index.html"), indexTemplate, struct {
		basePage
		Refs    []RefInfo
		Commits []CommitInfo
		Files   []TreeEntry
	}{page, refs, firstCommits(commits, 20), firstTree(tree, 50)}); err != nil {
		return err
	}
	if err := renderTemplate(filepath.Join(next, "refs.html"), refsTemplate, struct {
		basePage
		Refs []RefInfo
	}{page, refs}); err != nil {
		return err
	}
	if err := renderTemplate(filepath.Join(next, "log.html"), logTemplate, struct {
		basePage
		Commits []CommitInfo
	}{page, commits}); err != nil {
		return err
	}

	branchSlug := refSlug(repo.DefaultBranch)
	if err := renderTemplate(filepath.Join(next, "tree", branchSlug, "index.html"), treeTemplate, struct {
		basePage
		Ref   string
		Files []TreeEntry
	}{page, repo.DefaultBranch, tree}); err != nil {
		return err
	}

	for _, c := range commits {
		show, err := r.Git.Text(ctx, repo.MirrorDir, "show", "--date=iso-strict-local", "--stat", "--patch", "--find-renames", "--no-ext-diff", "--no-color", c.Hash)
		if err != nil {
			return err
		}
		if err := renderTemplate(filepath.Join(next, "commit", c.Hash+".html"), commitTemplate, struct {
			basePage
			Commit CommitInfo
			Show   string
		}{page, c, show}); err != nil {
			return err
		}
		if err := writeJSON(filepath.Join(next, "commit", c.Hash+".json"), c); err != nil {
			return err
		}
	}

	for _, e := range tree {
		if e.Type != "blob" {
			continue
		}
		content, err := r.Git.Output(ctx, repo.MirrorDir, "show", "--date=iso-strict-local", commit+":"+e.Path)
		if err != nil {
			return err
		}
		rawPath := filepath.Join(next, "raw", branchSlug, filepath.FromSlash(e.Path))
		if err := writeFile(rawPath, content, 0o644); err != nil {
			return err
		}

		blobPath := filepath.Join(next, "blob", branchSlug, filepath.FromSlash(e.Path)+".html")
		blob := blobView{Path: e.Path, Size: int64(len(content)), RawHref: relPath(filepath.Dir(blobPath), rawPath)}
		if len(content) > maxBlobHTMLBytes || bytes.IndexByte(content, 0) >= 0 || !utf8.Valid(content) {
			blob.Binary = true
		} else {
			blob.Text = string(content)
		}
		if err := renderTemplate(blobPath, blobTemplate, struct {
			basePage
			Blob blobView
		}{page, blob}); err != nil {
			return err
		}
	}

	if err := publish(next, repo.OutputDir, old); err != nil {
		return err
	}
	return nil
}

func (r *Renderer) resolveDefaultCommit(ctx context.Context, repo config.Repo) (string, error) {
	candidates := []string{"refs/heads/" + repo.DefaultBranch, repo.DefaultBranch, "HEAD"}
	var last error
	for _, ref := range candidates {
		out, err := r.Git.Text(ctx, repo.MirrorDir, "rev-parse", "--verify", ref)
		if err == nil {
			return strings.TrimSpace(out), nil
		}
		last = err
	}
	return "", last
}

func (r *Renderer) refs(ctx context.Context, repo config.Repo) ([]RefInfo, error) {
	out, err := r.Git.Output(ctx, repo.MirrorDir, "for-each-ref", "--format=%(refname)%00%(objectname)", "refs/heads", "refs/tags")
	if err != nil {
		return nil, err
	}
	var refs []RefInfo
	for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
		if line == "" {
			continue
		}
		parts := strings.Split(line, "\x00")
		if len(parts) < 2 {
			continue
		}
		name := parts[0]
		kind := "ref"
		short := name
		if strings.HasPrefix(name, "refs/heads/") {
			kind = "head"
			short = strings.TrimPrefix(name, "refs/heads/")
		} else if strings.HasPrefix(name, "refs/tags/") {
			kind = "tag"
			short = strings.TrimPrefix(name, "refs/tags/")
		}
		refs = append(refs, RefInfo{Name: name, Short: short, Kind: kind, Commit: parts[1]})
	}
	return refs, nil
}

func (r *Renderer) commits(ctx context.Context, repo config.Repo, ref string) ([]CommitInfo, error) {
	limit := strconv.Itoa(r.MaxCommits)
	format := "%H%x00%h%x00%an%x00%ae%x00%ad%x00%s"
	out, err := r.Git.Output(ctx, repo.MirrorDir, "log", "--date=iso-strict-local", "-n", limit, "--pretty=format:"+format, ref, "--")
	if err != nil {
		return nil, err
	}
	var commits []CommitInfo
	for _, line := range strings.Split(string(out), "\n") {
		if line == "" {
			continue
		}
		p := strings.SplitN(line, "\x00", 6)
		if len(p) != 6 {
			continue
		}
		commits = append(commits, CommitInfo{Hash: p[0], Short: p[1], Author: p[2], Email: p[3], Date: p[4], Subject: p[5]})
	}
	return commits, nil
}

func (r *Renderer) tree(ctx context.Context, repo config.Repo, ref string) ([]TreeEntry, error) {
	out, err := r.Git.Output(ctx, repo.MirrorDir, "ls-tree", "-r", "-z", ref)
	if err != nil {
		return nil, err
	}
	var entries []TreeEntry
	for _, item := range bytes.Split(out, []byte{0}) {
		if len(item) == 0 {
			continue
		}
		before, path, ok := bytes.Cut(item, []byte{'\t'})
		if !ok {
			continue
		}
		fields := strings.Fields(string(before))
		if len(fields) != 3 {
			continue
		}
		entry := TreeEntry{Mode: fields[0], Type: fields[1], Hash: fields[2], Path: string(path)}
		if entry.Type == "blob" {
			if sizeOut, err := r.Git.Text(ctx, repo.MirrorDir, "cat-file", "-s", entry.Hash); err == nil {
				if n, err := strconv.ParseInt(strings.TrimSpace(sizeOut), 10, 64); err == nil {
					entry.Size = n
				}
			}
		}
		entries = append(entries, entry)
	}
	return entries, nil
}

type basePage struct {
	RepoManifest Manifest
	GeneratedAt  string
}

type blobView struct {
	Path    string
	Size    int64
	RawHref string
	Text    string
	Binary  bool
}

func writeStatic(root string) error {
	css := `body{max-width:1100px;margin:2rem auto;padding:0 1rem;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.45}a{color:inherit}header{border-bottom:1px solid #ddd;margin-bottom:1rem}nav a{margin-right:1rem}.muted{color:#666}.mono,pre,code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}table{border-collapse:collapse;width:100%}td,th{border-bottom:1px solid #eee;padding:.35rem;text-align:left;vertical-align:top}pre{overflow:auto;background:#f6f6f6;padding:1rem;border:1px solid #eee}.pill{border:1px solid #ddd;border-radius:999px;padding:.1rem .45rem;font-size:.85em}`
	return writeFile(filepath.Join(root, "style.css"), []byte(css), 0o644)
}

func renderTemplate(path, text string, data any) error {
	t, err := template.New(filepath.Base(path)).Funcs(template.FuncMap{
		"short": func(s string) string {
			if len(s) > 12 {
				return s[:12]
			}
			return s
		},
		"href": func(parts ...string) string {
			joined := strings.Join(parts, "/")
			return url.PathEscape(joined)
		},
		"blobHref": func(refSlug, p string) string {
			return "blob/" + refSlug + "/" + strings.TrimPrefix(p, "/") + ".html"
		},
	}).Parse(layoutTemplate + text)
	if err != nil {
		return err
	}
	var b bytes.Buffer
	if err := t.ExecuteTemplate(&b, "layout", data); err != nil {
		return err
	}
	return writeFile(path, b.Bytes(), 0o644)
}

func writeJSON(path string, v any) error {
	var b bytes.Buffer
	enc := json.NewEncoder(&b)
	enc.SetIndent("", "  ")
	if err := enc.Encode(v); err != nil {
		return err
	}
	return writeFile(path, b.Bytes(), 0o644)
}

func writeFile(path string, b []byte, mode fs.FileMode) error {
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}
	return os.WriteFile(path, b, mode)
}

func publish(next, current, old string) error {
	_ = os.RemoveAll(old)
	if _, err := os.Stat(current); err == nil {
		if err := os.Rename(current, old); err != nil {
			return err
		}
	}
	if err := os.Rename(next, current); err != nil {
		if _, statErr := os.Stat(old); statErr == nil {
			_ = os.Rename(old, current)
		}
		return err
	}
	_ = os.RemoveAll(old)
	return nil
}

func firstCommits(in []CommitInfo, n int) []CommitInfo {
	if len(in) <= n {
		return in
	}
	return in[:n]
}

func firstTree(in []TreeEntry, n int) []TreeEntry {
	if len(in) <= n {
		return in
	}
	return in[:n]
}

func refSlug(ref string) string {
	ref = strings.TrimPrefix(ref, "refs/heads/")
	ref = strings.TrimPrefix(ref, "refs/tags/")
	ref = strings.ReplaceAll(ref, "/", "__")
	ref = strings.ReplaceAll(ref, "..", "_")
	if ref == "" {
		return "default"
	}
	return ref
}

func relPath(fromDir, to string) string {
	rel, err := filepath.Rel(fromDir, to)
	if err != nil {
		return to
	}
	return filepath.ToSlash(rel)
}

func isSafeOutput(path string) bool {
	clean := filepath.Clean(path)
	return clean != "." && clean != "/" && clean != ""
}