From 967182a676e2eecaaf4bfd089dbe89758ea2df65 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 20:13:14 +0000 Subject: [PATCH] feat(wasm): add component registration export Co-Authored-By: Virgil --- CLAUDE.md | 2 +- README.md | 2 +- cmd/wasm/components.go | 66 +++++++++++++++++++++++++++++++++++++++ cmd/wasm/main.go | 44 +++++++++++++++++++++++++- cmd/wasm/register_test.go | 47 ++++++++++++++++++++++++++++ docs/architecture.md | 5 ++- docs/index.md | 2 +- 7 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 cmd/wasm/components.go diff --git a/CLAUDE.md b/CLAUDE.md index 53333f7..0d9abd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ See `docs/architecture.md` for full detail. Summary: - **Responsive**: Multi-variant breakpoint wrapper (`data-variant` attributes), renders all variants in insertion order - **Pipeline**: Render → StripTags → go-i18n/reversal Tokenise → GrammarImprint (server-side only) - **Codegen**: Web Component classes with closed Shadow DOM, generated at build time by `cmd/codegen/` -- **WASM**: `cmd/wasm/` exports `renderToString()` only — size gate: < 3.5 MB raw, < 1 MB gzip +- **WASM**: `cmd/wasm/` exports `renderToString()` and `registerComponents()` — size gate: < 3.5 MB raw, < 1 MB gzip ## Server/Client Split diff --git a/README.md b/README.md index 4d7d8f7..c841134 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), 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()` and `registerComponents()`. **Module**: `forge.lthn.ai/core/go-html` **Licence**: EUPL-1.2 diff --git a/cmd/wasm/components.go b/cmd/wasm/components.go new file mode 100644 index 0000000..39f0685 --- /dev/null +++ b/cmd/wasm/components.go @@ -0,0 +1,66 @@ +// SPDX-Licence-Identifier: EUPL-1.2 +package main + +import ( + "strconv" + "strings" +) + +var canonicalSlotOrder = []string{"H", "L", "C", "R", "F"} + +// isValidCustomElementTag reports whether tag is a safe custom element name. +// It mirrors the codegen package validation without importing the heavier +// template and logging dependencies into the WASM-linked path. +func isValidCustomElementTag(tag string) bool { + if tag == "" || !strings.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 +} + +// tagToClassName converts a kebab-case tag into a PascalCase class name. +// Example: nav-bar -> NavBar. +func tagToClassName(tag string) string { + var b strings.Builder + for part := range strings.SplitSeq(tag, "-") { + if len(part) == 0 { + continue + } + b.WriteString(strings.ToUpper(part[:1])) + b.WriteString(part[1:]) + } + return b.String() +} + +// jsStringLiteral returns a quoted JavaScript string literal. +func jsStringLiteral(s string) string { + return strconv.Quote(s) +} + +// customElementClassSource returns a JavaScript class expression that mirrors +// the codegen bundle's closed-shadow custom element behaviour. +func customElementClassSource(tag, slot string) string { + className := tagToClassName(tag) + return "class " + className + " extends HTMLElement {" + + "constructor(){super();this._shadow=this.attachShadow({mode:\"closed\"});}" + + "connectedCallback(){this._shadow.textContent=\"\";const slot=this.getAttribute(\"data-slot\")||" + jsStringLiteral(slot) + ";" + + "this.dispatchEvent(new CustomEvent(\"wc-ready\",{detail:{tag:" + jsStringLiteral(tag) + ",slot}}));}" + + "render(html){const tpl=document.createElement(\"template\");tpl.insertAdjacentHTML(\"afterbegin\",html);" + + "this._shadow.textContent=\"\";this._shadow.appendChild(tpl.content.cloneNode(true));}" + + "}" +} diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index 8fb4fed..33a7eba 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -53,9 +53,51 @@ func renderToString(_ js.Value, args []js.Value) any { return layout.Render(ctx) } +// registerComponents defines custom elements from the HLCRF slot map. +// The input mirrors the codegen slot mapping: keys are HLCRF slot letters and +// values are custom element tag names. +func registerComponents(_ js.Value, args []js.Value) any { + if len(args) < 1 || args[0].Type() != js.TypeObject { + return 0 + } + + slots := args[0] + customElements := js.Global().Get("customElements") + seenTags := make(map[string]struct{}, len(canonicalSlotOrder)) + registered := 0 + + for _, slot := range canonicalSlotOrder { + content := slots.Get(slot) + if content.Type() != js.TypeString { + continue + } + + tag := content.String() + if !isValidCustomElementTag(tag) { + continue + } + if _, seen := seenTags[tag]; seen { + continue + } + if existing := customElements.Call("get", tag); existing.Truthy() { + seenTags[tag] = struct{}{} + continue + } + + factory := js.Global().Get("Function").New("return " + customElementClassSource(tag, slot) + ";") + ctor := factory.Invoke() + customElements.Call("define", tag, ctor) + seenTags[tag] = struct{}{} + registered++ + } + + return registered +} + func main() { js.Global().Set("gohtml", js.ValueOf(map[string]any{ - "renderToString": js.FuncOf(renderToString), + "renderToString": js.FuncOf(renderToString), + "registerComponents": js.FuncOf(registerComponents), })) select {} diff --git a/cmd/wasm/register_test.go b/cmd/wasm/register_test.go index 65f2af2..40bcb71 100644 --- a/cmd/wasm/register_test.go +++ b/cmd/wasm/register_test.go @@ -22,3 +22,50 @@ func TestBuildComponentJS_Bad_InvalidJSON(t *testing.T) { _, err := buildComponentJS("not json") assert.Error(t, err) } + +func TestTagToClassName(t *testing.T) { + tests := []struct { + tag string + want string + }{ + {tag: "nav-bar", want: "NavBar"}, + {tag: "my-super-widget", want: "MySuperWidget"}, + {tag: "x", want: "X"}, + } + + for _, tt := range tests { + t.Run(tt.tag, func(t *testing.T) { + assert.Equal(t, tt.want, tagToClassName(tt.tag)) + }) + } +} + +func TestIsValidCustomElementTag(t *testing.T) { + tests := []struct { + tag string + want bool + }{ + {tag: "nav-bar", want: true}, + {tag: "main-content", want: true}, + {tag: "NavBar", want: false}, + {tag: "nav", want: false}, + {tag: "nav_bar", want: false}, + } + + for _, tt := range tests { + t.Run(tt.tag, func(t *testing.T) { + assert.Equal(t, tt.want, isValidCustomElementTag(tt.tag)) + }) + } +} + +func TestCustomElementClassSource(t *testing.T) { + src := customElementClassSource(`nav-bar`, `H`) + + assert.Contains(t, src, `class NavBar extends HTMLElement`) + assert.Contains(t, src, `mode:"closed"`) + assert.Contains(t, src, `data-slot`) + assert.Contains(t, src, `wc-ready`) + assert.Contains(t, src, `nav-bar`) + assert.Contains(t, src, `H`) +} diff --git a/docs/architecture.md b/docs/architecture.md index a176a39..75fda3d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -242,10 +242,11 @@ A single-variant `Responsive` produces an empty score map (no pairs to compare). ## WASM Module -The WASM entry point at `cmd/wasm/main.go` is compiled with `GOOS=js GOARCH=wasm` and exposes a single JavaScript function: +The WASM entry point at `cmd/wasm/main.go` is compiled with `GOOS=js GOARCH=wasm` and exposes two JavaScript functions: ```js gohtml.renderToString(variant, locale, slots) +gohtml.registerComponents(slots) ``` **Parameters:** @@ -256,6 +257,8 @@ gohtml.renderToString(variant, locale, slots) Slot content is injected via `Raw()`. The caller is responsible for sanitisation -- the WASM module is a rendering engine for trusted content produced server-side or by the application's own templates. +`registerComponents(slots)` accepts the same slot-map shape used by the codegen CLI and registers closed-shadow custom elements in the browser at runtime. It skips invalid or duplicate tags and mirrors the generated component lifecycle by dispatching a `wc-ready` event when each element connects. + ### Size Budget The WASM binary has a size gate enforced by `cmd/wasm/size_test.go`: diff --git a/docs/index.md b/docs/index.md index f896bc2..3eeeb04 100644 --- a/docs/index.md +++ b/docs/index.md @@ -48,7 +48,7 @@ This builds a Header-Content-Footer layout with semantic HTML elements (`