CSS/JS Minification for ATCR#
Overview#
ATCR embeds static assets (CSS, JavaScript) directly into the binary using Go's embed directive. Currently:
- CSS Size: 40KB (
pkg/appview/static/css/style.css, 2,210 lines) - Embedded: All static files compiled into binary at build time
- No Minification: Source files embedded as-is
Problem: Embedded assets increase binary size and network transfer time.
Solution: Minify CSS/JS before embedding to reduce both binary size and network transfer.
Recommended Approach: tdewolff/minify#
Use the pure Go tdewolff/minify library with go:generate to minify assets at build time.
Benefits:
- Pure Go, no external dependencies (Node.js, npm)
- Integrates with existing
go:generateworkflow - ~30-40% CSS size reduction (40KB → ~28KB)
- Minifies CSS, JS, HTML, JSON, SVG, XML
Implementation#
Step 1: Add Dependency#
go get github.com/tdewolff/minify/v2
This will update go.mod:
require github.com/tdewolff/minify/v2 v2.20.37
Step 2: Create Minification Script#
Create pkg/appview/static/minify_assets.go:
//go:build ignore
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"github.com/tdewolff/minify/v2"
"github.com/tdewolff/minify/v2/css"
"github.com/tdewolff/minify/v2/js"
)
func main() {
m := minify.New()
m.AddFunc("text/css", css.Minify)
m.AddFunc("text/javascript", js.Minify)
// Get the directory of this script
dir, err := os.Getwd()
if err != nil {
log.Fatal(err)
}
// Minify CSS
if err := minifyFile(m, "text/css",
filepath.Join(dir, "pkg/appview/static/css/style.css"),
filepath.Join(dir, "pkg/appview/static/css/style.min.css"),
); err != nil {
log.Fatalf("Failed to minify CSS: %v", err)
}
// Minify JavaScript
if err := minifyFile(m, "text/javascript",
filepath.Join(dir, "pkg/appview/static/js/app.js"),
filepath.Join(dir, "pkg/appview/static/js/app.min.js"),
); err != nil {
log.Fatalf("Failed to minify JS: %v", err)
}
fmt.Println("✓ Assets minified successfully")
}
func minifyFile(m *minify.M, mediatype, src, dst string) error {
// Read source file
input, err := os.ReadFile(src)
if err != nil {
return fmt.Errorf("read %s: %w", src, err)
}
// Minify
output, err := m.Bytes(mediatype, input)
if err != nil {
return fmt.Errorf("minify %s: %w", src, err)
}
// Write minified output
if err := os.WriteFile(dst, output, 0644); err != nil {
return fmt.Errorf("write %s: %w", dst, err)
}
// Print size reduction
originalSize := len(input)
minifiedSize := len(output)
reduction := float64(originalSize-minifiedSize) / float64(originalSize) * 100
fmt.Printf(" %s: %d bytes → %d bytes (%.1f%% reduction)\n",
filepath.Base(src), originalSize, minifiedSize, reduction)
return nil
}
Step 3: Add go:generate Directive#
Add to pkg/appview/ui.go (before the //go:embed directive):
//go:generate go run ./static/minify_assets.go
//go:embed static
var staticFS embed.FS
Step 4: Update HTML Templates#
Update all template files to reference minified assets:
Before:
<link rel="stylesheet" href="/static/css/style.css">
<script src="/static/js/app.js"></script>
After:
<link rel="stylesheet" href="/static/css/style.min.css">
<script src="/static/js/app.min.js"></script>
Files to update:
pkg/appview/templates/components/head.html- Any other templates that reference CSS/JS directly
Step 5: Build Workflow#
# Generate minified assets
go generate ./pkg/appview
# Build binary (embeds minified assets)
go build -o bin/atcr-appview ./cmd/appview
# Or build all
go generate ./...
go build -o bin/atcr-appview ./cmd/appview
go build -o bin/atcr-hold ./cmd/hold
Step 6: Add to .gitignore#
Add minified files to .gitignore since they're generated:
# Generated minified assets
pkg/appview/static/css/*.min.css
pkg/appview/static/js/*.min.js
Alternative: Commit minified files if you want reproducible builds without running go generate.
Build Modes (Optional Enhancement)#
Use build tags to serve unminified assets in development:
Development (default):
- Edit
style.cssdirectly - No minification, easier debugging
- Faster build times
Production (with -tags production):
- Use minified assets
- Smaller binary size
- Optimized for deployment
Implementation with Build Tags#
pkg/appview/ui.go (development):
//go:build !production
//go:embed static
var staticFS embed.FS
func StylePath() string { return "/static/css/style.css" }
func ScriptPath() string { return "/static/js/app.js" }
pkg/appview/ui_production.go (production):
//go:build production
//go:generate go run ./static/minify_assets.go
//go:embed static
var staticFS embed.FS
func StylePath() string { return "/static/css/style.min.css" }
func ScriptPath() string { return "/static/js/app.min.js" }
Usage:
# Development build (unminified)
go build ./cmd/appview
# Production build (minified)
go generate ./pkg/appview
go build -tags production ./cmd/appview
Alternative Approaches#
Option 2: External Minifier (cssnano, esbuild)#
Use Node.js-based minifiers via go:generate:
//go:generate sh -c "npx cssnano static/css/style.css static/css/style.min.css"
//go:generate sh -c "npx esbuild static/js/app.js --minify --outfile=static/js/app.min.js"
Pros:
- Best-in-class minification (potentially better than tdewolff)
- Wide ecosystem of tools
Cons:
- Requires Node.js/npm in build environment
- Cross-platform compatibility issues (Windows vs Unix)
- External dependency management
Option 3: Runtime Gzip Compression#
Compress assets at runtime (complementary to minification):
import "github.com/NYTimes/gziphandler"
// Wrap static handler
mux.Handle("/static/", gziphandler.GzipHandler(appview.StaticHandler()))
Pros:
- Works for all static files (images, fonts)
- ~70-80% size reduction over network
- No build changes needed
Cons:
- Doesn't reduce binary size
- Adds runtime CPU cost
- Should be combined with minification for best results
Option 4: Brotli Compression (Better than Gzip)#
import "github.com/andybalholm/brotli"
// Custom handler with brotli
func BrotliHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "br") {
h.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Encoding", "br")
bw := brotli.NewWriterLevel(w, brotli.DefaultCompression)
defer bw.Close()
h.ServeHTTP(&brotliResponseWriter{Writer: bw, ResponseWriter: w}, r)
})
}
Expected Benefits#
File Size Reduction#
Current (unminified):
- CSS: 40KB
- JS: ~5KB (estimated)
- Total embedded: ~45KB
With Minification:
- CSS: ~28KB (30% reduction)
- JS: ~3KB (40% reduction)
- Total embedded: ~31KB
- Binary size savings: ~14KB
With Minification + Gzip (network transfer):
- CSS: ~8KB (80% reduction from original)
- JS: ~1.5KB (70% reduction from original)
- Total transferred: ~9.5KB
Performance Impact#
- Build time: +1-2 seconds (running minifier)
- Runtime: No impact (files pre-minified)
- Network: 75% less data transferred (with gzip)
- Browser parsing: Slightly faster (smaller files)
Maintenance#
Development Workflow#
-
Edit source files:
- Modify
pkg/appview/static/css/style.css - Modify
pkg/appview/static/js/app.js
- Modify
-
Test locally:
# Development build (unminified) go run ./cmd/appview serve -
Build for production:
# Generate minified assets go generate ./pkg/appview # Build binary go build -o bin/atcr-appview ./cmd/appview -
CI/CD:
# In GitHub Actions / CI go generate ./... go build ./...
Troubleshooting#
Q: Minified assets not updating?
- Delete
*.min.cssand*.min.jsfiles - Run
go generate ./pkg/appviewagain
Q: Build fails with "package not found"?
- Run
go mod tidyto download dependencies
Q: CSS broken after minification?
- Check for syntax errors in source CSS
- Minifier is strict about valid CSS
Integration with Existing Build#
ATCR already uses go:generate for:
- CBOR generation (
pkg/atproto/lexicon.go) - License downloads (
pkg/appview/licenses/licenses.go)
Minification follows the same pattern:
# Generate all (CBOR, licenses, minified assets)
go generate ./...
# Build all binaries
go build -o bin/atcr-appview ./cmd/appview
go build -o bin/atcr-hold ./cmd/hold
go build -o bin/docker-credential-atcr ./cmd/credential-helper
Recommendation#
For ATCR:
-
Immediate: Implement Option 1 (
tdewolff/minify)- Pure Go, no external dependencies
- Integrates with existing
go:generateworkflow - ~30% size reduction
-
Future: Add runtime gzip/brotli compression
- Wrap static handler with compression middleware
- Benefits all static assets
- Standard practice for web servers
-
Long-term: Consider build modes (development vs production)
- Use unminified assets in development
- Use minified assets in production builds
- Best developer experience
References#
- tdewolff/minify - Go minifier library
- NYTimes/gziphandler - Gzip middleware
- Go embed directive - Embedding static files
- Go generate - Code generation tool