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

Configure Feed

Select the types of activity you want to include in your feed.

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#