Commit 9678cd2

Anton Medvedev <anton@medv.io>
2025-11-30 19:44:32
Add HTML post-processing with minify and gzip support
1 parent c9c4bae
go.mod
@@ -4,9 +4,13 @@ go 1.24.0
 
 require (
 	github.com/alecthomas/chroma/v2 v2.20.0
+	github.com/tdewolff/minify/v2 v2.24.7
 	github.com/yuin/goldmark v1.7.8
 	github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
 	golang.org/x/net v0.47.0
 )
 
-require github.com/dlclark/regexp2 v1.11.5 // indirect
+require (
+	github.com/dlclark/regexp2 v1.11.5 // indirect
+	github.com/tdewolff/parse/v2 v2.8.5 // indirect
+)
go.sum
@@ -17,6 +17,12 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/tdewolff/minify/v2 v2.24.7 h1:aJNQ2s0WYZg58j5ZJQo0Mk0UXMPhvCXCMHbJEgWIDXQ=
+github.com/tdewolff/minify/v2 v2.24.7/go.mod h1:0Ukj0CRpo/sW/nd8uZ4ccXaV1rEVIWA3dj8U7+Shhfw=
+github.com/tdewolff/parse/v2 v2.8.5 h1:ZmBiA/8Do5Rpk7bDye0jbbDUpXXbCdc3iah4VeUvwYU=
+github.com/tdewolff/parse/v2 v2.8.5/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
+github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
+github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
 github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
main.go
@@ -19,6 +19,8 @@ var (
 	flagDefaultBranch string
 	flagTheme         string
 	flagPreviewThemes bool
+	flagMinify        bool
+	flagGzip          bool
 )
 
 type Params struct {
@@ -63,6 +65,8 @@ func main() {
 	flag.StringVar(&flagDefaultBranch, "default-branch", "", "Default branch to use (autodetect master or main)")
 	flag.StringVar(&flagTheme, "theme", "github", "Style theme")
 	flag.BoolVar(&flagPreviewThemes, "preview-themes", false, "Preview available themes")
+	flag.BoolVar(&flagMinify, "minify", false, "Minify all generated HTML files")
+	flag.BoolVar(&flagGzip, "gzip", false, "Compress all generated HTML files")
 	flag.Usage = usage
 	flag.Parse()
 
@@ -228,6 +232,13 @@ func main() {
 			panic(err)
 		}
 	}
+
+	if flagMinify || flagGzip {
+		echo("> post-processing HTML...")
+		if err := postProcessHTML(params.OutputDir, flagMinify, flagGzip); err != nil {
+			panic(err)
+		}
+	}
 }
 
 func usage() {
post_process.go
@@ -0,0 +1,138 @@
+package main
+
+import (
+	"compress/gzip"
+	"io"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"sync"
+
+	"github.com/tdewolff/minify/v2"
+	"github.com/tdewolff/minify/v2/css"
+	"github.com/tdewolff/minify/v2/html"
+	"github.com/tdewolff/minify/v2/svg"
+
+	"github.com/antonmedv/gitmal/pkg/progress_bar"
+)
+
+func postProcessHTML(root string, doMinify bool, doGzip bool) error {
+	// 1) Collect all HTML files first
+	var files []string
+	if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+		if err != nil {
+			return err
+		}
+		if d.IsDir() {
+			return nil
+		}
+		if strings.HasSuffix(d.Name(), ".html") {
+			files = append(files, path)
+		}
+		return nil
+	}); err != nil {
+		return err
+	}
+
+	if len(files) == 0 {
+		return nil
+	}
+
+	// 2) Setup progress bar
+	labels := []string{}
+	if doMinify {
+		labels = append(labels, "minify")
+	}
+	if doGzip {
+		labels = append(labels, "gzip")
+	}
+	pb := progress_bar.NewProgressBar(strings.Join(labels, " + "), len(files))
+	defer pb.Done()
+
+	// 3) Worker pool
+	workers := runtime.NumCPU()
+	if workers < 1 {
+		workers = 1
+	}
+	jobs := make(chan string, workers*2)
+	var wg sync.WaitGroup
+	var mu sync.Mutex
+	var firstErr error
+
+	workerFn := func() {
+		defer wg.Done()
+		var m *minify.M
+		if doMinify {
+			m = minify.New()
+			m.AddFunc("text/html", html.Minify)
+			m.AddFunc("text/css", css.Minify)
+			m.AddFunc("image/svg+xml", svg.Minify)
+		}
+		for path := range jobs {
+			data, err := os.ReadFile(path)
+			if err == nil && doMinify {
+				if md, e := m.Bytes("text/html", data); e == nil {
+					data = md
+				} else {
+					err = e
+				}
+			}
+			if err == nil {
+				if doGzip {
+					// write to file.html.gz
+					gzPath := path + ".gz"
+					if e := writeGzip(gzPath, data); e != nil {
+						err = e
+					} else if e := os.Remove(path); e != nil { // remove original .html
+						err = e
+					}
+				} else {
+					if e := os.WriteFile(path, data, 0o644); e != nil {
+						err = e
+					}
+				}
+			}
+
+			if err != nil {
+				mu.Lock()
+				if firstErr == nil {
+					firstErr = err
+				}
+				mu.Unlock()
+			}
+			pb.Inc()
+		}
+	}
+
+	wg.Add(workers)
+	for i := 0; i < workers; i++ {
+		go workerFn()
+	}
+	for _, f := range files {
+		jobs <- f
+	}
+	close(jobs)
+	wg.Wait()
+
+	return firstErr
+}
+
+func writeGzip(path string, data []byte) error {
+	f, err := os.Create(path)
+	if err != nil {
+		return err
+	}
+	defer func() { _ = f.Close() }()
+	gw := gzip.NewWriter(f)
+	gw.Name = filepath.Base(strings.TrimSuffix(path, ".gz"))
+	if _, err := io.Copy(gw, strings.NewReader(string(data))); err != nil {
+		_ = gw.Close()
+		return err
+	}
+	if err := gw.Close(); err != nil {
+		return err
+	}
+	return nil
+}