Skip to main content
Kosh’s asset pipeline processes and optimizes static assets using esbuild for CSS/JS bundling and WebP conversion for images. All assets are fingerprinted with content hashes for cache-busting.

Architecture

The asset pipeline runs in builder/services/asset_service.go and orchestrates multiple operations:
func (s *assetServiceImpl) Build(ctx context.Context) error {
    var wg sync.WaitGroup
    wg.Add(2)

    // 1. Static assets (images, fonts, WASM)
    go func() {
        // Copy theme static files
        // Copy site static files
        // Copy WASM search engine
        // Copy logo
    }()

    // 2. CSS/JS bundling with esbuild
    go func() {
        assets := utils.BuildAssetsEsbuild(...)
        s.renderer.SetAssets(assets)  // Critical: populate hash map
    }()

    wg.Wait()
    return nil
}
Assets build synchronously before post rendering. This ensures the Assets map is populated when templates reference hashed filenames.

CSS/JS Bundling

Esbuild Integration

Kosh uses esbuild for fast bundling, minification, and tree-shaking:
func BuildAssetsEsbuild(sourceFs, destFs afero.Fs, staticDir, destStaticDir string, 
                        compress bool, registerFn func(string), cacheDir string, 
                        force bool) (map[string]string, error)
Features:
  • Minification: Remove whitespace, shorten variable names
  • Tree-shaking: Eliminate unused code
  • Source maps: Optional for debugging
  • Cache-busting: Content-based hashing

Entry Points

Kosh automatically discovers entry points:
themes/<theme>/static/
├── css/
│   ├── layout.css      # Entry point
│   ├── syntax.css      # Entry point
│   └── components/     # Imported by layout.css
└── js/
    ├── main.js         # Entry point
    └── search.js       # Entry point
Bundling behavior:
  • Each top-level .css and .js file becomes an entry point
  • Files in subdirectories are only included if imported
  • Output filenames include content hashes: layout-3a9f2d.css

Asset Hash Map

Bundled assets populate the Assets map:
assets := map[string]string{
    "/static/css/layout.css":  "/static/css/layout-3a9f2d.css",
    "/static/css/syntax.css":  "/static/css/syntax-8b1e4f.css",
    "/static/js/main.js":      "/static/js/main-c7d3a2.js",
}
Template usage:
<!-- Before: hardcoded path -->
<link rel="stylesheet" href="/static/css/layout.css">

<!-- After: hashed path -->
<link rel="stylesheet" href="{{ index .Assets "/static/css/layout.css" }}">
<!-- Renders as: /static/css/layout-3a9f2d.css -->
When you update CSS, the hash changes automatically, busting browser caches.

Image Optimization

WebP Conversion

Kosh converts images to WebP format for smaller file sizes:
func CopyDirVFS(sourceFs, destFs afero.Fs, srcDir, destDir string, 
                compressImages bool, excludeExts []string, 
                registerFn func(string), imageCacheDir string, 
                imageWorkers int) error
Supported formats:
  • JPEG → WebP
  • PNG → WebP
  • GIF → WebP (first frame)
Configuration (in kosh.yaml):
compressImages: true      # Enable WebP conversion
imageWorkers: 4          # Parallel conversion workers

Image Cache

Converted images are cached in .kosh-cache/images/ to avoid reprocessing:
.kosh-cache/images/
├── 3a9f2d8e1c...webp  # Cached by source file hash
└── 7b2e4f1a9c...webp
Cache lookup:
sourceHash := HashFile(sourcePath)
cachePath := filepath.Join(imageCacheDir, sourceHash+".webp")

if exists(cachePath) {
    copy(cachePath, destPath)  // Use cached version
} else {
    convertToWebP(sourcePath, cachePath)  // Convert and cache
    copy(cachePath, destPath)
}

Size Comparison

FormatSizeQuality
Original JPEG245 KBBaseline
WebP (quality 85)112 KBVisually identical
Savings54%No loss
WebP conversion runs in parallel using a worker pool. Set imageWorkers: 8 on multi-core machines for faster builds.

Static File Copying

Assets are copied from two locations:

1. Theme Static Directory

From builder/services/asset_service.go:50-57:
// Theme Static: themes/<theme>/static/
if exists, _ := afero.Exists(s.sourceFs, s.cfg.StaticDir); exists {
    destStaticDir := filepath.Join(s.cfg.OutputDir, "static")
    utils.CopyDirVFS(s.sourceFs, s.destFs, s.cfg.StaticDir, destStaticDir, 
                     s.cfg.CompressImages, []string{".css", ".js"}, 
                     s.renderer.RegisterFile, s.cfg.CacheDir+"/images", 
                     s.cfg.ImageWorkers)
}

2. Site Static Directory

From asset_service.go:66-72:
// Site Static: static/ (root level)
if exists, _ := afero.Exists(s.sourceFs, "static"); exists {
    destStaticDir := filepath.Join(s.cfg.OutputDir, "static")
    utils.CopyDirVFS(s.sourceFs, s.destFs, "static", destStaticDir, 
                     s.cfg.CompressImages, []string{".css", ".js"}, 
                     s.renderer.RegisterFile, s.cfg.CacheDir+"/images", 
                     s.cfg.ImageWorkers)
}
Priority: Site static files override theme files with the same name.

File Exclusions

Excluded from copying:
  • .css files (handled by esbuild)
  • .js files (handled by esbuild)
Always copied:
  • Images (.png, .jpg, .gif, .svg)
  • Fonts (.ttf, .woff, .woff2)
  • WASM binaries (.wasm)
  • Other static assets

Special Files

WASM Search Engine

From asset_service.go:172-209:
// Fallback locations:
// 1. Site: static/wasm/search.wasm
// 2. Theme: themes/<theme>/static/wasm/search.wasm

var wasmSourcePath string
if exists("static/wasm/search.wasm") {
    wasmSourcePath = "static/wasm/search.wasm"
} else if exists(themePath + "/static/wasm/search.wasm") {
    wasmSourcePath = themePath + "/static/wasm/search.wasm"
}

if wasmSourcePath != "" {
    copy(wasmSourcePath, "public/static/wasm/search.wasm")
}

WASM Helper Scripts

Three special JS files are copied without esbuild processing:
  1. wasm_exec.js: Go’s WASM runtime (from Go SDK)
  2. wasm_engine.js: Custom WASM loader
  3. engine.js: WASM module instantiation
These files must remain unmodified to ensure WASM compatibility. From asset_service.go:211-238:
if s.cfg.Logo != "" {
    if exists(s.cfg.Logo) {
        copy(s.cfg.Logo, filepath.Join(s.cfg.OutputDir, s.cfg.Logo))
        s.renderer.RegisterFile(destPath)
    }
}
No compression: Logos are copied exactly as-is (no WebP conversion) to preserve branding.

Build Order Enforcement

From AGENTS.md:
Build Order (Critical)
Static assets MUST complete before post rendering because templates use the Assets map (hashed CSS/JS filenames). The build pipeline enforces this order:
  1. Static assets build → populates Assets map via SetAssets()
  2. Posts render → templates use {{ index .Assets "/static/css/layout.css" }}
  3. Global pages render → same asset references
  4. PWA generation → uses GetAssets()
Previous bug (fixed in v1.2.1): Assets used to build in parallel with posts, causing a race condition:
// OLD (broken):
go buildAssets()  // Populates Assets map
go buildPosts()   // Reads Assets map
// Race: posts might start before Assets is ready!
Current implementation:
// NEW (correct):
buildAssets()     // MUST complete first
buildPosts()      // Safe to use Assets map
This race condition caused CSS 404 errors on post pages because templates referenced unhashed paths like /static/css/layout.css instead of hashed paths like /static/css/layout-3a9f2d.css.

Development Mode

In dev mode, assets are rebuilt on every change:
force := s.cfg.IsDev  // Skip cache in dev mode
assets, err := utils.BuildAssetsEsbuild(..., force)
Why force rebuild?
  • CSS changes must reflect immediately
  • Content hashes must update when files change
  • Cache corruption could cause stale assets

Watch Mode Behavior

From builder/run/incremental.go:97-106:
// Handle CSS/JS changes - full rebuild to update asset hashes
if ext == ".css" || ext == ".js" {
    b.logger.Info("🎨 CSS/JS changed, running full rebuild...")
    if err := b.Build(ctx); err != nil {
        b.logger.Error("Build failed", "error", err)
        return
    }
    b.SaveCaches()
    return
}
Why full rebuild?
Asset hash changes affect every HTML file that references the asset. Rebuilding just one post would leave other pages with stale hashes.

Font Embedding

Kosh embeds Inter font files in the binary: From builder/assets/embed.go:
//go:embed fonts/*.ttf
var fontsFS embed.FS

func GetFont(filename string) ([]byte, error) {
    return fontsFS.ReadFile("fonts/" + filename)
}
Available fonts:
  • Inter-Regular.ttf
  • Inter-Medium.ttf
  • Inter-Bold.ttf
These can be used by themes without external dependencies.

Performance Metrics

Asset pipeline timings (100 posts, 50 images):
OperationTimeNotes
CSS bundling150msesbuild (3 entry points)
JS bundling200msesbuild (2 entry points)
Image conversion800ms50 images (4 workers)
Static copy50msFonts, WASM, misc
Total~1.2sParallelized
With cache:
OperationTimeSpeedup
CSS bundling150msNo cache
JS bundling200msNo cache
Image conversion50ms16x faster
Static copy50msNo change
Total~450ms2.7x faster
Image caching provides the biggest speedup. First build is slow, but subsequent builds reuse cached WebP files.

Build docs developers (and LLMs) love