feat(wasm): add component registration export
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-03 20:13:14 +00:00
parent 850dbdb0b6
commit 967182a676
7 changed files with 163 additions and 5 deletions

View file

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

View file

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

66
cmd/wasm/components.go Normal file
View file

@ -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));}" +
"}"
}

View file

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

View file

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

View file

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

View file

@ -48,7 +48,7 @@ This builds a Header-Content-Footer layout with semantic HTML elements (`<header
| `pipeline.go` | `StripTags`, `Imprint`, `CompareVariants` (server-side only, `!js` build tag) |
| `codegen/codegen.go` | Web Component class generation and TypeScript declarations (closed Shadow DOM) |
| `cmd/codegen/main.go` | Build-time CLI: JSON slot map on stdin, JS bundle on stdout, `-dts` for `.d.ts` output, `-watch` for file polling |
| `cmd/wasm/main.go` | WASM entry point exporting `renderToString()` to JavaScript |
| `cmd/wasm/main.go` | WASM entry point exporting `renderToString()` and `registerComponents()` to JavaScript |
## Key Concepts