From 84845973be99ef590a3d02696b3b7febcd548501 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Thu, 21 Dec 2023 22:39:35 +0000 Subject: [PATCH] feat: add support for rendering raw HTML, and bi-directional support for html/template (#337) Fixes #175 --- .version | 2 +- .../14-using-with-go-templates.md | 89 ++++++++++++ .../15-rendering-raw-html.md | 31 ++++ docs/docs/12-integrations/index.md | 4 + generator/htmldiff/diff.go | 35 +++++ .../test-go-template-in-templ/expected.html | 6 + .../test-go-template-in-templ/render_test.go | 22 +++ .../test-go-template-in-templ/template.templ | 14 ++ .../template_templ.go | 46 ++++++ generator/test-raw-elements/expected.html | 1 + generator/test-raw-elements/template.templ | 1 + generator/test-raw-elements/template_templ.go | 10 +- .../test-templ-in-go-template/expected.html | 6 + .../test-templ-in-go-template/render_test.go | 39 +++++ .../test-templ-in-go-template/template.templ | 15 ++ .../template_templ.go | 53 +++++++ runtime.go | 33 +++++ runtime_test.go | 135 ++++++++++++++++++ 18 files changed, 540 insertions(+), 2 deletions(-) create mode 100644 docs/docs/03-syntax-and-usage/14-using-with-go-templates.md create mode 100644 docs/docs/03-syntax-and-usage/15-rendering-raw-html.md create mode 100644 generator/test-go-template-in-templ/expected.html create mode 100644 generator/test-go-template-in-templ/render_test.go create mode 100644 generator/test-go-template-in-templ/template.templ create mode 100644 generator/test-go-template-in-templ/template_templ.go create mode 100644 generator/test-templ-in-go-template/expected.html create mode 100644 generator/test-templ-in-go-template/render_test.go create mode 100644 generator/test-templ-in-go-template/template.templ create mode 100644 generator/test-templ-in-go-template/template_templ.go diff --git a/.version b/.version index 8b55513d7..54af2b630 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -0.2.499 \ No newline at end of file +0.2.500 \ No newline at end of file diff --git a/docs/docs/03-syntax-and-usage/14-using-with-go-templates.md b/docs/docs/03-syntax-and-usage/14-using-with-go-templates.md new file mode 100644 index 000000000..ca74c4200 --- /dev/null +++ b/docs/docs/03-syntax-and-usage/14-using-with-go-templates.md @@ -0,0 +1,89 @@ +# Using with `html/template` + +Templ components can be used with the Go standard library [`html/template`](https://pkg.go.dev/html/template) package. + +## Using `html/template` in a templ component + +To use an existing `html/template` in a templ component, use the `templ.FromGoHTML` function. + +```templ title="component.templ" +package testgotemplates + +import "html/template" + +var goTemplate = template.Must(template.New("example").Parse("
{{ . }}
")) + +templ Example() { + + + + @templ.FromGoHTML(goTemplate, "Hello, World!") + + +} +``` + +```go title="main.go" +func main() { + Example.Render(context.Background(), os.Stdout) +} +``` + +```html title="Output" + + + +
Hello, World!
+ + +``` + +## Using a templ component with `html/template` + +To use a templ component within a `html/template`, use the `templ.ToGoHTML` function to render the component into a `template.HTML value`. + +```templ title="component.html" +package testgotemplates + +import "html/template" + +var example = template.Must(template.New("example").Parse(` + + + {{ . }} + + +`)) + +templ greeting() { +
Hello, World!
+} +``` + +```go title="main.go" +func main() { + // Create the templ component. + templComponent := greeting() + + // Render the templ component to a `template.HTML` value. + html, err := templ.ToGoHTML(context.Background(), templComponent) + if err != nil { + t.Fatalf("failed to convert to html: %v", err) + } + + // Use the `template.HTML` value within the text/html template. + err = example.Execute(os.Stdout, html) + if err != nil { + t.Fatalf("failed to execute template: %v", err) + } +} +``` + +```html title="Output" + + + +
Hello, World!
+ + +``` diff --git a/docs/docs/03-syntax-and-usage/15-rendering-raw-html.md b/docs/docs/03-syntax-and-usage/15-rendering-raw-html.md new file mode 100644 index 000000000..2114dd777 --- /dev/null +++ b/docs/docs/03-syntax-and-usage/15-rendering-raw-html.md @@ -0,0 +1,31 @@ +# Rendering raw HTML + +To render HTML that has come from a trusted source, bypassing all HTML escaping and security mechanisms that templ includes, use the `templ.Raw` function. + +:::info +Only include HTML that comes from a trusted source. +::: + +:::warning +Use of this function may introduce security vulnerabilities to your program. +::: + +```templ title="component.templ" +templ Example() { + + + + @templ.Raw("
Hello, World!
") + + +} +``` + +```html title="Output" + + + +
Hello, World!
+ + +``` diff --git a/docs/docs/12-integrations/index.md b/docs/docs/12-integrations/index.md index 3d90b261b..db2ab3155 100644 --- a/docs/docs/12-integrations/index.md +++ b/docs/docs/12-integrations/index.md @@ -15,3 +15,7 @@ https://github.com/a-h/templ/tree/main/examples/integration-gin An example of using templ with go-chi v5 can be found here: https://github.com/a-h/templ/tree/main/examples/integration-chi + +## `template/html` + +See [Using with Go templates](../syntax-and-usage/using-with-go-templates) diff --git a/generator/htmldiff/diff.go b/generator/htmldiff/diff.go index ab7c8b5b0..e0a33bf41 100644 --- a/generator/htmldiff/diff.go +++ b/generator/htmldiff/diff.go @@ -13,6 +13,41 @@ import ( "github.com/google/go-cmp/cmp" ) +func DiffStrings(expected, actual string) (diff string, err error) { + // Format both strings. + var wg sync.WaitGroup + wg.Add(2) + + var errs []error + + // Format expected. + go func() { + defer wg.Done() + e := new(strings.Builder) + err := htmlformat.Fragment(e, strings.NewReader(expected)) + if err != nil { + errs = append(errs, fmt.Errorf("expected html formatting error: %w", err)) + } + expected = e.String() + }() + + // Format actual. + go func() { + defer wg.Done() + a := new(strings.Builder) + err := htmlformat.Fragment(a, strings.NewReader(actual)) + if err != nil { + errs = append(errs, fmt.Errorf("actual html formatting error: %w", err)) + } + actual = a.String() + }() + + // Wait for processing. + wg.Wait() + + return cmp.Diff(expected, actual), errors.Join(errs...) +} + func Diff(input templ.Component, expected string) (diff string, err error) { return DiffCtx(context.Background(), input, expected) } diff --git a/generator/test-go-template-in-templ/expected.html b/generator/test-go-template-in-templ/expected.html new file mode 100644 index 000000000..cf995128f --- /dev/null +++ b/generator/test-go-template-in-templ/expected.html @@ -0,0 +1,6 @@ + + + +
Hello, World!
+ + diff --git a/generator/test-go-template-in-templ/render_test.go b/generator/test-go-template-in-templ/render_test.go new file mode 100644 index 000000000..36614f974 --- /dev/null +++ b/generator/test-go-template-in-templ/render_test.go @@ -0,0 +1,22 @@ +package testgotemplates + +import ( + _ "embed" + "testing" + + "github.com/a-h/templ/generator/htmldiff" +) + +//go:embed expected.html +var expected string + +func TestExample(t *testing.T) { + component := Example() + diff, err := htmldiff.Diff(component, expected) + if err != nil { + t.Fatal(err) + } + if diff != "" { + t.Error(diff) + } +} diff --git a/generator/test-go-template-in-templ/template.templ b/generator/test-go-template-in-templ/template.templ new file mode 100644 index 000000000..99614ef0f --- /dev/null +++ b/generator/test-go-template-in-templ/template.templ @@ -0,0 +1,14 @@ +package testgotemplates + +import "html/template" + +var goTemplate = template.Must(template.New("example").Parse("
{{ . }}
")) + +templ Example() { + + + + @templ.FromGoHTML(goTemplate, "Hello, World!") + + +} diff --git a/generator/test-go-template-in-templ/template_templ.go b/generator/test-go-template-in-templ/template_templ.go new file mode 100644 index 000000000..98eb42a9f --- /dev/null +++ b/generator/test-go-template-in-templ/template_templ.go @@ -0,0 +1,46 @@ +// Code generated by templ - DO NOT EDIT. + +package testgotemplates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import "context" +import "io" +import "bytes" + +import "html/template" + +var goTemplate = template.Must(template.New("example").Parse("
{{ . }}
")) + +func Example() templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.FromGoHTML(goTemplate, "Hello, World!").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} diff --git a/generator/test-raw-elements/expected.html b/generator/test-raw-elements/expected.html index 8e4ad92a3..86b5e3a6b 100644 --- a/generator/test-raw-elements/expected.html +++ b/generator/test-raw-elements/expected.html @@ -14,5 +14,6 @@ }

Hello

+
World
diff --git a/generator/test-raw-elements/template.templ b/generator/test-raw-elements/template.templ index e1858bd62..782ef142e 100644 --- a/generator/test-raw-elements/template.templ +++ b/generator/test-raw-elements/template.templ @@ -17,6 +17,7 @@ templ Example() { }

Hello

+ @templ.Raw("
World
") } diff --git a/generator/test-raw-elements/template_templ.go b/generator/test-raw-elements/template_templ.go index ae46295ff..471100d64 100644 --- a/generator/test-raw-elements/template_templ.go +++ b/generator/test-raw-elements/template_templ.go @@ -67,7 +67,15 @@ func Example() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.Raw("
World
").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/generator/test-templ-in-go-template/expected.html b/generator/test-templ-in-go-template/expected.html new file mode 100644 index 000000000..cf995128f --- /dev/null +++ b/generator/test-templ-in-go-template/expected.html @@ -0,0 +1,6 @@ + + + +
Hello, World!
+ + diff --git a/generator/test-templ-in-go-template/render_test.go b/generator/test-templ-in-go-template/render_test.go new file mode 100644 index 000000000..9fa7337c0 --- /dev/null +++ b/generator/test-templ-in-go-template/render_test.go @@ -0,0 +1,39 @@ +package testgotemplates + +import ( + "context" + _ "embed" + "strings" + "testing" + + "github.com/a-h/templ" + "github.com/a-h/templ/generator/htmldiff" +) + +//go:embed expected.html +var expected string + +func TestExample(t *testing.T) { + // Create the templ component. + templComponent := greeting() + html, err := templ.ToGoHTML(context.Background(), templComponent) + if err != nil { + t.Fatalf("failed to convert to html: %v", err) + } + + // Use it within the text/html template. + b := new(strings.Builder) + err = example.Execute(b, html) + if err != nil { + t.Fatalf("failed to execute template: %v", err) + } + + // Compare the output with the expected. + diff, err := htmldiff.DiffStrings(expected, b.String()) + if err != nil { + t.Fatalf("failed to diff strings: %v", err) + } + if diff != "" { + t.Error(diff) + } +} diff --git a/generator/test-templ-in-go-template/template.templ b/generator/test-templ-in-go-template/template.templ new file mode 100644 index 000000000..86378d1a1 --- /dev/null +++ b/generator/test-templ-in-go-template/template.templ @@ -0,0 +1,15 @@ +package testgotemplates + +import "html/template" + +var example = template.Must(template.New("example").Parse(` + + + {{ . }} + + +`)) + +templ greeting() { +
Hello, World!
+} diff --git a/generator/test-templ-in-go-template/template_templ.go b/generator/test-templ-in-go-template/template_templ.go new file mode 100644 index 000000000..4f1483404 --- /dev/null +++ b/generator/test-templ-in-go-template/template_templ.go @@ -0,0 +1,53 @@ +// Code generated by templ - DO NOT EDIT. + +package testgotemplates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import "context" +import "io" +import "bytes" + +import "html/template" + +var example = template.Must(template.New("example").Parse(` + + + {{ . }} + + +`)) + +func greeting() templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Var2 := `Hello, World!` + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} diff --git a/runtime.go b/runtime.go index 3c914d054..f4d3d5ae5 100644 --- a/runtime.go +++ b/runtime.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "html" + "html/template" "io" "net/http" "sort" @@ -744,3 +745,35 @@ func (e Error) Error() string { func (e Error) Unwrap() error { return e.Err } + +// Raw renders the input HTML to the output without applying HTML escaping. +// +// Use of this component presents a security risk - the HTML should come from +// a trusted source, because it will be included as-is in the output. +func Raw[T ~string](html T, errs ...error) Component { + return ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { + if err = errors.Join(errs...); err != nil { + return err + } + _, err = io.WriteString(w, string(html)) + return err + }) +} + +// FromGoHTML creates a templ Component from a Go html/template template. +func FromGoHTML(t *template.Template, data any) Component { + return ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { + return t.Execute(w, data) + }) +} + +// ToGoHTML renders the component to a Go html/template template.HTML string. +func ToGoHTML(ctx context.Context, c Component) (s template.HTML, err error) { + b := GetBuffer() + defer ReleaseBuffer(b) + if err = c.Render(ctx, b); err != nil { + return + } + s = template.HTML(b.String()) + return +} diff --git a/runtime_test.go b/runtime_test.go index a86ede3d0..738d32d78 100644 --- a/runtime_test.go +++ b/runtime_test.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "errors" + "fmt" + "html/template" "io" "net/http" "net/http/httptest" @@ -583,3 +585,136 @@ func TestErrorWrapping(t *testing.T) { } }) } + +func TestRawComponent(t *testing.T) { + tests := []struct { + name string + input templ.Component + expected string + expectedErr error + }{ + { + name: "Raw content is not escaped", + input: templ.Raw("
Test &
"), + expected: `
Test &
`, + }, + { + name: "Raw will return errors first", + input: templ.Raw("", nil, errors.New("test error")), + expected: `
Test &
`, + expectedErr: errors.New("test error"), + }, + { + name: "Strings marked as safe are rendered without escaping", + input: templ.Raw(template.HTML("
")), + expected: `
`, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + b := new(bytes.Buffer) + err := tt.input.Render(context.Background(), b) + if tt.expectedErr != nil { + expected := tt.expectedErr.Error() + actual := fmt.Sprintf("%v", err) + if actual != expected { + t.Errorf("expected error %q, got %q", expected, actual) + } + return + } + if err != nil { + t.Fatalf("failed to render content: %v", err) + } + if diff := cmp.Diff(tt.expected, b.String()); diff != "" { + t.Error(diff) + } + }) + } + t.Run("Raw does not require allocations", func(t *testing.T) { + actualAllocs := testing.AllocsPerRun(4, func() { + c := templ.Raw("
") + if c == nil { + t.Fatalf("unexpected nil value") + } + }) + if actualAllocs > 0 { + t.Errorf("expected no allocs, got %v", actualAllocs) + } + }) +} + +var goTemplate = template.Must(template.New("example").Parse("
{{ . }}
")) + +func TestGoHTMLComponents(t *testing.T) { + t.Run("Go templates can be rendered as templ components", func(t *testing.T) { + b := new(bytes.Buffer) + err := templ.FromGoHTML(goTemplate, "Test &").Render(context.Background(), b) + if err != nil { + t.Fatalf("failed to render content: %v", err) + } + if diff := cmp.Diff("
Test &
", b.String()); diff != "" { + t.Error(diff) + } + }) + t.Run("templ components can be rendered in Go templates", func(t *testing.T) { + b := new(bytes.Buffer) + c := templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { + _, err = io.WriteString(w, "
Unsanitized &
") + return err + }) + h, err := templ.ToGoHTML(context.Background(), c) + if err != nil { + t.Fatalf("failed to convert to Go HTML: %v", err) + } + if err = goTemplate.Execute(b, h); err != nil { + t.Fatalf("failed to render content: %v", err) + } + if diff := cmp.Diff("
Unsanitized &
", b.String()); diff != "" { + t.Error(diff) + } + }) + t.Run("errors in ToGoHTML are returned", func(t *testing.T) { + expectedErr := errors.New("test error") + c := templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { + return expectedErr + }) + _, err := templ.ToGoHTML(context.Background(), c) + if err == nil { + t.Fatalf("expected error, got nil") + } + if err != expectedErr { + t.Fatalf("expected error %q, got %q", expectedErr, err) + } + }) + t.Run("FromGoHTML does not require allocations", func(t *testing.T) { + actualAllocs := testing.AllocsPerRun(4, func() { + c := templ.FromGoHTML(goTemplate, "test &") + if c == nil { + t.Fatalf("unexpected nil value") + } + }) + if actualAllocs > 0 { + t.Errorf("expected no allocs, got %v", actualAllocs) + } + }) + t.Run("ToGoHTML requires one allocation", func(t *testing.T) { + expected := "
Unsanitized &
" + c := templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { + _, err = io.WriteString(w, expected) + return err + }) + actualAllocs := testing.AllocsPerRun(4, func() { + h, err := templ.ToGoHTML(context.Background(), c) + if err != nil { + t.Fatalf("failed to convert to Go HTML: %v", err) + } + if h != template.HTML(expected) { + t.Fatalf("unexpected value") + } + }) + if actualAllocs > 1 { + t.Errorf("expected 1 alloc, got %v", actualAllocs) + } + }) +}