From 4eb48789ebf29380c011df0b2067901cd89338cd Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Sun, 10 Oct 2021 17:23:22 +0100 Subject: [PATCH] feat: add support for plain HTML inside elements, not just Go expressions - fixes #22 --- README.md | 107 ++++++++++-------- generator/generator.go | 32 ++++++ generator/test-a-href/template.templ | 6 +- generator/test-a-href/template_templ.go | 17 +-- .../test-attribute-escaping/render_test.go | 2 +- .../test-attribute-escaping/template.templ | 2 +- .../test-attribute-escaping/template_templ.go | 3 +- generator/test-call/template.templ | 2 +- generator/test-call/template_templ.go | 7 +- generator/test-html/template.templ | 2 +- generator/test-html/template_templ.go | 7 +- generator/test-text/render_test.go | 25 ++++ generator/test-text/template.templ | 9 ++ generator/test-text/template_templ.go | 75 ++++++++++++ parser/templateparser.go | 13 ++- parser/textparser.go | 36 ++++++ parser/textparser_test.go | 75 ++++++++++++ parser/types.go | 11 ++ 18 files changed, 359 insertions(+), 72 deletions(-) create mode 100644 generator/test-text/render_test.go create mode 100644 generator/test-text/template.templ create mode 100644 generator/test-text/template_templ.go create mode 100644 parser/textparser.go create mode 100644 parser/textparser_test.go diff --git a/README.md b/README.md index b0e0c4465..8f8e80336 100644 --- a/README.md +++ b/README.md @@ -24,46 +24,6 @@ The language generates Go code, some sections of the template (e.g. `package`, ` * `templ fmt` formats template files in the current directory tree. * `templ lsp` provides a Language Server to support IDE integrations. The compile command generates a sourcemap which maps from the `*.templ` files to the compiled Go file. This enables the `templ` LSP to use the Go language `gopls` language server as is, providing a thin shim to do the source remapping. This is used to provide autocomplete for template variables and functions. -## Security - -templ will automatically escape content according to the following rules. - -``` -{% templ Example() %} - -
- {%= "will be HTML encoded using templ.Escape" %}
- - -
-
-
{%= "will be escaped using templ.Escape" %}
- Text - - -{% endtempl %} -``` - -CSS property names, and constant CSS property values are not sanitized or escaped. - -``` -{% css className() %} - background-color: #ffffff; -{% endcss %} -``` - -CSS property values based on expressions are passed through `templ.SanitizeCSS` to replace potentially unsafe values with placeholders. - -``` -{% css className() %} - color: {%= red %}; -{% endcss %} -``` - ## Design ### Overview @@ -192,31 +152,37 @@ Templ provides a `templ.URL` function that sanitizes input URLs and checks that ### Text -Text is rendered from Go expressions, which includes constant values: +Text is rendered from HTML included in the template itself, or by using Go expressions. No processing or conversion is applied to HTML included within the template, whereas Go string expressions are HTML encoded on output. -``` -{%= "this is a string" %} +Plain HTML: + +```html +
Plain HTML is allowed.
``` -Using the backtick format (single-line only): +Constant Go expressions: ``` -{%= `this is also a string` %} +
{%= "this is a string" %}
``` -Calling a function that returns a string: +The backtick constant expression (single-line only): ``` -{%= time.Now().String() %} +
{%= `this is also a string` %}
``` -Or using a string parameter, or variable that's in scope. +Functions that return a string: ``` -{%= v.s %} +
{%= time.Now().String() %}
``` -What you can't do, is write text directly between elements (e.g. `
Some text
`, because the parser would have to become more complex to support HTML entities and the various mistakes people make when they're doing that (bare ampersands etc.). Go strings support UTF-8 which is much easier, and the escaping rules are well known by Go programmers. +A string parameter, or variable that's in scope: + +``` +
{%= v.s %}
+``` ### CSS @@ -549,3 +515,44 @@ Please get in touch if you're interested in building a feature as I don't want p * https://adrianhesketh.com/2021/05/18/introducing-templ/ * https://adrianhesketh.com/2021/05/28/templ-hot-reload-with-air/ * https://adrianhesketh.com/2021/06/04/hotwired-go-with-templ/ + +## Security + +templ will automatically escape content according to the following rules. + +``` +{% templ Example() %} + +
+ {%= "will be HTML encoded using templ.Escape" %}
+
+ +
+
+
{%= "will be escaped using templ.Escape" %}
+
Text + + +{% endtempl %} +``` + +CSS property names, and constant CSS property values are not sanitized or escaped. + +``` +{% css className() %} + background-color: #ffffff; +{% endcss %} +``` + +CSS property values based on expressions are passed through `templ.SanitizeCSS` to replace potentially unsafe values with placeholders. + +``` +{% css className() %} + color: {%= red %}; +{% endcss %} +``` + diff --git a/generator/generator.go b/generator/generator.go index 73df6e328..146cac3fd 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -349,6 +349,8 @@ func (g *generator) writeNode(indentLevel int, node parser.Node) error { g.writeStringExpression(indentLevel, n.Expression) case parser.Whitespace: // Whitespace is not included in template output to minify HTML. + case parser.Text: + g.writeText(indentLevel, n) default: g.w.Write(fmt.Sprintf("Unhandled type: %v\n", reflect.TypeOf(n))) } @@ -790,3 +792,33 @@ func (g *generator) writeStringExpression(indentLevel int, e parser.Expression) } return nil } + +func (g *generator) writeText(indentLevel int, e parser.Text) (err error) { + vn := g.createVariableName() + // vn := sExpr + if _, err = g.w.WriteIndent(indentLevel, vn+" := "+createGoString(e.Value)+"\n"); err != nil { + return err + } + // _, err = io.WriteString(w, vn) + if _, err = g.w.WriteIndent(indentLevel, "_, err = io.WriteString(w, "+vn+")\n"); err != nil { + return err + } + if err = g.writeErrorHandler(indentLevel); err != nil { + return err + } + return nil +} + +func createGoString(s string) string { + var sb strings.Builder + sb.WriteRune('`') + sects := strings.Split(s, "`") + for i := 0; i < len(sects); i++ { + sb.WriteString(sects[i]) + if len(sects) > i+1 { + sb.WriteString("` + \"`\" + `") + } + } + sb.WriteRune('`') + return sb.String() +} diff --git a/generator/test-a-href/template.templ b/generator/test-a-href/template.templ index 442b17b62..154559bc1 100644 --- a/generator/test-a-href/template.templ +++ b/generator/test-a-href/template.templ @@ -1,8 +1,8 @@ {% package testahref %} {% templ render() %} - {%= "Ignored" %} - {%= "Sanitized" %} - {%= "Unsanitized" %} + Ignored + Sanitized + Unsanitized {% endtempl %} diff --git a/generator/test-a-href/template_templ.go b/generator/test-a-href/template_templ.go index 0df2ba180..a439e0c2c 100644 --- a/generator/test-a-href/template_templ.go +++ b/generator/test-a-href/template_templ.go @@ -21,7 +21,8 @@ func render() templ.Component { if err != nil { return err } - _, err = io.WriteString(w, templ.EscapeString("Ignored")) + var_1 := `Ignored` + _, err = io.WriteString(w, var_1) if err != nil { return err } @@ -41,8 +42,8 @@ func render() templ.Component { if err != nil { return err } - var var_1 templ.SafeURL = templ.URL("javascript:alert('should be sanitized')") - _, err = io.WriteString(w, templ.EscapeString(string(var_1))) + var var_2 templ.SafeURL = templ.URL("javascript:alert('should be sanitized')") + _, err = io.WriteString(w, templ.EscapeString(string(var_2))) if err != nil { return err } @@ -54,7 +55,8 @@ func render() templ.Component { if err != nil { return err } - _, err = io.WriteString(w, templ.EscapeString("Sanitized")) + var_3 := `Sanitized` + _, err = io.WriteString(w, var_3) if err != nil { return err } @@ -74,8 +76,8 @@ func render() templ.Component { if err != nil { return err } - var var_2 templ.SafeURL = templ.SafeURL("javascript:alert('should not be sanitized')") - _, err = io.WriteString(w, templ.EscapeString(string(var_2))) + var var_4 templ.SafeURL = templ.SafeURL("javascript:alert('should not be sanitized')") + _, err = io.WriteString(w, templ.EscapeString(string(var_4))) if err != nil { return err } @@ -87,7 +89,8 @@ func render() templ.Component { if err != nil { return err } - _, err = io.WriteString(w, templ.EscapeString("Unsanitized")) + var_5 := `Unsanitized` + _, err = io.WriteString(w, var_5) if err != nil { return err } diff --git a/generator/test-attribute-escaping/render_test.go b/generator/test-attribute-escaping/render_test.go index 920acb347..7dba764a5 100644 --- a/generator/test-attribute-escaping/render_test.go +++ b/generator/test-attribute-escaping/render_test.go @@ -9,7 +9,7 @@ import ( ) const expected = `
` + - `text` + `
` func TestHTML(t *testing.T) { diff --git a/generator/test-attribute-escaping/template.templ b/generator/test-attribute-escaping/template.templ index a2d86ca22..9e72e8680 100644 --- a/generator/test-attribute-escaping/template.templ +++ b/generator/test-attribute-escaping/template.templ @@ -2,7 +2,7 @@ {% templ BasicTemplate(url string) %}
- {%= "text" %} + text
{% endtempl %} diff --git a/generator/test-attribute-escaping/template_templ.go b/generator/test-attribute-escaping/template_templ.go index ee8352217..8051512ea 100644 --- a/generator/test-attribute-escaping/template_templ.go +++ b/generator/test-attribute-escaping/template_templ.go @@ -38,7 +38,8 @@ func BasicTemplate(url string) templ.Component { if err != nil { return err } - _, err = io.WriteString(w, templ.EscapeString("text")) + var_2 := `text` + _, err = io.WriteString(w, var_2) if err != nil { return err } diff --git a/generator/test-call/template.templ b/generator/test-call/template.templ index 05f8260c5..ae2251157 100644 --- a/generator/test-call/template.templ +++ b/generator/test-call/template.templ @@ -10,6 +10,6 @@ {% endtempl %} {% templ email(s string) %} -
{%= "email:" %}{%= s %}
+
email:{%= s %}
{% endtempl %} diff --git a/generator/test-call/template_templ.go b/generator/test-call/template_templ.go index cc6d90ba4..c80453c34 100644 --- a/generator/test-call/template_templ.go +++ b/generator/test-call/template_templ.go @@ -80,7 +80,8 @@ func email(s string) templ.Component { if err != nil { return err } - _, err = io.WriteString(w, templ.EscapeString("email:")) + var_1 := `email:` + _, err = io.WriteString(w, var_1) if err != nil { return err } @@ -96,8 +97,8 @@ func email(s string) templ.Component { if err != nil { return err } - var var_1 templ.SafeURL = templ.URL("mailto: " + s) - _, err = io.WriteString(w, templ.EscapeString(string(var_1))) + var var_2 templ.SafeURL = templ.URL("mailto: " + s) + _, err = io.WriteString(w, templ.EscapeString(string(var_2))) if err != nil { return err } diff --git a/generator/test-html/template.templ b/generator/test-html/template.templ index 44a8040f9..db0e2ae2e 100644 --- a/generator/test-html/template.templ +++ b/generator/test-html/template.templ @@ -4,7 +4,7 @@

{%= p.name %}

` %}> -
{%= "email:" %}{%= p.email %}
+

diff --git a/generator/test-html/template_templ.go b/generator/test-html/template_templ.go index 3c5b65ed4..5630c852a 100644 --- a/generator/test-html/template_templ.go +++ b/generator/test-html/template_templ.go @@ -61,7 +61,8 @@ func render(p person) templ.Component { if err != nil { return err } - _, err = io.WriteString(w, templ.EscapeString("email:")) + var_1 := `email:` + _, err = io.WriteString(w, var_1) if err != nil { return err } @@ -77,8 +78,8 @@ func render(p person) templ.Component { if err != nil { return err } - var var_1 templ.SafeURL = templ.URL("mailto: " + p.email) - _, err = io.WriteString(w, templ.EscapeString(string(var_1))) + var var_2 templ.SafeURL = templ.URL("mailto: " + p.email) + _, err = io.WriteString(w, templ.EscapeString(string(var_2))) if err != nil { return err } diff --git a/generator/test-text/render_test.go b/generator/test-text/render_test.go new file mode 100644 index 000000000..3c2c11c03 --- /dev/null +++ b/generator/test-text/render_test.go @@ -0,0 +1,25 @@ +package testtext + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +const expected = `
Name: Luiz Bonfa
` + + `
Text ` + "`" + `with backticks` + "`" + `
` + + `
Text ` + "`" + `with backtick` + `
` + + `
Text ` + "`" + `with backtick alongside variable: ` + `Luiz Bonfa
` + +func TestHTML(t *testing.T) { + w := new(strings.Builder) + err := BasicTemplate("Luiz Bonfa").Render(context.Background(), w) + if err != nil { + t.Errorf("failed to render: %v", err) + } + if diff := cmp.Diff(expected, w.String()); diff != "" { + t.Error(diff) + } +} diff --git a/generator/test-text/template.templ b/generator/test-text/template.templ new file mode 100644 index 000000000..b9de0d081 --- /dev/null +++ b/generator/test-text/template.templ @@ -0,0 +1,9 @@ +{% package testtext %} + +{% templ BasicTemplate(name string) %} +
Name: {%= name %}
+
Text `with backticks`
+
Text `with backtick
+
Text `with backtick alongside variable: {%= name %}
+{% endtempl %} + diff --git a/generator/test-text/template_templ.go b/generator/test-text/template_templ.go new file mode 100644 index 000000000..cc5231b30 --- /dev/null +++ b/generator/test-text/template_templ.go @@ -0,0 +1,75 @@ +// Code generated by templ DO NOT EDIT. + +package testtext + +import "github.com/a-h/templ" +import "context" +import "io" + +func BasicTemplate(name string) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { + ctx, _ = templ.RenderedCSSClassesFromContext(ctx) + _, err = io.WriteString(w, "
") + if err != nil { + return err + } + var_1 := `Name: ` + _, err = io.WriteString(w, var_1) + if err != nil { + return err + } + _, err = io.WriteString(w, templ.EscapeString(name)) + if err != nil { + return err + } + _, err = io.WriteString(w, "
") + if err != nil { + return err + } + _, err = io.WriteString(w, "
") + if err != nil { + return err + } + var_2 := `Text ` + "`" + `with backticks` + "`" + `` + _, err = io.WriteString(w, var_2) + if err != nil { + return err + } + _, err = io.WriteString(w, "
") + if err != nil { + return err + } + _, err = io.WriteString(w, "
") + if err != nil { + return err + } + var_3 := `Text ` + "`" + `with backtick` + _, err = io.WriteString(w, var_3) + if err != nil { + return err + } + _, err = io.WriteString(w, "
") + if err != nil { + return err + } + _, err = io.WriteString(w, "
") + if err != nil { + return err + } + var_4 := `Text ` + "`" + `with backtick alongside variable: ` + _, err = io.WriteString(w, var_4) + if err != nil { + return err + } + _, err = io.WriteString(w, templ.EscapeString(name)) + if err != nil { + return err + } + _, err = io.WriteString(w, "
") + if err != nil { + return err + } + return err + }) +} + diff --git a/parser/templateparser.go b/parser/templateparser.go index ee26f3adf..2c95621ae 100644 --- a/parser/templateparser.go +++ b/parser/templateparser.go @@ -81,7 +81,7 @@ func (p templateExpressionParser) Parse(pi parse.Input) parse.Result { // Expect a newline. from = NewPositionFromInput(pi) if lb := newLine(pi); !lb.Success { - return parse.Failure("templateExpressionParser", newParseError("template expression missing terminating newline", from, NewPositionFromInput(pi))) + return parse.Failure("templateExpressionParser", newParseError("template expression: missing terminating newline", from, NewPositionFromInput(pi))) } return parse.Success("templateExpressionParser", r, nil) @@ -191,6 +191,17 @@ func (p templateNodeParser) Parse(pi parse.Input) parse.Result { continue } + // Try for text. + // anything & everything accepted... + pr = newTextParser().Parse(pi) + if pr.Error != nil { + return pr + } + if pr.Success && len(pr.Item.(Text).Value) > 0 { + op = append(op, pr.Item.(Text)) + continue + } + // Check if we've reached the end. if p.until == nil { // In this case, we're just reading as many nodes as we can. diff --git a/parser/textparser.go b/parser/textparser.go new file mode 100644 index 000000000..42202fe9d --- /dev/null +++ b/parser/textparser.go @@ -0,0 +1,36 @@ +package parser + +import ( + "errors" + "io" + + "github.com/a-h/lexical/parse" +) + +func newTextParser() textParser { + return textParser{} +} + +type textParser struct { +} + +func (p textParser) Parse(pi parse.Input) parse.Result { + from := NewPositionFromInput(pi) + + // Read until a tag or templ expression opens. + tagOpen := parse.Rune('<') + templOpen := parse.String("{%") + + dtr := parse.StringUntil(parse.Or(tagOpen, templOpen))(pi) + if dtr.Error != nil { + if errors.Is(dtr.Error, io.EOF) { + return parse.Failure("textParser", newParseError("textParser: unterminated text, expected tag open or templ expression open statement", from, NewPositionFromInput(pi))) + } + return dtr + } + s, ok := dtr.Item.(string) + if !ok || len(s) == 0 { + return parse.Failure("textParser", nil) + } + return parse.Success("textParser", Text{Value: s}, nil) +} diff --git a/parser/textparser_test.go b/parser/textparser_test.go new file mode 100644 index 000000000..a35221524 --- /dev/null +++ b/parser/textparser_test.go @@ -0,0 +1,75 @@ +package parser + +import ( + "testing" + + "github.com/a-h/lexical/input" + "github.com/google/go-cmp/cmp" +) + +func TestTextParser(t *testing.T) { + var tests = []struct { + name string + input string + expected Text + }{ + { + name: "Text ends at an element start", + input: `abcdefMore`, + expected: Text{ + Value: "abcdef", + }, + }, + { + name: "Text ends at a templ expression start", + input: `abcdef{%= "test" %}`, + expected: Text{ + Value: "abcdef", + }, + }, + { + name: "Text may contain spaces", + input: `abcdef ghijk{%= "test" %}`, + expected: Text{ + Value: "abcdef ghijk", + }, + }, + { + name: "Text may contain named references", + input: `abcdef ghijk{%= "test" %}`, + expected: Text{ + Value: "abcdef ghijk", + }, + }, + { + name: "Text may contain base 10 numeric references", + input: `abcdef ghijk{%= "test" %}`, + expected: Text{ + Value: "abcdef ghijk", + }, + }, + { + name: "Text may contain hexadecimal numeric references", + input: `abcdef ghijk{%= "test" %}`, + expected: Text{ + Value: "abcdef ghijk", + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + input := input.NewFromString(tt.input) + result := newTextParser().Parse(input) + if result.Error != nil { + t.Fatalf("parser error: %v", result.Error) + } + if !result.Success { + t.Fatalf("failed to parse at %d", input.Index()) + } + if diff := cmp.Diff(tt.expected, result.Item); diff != "" { + t.Errorf(diff) + } + }) + } +} diff --git a/parser/types.go b/parser/types.go index 7745f7e8e..f69f58921 100644 --- a/parser/types.go +++ b/parser/types.go @@ -306,6 +306,17 @@ type Node interface { Write(w io.Writer, indent int) error } +// Text node within the document. +type Text struct { + // Value is the raw HTML encoded value. + Value string +} + +func (t Text) IsNode() bool { return true } +func (t Text) Write(w io.Writer, indent int) error { + return writeIndent(w, indent, t.Value) +} + // or
...
type Element struct { Name string