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=