diff --git a/.core/build.yaml b/.core/build.yaml deleted file mode 100644 index 95a5309..0000000 --- a/.core/build.yaml +++ /dev/null @@ -1,25 +0,0 @@ -version: 1 - -project: - name: go-html - description: HTML templating engine - main: ./cmd/wasm - binary: core-html-wasm - -build: - cgo: false - flags: - - -trimpath - ldflags: - - -s - - -w - -targets: - - os: linux - arch: amd64 - - os: linux - arch: arm64 - - os: darwin - arch: arm64 - - os: windows - arch: amd64 diff --git a/.core/release.yaml b/.core/release.yaml deleted file mode 100644 index 5a1a9c5..0000000 --- a/.core/release.yaml +++ /dev/null @@ -1,20 +0,0 @@ -version: 1 - -project: - name: go-html - repository: core/go-html - -publishers: [] - -changelog: - include: - - feat - - fix - - perf - - refactor - exclude: - - chore - - docs - - style - - test - - ci diff --git a/.gitignore b/.gitignore index cdc6f76..509a6b1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .vscode/ *.log .core/ +dist/ diff --git a/README.md b/README.md index 507bc63..73639bb 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,19 @@ -[![Go Reference](https://pkg.go.dev/badge/forge.lthn.ai/core/go-html.svg)](https://pkg.go.dev/forge.lthn.ai/core/go-html) +[![Go Reference](https://pkg.go.dev/badge/dappco.re/go/core/html.svg)](https://pkg.go.dev/dappco.re/go/core/html) [![License: EUPL-1.2](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE.md) [![Go Version](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat&logo=go)](go.mod) # 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, 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()`. +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, TabIndex, AutoFocus, Role), 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 with optional TypeScript declarations, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()`. -**Module**: `forge.lthn.ai/core/go-html` +**Module**: `dappco.re/go/core/html` **Licence**: EUPL-1.2 -**Language**: Go 1.25 +**Language**: Go 1.26 ## Quick Start ```go -import "forge.lthn.ai/core/go-html" +import html "dappco.re/go/core/html" page := html.NewLayout("HCF"). H(html.El("nav", html.Text("i18n.label.navigation"))). diff --git a/SESSION-BRIEF.md b/SESSION-BRIEF.md index 370b226..82fb5fb 100644 --- a/SESSION-BRIEF.md +++ b/SESSION-BRIEF.md @@ -2,7 +2,7 @@ **Repo**: `forge.lthn.ai/core/go-html` (clone at `/tmp/core-go-html`) **Module**: `forge.lthn.ai/core/go-html` -**Status**: 19 Go files, 1,591 LOC, 53 tests ALL PASS +**Status**: Current tests pass; WASM build is within budget and codegen emits JS plus TypeScript defs **Wiki**: https://forge.lthn.ai/core/go-html/wiki (6 pages) ## What This Is @@ -12,19 +12,19 @@ HLCRF DOM compositor with grammar pipeline. Renders semantic HTML from composabl - **HLCRF Layout**: Header/Left/Content/Right/Footer with ARIA roles - **Responsive**: Multi-variant breakpoint rendering - **Pipeline**: Render → strip tags → tokenise via go-i18n/reversal → GrammarImprint -- **WASM target**: `cmd/wasm/` exposes renderToString() and registerComponents() to JS -- **Codegen**: Web Component classes with closed Shadow DOM +- **WASM target**: `cmd/wasm/` exposes `renderToString()` to JS +- **Codegen**: Web Component classes with closed Shadow DOM plus `.d.ts` generation ## Current State | Area | Status | |------|--------| | Core (node, layout, responsive, pipeline) | SOLID — all tested, clean API | -| Tests | 53/53 pass, excellent coverage ratios | +| Tests | Passing | | go vet | Clean | | TODOs/FIXMEs | None | -| WASM build | FAILS — 1.58 MB gzip exceeds 1 MB Makefile limit | -| Codegen | Working — generates WC classes | +| WASM build | PASS — within the 1 MB gzip gate | +| Codegen | Working — generates WC classes and `.d.ts` definitions | ## Dependencies @@ -34,21 +34,7 @@ HLCRF DOM compositor with grammar pipeline. Renders semantic HTML from composabl ## Priority Work -### High (blockers) -1. **Fix WASM size** — Move `buildComponentJS()` / JSON parsing to server-side. WASM should only do `Render()`. Current: 6.0 MB raw / 1.58 MB gzip. -2. **WASM integration tests** — No `cmd/wasm/main_test.go` exists. Can't test JS↔Go round-trip. - -### Medium (completeness) -3. **Performance benchmarks** — No `BenchmarkRender()` or `BenchmarkImprint()`. Add them. -4. **TypeScript type definitions** — Codegen only produces JS. Add `.d.ts` generation for WC bundle. -5. **Accessibility helpers** — Layout has semantic HTML + ARIA roles, but no aria-label builder or alt text helpers. -6. **Layout variant validation** — `NewLayout("XYZ")` silently produces empty output. Could warn. - -### Low (hardening) -7. **Unicode/RTL edge cases** — Test emoji, RTL text in Text nodes -8. **Deep nesting stress test** — Circular or very deep Layout nesting -9. **Large Each[T]** — Test with thousands of items -10. **Browser polyfill docs** — Closed Shadow DOM support matrix +No active blockers recorded here. See `docs/history.md` for the remaining design choices and deferred ideas that were captured during earlier implementation phases. ## File Map diff --git a/Taskfile.yaml b/Taskfile.yaml new file mode 100644 index 0000000..01645f0 --- /dev/null +++ b/Taskfile.yaml @@ -0,0 +1,11 @@ +version: '3' + +tasks: + wasm:build: + desc: Build the browser/Node/Deno render WASM module. + env: + GOWORK: off + GOFLAGS: -mod=readonly + cmds: + - mkdir -p dist + - GOOS=js GOARCH=wasm go build -o dist/render.wasm ./cmd/wasm/ diff --git a/bench_test.go b/bench_test.go index 7440203..4a9a4c9 100644 --- a/bench_test.go +++ b/bench_test.go @@ -3,7 +3,7 @@ package html import ( "testing" - i18n "dappco.re/go/core/i18n" + i18n "dappco.re/go/i18n" ) func init() { diff --git a/cmd/codegen/main.go b/cmd/codegen/main.go index 3874c2f..6e9a56f 100644 --- a/cmd/codegen/main.go +++ b/cmd/codegen/main.go @@ -7,24 +7,23 @@ // // 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 +// go run ./cmd/codegen/ -watch --input=slots.json --output=components.js package main import ( "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" + "dappco.re/go/html/codegen" + coreio "dappco.re/go/io" + log "dappco.re/go/log" ) +const defaultPollInterval = 250 * time.Millisecond +const defaultInputPath = "/dev/stdin" +const defaultOutputPath = "/dev/stdout" + func generate(data []byte, emitTypes bool) (string, error) { var slots map[string]string if result := core.JSONUnmarshal(data, &slots); !result.OK { @@ -43,10 +42,10 @@ func generate(data []byte, emitTypes bool) (string, error) { return out, nil } -func run(r goio.Reader, w goio.Writer, emitTypes bool) error { - data, err := goio.ReadAll(r) +func run(input, output any, emitTypes bool) error { + data, err := readInput(input) if err != nil { - return log.E("codegen", "reading stdin", err) + return log.E("codegen", "reading input", err) } out, err := generate(data, emitTypes) @@ -54,13 +53,37 @@ func run(r goio.Reader, w goio.Writer, emitTypes bool) error { return err } - _, err = goio.WriteString(w, out) - if err != nil { + if err := writeOutput(output, out); err != nil { return log.E("codegen", "writing output", err) } return nil } +func readInput(input any) ([]byte, error) { + if path, ok := input.(string); ok { + return readLocalFile(path) + } + + result := core.ReadAll(input) + if !result.OK { + return nil, resultError("codegen", "reading input stream", result) + } + content, _ := result.Value.(string) + return []byte(content), nil +} + +func writeOutput(output any, content string) error { + if path, ok := output.(string); ok { + return writeLocalFile(path, content) + } + + result := core.WriteAll(output, content) + if !result.OK { + return resultError("codegen", "writing output stream", result) + } + 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) @@ -69,7 +92,7 @@ func runDaemon(ctx context.Context, inputPath, outputPath string, emitTypes bool return log.E("codegen", "watch mode requires -output", nil) } if pollInterval <= 0 { - pollInterval = 250 * time.Millisecond + pollInterval = defaultPollInterval } var lastInput []byte @@ -92,7 +115,7 @@ func runDaemon(ctx context.Context, inputPath, outputPath string, emitTypes bool select { case <-ctx.Done(): - if errors.Is(ctx.Err(), context.Canceled) { + if core.Is(ctx.Err(), context.Canceled) { return nil } return ctx.Err() @@ -102,28 +125,54 @@ func runDaemon(ctx context.Context, inputPath, outputPath string, emitTypes bool } func readLocalFile(path string) ([]byte, error) { - f, err := coreio.Local.Open(path) + if path == "" { + path = defaultInputPath + } + + if path == defaultInputPath { + f, err := coreio.Local.Open(path) + if err != nil { + return nil, err + } + + result := core.ReadAll(f) + if !result.OK { + return nil, resultError("codegen", "reading stdin", result) + } + content, _ := result.Value.(string) + return []byte(content), nil + } + + content, err := coreio.Local.Read(path) if err != nil { return nil, err } - defer func() { - _ = f.Close() - }() - - return goio.ReadAll(f) + return []byte(content), nil } func writeLocalFile(path, content string) error { - f, err := coreio.Local.Create(path) - if err != nil { - return err + if path == "" { + path = defaultOutputPath + } + + if path == defaultOutputPath { + f, err := coreio.Local.Create(path) + if err != nil { + f, err = coreio.Local.Append(path) + if err != nil { + core.Print(nil, "%s", content) + return nil + } + } + + result := core.WriteAll(f, content) + if !result.OK { + return resultError("codegen", "writing stdout", result) + } + return nil } - defer func() { - _ = f.Close() - }() - _, err = goio.WriteString(f, content) - return err + return coreio.Local.Write(path, content) } func sameBytes(a, b []byte) bool { @@ -138,44 +187,180 @@ func sameBytes(a, b []byte) bool { return true } -func main() { - 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 +func runStdio(emitTypes bool) error { + return run(defaultInputPath, defaultOutputPath, emitTypes) +} + +func newCodegenApp() *core.Core { + c := core.New(core.WithOption("name", "codegen")) + if cli := c.Cli(); cli != nil { + cli.SetOutput(discardWriter{}) + } + + registerCodegenCommands(c) + return c +} + +func registerCodegenCommands(c *core.Core) { + c.Command("generate", core.Command{ + Description: "Generate JavaScript or TypeScript from a JSON slot map on stdin", + Flags: codegenCommandFlags(), + Action: func(opts core.Options) core.Result { + return resultFromError(runGenerateCommand(opts, opts.Bool("types"))) + }, + }) + c.Command("types", core.Command{ + Description: "Generate TypeScript declarations from a JSON slot map on stdin", + Flags: codegenCommandFlags(), + Action: func(opts core.Options) core.Result { + return resultFromError(runGenerateCommand(opts, true)) + }, + }) + c.Command("-types", core.Command{ + Hidden: true, + Flags: codegenCommandFlags(), + Action: func(opts core.Options) core.Result { + return resultFromError(runGenerateCommand(opts, true)) + }, + }) + c.Command("--types", core.Command{ + Hidden: true, + Flags: codegenCommandFlags(), + Action: func(opts core.Options) core.Result { + return resultFromError(runGenerateCommand(opts, true)) + }, + }) + c.Command("watch", core.Command{ + Description: "Poll an input JSON file and rewrite the generated output", + Flags: codegenCommandFlags(), + Action: func(opts core.Options) core.Result { + return resultFromError(runWatchCommand(c, opts, opts.Bool("types"))) + }, + }) + c.Command("-watch", core.Command{ + Hidden: true, + Flags: codegenCommandFlags(), + Action: func(opts core.Options) core.Result { + return resultFromError(runWatchCommand(c, opts, opts.Bool("types"))) + }, + }) + c.Command("--watch", core.Command{ + Hidden: true, + Flags: codegenCommandFlags(), + Action: func(opts core.Options) core.Result { + return resultFromError(runWatchCommand(c, opts, opts.Bool("types"))) + }, + }) +} + +func codegenCommandFlags() core.Options { + return core.NewOptions( + core.Option{Key: "types", Value: false}, + core.Option{Key: "input", Value: ""}, + core.Option{Key: "output", Value: ""}, + core.Option{Key: "poll", Value: defaultPollInterval.String()}, + ) +} + +func runGenerateCommand(opts core.Options, emitTypes bool) error { + return run(inputPathFromOptions(opts), outputPathFromOptions(opts), emitTypes) +} + +func inputPathFromOptions(opts core.Options) string { + if input := opts.String("input"); input != "" { + return input } + return defaultInputPath +} + +func outputPathFromOptions(opts core.Options) string { + if output := opts.String("output"); output != "" { + return output + } + return defaultOutputPath +} - stdin, err := coreio.Local.Open("/dev/stdin") +func runWatchCommand(c *core.Core, opts core.Options, emitTypes bool) error { + pollInterval, err := pollIntervalFromOptions(opts) if err != nil { - log.Error("failed to open stdin", "scope", "codegen.main", "err", log.E("codegen.main", "open stdin", err)) - os.Exit(1) + return err + } + + ctx := context.Background() + if c != nil { + ctx = c.Context() + } + return runDaemon(ctx, opts.String("input"), opts.String("output"), emitTypes, pollInterval) +} + +func pollIntervalFromOptions(opts core.Options) (time.Duration, error) { + raw := opts.String("poll") + if raw == "" { + return defaultPollInterval, nil } - stdout, err := coreio.Local.Create("/dev/stdout") + pollInterval, err := time.ParseDuration(raw) 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) + return 0, log.E("codegen", "invalid poll interval", err) } + return pollInterval, nil +} + +func runCodegenApp(c *core.Core) error { + if c == nil { + return log.E("codegen.main", "core app is required", nil) + } + defer func() { - _ = stdin.Close() - _ = stdout.Close() + _ = c.ServiceShutdown(context.Background()) }() - if err := run(stdin, stdout, *emitTypes); err != nil { + if result := c.ServiceStartup(c.Context(), nil); !result.OK { + return resultError("codegen.main", "startup failed", result) + } + + cli := c.Cli() + if cli == nil { + return runStdio(false) + } + + result := cli.Run() + if result.OK { + return nil + } + if err, ok := result.Value.(error); ok && err != nil { + return err + } + + return runStdio(false) +} + +func resultFromError(err error) core.Result { + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{OK: true} +} + +func resultError(op, msg string, result core.Result) error { + if result.OK { + return nil + } + if err, ok := result.Value.(error); ok && err != nil { + return err + } + return log.E(op, msg, nil) +} + +type discardWriter struct{} + +func (discardWriter) Write(data []byte) (int, error) { + return len(data), nil +} + +func main() { + c := newCodegenApp() + if err := runCodegenApp(c); 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 703d0ab..812b7d0 100644 --- a/cmd/codegen/main_test.go +++ b/cmd/codegen/main_test.go @@ -11,23 +11,30 @@ import ( "time" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + coreio "dappco.re/go/io" ) func TestRun_WritesBundle_Good(t *testing.T) { input := core.NewReader(`{"H":"nav-bar","C":"main-content"}`) output := core.NewBuilder() - err := run(input, output, false) - require.NoError(t, err) + if err := run(input, output, false); err != nil { + t.Fatalf("unexpected error: %v", err) + } js := output.String() - assert.Contains(t, js, "NavBar") - assert.Contains(t, js, "MainContent") - assert.Contains(t, js, "customElements.define") - assert.Equal(t, 2, countSubstr(js, "extends HTMLElement")) + if !strings.Contains(js, "NavBar") { + t.Fatal("expected js to contain NavBar") + } + if !strings.Contains(js, "MainContent") { + t.Fatal("expected js to contain MainContent") + } + if !strings.Contains(js, "customElements.define") { + t.Fatal("expected js to contain customElements.define") + } + if got := countSubstr(js, "extends HTMLElement"); got != 2 { + t.Fatalf("want 2 extends HTMLElement, got %d", got) + } } func TestRun_InvalidJSON_Bad(t *testing.T) { @@ -35,8 +42,12 @@ func TestRun_InvalidJSON_Bad(t *testing.T) { output := core.NewBuilder() err := run(input, output, false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid JSON") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "invalid JSON") { + t.Fatalf("expected error to contain %q, got %v", "invalid JSON", err) + } } func TestRun_InvalidTag_Bad(t *testing.T) { @@ -44,8 +55,12 @@ func TestRun_InvalidTag_Bad(t *testing.T) { output := core.NewBuilder() err := run(input, output, false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "hyphen") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "hyphen") { + t.Fatalf("expected error to contain %q, got %v", "hyphen", err) + } } func TestRun_InvalidTagCharacters_Bad(t *testing.T) { @@ -53,32 +68,46 @@ func TestRun_InvalidTagCharacters_Bad(t *testing.T) { output := core.NewBuilder() err := run(input, output, false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "lowercase hyphenated name") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "lowercase hyphenated name") { + t.Fatalf("expected error to contain %q, got %v", "lowercase hyphenated name", err) + } } func TestRun_EmptySlots_Good(t *testing.T) { input := core.NewReader(`{}`) output := core.NewBuilder() - err := run(input, output, false) - require.NoError(t, err) - assert.Empty(t, output.String()) + if err := run(input, output, false); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := output.String(); got != "" { + t.Fatalf("expected empty output, got %q", got) + } } 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) + if err := run(input, output, true); err != nil { + t.Fatalf("unexpected error: %v", 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") + for _, want := range []string{ + "declare global", + `"nav-bar": NavBar;`, + `"main-content": MainContent;`, + "export declare class NavBar extends HTMLElement", + "export declare class MainContent extends HTMLElement", + } { + if !strings.Contains(dts, want) { + t.Fatalf("expected dts to contain %q", want) + } + } } func TestRunDaemon_WritesUpdatedBundle_Good(t *testing.T) { @@ -86,7 +115,9 @@ func TestRunDaemon_WritesUpdatedBundle_Good(t *testing.T) { inputPath := filepath.Join(dir, "slots.json") outputPath := filepath.Join(dir, "bundle.js") - require.NoError(t, writeTextFile(inputPath, `{"H":"nav-bar","C":"main-content"}`)) + if err := writeTextFile(inputPath, `{"H":"nav-bar","C":"main-content"}`); err != nil { + t.Fatalf("unexpected error: %v", err) + } ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -96,22 +127,34 @@ func TestRunDaemon_WritesUpdatedBundle_Good(t *testing.T) { done <- runDaemon(ctx, inputPath, outputPath, false, 5*time.Millisecond) }() - require.Eventually(t, func() bool { + deadline := time.Now().Add(time.Second) + ok := false + for time.Now().Before(deadline) { got, err := readTextFile(outputPath) - if err != nil { - return false + if err == nil && strings.Contains(got, "NavBar") && strings.Contains(got, "MainContent") { + ok = true + break } - return strings.Contains(got, "NavBar") && strings.Contains(got, "MainContent") - }, time.Second, 10*time.Millisecond) + time.Sleep(10 * time.Millisecond) + } + if !ok { + t.Fatal("expected bundle file to contain NavBar and MainContent within 1s") + } cancel() - require.NoError(t, <-done) + if err := <-done; err != nil { + t.Fatalf("unexpected error: %v", err) + } } 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") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "watch mode requires -input") { + t.Fatalf("expected error to contain %q, got %v", "watch mode requires -input", err) + } } func countSubstr(s, substr string) int { diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index dc74244..63a97e2 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -3,61 +3,92 @@ package main import ( + "strings" "syscall/js" - html "dappco.re/go/core/html" + html "dappco.re/go/html" ) -// renderToString builds an HLCRF layout from JS arguments and returns HTML. -// Slot content is injected via Raw() — the caller is responsible for sanitisation. -// This is intentional: the WASM module is a rendering engine for trusted content -// produced server-side or by the application's own templates. +// Keep the callback alive for the lifetime of the WASM module. +var renderToStringFunc js.Func + +// renderToString renders a template string with optional scalar data. +// TinyGo can be evaluated later if stdlib WASM size becomes the limiting factor. func renderToString(_ js.Value, args []js.Value) any { if len(args) < 1 || args[0].Type() != js.TypeString { return "" } - variant := args[0].String() - if variant == "" { + template := args[0].String() + if template == "" { return "" } - ctx := html.NewContext() + if len(args) >= 3 { + return renderLegacyLayout(args) + } + + if len(args) < 2 || args[1].Type() != js.TypeObject { + return html.Render(html.Raw(template), html.NewContext()) + } + + return renderTemplateString(template, args[1]) +} + +func renderLegacyLayout(args []js.Value) string { + variant := args[0].String() + locale := "" if len(args) >= 2 && args[1].Type() == js.TypeString { - ctx.SetLocale(args[1].String()) + locale = args[1].String() } - layout := html.NewLayout(variant) + slots := make(map[string]string) if len(args) >= 3 && args[2].Type() == js.TypeObject { - slots := args[2] + jsSlots := args[2] for _, slot := range []string{"H", "L", "C", "R", "F"} { - content := slots.Get(slot) - if content.Type() == js.TypeString && content.String() != "" { - switch slot { - case "H": - layout.H(html.Raw(content.String())) - case "L": - layout.L(html.Raw(content.String())) - case "C": - layout.C(html.Raw(content.String())) - case "R": - layout.R(html.Raw(content.String())) - case "F": - layout.F(html.Raw(content.String())) - } + content := jsSlots.Get(slot) + if content.Type() == js.TypeString { + slots[slot] = content.String() } } } - return layout.Render(ctx) + return renderLayout(variant, locale, slots) +} + +func renderTemplateString(template string, data js.Value) string { + ctx := html.NewContext() + out := template + keys := js.Global().Get("Object").Call("keys", data) + for i := 0; i < keys.Get("length").Int(); i++ { + key := keys.Index(i).String() + value := data.Get(key) + if value.Type() == js.TypeUndefined || value.Type() == js.TypeNull || value.Type() == js.TypeObject || value.Type() == js.TypeFunction { + continue + } + rendered := html.Render(html.Text(scalarString(value)), ctx) + out = strings.ReplaceAll(out, "{{"+key+"}}", rendered) + out = strings.ReplaceAll(out, "{{ "+key+" }}", rendered) + } + return out +} + +func scalarString(value js.Value) string { + if value.Type() == js.TypeString { + return value.String() + } + return js.Global().Get("String").Invoke(value).String() } func main() { - js.Global().Set("gohtml", js.ValueOf(map[string]any{ - "renderToString": js.FuncOf(renderToString), - })) + renderToStringFunc = js.FuncOf(renderToString) + + api := js.Global().Get("Object").New() + api.Set("renderToString", renderToStringFunc) + js.Global().Set("gohtml", api) + js.Global().Set("renderToString", renderToStringFunc) select {} } diff --git a/cmd/wasm/main_test.go b/cmd/wasm/main_test.go index 4184caa..e1f0739 100644 --- a/cmd/wasm/main_test.go +++ b/cmd/wasm/main_test.go @@ -20,12 +20,30 @@ func TestRenderToString_Good(t *testing.T) { t.Fatalf("renderToString should return string, got %T", gotAny) } - want := `
hello
` + want := `
hello
` if got != want { t.Fatalf("renderToString(...) = %q, want %q", got, want) } } +func TestRenderToString_EmptySlot_Good(t *testing.T) { + gotAny := renderToString(js.Value{}, []js.Value{ + js.ValueOf("C"), + js.ValueOf("en-GB"), + js.ValueOf(map[string]any{"C": ""}), + }) + + got, ok := gotAny.(string) + if !ok { + t.Fatalf("renderToString should return string, got %T", gotAny) + } + + want := `
` + if got != want { + t.Fatalf("renderToString empty slot = %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) @@ -48,7 +66,7 @@ func TestRenderToString_LocaleTypeGuard(t *testing.T) { t.Fatalf("renderToString should return string, got %T", gotAny) } - want := `
x
` + 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 d463465..dc86943 100644 --- a/cmd/wasm/register.go +++ b/cmd/wasm/register.go @@ -5,8 +5,8 @@ package main import ( core "dappco.re/go/core" - "dappco.re/go/core/html/codegen" - log "dappco.re/go/core/log" + "dappco.re/go/html/codegen" + log "dappco.re/go/log" ) // buildComponentJS takes a JSON slot map and returns the WC bundle JS string. diff --git a/cmd/wasm/register_test.go b/cmd/wasm/register_test.go index 9365e9e..d3632c3 100644 --- a/cmd/wasm/register_test.go +++ b/cmd/wasm/register_test.go @@ -3,22 +3,30 @@ package main import ( + "strings" "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestBuildComponentJS_ValidJSON_Good(t *testing.T) { slotsJSON := `{"H":"nav-bar","C":"main-content"}` js, err := buildComponentJS(slotsJSON) - require.NoError(t, err) - assert.Contains(t, js, "NavBar") - assert.Contains(t, js, "MainContent") - assert.Contains(t, js, "customElements.define") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(js, "NavBar") { + t.Fatal("expected js to contain NavBar") + } + if !strings.Contains(js, "MainContent") { + t.Fatal("expected js to contain MainContent") + } + if !strings.Contains(js, "customElements.define") { + t.Fatal("expected js to contain customElements.define") + } } func TestBuildComponentJS_InvalidJSON_Bad(t *testing.T) { _, err := buildComponentJS("not json") - assert.Error(t, err) + if err == nil { + t.Fatal("expected error, got nil") + } } diff --git a/cmd/wasm/render_layout.go b/cmd/wasm/render_layout.go new file mode 100644 index 0000000..709c22e --- /dev/null +++ b/cmd/wasm/render_layout.go @@ -0,0 +1,44 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package main + +import html "dappco.re/go/html" + +// renderLayout renders an HLCRF layout from a slot map. +// +// Empty string values are meaningful: they create an explicit empty slot +// container rather than being treated as absent input. +func renderLayout(variant, locale string, slots map[string]string) string { + if variant == "" { + return "" + } + + ctx := html.NewContext() + if locale != "" { + ctx.SetLocale(locale) + } + + layout := html.NewLayout(variant) + + for _, slot := range []string{"H", "L", "C", "R", "F"} { + content, ok := slots[slot] + if !ok { + continue + } + + switch slot { + case "H": + layout.H(html.Raw(content)) + case "L": + layout.L(html.Raw(content)) + case "C": + layout.C(html.Raw(content)) + case "R": + layout.R(html.Raw(content)) + case "F": + layout.F(html.Raw(content)) + } + } + + return layout.Render(ctx) +} diff --git a/cmd/wasm/render_layout_test.go b/cmd/wasm/render_layout_test.go new file mode 100644 index 0000000..899b56c --- /dev/null +++ b/cmd/wasm/render_layout_test.go @@ -0,0 +1,15 @@ +//go:build !js + +// SPDX-Licence-Identifier: EUPL-1.2 + +package main + +import "testing" + +func TestRenderLayout_EmptyStringSlot_Good(t *testing.T) { + got := renderLayout("C", "en-GB", map[string]string{"C": ""}) + want := `
` + if got != want { + t.Fatalf("renderLayout with empty slot = %q, want %q", got, want) + } +} diff --git a/cmd/wasm/size_test.go b/cmd/wasm/size_test.go index e8b1d96..94b93b9 100644 --- a/cmd/wasm/size_test.go +++ b/cmd/wasm/size_test.go @@ -9,10 +9,8 @@ import ( "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" + coreio "dappco.re/go/io" + process "dappco.re/go/process" ) const ( @@ -30,10 +28,14 @@ func TestCmdWasm_WASMBinarySize_Good(t *testing.T) { factory := process.NewService(process.Options{}) serviceValue, err := factory(core.New()) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } svc, ok := serviceValue.(*process.Service) - require.True(t, ok, "process service factory returned %T", serviceValue) + if !ok { + t.Fatalf("process service factory returned %T", serviceValue) + } output, err := svc.RunWithOptions(context.Background(), process.RunOptions{ Command: "go", @@ -41,23 +43,34 @@ func TestCmdWasm_WASMBinarySize_Good(t *testing.T) { Dir: ".", Env: []string{"GOOS=js", "GOARCH=wasm"}, }) - require.NoError(t, err, "WASM build failed: %s", output) + if err != nil { + t.Fatalf("WASM build failed: %v: %s", err, output) + } rawStr, err := coreio.Local.Read(out) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } rawBytes := []byte(rawStr) buf := core.NewBuilder() gz, err := gzip.NewWriterLevel(buf, gzip.BestCompression) - require.NoError(t, err) - _, err = gz.Write(rawBytes) - require.NoError(t, err) - require.NoError(t, gz.Close()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := gz.Write(rawBytes); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("unexpected error: %v", err) + } 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(rawBytes), wasmRawLimit, - "WASM raw size %d exceeds 3MB limit", len(rawBytes)) + if buf.Len() >= wasmGzLimit { + t.Fatalf("WASM gzip size %d exceeds 1MB limit", buf.Len()) + } + if len(rawBytes) >= wasmRawLimit { + t.Fatalf("WASM raw size %d exceeds 3MB limit", len(rawBytes)) + } } diff --git a/codegen/codegen.go b/codegen/codegen.go index 2fbcebf..b210714 100644 --- a/codegen/codegen.go +++ b/codegen/codegen.go @@ -5,29 +5,49 @@ package codegen import ( "sort" "text/template" + "unicode" + "unicode/utf8" core "dappco.re/go/core" - log "dappco.re/go/core/log" + log "dappco.re/go/log" ) -// isValidCustomElementTag reports whether tag is a safe custom element name. +var reservedCustomElementNames = map[string]struct{}{ + "annotation-xml": {}, + "color-profile": {}, + "font-face": {}, + "font-face-src": {}, + "font-face-uri": {}, + "font-face-format": {}, + "font-face-name": {}, + "missing-glyph": {}, +} + +// isValidCustomElementTag reports whether tag is a valid 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 !utf8.ValidString(tag) { + return false + } - if tag[0] < 'a' || tag[0] > 'z' { + if _, reserved := reservedCustomElementNames[tag]; reserved { 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: + first, _ := utf8.DecodeRuneInString(tag) + if first < 'a' || first > 'z' { + return false + } + + for _, r := range tag { + if r >= 'A' && r <= 'Z' { + return false + } + switch r { + case 0, '/', '>', '\t', '\n', '\f', '\r', ' ': return false } } @@ -35,6 +55,66 @@ func isValidCustomElementTag(tag string) bool { return true } +type jsStringBuilder interface { + WriteByte(byte) error + WriteRune(rune) (int, error) + WriteString(string) (int, error) + String() string +} + +// escapeJSStringLiteral escapes content for inclusion inside a double-quoted JS string. +func escapeJSStringLiteral(s string) string { + b := core.NewBuilder() + appendJSStringLiteral(b, s) + return b.String() +} + +func appendJSStringLiteral(b jsStringBuilder, s string) { + for _, r := range s { + switch r { + case '\\': + b.WriteString(`\\`) + case '"': + b.WriteString(`\"`) + case '\b': + b.WriteString(`\b`) + case '\f': + b.WriteString(`\f`) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + case 0x2028: + b.WriteString(`\u2028`) + case 0x2029: + b.WriteString(`\u2029`) + default: + if r < 0x20 { + appendUnicodeEscape(b, r) + continue + } + if r > 0xFFFF { + rr := r - 0x10000 + appendUnicodeEscape(b, rune(0xD800+(rr>>10))) + appendUnicodeEscape(b, rune(0xDC00+(rr&0x3FF))) + continue + } + _, _ = b.WriteRune(r) + } + } +} + +func appendUnicodeEscape(b jsStringBuilder, r rune) { + const hex = "0123456789ABCDEF" + b.WriteString(`\u`) + b.WriteByte(hex[(r>>12)&0xF]) + b.WriteByte(hex[(r>>8)&0xF]) + b.WriteByte(hex[(r>>4)&0xF]) + b.WriteByte(hex[r&0xF]) +} + // 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). @@ -46,12 +126,12 @@ var wcTemplate = template.Must(template.New("wc").Parse(`class {{.ClassName}} ex } connectedCallback() { this.#shadow.textContent = ""; - const slot = this.getAttribute("data-slot") || "{{.Slot}}"; - this.dispatchEvent(new CustomEvent("wc-ready", { detail: { tag: "{{.Tag}}", slot } })); + const slot = this.getAttribute("data-slot") || "{{.SlotLiteral}}"; + this.dispatchEvent(new CustomEvent("wc-ready", { detail: { tag: "{{.TagLiteral}}", slot } })); } render(html) { const tpl = document.createElement("template"); - tpl.insertAdjacentHTML("afterbegin", html); + tpl.innerHTML = html; this.#shadow.textContent = ""; this.#shadow.appendChild(tpl.content.cloneNode(true)); } @@ -64,12 +144,14 @@ func GenerateClass(tag, slot string) (string, error) { return "", log.E("codegen.GenerateClass", "custom element tag must be a lowercase hyphenated name: "+tag, nil) } b := core.NewBuilder() + tagLiteral := escapeJSStringLiteral(tag) + slotLiteral := escapeJSStringLiteral(slot) err := wcTemplate.Execute(b, struct { - ClassName, Tag, Slot string + ClassName, TagLiteral, SlotLiteral string }{ - ClassName: TagToClassName(tag), - Tag: tag, - Slot: slot, + ClassName: TagToClassName(tag), + TagLiteral: tagLiteral, + SlotLiteral: slotLiteral, }) if err != nil { return "", log.E("codegen.GenerateClass", "template exec", err) @@ -80,17 +162,28 @@ 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 `customElements.define("` + tag + `", ` + className + `);` + return `customElements.define("` + escapeJSStringLiteral(tag) + `", ` + className + `);` } -// TagToClassName converts a kebab-case tag to PascalCase class name. +// TagToClassName converts a custom element tag to PascalCase class name. // Usage example: className := TagToClassName("nav-bar") func TagToClassName(tag string) string { b := core.NewBuilder() - for _, p := range core.Split(tag, "-") { - if len(p) > 0 { - b.WriteString(core.Upper(p[:1])) - b.WriteString(p[1:]) + upperNext := true + for _, r := range tag { + switch { + case unicode.IsLetter(r): + if upperNext { + _, _ = b.WriteRune(unicode.ToUpper(r)) + } else { + _, _ = b.WriteRune(r) + } + upperNext = false + case unicode.IsDigit(r): + _, _ = b.WriteRune(r) + upperNext = false + default: + upperNext = true } } return b.String() diff --git a/codegen/codegen_test.go b/codegen/codegen_test.go index 3fdcdc4..36ef4da 100644 --- a/codegen/codegen_test.go +++ b/codegen/codegen_test.go @@ -5,36 +5,79 @@ package codegen import ( "strings" "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) 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") - assert.Contains(t, js, "attachShadow") - assert.Contains(t, js, `mode: "closed"`) - assert.Contains(t, js, "photo-grid") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, want := range []string{ + "class PhotoGrid extends HTMLElement", + "attachShadow", + `mode: "closed"`, + "photo-grid", + } { + if !strings.Contains(js, want) { + t.Fatalf("expected js to contain %q", want) + } + } } 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") + if _, err := GenerateClass("invalid", "C"); err == nil { + t.Fatal("expected error: custom element names must contain a hyphen") + } + if _, err := GenerateClass("Nav-Bar", "C"); err == nil { + t.Fatal("expected error: custom element names must be lowercase") + } + if _, err := GenerateClass("nav bar", "C"); err == nil { + t.Fatal("expected error: custom element names must reject spaces") + } + if _, err := GenerateClass("annotation-xml", "C"); err == nil { + t.Fatal("expected error: reserved custom element names must be rejected") + } } 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") + for _, want := range []string{ + "customElements.define", + `"photo-grid"`, + "PhotoGrid", + } { + if !strings.Contains(js, want) { + t.Fatalf("expected js to contain %q", want) + } + } +} + +func TestGenerateClass_ValidExtendedTag_Good(t *testing.T) { + tests := []struct { + tag string + wantClass string + }{ + {tag: "foo.bar-baz", wantClass: "FooBarBaz"}, + {tag: "math-α", wantClass: "MathΑ"}, + } + + for _, tt := range tests { + t.Run(tt.tag, func(t *testing.T) { + js, err := GenerateClass(tt.tag, "C") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := "class " + tt.wantClass + " extends HTMLElement"; !strings.Contains(js, want) { + t.Fatalf("expected js to contain %q", want) + } + if want := `tag: "` + tt.tag + `"`; !strings.Contains(js, want) { + t.Fatalf("expected js to contain %q", want) + } + if want := `slot = this.getAttribute("data-slot") || "C";`; !strings.Contains(js, want) { + t.Fatalf("expected js to contain %q", want) + } + }) + } } func TestTagToClassName_KebabCase_Good(t *testing.T) { @@ -42,10 +85,16 @@ func TestTagToClassName_KebabCase_Good(t *testing.T) { {"photo-grid", "PhotoGrid"}, {"nav-breadcrumb", "NavBreadcrumb"}, {"my-super-widget", "MySuperWidget"}, + {"nav_bar", "NavBar"}, + {"nav.bar", "NavBar"}, + {"nav--bar", "NavBar"}, + {"math-α", "MathΑ"}, } for _, tt := range tests { got := TagToClassName(tt.tag) - assert.Equal(t, tt.want, got, "TagToClassName(%q)", tt.tag) + if tt.want != got { + t.Fatalf("TagToClassName(%q): want %v, got %v", tt.tag, tt.want, got) + } } } @@ -56,11 +105,21 @@ func TestGenerateBundle_DeduplicatesRegistrations_Good(t *testing.T) { "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, countSubstr(js, "extends HTMLElement")) - assert.Equal(t, 2, countSubstr(js, "customElements.define")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(js, "NavBar") { + t.Fatal("expected js to contain NavBar") + } + if !strings.Contains(js, "MainContent") { + t.Fatal("expected js to contain MainContent") + } + if got := countSubstr(js, "extends HTMLElement"); got != 2 { + t.Fatalf("want 2 extends HTMLElement, got %d", got) + } + if got := countSubstr(js, "customElements.define"); got != 2 { + t.Fatalf("want 2 customElements.define, got %d", got) + } } func TestGenerateBundle_DeterministicOrdering_Good(t *testing.T) { @@ -71,19 +130,35 @@ func TestGenerateBundle_DeterministicOrdering_Good(t *testing.T) { } js, err := GenerateBundle(slots) - require.NoError(t, err) + if err != nil { + t.Fatalf("unexpected error: %v", 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")) + if alpha == -1 { + t.Fatal("expected AlphaPanel class in js") + } + if main == -1 { + t.Fatal("expected MainContent class in js") + } + if zed == -1 { + t.Fatal("expected ZedPanel class in js") + } + if !(alpha < main) { + t.Fatalf("expected AlphaPanel (%d) before MainContent (%d)", alpha, main) + } + if !(main < zed) { + t.Fatalf("expected MainContent (%d) before ZedPanel (%d)", main, zed) + } + if got := countSubstr(js, "extends HTMLElement"); got != 3 { + t.Fatalf("want 3 extends HTMLElement, got %d", got) + } + if got := countSubstr(js, "customElements.define"); got != 3 { + t.Fatalf("want 3 customElements.define, got %d", got) + } } func TestGenerateTypeScriptDefinitions_DeduplicatesAndOrders_Good(t *testing.T) { @@ -95,14 +170,30 @@ func TestGenerateTypeScriptDefinitions_DeduplicatesAndOrders_Good(t *testing.T) 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;`)) + for _, want := range []string{ + `interface HTMLElementTagNameMap`, + `"alpha-panel": AlphaPanel;`, + `"zed-panel": ZedPanel;`, + "export {};", + } { + if !strings.Contains(dts, want) { + t.Fatalf("expected dts to contain %q", want) + } + } + if got := countSubstr(dts, `"alpha-panel": AlphaPanel;`); got != 1 { + t.Fatalf(`want 1 "alpha-panel" entry, got %d`, got) + } + if got := countSubstr(dts, `export declare class AlphaPanel extends HTMLElement`); got != 1 { + t.Fatalf("want 1 AlphaPanel declaration, got %d", got) + } + if got := countSubstr(dts, `export declare class ZedPanel extends HTMLElement`); got != 1 { + t.Fatalf("want 1 ZedPanel declaration, got %d", got) + } + alphaIdx := strings.Index(dts, `"alpha-panel": AlphaPanel;`) + zedIdx := strings.Index(dts, `"zed-panel": ZedPanel;`) + if !(alphaIdx < zedIdx) { + t.Fatalf("expected alpha-panel (%d) before zed-panel (%d)", alphaIdx, zedIdx) + } } func TestGenerateTypeScriptDefinitions_SkipsInvalidTags_Good(t *testing.T) { @@ -114,10 +205,33 @@ func TestGenerateTypeScriptDefinitions_SkipsInvalidTags_Good(t *testing.T) { 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`)) + if !strings.Contains(dts, `"nav-bar": NavBar;`) { + t.Fatal(`expected dts to contain "nav-bar": NavBar;`) + } + if strings.Contains(dts, "Nav-Bar") { + t.Fatal("expected dts NOT to contain Nav-Bar") + } + if strings.Contains(dts, "nav bar") { + t.Fatal("expected dts NOT to contain nav bar") + } + if got := countSubstr(dts, `export declare class NavBar extends HTMLElement`); got != 1 { + t.Fatalf("want 1 NavBar declaration, got %d", got) + } +} + +func TestGenerateTypeScriptDefinitions_ValidExtendedTag_Good(t *testing.T) { + slots := map[string]string{ + "H": "foo.bar-baz", + } + + dts := GenerateTypeScriptDefinitions(slots) + + if !strings.Contains(dts, `"foo.bar-baz": FooBarBaz;`) { + t.Fatal(`expected dts to contain "foo.bar-baz": FooBarBaz;`) + } + if !strings.Contains(dts, `export declare class FooBarBaz extends HTMLElement`) { + t.Fatal("expected dts to contain FooBarBaz class declaration") + } } func countSubstr(s, substr string) int { diff --git a/codegen/typescript.go b/codegen/typescript.go index 035a0e7..38aad3f 100644 --- a/codegen/typescript.go +++ b/codegen/typescript.go @@ -33,7 +33,7 @@ func GenerateTypeScriptDefinitions(slots map[string]string) string { } seen[tag] = true b.WriteString(" \"") - b.WriteString(tag) + b.WriteString(escapeJSStringLiteral(tag)) b.WriteString("\": ") b.WriteString(TagToClassName(tag)) b.WriteString(";\n") diff --git a/context.go b/context.go index f5d40e5..160d552 100644 --- a/context.go +++ b/context.go @@ -1,5 +1,9 @@ package html +// Note: this file is WASM-linked. Per RFC §7 the WASM build must stay under the +// 3.5 MB raw / 1 MB gzip size budget, so this shared context type remains free +// of dappco.re/go/core and other heavyweight server-only dependencies. + // Translator provides Text() lookups for a rendering context. // Usage example: ctx := NewContextWithService(myTranslator) // @@ -11,11 +15,15 @@ type Translator interface { // Context carries rendering state through the node tree. // Usage example: ctx := NewContext() +// +// Metadata is an alias for Data — both fields reference the same underlying map. +// Treat them as interchangeable; use whichever reads best in context. type Context struct { Identity string Locale string Entitlements func(feature string) bool Data map[string]any + Metadata map[string]any service Translator } @@ -25,22 +33,52 @@ func applyLocaleToService(svc Translator, locale string) { } 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(serviceLocale(svc, locale)) + } +} + +func serviceLocale(svc Translator, locale string) string { + base := baseLanguage(locale) + if base == locale { + return locale + } + + languages, ok := svc.(interface{ AvailableLanguages() []string }) + if !ok { + return locale + } + + hasBase := false + for _, lang := range languages.AvailableLanguages() { + if lang == locale { + return locale + } + if lang == base { + hasBase = true } - _ = setter.SetLanguage(base) } + if hasBase { + return base + } + return locale +} + +func baseLanguage(locale string) string { + for i := 0; i < len(locale); i++ { + if locale[i] == '-' || locale[i] == '_' { + return locale[:i] + } + } + return locale } // NewContext creates a new rendering context with sensible defaults. // Usage example: html := Render(Text("welcome"), NewContext("en-GB")) func NewContext(locale ...string) *Context { + data := make(map[string]any) ctx := &Context{ - Data: make(map[string]any), + Data: data, + Metadata: data, // alias — same underlying map } if len(locale) > 0 { ctx.SetLocale(locale[0]) @@ -79,3 +117,65 @@ func (ctx *Context) SetLocale(locale string) *Context { applyLocaleToService(ctx.service, ctx.Locale) return ctx } + +func cloneContext(ctx *Context) *Context { + if ctx == nil { + return nil + } + + clone := *ctx + // Preserve the shared Data/Metadata alias when callers pointed both fields + // at the same map. + if sameMetadataMap(ctx.Data, ctx.Metadata) { + shared := cloneMetadataMap(ctx.Data) + clone.Data = shared + clone.Metadata = shared + return &clone + } + + clone.Data = cloneMetadataMap(ctx.Data) + clone.Metadata = cloneMetadataMap(ctx.Metadata) + return &clone +} + +func cloneMetadataMap(src map[string]any) map[string]any { + if src == nil { + return nil + } + + dst := make(map[string]any, len(src)) + for key, value := range src { + dst[key] = value + } + return dst +} + +func sameMetadataMap(a, b map[string]any) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + + key := metadataAliasProbeKey(a, b) + marker := &struct{}{} + + a[key] = marker + defer delete(a, key) + + value, ok := b[key] + return ok && value == marker +} + +func metadataAliasProbeKey(a, b map[string]any) string { + key := "__go_html_metadata_alias_probe__" + for { + if _, ok := a[key]; ok { + key += "_" + continue + } + if _, ok := b[key]; ok { + key += "_" + continue + } + return key + } +} diff --git a/context_test.go b/context_test.go index 93519d4..ea7c6d3 100644 --- a/context_test.go +++ b/context_test.go @@ -3,11 +3,29 @@ package html import ( + "reflect" "testing" - i18n "dappco.re/go/core/i18n" + i18n "dappco.re/go/i18n" ) +type recordingTranslator struct { + lang string + key string + args []any +} + +func (r *recordingTranslator) SetLanguage(lang string) error { + r.lang = lang + return nil +} + +func (r *recordingTranslator) T(key string, args ...any) string { + r.key = key + r.args = append(r.args[:0], args...) + return "translated" +} + func TestNewContext_OptionalLocale_Good(t *testing.T) { ctx := NewContext("en-GB") @@ -47,6 +65,67 @@ func TestNewContextWithService_AppliesLocaleToService_Good(t *testing.T) { } } +func TestNewContextWithService_ForwardsFullLocaleToTranslator_Good(t *testing.T) { + svc := &recordingTranslator{} + ctx := NewContextWithService(svc, "en-GB") + + if ctx == nil { + t.Fatal("NewContextWithService returned nil") + } + if svc.lang != "en-GB" { + t.Fatalf("NewContextWithService forwarded locale = %q, want %q", svc.lang, "en-GB") + } +} + +func TestTextNode_UsesMetadataAliasWhenDataNil_Good(t *testing.T) { + svc, _ := i18n.New() + i18n.SetDefault(svc) + + ctx := &Context{ + Metadata: map[string]any{"count": 1}, + } + + got := Text("i18n.count.file").Render(ctx) + if got != "1 file" { + t.Fatalf("Text with metadata-only count = %q, want %q", got, "1 file") + } +} + +func TestTextNode_CustomTranslatorReceivesCountArgs_Good(t *testing.T) { + ctx := NewContextWithService(&recordingTranslator{}) + ctx.Metadata["count"] = 3 + + got := Text("i18n.count.file", "ignored").Render(ctx) + if got != "translated" { + t.Fatalf("Text with custom translator = %q, want %q", got, "translated") + } + + svc := ctx.service.(*recordingTranslator) + if svc.key != "i18n.count.file" { + t.Fatalf("custom translator key = %q, want %q", svc.key, "i18n.count.file") + } + + wantArgs := []any{3, "ignored"} + if !reflect.DeepEqual(svc.args, wantArgs) { + t.Fatalf("custom translator args = %#v, want %#v", svc.args, wantArgs) + } +} + +func TestTextNode_NonCountKey_DoesNotInjectCount_Good(t *testing.T) { + ctx := NewContextWithService(&recordingTranslator{}) + ctx.Metadata["count"] = 3 + + got := Text("greeting.hello").Render(ctx) + if got != "translated" { + t.Fatalf("Text with non-count key = %q, want %q", got, "translated") + } + + svc := ctx.service.(*recordingTranslator) + if len(svc.args) != 0 { + t.Fatalf("non-count key should not receive count args, got %#v", svc.args) + } +} + func TestContext_SetService_AppliesLocale_Good(t *testing.T) { svc, _ := i18n.New() ctx := NewContext("fr-FR") @@ -82,9 +161,44 @@ func TestContext_SetLocale_AppliesLocale_Good(t *testing.T) { } } +func TestContext_SetLocale_ForwardsFullLocaleToTranslator_Good(t *testing.T) { + ctx := NewContextWithService(&recordingTranslator{}) + + if got := ctx.SetLocale("fr-FR"); got != ctx { + t.Fatal("SetLocale should return the same context for chaining") + } + + svc := ctx.service.(*recordingTranslator) + if svc.lang != "fr-FR" { + t.Fatalf("SetLocale forwarded locale = %q, want %q", svc.lang, "fr-FR") + } +} + 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") } } + +func TestCloneContext_PreservesMetadataAlias_Good(t *testing.T) { + ctx := NewContext() + ctx.Data["count"] = 3 + + clone := cloneContext(ctx) + if clone == nil { + t.Fatal("cloneContext returned nil") + } + if clone.Data == nil || clone.Metadata == nil { + t.Fatal("cloneContext should preserve non-nil metadata maps") + } + + dataPtr := reflect.ValueOf(clone.Data).Pointer() + metadataPtr := reflect.ValueOf(clone.Metadata).Pointer() + if dataPtr != metadataPtr { + t.Fatalf("cloneContext should keep Data and Metadata aliased, got %x and %x", dataPtr, metadataPtr) + } + if clone.Data["count"] != 3 || clone.Metadata["count"] != 3 { + t.Fatalf("cloneContext should copy map contents, got Data=%v Metadata=%v", clone.Data, clone.Metadata) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 169dc36..7018240 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,7 +22,7 @@ All concrete node types are unexported structs with exported constructor functio | 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. | +| `Attr(Node, key, value)` | Sets an attribute on an `El` node. Traverses through `If`, `Unless`, `Entitled`, `Each`, `EachSeq`, `Switch`, `Layout`, and `Responsive` wrappers. Returns the node for chaining. | | `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. | @@ -55,6 +55,7 @@ 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 + Metadata map[string]any // alias of Data for alternate naming service Translator // unexported; set via constructor } ``` @@ -64,6 +65,8 @@ Two constructors are provided: - `NewContext()` creates a context with sensible defaults and an empty `Data` map. - `NewContextWithService(svc)` creates a context backed by any translator implementing `T(key, ...any) string` such as `*i18n.Service`. +`Data` and `Metadata` point at the same backing map when the context is created through `NewContext()`. Use whichever name is clearer in the calling code. `SetLocale()` and `SetService()` keep the active translator in sync when either value changes. + 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 @@ -73,7 +76,7 @@ The `Layout` type is a compositor for five named slots: | Slot Letter | Semantic Element | ARIA Role | Accessor | |-------------|-----------------|-----------|----------| | H | `
` | `banner` | `layout.H(...)` | -| L | `