mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-05-01 13:35:46 +00:00
research minifing css/js through go generate
This commit is contained in:
398
docs/MINIFY.md
Normal file
398
docs/MINIFY.md
Normal file
@@ -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
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<script src="/static/js/app.js"></script>
|
||||
```
|
||||
|
||||
**After:**
|
||||
```html
|
||||
<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
|
||||
|
||||
```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
|
||||
Reference in New Issue
Block a user