A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

research minifing css/js through go generate

evan.jarrett.net d4b9d84d f07376c3

verified
Changed files
+398
docs
+398
docs/MINIFY.md
··· 1 + # CSS/JS Minification for ATCR 2 + 3 + ## Overview 4 + 5 + ATCR embeds static assets (CSS, JavaScript) directly into the binary using Go's `embed` directive. Currently: 6 + 7 + - **CSS Size:** 40KB (`pkg/appview/static/css/style.css`, 2,210 lines) 8 + - **Embedded:** All static files compiled into binary at build time 9 + - **No Minification:** Source files embedded as-is 10 + 11 + **Problem:** Embedded assets increase binary size and network transfer time. 12 + 13 + **Solution:** Minify CSS/JS before embedding to reduce both binary size and network transfer. 14 + 15 + ## Recommended Approach: `tdewolff/minify` 16 + 17 + Use the pure Go `tdewolff/minify` library with `go:generate` to minify assets at build time. 18 + 19 + **Benefits:** 20 + - Pure Go, no external dependencies (Node.js, npm) 21 + - Integrates with existing `go:generate` workflow 22 + - ~30-40% CSS size reduction (40KB → ~28KB) 23 + - Minifies CSS, JS, HTML, JSON, SVG, XML 24 + 25 + ## Implementation 26 + 27 + ### Step 1: Add Dependency 28 + 29 + ```bash 30 + go get github.com/tdewolff/minify/v2 31 + ``` 32 + 33 + This will update `go.mod`: 34 + ```go 35 + require github.com/tdewolff/minify/v2 v2.20.37 36 + ``` 37 + 38 + ### Step 2: Create Minification Script 39 + 40 + Create `pkg/appview/static/minify_assets.go`: 41 + 42 + ```go 43 + //go:build ignore 44 + 45 + package main 46 + 47 + import ( 48 + "fmt" 49 + "log" 50 + "os" 51 + "path/filepath" 52 + 53 + "github.com/tdewolff/minify/v2" 54 + "github.com/tdewolff/minify/v2/css" 55 + "github.com/tdewolff/minify/v2/js" 56 + ) 57 + 58 + func main() { 59 + m := minify.New() 60 + m.AddFunc("text/css", css.Minify) 61 + m.AddFunc("text/javascript", js.Minify) 62 + 63 + // Get the directory of this script 64 + dir, err := os.Getwd() 65 + if err != nil { 66 + log.Fatal(err) 67 + } 68 + 69 + // Minify CSS 70 + if err := minifyFile(m, "text/css", 71 + filepath.Join(dir, "pkg/appview/static/css/style.css"), 72 + filepath.Join(dir, "pkg/appview/static/css/style.min.css"), 73 + ); err != nil { 74 + log.Fatalf("Failed to minify CSS: %v", err) 75 + } 76 + 77 + // Minify JavaScript 78 + if err := minifyFile(m, "text/javascript", 79 + filepath.Join(dir, "pkg/appview/static/js/app.js"), 80 + filepath.Join(dir, "pkg/appview/static/js/app.min.js"), 81 + ); err != nil { 82 + log.Fatalf("Failed to minify JS: %v", err) 83 + } 84 + 85 + fmt.Println("✓ Assets minified successfully") 86 + } 87 + 88 + func minifyFile(m *minify.M, mediatype, src, dst string) error { 89 + // Read source file 90 + input, err := os.ReadFile(src) 91 + if err != nil { 92 + return fmt.Errorf("read %s: %w", src, err) 93 + } 94 + 95 + // Minify 96 + output, err := m.Bytes(mediatype, input) 97 + if err != nil { 98 + return fmt.Errorf("minify %s: %w", src, err) 99 + } 100 + 101 + // Write minified output 102 + if err := os.WriteFile(dst, output, 0644); err != nil { 103 + return fmt.Errorf("write %s: %w", dst, err) 104 + } 105 + 106 + // Print size reduction 107 + originalSize := len(input) 108 + minifiedSize := len(output) 109 + reduction := float64(originalSize-minifiedSize) / float64(originalSize) * 100 110 + 111 + fmt.Printf(" %s: %d bytes → %d bytes (%.1f%% reduction)\n", 112 + filepath.Base(src), originalSize, minifiedSize, reduction) 113 + 114 + return nil 115 + } 116 + ``` 117 + 118 + ### Step 3: Add `go:generate` Directive 119 + 120 + Add to `pkg/appview/ui.go` (before the `//go:embed` directive): 121 + 122 + ```go 123 + //go:generate go run ./static/minify_assets.go 124 + 125 + //go:embed static 126 + var staticFS embed.FS 127 + ``` 128 + 129 + ### Step 4: Update HTML Templates 130 + 131 + Update all template files to reference minified assets: 132 + 133 + **Before:** 134 + ```html 135 + <link rel="stylesheet" href="/static/css/style.css"> 136 + <script src="/static/js/app.js"></script> 137 + ``` 138 + 139 + **After:** 140 + ```html 141 + <link rel="stylesheet" href="/static/css/style.min.css"> 142 + <script src="/static/js/app.min.js"></script> 143 + ``` 144 + 145 + **Files to update:** 146 + - `pkg/appview/templates/components/head.html` 147 + - Any other templates that reference CSS/JS directly 148 + 149 + ### Step 5: Build Workflow 150 + 151 + ```bash 152 + # Generate minified assets 153 + go generate ./pkg/appview 154 + 155 + # Build binary (embeds minified assets) 156 + go build -o bin/atcr-appview ./cmd/appview 157 + 158 + # Or build all 159 + go generate ./... 160 + go build -o bin/atcr-appview ./cmd/appview 161 + go build -o bin/atcr-hold ./cmd/hold 162 + ``` 163 + 164 + ### Step 6: Add to .gitignore 165 + 166 + Add minified files to `.gitignore` since they're generated: 167 + 168 + ``` 169 + # Generated minified assets 170 + pkg/appview/static/css/*.min.css 171 + pkg/appview/static/js/*.min.js 172 + ``` 173 + 174 + **Alternative:** Commit minified files if you want reproducible builds without running `go generate`. 175 + 176 + ## Build Modes (Optional Enhancement) 177 + 178 + Use build tags to serve unminified assets in development: 179 + 180 + **Development (default):** 181 + - Edit `style.css` directly 182 + - No minification, easier debugging 183 + - Faster build times 184 + 185 + **Production (with `-tags production`):** 186 + - Use minified assets 187 + - Smaller binary size 188 + - Optimized for deployment 189 + 190 + ### Implementation with Build Tags 191 + 192 + **pkg/appview/ui.go** (development): 193 + ```go 194 + //go:build !production 195 + 196 + //go:embed static 197 + var staticFS embed.FS 198 + 199 + func StylePath() string { return "/static/css/style.css" } 200 + func ScriptPath() string { return "/static/js/app.js" } 201 + ``` 202 + 203 + **pkg/appview/ui_production.go** (production): 204 + ```go 205 + //go:build production 206 + 207 + //go:generate go run ./static/minify_assets.go 208 + 209 + //go:embed static 210 + var staticFS embed.FS 211 + 212 + func StylePath() string { return "/static/css/style.min.css" } 213 + func ScriptPath() string { return "/static/js/app.min.js" } 214 + ``` 215 + 216 + **Usage:** 217 + ```bash 218 + # Development build (unminified) 219 + go build ./cmd/appview 220 + 221 + # Production build (minified) 222 + go generate ./pkg/appview 223 + go build -tags production ./cmd/appview 224 + ``` 225 + 226 + ## Alternative Approaches 227 + 228 + ### Option 2: External Minifier (cssnano, esbuild) 229 + 230 + Use Node.js-based minifiers via `go:generate`: 231 + 232 + ```go 233 + //go:generate sh -c "npx cssnano static/css/style.css static/css/style.min.css" 234 + //go:generate sh -c "npx esbuild static/js/app.js --minify --outfile=static/js/app.min.js" 235 + ``` 236 + 237 + **Pros:** 238 + - Best-in-class minification (potentially better than tdewolff) 239 + - Wide ecosystem of tools 240 + 241 + **Cons:** 242 + - Requires Node.js/npm in build environment 243 + - Cross-platform compatibility issues (Windows vs Unix) 244 + - External dependency management 245 + 246 + ### Option 3: Runtime Gzip Compression 247 + 248 + Compress assets at runtime (complementary to minification): 249 + 250 + ```go 251 + import "github.com/NYTimes/gziphandler" 252 + 253 + // Wrap static handler 254 + mux.Handle("/static/", gziphandler.GzipHandler(appview.StaticHandler())) 255 + ``` 256 + 257 + **Pros:** 258 + - Works for all static files (images, fonts) 259 + - ~70-80% size reduction over network 260 + - No build changes needed 261 + 262 + **Cons:** 263 + - Doesn't reduce binary size 264 + - Adds runtime CPU cost 265 + - Should be combined with minification for best results 266 + 267 + ### Option 4: Brotli Compression (Better than Gzip) 268 + 269 + ```go 270 + import "github.com/andybalholm/brotli" 271 + 272 + // Custom handler with brotli 273 + func BrotliHandler(h http.Handler) http.Handler { 274 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 275 + if !strings.Contains(r.Header.Get("Accept-Encoding"), "br") { 276 + h.ServeHTTP(w, r) 277 + return 278 + } 279 + w.Header().Set("Content-Encoding", "br") 280 + bw := brotli.NewWriterLevel(w, brotli.DefaultCompression) 281 + defer bw.Close() 282 + h.ServeHTTP(&brotliResponseWriter{Writer: bw, ResponseWriter: w}, r) 283 + }) 284 + } 285 + ``` 286 + 287 + ## Expected Benefits 288 + 289 + ### File Size Reduction 290 + 291 + **Current (unminified):** 292 + - CSS: 40KB 293 + - JS: ~5KB (estimated) 294 + - **Total embedded:** ~45KB 295 + 296 + **With Minification:** 297 + - CSS: ~28KB (30% reduction) 298 + - JS: ~3KB (40% reduction) 299 + - **Total embedded:** ~31KB 300 + - **Binary size savings:** ~14KB 301 + 302 + **With Minification + Gzip (network transfer):** 303 + - CSS: ~8KB (80% reduction from original) 304 + - JS: ~1.5KB (70% reduction from original) 305 + - **Total transferred:** ~9.5KB 306 + 307 + ### Performance Impact 308 + 309 + - **Build time:** +1-2 seconds (running minifier) 310 + - **Runtime:** No impact (files pre-minified) 311 + - **Network:** 75% less data transferred (with gzip) 312 + - **Browser parsing:** Slightly faster (smaller files) 313 + 314 + ## Maintenance 315 + 316 + ### Development Workflow 317 + 318 + 1. **Edit source files:** 319 + - Modify `pkg/appview/static/css/style.css` 320 + - Modify `pkg/appview/static/js/app.js` 321 + 322 + 2. **Test locally:** 323 + ```bash 324 + # Development build (unminified) 325 + go run ./cmd/appview serve 326 + ``` 327 + 328 + 3. **Build for production:** 329 + ```bash 330 + # Generate minified assets 331 + go generate ./pkg/appview 332 + 333 + # Build binary 334 + go build -o bin/atcr-appview ./cmd/appview 335 + ``` 336 + 337 + 4. **CI/CD:** 338 + ```bash 339 + # In GitHub Actions / CI 340 + go generate ./... 341 + go build ./... 342 + ``` 343 + 344 + ### Troubleshooting 345 + 346 + **Q: Minified assets not updating?** 347 + - Delete `*.min.css` and `*.min.js` files 348 + - Run `go generate ./pkg/appview` again 349 + 350 + **Q: Build fails with "package not found"?** 351 + - Run `go mod tidy` to download dependencies 352 + 353 + **Q: CSS broken after minification?** 354 + - Check for syntax errors in source CSS 355 + - Minifier is strict about valid CSS 356 + 357 + ## Integration with Existing Build 358 + 359 + ATCR already uses `go:generate` for: 360 + - CBOR generation (`pkg/atproto/lexicon.go`) 361 + - License downloads (`pkg/appview/licenses/licenses.go`) 362 + 363 + Minification follows the same pattern: 364 + ```bash 365 + # Generate all (CBOR, licenses, minified assets) 366 + go generate ./... 367 + 368 + # Build all binaries 369 + go build -o bin/atcr-appview ./cmd/appview 370 + go build -o bin/atcr-hold ./cmd/hold 371 + go build -o bin/docker-credential-atcr ./cmd/credential-helper 372 + ``` 373 + 374 + ## Recommendation 375 + 376 + **For ATCR:** 377 + 378 + 1. **Immediate:** Implement Option 1 (`tdewolff/minify`) 379 + - Pure Go, no external dependencies 380 + - Integrates with existing `go:generate` workflow 381 + - ~30% size reduction 382 + 383 + 2. **Future:** Add runtime gzip/brotli compression 384 + - Wrap static handler with compression middleware 385 + - Benefits all static assets 386 + - Standard practice for web servers 387 + 388 + 3. **Long-term:** Consider build modes (development vs production) 389 + - Use unminified assets in development 390 + - Use minified assets in production builds 391 + - Best developer experience 392 + 393 + ## References 394 + 395 + - [tdewolff/minify](https://github.com/tdewolff/minify) - Go minifier library 396 + - [NYTimes/gziphandler](https://github.com/NYTimes/gziphandler) - Gzip middleware 397 + - [Go embed directive](https://pkg.go.dev/embed) - Embedding static files 398 + - [Go generate](https://go.dev/blog/generate) - Code generation tool