232 lines
6 KiB
Go
232 lines
6 KiB
Go
package codegen
|
|
|
|
import (
|
|
"fmt"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"text/template"
|
|
|
|
log "dappco.re/go/core/log"
|
|
)
|
|
|
|
// isValidCustomElementTag reports whether tag is a safe custom element name.
|
|
// The generator rejects values that would fail at customElements.define() time.
|
|
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
|
|
}
|
|
|
|
// 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));
|
|
}
|
|
}`))
|
|
|
|
// codegen/codegen.go: GenerateClass produces a JS class definition for a custom element.
|
|
//
|
|
// Example: cls, err := GenerateClass("nav-bar", "H")
|
|
func GenerateClass(tag, slot string) (string, error) {
|
|
if !isValidCustomElementTag(tag) {
|
|
return "", log.E("codegen.GenerateClass", "custom element tag must be a lowercase hyphenated name: "+tag, nil)
|
|
}
|
|
var b strings.Builder
|
|
err := wcTemplate.Execute(&b, struct {
|
|
ClassName, Tag, Slot string
|
|
}{
|
|
ClassName: TagToClassName(tag),
|
|
Tag: tag,
|
|
Slot: slot,
|
|
})
|
|
if err != nil {
|
|
return "", log.E("codegen.GenerateClass", "template exec", err)
|
|
}
|
|
return b.String(), nil
|
|
}
|
|
|
|
// codegen/codegen.go: GenerateRegistration produces the customElements.define() call.
|
|
//
|
|
// Example: GenerateRegistration("nav-bar", "NavBar")
|
|
func GenerateRegistration(tag, className string) string {
|
|
return fmt.Sprintf(`customElements.define("%s", %s);`, tag, className)
|
|
}
|
|
|
|
// codegen/codegen.go: TagToClassName converts a kebab-case tag to PascalCase class name.
|
|
//
|
|
// Example: className := TagToClassName("nav-bar") // NavBar
|
|
func TagToClassName(tag string) string {
|
|
var b strings.Builder
|
|
for p := range strings.SplitSeq(tag, "-") {
|
|
if len(p) > 0 {
|
|
b.WriteString(strings.ToUpper(p[:1]))
|
|
b.WriteString(p[1:])
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// codegen/codegen.go: GenerateBundle produces all WC class definitions and registrations
|
|
// for a set of HLCRF slot assignments.
|
|
//
|
|
// Example: js, err := GenerateBundle(map[string]string{"H":"nav-bar", "C":"main-content"})
|
|
func GenerateBundle(slots map[string]string) (string, error) {
|
|
if err := validateSlotKeys(slots); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var b strings.Builder
|
|
|
|
for _, entry := range orderedSlotEntries(slots) {
|
|
cls, err := GenerateClass(entry.Tag, entry.Slot)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
b.WriteString(cls)
|
|
b.WriteByte('\n')
|
|
b.WriteString(GenerateRegistration(entry.Tag, TagToClassName(entry.Tag)))
|
|
b.WriteByte('\n')
|
|
}
|
|
return b.String(), nil
|
|
}
|
|
|
|
// codegen/codegen.go: GenerateTypeDefinitions produces a TypeScript declaration file for the
|
|
// generated custom elements.
|
|
//
|
|
// Example: dts, err := GenerateTypeDefinitions(map[string]string{"H":"nav-bar"})
|
|
func GenerateTypeDefinitions(slots map[string]string) (string, error) {
|
|
if err := validateSlotKeys(slots); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
entries := orderedSlotEntries(slots)
|
|
var b strings.Builder
|
|
|
|
for _, entry := range entries {
|
|
if !isValidCustomElementTag(entry.Tag) {
|
|
continue
|
|
}
|
|
className := TagToClassName(entry.Tag)
|
|
b.WriteString("declare class ")
|
|
b.WriteString(className)
|
|
b.WriteString(" extends HTMLElement {\n")
|
|
b.WriteString(" connectedCallback(): void;\n")
|
|
b.WriteString(" render(html: string): void;\n")
|
|
b.WriteString("}\n")
|
|
}
|
|
|
|
b.WriteString("\ndeclare global {\n")
|
|
b.WriteString(" interface WcReadyDetail {\n")
|
|
b.WriteString(" tag: string;\n")
|
|
b.WriteString(" slot: string;\n")
|
|
b.WriteString(" }\n")
|
|
b.WriteString(" interface HTMLElementEventMap {\n")
|
|
b.WriteString(` "wc-ready": CustomEvent<WcReadyDetail>;` + "\n")
|
|
b.WriteString(" }\n")
|
|
b.WriteString(" interface HTMLElementTagNameMap {\n")
|
|
for _, entry := range entries {
|
|
if !isValidCustomElementTag(entry.Tag) {
|
|
continue
|
|
}
|
|
className := TagToClassName(entry.Tag)
|
|
b.WriteString(` "`)
|
|
b.WriteString(entry.Tag)
|
|
b.WriteString(`": `)
|
|
b.WriteString(className)
|
|
b.WriteString(";\n")
|
|
}
|
|
b.WriteString(" }\n")
|
|
b.WriteString("}\n")
|
|
b.WriteString("\nexport {};\n")
|
|
|
|
return b.String(), nil
|
|
}
|
|
|
|
type slotEntry struct {
|
|
Slot string
|
|
Tag string
|
|
}
|
|
|
|
var canonicalSlotOrder = []string{"H", "L", "C", "R", "F"}
|
|
|
|
func validateSlotKeys(slots map[string]string) error {
|
|
if len(slots) == 0 {
|
|
return nil
|
|
}
|
|
|
|
valid := map[string]struct{}{
|
|
"H": {},
|
|
"L": {},
|
|
"C": {},
|
|
"R": {},
|
|
"F": {},
|
|
}
|
|
|
|
var invalid []string
|
|
for slot := range slots {
|
|
if _, ok := valid[slot]; ok {
|
|
continue
|
|
}
|
|
invalid = append(invalid, slot)
|
|
}
|
|
if len(invalid) == 0 {
|
|
return nil
|
|
}
|
|
|
|
slices.Sort(invalid)
|
|
quoted := make([]string, 0, len(invalid))
|
|
for _, slot := range invalid {
|
|
quoted = append(quoted, strconv.Quote(slot))
|
|
}
|
|
return log.E("codegen", "invalid slot key(s): "+strings.Join(quoted, ", "), nil)
|
|
}
|
|
|
|
func orderedSlotEntries(slots map[string]string) []slotEntry {
|
|
entries := make([]slotEntry, 0, len(slots))
|
|
seenTags := make(map[string]struct{}, len(slots))
|
|
|
|
for _, slot := range canonicalSlotOrder {
|
|
tag, ok := slots[slot]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if _, seen := seenTags[tag]; seen {
|
|
continue
|
|
}
|
|
seenTags[tag] = struct{}{}
|
|
entries = append(entries, slotEntry{Slot: slot, Tag: tag})
|
|
}
|
|
return entries
|
|
}
|