178 lines
6.8 KiB
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-website/css/app.css",
|
|
"-out", "cmd/kjol-website/wwwroot/app.css",
|
|
"-base", ".",
|
|
"webui/**/*.go",
|
|
"lexer/**/*.go",
|
|
"cmd/kjol-website/app/**/*.go",
|
|
"cmd/kjol-website/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
|
|
}
|