Skip to content

Commit ef4dde6

Browse files
cornejongCorné de Jonga-h
authoredAug 18, 2024··
feat: add JSExpression to support passing arbitrary JS to script templates (#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>
1 parent 211912f commit ef4dde6

File tree

8 files changed

+312
-216
lines changed

8 files changed

+312
-216
lines changed
 

‎docs/docs/03-syntax-and-usage/12-script-templates.md

+22
Original file line numberDiff line numberDiff line change
@@ -403,3 +403,25 @@ After building and running the executable, running `curl http://localhost:8080/`
403403
</body>
404404
</html>
405405
```
406+
407+
The `JSExpression` type is used to pass arbitrary JavaScript expressions to a templ script template.
408+
409+
A common use case is to pass the `event` or `this` objects to an event handler.
410+
411+
```templ
412+
package main
413+
414+
script showButtonWasClicked(event templ.JSExpression) {
415+
const originalButtonText = event.target.innerText
416+
event.target.innerText = "I was Clicked!"
417+
setTimeout(() => event.target.innerText = originalButtonText, 2000)
418+
}
419+
420+
templ page() {
421+
<html>
422+
<body>
423+
<button type="button" onclick={ showButtonWasClicked(templ.JSExpression("event")) }>Click Me</button>
424+
</body>
425+
</html>
426+
}
427+
```

‎generator/test-script-usage/expected.html

+5
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
}
1313
</script>
1414
<button hx-on::click="__templ_onClick_657d()" type="button">Button E</button>
15+
<script type="text/javascript">
16+
function __templ_whenButtonIsClicked_253e(event){console.log(event.target)
17+
}
18+
</script>
19+
<button onclick="__templ_whenButtonIsClicked_253e(event)">Button F</button>
1520
<script type="text/javascript">
1621
function __templ_conditionalScript_de41(){alert("conditional");
1722
}

‎generator/test-script-usage/template.templ

+5
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,17 @@ script withComment() {
2020
//'
2121
}
2222

23+
script whenButtonIsClicked(event templ.JSExpression) {
24+
console.log(event.target)
25+
}
26+
2327
templ ThreeButtons() {
2428
@Button("A")
2529
@Button("B")
2630
<button onMouseover="console.log('mouseover')" type="button">Button C</button>
2731
<button hx-on::click="alert('clicked inline')" type="button">Button D</button>
2832
<button hx-on::click={ onClick() } type="button">Button E</button>
33+
<button onclick={ whenButtonIsClicked(templ.JSExpression("event")) }>Button F</button>
2934
@Conditional(true)
3035
@ScriptOnLoad()
3136
}

‎generator/test-script-usage/template_templ.go

+38-11
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎runtime.go

+1-126
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"context"
66
"crypto/sha256"
77
"encoding/hex"
8-
"encoding/json"
98
"errors"
109
"fmt"
1110
"html"
@@ -491,42 +490,7 @@ func RenderAttributes(ctx context.Context, w io.Writer, attributes Attributes) (
491490
return nil
492491
}
493492

494-
// Script handling.
495-
496-
func safeEncodeScriptParams(escapeHTML bool, params []any) []string {
497-
encodedParams := make([]string, len(params))
498-
for i := 0; i < len(encodedParams); i++ {
499-
enc, _ := json.Marshal(params[i])
500-
if !escapeHTML {
501-
encodedParams[i] = string(enc)
502-
continue
503-
}
504-
encodedParams[i] = EscapeString(string(enc))
505-
}
506-
return encodedParams
507-
}
508-
509-
// SafeScript encodes unknown parameters for safety for inside HTML attributes.
510-
func SafeScript(functionName string, params ...any) string {
511-
encodedParams := safeEncodeScriptParams(true, params)
512-
sb := new(strings.Builder)
513-
sb.WriteString(functionName)
514-
sb.WriteRune('(')
515-
sb.WriteString(strings.Join(encodedParams, ","))
516-
sb.WriteRune(')')
517-
return sb.String()
518-
}
519-
520-
// SafeScript encodes unknown parameters for safety for inline scripts.
521-
func SafeScriptInline(functionName string, params ...any) string {
522-
encodedParams := safeEncodeScriptParams(false, params)
523-
sb := new(strings.Builder)
524-
sb.WriteString(functionName)
525-
sb.WriteRune('(')
526-
sb.WriteString(strings.Join(encodedParams, ","))
527-
sb.WriteRune(')')
528-
return sb.String()
529-
}
493+
// Context.
530494

531495
type contextKeyType int
532496

@@ -603,95 +567,6 @@ func getContext(ctx context.Context) (context.Context, *contextValue) {
603567
return ctx, v
604568
}
605569

606-
// ComponentScript is a templ Script template.
607-
type ComponentScript struct {
608-
// Name of the script, e.g. print.
609-
Name string
610-
// Function to render.
611-
Function string
612-
// Call of the function in JavaScript syntax, including parameters, and
613-
// ensures parameters are HTML escaped; useful for injecting into HTML
614-
// attributes like onclick, onhover, etc.
615-
//
616-
// Given:
617-
// functionName("some string",12345)
618-
// It would render:
619-
// __templ_functionName_sha(&#34;some string&#34;,12345))
620-
//
621-
// This is can be injected into HTML attributes:
622-
// <button onClick="__templ_functionName_sha(&#34;some string&#34;,12345))">Click Me</button>
623-
Call string
624-
// Call of the function in JavaScript syntax, including parameters. It
625-
// does not HTML escape parameters; useful for directly calling in script
626-
// elements.
627-
//
628-
// Given:
629-
// functionName("some string",12345)
630-
// It would render:
631-
// __templ_functionName_sha("some string",12345))
632-
//
633-
// This is can be used to call the function inside a script tag:
634-
// <script>__templ_functionName_sha("some string",12345))</script>
635-
CallInline string
636-
}
637-
638-
var _ Component = ComponentScript{}
639-
640-
func writeScriptHeader(ctx context.Context, w io.Writer) (err error) {
641-
var nonceAttr string
642-
if nonce := GetNonce(ctx); nonce != "" {
643-
nonceAttr = " nonce=\"" + EscapeString(nonce) + "\""
644-
}
645-
_, err = fmt.Fprintf(w, `<script type="text/javascript"%s>`, nonceAttr)
646-
return err
647-
}
648-
649-
func (c ComponentScript) Render(ctx context.Context, w io.Writer) error {
650-
err := RenderScriptItems(ctx, w, c)
651-
if err != nil {
652-
return err
653-
}
654-
if len(c.Call) > 0 {
655-
if err = writeScriptHeader(ctx, w); err != nil {
656-
return err
657-
}
658-
if _, err = io.WriteString(w, c.CallInline); err != nil {
659-
return err
660-
}
661-
if _, err = io.WriteString(w, `</script>`); err != nil {
662-
return err
663-
}
664-
}
665-
return nil
666-
}
667-
668-
// RenderScriptItems renders a <script> element, if the script has not already been rendered.
669-
func RenderScriptItems(ctx context.Context, w io.Writer, scripts ...ComponentScript) (err error) {
670-
if len(scripts) == 0 {
671-
return nil
672-
}
673-
_, v := getContext(ctx)
674-
sb := new(strings.Builder)
675-
for _, s := range scripts {
676-
if !v.hasScriptBeenRendered(s.Name) {
677-
sb.WriteString(s.Function)
678-
v.addScript(s.Name)
679-
}
680-
}
681-
if sb.Len() > 0 {
682-
if err = writeScriptHeader(ctx, w); err != nil {
683-
return err
684-
}
685-
if _, err = io.WriteString(w, sb.String()); err != nil {
686-
return err
687-
}
688-
if _, err = io.WriteString(w, `</script>`); err != nil {
689-
return err
690-
}
691-
}
692-
return nil
693-
}
694-
695570
var bufferPool = sync.Pool{
696571
New: func() any {
697572
return new(bytes.Buffer)

‎runtime_test.go

-79
Original file line numberDiff line numberDiff line change
@@ -377,85 +377,6 @@ func TestClassesFunction(t *testing.T) {
377377
}
378378
}
379379

380-
func TestRenderScriptItems(t *testing.T) {
381-
s1 := templ.ComponentScript{
382-
Name: "s1",
383-
Function: "function s1() { return 'hello1'; }",
384-
}
385-
s2 := templ.ComponentScript{
386-
Name: "s2",
387-
Function: "function s2() { return 'hello2'; }",
388-
}
389-
tests := []struct {
390-
name string
391-
toIgnore []templ.ComponentScript
392-
toRender []templ.ComponentScript
393-
expected string
394-
}{
395-
{
396-
name: "if none are ignored, everything is rendered",
397-
toIgnore: nil,
398-
toRender: []templ.ComponentScript{s1, s2},
399-
expected: `<script type="text/javascript">` + s1.Function + s2.Function + `</script>`,
400-
},
401-
{
402-
name: "if something outside the expected is ignored, if has no effect",
403-
toIgnore: []templ.ComponentScript{
404-
{
405-
Name: "s3",
406-
Function: "function s3() { return 'hello3'; }",
407-
},
408-
},
409-
toRender: []templ.ComponentScript{s1, s2},
410-
expected: `<script type="text/javascript">` + s1.Function + s2.Function + `</script>`,
411-
},
412-
{
413-
name: "if one is ignored, it's not rendered",
414-
toIgnore: []templ.ComponentScript{s1},
415-
toRender: []templ.ComponentScript{s1, s2},
416-
expected: `<script type="text/javascript">` + s2.Function + `</script>`,
417-
},
418-
{
419-
name: "if all are ignored, not even style tags are rendered",
420-
toIgnore: []templ.ComponentScript{
421-
s1,
422-
s2,
423-
{
424-
Name: "s3",
425-
Function: "function s3() { return 'hello3'; }",
426-
},
427-
},
428-
toRender: []templ.ComponentScript{s1, s2},
429-
expected: ``,
430-
},
431-
}
432-
for _, tt := range tests {
433-
tt := tt
434-
t.Run(tt.name, func(t *testing.T) {
435-
ctx := context.Background()
436-
b := new(bytes.Buffer)
437-
438-
// Render twice, reusing the same context so that there's a memory of which classes have been rendered.
439-
ctx = templ.InitializeContext(ctx)
440-
err := templ.RenderScriptItems(ctx, b, tt.toIgnore...)
441-
if err != nil {
442-
t.Fatalf("failed to render initial scripts: %v", err)
443-
}
444-
445-
// Now render again to check that only the expected classes were rendered.
446-
b.Reset()
447-
err = templ.RenderScriptItems(ctx, b, tt.toRender...)
448-
if err != nil {
449-
t.Fatalf("failed to render scripts: %v", err)
450-
}
451-
452-
if diff := cmp.Diff(tt.expected, b.String()); diff != "" {
453-
t.Error(diff)
454-
}
455-
})
456-
}
457-
}
458-
459380
type baseError struct {
460381
Value int
461382
}

‎scripttemplate.go

+143
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package templ
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"strings"
9+
)
10+
11+
// ComponentScript is a templ Script template.
12+
type ComponentScript struct {
13+
// Name of the script, e.g. print.
14+
Name string
15+
// Function to render.
16+
Function string
17+
// Call of the function in JavaScript syntax, including parameters, and
18+
// ensures parameters are HTML escaped; useful for injecting into HTML
19+
// attributes like onclick, onhover, etc.
20+
//
21+
// Given:
22+
// functionName("some string",12345)
23+
// It would render:
24+
// __templ_functionName_sha(&#34;some string&#34;,12345))
25+
//
26+
// This is can be injected into HTML attributes:
27+
// <button onClick="__templ_functionName_sha(&#34;some string&#34;,12345))">Click Me</button>
28+
Call string
29+
// Call of the function in JavaScript syntax, including parameters. It
30+
// does not HTML escape parameters; useful for directly calling in script
31+
// elements.
32+
//
33+
// Given:
34+
// functionName("some string",12345)
35+
// It would render:
36+
// __templ_functionName_sha("some string",12345))
37+
//
38+
// This is can be used to call the function inside a script tag:
39+
// <script>__templ_functionName_sha("some string",12345))</script>
40+
CallInline string
41+
}
42+
43+
var _ Component = ComponentScript{}
44+
45+
func writeScriptHeader(ctx context.Context, w io.Writer) (err error) {
46+
var nonceAttr string
47+
if nonce := GetNonce(ctx); nonce != "" {
48+
nonceAttr = " nonce=\"" + EscapeString(nonce) + "\""
49+
}
50+
_, err = fmt.Fprintf(w, `<script type="text/javascript"%s>`, nonceAttr)
51+
return err
52+
}
53+
54+
func (c ComponentScript) Render(ctx context.Context, w io.Writer) error {
55+
err := RenderScriptItems(ctx, w, c)
56+
if err != nil {
57+
return err
58+
}
59+
if len(c.Call) > 0 {
60+
if err = writeScriptHeader(ctx, w); err != nil {
61+
return err
62+
}
63+
if _, err = io.WriteString(w, c.CallInline); err != nil {
64+
return err
65+
}
66+
if _, err = io.WriteString(w, `</script>`); err != nil {
67+
return err
68+
}
69+
}
70+
return nil
71+
}
72+
73+
// RenderScriptItems renders a <script> element, if the script has not already been rendered.
74+
func RenderScriptItems(ctx context.Context, w io.Writer, scripts ...ComponentScript) (err error) {
75+
if len(scripts) == 0 {
76+
return nil
77+
}
78+
_, v := getContext(ctx)
79+
sb := new(strings.Builder)
80+
for _, s := range scripts {
81+
if !v.hasScriptBeenRendered(s.Name) {
82+
sb.WriteString(s.Function)
83+
v.addScript(s.Name)
84+
}
85+
}
86+
if sb.Len() > 0 {
87+
if err = writeScriptHeader(ctx, w); err != nil {
88+
return err
89+
}
90+
if _, err = io.WriteString(w, sb.String()); err != nil {
91+
return err
92+
}
93+
if _, err = io.WriteString(w, `</script>`); err != nil {
94+
return err
95+
}
96+
}
97+
return nil
98+
}
99+
100+
// JSExpression represents a JavaScript expression intended for use as an argument for script templates.
101+
// The string value of JSExpression will be inserted directly as JavaScript code in function call arguments.
102+
type JSExpression string
103+
104+
// SafeScript encodes unknown parameters for safety for inside HTML attributes.
105+
func SafeScript(functionName string, params ...any) string {
106+
encodedParams := safeEncodeScriptParams(true, params)
107+
sb := new(strings.Builder)
108+
sb.WriteString(functionName)
109+
sb.WriteRune('(')
110+
sb.WriteString(strings.Join(encodedParams, ","))
111+
sb.WriteRune(')')
112+
return sb.String()
113+
}
114+
115+
// SafeScript encodes unknown parameters for safety for inline scripts.
116+
func SafeScriptInline(functionName string, params ...any) string {
117+
encodedParams := safeEncodeScriptParams(false, params)
118+
sb := new(strings.Builder)
119+
sb.WriteString(functionName)
120+
sb.WriteRune('(')
121+
sb.WriteString(strings.Join(encodedParams, ","))
122+
sb.WriteRune(')')
123+
return sb.String()
124+
}
125+
126+
func safeEncodeScriptParams(escapeHTML bool, params []any) []string {
127+
encodedParams := make([]string, len(params))
128+
for i := 0; i < len(encodedParams); i++ {
129+
if val, ok := params[i].(JSExpression); ok {
130+
encodedParams[i] = string(val)
131+
continue
132+
}
133+
134+
enc, _ := json.Marshal(params[i])
135+
if !escapeHTML {
136+
encodedParams[i] = string(enc)
137+
continue
138+
}
139+
encodedParams[i] = EscapeString(string(enc))
140+
}
141+
142+
return encodedParams
143+
}

‎scripttemplate_test.go

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package templ_test
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"testing"
7+
8+
"github.com/a-h/templ"
9+
"github.com/google/go-cmp/cmp"
10+
)
11+
12+
func TestRenderScriptItems(t *testing.T) {
13+
s1 := templ.ComponentScript{
14+
Name: "s1",
15+
Function: "function s1() { return 'hello1'; }",
16+
}
17+
s2 := templ.ComponentScript{
18+
Name: "s2",
19+
Function: "function s2() { return 'hello2'; }",
20+
}
21+
tests := []struct {
22+
name string
23+
toIgnore []templ.ComponentScript
24+
toRender []templ.ComponentScript
25+
expected string
26+
}{
27+
{
28+
name: "if none are ignored, everything is rendered",
29+
toIgnore: nil,
30+
toRender: []templ.ComponentScript{s1, s2},
31+
expected: `<script type="text/javascript">` + s1.Function + s2.Function + `</script>`,
32+
},
33+
{
34+
name: "if something outside the expected is ignored, if has no effect",
35+
toIgnore: []templ.ComponentScript{
36+
{
37+
Name: "s3",
38+
Function: "function s3() { return 'hello3'; }",
39+
},
40+
},
41+
toRender: []templ.ComponentScript{s1, s2},
42+
expected: `<script type="text/javascript">` + s1.Function + s2.Function + `</script>`,
43+
},
44+
{
45+
name: "if one is ignored, it's not rendered",
46+
toIgnore: []templ.ComponentScript{s1},
47+
toRender: []templ.ComponentScript{s1, s2},
48+
expected: `<script type="text/javascript">` + s2.Function + `</script>`,
49+
},
50+
{
51+
name: "if all are ignored, not even style tags are rendered",
52+
toIgnore: []templ.ComponentScript{
53+
s1,
54+
s2,
55+
{
56+
Name: "s3",
57+
Function: "function s3() { return 'hello3'; }",
58+
},
59+
},
60+
toRender: []templ.ComponentScript{s1, s2},
61+
expected: ``,
62+
},
63+
}
64+
for _, tt := range tests {
65+
tt := tt
66+
t.Run(tt.name, func(t *testing.T) {
67+
ctx := context.Background()
68+
b := new(bytes.Buffer)
69+
70+
// Render twice, reusing the same context so that there's a memory of which classes have been rendered.
71+
ctx = templ.InitializeContext(ctx)
72+
err := templ.RenderScriptItems(ctx, b, tt.toIgnore...)
73+
if err != nil {
74+
t.Fatalf("failed to render initial scripts: %v", err)
75+
}
76+
77+
// Now render again to check that only the expected classes were rendered.
78+
b.Reset()
79+
err = templ.RenderScriptItems(ctx, b, tt.toRender...)
80+
if err != nil {
81+
t.Fatalf("failed to render scripts: %v", err)
82+
}
83+
84+
if diff := cmp.Diff(tt.expected, b.String()); diff != "" {
85+
t.Error(diff)
86+
}
87+
})
88+
}
89+
}
90+
91+
func TestJSExpression(t *testing.T) {
92+
expected := "myJSFunction(\"StringValue\",123,event,1 + 2)"
93+
actual := templ.SafeScriptInline("myJSFunction", "StringValue", 123, templ.JSExpression("event"), templ.JSExpression("1 + 2"))
94+
95+
if actual != expected {
96+
t.Fatalf("TestJSExpression: expected %q, got %q", expected, actual)
97+
}
98+
}

0 commit comments

Comments
 (0)
Please sign in to comment.