diff --git a/cmd/codegen/main.go b/cmd/codegen/main.go index 5b107a2..4e9ca28 100644 --- a/cmd/codegen/main.go +++ b/cmd/codegen/main.go @@ -1,23 +1,37 @@ +//go:build !js + // Package main provides a build-time CLI for generating Web Component JS bundles. -// Reads a JSON slot map from stdin, writes the generated JS to stdout. +// Reads a JSON slot map from stdin, writes the generated JS to stdout, and can +// optionally watch a slot file and rewrite an output bundle on change. // // Usage: // // echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ > components.js // echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ -dts > components.d.ts +// echo '{"H":"nav-bar","C":"main-content"}' > slots.json +// go run ./cmd/codegen/ -watch -input slots.json -output components.js package main import ( + "context" "encoding/json" + "errors" "flag" goio "io" "os" + "os/signal" + "time" "dappco.re/go/core/html/codegen" + coreio "dappco.re/go/core/io" log "dappco.re/go/core/log" ) var emitTypeDefinitions = flag.Bool("dts", false, "emit TypeScript declarations instead of JavaScript") +var watchMode = flag.Bool("watch", false, "poll an input file and rewrite an output bundle when it changes") +var watchInputPath = flag.String("input", "", "path to the JSON slot map used by -watch") +var watchOutputPath = flag.String("output", "", "path to the generated bundle written by -watch") +var watchPollInterval = flag.Duration("poll", 250*time.Millisecond, "poll interval used by -watch") func run(r goio.Reader, w goio.Writer) error { return runWithMode(r, w, false) @@ -33,37 +47,83 @@ func runWithMode(r goio.Reader, w goio.Writer, emitTypes bool) error { return log.E("codegen", "reading stdin", err) } - var slots map[string]string - if err := json.Unmarshal(data, &slots); err != nil { - return log.E("codegen", "invalid JSON", err) - } - - if emitTypes { - dts, err := codegen.GenerateTypeDefinitions(slots) - if err != nil { - return err - } - _, err = goio.WriteString(w, dts) - return err - } - - js, err := codegen.GenerateBundle(slots) + out, err := generate(data, emitTypes) if err != nil { return err } - _, err = goio.WriteString(w, js) + _, err = goio.WriteString(w, out) return err } +func generate(data []byte, emitTypes bool) (string, error) { + var slots map[string]string + if err := json.Unmarshal(data, &slots); err != nil { + return "", log.E("codegen", "invalid JSON", err) + } + + if emitTypes { + return codegen.GenerateTypeDefinitions(slots) + } + + return codegen.GenerateBundle(slots) +} + +func runDaemon(ctx context.Context, inputPath, outputPath string, emitTypes bool, pollInterval time.Duration) error { + if inputPath == "" { + return log.E("codegen", "watch mode requires -input", nil) + } + if outputPath == "" { + return log.E("codegen", "watch mode requires -output", nil) + } + if pollInterval <= 0 { + pollInterval = 250 * time.Millisecond + } + + var lastInput string + for { + input, err := coreio.Local.Read(inputPath) + if err != nil { + return log.E("codegen", "reading input file", err) + } + + if input != lastInput { + out, err := generate([]byte(input), emitTypes) + if err != nil { + return err + } + if err := coreio.Local.Write(outputPath, out); err != nil { + return log.E("codegen", "writing output file", err) + } + lastInput = input + } + + select { + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.Canceled) { + return nil + } + return ctx.Err() + case <-time.After(pollInterval): + } + } +} + func main() { flag.Parse() var err error - if *emitTypeDefinitions { - err = runTypeDefinitions(os.Stdin, os.Stdout) + if *watchMode { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + err = runDaemon(ctx, *watchInputPath, *watchOutputPath, *emitTypeDefinitions, *watchPollInterval) } else { - err = run(os.Stdin, os.Stdout) + if *emitTypeDefinitions { + err = runTypeDefinitions(os.Stdin, os.Stdout) + } else { + err = run(os.Stdin, os.Stdout) + } } if err != nil { log.Error("codegen failed", "err", err) diff --git a/cmd/codegen/main_test.go b/cmd/codegen/main_test.go index c7ae45f..8802d9d 100644 --- a/cmd/codegen/main_test.go +++ b/cmd/codegen/main_test.go @@ -1,9 +1,14 @@ +//go:build !js + package main import ( "bytes" + "context" + "os" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -63,3 +68,48 @@ func TestRun_Good_Empty(t *testing.T) { require.NoError(t, err) assert.Empty(t, output.String()) } + +func TestRunDaemon_WritesBundle(t *testing.T) { + dir := t.TempDir() + inputPath := dir + "/slots.json" + outputPath := dir + "/bundle.js" + + require.NoError(t, writeTestFile(inputPath, `{"H":"nav-bar","C":"main-content"}`)) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- runDaemon(ctx, inputPath, outputPath, false, 5*time.Millisecond) + }() + + require.Eventually(t, func() bool { + got, err := readTestFile(outputPath) + if err != nil { + return false + } + return strings.Contains(got, "NavBar") && strings.Contains(got, "MainContent") + }, time.Second, 10*time.Millisecond) + + cancel() + require.NoError(t, <-done) +} + +func TestRunDaemon_MissingPaths(t *testing.T) { + err := runDaemon(context.Background(), "", "", false, time.Millisecond) + require.Error(t, err) + assert.Contains(t, err.Error(), "watch mode requires -input") +} + +func writeTestFile(path, content string) error { + return os.WriteFile(path, []byte(content), 0o600) +} + +func readTestFile(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/deps/go-io/io.go b/deps/go-io/io.go index 36c9696..e307ce0 100644 --- a/deps/go-io/io.go +++ b/deps/go-io/io.go @@ -15,3 +15,8 @@ func (localFS) Read(path string) (string, error) { } return string(b), nil } + +// Write stores content at path, replacing any existing file. +func (localFS) Write(path, content string) error { + return os.WriteFile(path, []byte(content), 0o600) +} diff --git a/docs/development.md b/docs/development.md index eb5c476..eff4ab0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -145,6 +145,14 @@ echo '{"H":"site-header","C":"app-content","F":"site-footer"}' \ JSON keys are HLCRF slot letters (`H`, `L`, `C`, `R`, `F`). Values are custom element tag names (must contain a hyphen per the Web Components specification). Duplicate tag values are deduplicated. +To run the daemon mode, point it at an input JSON file and an output bundle path: + +```bash +go run ./cmd/codegen/ -watch -input slots.json -output components.js +``` + +Add `-dts` to emit TypeScript declarations instead of JavaScript in either mode. + To test the CLI: ```bash diff --git a/docs/index.md b/docs/index.md index 8eed525..989e0e7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -47,7 +47,7 @@ This builds a Header-Content-Footer layout with semantic HTML elements (`