+398
docs/MINIFY.md
+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