diff --git a/README.md b/README.md index 4d7d8f7..507bc63 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # go-html -HLCRF DOM compositor with grammar pipeline integration for server-side HTML generation and optional WASM client rendering. Provides a type-safe node tree (El, Text, Raw, If, Each, Switch, Entitled), a five-slot Header/Left/Content/Right/Footer layout compositor with deterministic `data-block` path IDs and ARIA roles, a responsive multi-variant wrapper, a server-side grammar pipeline (StripTags, GrammarImprint via go-i18n reversal, CompareVariants), a build-time Web Component codegen CLI, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()`. +HLCRF DOM compositor with grammar pipeline integration for server-side HTML generation and optional WASM client rendering. Provides a type-safe node tree (El, Text, Raw, If, Each, Switch, Entitled, AriaLabel, AltText), a five-slot Header/Left/Content/Right/Footer layout compositor with deterministic `data-block` path IDs and ARIA roles, a responsive multi-variant wrapper, a server-side grammar pipeline (StripTags, GrammarImprint via go-i18n reversal, CompareVariants), a build-time Web Component codegen CLI, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()`. **Module**: `forge.lthn.ai/core/go-html` **Licence**: EUPL-1.2 diff --git a/bench_test.go b/bench_test.go index 220363e..7440203 100644 --- a/bench_test.go +++ b/bench_test.go @@ -1,7 +1,6 @@ package html import ( - "fmt" "testing" i18n "dappco.re/go/core/i18n" @@ -100,7 +99,7 @@ func BenchmarkImprint_Small(b *testing.B) { func BenchmarkImprint_Large(b *testing.B) { items := make([]string, 20) for i := range items { - items[i] = fmt.Sprintf("Item %d was created successfully", i) + items[i] = "Item " + itoaText(i) + " was created successfully" } page := NewLayout("HLCRF"). H(El("h1", Text("Building project"))). @@ -207,7 +206,7 @@ func BenchmarkLayout_Nested(b *testing.B) { func BenchmarkLayout_ManySlotChildren(b *testing.B) { nodes := make([]Node, 50) for i := range nodes { - nodes[i] = El("p", Raw(fmt.Sprintf("paragraph %d", i))) + nodes[i] = El("p", Raw("paragraph "+itoaText(i))) } layout := NewLayout("HLCRF"). H(Raw("header")). @@ -242,7 +241,7 @@ func benchEach(b *testing.B, n int) { items[i] = i } node := Each(items, func(i int) Node { - return El("li", Raw(fmt.Sprintf("item-%d", i))) + return El("li", Raw("item-"+itoaText(i))) }) ctx := NewContext() diff --git a/cmd/codegen/main.go b/cmd/codegen/main.go index b72def3..3874c2f 100644 --- a/cmd/codegen/main.go +++ b/cmd/codegen/main.go @@ -1,43 +1,181 @@ -// 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. +//go:build !js + +// Package main provides a build-time CLI for generating Web Component bundles. +// Reads a JSON slot map from stdin, writes the generated JS or TypeScript to stdout. // // 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/ -types > components.d.ts +// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ -watch -input slots.json -output components.js package main import ( - "encoding/json" + "context" + "errors" + "flag" goio "io" "os" + "os/signal" + "time" + core "dappco.re/go/core" "dappco.re/go/core/html/codegen" + coreio "dappco.re/go/core/io" log "dappco.re/go/core/log" ) -func run(r goio.Reader, w goio.Writer) error { +func generate(data []byte, emitTypes bool) (string, error) { + var slots map[string]string + if result := core.JSONUnmarshal(data, &slots); !result.OK { + err, _ := result.Value.(error) + return "", log.E("codegen", "invalid JSON", err) + } + + if emitTypes { + return codegen.GenerateTypeScriptDefinitions(slots), nil + } + + out, err := codegen.GenerateBundle(slots) + if err != nil { + return "", log.E("codegen", "generate bundle", err) + } + return out, nil +} + +func run(r goio.Reader, w goio.Writer, emitTypes bool) error { data, err := goio.ReadAll(r) if err != nil { 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) + out, err := generate(data, emitTypes) + if err != nil { + return err } - js, err := codegen.GenerateBundle(slots) + _, err = goio.WriteString(w, out) + if err != nil { + return log.E("codegen", "writing output", err) + } + return nil +} + +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 []byte + for { + input, err := readLocalFile(inputPath) + if err != nil { + return log.E("codegen", "reading input file", err) + } + + if !sameBytes(input, lastInput) { + out, err := generate(input, emitTypes) + if err != nil { + return err + } + if err := writeLocalFile(outputPath, out); err != nil { + return log.E("codegen", "writing output file", err) + } + lastInput = append(lastInput[:0], input...) + } + + select { + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.Canceled) { + return nil + } + return ctx.Err() + case <-time.After(pollInterval): + } + } +} + +func readLocalFile(path string) ([]byte, error) { + f, err := coreio.Local.Open(path) + if err != nil { + return nil, err + } + defer func() { + _ = f.Close() + }() + + return goio.ReadAll(f) +} + +func writeLocalFile(path, content string) error { + f, err := coreio.Local.Create(path) if err != nil { return err } + defer func() { + _ = f.Close() + }() - _, err = goio.WriteString(w, js) + _, err = goio.WriteString(f, content) return err } +func sameBytes(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range len(a) { + if a[i] != b[i] { + return false + } + } + return true +} + func main() { - if err := run(os.Stdin, os.Stdout); err != nil { - log.Error("codegen failed", "err", err) + emitWatch := flag.Bool("watch", false, "poll input and rewrite output when the JSON changes") + inputPath := flag.String("input", "", "path to the JSON slot map used by -watch") + outputPath := flag.String("output", "", "path to the generated bundle written by -watch") + emitTypes := flag.Bool("types", false, "emit TypeScript declarations instead of JavaScript") + pollInterval := flag.Duration("poll", 250*time.Millisecond, "poll interval used by -watch") + flag.Parse() + + if *emitWatch { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + if err := runDaemon(ctx, *inputPath, *outputPath, *emitTypes, *pollInterval); err != nil { + log.Error("codegen failed", "scope", "codegen.main", "err", err) + os.Exit(1) + } + return + } + + stdin, err := coreio.Local.Open("/dev/stdin") + if err != nil { + log.Error("failed to open stdin", "scope", "codegen.main", "err", log.E("codegen.main", "open stdin", err)) + os.Exit(1) + } + + stdout, err := coreio.Local.Create("/dev/stdout") + if err != nil { + _ = stdin.Close() + log.Error("failed to open stdout", "scope", "codegen.main", "err", log.E("codegen.main", "open stdout", err)) + os.Exit(1) + } + defer func() { + _ = stdin.Close() + _ = stdout.Close() + }() + + if err := run(stdin, stdout, *emitTypes); err != nil { + log.Error("codegen failed", "scope", "codegen.main", "err", err) os.Exit(1) } } diff --git a/cmd/codegen/main_test.go b/cmd/codegen/main_test.go index edacf19..703d0ab 100644 --- a/cmd/codegen/main_test.go +++ b/cmd/codegen/main_test.go @@ -1,51 +1,179 @@ +//go:build !js + package main import ( - "bytes" + "context" + goio "io" + "path/filepath" "strings" "testing" + "time" + core "dappco.re/go/core" + coreio "dappco.re/go/core/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestRun_Good(t *testing.T) { - input := strings.NewReader(`{"H":"nav-bar","C":"main-content"}`) - var output bytes.Buffer +func TestRun_WritesBundle_Good(t *testing.T) { + input := core.NewReader(`{"H":"nav-bar","C":"main-content"}`) + output := core.NewBuilder() - err := run(input, &output) + err := run(input, output, false) require.NoError(t, err) js := output.String() assert.Contains(t, js, "NavBar") assert.Contains(t, js, "MainContent") assert.Contains(t, js, "customElements.define") - assert.Equal(t, 2, strings.Count(js, "extends HTMLElement")) + assert.Equal(t, 2, countSubstr(js, "extends HTMLElement")) } -func TestRun_Bad_InvalidJSON(t *testing.T) { - input := strings.NewReader(`not json`) - var output bytes.Buffer +func TestRun_InvalidJSON_Bad(t *testing.T) { + input := core.NewReader(`not json`) + output := core.NewBuilder() - err := run(input, &output) + err := run(input, output, false) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid JSON") } -func TestRun_Bad_InvalidTag(t *testing.T) { - input := strings.NewReader(`{"H":"notag"}`) - var output bytes.Buffer +func TestRun_InvalidTag_Bad(t *testing.T) { + input := core.NewReader(`{"H":"notag"}`) + output := core.NewBuilder() - err := run(input, &output) + err := run(input, output, false) assert.Error(t, err) assert.Contains(t, err.Error(), "hyphen") } -func TestRun_Good_Empty(t *testing.T) { - input := strings.NewReader(`{}`) - var output bytes.Buffer +func TestRun_InvalidTagCharacters_Bad(t *testing.T) { + input := core.NewReader(`{"H":"Nav-Bar","C":"nav bar"}`) + output := core.NewBuilder() + + err := run(input, output, false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "lowercase hyphenated name") +} + +func TestRun_EmptySlots_Good(t *testing.T) { + input := core.NewReader(`{}`) + output := core.NewBuilder() - err := run(input, &output) + err := run(input, output, false) require.NoError(t, err) assert.Empty(t, output.String()) } + +func TestRun_WritesTypeScriptDefinitions_Good(t *testing.T) { + input := core.NewReader(`{"H":"nav-bar","C":"main-content"}`) + output := core.NewBuilder() + + err := run(input, output, true) + require.NoError(t, err) + + dts := output.String() + assert.Contains(t, dts, "declare global") + assert.Contains(t, dts, `"nav-bar": NavBar;`) + assert.Contains(t, dts, `"main-content": MainContent;`) + assert.Contains(t, dts, "export declare class NavBar extends HTMLElement") + assert.Contains(t, dts, "export declare class MainContent extends HTMLElement") +} + +func TestRunDaemon_WritesUpdatedBundle_Good(t *testing.T) { + dir := t.TempDir() + inputPath := filepath.Join(dir, "slots.json") + outputPath := filepath.Join(dir, "bundle.js") + + require.NoError(t, writeTextFile(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 := readTextFile(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_Bad(t *testing.T) { + err := runDaemon(context.Background(), "", "", false, time.Millisecond) + require.Error(t, err) + assert.Contains(t, err.Error(), "watch mode requires -input") +} + +func countSubstr(s, substr string) int { + if substr == "" { + return len(s) + 1 + } + + count := 0 + for i := 0; i <= len(s)-len(substr); { + j := indexSubstr(s[i:], substr) + if j < 0 { + return count + } + count++ + i += j + len(substr) + } + + return count +} + +func indexSubstr(s, substr string) int { + if substr == "" { + return 0 + } + if len(substr) > len(s) { + return -1 + } + + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + + return -1 +} + +func writeTextFile(path, content string) error { + f, err := coreio.Local.Create(path) + if err != nil { + return err + } + defer func() { + _ = f.Close() + }() + + _, err = goio.WriteString(f, content) + return err +} + +func readTextFile(path string) (string, error) { + f, err := coreio.Local.Open(path) + if err != nil { + return "", err + } + defer func() { + _ = f.Close() + }() + + data, err := goio.ReadAll(f) + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index a3c0db2..dc74244 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -13,15 +13,19 @@ import ( // This is intentional: the WASM module is a rendering engine for trusted content // produced server-side or by the application's own templates. func renderToString(_ js.Value, args []js.Value) any { - if len(args) < 1 { + if len(args) < 1 || args[0].Type() != js.TypeString { return "" } variant := args[0].String() + if variant == "" { + return "" + } + ctx := html.NewContext() - if len(args) >= 2 { - ctx.Locale = args[1].String() + if len(args) >= 2 && args[1].Type() == js.TypeString { + ctx.SetLocale(args[1].String()) } layout := html.NewLayout(variant) diff --git a/cmd/wasm/main_test.go b/cmd/wasm/main_test.go new file mode 100644 index 0000000..4184caa --- /dev/null +++ b/cmd/wasm/main_test.go @@ -0,0 +1,55 @@ +//go:build js && wasm + +package main + +import ( + "testing" + + "syscall/js" +) + +func TestRenderToString_Good(t *testing.T) { + gotAny := renderToString(js.Value{}, []js.Value{ + js.ValueOf("C"), + js.ValueOf("en-GB"), + js.ValueOf(map[string]any{"C": "hello"}), + }) + + got, ok := gotAny.(string) + if !ok { + t.Fatalf("renderToString should return string, got %T", gotAny) + } + + want := `
hello
` + if got != want { + t.Fatalf("renderToString(...) = %q, want %q", got, want) + } +} + +func TestRenderToString_VariantTypeGuard(t *testing.T) { + if got := renderToString(js.Value{}, []js.Value{js.ValueOf(123)}); got != "" { + t.Fatalf("non-string variant should be empty, got %q", got) + } + + if got := renderToString(js.Value{}, []js.Value{}); got != "" { + t.Fatalf("missing variant should be empty, got %q", got) + } +} + +func TestRenderToString_LocaleTypeGuard(t *testing.T) { + gotAny := renderToString(js.Value{}, []js.Value{ + js.ValueOf("C"), + js.ValueOf(123), + js.ValueOf(map[string]any{"C": "x"}), + }) + + got, ok := gotAny.(string) + if !ok { + t.Fatalf("renderToString should return string, got %T", gotAny) + } + + want := `
x
` + if got != want { + t.Fatalf("renderToString with non-string locale = %q, want %q", got, want) + } +} diff --git a/cmd/wasm/register.go b/cmd/wasm/register.go index d88c866..d463465 100644 --- a/cmd/wasm/register.go +++ b/cmd/wasm/register.go @@ -3,7 +3,7 @@ package main import ( - "encoding/json" + core "dappco.re/go/core" "dappco.re/go/core/html/codegen" log "dappco.re/go/core/log" @@ -15,7 +15,8 @@ import ( // Use cmd/codegen/ CLI instead for build-time generation. func buildComponentJS(slotsJSON string) (string, error) { var slots map[string]string - if err := json.Unmarshal([]byte(slotsJSON), &slots); err != nil { + if result := core.JSONUnmarshalString(slotsJSON, &slots); !result.OK { + err, _ := result.Value.(error) return "", log.E("buildComponentJS", "unmarshal JSON", err) } return codegen.GenerateBundle(slots) diff --git a/cmd/wasm/register_test.go b/cmd/wasm/register_test.go index 65f2af2..9365e9e 100644 --- a/cmd/wasm/register_test.go +++ b/cmd/wasm/register_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestBuildComponentJS_Good(t *testing.T) { +func TestBuildComponentJS_ValidJSON_Good(t *testing.T) { slotsJSON := `{"H":"nav-bar","C":"main-content"}` js, err := buildComponentJS(slotsJSON) require.NoError(t, err) @@ -18,7 +18,7 @@ func TestBuildComponentJS_Good(t *testing.T) { assert.Contains(t, js, "customElements.define") } -func TestBuildComponentJS_Bad_InvalidJSON(t *testing.T) { +func TestBuildComponentJS_InvalidJSON_Bad(t *testing.T) { _, err := buildComponentJS("not json") assert.Error(t, err) } diff --git a/cmd/wasm/size_test.go b/cmd/wasm/size_test.go index ed759c1..e8b1d96 100644 --- a/cmd/wasm/size_test.go +++ b/cmd/wasm/size_test.go @@ -4,14 +4,13 @@ package main import ( - "bytes" "compress/gzip" - "os" - "os/exec" - "path/filepath" + "context" "testing" + core "dappco.re/go/core" coreio "dappco.re/go/core/io" + process "dappco.re/go/core/process" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -21,34 +20,44 @@ const ( wasmRawLimit = 3_670_016 // 3.5 MB raw size limit ) -func TestWASMBinarySize_Good(t *testing.T) { +func TestCmdWasm_WASMBinarySize_Good(t *testing.T) { if testing.Short() { t.Skip("skipping WASM build test in short mode") } dir := t.TempDir() - out := filepath.Join(dir, "gohtml.wasm") + out := core.Path(dir, "gohtml.wasm") - cmd := exec.Command("go", "build", "-ldflags=-s -w", "-o", out, ".") - cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm") - output, err := cmd.CombinedOutput() + factory := process.NewService(process.Options{}) + serviceValue, err := factory(core.New()) + require.NoError(t, err) + + svc, ok := serviceValue.(*process.Service) + require.True(t, ok, "process service factory returned %T", serviceValue) + + output, err := svc.RunWithOptions(context.Background(), process.RunOptions{ + Command: "go", + Args: []string{"build", "-ldflags=-s -w", "-o", out, "."}, + Dir: ".", + Env: []string{"GOOS=js", "GOARCH=wasm"}, + }) require.NoError(t, err, "WASM build failed: %s", output) rawStr, err := coreio.Local.Read(out) require.NoError(t, err) - raw := []byte(rawStr) + rawBytes := []byte(rawStr) - var buf bytes.Buffer - gz, err := gzip.NewWriterLevel(&buf, gzip.BestCompression) + buf := core.NewBuilder() + gz, err := gzip.NewWriterLevel(buf, gzip.BestCompression) require.NoError(t, err) - _, err = gz.Write(raw) + _, err = gz.Write(rawBytes) require.NoError(t, err) require.NoError(t, gz.Close()) - t.Logf("WASM size: %d bytes raw, %d bytes gzip", len(raw), buf.Len()) + t.Logf("WASM size: %d bytes raw, %d bytes gzip", len(rawBytes), buf.Len()) assert.Less(t, buf.Len(), wasmGzLimit, "WASM gzip size %d exceeds 1MB limit", buf.Len()) - assert.Less(t, len(raw), wasmRawLimit, - "WASM raw size %d exceeds 3MB limit", len(raw)) + assert.Less(t, len(rawBytes), wasmRawLimit, + "WASM raw size %d exceeds 3MB limit", len(rawBytes)) } diff --git a/codegen/bench_test.go b/codegen/bench_test.go index 0fdaecd..4a678d9 100644 --- a/codegen/bench_test.go +++ b/codegen/bench_test.go @@ -1,3 +1,5 @@ +//go:build !js + package codegen import "testing" diff --git a/codegen/codegen.go b/codegen/codegen.go index 8692b0b..2fbcebf 100644 --- a/codegen/codegen.go +++ b/codegen/codegen.go @@ -1,13 +1,40 @@ +//go:build !js + package codegen import ( - "fmt" - "strings" + "sort" "text/template" + core "dappco.re/go/core" log "dappco.re/go/core/log" ) +// isValidCustomElementTag reports whether tag is a safe custom element name. +// The generator rejects values that would fail at customElements.define() time. +func isValidCustomElementTag(tag string) bool { + if tag == "" || !core.Contains(tag, "-") { + return false + } + + if tag[0] < 'a' || tag[0] > 'z' { + return false + } + + for i := range len(tag) { + ch := tag[i] + switch { + case ch >= 'a' && ch <= 'z': + case ch >= '0' && ch <= '9': + case ch == '-' || ch == '.' || ch == '_': + default: + return false + } + } + + return true +} + // wcTemplate is the Web Component class template. // Uses closed Shadow DOM for isolation. Content is set via the shadow root's // DOM API using trusted go-html codegen output (never user input). @@ -31,12 +58,13 @@ var wcTemplate = template.Must(template.New("wc").Parse(`class {{.ClassName}} ex }`)) // GenerateClass produces a JS class definition for a custom element. +// Usage example: js, err := GenerateClass("nav-bar", "H") func GenerateClass(tag, slot string) (string, error) { - if !strings.Contains(tag, "-") { - return "", log.E("codegen.GenerateClass", "custom element tag must contain a hyphen: "+tag, nil) + if !isValidCustomElementTag(tag) { + return "", log.E("codegen.GenerateClass", "custom element tag must be a lowercase hyphenated name: "+tag, nil) } - var b strings.Builder - err := wcTemplate.Execute(&b, struct { + b := core.NewBuilder() + err := wcTemplate.Execute(b, struct { ClassName, Tag, Slot string }{ ClassName: TagToClassName(tag), @@ -50,16 +78,18 @@ func GenerateClass(tag, slot string) (string, error) { } // GenerateRegistration produces the customElements.define() call. +// Usage example: js := GenerateRegistration("nav-bar", "NavBar") func GenerateRegistration(tag, className string) string { - return fmt.Sprintf(`customElements.define("%s", %s);`, tag, className) + return `customElements.define("` + tag + `", ` + className + `);` } // TagToClassName converts a kebab-case tag to PascalCase class name. +// Usage example: className := TagToClassName("nav-bar") func TagToClassName(tag string) string { - var b strings.Builder - for p := range strings.SplitSeq(tag, "-") { + b := core.NewBuilder() + for _, p := range core.Split(tag, "-") { if len(p) > 0 { - b.WriteString(strings.ToUpper(p[:1])) + b.WriteString(core.Upper(p[:1])) b.WriteString(p[1:]) } } @@ -68,11 +98,18 @@ func TagToClassName(tag string) string { // GenerateBundle produces all WC class definitions and registrations // for a set of HLCRF slot assignments. +// Usage example: js, err := GenerateBundle(map[string]string{"H": "nav-bar"}) func GenerateBundle(slots map[string]string) (string, error) { seen := make(map[string]bool) - var b strings.Builder + b := core.NewBuilder() + keys := make([]string, 0, len(slots)) + for slot := range slots { + keys = append(keys, slot) + } + sort.Strings(keys) - for slot, tag := range slots { + for _, slot := range keys { + tag := slots[slot] if seen[tag] { continue } @@ -80,7 +117,7 @@ func GenerateBundle(slots map[string]string) (string, error) { cls, err := GenerateClass(tag, slot) if err != nil { - return "", err + return "", log.E("codegen.GenerateBundle", "generate class for tag "+tag, err) } b.WriteString(cls) b.WriteByte('\n') diff --git a/codegen/codegen_test.go b/codegen/codegen_test.go index 28a6aa2..3fdcdc4 100644 --- a/codegen/codegen_test.go +++ b/codegen/codegen_test.go @@ -1,3 +1,5 @@ +//go:build !js + package codegen import ( @@ -8,7 +10,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestGenerateClass_Good(t *testing.T) { +func TestGenerateClass_ValidTag_Good(t *testing.T) { js, err := GenerateClass("photo-grid", "C") require.NoError(t, err) assert.Contains(t, js, "class PhotoGrid extends HTMLElement") @@ -17,19 +19,25 @@ func TestGenerateClass_Good(t *testing.T) { assert.Contains(t, js, "photo-grid") } -func TestGenerateClass_Bad_InvalidTag(t *testing.T) { +func TestGenerateClass_InvalidTag_Bad(t *testing.T) { _, err := GenerateClass("invalid", "C") assert.Error(t, err, "custom element names must contain a hyphen") + + _, err = GenerateClass("Nav-Bar", "C") + assert.Error(t, err, "custom element names must be lowercase") + + _, err = GenerateClass("nav bar", "C") + assert.Error(t, err, "custom element names must reject spaces") } -func TestGenerateRegistration_Good(t *testing.T) { +func TestGenerateRegistration_DefinesCustomElement_Good(t *testing.T) { js := GenerateRegistration("photo-grid", "PhotoGrid") assert.Contains(t, js, "customElements.define") assert.Contains(t, js, `"photo-grid"`) assert.Contains(t, js, "PhotoGrid") } -func TestTagToClassName_Good(t *testing.T) { +func TestTagToClassName_KebabCase_Good(t *testing.T) { tests := []struct{ tag, want string }{ {"photo-grid", "PhotoGrid"}, {"nav-breadcrumb", "NavBreadcrumb"}, @@ -41,14 +49,108 @@ func TestTagToClassName_Good(t *testing.T) { } } -func TestGenerateBundle_Good(t *testing.T) { +func TestGenerateBundle_DeduplicatesRegistrations_Good(t *testing.T) { slots := map[string]string{ "H": "nav-bar", "C": "main-content", + "F": "nav-bar", } js, err := GenerateBundle(slots) require.NoError(t, err) assert.Contains(t, js, "NavBar") assert.Contains(t, js, "MainContent") - assert.Equal(t, 2, strings.Count(js, "extends HTMLElement")) + assert.Equal(t, 2, countSubstr(js, "extends HTMLElement")) + assert.Equal(t, 2, countSubstr(js, "customElements.define")) +} + +func TestGenerateBundle_DeterministicOrdering_Good(t *testing.T) { + slots := map[string]string{ + "Z": "zed-panel", + "A": "alpha-panel", + "M": "main-content", + } + + js, err := GenerateBundle(slots) + require.NoError(t, err) + + alpha := strings.Index(js, "class AlphaPanel") + main := strings.Index(js, "class MainContent") + zed := strings.Index(js, "class ZedPanel") + + assert.NotEqual(t, -1, alpha) + assert.NotEqual(t, -1, main) + assert.NotEqual(t, -1, zed) + assert.Less(t, alpha, main) + assert.Less(t, main, zed) + assert.Equal(t, 3, countSubstr(js, "extends HTMLElement")) + assert.Equal(t, 3, countSubstr(js, "customElements.define")) +} + +func TestGenerateTypeScriptDefinitions_DeduplicatesAndOrders_Good(t *testing.T) { + slots := map[string]string{ + "Z": "zed-panel", + "A": "alpha-panel", + "M": "alpha-panel", + } + + dts := GenerateTypeScriptDefinitions(slots) + + assert.Contains(t, dts, `interface HTMLElementTagNameMap`) + assert.Contains(t, dts, `"alpha-panel": AlphaPanel;`) + assert.Contains(t, dts, `"zed-panel": ZedPanel;`) + assert.Equal(t, 1, countSubstr(dts, `"alpha-panel": AlphaPanel;`)) + assert.Equal(t, 1, countSubstr(dts, `export declare class AlphaPanel extends HTMLElement`)) + assert.Equal(t, 1, countSubstr(dts, `export declare class ZedPanel extends HTMLElement`)) + assert.Contains(t, dts, "export {};") + assert.Less(t, strings.Index(dts, `"alpha-panel": AlphaPanel;`), strings.Index(dts, `"zed-panel": ZedPanel;`)) +} + +func TestGenerateTypeScriptDefinitions_SkipsInvalidTags_Good(t *testing.T) { + slots := map[string]string{ + "H": "nav-bar", + "C": "Nav-Bar", + "F": "nav bar", + } + + dts := GenerateTypeScriptDefinitions(slots) + + assert.Contains(t, dts, `"nav-bar": NavBar;`) + assert.NotContains(t, dts, "Nav-Bar") + assert.NotContains(t, dts, "nav bar") + assert.Equal(t, 1, countSubstr(dts, `export declare class NavBar extends HTMLElement`)) +} + +func countSubstr(s, substr string) int { + if substr == "" { + return len(s) + 1 + } + + count := 0 + for i := 0; i <= len(s)-len(substr); { + j := indexSubstr(s[i:], substr) + if j < 0 { + return count + } + count++ + i += j + len(substr) + } + + return count +} + +func indexSubstr(s, substr string) int { + if substr == "" { + return 0 + } + if len(substr) > len(s) { + return -1 + } + + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + + return -1 } diff --git a/codegen/doc.go b/codegen/doc.go new file mode 100644 index 0000000..afc9b13 --- /dev/null +++ b/codegen/doc.go @@ -0,0 +1,13 @@ +//go:build !js + +// SPDX-Licence-Identifier: EUPL-1.2 + +// Package codegen generates Web Component bundles for go-html slot maps. +// +// Use it at build time, or through the cmd/codegen CLI: +// +// bundle, err := GenerateBundle(map[string]string{ +// "H": "site-header", +// "C": "app-main", +// }) +package codegen diff --git a/codegen/typescript.go b/codegen/typescript.go new file mode 100644 index 0000000..035a0e7 --- /dev/null +++ b/codegen/typescript.go @@ -0,0 +1,61 @@ +//go:build !js + +// SPDX-Licence-Identifier: EUPL-1.2 + +package codegen + +import ( + "sort" + + core "dappco.re/go/core" +) + +// GenerateTypeScriptDefinitions produces ambient TypeScript declarations for +// a set of custom elements generated from HLCRF slot assignments. +// Usage example: dts := GenerateTypeScriptDefinitions(map[string]string{"H": "nav-bar"}) +func GenerateTypeScriptDefinitions(slots map[string]string) string { + seen := make(map[string]bool) + declared := make(map[string]bool) + b := core.NewBuilder() + + keys := make([]string, 0, len(slots)) + for slot := range slots { + keys = append(keys, slot) + } + sort.Strings(keys) + + b.WriteString("declare global {\n") + b.WriteString(" interface HTMLElementTagNameMap {\n") + for _, slot := range keys { + tag := slots[slot] + if !isValidCustomElementTag(tag) || seen[tag] { + continue + } + seen[tag] = true + b.WriteString(" \"") + b.WriteString(tag) + b.WriteString("\": ") + b.WriteString(TagToClassName(tag)) + b.WriteString(";\n") + } + b.WriteString(" }\n") + b.WriteString("}\n\n") + + for _, slot := range keys { + tag := slots[slot] + if !seen[tag] || declared[tag] { + continue + } + declared[tag] = true + b.WriteString("export declare class ") + b.WriteString(TagToClassName(tag)) + b.WriteString(" extends HTMLElement {\n") + b.WriteString(" connectedCallback(): void;\n") + b.WriteString(" render(html: string): void;\n") + b.WriteString("}\n\n") + } + + b.WriteString("export {};\n") + + return b.String() +} diff --git a/context.go b/context.go index 3bc77ef..f5d40e5 100644 --- a/context.go +++ b/context.go @@ -1,27 +1,81 @@ package html -import i18n "dappco.re/go/core/i18n" +// Translator provides Text() lookups for a rendering context. +// Usage example: ctx := NewContextWithService(myTranslator) +// +// The default server build uses go-i18n. Alternate builds, including WASM, +// can provide any implementation with the same T() method. +type Translator interface { + T(key string, args ...any) string +} // Context carries rendering state through the node tree. +// Usage example: ctx := NewContext() type Context struct { Identity string Locale string Entitlements func(feature string) bool Data map[string]any - service *i18n.Service + service Translator +} + +func applyLocaleToService(svc Translator, locale string) { + if svc == nil || locale == "" { + return + } + + if setter, ok := svc.(interface{ SetLanguage(string) error }); ok { + base := locale + for i := 0; i < len(base); i++ { + if base[i] == '-' || base[i] == '_' { + base = base[:i] + break + } + } + _ = setter.SetLanguage(base) + } } // NewContext creates a new rendering context with sensible defaults. -func NewContext() *Context { - return &Context{ +// Usage example: html := Render(Text("welcome"), NewContext("en-GB")) +func NewContext(locale ...string) *Context { + ctx := &Context{ Data: make(map[string]any), } + if len(locale) > 0 { + ctx.SetLocale(locale[0]) + } + return ctx } -// NewContextWithService creates a rendering context backed by a specific i18n service. -func NewContextWithService(svc *i18n.Service) *Context { - return &Context{ - Data: make(map[string]any), - service: svc, +// NewContextWithService creates a rendering context backed by a specific translator. +// Usage example: ctx := NewContextWithService(myTranslator, "en-GB") +func NewContextWithService(svc Translator, locale ...string) *Context { + ctx := NewContext(locale...) + ctx.SetService(svc) + return ctx +} + +// SetService swaps the translator used by the context. +// Usage example: ctx.SetService(myTranslator) +func (ctx *Context) SetService(svc Translator) *Context { + if ctx == nil { + return nil } + + ctx.service = svc + applyLocaleToService(svc, ctx.Locale) + return ctx +} + +// SetLocale updates the context locale and reapplies it to the active translator. +// Usage example: ctx.SetLocale("en-GB") +func (ctx *Context) SetLocale(locale string) *Context { + if ctx == nil { + return nil + } + + ctx.Locale = locale + applyLocaleToService(ctx.service, ctx.Locale) + return ctx } diff --git a/context_test.go b/context_test.go new file mode 100644 index 0000000..93519d4 --- /dev/null +++ b/context_test.go @@ -0,0 +1,90 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package html + +import ( + "testing" + + i18n "dappco.re/go/core/i18n" +) + +func TestNewContext_OptionalLocale_Good(t *testing.T) { + ctx := NewContext("en-GB") + + if ctx == nil { + t.Fatal("NewContext returned nil") + } + if ctx.Locale != "en-GB" { + t.Fatalf("NewContext locale = %q, want %q", ctx.Locale, "en-GB") + } + if ctx.Data == nil { + t.Fatal("NewContext should initialise Data") + } +} + +func TestNewContextWithService_OptionalLocale_Good(t *testing.T) { + svc, _ := i18n.New() + ctx := NewContextWithService(svc, "fr-FR") + + if ctx == nil { + t.Fatal("NewContextWithService returned nil") + } + if ctx.Locale != "fr-FR" { + t.Fatalf("NewContextWithService locale = %q, want %q", ctx.Locale, "fr-FR") + } + if ctx.service == nil { + t.Fatal("NewContextWithService should set translator service") + } +} + +func TestNewContextWithService_AppliesLocaleToService_Good(t *testing.T) { + svc, _ := i18n.New() + ctx := NewContextWithService(svc, "fr-FR") + + got := Text("prompt.yes").Render(ctx) + if got != "o" { + t.Fatalf("NewContextWithService locale translation = %q, want %q", got, "o") + } +} + +func TestContext_SetService_AppliesLocale_Good(t *testing.T) { + svc, _ := i18n.New() + ctx := NewContext("fr-FR") + + if got := ctx.SetService(svc); got != ctx { + t.Fatal("SetService should return the same context for chaining") + } + + got := Text("prompt.yes").Render(ctx) + if got != "o" { + t.Fatalf("SetService locale translation = %q, want %q", got, "o") + } +} + +func TestContext_SetService_NilContext_Ugly(t *testing.T) { + var ctx *Context + if got := ctx.SetService(nil); got != nil { + t.Fatal("SetService on nil context should return nil") + } +} + +func TestContext_SetLocale_AppliesLocale_Good(t *testing.T) { + svc, _ := i18n.New() + ctx := NewContextWithService(svc) + + if got := ctx.SetLocale("fr-FR"); got != ctx { + t.Fatal("SetLocale should return the same context for chaining") + } + + got := Text("prompt.yes").Render(ctx) + if got != "o" { + t.Fatalf("SetLocale translation = %q, want %q", got, "o") + } +} + +func TestContext_SetLocale_NilContext_Ugly(t *testing.T) { + var ctx *Context + if got := ctx.SetLocale("en-GB"); got != nil { + t.Fatal("SetLocale on nil context should return nil") + } +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..b3c3317 --- /dev/null +++ b/doc.go @@ -0,0 +1,12 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +// Package html renders semantic HTML from composable node trees. +// +// A typical page combines Layout, El, Text, and Render: +// +// page := NewLayout("HCF"). +// H(El("h1", Text("page.title"))). +// C(El("main", Text("page.body"))). +// F(El("small", Text("page.footer"))) +// out := Render(page, NewContext()) +package html diff --git a/docs/architecture.md b/docs/architecture.md index a78bad6..169dc36 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,13 +17,18 @@ type Node interface { } ``` -All concrete node types are unexported structs with exported constructor functions. The public API surface consists of nine constructors plus the `Attr()` and `Render()` helpers: +All concrete node types are unexported structs with exported constructor functions. The public API surface consists of nine node constructors, four accessibility helpers, plus the `Attr()` and `Render()` helpers: | Constructor | Behaviour | |-------------|-----------| | `El(tag, ...Node)` | HTML element with children. Void elements (`br`, `img`, `input`, etc.) never emit a closing tag. | | `Attr(Node, key, value)` | Sets an attribute on an `El` node. Traverses through `If`, `Unless`, and `Entitled` wrappers. Returns the node for chaining. | -| `Text(key, ...any)` | Translated text via `go-i18n`. Output is always HTML-escaped. | +| `AriaLabel(Node, label)` | Convenience helper that sets `aria-label` on an element node. | +| `AltText(Node, text)` | Convenience helper that sets `alt` on an element node. | +| `TabIndex(Node, index)` | Convenience helper that sets `tabindex` on an element node. | +| `AutoFocus(Node)` | Convenience helper that sets `autofocus` on an element node. | +| `Role(Node, role)` | Convenience helper that sets `role` on an element node. | +| `Text(key, ...any)` | Translated text via the active context translator. Server builds fall back to global `go-i18n`; JS builds fall back to the key. Output is always HTML-escaped. | | `Raw(content)` | Unescaped trusted content. Explicit escape hatch. | | `If(cond, Node)` | Renders the child only when the condition function returns true. | | `Unless(cond, Node)` | Renders the child only when the condition function returns false. | @@ -50,16 +55,16 @@ type Context struct { Locale string // BCP 47 locale string Entitlements func(feature string) bool // feature gate callback Data map[string]any // arbitrary per-request data - service *i18n.Service // unexported; set via constructor + service Translator // unexported; set via constructor } ``` Two constructors are provided: - `NewContext()` creates a context with sensible defaults and an empty `Data` map. -- `NewContextWithService(svc)` creates a context backed by a specific `i18n.Service` instance. +- `NewContextWithService(svc)` creates a context backed by any translator implementing `T(key, ...any) string` such as `*i18n.Service`. -The `service` field is intentionally unexported. When nil, `Text` nodes fall back to the global `i18n.T()` default. This prevents callers from setting the service inconsistently after construction. +The `service` field is intentionally unexported. When nil, server builds fall back to the global `i18n.T()` default while JS builds render the key unchanged. This prevents callers from setting the service inconsistently after construction while keeping the WASM import graph lean. ## HLCRF Layout @@ -161,6 +166,8 @@ html.NewResponsive(). Each variant renders inside a `
` container. Variants render in insertion order. CSS media queries or JavaScript can target these containers for show/hide logic. +`VariantSelector(name)` returns a CSS attribute selector for a specific responsive variant, making stylesheet targeting less error-prone than hand-writing the attribute selector repeatedly. + `Responsive` implements `Node`, so it can be passed to `Render()` or `Imprint()`. The `Variant()` method accepts `*Layout` specifically, not arbitrary `Node` values. Each variant maintains independent block ID namespaces -- nesting a layout inside a responsive variant does not conflict with the same layout structure in another variant. diff --git a/docs/development.md b/docs/development.md index eb5c476..46cbcc1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -66,7 +66,7 @@ go test ./cmd/codegen/ go test ./cmd/wasm/ ``` -The WASM size gate test (`TestWASMBinarySize_Good`) builds the WASM binary as a subprocess. It is slow and is skipped under `-short`. It is also guarded with `//go:build !js` so it cannot run within the WASM environment itself. +The WASM size gate test (`TestWASMBinarySize_WithinBudget`) builds the WASM binary as a subprocess. It is slow and is skipped under `-short`. It is also guarded with `//go:build !js` so it cannot run within the WASM environment itself. ### Test Dependencies @@ -145,6 +145,24 @@ 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. +Pass `-types` to emit ambient TypeScript declarations instead of JavaScript: + +```bash +echo '{"H":"site-header","C":"app-content"}' \ + | go run ./cmd/codegen/ -types \ + > components.d.ts +``` + +For local development, `-watch` polls an input JSON file and rewrites the +output file whenever the slot map changes: + +```bash +go run ./cmd/codegen/ \ + -watch \ + -input slots.json \ + -output components.js +``` + To test the CLI: ```bash @@ -278,7 +296,7 @@ func TestIntegration_RenderThenReverse(t *testing.T) { ### Codegen Tests with Testify ```go -func TestGenerateClass_Good(t *testing.T) { +func TestGenerateClass_ValidTag(t *testing.T) { js, err := GenerateClass("photo-grid", "C") require.NoError(t, err) assert.Contains(t, js, "class PhotoGrid extends HTMLElement") @@ -291,6 +309,6 @@ func TestGenerateClass_Good(t *testing.T) { - `NewLayout("XYZ")` silently produces empty output for unrecognised slot letters. Valid letters are `H`, `L`, `C`, `R`, `F`. There is no error or warning. - `Responsive.Variant()` accepts only `*Layout`, not arbitrary `Node` values. Arbitrary subtrees must be wrapped in a single-slot layout first. -- `Context.service` is unexported. Custom i18n service injection requires `NewContextWithService()`. There is no way to swap the service after construction. +- `Context.service` is unexported. Custom translation injection uses `NewContextWithService()`, and `Context.SetService()` can swap the translator later while preserving locale-aware services. - The WASM module has no integration test for the JavaScript exports. `size_test.go` tests binary size only; it does not exercise `renderToString` behaviour from JavaScript. -- `codegen.GenerateBundle()` iterates a `map`, so the order of class definitions in the output is non-deterministic. This does not affect correctness but may cause cosmetic diffs between runs. +- `codegen.GenerateBundle()` now renders output classes in sorted slot-key order so generated bundles are stable between runs. diff --git a/docs/history.md b/docs/history.md index 4631ef2..fc6ff0b 100644 --- a/docs/history.md +++ b/docs/history.md @@ -78,7 +78,7 @@ The fix was applied in three distinct steps: ### Size gate test (`aae5d21`) -`cmd/wasm/size_test.go` was added to prevent regression. `TestWASMBinarySize_Good` builds the WASM binary in a temp directory, gzip-compresses it, and asserts: +`cmd/wasm/size_test.go` was added to prevent regression. `TestWASMBinarySize_WithinBudget` builds the WASM binary in a temp directory, gzip-compresses it, and asserts: - Gzip size < 1,048,576 bytes (1 MB). - Raw size < 3,145,728 bytes (3 MB). @@ -101,11 +101,11 @@ These are not regressions; they are design choices or deferred work recorded for 3. **Responsive accepts only Layout.** `Responsive.Variant()` takes `*Layout` rather than `Node`. The rationale is that `CompareVariants` in the pipeline needs access to the slot structure. Accepting `Node` would require a different approach to variant analysis. -4. **Context.service is private.** The i18n service cannot be set after construction or swapped. This is a conservative choice; relaxing it requires deciding whether mutation should be safe for concurrent use. +4. **Context.service is private.** The i18n service is still unexported, but callers can now swap it explicitly with `Context.SetService()`. This keeps the field encapsulated while allowing controlled mutation. 5. **TypeScript definitions not generated.** `codegen.GenerateBundle()` produces JS only. A `.d.ts` companion would benefit TypeScript consumers of the generated Web Components. -6. **No CSS scoping helper.** Responsive variants are identified by `data-variant` attributes. Targeting them from CSS requires knowledge of the attribute name. An optional utility for generating scoped CSS selectors is deferred. +6. **CSS scoping helper added.** `VariantSelector(name)` returns a reusable `data-variant` attribute selector for stylesheet targeting. The `Responsive` rendering model remains unchanged. 7. **Browser polyfill matrix not documented.** Closed Shadow DOM is well-supported but older browsers require polyfills. The support matrix is not documented. @@ -114,6 +114,7 @@ These are not regressions; they are design choices or deferred work recorded for These items were captured during the WASM size reduction work and expert review sessions. They are not committed work items. - **TypeScript type definitions** alongside `GenerateBundle()` for typed Web Component consumers. -- **Accessibility helpers** — `aria-label` builder, `alt` text helpers, focus management nodes. The layout has semantic HTML and ARIA roles but no API for fine-grained accessibility attributes beyond `Attr()`. +- **Accessibility helpers** — `aria-label` builder, `alt` text helpers, and focus management helpers (`TabIndex`, `AutoFocus`). The layout has semantic HTML and ARIA roles but no API for fine-grained accessibility attributes beyond `Attr()`. +- **Responsive CSS helpers** — `VariantSelector(name)` makes `data-variant` targeting explicit and reusable in stylesheets. - **Layout variant validation** — return a warning or sentinel error from `NewLayout` when the variant string contains unrecognised slot characters. - **Daemon mode for codegen** — watch mode for regenerating the JS bundle when slot config changes, for development workflows. diff --git a/docs/index.md b/docs/index.md index c7b12c4..2d3385f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -39,9 +39,9 @@ This builds a Header-Content-Footer layout with semantic HTML elements (`
` (H), `