diff --git a/codegen/codegen.go b/codegen/codegen.go index 42f2eda..c6728e5 100644 --- a/codegen/codegen.go +++ b/codegen/codegen.go @@ -5,29 +5,49 @@ package codegen import ( "sort" "text/template" + "unicode" + "unicode/utf8" core "dappco.re/go/core" log "dappco.re/go/core/log" ) -// isValidCustomElementTag reports whether tag is a safe custom element name. +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 tag[0] < 'a' || tag[0] > 'z' { + if !utf8.ValidString(tag) { 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: + 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 } } @@ -35,6 +55,66 @@ func isValidCustomElementTag(tag string) bool { 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). @@ -46,8 +126,8 @@ var wcTemplate = template.Must(template.New("wc").Parse(`class {{.ClassName}} ex } connectedCallback() { this.#shadow.textContent = ""; - const slot = this.getAttribute("data-slot") || "{{.Slot}}"; - this.dispatchEvent(new CustomEvent("wc-ready", { detail: { tag: "{{.Tag}}", slot } })); + const slot = this.getAttribute("data-slot") || "{{.SlotLiteral}}"; + this.dispatchEvent(new CustomEvent("wc-ready", { detail: { tag: "{{.TagLiteral}}", slot } })); } render(html) { const tpl = document.createElement("template"); @@ -64,12 +144,14 @@ func GenerateClass(tag, slot string) (string, error) { 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, Tag, Slot string + ClassName, TagLiteral, SlotLiteral string }{ - ClassName: TagToClassName(tag), - Tag: tag, - Slot: slot, + ClassName: TagToClassName(tag), + TagLiteral: tagLiteral, + SlotLiteral: slotLiteral, }) if err != nil { return "", log.E("codegen.GenerateClass", "template exec", err) @@ -80,7 +162,7 @@ func GenerateClass(tag, slot string) (string, error) { // GenerateRegistration produces the customElements.define() call. // Usage example: js := GenerateRegistration("nav-bar", "NavBar") func GenerateRegistration(tag, className string) string { - return `customElements.define("` + tag + `", ` + className + `);` + return `customElements.define("` + escapeJSStringLiteral(tag) + `", ` + className + `);` } // TagToClassName converts a custom element tag to PascalCase class name. @@ -88,21 +170,17 @@ func GenerateRegistration(tag, className string) string { func TagToClassName(tag string) string { b := core.NewBuilder() upperNext := true - for i := 0; i < len(tag); i++ { - ch := tag[i] + for _, r := range tag { switch { - case ch >= 'a' && ch <= 'z': + case unicode.IsLetter(r): if upperNext { - b.WriteByte(ch - ('a' - 'A')) + _, _ = b.WriteRune(unicode.ToUpper(r)) } else { - b.WriteByte(ch) + _, _ = b.WriteRune(r) } upperNext = false - case ch >= 'A' && ch <= 'Z': - b.WriteByte(ch) - upperNext = false - case ch >= '0' && ch <= '9': - b.WriteByte(ch) + case unicode.IsDigit(r): + _, _ = b.WriteRune(r) upperNext = false default: upperNext = true diff --git a/codegen/codegen_test.go b/codegen/codegen_test.go index b4e3221..ce2967d 100644 --- a/codegen/codegen_test.go +++ b/codegen/codegen_test.go @@ -28,6 +28,9 @@ func TestGenerateClass_InvalidTag_Bad(t *testing.T) { _, err = GenerateClass("nav bar", "C") assert.Error(t, err, "custom element names must reject spaces") + + _, err = GenerateClass("annotation-xml", "C") + assert.Error(t, err, "reserved custom element names must be rejected") } func TestGenerateRegistration_DefinesCustomElement_Good(t *testing.T) { @@ -37,6 +40,26 @@ func TestGenerateRegistration_DefinesCustomElement_Good(t *testing.T) { assert.Contains(t, js, "PhotoGrid") } +func TestGenerateClass_ValidExtendedTag_Good(t *testing.T) { + tests := []struct { + tag string + wantClass string + }{ + {tag: "foo.bar-baz", wantClass: "FooBarBaz"}, + {tag: "math-α", wantClass: "MathΑ"}, + } + + for _, tt := range tests { + t.Run(tt.tag, func(t *testing.T) { + js, err := GenerateClass(tt.tag, "C") + require.NoError(t, err) + assert.Contains(t, js, "class "+tt.wantClass+" extends HTMLElement") + assert.Contains(t, js, `tag: "`+tt.tag+`"`) + assert.Contains(t, js, `slot = this.getAttribute("data-slot") || "C";`) + }) + } +} + func TestTagToClassName_KebabCase_Good(t *testing.T) { tests := []struct{ tag, want string }{ {"photo-grid", "PhotoGrid"}, @@ -45,6 +68,7 @@ func TestTagToClassName_KebabCase_Good(t *testing.T) { {"nav_bar", "NavBar"}, {"nav.bar", "NavBar"}, {"nav--bar", "NavBar"}, + {"math-α", "MathΑ"}, } for _, tt := range tests { got := TagToClassName(tt.tag) @@ -123,6 +147,17 @@ func TestGenerateTypeScriptDefinitions_SkipsInvalidTags_Good(t *testing.T) { assert.Equal(t, 1, countSubstr(dts, `export declare class NavBar extends HTMLElement`)) } +func TestGenerateTypeScriptDefinitions_ValidExtendedTag_Good(t *testing.T) { + slots := map[string]string{ + "H": "foo.bar-baz", + } + + dts := GenerateTypeScriptDefinitions(slots) + + assert.Contains(t, dts, `"foo.bar-baz": FooBarBaz;`) + assert.Contains(t, dts, `export declare class FooBarBaz extends HTMLElement`) +} + func countSubstr(s, substr string) int { if substr == "" { return len(s) + 1 diff --git a/codegen/typescript.go b/codegen/typescript.go index 035a0e7..38aad3f 100644 --- a/codegen/typescript.go +++ b/codegen/typescript.go @@ -33,7 +33,7 @@ func GenerateTypeScriptDefinitions(slots map[string]string) string { } seen[tag] = true b.WriteString(" \"") - b.WriteString(tag) + b.WriteString(escapeJSStringLiteral(tag)) b.WriteString("\": ") b.WriteString(TagToClassName(tag)) b.WriteString(";\n")