// Package build is the build pipeline: directive codegen, Tailwind, the wasm binary, // the Solid bundle, and Go's JS shim. // // It used to be two packages — a `buildsteps` library and a `build` command that did // nothing but loop over it and print. There was nothing for that split to be: the // library had exactly two importers, both in this directory tree, and a package with no // outside consumers is an import statement pretending to be a boundary. It is one // package now. // // It cannot be `package main`, because the dev server imports it and Go will not let you // import a main. So the cold build is a flag on the server rather than a second binary: // // go run ./server -build # build once and exit // go run ./server # build, then watch and serve // // It is Go rather than a shell script for three reasons. The dev server has to call // these steps on every save and cannot shell out to bash on Windows. Editors need to run // them as tasks, and a task that only works on one platform is a task half the team // cannot use. And the cold build and the watch build must be the SAME steps — the moment // they are two scripts they drift, and the bug only shows up in whichever one you use // less. package build import ( "log" "os" "os/exec" "path/filepath" "strings" "kjol/jsbundler" ) // kjolRoot is the kjol Go module root, relative to the app. The Tailwind and codegen // commands are run FROM there so the engine's dependencies resolve in kjol's own go.mod, // and this app's stays lean. const kjolRoot = "../.." // Wwwroot is where every build artefact lands, and what the server serves. const Wwwroot = "wwwroot" // Step is one named stage. Naming them lets the cold build narrate itself without the // watch loop having to care what they are called. type Step struct { Name string Run func() ([]byte, error) } func Steps() []Step { return []Step{ {"generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)", Codegen}, {"compiling Tailwind CSS -> wwwroot/app.css", Tailwind}, {"compiling ./wasm -> wwwroot/app.wasm (GOOS=js GOARCH=wasm)", Wasm}, {"bundling the Solid app -> wwwroot/bundle.min.{js,css} (TSX -> Solid -> esbuild)", JS}, {"copying Go's wasm_exec.js shim into wwwroot/", Shim}, } } // All is the full build, in order. It is what the dev server runs on a code change. // // The returned bytes are the failing command's combined stdout+stderr, which the dev // server puts straight into the browser's error overlay — so a compile error lands in // front of you rather than in a terminal you were not looking at. func All() ([]byte, error) { for _, s := range Steps() { if out, err := s.Run(); err != nil { return out, err } } return nil, nil } // Cold runs every step once, narrating as it goes, and exits non-zero on the first // failure. This is `go run ./server -build`: CI, a cold start, or an editor's pre-launch // task — anywhere there is nobody watching a browser overlay. func Cold() { log.SetFlags(0) for _, s := range Steps() { log.Println("==>", s.Name) if out, err := s.Run(); err != nil { os.Stderr.Write(out) log.Fatalln("build failed:", err) } } log.Println("==> Done. Serve it with: go run ./server") } // Codegen regenerates app/*.gen.go from the //gowasm: directives — the routes, the // layouts, and the client stubs for server components. It runs FIRST: everything after // it compiles the code it writes. func Codegen() ([]byte, error) { return exec.Command("go", "run", "kjol/cmd/wasmgen", "./app").CombinedOutput() } // Tailwind compiles css/app.css to wwwroot/app.css, scanning the webui kit, the lexer's // palette and this app's Go markup for utility candidates. // // It scans .go files, which is the whole point of kjol's native engine: the markup is // written in Go, so that is where the class names are. Nothing in this stage involves // JavaScript. // // EVERY package whose class names have to exist has to be listed here. That is not a // warning about carelessness — it is the failure mode: a package left off this list still // compiles, still renders, and just comes out unstyled, because the class it asked for was // never generated. kjol/lexer is here for exactly that reason; its whole output is class // names, and nothing else in the tree mentions text-teal-300. func Tailwind() ([]byte, error) { cmd := exec.Command("go", "run", "./cmd/twcss", "-entry", "cmd/kjol-web/css/app.css", "-out", "cmd/kjol-web/wwwroot/app.css", "-base", ".", "webui/**/*.go", "lexer/**/*.go", "cmd/kjol-web/app/**/*.go", "cmd/kjol-web/server/**/*.go", ) cmd.Dir = kjolRoot return cmd.CombinedOutput() } // Wasm compiles ./wasm to wwwroot/app.wasm. func Wasm() ([]byte, error) { cmd := exec.Command("go", "build", "-o", filepath.Join(Wwwroot, "app.wasm"), "./wasm") cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm") return cmd.CombinedOutput() } // JS builds the Solid app: the SPA under /js, the server-rendered public pages, and // their stylesheet. It is kjol/jsbundler — TSX compiled to Solid by a Go program, bundled // by esbuild's Go API, styled by kjol/tw — run in-process rather than shelled out to, so // a compile error comes back as a Go error and lands in the browser's error overlay like // every other failure. // // It writes bundle.min.{js,css} and public.bundle.min.{js,css} into the SAME wwwroot as // the wasm build. They never collide: different filenames, one static dir, one server. // // WebDir points at the shared tree — the kit, the vendored Solid runtime, the icon SVGs // and the @theme scaffold all live there. func JS() ([]byte, error) { err := jsbundler.Build(jsbundler.Config{ AppFrontend: "frontend", WebDir: filepath.Join(kjolRoot, "jsruntime"), Output: Wwwroot, GenTSDir: filepath.Join("frontend", "src", "ui", "generated"), }) if err != nil { return []byte(err.Error()), err } return nil, nil } // Shim copies Go's wasm_exec.js into wwwroot. It is the loader the browser needs to start // a Go wasm binary, it ships with the toolchain, and it must match the compiler that // produced the binary — so it is copied from GOROOT rather than vendored. func Shim() ([]byte, error) { out, err := exec.Command("go", "env", "GOROOT").Output() if err != nil { return out, err } goroot := strings.TrimSpace(string(out)) for _, src := range []string{ filepath.Join(goroot, "lib", "wasm", "wasm_exec.js"), // Go >= 1.24 filepath.Join(goroot, "misc", "wasm", "wasm_exec.js"), // Go <= 1.23 } { b, err := os.ReadFile(src) if err != nil { continue } dst := filepath.Join(Wwwroot, "wasm_exec.js") // The GOROOT copy is read-only, and so is the copy we made last time. Remove it // first, or the write fails with a permission error that says nothing useful. os.Remove(dst) return nil, os.WriteFile(dst, b, 0o644) } return nil, os.ErrNotExist }