diff --git a/docs/MINIFY.md b/docs/MINIFY.md new file mode 100644 index 0000000..0ddfa98 --- /dev/null +++ b/docs/MINIFY.md @@ -0,0 +1,398 @@ +# 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:generate` workflow +- ~30-40% CSS size reduction (40KB → ~28KB) +- Minifies CSS, JS, HTML, JSON, SVG, XML + +## Implementation + +### Step 1: Add Dependency + +```bash +go get github.com/tdewolff/minify/v2 +``` + +This will update `go.mod`: +```go +require github.com/tdewolff/minify/v2 v2.20.37 +``` + +### Step 2: Create Minification Script + +Create `pkg/appview/static/minify_assets.go`: + +```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 +//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:** +```html + + +``` + +**After:** +```html + + +``` + +**Files to update:** +- `pkg/appview/templates/components/head.html` +- Any other templates that reference CSS/JS directly + +### Step 5: Build Workflow + +```bash +# 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.css` directly +- 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 +//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 +//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:** +```bash +# 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 +//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): + +```go +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) + +```go +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 + +1. **Edit source files:** + - Modify `pkg/appview/static/css/style.css` + - Modify `pkg/appview/static/js/app.js` + +2. **Test locally:** + ```bash + # Development build (unminified) + go run ./cmd/appview serve + ``` + +3. **Build for production:** + ```bash + # Generate minified assets + go generate ./pkg/appview + + # Build binary + go build -o bin/atcr-appview ./cmd/appview + ``` + +4. **CI/CD:** + ```bash + # In GitHub Actions / CI + go generate ./... + go build ./... + ``` + +### Troubleshooting + +**Q: Minified assets not updating?** +- Delete `*.min.css` and `*.min.js` files +- Run `go generate ./pkg/appview` again + +**Q: Build fails with "package not found"?** +- Run `go mod tidy` to 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: +```bash +# 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:** + +1. **Immediate:** Implement Option 1 (`tdewolff/minify`) + - Pure Go, no external dependencies + - Integrates with existing `go:generate` workflow + - ~30% size reduction + +2. **Future:** Add runtime gzip/brotli compression + - Wrap static handler with compression middleware + - Benefits all static assets + - Standard practice for web servers + +3. **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](https://github.com/tdewolff/minify) - Go minifier library +- [NYTimes/gziphandler](https://github.com/NYTimes/gziphandler) - Gzip middleware +- [Go embed directive](https://pkg.go.dev/embed) - Embedding static files +- [Go generate](https://go.dev/blog/generate) - Code generation tool