Broaden valid custom element names to match the HTML standard, add JS/TS string escaping for generated output, and cover dotted plus Unicode tag names in tests. Co-Authored-By: Virgil <virgil@lethean.io>
221 lines
5.4 KiB
Go
221 lines
5.4 KiB
Go
//go:build !js
|
|
|
|
package codegen
|
|
|
|
import (
|
|
"sort"
|
|
"text/template"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
core "dappco.re/go/core"
|
|
log "dappco.re/go/core/log"
|
|
)
|
|
|
|
var reservedCustomElementNames = map[string]struct{}{
|
|
"annotation-xml": {},
|
|
"color-profile": {},
|
|
"font-face": {},
|
|
"font-face-src": {},
|
|
"font-face-uri": {},
|
|
"font-face-format": {},
|
|
"font-face-name": {},
|
|
"missing-glyph": {},
|
|
}
|
|
|
|
// isValidCustomElementTag reports whether tag is a valid custom element name.
|
|
// The generator rejects values that would fail at customElements.define() time.
|
|
func isValidCustomElementTag(tag string) bool {
|
|
if tag == "" || !core.Contains(tag, "-") {
|
|
return false
|
|
}
|
|
if !utf8.ValidString(tag) {
|
|
return false
|
|
}
|
|
|
|
if _, reserved := reservedCustomElementNames[tag]; reserved {
|
|
return false
|
|
}
|
|
|
|
first, _ := utf8.DecodeRuneInString(tag)
|
|
if first < 'a' || first > 'z' {
|
|
return false
|
|
}
|
|
|
|
for _, r := range tag {
|
|
if r >= 'A' && r <= 'Z' {
|
|
return false
|
|
}
|
|
switch r {
|
|
case 0, '/', '>', '\t', '\n', '\f', '\r', ' ':
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
type jsStringBuilder interface {
|
|
WriteByte(byte) error
|
|
WriteRune(rune) (int, error)
|
|
WriteString(string) (int, error)
|
|
String() string
|
|
}
|
|
|
|
// escapeJSStringLiteral escapes content for inclusion inside a double-quoted JS string.
|
|
func escapeJSStringLiteral(s string) string {
|
|
b := core.NewBuilder()
|
|
appendJSStringLiteral(b, s)
|
|
return b.String()
|
|
}
|
|
|
|
func appendJSStringLiteral(b jsStringBuilder, s string) {
|
|
for _, r := range s {
|
|
switch r {
|
|
case '\\':
|
|
b.WriteString(`\\`)
|
|
case '"':
|
|
b.WriteString(`\"`)
|
|
case '\b':
|
|
b.WriteString(`\b`)
|
|
case '\f':
|
|
b.WriteString(`\f`)
|
|
case '\n':
|
|
b.WriteString(`\n`)
|
|
case '\r':
|
|
b.WriteString(`\r`)
|
|
case '\t':
|
|
b.WriteString(`\t`)
|
|
case 0x2028:
|
|
b.WriteString(`\u2028`)
|
|
case 0x2029:
|
|
b.WriteString(`\u2029`)
|
|
default:
|
|
if r < 0x20 {
|
|
appendUnicodeEscape(b, r)
|
|
continue
|
|
}
|
|
if r > 0xFFFF {
|
|
rr := r - 0x10000
|
|
appendUnicodeEscape(b, rune(0xD800+(rr>>10)))
|
|
appendUnicodeEscape(b, rune(0xDC00+(rr&0x3FF)))
|
|
continue
|
|
}
|
|
_, _ = b.WriteRune(r)
|
|
}
|
|
}
|
|
}
|
|
|
|
func appendUnicodeEscape(b jsStringBuilder, r rune) {
|
|
const hex = "0123456789ABCDEF"
|
|
b.WriteString(`\u`)
|
|
b.WriteByte(hex[(r>>12)&0xF])
|
|
b.WriteByte(hex[(r>>8)&0xF])
|
|
b.WriteByte(hex[(r>>4)&0xF])
|
|
b.WriteByte(hex[r&0xF])
|
|
}
|
|
|
|
// 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") || "{{.SlotLiteral}}";
|
|
this.dispatchEvent(new CustomEvent("wc-ready", { detail: { tag: "{{.TagLiteral}}", slot } }));
|
|
}
|
|
render(html) {
|
|
const tpl = document.createElement("template");
|
|
tpl.innerHTML = html;
|
|
this.#shadow.textContent = "";
|
|
this.#shadow.appendChild(tpl.content.cloneNode(true));
|
|
}
|
|
}`))
|
|
|
|
// GenerateClass produces a JS class definition for a custom element.
|
|
// Usage example: js, 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)
|
|
}
|
|
b := core.NewBuilder()
|
|
tagLiteral := escapeJSStringLiteral(tag)
|
|
slotLiteral := escapeJSStringLiteral(slot)
|
|
err := wcTemplate.Execute(b, struct {
|
|
ClassName, TagLiteral, SlotLiteral string
|
|
}{
|
|
ClassName: TagToClassName(tag),
|
|
TagLiteral: tagLiteral,
|
|
SlotLiteral: slotLiteral,
|
|
})
|
|
if err != nil {
|
|
return "", log.E("codegen.GenerateClass", "template exec", err)
|
|
}
|
|
return b.String(), nil
|
|
}
|
|
|
|
// GenerateRegistration produces the customElements.define() call.
|
|
// Usage example: js := GenerateRegistration("nav-bar", "NavBar")
|
|
func GenerateRegistration(tag, className string) string {
|
|
return `customElements.define("` + escapeJSStringLiteral(tag) + `", ` + className + `);`
|
|
}
|
|
|
|
// TagToClassName converts a custom element tag to PascalCase class name.
|
|
// Usage example: className := TagToClassName("nav-bar")
|
|
func TagToClassName(tag string) string {
|
|
b := core.NewBuilder()
|
|
upperNext := true
|
|
for _, r := range tag {
|
|
switch {
|
|
case unicode.IsLetter(r):
|
|
if upperNext {
|
|
_, _ = b.WriteRune(unicode.ToUpper(r))
|
|
} else {
|
|
_, _ = b.WriteRune(r)
|
|
}
|
|
upperNext = false
|
|
case unicode.IsDigit(r):
|
|
_, _ = b.WriteRune(r)
|
|
upperNext = false
|
|
default:
|
|
upperNext = true
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// GenerateBundle produces all WC class definitions and registrations
|
|
// for a set of HLCRF slot assignments.
|
|
// Usage example: js, err := GenerateBundle(map[string]string{"H": "nav-bar"})
|
|
func GenerateBundle(slots map[string]string) (string, error) {
|
|
seen := make(map[string]bool)
|
|
b := core.NewBuilder()
|
|
keys := make([]string, 0, len(slots))
|
|
for slot := range slots {
|
|
keys = append(keys, slot)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
for _, slot := range keys {
|
|
tag := slots[slot]
|
|
if seen[tag] {
|
|
continue
|
|
}
|
|
seen[tag] = true
|
|
|
|
cls, err := GenerateClass(tag, slot)
|
|
if err != nil {
|
|
return "", log.E("codegen.GenerateBundle", "generate class for tag "+tag, err)
|
|
}
|
|
b.WriteString(cls)
|
|
b.WriteByte('\n')
|
|
b.WriteString(GenerateRegistration(tag, TagToClassName(tag)))
|
|
b.WriteByte('\n')
|
|
}
|
|
return b.String(), nil
|
|
}
|