feat(codegen): Web Component class generation from HLCRF slots
Generates Custom Element v1 classes with closed Shadow DOM from manifest slot assignments. Includes tag validation, PascalCase conversion, and bundle generation for full HLCRF layout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
496513e8ab
commit
937c08d9ed
4 changed files with 164 additions and 2 deletions
90
codegen/codegen.go
Normal file
90
codegen/codegen.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package codegen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// 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).
|
||||
var wcTemplate = template.Must(template.New("wc").Parse(`class {{.ClassName}} extends HTMLElement {
|
||||
#shadow;
|
||||
constructor() {
|
||||
super();
|
||||
this.#shadow = this.attachShadow({ mode: "closed" });
|
||||
}
|
||||
connectedCallback() {
|
||||
this.#shadow.textContent = "";
|
||||
const slot = this.getAttribute("data-slot") || "{{.Slot}}";
|
||||
this.dispatchEvent(new CustomEvent("wc-ready", { detail: { tag: "{{.Tag}}", slot } }));
|
||||
}
|
||||
render(html) {
|
||||
const tpl = document.createElement("template");
|
||||
tpl.insertAdjacentHTML("afterbegin", html);
|
||||
this.#shadow.textContent = "";
|
||||
this.#shadow.appendChild(tpl.content.cloneNode(true));
|
||||
}
|
||||
}`))
|
||||
|
||||
// GenerateClass produces a JS class definition for a custom element.
|
||||
func GenerateClass(tag, slot string) (string, error) {
|
||||
if !strings.Contains(tag, "-") {
|
||||
return "", fmt.Errorf("codegen: custom element tag %q must contain a hyphen", tag)
|
||||
}
|
||||
var b strings.Builder
|
||||
err := wcTemplate.Execute(&b, struct {
|
||||
ClassName, Tag, Slot string
|
||||
}{
|
||||
ClassName: TagToClassName(tag),
|
||||
Tag: tag,
|
||||
Slot: slot,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("codegen: template exec: %w", err)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// GenerateRegistration produces the customElements.define() call.
|
||||
func GenerateRegistration(tag, className string) string {
|
||||
return fmt.Sprintf(`customElements.define("%s", %s);`, tag, className)
|
||||
}
|
||||
|
||||
// TagToClassName converts a kebab-case tag to PascalCase class name.
|
||||
func TagToClassName(tag string) string {
|
||||
parts := strings.Split(tag, "-")
|
||||
var b strings.Builder
|
||||
for _, p := range parts {
|
||||
if len(p) > 0 {
|
||||
b.WriteString(strings.ToUpper(p[:1]))
|
||||
b.WriteString(p[1:])
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// GenerateBundle produces all WC class definitions and registrations
|
||||
// for a set of HLCRF slot assignments.
|
||||
func GenerateBundle(slots map[string]string) (string, error) {
|
||||
seen := make(map[string]bool)
|
||||
var b strings.Builder
|
||||
|
||||
for slot, tag := range slots {
|
||||
if seen[tag] {
|
||||
continue
|
||||
}
|
||||
seen[tag] = true
|
||||
|
||||
cls, err := GenerateClass(tag, slot)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteString(cls)
|
||||
b.WriteByte('\n')
|
||||
b.WriteString(GenerateRegistration(tag, TagToClassName(tag)))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
54
codegen/codegen_test.go
Normal file
54
codegen/codegen_test.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package codegen
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateClass_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")
|
||||
}
|
||||
|
||||
func TestGenerateClass_Bad_InvalidTag(t *testing.T) {
|
||||
_, err := GenerateClass("invalid", "C")
|
||||
assert.Error(t, err, "custom element names must contain a hyphen")
|
||||
}
|
||||
|
||||
func TestGenerateRegistration_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) {
|
||||
tests := []struct{ tag, want string }{
|
||||
{"photo-grid", "PhotoGrid"},
|
||||
{"nav-breadcrumb", "NavBreadcrumb"},
|
||||
{"my-super-widget", "MySuperWidget"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := TagToClassName(tt.tag)
|
||||
assert.Equal(t, tt.want, got, "TagToClassName(%q)", tt.tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateBundle_Good(t *testing.T) {
|
||||
slots := map[string]string{
|
||||
"H": "nav-bar",
|
||||
"C": "main-content",
|
||||
}
|
||||
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"))
|
||||
}
|
||||
12
go.mod
12
go.mod
|
|
@ -4,6 +4,14 @@ go 1.25.5
|
|||
|
||||
replace forge.lthn.ai/core/go-i18n => ../go-i18n
|
||||
|
||||
require forge.lthn.ai/core/go-i18n v0.0.0-00010101000000-000000000000
|
||||
require (
|
||||
forge.lthn.ai/core/go-i18n v0.0.0-00010101000000-000000000000
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require golang.org/x/text v0.33.0 // indirect
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
|
|||
10
go.sum
10
go.sum
|
|
@ -1,2 +1,12 @@
|
|||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue