Files
kjol/go/webbundler/build.go

148 lines
4.9 KiB
Go

// Package bundler is the frontend build system: it drives esbuild's Go API for
// JS bundling, compiles Solid JSX/TSX and Tailwind v4 CSS with Go-native
// compilers (no Node, no Babel, no goja on the build path), and bakes the
// public-page SSR (which does still use goja to execute components). The
// `cmd/bundle` command is a thin CLI wrapper over this package.
//
// The package also carries the runtime SSR entry (RenderBundleWithData, in
// ssr.go/renderer.go) that the server imports for ISR.
//
// Pipeline stages live in sibling files:
//
// js.go esbuild-driven JS bundling + sourcemap fixup
// export_shim.go default/named export shim plugin
// import_check.go .js-extension import validation
// css.go Tailwind compile driver + minify
// tailwind.go official Tailwind v4 compiler run in goja
// candidates.go utility-class candidate scanner (feeds Tailwind's build())
// genssr.go public-page SSR bake + Go registry generation
// ssr.go/renderer.go the goja SSR engine (also used by the server at runtime)
// watch.go poll-and-rebuild watch loop
package webbundler
import (
"fmt"
"strings"
"sync"
"time"
)
type bundleStats struct {
files int
bytes int
}
// Build runs the full one-shot build: FA icon subset, public-route generation,
// the JS/CSS bundles, and the SSR bake, printing a stats summary. c selects the
// app + kjol web trees (see Config); zero-value fields fall back to the
// single-tree defaults.
func Build(c Config) error {
Configure(c)
// esbuild silently resolves a `.js` import to a sibling `.ts`/`.tsx`/`.jsx`,
// which lets misnamed specifiers slip through. Catch them up front so the
// import path always names the file that actually exists on disk.
if violations := checkImportExtensions(); len(violations) > 0 {
reportImportViolations(violations)
return fmt.Errorf("%d import-extension violation(s)", len(violations))
}
buildStart := time.Now()
fmt.Println("Generating FA icon subset...")
if err := generateFAIcons(); err != nil {
return fmt.Errorf("FA icon generation failed: %w", err)
}
fmt.Println("Generating public routes...")
routesStart := time.Now()
if err := generatePublicRoutes(); err != nil {
return fmt.Errorf("public route generation failed: %w", err)
}
routesDur := time.Since(routesStart)
// The four bundles are independent — distinct output files, shared read-only
// inputs plus the generated files above — so run them in parallel. The Go
// Solid compiler and Go Tailwind engine are both stateless per call.
fmt.Println("Bundling JS + CSS...")
results := runBundlesParallel()
for _, r := range results {
if r.err != nil {
return fmt.Errorf("%s failed: %w", r.name, r.err)
}
}
// One column layout drives header, rows, and footer so every field lines up.
// Files is %6d because candidate counts run into the thousands (a %3d would
// overflow and shift Size/Time right on the CSS rows). Total width = 55.
sep := strings.Repeat("-", 55)
fmt.Println()
fmt.Printf("%-27s %6s %8s %9s\n", "Bundle", "Files", "Size", "Time")
fmt.Println(sep)
for _, r := range results {
printStats(r.name, r.stats, r.dur)
}
fmt.Println(sep)
// %-46s%9s places the time in the same field (cols 47-55) as the bundle rows.
fmt.Printf("%-46s%9s\n", "Public routes (SSR)", formatDuration(routesDur))
fmt.Printf("%-46s%9s\n", "Total build time", formatDuration(time.Since(buildStart)))
fmt.Println()
fmt.Println("Done!")
return nil
}
type bundleResult struct {
name string
stats bundleStats
dur time.Duration
err error
}
// runBundlesParallel runs the four output bundles concurrently and returns their
// results in a stable order. Per-bundle durations overlap, so they won't sum to
// wall-clock — that's the point.
func runBundlesParallel() []bundleResult {
jobs := []struct {
name string
fn func() (bundleStats, error)
}{
{"bundle.min.js", bundleJS},
{"public.bundle.min.js", bundlePublicJS},
{"bundle.min.css", bundleSPACSS},
{"public.bundle.min.css", bundlePublicCSS},
}
results := make([]bundleResult, len(jobs))
var wg sync.WaitGroup
for i, j := range jobs {
wg.Add(1)
go func(i int, name string, fn func() (bundleStats, error)) {
defer wg.Done()
t := time.Now()
s, err := fn()
results[i] = bundleResult{name: name, stats: s, dur: time.Since(t), err: err}
}(i, j.name, j.fn)
}
wg.Wait()
return results
}
func printStats(name string, s bundleStats, d time.Duration) {
fmt.Printf("%-27s %6d %8s %9s\n", name, s.files, formatSize(s.bytes), formatDuration(d))
}
// formatDuration renders a build-phase duration compactly: sub-second in ms,
// otherwise seconds with two decimals.
func formatDuration(d time.Duration) string {
if d < time.Second {
return fmt.Sprintf("%dms", d.Milliseconds())
}
return fmt.Sprintf("%.2fs", d.Seconds())
}
func formatSize(bytes int) string {
if bytes >= 1024*1024 {
return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
}
return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
}