feat(wasm): add component registration export
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
850dbdb0b6
commit
967182a676
7 changed files with 163 additions and 5 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
66
cmd/wasm/components.go
Normal 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));}" +
|
||||
"}"
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue