From 937c08d9edd25a5c904b98c4ba711094e5d71e28 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 20:49:45 +0000 Subject: [PATCH] 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 --- codegen/codegen.go | 90 +++++++++++++++++++++++++++++++++++++++++ codegen/codegen_test.go | 54 +++++++++++++++++++++++++ go.mod | 12 +++++- go.sum | 10 +++++ 4 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 codegen/codegen.go create mode 100644 codegen/codegen_test.go diff --git a/codegen/codegen.go b/codegen/codegen.go new file mode 100644 index 0000000..6e60783 --- /dev/null +++ b/codegen/codegen.go @@ -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 +} diff --git a/codegen/codegen_test.go b/codegen/codegen_test.go new file mode 100644 index 0000000..28a6aa2 --- /dev/null +++ b/codegen/codegen_test.go @@ -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")) +} diff --git a/go.mod b/go.mod index 01fbda6..e6d771e 100644 --- a/go.mod +++ b/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 +) diff --git a/go.sum b/go.sum index 62a1313..656be30 100644 --- a/go.sum +++ b/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=