Files
kjol/go/cmd/kjol-web/build/build.go

178 lines
6.8 KiB
Go

// 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
}