1
0
mirror of https://github.com/a-h/templ.git synced 2025-02-06 10:03:16 +00:00

feat: add new JS handling features - templ.JSFuncCall and templ.JSUnsafeFuncCall (#1038)

Co-authored-by: Joe Davidson <joe.davidson.21111@gmail.com>
This commit is contained in:
Adrian Hesketh 2025-01-06 14:17:18 +00:00 committed by GitHub
parent fcc0519ac7
commit fb44b3e841
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 713 additions and 75 deletions

View File

@ -1 +1 @@
0.3.819
0.3.822

View File

@ -15,15 +15,149 @@ templ body() {
}
```
To pass data from the server to client-side scripts, see [Passing server-side data to scripts](#passing-server-side-data-to-scripts).
## Adding client side behaviours to components
To ensure that a `<script>` tag within a templ component is only rendered once per HTTP response, use a [templ.OnceHandle](18-render-once.md).
:::tip
To ensure that a `<script>` tag within a templ component is only rendered once per HTTP response (or context), use a [templ.OnceHandle](18-render-once.md).
Using a `templ.OnceHandle` allows a component to define global client-side scripts that it needs to run without including the scripts multiple times in the response.
:::
The example below also demonstrates applying behaviour that's defined in a multiline script to its sibling element.
## Pass Go data to JavaScript
### Pass Go data to a JavaScript event handler
Use `templ.JSFuncCall` to pass server-side data to client-side scripts by calling a JavaScript function.
```templ title="input.templ"
templ Component(data CustomType) {
<button onclick={ templ.JSFuncCall("alert", data.Message) }>Show alert</button>
}
```
The data passed to the `alert` function is JSON encoded, so if `data.Message` was the string value of `Hello, from the JSFuncCall data`, the output would be:
```html title="output.html"
<button onclick="alert('Hello, from the JSFuncCall data')">Show alert</button>
```
### Pass event objects to an Event Handler
HTML element `on*` attributes pass an event object to the function. To pass the event object to a function, use `templ.JSExpression`.
:::warning
`templ.JSExpression` bypasses JSON encoding, so the string value is output directly to the HTML - this can be a security risk if the data is not trusted, e.g. the data is user input, not a compile-time constant.
:::
```templ title="input.templ"
<script type="text/javascript">
function clickHandler(event, message) {
alert(message);
event.preventDefault();
}
</script>
<button onclick={ templ.JSFuncCall("clickHandler", templ.JSExpression("event"), "message from Go") }>Show event</button>
```
The output would be:
```html title="output.html"
<script type="text/javascript">
function clickHandler(event, message) {
alert(message);
event.preventDefault();
}
</script>
<button onclick="clickHandler(event, 'message from Go')">Show event</button>
```
### Call client side functions with server side data
Use `templ.JSFuncCall` to call a client-side function with server-side data.
`templ.JSFuncCall` takes a function name and a variadic list of arguments. The arguments are JSON encoded and passed to the function.
In the case that the function name is invalid (e.g. contains `</script>` or is a JavaScript expression, not a function name), the function name will be sanitized to `__templ_invalid_function_name`.
```templ title="components.templ"
templ InitializeClientSideScripts(data CustomType) {
@templ.JSFuncCall("functionToCall", data.Name, data.Age)
}
```
This will output a `<script>` tag that calls the `functionToCall` function with the `Name` and `Age` properties of the `data` object.
```html title="output.html"
<script type="text/javascript">
functionToCall("John", 42);
</script>
```
:::tip
If you want to write out an arbitrary string containing JavaScript, and are sure it is safe, you can use `templ.JSUnsafeFuncCall` to bypass script sanitization.
Whatever string you pass to `templ.JSUnsafeFuncCall` will be output directly to the HTML, so be sure to validate the input.
:::
### Pass server-side data to the client in a HTML attribute
A common approach used by libraries like alpine.js is to pass data to the client in a HTML attribute.
To pass server-side data to the client in a HTML attribute, use `templ.JSONString` to encode the data as a JSON string.
```templ title="input.templ"
templ body(data any) {
<button id="alerter" alert-data={ templ.JSONString(data) }>Show alert</button>
}
```
```html title="output.html"
<button id="alerter" alert-data="{&quot;msg&quot;:&quot;Hello, from the attribute data&quot;}">Show alert</button>
```
The data in the attribute can then be accessed from client-side JavaScript.
```javascript
const button = document.getElementById('alerter');
const data = JSON.parse(button.getAttribute('alert-data'));
```
[alpine.js](https://alpinejs.dev/) uses `x-*` attributes to pass data to the client:
```templ
templ DataDisplay(data DataType) {
<div x-data={ templ.JSONString(data) }>
...
</div>
}
```
### Pass server-side data to the client in a script element
In addition to passing data in HTML attributes, you can also pass data to the client in a `<script>` element.
```templ title="input.templ"
templ body(data any) {
@templ.JSONScript("id", data)
}
```
```html title="output.html"
<script id="id" type="application/json">{"msg":"Hello, from the script data"}</script>
```
The data in the script tag can then be accessed from client-side JavaScript.
```javascript
const data = JSON.parse(document.getElementById('id').textContent);
```
## Avoiding inline event handlers
According to Mozilla, [inline event handlers are considered bad practice](https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Scripting/Events#inline_event_handlers_%E2%80%94_dont_use_these).
This example demonstrates how to add client-side behaviour to a component using a script tag.
The example uses a `templ.OnceHandle` to define global client-side scripts that are required, without rendering the scripts multiple times in the response.
```templ title="component.templ"
package main
@ -147,47 +281,6 @@ http.ListenAndServe("localhost:8080", mux)
```
:::
## Passing server-side data to scripts
Pass data from the server to the client by embedding it in the HTML as a JSON object in an attribute or script tag.
### Pass server-side data to the client in a HTML attribute
```templ title="input.templ"
templ body(data any) {
<button id="alerter" alert-data={ templ.JSONString(data) }>Show alert</button>
}
```
```html title="output.html"
<button id="alerter" alert-data="{&quot;msg&quot;:&quot;Hello, from the attribute data&quot;}">Show alert</button>
```
The data in the attribute can then be accessed from client-side JavaScript.
```javascript
const button = document.getElementById('alerter');
const data = JSON.parse(button.getAttribute('alert-data'));
```
### Pass server-side data to the client in a script element
```templ title="input.templ"
templ body(data any) {
@templ.JSONScript("id", data)
}
```
```html title="output.html"
<script id="id" type="application/json">{"msg":"Hello, from the script data"}</script>
```
The data in the script tag can then be accessed from client-side JavaScript.
```javascript
const data = JSON.parse(document.getElementById('id').textContent);
```
## Working with NPM projects
https://github.com/a-h/templ/tree/main/examples/typescript contains a TypeScript example that uses `esbuild` to transpile TypeScript into plain JavaScript, along with any required `npm` modules.
@ -272,7 +365,9 @@ func main() {
## Script templates
:::warning
Script templates are a legacy feature and are not recommended for new projects. Use standard `<script>` tags to import a standalone JavaScript file, optionally created by a bundler like `esbuild`.
Script templates are a legacy feature and are not recommended for new projects.
Use the `templ.JSFuncCall`, `templ.JSONString` and other features of templ alongside standard `<script>` tags to import standalone JavaScript files, optionally created by a bundler like `esbuild`.
:::
If you need to pass Go data to scripts, you can use a script template.

View File

@ -49,10 +49,11 @@ func DiffStrings(expected, actual string) (diff string, err error) {
}
func Diff(input templ.Component, expected string) (diff string, err error) {
return DiffCtx(context.Background(), input, expected)
_, diff, err = DiffCtx(context.Background(), input, expected)
return diff, err
}
func DiffCtx(ctx context.Context, input templ.Component, expected string) (diff string, err error) {
func DiffCtx(ctx context.Context, input templ.Component, expected string) (formattedInput, diff string, err error) {
var wg sync.WaitGroup
wg.Add(2)
@ -90,5 +91,5 @@ func DiffCtx(ctx context.Context, input templ.Component, expected string) (diff
// Wait for processing.
wg.Wait()
return cmp.Diff(expected, actual.String()), errors.Join(errs...)
return actual.String(), cmp.Diff(expected, actual.String()), errors.Join(errs...)
}

View File

@ -16,7 +16,7 @@ func Test(t *testing.T) {
ctx := context.WithValue(context.Background(), contextKeyName, "test")
diff, err := htmldiff.DiffCtx(ctx, component, expected)
_, diff, err := htmldiff.DiffCtx(ctx, component, expected)
if err != nil {
t.Fatal(err)
}

View File

@ -0,0 +1,6 @@
<button onClick="anythingILike('blah')">
Click me
</button>
<script type="text/javascript">
// Arbitrary JS code
</script>

View File

@ -0,0 +1,23 @@
package testjsunsafeusage
import (
_ "embed"
"testing"
"github.com/a-h/templ/generator/htmldiff"
)
//go:embed expected.html
var expected string
func Test(t *testing.T) {
component := TestComponent()
diff, err := htmldiff.Diff(component, expected)
if err != nil {
t.Fatal(err)
}
if diff != "" {
t.Error(diff)
}
}

View File

@ -0,0 +1,6 @@
package testjsunsafeusage
templ TestComponent() {
<button onClick={ templ.JSUnsafeFuncCall("anythingILike('blah')") }>Click me</button>
@templ.JSUnsafeFuncCall("// Arbitrary JS code")
}

View File

@ -0,0 +1,56 @@
// Code generated by templ - DO NOT EDIT.
package testjsunsafeusage
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func TestComponent() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
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.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.JSUnsafeFuncCall("anythingILike('blah')"))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<button onClick=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 templ.ComponentScript = templ.JSUnsafeFuncCall("anythingILike('blah')")
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\">Click me</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.JSUnsafeFuncCall("// Arbitrary JS code").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View File

@ -0,0 +1,27 @@
<button onclick="alert(&#34;Hello, World!&#34;)">
Click me
</button>
<script type="text/javascript">
function customAlert(msg, date) {
alert(msg + " " + date);
}
</script>
<button onclick="customAlert(&#34;Hello, custom alert 1: &#34;,&#34;2020-01-01T00:00:00Z&#34;)">
Click me
</button>
<button onclick="customAlert(&#34;Hello, custom alert 2: &#34;,&#34;2020-01-01T00:00:00Z&#34;)">
Click me
</button>
<script type="text/javascript">
customAlert("Runs on page load","2020-01-01T00:00:00Z")
</script>
<script>
function onClickEventHandler(event, data) {
alert(event.type);
alert(data)
event.preventDefault();
}
</script>
<button onclick="onClickEventHandler(event,&#34;1234&#34;)">
Pass event handler
</button>

View File

@ -0,0 +1,23 @@
package testjsusage
import (
_ "embed"
"testing"
"github.com/a-h/templ/generator/htmldiff"
)
//go:embed expected.html
var expected string
func Test(t *testing.T) {
component := TestComponent()
diff, err := htmldiff.Diff(component, expected)
if err != nil {
t.Fatal(err)
}
if diff != "" {
t.Error(diff)
}
}

View File

@ -0,0 +1,27 @@
package testjsusage
import "time"
var onceHandle = templ.NewOnceHandle()
templ TestComponent() {
<button onClick={ templ.JSFuncCall("alert", "Hello, World!") }>Click me</button>
@onceHandle.Once() {
<script type="text/javascript">
function customAlert(msg, date) {
alert(msg + " " + date);
}
</script>
}
<button onClick={ templ.JSFuncCall("customAlert", "Hello, custom alert 1: ", time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)) }>Click me</button>
<button onClick={ templ.JSFuncCall("customAlert", "Hello, custom alert 2: ", time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)) }>Click me</button>
@templ.JSFuncCall("customAlert", "Runs on page load", time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC))
<script>
function onClickEventHandler(event, data) {
alert(event.type);
alert(data)
event.preventDefault();
}
</script>
<button onclick={ templ.JSFuncCall("onClickEventHandler", templ.JSExpression("event"), "1234") }>Pass event handler</button>
}

View File

@ -0,0 +1,137 @@
// Code generated by templ - DO NOT EDIT.
package testjsusage
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "time"
var onceHandle = templ.NewOnceHandle()
func TestComponent() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
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.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.JSFuncCall("alert", "Hello, World!"))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<button onClick=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 templ.ComponentScript = templ.JSFuncCall("alert", "Hello, World!")
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\">Click me</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var3 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<script type=\"text/javascript\">\n\t\t\tfunction customAlert(msg, date) {\n\t\t\t\talert(msg + \" \" + date);\n\t\t\t}\n\t\t</script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = onceHandle.Once().Render(templ.WithChildren(ctx, templ_7745c5c3_Var3), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.JSFuncCall("customAlert", "Hello, custom alert 1: ", time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<button onClick=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 templ.ComponentScript = templ.JSFuncCall("customAlert", "Hello, custom alert 1: ", time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\">Click me</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.JSFuncCall("customAlert", "Hello, custom alert 2: ", time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<button onClick=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 templ.ComponentScript = templ.JSFuncCall("customAlert", "Hello, custom alert 2: ", time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\">Click me</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.JSFuncCall("customAlert", "Runs on page load", time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<script>\n\t\tfunction onClickEventHandler(event, data) {\n\t\t\talert(event.type);\n\t\t\talert(data)\n\t\t\tevent.preventDefault();\n\t\t}\n\t</script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.JSFuncCall("onClickEventHandler", templ.JSExpression("event"), "1234"))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<button onclick=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 templ.ComponentScript = templ.JSFuncCall("onClickEventHandler", templ.JSExpression("event"), "1234")
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\">Pass event handler</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View File

@ -16,7 +16,7 @@ func Test(t *testing.T) {
component := ThreeButtons()
ctx := templ.WithNonce(context.Background(), "nonce1")
diff, err := htmldiff.DiffCtx(ctx, component, expected)
_, diff, err := htmldiff.DiffCtx(ctx, component, expected)
if err != nil {
t.Fatal(err)
}

40
js.go Normal file
View File

@ -0,0 +1,40 @@
package templ
import (
"crypto/sha256"
"encoding/hex"
"html"
)
// JSUnsafeFuncCall calls arbitrary JavaScript in the js parameter.
//
// Use of this function presents a security risk - the JavaScript must come
// from a trusted source, because it will be included as-is in the output.
func JSUnsafeFuncCall[T ~string](js T) ComponentScript {
sum := sha256.Sum256([]byte(js))
return ComponentScript{
Name: "jsUnsafeFuncCall_" + hex.EncodeToString(sum[:]),
// Function is empty because the body of the function is defined elsewhere,
// e.g. in a <script> tag within a templ.Once block.
Function: "",
Call: html.EscapeString(string(js)),
CallInline: string(js),
}
}
// JSFuncCall calls a JavaScript function with the given arguments.
//
// It can be used in event handlers, e.g. onclick, onhover, etc. or
// directly in HTML.
func JSFuncCall[T ~string](functionName T, args ...any) ComponentScript {
call := SafeScript(string(functionName), args...)
sum := sha256.Sum256([]byte(call))
return ComponentScript{
Name: "jsFuncCall_" + hex.EncodeToString(sum[:]),
// Function is empty because the body of the function is defined elsewhere,
// e.g. in a <script> tag within a templ.Once block.
Function: "",
Call: call,
CallInline: SafeScriptInline(string(functionName), args...),
}
}

189
js_test.go Normal file
View File

@ -0,0 +1,189 @@
package templ
import (
"bytes"
"context"
"testing"
"github.com/google/go-cmp/cmp"
)
// Note: use of the RawEventHandler and JSFuncCall in ExpressionAttributes is tested in the parser package.
func TestJSUnsafeFuncCall(t *testing.T) {
tests := []struct {
name string
js string
expected ComponentScript
}{
{
name: "alert",
js: "alert('hello')",
expected: ComponentScript{
Name: "jsUnsafeFuncCall_bc8b29d9abedc43cb4d79ec0af23be8c4255a4b76691aecf23ba3b0b8ab90011",
Function: "",
// Note that the Call field is attribute encoded.
Call: "alert(&#39;hello&#39;)",
// Whereas the CallInline field is what you would see inside a <script> tag.
CallInline: "alert('hello')",
},
},
{
name: "immediately executed function",
js: "(function(x) { x < 3 ? alert('hello less than 3') : alert('more than 3'); })(2);",
expected: ComponentScript{
Name: "jsUnsafeFuncCall_e4c24908f83227fd10c1a984fe8f99e15bfb3f195985af517253f6a72ec9106b",
Function: "",
Call: "(function(x) { x &lt; 3 ? alert(&#39;hello less than 3&#39;) : alert(&#39;more than 3&#39;); })(2);",
CallInline: "(function(x) { x < 3 ? alert('hello less than 3') : alert('more than 3'); })(2);",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
actual := JSUnsafeFuncCall(tt.js)
if diff := cmp.Diff(tt.expected, actual); diff != "" {
t.Error(diff)
}
})
}
}
func TestJSFuncCall(t *testing.T) {
tests := []struct {
name string
functionName string
args []any
expected ComponentScript
expectedComponentOutput string
}{
{
name: "no arguments are supported",
functionName: "doSomething",
args: nil,
expected: ComponentScript{
Name: "jsFuncCall_6e742483001f8d3c67652945c597b5a2025a7411cb9d1bae2f9e160bebfeb4c6",
Function: "",
Call: "doSomething()",
CallInline: "doSomething()",
},
expectedComponentOutput: `<script type="text/javascript">doSomething()</script>`,
},
{
name: "single argument is supported",
functionName: "alert",
args: []any{"hello"},
expected: ComponentScript{
Name: "jsFuncCall_92df7244f17dc5bfc41dfd02043df695e4664f8bf42c265a46d79b32b97693d0",
Function: "",
Call: "alert(&#34;hello&#34;)",
CallInline: `alert("hello")`,
},
expectedComponentOutput: `<script type="text/javascript">alert("hello")</script>`,
},
{
name: "multiple arguments are supported",
functionName: "console.log",
args: []any{"hello", "world"},
expected: ComponentScript{
Name: "jsFuncCall_2b3416c14fc2700d01e0013e7b7076bb8dd5f3126d19e2e801de409163e3960c",
Function: "",
Call: "console.log(&#34;hello&#34;,&#34;world&#34;)",
CallInline: `console.log("hello","world")`,
},
expectedComponentOutput: `<script type="text/javascript">console.log("hello","world")</script>`,
},
{
name: "attribute injection fails",
functionName: `" onmouseover="alert('hello')`,
args: nil,
expected: ComponentScript{
Name: "jsFuncCall_e56d1214f3b4fbf27406f209e3f4a58c2842fa2760b6d83da5ee72e04c89f913",
Function: "",
Call: "__templ_invalid_js_function_name()",
CallInline: "__templ_invalid_js_function_name()",
},
expectedComponentOutput: `<script type="text/javascript">__templ_invalid_js_function_name()</script>`,
},
{
name: "closing the script and injecting HTML fails",
functionName: `</script><div>Hello</div><script>`,
args: nil,
expected: ComponentScript{
Name: "jsFuncCall_e56d1214f3b4fbf27406f209e3f4a58c2842fa2760b6d83da5ee72e04c89f913",
Function: "",
Call: "__templ_invalid_js_function_name()",
CallInline: "__templ_invalid_js_function_name()",
},
expectedComponentOutput: `<script type="text/javascript">__templ_invalid_js_function_name()</script>`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Test creation.
actual := JSFuncCall(tt.functionName, tt.args...)
if diff := cmp.Diff(tt.expected, actual); diff != "" {
t.Error(diff)
}
// Test rendering.
buf := new(bytes.Buffer)
err := actual.Render(context.Background(), buf)
if err != nil {
t.Error(err)
}
if diff := cmp.Diff(tt.expectedComponentOutput, buf.String()); diff != "" {
t.Error(diff)
}
})
}
}
func TestJSFunctionNameRegexp(t *testing.T) {
tests := []struct {
input string
expected bool
}{
{
input: "console.log",
expected: true,
},
{
input: "alert",
expected: true,
},
{
input: "console.log('hello')",
expected: false,
},
{
input: "</script><div>Hello</div><script>",
expected: false,
},
{
input: `" onmouseover="alert('hello')`,
expected: false,
},
{
input: "(new Date()).getTime",
expected: false,
},
{
input: "expressionThatReturnsAFunction()",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
t.Parallel()
actual := jsFunctionName.MatchString(tt.input)
if actual != tt.expected {
t.Errorf("expected %v, got %v", tt.expected, actual)
}
})
}
}

View File

@ -4,7 +4,9 @@ import (
"context"
"encoding/json"
"fmt"
"html"
"io"
"regexp"
"strings"
)
@ -103,41 +105,47 @@ type JSExpression string
// SafeScript encodes unknown parameters for safety for inside HTML attributes.
func SafeScript(functionName string, params ...any) string {
encodedParams := safeEncodeScriptParams(true, params)
if !jsFunctionName.MatchString(functionName) {
functionName = "__templ_invalid_js_function_name"
}
sb := new(strings.Builder)
sb.WriteString(functionName)
sb.WriteString(html.EscapeString(functionName))
sb.WriteRune('(')
sb.WriteString(strings.Join(encodedParams, ","))
for i, p := range params {
sb.WriteString(EscapeString(jsonEncodeParam(p)))
if i < len(params)-1 {
sb.WriteRune(',')
}
}
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)
if !jsFunctionName.MatchString(functionName) {
functionName = "__templ_invalid_js_function_name"
}
sb := new(strings.Builder)
sb.WriteString(functionName)
sb.WriteRune('(')
sb.WriteString(strings.Join(encodedParams, ","))
for i, p := range params {
sb.WriteString(jsonEncodeParam(p))
if i < len(params)-1 {
sb.WriteRune(',')
}
}
sb.WriteRune(')')
return sb.String()
}
func safeEncodeScriptParams(escapeHTML bool, params []any) []string {
encodedParams := make([]string, len(params))
for i := 0; i < len(encodedParams); i++ {
if val, ok := params[i].(JSExpression); ok {
encodedParams[i] = string(val)
continue
}
enc, _ := json.Marshal(params[i])
if !escapeHTML {
encodedParams[i] = string(enc)
continue
}
encodedParams[i] = EscapeString(string(enc))
func jsonEncodeParam(param any) string {
if val, ok := param.(JSExpression); ok {
return string(val)
}
return encodedParams
enc, _ := json.Marshal(param)
return string(enc)
}
// isValidJSFunctionName returns true if the given string is a valid JavaScript function name, e.g. console.log, alert, etc.
var jsFunctionName = regexp.MustCompile(`^([$_a-zA-Z][$_a-zA-Z0-9]+\.?)+$`)