feat(codegen): restore watch mode
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-03 16:52:47 +00:00
parent afa0337bbd
commit 3d2fdf4e22
5 changed files with 145 additions and 22 deletions

View file

@ -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)

View file

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

5
deps/go-io/io.go vendored
View file

@ -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)
}

View file

@ -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

View file

@ -47,7 +47,7 @@ This builds a Header-Content-Footer layout with semantic HTML elements (`<header
| `path.go` | `ParseBlockID()` for decoding `data-block` path attributes |
| `pipeline.go` | `StripTags`, `Imprint`, `CompareVariants` (server-side only, `!js` build tag) |
| `codegen/codegen.go` | Web Component class generation and TypeScript declarations (closed Shadow DOM) |
| `cmd/codegen/main.go` | Build-time CLI: JSON slot map on stdin, JS bundle on stdout, `-dts` for `.d.ts` output |
| `cmd/codegen/main.go` | Build-time CLI: JSON slot map on stdin, JS bundle on stdout, `-dts` for `.d.ts` output, `-watch` for file polling |
| `cmd/wasm/main.go` | WASM entry point exporting `renderToString()` to JavaScript |
## Key Concepts
@ -60,7 +60,7 @@ This builds a Header-Content-Footer layout with semantic HTML elements (`<header
**Grammar pipeline** -- Server-side only. `Imprint()` renders a node tree to HTML, strips tags, tokenises the plain text via `go-i18n/reversal`, and returns a `GrammarImprint` for semantic analysis. `CompareVariants()` computes pairwise similarity scores across responsive variants.
**Web Component codegen** -- `cmd/codegen/` generates ES2022 Web Component classes with closed Shadow DOM from a JSON slot-to-tag mapping. This is a build-time tool, not used at runtime.
**Web Component codegen** -- `cmd/codegen/` generates ES2022 Web Component classes with closed Shadow DOM from a JSON slot-to-tag mapping. This is a build-time tool, not used at runtime. It also supports `-watch` for polling an input JSON file and rewriting an output bundle in place.
## Dependencies