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

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.

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#

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.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: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#

  1. Edit source files:

    • Modify pkg/appview/static/css/style.css
    • Modify pkg/appview/static/js/app.js
  2. Test locally:

    # Development build (unminified)
    go run ./cmd/appview serve
    
  3. Build for production:

    # Generate minified assets
    go generate ./pkg/appview
    
    # Build binary
    go build -o bin/atcr-appview ./cmd/appview
    
  4. CI/CD:

    # 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:

# 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#