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
| Format | Size | Quality |
|---|
| Original JPEG | 245 KB | Baseline |
| WebP (quality 85) | 112 KB | Visually identical |
| Savings | 54% | 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:
wasm_exec.js: Go’s WASM runtime (from Go SDK)
wasm_engine.js: Custom WASM loader
engine.js: WASM module instantiation
These files must remain unmodified to ensure WASM compatibility.
Logo
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:
- Static assets build → populates
Assets map via SetAssets()
- Posts render → templates use
{{ index .Assets "/static/css/layout.css" }}
- Global pages render → same asset references
- 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.
Asset pipeline timings (100 posts, 50 images):
| Operation | Time | Notes |
|---|
| CSS bundling | 150ms | esbuild (3 entry points) |
| JS bundling | 200ms | esbuild (2 entry points) |
| Image conversion | 800ms | 50 images (4 workers) |
| Static copy | 50ms | Fonts, WASM, misc |
| Total | ~1.2s | Parallelized |
With cache:
| Operation | Time | Speedup |
|---|
| CSS bundling | 150ms | No cache |
| JS bundling | 200ms | No cache |
| Image conversion | 50ms | 16x faster |
| Static copy | 50ms | No change |
| Total | ~450ms | 2.7x faster |
Image caching provides the biggest speedup. First build is slow, but subsequent builds reuse cached WebP files.