learnlytics-go/templ/parser/v2/elementparser_test.go
2025-03-20 12:35:13 +01:00

1787 lines
41 KiB
Go

package parser
import (
"strings"
"testing"
"github.com/a-h/parse"
"github.com/google/go-cmp/cmp"
)
type attributeTest[T any] struct {
name string
input string
parser parse.Parser[T]
expected T
}
func TestAttributeParser(t *testing.T) {
tests := []attributeTest[any]{
{
name: "element: open",
input: `<a>`,
parser: StripType(elementOpenTagParser),
expected: elementOpenTag{
Name: "a",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 2, Line: 0, Col: 2},
},
},
},
{
name: "element: hyphen in name",
input: `<turbo-frame>`,
parser: StripType(elementOpenTagParser),
expected: elementOpenTag{
Name: "turbo-frame",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 12, Line: 0, Col: 12},
},
},
},
{
name: "element: open with hyperscript attribute",
input: `<div _="show = true">`,
parser: StripType(elementOpenTagParser),
expected: elementOpenTag{
Name: "div",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 4, Line: 0, Col: 4},
},
Attributes: []Attribute{
ConstantAttribute{
Name: "_",
Value: "show = true",
NameRange: Range{
From: Position{Index: 5, Line: 0, Col: 5},
To: Position{Index: 6, Line: 0, Col: 6},
},
},
},
},
},
{
name: "element: open with complex attributes",
input: `<div @click="show = true" :class="{'foo': true}">`,
parser: StripType(elementOpenTagParser),
expected: elementOpenTag{
Name: "div",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 4, Line: 0, Col: 4},
},
Attributes: []Attribute{
ConstantAttribute{
Name: "@click",
Value: "show = true",
NameRange: Range{
From: Position{Index: 5, Line: 0, Col: 5},
To: Position{Index: 11, Line: 0, Col: 11},
},
},
ConstantAttribute{
Name: ":class",
Value: "{'foo': true}",
NameRange: Range{
From: Position{Index: 26, Line: 0, Col: 26},
To: Position{Index: 32, Line: 0, Col: 32},
},
},
},
},
},
{
name: "element: open with attributes",
input: `<div id="123" style="padding: 10px">`,
parser: StripType(elementOpenTagParser),
expected: elementOpenTag{
Name: "div",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 4, Line: 0, Col: 4},
},
Attributes: []Attribute{
ConstantAttribute{
Name: "id",
Value: "123",
NameRange: Range{
From: Position{Index: 5, Line: 0, Col: 5},
To: Position{Index: 7, Line: 0, Col: 7},
},
},
ConstantAttribute{
Name: "style",
Value: "padding: 10px",
NameRange: Range{
From: Position{Index: 14, Line: 0, Col: 14},
To: Position{Index: 19, Line: 0, Col: 19},
},
},
},
},
},
{
name: "conditional expression attribute - single",
input: `
if p.important {
class="important"
}
"`,
parser: StripType(conditionalAttribute),
expected: ConditionalAttribute{
Expression: Expression{
Value: "p.important",
Range: Range{
From: Position{
Index: 6,
Line: 1,
Col: 5,
},
To: Position{
Index: 17,
Line: 1,
Col: 16,
},
},
},
Then: []Attribute{
ConstantAttribute{
Name: "class",
Value: "important",
NameRange: Range{
From: Position{Index: 23, Line: 2, Col: 3},
To: Position{Index: 28, Line: 2, Col: 8},
},
},
},
},
},
{
name: "conditional expression attribute - multiple",
input: `
if test {
class="itIsTrue"
noshade
name={ "other" }
}
"`,
parser: StripType(conditionalAttribute),
expected: ConditionalAttribute{
Expression: Expression{
Value: "test",
Range: Range{
From: Position{
Index: 4,
Line: 1,
Col: 3,
},
To: Position{
Index: 8,
Line: 1,
Col: 7,
},
},
},
Then: []Attribute{
ConstantAttribute{
Name: "class",
Value: "itIsTrue",
NameRange: Range{
From: Position{Index: 13, Line: 2, Col: 1},
To: Position{Index: 18, Line: 2, Col: 6},
},
},
BoolConstantAttribute{
Name: "noshade",
NameRange: Range{
From: Position{Index: 31, Line: 3, Col: 1},
To: Position{Index: 38, Line: 3, Col: 8},
},
},
ExpressionAttribute{
Name: "name",
NameRange: Range{
From: Position{Index: 40, Line: 4, Col: 1},
To: Position{Index: 44, Line: 4, Col: 5},
},
Expression: Expression{
Value: `"other"`,
Range: Range{
From: Position{
Index: 47,
Line: 4,
Col: 8,
},
To: Position{
Index: 54,
Line: 4,
Col: 15,
},
},
},
},
},
},
},
{
name: "boolean expression attribute",
input: ` noshade?={ true }"`,
parser: StripType(boolExpressionAttributeParser),
expected: BoolExpressionAttribute{
Name: "noshade",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 8, Line: 0, Col: 8},
},
Expression: Expression{
Value: "true",
Range: Range{
From: Position{
Index: 12,
Line: 0,
Col: 12,
},
To: Position{
Index: 16,
Line: 0,
Col: 16,
},
},
},
},
},
{
name: "boolean expression attribute without spaces",
input: ` noshade?={true}"`,
parser: StripType(boolExpressionAttributeParser),
expected: BoolExpressionAttribute{
Name: "noshade",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 8, Line: 0, Col: 8},
},
Expression: Expression{
Value: "true",
Range: Range{
From: Position{
Index: 11,
Line: 0,
Col: 11,
},
To: Position{
Index: 15,
Line: 0,
Col: 15,
},
},
},
},
},
{
name: "attribute parsing handles boolean expression attributes",
input: ` noshade?={ true }`,
parser: StripType(attributeParser{}),
expected: BoolExpressionAttribute{
Name: "noshade",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 8, Line: 0, Col: 8},
},
Expression: Expression{
Value: "true",
Range: Range{
From: Position{
Index: 12,
Line: 0,
Col: 12,
},
To: Position{
Index: 16,
Line: 0,
Col: 16,
},
},
},
},
},
{
name: "boolean expression with excess spaces",
input: ` noshade?={ true }"`,
parser: StripType(boolExpressionAttributeParser),
expected: BoolExpressionAttribute{
Name: "noshade",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 8, Line: 0, Col: 8},
},
Expression: Expression{
Value: "true",
Range: Range{
From: Position{
Index: 12,
Line: 0,
Col: 12,
},
To: Position{
Index: 16,
Line: 0,
Col: 16,
},
},
},
},
},
{
name: "spread attributes",
input: ` { spread... }"`,
parser: StripType(spreadAttributesParser),
expected: SpreadAttributes{
Expression{
Value: "spread",
Range: Range{
From: Position{
Index: 3,
Line: 0,
Col: 3,
},
To: Position{
Index: 9,
Line: 0,
Col: 9,
},
},
},
},
},
{
name: "constant attribute",
input: ` href="test"`,
parser: StripType(constantAttributeParser),
expected: ConstantAttribute{
Name: "href",
Value: "test",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 5, Line: 0, Col: 5},
},
},
},
{
name: "single quote not required constant attribute",
input: ` href='no double quote in value'`,
parser: StripType(constantAttributeParser),
expected: ConstantAttribute{
Name: "href",
Value: `no double quote in value`,
SingleQuote: false,
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 5, Line: 0, Col: 5},
},
},
},
{
name: "single quote required constant attribute",
input: ` href='"test"'`,
parser: StripType(constantAttributeParser),
expected: ConstantAttribute{
Name: "href",
Value: `"test"`,
SingleQuote: true,
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 5, Line: 0, Col: 5},
},
},
},
{
name: "attribute name with hyphens",
input: ` data-turbo-permanent="value"`,
parser: StripType(constantAttributeParser),
expected: ConstantAttribute{
Name: "data-turbo-permanent",
Value: "value",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 21, Line: 0, Col: 21},
},
},
},
{
name: "empty attribute",
input: ` data=""`,
parser: StripType(constantAttributeParser),
expected: ConstantAttribute{
Name: "data",
Value: "",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 5, Line: 0, Col: 5},
},
},
},
{
name: "multiline attribute",
input: ` data-script="on click
do something
end"
`,
parser: StripType(constantAttributeParser),
expected: ConstantAttribute{
Name: "data-script",
Value: "on click\n do something\n end",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 12, Line: 0, Col: 12},
},
},
},
{
name: "bool constant attribute",
input: `<div data>`,
parser: StripType(elementOpenTagParser),
expected: elementOpenTag{
Name: "div",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 4, Line: 0, Col: 4},
},
Attributes: []Attribute{
BoolConstantAttribute{
Name: "data",
NameRange: Range{
From: Position{Index: 5, Line: 0, Col: 5},
To: Position{Index: 9, Line: 0, Col: 9},
},
},
},
},
},
{
name: "bool constant attributes can end with a Unix newline",
input: "<input\n\t\trequired\n\t/>",
parser: StripType(element),
expected: Element{
Name: "input",
IndentAttrs: true,
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 6, Line: 0, Col: 6},
},
Attributes: []Attribute{
BoolConstantAttribute{
Name: "required",
NameRange: Range{
From: Position{Index: 9, Line: 1, Col: 2},
To: Position{Index: 17, Line: 1, Col: 10},
},
},
},
},
},
{
name: "bool constant attributes can end with a Windows newline",
input: "<input\r\n\t\trequired\r\n\t/>",
parser: StripType(element),
expected: Element{
Name: "input",
IndentAttrs: true,
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 6, Line: 0, Col: 6},
},
Attributes: []Attribute{
BoolConstantAttribute{
Name: "required",
NameRange: Range{
From: Position{Index: 10, Line: 1, Col: 2},
To: Position{Index: 18, Line: 1, Col: 10},
},
},
},
},
},
{
name: "attribute containing escaped text",
input: ` href="&lt;&quot;&gt;"`,
parser: StripType(constantAttributeParser),
expected: ConstantAttribute{
Name: "href",
Value: `<">`,
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 5, Line: 0, Col: 5},
},
},
},
{
name: "HTMX wildcard attribute names are supported",
input: ` hx-target-*="#errors"`,
parser: StripType(constantAttributeParser),
expected: ConstantAttribute{
Name: "hx-target-*",
Value: `#errors`,
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 12, Line: 0, Col: 12},
},
},
},
{
name: "unquoted attributes are supported",
input: ` data=123`,
parser: StripType(constantAttributeParser),
expected: ConstantAttribute{
Name: "data",
Value: "123",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 5, Line: 0, Col: 5},
},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
input := parse.NewInput(tt.input)
result, ok, err := tt.parser.Parse(input)
if err != nil {
t.Error(err)
}
if !ok {
t.Errorf("failed to parse at %v", input.Position())
}
if diff := cmp.Diff(tt.expected, result); diff != "" {
t.Error(diff)
}
})
}
}
func TestVoidElementCloserParser(t *testing.T) {
t.Run("all void elements are parsed", func(t *testing.T) {
for _, input := range voidElementCloseTags {
_, ok, err := voidElementCloser.Parse(parse.NewInput(input))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ok {
t.Fatalf("failed to parse %q", input)
}
}
})
}
func TestElementParser(t *testing.T) {
tests := []struct {
name string
input string
expected Element
}{
{
name: "element: self-closing with single constant attribute",
input: `<a href="test"/>`,
expected: Element{
Name: "a",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 2, Line: 0, Col: 2},
},
Attributes: []Attribute{
ConstantAttribute{
Name: "href",
Value: "test",
NameRange: Range{
From: Position{Index: 3, Line: 0, Col: 3},
To: Position{Index: 7, Line: 0, Col: 7},
},
},
},
},
},
{
name: "element: colon in name, empty",
input: `<maps:map></maps:map>`,
expected: Element{
Name: "maps:map",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 9, Line: 0, Col: 9},
},
},
},
{
name: "element: colon in name, with content",
input: `<maps:map>Content</maps:map>`,
expected: Element{
Name: "maps:map",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 9, Line: 0, Col: 9},
},
Children: []Node{
Text{
Value: "Content",
Range: Range{
From: Position{Index: 10, Line: 0, Col: 10},
To: Position{Index: 17, Line: 0, Col: 17},
},
},
},
},
},
{
name: "element: void (input)",
input: `<input>`,
expected: Element{
Name: "input",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 6, Line: 0, Col: 6},
},
Children: nil,
},
},
{
name: "element: void (br)",
input: `<br>`,
expected: Element{
Name: "br",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 3, Line: 0, Col: 3},
},
Children: nil,
},
},
{
name: "element: void (hr)",
input: `<hr noshade>`,
expected: Element{
Name: "hr",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 3, Line: 0, Col: 3},
},
Attributes: []Attribute{
BoolConstantAttribute{
Name: "noshade",
NameRange: Range{
From: Position{Index: 4, Line: 0, Col: 4},
To: Position{Index: 11, Line: 0, Col: 11},
},
},
},
Children: nil,
},
},
{
name: "element: void with content",
input: `<input>Text</input>`,
expected: Element{
Name: "input",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 6, Line: 0, Col: 6},
},
// <input> is a void element, so text is not a child of the input.
// </input> is ignored.
Children: nil,
},
},
{
name: "element: self-closing with single bool expression attribute",
input: `<hr noshade?={ true }/>`,
expected: Element{
Name: "hr",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 3, Line: 0, Col: 3},
},
Attributes: []Attribute{
BoolExpressionAttribute{
Name: "noshade",
NameRange: Range{
From: Position{Index: 4, Line: 0, Col: 4},
To: Position{Index: 11, Line: 0, Col: 11},
},
Expression: Expression{
Value: `true`,
Range: Range{
From: Position{
Index: 15,
Line: 0,
Col: 15,
},
To: Position{
Index: 19,
Line: 0,
Col: 19,
},
},
},
},
},
},
},
{
name: "element: void nesting same is OK",
input: `<div><br><br></br></div>`,
expected: Element{
Name: "div",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 4, Line: 0, Col: 4},
},
Children: []Node{
Element{
Name: "br", // The <br> one.
NameRange: Range{
From: Position{Index: 6, Line: 0, Col: 6},
To: Position{Index: 8, Line: 0, Col: 8},
},
},
Element{
Name: "br", // The <br></br> one.
NameRange: Range{
From: Position{Index: 10, Line: 0, Col: 10},
To: Position{Index: 12, Line: 0, Col: 12},
},
},
},
},
},
{
name: "element: void nesting is ignored",
input: `<br><hr></br>`,
expected: Element{
Name: "br",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 3, Line: 0, Col: 3},
},
// <br> is a void element, so <hr> is not a child of the <br>.
// </br> is ignored.
Children: nil,
},
},
{
name: "element: self-closing with single expression attribute",
input: `<a href={ "test" }/>`,
expected: Element{
Name: "a",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 2, Line: 0, Col: 2},
},
Attributes: []Attribute{
ExpressionAttribute{
Name: "href",
NameRange: Range{
From: Position{Index: 3, Line: 0, Col: 3},
To: Position{Index: 7, Line: 0, Col: 7},
},
Expression: Expression{
Value: `"test"`,
Range: Range{
From: Position{
Index: 10,
Line: 0,
Col: 10,
},
To: Position{
Index: 16,
Line: 0,
Col: 16,
},
},
},
},
},
},
},
{
name: "element: self-closing with multiple constant attributes",
input: `<a href="test" style="text-underline: auto"/>`,
expected: Element{
Name: "a",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 2, Line: 0, Col: 2},
},
Attributes: []Attribute{
ConstantAttribute{
Name: "href",
Value: "test",
NameRange: Range{
From: Position{Index: 3, Line: 0, Col: 3},
To: Position{Index: 7, Line: 0, Col: 7},
},
},
ConstantAttribute{
Name: "style",
Value: "text-underline: auto",
NameRange: Range{
From: Position{Index: 15, Line: 0, Col: 15},
To: Position{Index: 20, Line: 0, Col: 20},
},
},
},
},
},
{
name: "element: self-closing with multiple spreads attributes",
input: `<a { firstSpread... } { children... }/>`,
expected: Element{
Name: "a",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 2, Line: 0, Col: 2},
},
Attributes: []Attribute{
SpreadAttributes{
Expression: Expression{
Value: "firstSpread",
Range: Range{
From: Position{
Index: 5,
Line: 0,
Col: 5,
},
To: Position{
Index: 16,
Line: 0,
Col: 16,
},
},
},
},
SpreadAttributes{
Expression: Expression{
Value: "children",
Range: Range{
From: Position{
Index: 24,
Line: 0,
Col: 24,
},
To: Position{
Index: 32,
Line: 0,
Col: 32,
},
},
},
},
},
},
},
{
name: "element: self-closing with multiple boolean attributes",
input: `<hr optionA optionB?={ true } optionC="other"/>`,
expected: Element{
Name: "hr",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 3, Line: 0, Col: 3},
},
Attributes: []Attribute{
BoolConstantAttribute{
Name: "optionA",
NameRange: Range{
From: Position{Index: 4, Line: 0, Col: 4},
To: Position{Index: 11, Line: 0, Col: 11},
},
},
BoolExpressionAttribute{
Name: "optionB",
NameRange: Range{
From: Position{Index: 12, Line: 0, Col: 12},
To: Position{Index: 19, Line: 0, Col: 19},
},
Expression: Expression{
Value: `true`,
Range: Range{
From: Position{
Index: 23,
Line: 0,
Col: 23,
},
To: Position{
Index: 27,
Line: 0,
Col: 27,
},
},
},
},
ConstantAttribute{
Name: "optionC",
Value: "other",
NameRange: Range{
From: Position{Index: 30, Line: 0, Col: 30},
To: Position{Index: 37, Line: 0, Col: 37},
},
},
},
},
},
{
name: "element: self-closing with multiple constant and expr attributes",
input: `<a href="test" title={ localisation.Get("a_title") } style="text-underline: auto"/>`,
expected: Element{
Name: "a",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 2, Line: 0, Col: 2},
},
Attributes: []Attribute{
ConstantAttribute{
Name: "href",
Value: "test",
NameRange: Range{
From: Position{Index: 3, Line: 0, Col: 3},
To: Position{Index: 7, Line: 0, Col: 7},
},
},
ExpressionAttribute{
Name: "title",
NameRange: Range{
From: Position{Index: 15, Line: 0, Col: 15},
To: Position{Index: 20, Line: 0, Col: 20},
},
Expression: Expression{
Value: `localisation.Get("a_title")`,
Range: Range{
From: Position{
Index: 23,
Line: 0,
Col: 23,
},
To: Position{
Index: 50,
Line: 0,
Col: 50,
},
},
},
},
ConstantAttribute{
Name: "style",
Value: "text-underline: auto",
NameRange: Range{
From: Position{Index: 53, Line: 0, Col: 53},
To: Position{Index: 58, Line: 0, Col: 58},
},
},
},
},
},
{
name: "element: self-closing with multiple constant, conditional and expr attributes",
input: `<div style="width: 100;"
if p.important {
class="important"
}
>Test</div>
}
`,
expected: Element{
Name: "div",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 4, Line: 0, Col: 4},
},
Attributes: []Attribute{
ConstantAttribute{
Name: "style",
Value: "width: 100;",
NameRange: Range{
From: Position{Index: 5, Line: 0, Col: 5},
To: Position{Index: 10, Line: 0, Col: 10},
},
},
ConditionalAttribute{
Expression: Expression{
Value: `p.important`,
Range: Range{
From: Position{
Index: 30,
Line: 1,
Col: 5,
},
To: Position{
Index: 41,
Line: 1,
Col: 16,
},
},
},
Then: []Attribute{
ConstantAttribute{
Name: "class",
Value: "important",
NameRange: Range{
From: Position{Index: 47, Line: 2, Col: 3},
To: Position{Index: 52, Line: 2, Col: 8},
},
},
},
},
},
IndentAttrs: true,
Children: []Node{
Text{
Value: "Test",
Range: Range{
From: Position{Index: 70, Line: 4, Col: 1},
To: Position{Index: 74, Line: 4, Col: 5},
},
},
},
TrailingSpace: SpaceVertical,
},
},
{
name: "element: self-closing with no attributes",
input: `<hr/>`,
expected: Element{
Name: "hr",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 3, Line: 0, Col: 3},
},
},
},
{
name: "element: self-closing with attribute",
input: `<hr style="padding: 10px" />`,
expected: Element{
Name: "hr",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 3, Line: 0, Col: 3},
},
Attributes: []Attribute{
ConstantAttribute{
Name: "style",
Value: "padding: 10px",
NameRange: Range{
From: Position{Index: 4, Line: 0, Col: 4},
To: Position{Index: 9, Line: 0, Col: 9},
},
},
},
},
},
{
name: "element: self-closing with conditional attribute",
input: `<hr style="padding: 10px"
if true {
class="itIsTrue"
}
/>`,
expected: Element{
Name: "hr",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 3, Line: 0, Col: 3},
},
Attributes: []Attribute{
ConstantAttribute{
Name: "style",
Value: "padding: 10px",
NameRange: Range{
From: Position{Index: 4, Line: 0, Col: 4},
To: Position{Index: 9, Line: 0, Col: 9},
},
},
ConditionalAttribute{
Expression: Expression{
Value: "true",
Range: Range{
From: Position{
Index: 33,
Line: 1,
Col: 6,
},
To: Position{
Index: 37,
Line: 1,
Col: 10,
},
},
},
Then: []Attribute{
ConstantAttribute{
Name: "class",
Value: "itIsTrue",
NameRange: Range{
From: Position{Index: 44, Line: 2, Col: 4},
To: Position{Index: 49, Line: 2, Col: 9},
},
},
},
},
},
IndentAttrs: true,
},
},
{
name: "element: self-closing with conditional attribute with else block",
input: `<hr style="padding: 10px"
if true {
class="itIsTrue"
} else {
class="itIsNotTrue"
}
/>`,
expected: Element{
Name: "hr",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 3, Line: 0, Col: 3},
},
Attributes: []Attribute{
ConstantAttribute{
Name: "style",
Value: "padding: 10px",
NameRange: Range{
From: Position{Index: 4, Line: 0, Col: 4},
To: Position{Index: 9, Line: 0, Col: 9},
},
},
ConditionalAttribute{
Expression: Expression{
Value: "true",
Range: Range{
From: Position{
Index: 33,
Line: 1,
Col: 6,
},
To: Position{
Index: 37,
Line: 1,
Col: 10,
},
},
},
Then: []Attribute{
ConstantAttribute{
Name: "class",
Value: "itIsTrue",
NameRange: Range{
From: Position{Index: 44, Line: 2, Col: 4},
To: Position{Index: 49, Line: 2, Col: 9},
},
},
},
Else: []Attribute{
ConstantAttribute{
Name: "class",
Value: "itIsNotTrue",
NameRange: Range{
From: Position{Index: 77, Line: 4, Col: 4},
To: Position{Index: 82, Line: 4, Col: 9},
},
},
},
},
},
IndentAttrs: true,
},
},
{
name: "element: open and close with conditional attribute",
input: `<p style="padding: 10px"
if true {
class="itIsTrue"
}
>Test</p>`,
expected: Element{
Name: "p",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 2, Line: 0, Col: 2},
},
Attributes: []Attribute{
ConstantAttribute{
Name: "style",
Value: "padding: 10px",
NameRange: Range{
From: Position{Index: 3, Line: 0, Col: 3},
To: Position{Index: 8, Line: 0, Col: 8},
},
},
ConditionalAttribute{
Expression: Expression{
Value: "true",
Range: Range{
From: Position{
Index: 32,
Line: 1,
Col: 6,
},
To: Position{
Index: 36,
Line: 1,
Col: 10,
},
},
},
Then: []Attribute{
ConstantAttribute{
Name: "class",
Value: "itIsTrue",
NameRange: Range{
From: Position{Index: 43, Line: 2, Col: 4},
To: Position{Index: 48, Line: 2, Col: 9},
},
},
},
},
},
IndentAttrs: true,
Children: []Node{
Text{
Value: "Test",
Range: Range{
From: Position{Index: 66, Line: 4, Col: 1},
To: Position{Index: 70, Line: 4, Col: 5},
},
},
},
},
},
{
name: "element: open and close",
input: `<a></a>`,
expected: Element{
Name: "a",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 2, Line: 0, Col: 2},
},
},
},
{
name: "element: open and close with text",
input: `<a>The text</a>`,
expected: Element{
Name: "a",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 2, Line: 0, Col: 2},
},
Children: []Node{
Text{
Value: "The text",
Range: Range{
From: Position{Index: 3, Line: 0, Col: 3},
To: Position{Index: 11, Line: 0, Col: 11},
},
},
},
},
},
{
name: "element: with self-closing child element",
input: `<a><b/></a>`,
expected: Element{
Name: "a",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 2, Line: 0, Col: 2},
},
Children: []Node{
Element{
Name: "b",
NameRange: Range{
From: Position{Index: 4, Line: 0, Col: 4},
To: Position{Index: 5, Line: 0, Col: 5},
},
},
},
},
},
{
name: "element: with non-self-closing child element",
input: `<a><b></b></a>`,
expected: Element{
Name: "a",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 2, Line: 0, Col: 2},
},
Children: []Node{
Element{
Name: "b",
NameRange: Range{
From: Position{Index: 4, Line: 0, Col: 4},
To: Position{Index: 5, Line: 0, Col: 5},
},
},
},
},
},
{
name: "element: containing space",
input: `<a> <b> </b> </a>`,
expected: Element{
Name: "a",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 2, Line: 0, Col: 2},
},
Children: []Node{
Whitespace{Value: " "},
Element{
Name: "b",
NameRange: Range{
From: Position{Index: 5, Line: 0, Col: 5},
To: Position{Index: 6, Line: 0, Col: 6},
},
Children: []Node{
Whitespace{Value: " "},
},
TrailingSpace: SpaceHorizontal,
},
},
},
},
{
name: "element: with multiple child elements",
input: `<a><b></b><c><d/></c></a>`,
expected: Element{
Name: "a",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 2, Line: 0, Col: 2},
},
Children: []Node{
Element{
Name: "b",
NameRange: Range{
From: Position{Index: 4, Line: 0, Col: 4},
To: Position{Index: 5, Line: 0, Col: 5},
},
},
Element{
Name: "c",
NameRange: Range{
From: Position{Index: 11, Line: 0, Col: 11},
To: Position{Index: 12, Line: 0, Col: 12},
},
Children: []Node{
Element{
Name: "d",
NameRange: Range{
From: Position{Index: 14, Line: 0, Col: 14},
To: Position{Index: 15, Line: 0, Col: 15},
},
},
},
},
},
},
},
{
name: "element: empty",
input: `<div></div>`,
expected: Element{
Name: "div",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 4, Line: 0, Col: 4},
},
},
},
{
name: "element: containing string expression",
input: `<div>{ "test" }</div>`,
expected: Element{
Name: "div",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 4, Line: 0, Col: 4},
},
Children: []Node{
StringExpression{
Expression: Expression{
Value: `"test"`,
Range: Range{
From: Position{
Index: 7,
Line: 0,
Col: 7,
},
To: Position{
Index: 13,
Line: 0,
Col: 13,
},
},
},
},
},
},
},
{
name: "element: inputs can contain class attributes",
input: `<input type="email" id="email" name="email" class={ "a", "b", "c", templ.KV("c", false)} placeholder="your@email.com" autocomplete="off"/>`,
expected: Element{
Name: "input",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 6, Line: 0, Col: 6},
},
Attributes: []Attribute{
ConstantAttribute{
Name: "type",
Value: "email",
NameRange: Range{
From: Position{Index: 8, Line: 0, Col: 8},
To: Position{Index: 12, Line: 0, Col: 12},
},
},
ConstantAttribute{
Name: "id",
Value: "email",
NameRange: Range{
From: Position{Index: 21, Line: 0, Col: 21},
To: Position{Index: 23, Line: 0, Col: 23},
},
},
ConstantAttribute{
Name: "name",
Value: "email",
NameRange: Range{
From: Position{Index: 32, Line: 0, Col: 32},
To: Position{Index: 36, Line: 0, Col: 36},
},
},
ExpressionAttribute{
Name: "class",
NameRange: Range{
From: Position{Index: 45, Line: 0, Col: 45},
To: Position{Index: 50, Line: 0, Col: 50},
},
Expression: Expression{
Value: `"a", "b", "c", templ.KV("c", false)`,
Range: Range{
From: Position{
Index: 53,
Line: 0,
Col: 53,
},
To: Position{
Index: 89,
Line: 0,
Col: 89,
},
},
},
},
ConstantAttribute{
Name: "placeholder",
Value: "your@email.com",
NameRange: Range{
From: Position{Index: 91, Line: 0, Col: 91},
To: Position{Index: 102, Line: 0, Col: 102},
},
},
ConstantAttribute{
Name: "autocomplete",
Value: "off",
NameRange: Range{
From: Position{Index: 120, Line: 0, Col: 120},
To: Position{Index: 132, Line: 0, Col: 132},
},
},
},
},
},
{
name: "element: with multi-line attributes",
input: `<input
type="email"
id="email"
name="email"
></input>`,
expected: Element{
Name: "input",
IndentAttrs: true,
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 6, Line: 0, Col: 6},
},
Attributes: []Attribute{
ConstantAttribute{
Name: "type",
Value: "email",
NameRange: Range{
From: Position{Index: 8, Line: 1, Col: 1},
To: Position{Index: 12, Line: 1, Col: 5},
},
},
ConstantAttribute{
Name: "id",
Value: "email",
NameRange: Range{
From: Position{Index: 23, Line: 2, Col: 1},
To: Position{Index: 25, Line: 2, Col: 3},
},
},
ConstantAttribute{
Name: "name",
Value: "email",
NameRange: Range{
From: Position{Index: 36, Line: 3, Col: 1},
To: Position{Index: 40, Line: 3, Col: 5},
},
},
},
},
},
{
name: "element: can contain text that starts with for",
input: `<div>for which any
amount is charged</div>`,
expected: Element{
Name: "div",
IndentChildren: true,
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 4, Line: 0, Col: 4},
},
Children: []Node{
Text{
Value: "for which any ",
Range: Range{
From: Position{Index: 5, Line: 0, Col: 5},
To: Position{Index: 19, Line: 0, Col: 19},
},
TrailingSpace: SpaceVertical,
},
Text{
Value: "amount is charged",
Range: Range{
From: Position{Index: 20, Line: 1, Col: 0},
To: Position{Index: 37, Line: 1, Col: 17},
},
TrailingSpace: SpaceNone,
},
},
},
},
{
name: "element: self-closing with unquoted attribute",
input: `<hr noshade=noshade/>`,
expected: Element{
Name: "hr",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 3, Line: 0, Col: 3},
},
Attributes: []Attribute{
ConstantAttribute{
Name: "noshade",
Value: "noshade",
NameRange: Range{
From: Position{Index: 4, Line: 0, Col: 4},
To: Position{Index: 11, Line: 0, Col: 11},
},
},
},
},
},
{
name: "element: self-closing with unquoted and other attributes",
input: `<hr noshade=noshade disabled other-attribute={ false } />`,
expected: Element{
Name: "hr",
NameRange: Range{
From: Position{Index: 1, Line: 0, Col: 1},
To: Position{Index: 3, Line: 0, Col: 3},
},
Attributes: []Attribute{
ConstantAttribute{
Name: "noshade",
Value: "noshade",
NameRange: Range{
From: Position{Index: 4, Line: 0, Col: 4},
To: Position{Index: 11, Line: 0, Col: 11},
},
},
BoolConstantAttribute{
Name: "disabled",
NameRange: Range{
From: Position{Index: 20, Line: 0, Col: 20},
To: Position{Index: 28, Line: 0, Col: 28},
},
},
ExpressionAttribute{
Name: "other-attribute",
NameRange: Range{
From: Position{Index: 29, Line: 0, Col: 29},
To: Position{Index: 44, Line: 0, Col: 44},
},
Expression: Expression{
Value: "false",
Range: Range{
From: Position{
Index: 47,
Line: 0,
Col: 47,
},
To: Position{
Index: 52,
Line: 0,
Col: 52,
},
},
},
},
},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
input := parse.NewInput(tt.input)
result, ok, err := element.Parse(input)
if err != nil {
t.Fatalf("parser error: %v", err)
}
if !ok {
t.Fatalf("failed to parse at %d", input.Index())
}
if diff := cmp.Diff(tt.expected, result); diff != "" {
t.Error(diff)
}
})
}
}
func TestElementParserErrors(t *testing.T) {
tests := []struct {
name string
input string
expected error
}{
{
name: "element: mismatched end tag",
input: `<a></b>`,
expected: parse.Error("<a>: close tag not found",
parse.Position{
Index: 3,
Line: 0,
Col: 3,
}),
},
{
name: "element: style must only contain text",
input: `<style><button /></style>`,
expected: parse.Error("<style>: invalid node contents: script and style attributes must only contain text",
parse.Position{
Index: 0,
Line: 0,
Col: 0,
}),
},
{
name: "element: script must only contain text",
input: `<script><button /></script>`,
expected: parse.Error("<script>: invalid node contents: script and style attributes must only contain text",
parse.Position{
Index: 0,
Line: 0,
Col: 0,
}),
},
{
name: "element: script tags cannot contain non-text nodes",
input: `<script>{ "value" }</script>`,
expected: parse.Error("<script>: invalid node contents: script and style attributes must only contain text",
parse.Position{
Index: 0,
Line: 0,
Col: 0,
}),
},
{
name: "element: style tags cannot contain non-text nodes",
input: `<style>{ "value" }</style>`,
expected: parse.Error("<style>: invalid node contents: script and style attributes must only contain text",
parse.Position{
Index: 0,
Line: 0,
Col: 0,
}),
},
{
name: "element: names cannot be greater than 128 characters",
input: `<aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa></aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa>`,
expected: parse.Error("element names must be < 128 characters long",
parse.Position{
Index: 130,
Line: 0,
Col: 130,
}),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
input := parse.NewInput(tt.input)
_, _, err := element.Parse(input)
if diff := cmp.Diff(tt.expected, err); diff != "" {
t.Error(diff)
}
})
}
}
func TestBigElement(t *testing.T) {
sb := new(strings.Builder)
sb.WriteString("<div>")
for i := 0; i < 4096*4; i++ {
sb.WriteString("a")
}
sb.WriteString("</div>")
_, ok, err := element.Parse(parse.NewInput(sb.String()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ok {
t.Errorf("unexpected failure to parse")
}
}
func FuzzElement(f *testing.F) {
seeds := []string{
`<br>`,
`<a href="test" unquoted=unquoted/>`,
`<input value={ "test" }/>`,
`<div>{ "test" }</div>`,
`<a unquoted=unquoted href="test" unquoted=unquoted>Test</a>`,
}
for _, tc := range seeds {
f.Add(tc)
}
f.Fuzz(func(t *testing.T, input string) {
_, _, _ = element.Parse(parse.NewInput(input))
})
}