// Package jsbundler 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 jsbundler 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 JS 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) }