Skip to content

Commit

Permalink
feat: add JSExpression to support passing arbitrary JS to script temp…
Browse files Browse the repository at this point in the history
…lates (#851)

Co-authored-by: Corné de Jong <5366568-cornedejong@users.noreply.gitlab.com>
Co-authored-by: Adrian Hesketh <a-h@users.noreply.github.com>
Co-authored-by: Adrian Hesketh <adrianhesketh@hushmail.com>
  • Loading branch information
4 people authored Aug 18, 2024
1 parent 211912f commit ef4dde6
Show file tree
Hide file tree
Showing 8 changed files with 312 additions and 216 deletions.
22 changes: 22 additions & 0 deletions docs/docs/03-syntax-and-usage/12-script-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,3 +403,25 @@ After building and running the executable, running `curl http://localhost:8080/`
</body>
</html>
```

The `JSExpression` type is used to pass arbitrary JavaScript expressions to a templ script template.

A common use case is to pass the `event` or `this` objects to an event handler.

```templ
package main
script showButtonWasClicked(event templ.JSExpression) {
const originalButtonText = event.target.innerText
event.target.innerText = "I was Clicked!"
setTimeout(() => event.target.innerText = originalButtonText, 2000)
}
templ page() {
<html>
<body>
<button type="button" onclick={ showButtonWasClicked(templ.JSExpression("event")) }>Click Me</button>
</body>
</html>
}
```
5 changes: 5 additions & 0 deletions generator/test-script-usage/expected.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
}
</script>
<button hx-on::click="__templ_onClick_657d()" type="button">Button E</button>
<script type="text/javascript">
function __templ_whenButtonIsClicked_253e(event){console.log(event.target)
}
</script>
<button onclick="__templ_whenButtonIsClicked_253e(event)">Button F</button>
<script type="text/javascript">
function __templ_conditionalScript_de41(){alert("conditional");
}
Expand Down
5 changes: 5 additions & 0 deletions generator/test-script-usage/template.templ
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,17 @@ script withComment() {
//'
}

script whenButtonIsClicked(event templ.JSExpression) {
console.log(event.target)
}

templ ThreeButtons() {
@Button("A")
@Button("B")
<button onMouseover="console.log('mouseover')" type="button">Button C</button>
<button hx-on::click="alert('clicked inline')" type="button">Button D</button>
<button hx-on::click={ onClick() } type="button">Button E</button>
<button onclick={ whenButtonIsClicked(templ.JSExpression("event")) }>Button F</button>
@Conditional(true)
@ScriptOnLoad()
}
Expand Down
49 changes: 38 additions & 11 deletions generator/test-script-usage/template_templ.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

127 changes: 1 addition & 126 deletions runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html"
Expand Down Expand Up @@ -491,42 +490,7 @@ func RenderAttributes(ctx context.Context, w io.Writer, attributes Attributes) (
return nil
}

// Script handling.

func safeEncodeScriptParams(escapeHTML bool, params []any) []string {
encodedParams := make([]string, len(params))
for i := 0; i < len(encodedParams); i++ {
enc, _ := json.Marshal(params[i])
if !escapeHTML {
encodedParams[i] = string(enc)
continue
}
encodedParams[i] = EscapeString(string(enc))
}
return encodedParams
}

// SafeScript encodes unknown parameters for safety for inside HTML attributes.
func SafeScript(functionName string, params ...any) string {
encodedParams := safeEncodeScriptParams(true, params)
sb := new(strings.Builder)
sb.WriteString(functionName)
sb.WriteRune('(')
sb.WriteString(strings.Join(encodedParams, ","))
sb.WriteRune(')')
return sb.String()
}

// SafeScript encodes unknown parameters for safety for inline scripts.
func SafeScriptInline(functionName string, params ...any) string {
encodedParams := safeEncodeScriptParams(false, params)
sb := new(strings.Builder)
sb.WriteString(functionName)
sb.WriteRune('(')
sb.WriteString(strings.Join(encodedParams, ","))
sb.WriteRune(')')
return sb.String()
}
// Context.

type contextKeyType int

Expand Down Expand Up @@ -603,95 +567,6 @@ func getContext(ctx context.Context) (context.Context, *contextValue) {
return ctx, v
}

// ComponentScript is a templ Script template.
type ComponentScript struct {
// Name of the script, e.g. print.
Name string
// Function to render.
Function string
// Call of the function in JavaScript syntax, including parameters, and
// ensures parameters are HTML escaped; useful for injecting into HTML
// attributes like onclick, onhover, etc.
//
// Given:
// functionName("some string",12345)
// It would render:
// __templ_functionName_sha(&#34;some string&#34;,12345))
//
// This is can be injected into HTML attributes:
// <button onClick="__templ_functionName_sha(&#34;some string&#34;,12345))">Click Me</button>
Call string
// Call of the function in JavaScript syntax, including parameters. It
// does not HTML escape parameters; useful for directly calling in script
// elements.
//
// Given:
// functionName("some string",12345)
// It would render:
// __templ_functionName_sha("some string",12345))
//
// This is can be used to call the function inside a script tag:
// <script>__templ_functionName_sha("some string",12345))</script>
CallInline string
}

var _ Component = ComponentScript{}

func writeScriptHeader(ctx context.Context, w io.Writer) (err error) {
var nonceAttr string
if nonce := GetNonce(ctx); nonce != "" {
nonceAttr = " nonce=\"" + EscapeString(nonce) + "\""
}
_, err = fmt.Fprintf(w, `<script type="text/javascript"%s>`, nonceAttr)
return err
}

func (c ComponentScript) Render(ctx context.Context, w io.Writer) error {
err := RenderScriptItems(ctx, w, c)
if err != nil {
return err
}
if len(c.Call) > 0 {
if err = writeScriptHeader(ctx, w); err != nil {
return err
}
if _, err = io.WriteString(w, c.CallInline); err != nil {
return err
}
if _, err = io.WriteString(w, `</script>`); err != nil {
return err
}
}
return nil
}

// RenderScriptItems renders a <script> element, if the script has not already been rendered.
func RenderScriptItems(ctx context.Context, w io.Writer, scripts ...ComponentScript) (err error) {
if len(scripts) == 0 {
return nil
}
_, v := getContext(ctx)
sb := new(strings.Builder)
for _, s := range scripts {
if !v.hasScriptBeenRendered(s.Name) {
sb.WriteString(s.Function)
v.addScript(s.Name)
}
}
if sb.Len() > 0 {
if err = writeScriptHeader(ctx, w); err != nil {
return err
}
if _, err = io.WriteString(w, sb.String()); err != nil {
return err
}
if _, err = io.WriteString(w, `</script>`); err != nil {
return err
}
}
return nil
}

var bufferPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
Expand Down
79 changes: 0 additions & 79 deletions runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -377,85 +377,6 @@ func TestClassesFunction(t *testing.T) {
}
}

func TestRenderScriptItems(t *testing.T) {
s1 := templ.ComponentScript{
Name: "s1",
Function: "function s1() { return 'hello1'; }",
}
s2 := templ.ComponentScript{
Name: "s2",
Function: "function s2() { return 'hello2'; }",
}
tests := []struct {
name string
toIgnore []templ.ComponentScript
toRender []templ.ComponentScript
expected string
}{
{
name: "if none are ignored, everything is rendered",
toIgnore: nil,
toRender: []templ.ComponentScript{s1, s2},
expected: `<script type="text/javascript">` + s1.Function + s2.Function + `</script>`,
},
{
name: "if something outside the expected is ignored, if has no effect",
toIgnore: []templ.ComponentScript{
{
Name: "s3",
Function: "function s3() { return 'hello3'; }",
},
},
toRender: []templ.ComponentScript{s1, s2},
expected: `<script type="text/javascript">` + s1.Function + s2.Function + `</script>`,
},
{
name: "if one is ignored, it's not rendered",
toIgnore: []templ.ComponentScript{s1},
toRender: []templ.ComponentScript{s1, s2},
expected: `<script type="text/javascript">` + s2.Function + `</script>`,
},
{
name: "if all are ignored, not even style tags are rendered",
toIgnore: []templ.ComponentScript{
s1,
s2,
{
Name: "s3",
Function: "function s3() { return 'hello3'; }",
},
},
toRender: []templ.ComponentScript{s1, s2},
expected: ``,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
b := new(bytes.Buffer)

// Render twice, reusing the same context so that there's a memory of which classes have been rendered.
ctx = templ.InitializeContext(ctx)
err := templ.RenderScriptItems(ctx, b, tt.toIgnore...)
if err != nil {
t.Fatalf("failed to render initial scripts: %v", err)
}

// Now render again to check that only the expected classes were rendered.
b.Reset()
err = templ.RenderScriptItems(ctx, b, tt.toRender...)
if err != nil {
t.Fatalf("failed to render scripts: %v", err)
}

if diff := cmp.Diff(tt.expected, b.String()); diff != "" {
t.Error(diff)
}
})
}
}

type baseError struct {
Value int
}
Expand Down
Loading

0 comments on commit ef4dde6

Please sign in to comment.