Repository: a-h/templ
Pull request #1065 (2025-02-09 15:19:51)

feat: support go expressions in script elements
Support the use of Go variables within `<script>` elements.

```templ
package testscriptexpressions

templ Script[T any](name string, data T) {
	<h1>{ name }</h1>
	<script>
		var a = {{ data }}
		var b = "{{ data }}"
		var c = '{{ data }}'
		var d = `{{ data }}`
	</script>
	<script>
		console.log({{ data }})
	</script>
}

templ AllTests() {
	@Script("string data", "hello")
	@Script("string data with quotes", "hello 'world'")
	@Script("numeric data", 123)
	@Script("boolean data", true)
	@Script("array data", []int{1, 2, 3})
	@Script("object data", struct {
		Name string
		Age  int
	}{"Alice", 30})
	@Script[*string]("null data", nil)
}
```

Result is:

```html
<h1>string data</h1>
<script>
		var a = "hello"
		var b = "hello"
		var c = 'hello'
		var d = `hello`
</script>
<script>
		console.log("hello")
</script>
<h1>string data with quotes</h1>
<script>
		var a = "hello 'world'"
		var b = "hello \u0027world\u0027"
		var c = 'hello \u0027world\u0027'
		var d = `hello \u0027world\u0027`
</script>
<script>
		console.log("hello 'world'")
</script>
<h1>numeric data</h1>
<script>
		var a = 123
		var b = "123"
		var c = '123'
		var d = `123`
</script>
<script>
		console.log(123)
</script>
<h1>boolean data</h1>
<script>
		var a = true
		var b = "true"
		var c = 'true'
		var d = `true`
</script>
<script>
		console.log(true)
</script>
<h1>array data</h1>
<script>
		var a = [1,2,3]
		var b = "[1,2,3]"
		var c = '[1,2,3]'
		var d = `[1,2,3]`
</script>
<script>
		console.log([1,2,3])
</script>
<h1>object data</h1>
<script>
		var a = {"Name":"Alice","Age":30}
		var b = "{\u0022Name\u0022:\u0022Alice\u0022,\u0022Age\u0022:30}"
		var c = '{\u0022Name\u0022:\u0022Alice\u0022,\u0022Age\u0022:30}'
		var d = `{\u0022Name\u0022:\u0022Alice\u0022,\u0022Age\u0022:30}`
</script>
<script>
		console.log({"Name":"Alice","Age":30})
</script>
<h1>null data</h1>
<script>
		var a = null
		var b = "null"
		var c = 'null'
		var d = `null`
</script>
<script>
		console.log(null)
</script>
```

a-h: Needs an update to the documentation.
a-h: I think this is ready for review now. I ran the fuzz tests for a few hours, getting through about 73M tests without issue.
joerdav: Nice idea on the fuzz test. I can see it there were some iterations commited so I guess it caught some issues that were fixed?

Patch:
diff --git a/.version b/.version
index 0fd800e..9663c0d 100644
--- a/.version
+++ b/.version
@@ -1,1 +1,1 @@
-0.3.840
\ No newline at end of file
+0.3.841
\ No newline at end of file
diff --git a/docs/docs/03-syntax-and-usage/12-script-templates.md b/docs/docs/03-syntax-and-usage/12-script-templates.md
index ce40557..da6e1b2 100644
--- a/docs/docs/03-syntax-and-usage/12-script-templates.md
+++ b/docs/docs/03-syntax-and-usage/12-script-templates.md
@@ -151,6 +151,56 @@ The data in the script tag can then be accessed from client-side JavaScript.
 const data = JSON.parse(document.getElementById('id').textContent);
 ```
 
+### Interpolate Go data within JavaScript code in a script tag
+
+If you want to use Go data as variables within JavaScript, you can use a `{{ value }}` block to place Go data within the script.
+
+templ will automatically escape the Go data to prevent XSS attacks.
+
+Within strings, you can use `{{ value }}` to interpolate Go data.
+
+```templ title="input.templ"
+templ body(msg string) {
+  <script>
+    const message = "Your message: {{ msg }}";
+    alert(message);
+  </script>
+}
+```
+
+The output would be:
+
+```html title="output.html" msg="Hello"
+<script>
+  const message = "Your message: Hello";
+  alert(message);
+</script>
+```
+
+Outside JavaScript strings, data is JSON encoded so that it can be used as a JavaScript object.
+
+```templ title="input.templ"
+templ body(msg string) {
+  <script>
+    const message = {{ msg }};
+    alert(message);
+  </script>
+}
+```
+
+The output would be:
+
+```html title="output.html" msg="Hello"
+<script>
+  const message = "Hello";
+  alert(message);
+</script>
+```
+
+:::tip
+It's better to pass data to the client in a HTML attribute or a script tag, as this separates the data from the JavaScript code, making it easier to maintain and debug.
+:::
+
 ## 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).
diff --git a/flake.nix b/flake.nix
index b6022cf..32a55c1 100644
--- a/flake.nix
+++ b/flake.nix
@@ -41,7 +41,7 @@
             name = "templ";
             subPackages = [ "cmd/templ" ];
             src = gitignore.lib.gitignoreSource ./.;
-            vendorHash = "sha256-/aLLFf7sbpNPtLS3crqxy1FCZh+Kq2ourONXcU4Gt4Q=";
+            vendorHash = "sha256-v/GSIJfGTzHOVFJfpGBi9qtnUFo6CZQl8SdqJepgc74=";
             CGO_ENABLED = 0;
             flags = [
               "-trimpath"
diff --git a/generator/generator.go b/generator/generator.go
index 0842fe1..f7be558 100644
--- a/generator/generator.go
+++ b/generator/generator.go
@@ -3,6 +3,7 @@ package generator
 import (
 	"crypto/sha256"
 	"encoding/hex"
+	"errors"
 	"fmt"
 	"html"
 	"io"
@@ -596,6 +597,8 @@ func (g *generator) writeNode(indentLevel int, current parser.Node, next parser.
 		err = g.writeChildrenExpression(indentLevel)
 	case parser.RawElement:
 		err = g.writeRawElement(indentLevel, n)
+	case parser.ScriptElement:
+		err = g.writeScriptElement(indentLevel, n)
 	case parser.ForExpression:
 		err = g.writeForExpression(indentLevel, n, next)
 	case parser.CallTemplateExpression:
@@ -1434,6 +1437,100 @@ func (g *generator) writeRawElement(indentLevel int, n parser.RawElement) (err e
 	return err
 }
 
+func (g *generator) writeScriptElement(indentLevel int, n parser.ScriptElement) (err error) {
+	if len(n.Attributes) == 0 {
+		// <div>
+		if _, err = g.w.WriteStringLiteral(indentLevel, `<script>`); err != nil {
+			return err
+		}
+	} else {
+		// <script></script>
+		if err = g.writeElementScript(indentLevel, n.Attributes); err != nil {
+			return err
+		}
+		// <div
+		if _, err = g.w.WriteStringLiteral(indentLevel, "<script"); err != nil {
+			return err
+		}
+		if err = g.writeElementAttributes(indentLevel, "script", n.Attributes); err != nil {
+			return err
+		}
+		// >
+		if _, err = g.w.WriteStringLiteral(indentLevel, `>`); err != nil {
+			return err
+		}
+	}
+	// Contents.
+	for _, c := range n.Contents {
+		if err = g.writeScriptContents(indentLevel, c); err != nil {
+			return err
+		}
+	}
+	// </div>
+	if _, err = g.w.WriteStringLiteral(indentLevel, "</script>"); err != nil {
+		return err
+	}
+	return err
+}
+
+func (g *generator) writeScriptContents(indentLevel int, c parser.ScriptContents) (err error) {
+	if c.Value != nil {
+		if *c.Value == "" {
+			return nil
+		}
+		// This is a JS expression and can be written directly to the output.
+		return g.writeText(indentLevel, parser.Text{Value: *c.Value})
+	}
+	if c.GoCode != nil {
+		// This is a Go code block. The code needs to be evaluated, and the result written to the output.
+		// The variable is JSON encoded to ensure that it is safe to use within a script tag.
+		var r parser.Range
+		vn := g.createVariableName()
+		// Here, we need to get the result, which might be any type. We can use templ.ScriptContent to get the result.
+		// vn, templ_7745c5c3_Err := templruntime.ScriptContent(
+		fnCall := "templruntime.ScriptContentOutsideStringLiteral"
+		if c.InsideStringLiteral {
+			fnCall = "templruntime.ScriptContentInsideStringLiteral"
+		}
+		if _, err = g.w.WriteIndent(indentLevel, vn+", templ_7745c5c3_Err := "+fnCall+"("); err != nil {
+			return err
+		}
+		// p.Name()
+		if r, err = g.w.Write(c.GoCode.Expression.Value); err != nil {
+			return err
+		}
+		g.sourceMap.Add(c.GoCode.Expression, r)
+		// )
+		if _, err = g.w.Write(")\n"); err != nil {
+			return err
+		}
+
+		// Expression error handler.
+		err = g.writeExpressionErrorHandler(indentLevel, c.GoCode.Expression)
+		if err != nil {
+			return err
+		}
+
+		// _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(jvn)
+		if _, err = g.w.WriteIndent(indentLevel, "_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("+vn+")\n"); err != nil {
+			return err
+		}
+		if err = g.writeErrorHandler(indentLevel); err != nil {
+			return err
+		}
+
+		// Write any trailing space.
+		if c.GoCode.TrailingSpace != "" {
+			if err = g.writeText(indentLevel, parser.Text{Value: string(c.GoCode.TrailingSpace)}); err != nil {
+				return err
+			}
+		}
+
+		return nil
+	}
+	return errors.New("unknown script content")
+}
+
 func (g *generator) writeComment(indentLevel int, c parser.HTMLComment) (err error) {
 	// <!--
 	if _, err = g.w.WriteStringLiteral(indentLevel, "<!--"); err != nil {
diff --git a/go.mod b/go.mod
index 834066c..60efb19 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,7 @@ go 1.23
 toolchain go1.23.3
 
 require (
-	github.com/a-h/htmlformat v0.0.0-20231108124658-5bd994fe268e
+	github.com/a-h/htmlformat v0.0.0-20250209131833-673be874c677
 	github.com/a-h/parse v0.0.0-20250122154542-74294addb73e
 	github.com/andybalholm/brotli v1.1.0
 	github.com/cenkalti/backoff/v4 v4.3.0
@@ -16,7 +16,7 @@ require (
 	github.com/natefinch/atomic v1.0.1
 	github.com/rs/cors v1.11.0
 	golang.org/x/mod v0.20.0
-	golang.org/x/net v0.33.0
+	golang.org/x/net v0.34.0
 	golang.org/x/sync v0.10.0
 	golang.org/x/tools v0.24.0
 )
@@ -24,7 +24,7 @@ require (
 require (
 	github.com/mattn/go-colorable v0.1.13 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
-	golang.org/x/sys v0.28.0 // indirect
+	golang.org/x/sys v0.29.0 // indirect
 )
 
 // replace github.com/a-h/parse => /Users/adrian/github.com/a-h/parse
diff --git a/go.sum b/go.sum
index d4996fb..57fd017 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,5 @@
-github.com/a-h/htmlformat v0.0.0-20231108124658-5bd994fe268e h1:Eog54DQpku7NpPNff9wzQYT61TGu9jjq5N8UhAkqIgw=
-github.com/a-h/htmlformat v0.0.0-20231108124658-5bd994fe268e/go.mod h1:FMIm5afKmEfarNbIXOaPHFY8X7fo+fRQB6I9MPG2nB0=
+github.com/a-h/htmlformat v0.0.0-20250209131833-673be874c677 h1:H1EoxMpNo/TnCZEgSaKsomi+dQ05dXiVSKDN86Lap9A=
+github.com/a-h/htmlformat v0.0.0-20250209131833-673be874c677/go.mod h1:FMIm5afKmEfarNbIXOaPHFY8X7fo+fRQB6I9MPG2nB0=
 github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
 github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
 github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
@@ -25,13 +25,13 @@ github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po=
 github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
 golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=
 golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
-golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
+golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
+golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
 golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
 golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
-golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
+golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
 golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
diff --git a/parser/v2/elementparser.go b/parser/v2/elementparser.go
index b9ecf4b..e91ad29 100644
--- a/parser/v2/elementparser.go
+++ b/parser/v2/elementparser.go
@@ -444,6 +444,12 @@ func (elementParser) Parse(pi *parse.Input) (n Node, ok bool, err error) {
 	r.IndentAttrs = ot.IndentAttrs
 	r.NameRange = ot.NameRange
 
+	if r.Name == "script" {
+		// Script elements have special handling.
+		pi.Seek(start.Index)
+		return n, false, nil
+	}
+
 	// Once we've got an open tag, the rest must be present.
 	l := pi.Position().Line
 
diff --git a/parser/v2/expressionparser.go b/parser/v2/expressionparser.go
index cde2dfd..e92d405 100644
--- a/parser/v2/expressionparser.go
+++ b/parser/v2/expressionparser.go
@@ -39,6 +39,9 @@ var openBraceWithOptionalPadding = parse.Any(openBraceWithPadding, openBrace)
 var closeBrace = parse.String("}")
 var closeBraceWithOptionalPadding = parse.StringFrom(optionalSpaces, closeBrace)
 
+var dblOpenBrace = parse.String("{{")
+var dblOpenBraceWithOptionalPadding = parse.StringFrom(dblOpenBrace, optionalSpaces)
+
 var dblCloseBrace = parse.String("}}")
 var dblCloseBraceWithOptionalPadding = parse.StringFrom(optionalSpaces, dblCloseBrace)
 
diff --git a/parser/v2/gocodeparser.go b/parser/v2/gocodeparser.go
index 027bcc4..36e5ea5 100644
--- a/parser/v2/gocodeparser.go
+++ b/parser/v2/gocodeparser.go
@@ -5,41 +5,56 @@ import (
 	"github.com/a-h/templ/parser/v2/goexpression"
 )
 
-var goCode = parse.Func(func(pi *parse.Input) (n Node, ok bool, err error) {
-	// Check the prefix first.
-	if _, ok, err = parse.Or(parse.String("{{ "), parse.String("{{")).Parse(pi); err != nil || !ok {
-		return
-	}
-
-	// Once we have a prefix, we must have an expression that returns a string, with optional err.
-	l := pi.Position().Line
-	var r GoCode
-	if r.Expression, err = parseGo("go code", pi, goexpression.Expression); err != nil {
-		return r, false, err
-	}
-
-	if l != pi.Position().Line {
-		r.Multiline = true
-	}
-
-	// Clear any optional whitespace.
-	_, _, _ = parse.OptionalWhitespace.Parse(pi)
-
-	// }}
-	if _, ok, err = dblCloseBraceWithOptionalPadding.Parse(pi); err != nil || !ok {
-		err = parse.Error("go code: missing close braces", pi.Position())
-		return
-	}
-
-	// Parse trailing whitespace.
-	ws, _, err := parse.Whitespace.Parse(pi)
-	if err != nil {
-		return r, false, err
-	}
-	r.TrailingSpace, err = NewTrailingSpace(ws)
-	if err != nil {
-		return r, false, err
-	}
-
-	return r, true, nil
-})
+// goCode is the parser used to parse Raw Go code within templates.
+//
+// goCodeInJavaScript is the same, but handles the case where Go expressions
+// are embedded within JavaScript.
+//
+// The only difference is that goCode normalises whitespace after the
+// closing brace pair, whereas goCodeInJavaScript retains all whitespace.
+var goCode = getGoCodeParser(true)
+var goCodeInJavaScript = getGoCodeParser(false)
+
+func getGoCodeParser(normalizeWhitespace bool) parse.Parser[Node] {
+	return parse.Func(func(pi *parse.Input) (n Node, ok bool, err error) {
+		// Check the prefix first.
+		if _, ok, err = dblOpenBraceWithOptionalPadding.Parse(pi); err != nil || !ok {
+			return
+		}
+
+		// Once we have a prefix, we must have an expression that returns a string, with optional err.
+		l := pi.Position().Line
+		var r GoCode
+		if r.Expression, err = parseGo("go code", pi, goexpression.Expression); err != nil {
+			return r, false, err
+		}
+
+		if l != pi.Position().Line {
+			r.Multiline = true
+		}
+
+		// Clear any optional whitespace.
+		_, _, _ = parse.OptionalWhitespace.Parse(pi)
+
+		// }}
+		if _, ok, err = dblCloseBraceWithOptionalPadding.Parse(pi); err != nil || !ok {
+			err = parse.Error("go code: missing close braces", pi.Position())
+			return
+		}
+
+		// Parse trailing whitespace.
+		ws, _, err := parse.Whitespace.Parse(pi)
+		if err != nil {
+			return r, false, err
+		}
+		if normalizeWhitespace {
+			if r.TrailingSpace, err = NewTrailingSpace(ws); err != nil {
+				return r, false, err
+			}
+		} else {
+			r.TrailingSpace = TrailingSpace(ws)
+		}
+
+		return r, true, nil
+	})
+}
diff --git a/parser/v2/raw.go b/parser/v2/raw.go
index 6cdeed3..be07b63 100644
--- a/parser/v2/raw.go
+++ b/parser/v2/raw.go
@@ -10,10 +10,6 @@ var styleElement = rawElementParser{
 	name: "style",
 }
 
-var scriptElement = rawElementParser{
-	name: "script",
-}
-
 type rawElementParser struct {
 	name string
 }
diff --git a/parser/v2/scriptparser.go b/parser/v2/scriptparser.go
new file mode 100644
index 0000000..d37618d
--- /dev/null
+++ b/parser/v2/scriptparser.go
@@ -0,0 +1,142 @@
+package parser
+
+import (
+	"strings"
+
+	"github.com/a-h/parse"
+)
+
+var scriptElement = scriptElementParser{}
+
+type scriptElementParser struct{}
+
+func (p scriptElementParser) Parse(pi *parse.Input) (n Node, ok bool, err error) {
+	start := pi.Index()
+
+	// <
+	if _, ok, err = lt.Parse(pi); err != nil || !ok {
+		return
+	}
+
+	// Element name.
+	var e ScriptElement
+	var name string
+	if name, ok, err = elementNameParser.Parse(pi); err != nil || !ok {
+		pi.Seek(start)
+		return
+	}
+
+	if name != "script" {
+		pi.Seek(start)
+		ok = false
+		return
+	}
+
+	if e.Attributes, ok, err = (attributesParser{}).Parse(pi); err != nil || !ok {
+		pi.Seek(start)
+		return
+	}
+
+	// Optional whitespace.
+	if _, _, err = parse.OptionalWhitespace.Parse(pi); err != nil {
+		pi.Seek(start)
+		return
+	}
+
+	// >
+	if _, ok, err = gt.Parse(pi); err != nil || !ok {
+		pi.Seek(start)
+		return
+	}
+
+	// Parse the contents, we should get script text or Go expressions up until the closing tag.
+	var sb strings.Builder
+	var isInsideStringLiteral bool
+loop:
+	for {
+		// Read and decide whether we're we've hit a:
+		//  - {{ - Start of a Go expression, read the contents with the `goCode` function.
+		//  - </script> - End of the script, break out of the loop.
+		//  - ' - Start of a single quoted string.
+		//  - " - Start of a double quoted string.
+		//  - ` - Start of a backtick quoted string.
+		//  - // - Start of a single line comment - can read to the end of the line without parsing.
+		//  - /* - Start of a multi-line comment - can read to the end of the comment without parsing.
+		//  - \ - Start of an escape sequence, we can just take the value.
+		//  - Anything else - Add it to the script.
+
+		if _, ok, err = jsEndTag.Parse(pi); err != nil || ok {
+			// We've reached the end of the script.
+			break loop
+		}
+
+		var code Node
+		code, ok, err = goCodeInJavaScript.Parse(pi)
+		if err != nil {
+			return nil, false, err
+		}
+		if ok {
+			e.Contents = append(e.Contents, NewScriptContentsGo(code.(GoCode), isInsideStringLiteral))
+			continue loop
+		}
+
+		// Try for a comment.
+		var comment string
+		comment, ok, err = jsComment.Parse(pi)
+		if err != nil {
+			return nil, false, err
+		}
+		if ok {
+			e.Contents = append(e.Contents, NewScriptContentsJS(comment))
+			continue loop
+		}
+
+		for {
+			before := pi.Index()
+			var c string
+			c, ok, err := jsCharacter.Parse(pi)
+			if err != nil {
+				return nil, false, err
+			}
+			if ok {
+				_, isEOF, _ := parse.EOF[string]().Parse(pi)
+				if c == `"` || c == "'" || c == "`" {
+					isInsideStringLiteral = !isInsideStringLiteral
+				}
+				peeked, _ := pi.Peek(1)
+				peeked = c + peeked
+				if isEOF || peeked == "{{" || peeked == "</" || peeked == "//" || peeked == "/*" {
+					if sb.Len() > 0 {
+						e.Contents = append(e.Contents, NewScriptContentsJS(sb.String()))
+						sb.Reset()
+					}
+					if isEOF {
+						break loop
+					}
+					pi.Seek(before)
+					continue loop
+				}
+				sb.WriteString(c)
+			}
+			if _, ok, _ = parse.EOF[string]().Parse(pi); ok {
+				return nil, false, parse.Error("script: unclosed <script> element", pi.Position())
+			}
+		}
+	}
+
+	return e, true, nil
+}
+
+var jsEndTag = parse.String("</script>")
+
+var jsCharacter = parse.Any(jsEscapedCharacter, parse.AnyRune)
+
+var jsEscapedCharacter = parse.StringFrom(parse.String("\\"), parse.AnyRune)
+
+var jsComment = parse.Any(jsSingleLineComment, jsMultiLineComment)
+
+var jsStartSingleLineComment = parse.String("//")
+var jsSingleLineComment = parse.StringFrom(jsStartSingleLineComment, parse.StringUntil(parse.NewLine), parse.NewLine)
+
+var jsStartMultiLineComment = parse.String("/*")
+var jsMultiLineComment = parse.StringFrom(jsStartMultiLineComment, parse.StringUntil(parse.String("*/")), parse.String("*/"), parse.OptionalWhitespace)
diff --git a/parser/v2/types.go b/parser/v2/types.go
index e4d47bc..2e8f1fa 100644
--- a/parser/v2/types.go
+++ b/parser/v2/types.go
@@ -492,10 +492,10 @@ func (e Element) IsBlockElement() bool {
 
 // Validate that no invalid expressions have been used.
 func (e Element) Validate() (msgs []string, ok bool) {
-	// Validate that script and style tags don't contain expressions.
-	if strings.EqualFold(e.Name, "script") || strings.EqualFold(e.Name, "style") {
+	// Validate that style tags don't contain expressions.
+	if strings.EqualFold(e.Name, "style") {
 		if containsNonTextNodes(e.Children) {
-			msgs = append(msgs, "invalid node contents: script and style attributes must only contain text")
+			msgs = append(msgs, "invalid node contents: style elements must only contain text")
 		}
 	}
 	return msgs, len(msgs) == 0
@@ -658,6 +658,89 @@ func isBlockNode(node Node) bool {
 	return false
 }
 
+func NewScriptContentsJS(value string) ScriptContents {
+	return ScriptContents{
+		Value: &value,
+	}
+}
+
+func NewScriptContentsGo(code GoCode, insideStringLiteral bool) ScriptContents {
+	return ScriptContents{
+		GoCode:              &code,
+		InsideStringLiteral: insideStringLiteral,
+	}
+}
+
+type ScriptContents struct {
+	// Value is the raw script contents. This is nil if the Type is Go.
+	Value *string
+	// GoCode is the Go expression. This is nil if the Type is JS.
+	GoCode *GoCode
+	// InsideStringLiteral denotes how the result of any Go expression should be escaped in the output.
+	//  - Not quoted: JSON encoded.
+	//  - InsideStringLiteral: JS escaped (newlines become \n, `"' becomes \`\"\' etc.), HTML escaped so that a string can't contain </script>.
+	InsideStringLiteral bool
+}
+
+type ScriptElement struct {
+	Attributes []Attribute
+	Contents   []ScriptContents
+}
+
+func (se ScriptElement) IsNode() bool { return true }
+func (se ScriptElement) Write(w io.Writer, indent int) error {
+	// Start.
+	if err := writeIndent(w, indent, "<script"); err != nil {
+		return err
+	}
+	for i := 0; i < len(se.Attributes); i++ {
+		if _, err := w.Write([]byte(" ")); err != nil {
+			return err
+		}
+		a := se.Attributes[i]
+		// Don't indent the attributes, only the conditional attributes get indented.
+		if err := a.Write(w, 0); err != nil {
+			return err
+		}
+	}
+	if _, err := w.Write([]byte(">")); err != nil {
+		return err
+	}
+	// Contents.
+	for _, c := range se.Contents {
+		if c.Value != nil {
+			if err := writeStrings(w, *c.Value); err != nil {
+				return err
+			}
+			continue
+		}
+		// Write the expression.
+		if c.GoCode == nil {
+			return errors.New("script contents expression is nil")
+		}
+		if isWhitespace(c.GoCode.Expression.Value) {
+			c.GoCode.Expression.Value = ""
+		}
+		if err := writeStrings(w, `{{ `, c.GoCode.Expression.Value, ` }}`, string(c.GoCode.TrailingSpace)); err != nil {
+			return err
+		}
+	}
+	// Close.
+	if _, err := w.Write([]byte("</script>")); err != nil {
+		return err
+	}
+	return nil
+}
+
+func writeStrings(w io.Writer, ss ...string) error {
+	for _, s := range ss {
+		if _, err := io.WriteString(w, s); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 type RawElement struct {
 	Name       string
 	Attributes []Attribute
diff --git a/runtime/fuzzing/fuzz.templ b/runtime/fuzzing/fuzz.templ
new file mode 100644
index 0000000..282a50a
--- /dev/null
+++ b/runtime/fuzzing/fuzz.templ
@@ -0,0 +1,19 @@
+package fuzzing
+
+templ String(v any) {
+	<script>
+		function logValue() {
+			console.log("{{ v }}")
+			return "result_ok"
+		}
+	</script>
+}
+
+templ Any(v any) {
+	<script>
+		function logValue() {
+			console.log({{ v }})
+			return "result_ok"
+		}
+	</script>
+}
diff --git a/runtime/fuzzing/fuzz_templ.go b/runtime/fuzzing/fuzz_templ.go
new file mode 100644
index 0000000..4619b1a
--- /dev/null
+++ b/runtime/fuzzing/fuzz_templ.go
@@ -0,0 +1,92 @@
+// Code generated by templ - DO NOT EDIT.
+
+package fuzzing
+
+//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 String(v any) 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 = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<script>\n\t\tfunction logValue() {\n\t\t\tconsole.log(\"")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Var2, templ_7745c5c3_Err := templruntime.ScriptContentInsideStringLiteral(v)
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `runtime/fuzzing/fuzz.templ`, Line: 6, Col: 20}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\")\n\t\t\treturn \"result_ok\"\n\t\t}\n\t</script>")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		return nil
+	})
+}
+
+func Any(v any) 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_Var3 := templ.GetChildren(ctx)
+		if templ_7745c5c3_Var3 == nil {
+			templ_7745c5c3_Var3 = templ.NopComponent
+		}
+		ctx = templ.ClearChildren(ctx)
+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<script>\n\t\tfunction logValue() {\n\t\t\tconsole.log(")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Var4, templ_7745c5c3_Err := templruntime.ScriptContentOutsideStringLiteral(v)
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `runtime/fuzzing/fuzz.templ`, Line: 15, Col: 19}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, ")\n\t\t\treturn \"result_ok\"\n\t\t}\n\t</script>")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		return nil
+	})
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/runtime/fuzzing/go.mod b/runtime/fuzzing/go.mod
new file mode 100644
index 0000000..d6c102f
--- /dev/null
+++ b/runtime/fuzzing/go.mod
@@ -0,0 +1,14 @@
+module github.com/a-h/templ/runtime/fuzzing
+
+go 1.23.3
+
+require (
+	github.com/a-h/templ v0.3.833
+	golang.org/x/net v0.34.0
+)
+
+require rogchap.com/v8go v0.9.0
+
+require github.com/brianvoe/gofakeit/v7 v7.2.1 // indirect
+
+replace github.com/a-h/templ => ../..
diff --git a/runtime/fuzzing/go.sum b/runtime/fuzzing/go.sum
new file mode 100644
index 0000000..eb72aa8
--- /dev/null
+++ b/runtime/fuzzing/go.sum
@@ -0,0 +1,8 @@
+github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
+github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk=
+github.com/brianvoe/gofakeit/v7 v7.2.1 h1:AGojgaaCdgq4Adzrd2uWdbGNDyX6MWNhHdQBraNfOHI=
+github.com/brianvoe/gofakeit/v7 v7.2.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
+golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
+golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
+rogchap.com/v8go v0.9.0 h1:wYbUCO4h6fjTamziHrzyrPnpFNuzPpjZY+nfmZjNaew=
+rogchap.com/v8go v0.9.0/go.mod h1:MxgP3pL2MW4dpme/72QRs8sgNMmM0pRc8DPhcuLWPAs=
diff --git a/runtime/scriptelement.go b/runtime/scriptelement.go
new file mode 100644
index 0000000..a742e93
--- /dev/null
+++ b/runtime/scriptelement.go
@@ -0,0 +1,107 @@
+package runtime
+
+import (
+	"encoding/json"
+	"errors"
+	"strings"
+	"unicode/utf8"
+)
+
+func ScriptContentInsideStringLiteral[T any](v T, errs ...error) (string, error) {
+	return scriptContent(v, true, errs...)
+}
+
+func ScriptContentOutsideStringLiteral[T any](v T, errs ...error) (string, error) {
+	return scriptContent(v, false, errs...)
+}
+
+func scriptContent[T any](v T, insideStringLiteral bool, errs ...error) (string, error) {
+	if errors.Join(errs...) != nil {
+		return "", errors.Join(errs...)
+	}
+	if vs, ok := any(v).(string); ok && insideStringLiteral {
+		return replace(vs, jsStrReplacementTable), nil
+	}
+	jd, err := json.Marshal(v)
+	if err != nil {
+		return "", err
+	}
+	if insideStringLiteral {
+		return replace(string(jd), jsStrReplacementTable), nil
+	}
+	return string(jd), nil
+}
+
+// See https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/html/template/js.go
+
+// replace replaces each rune r of s with replacementTable[r], provided that
+// r < len(replacementTable). If replacementTable[r] is the empty string then
+// no replacement is made.
+// It also replaces runes U+2028 and U+2029 with the raw strings `\u2028` and
+// `\u2029`.
+func replace(s string, replacementTable []string) string {
+	var b strings.Builder
+	r, w, written := rune(0), 0, 0
+	for i := 0; i < len(s); i += w {
+		// See comment in htmlEscaper.
+		r, w = utf8.DecodeRuneInString(s[i:])
+		var repl string
+		switch {
+		case int(r) < len(lowUnicodeReplacementTable):
+			repl = lowUnicodeReplacementTable[r]
+		case int(r) < len(replacementTable) && replacementTable[r] != "":
+			repl = replacementTable[r]
+		case r == '\u2028':
+			repl = `\u2028`
+		case r == '\u2029':
+			repl = `\u2029`
+		default:
+			continue
+		}
+		if written == 0 {
+			b.Grow(len(s))
+		}
+		b.WriteString(s[written:i])
+		b.WriteString(repl)
+		written = i + w
+	}
+	if written == 0 {
+		return s
+	}
+	b.WriteString(s[written:])
+	return b.String()
+}
+
+var lowUnicodeReplacementTable = []string{
+	0: `\u0000`, 1: `\u0001`, 2: `\u0002`, 3: `\u0003`, 4: `\u0004`, 5: `\u0005`, 6: `\u0006`,
+	'\a': `\u0007`,
+	'\b': `\u0008`,
+	'\t': `\t`,
+	'\n': `\n`,
+	'\v': `\u000b`, // "\v" == "v" on IE 6.
+	'\f': `\f`,
+	'\r': `\r`,
+	0xe:  `\u000e`, 0xf: `\u000f`, 0x10: `\u0010`, 0x11: `\u0011`, 0x12: `\u0012`, 0x13: `\u0013`,
+	0x14: `\u0014`, 0x15: `\u0015`, 0x16: `\u0016`, 0x17: `\u0017`, 0x18: `\u0018`, 0x19: `\u0019`,
+	0x1a: `\u001a`, 0x1b: `\u001b`, 0x1c: `\u001c`, 0x1d: `\u001d`, 0x1e: `\u001e`, 0x1f: `\u001f`,
+}
+
+var jsStrReplacementTable = []string{
+	0:    `\u0000`,
+	'\t': `\t`,
+	'\n': `\n`,
+	'\v': `\u000b`, // "\v" == "v" on IE 6.
+	'\f': `\f`,
+	'\r': `\r`,
+	// Encode HTML specials as hex so the output can be embedded
+	// in HTML attributes without further encoding.
+	'"':  `\u0022`,
+	'`':  `\u0060`,
+	'&':  `\u0026`,
+	'\'': `\u0027`,
+	'+':  `\u002b`,
+	'/':  `\/`,
+	'<':  `\u003c`,
+	'>':  `\u003e`,
+	'\\': `\\`,
+}


Test patch:
diff --git a/generator/test-complex-attributes/expected.html b/generator/test-complex-attributes/expected.html
index e78916e..2a2be3d 100644
--- a/generator/test-complex-attributes/expected.html
+++ b/generator/test-complex-attributes/expected.html
@@ -1,18 +1,17 @@
 <div
-	x-data="{darkMode: localStorage.getItem('darkMode') || localStorage.setItem('darkMode', 'system')}"
-	x-init="$watch('darkMode', val => localStorage.setItem('darkMode', val))"
-	:class="{'dark': darkMode === 'dark' || (darkMode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)}"
->
-</div>
+  x-data="{darkMode: localStorage.getItem('darkMode') || localStorage.setItem('darkMode', 'system')}"
+  x-init="$watch('darkMode', val => localStorage.setItem('darkMode', val))"
+  :class="{'dark': darkMode === 'dark' || (darkMode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)}"
+></div>
 <div x-data="{ count: 0 }">
-	<button
-		x-on:click="count++"
-	>Increment</button>
-	<span x-text="count"></span>
+  <button
+    x-on:click="count++"
+  >Increment</button>
+  <span x-text="count"></span>
 </div>
 <div x-data="{ count: 0 }">
-	<button
-		@click="count++"
-	>Increment</button>
-	<span x-text="count"></span>
+  <button
+    @click="count++"
+  >Increment</button>
+  <span x-text="count"></span>
 </div>
diff --git a/generator/test-element-attributes/expected.html b/generator/test-element-attributes/expected.html
index 03680c7..c821a8b 100644
--- a/generator/test-element-attributes/expected.html
+++ b/generator/test-element-attributes/expected.html
@@ -15,8 +15,7 @@
  </div>
 <div data-script="on click
                 do something
-             end">
-</div>
+             end"></div>
 
 <h2>HTMX Wildcard attribute</h2>
 
diff --git a/generator/test-script-expressions/expected.html b/generator/test-script-expressions/expected.html
new file mode 100644
index 0000000..767a7be
--- /dev/null
+++ b/generator/test-script-expressions/expected.html
@@ -0,0 +1,70 @@
+<h1>string data</h1>
+<script>
+		var a = "hello"
+		var b = "hello"
+		var c = 'hello'
+		var d = `hello`
+</script>
+<script>
+		console.log("hello")
+</script>
+<h1>string data with quotes</h1>
+<script>
+		var a = "hello 'world'"
+		var b = "hello \u0027world\u0027"
+		var c = 'hello \u0027world\u0027'
+		var d = `hello \u0027world\u0027`
+</script>
+<script>
+		console.log("hello 'world'")
+</script>
+<h1>numeric data</h1>
+<script>
+		var a = 123
+		var b = "123"
+		var c = '123'
+		var d = `123`
+</script>
+<script>
+		console.log(123)
+</script>
+<h1>boolean data</h1>
+<script>
+		var a = true
+		var b = "true"
+		var c = 'true'
+		var d = `true`
+</script>
+<script>
+		console.log(true)
+</script>
+<h1>array data</h1>
+<script>
+		var a = [1,2,3]
+		var b = "[1,2,3]"
+		var c = '[1,2,3]'
+		var d = `[1,2,3]`
+</script>
+<script>
+		console.log([1,2,3])
+</script>
+<h1>object data</h1>
+<script>
+		var a = {"Name":"Alice","Age":30}
+		var b = "{\u0022Name\u0022:\u0022Alice\u0022,\u0022Age\u0022:30}"
+		var c = '{\u0022Name\u0022:\u0022Alice\u0022,\u0022Age\u0022:30}'
+		var d = `{\u0022Name\u0022:\u0022Alice\u0022,\u0022Age\u0022:30}`
+</script>
+<script>
+		console.log({"Name":"Alice","Age":30})
+</script>
+<h1>null data</h1>
+<script>
+		var a = null
+		var b = "null"
+		var c = 'null'
+		var d = `null`
+</script>
+<script>
+		console.log(null)
+</script>
diff --git a/generator/test-script-expressions/render_test.go b/generator/test-script-expressions/render_test.go
new file mode 100644
index 0000000..e699a7a
--- /dev/null
+++ b/generator/test-script-expressions/render_test.go
@@ -0,0 +1,23 @@
+package testscriptexpressions
+
+import (
+	_ "embed"
+	"testing"
+
+	"github.com/a-h/templ/generator/htmldiff"
+)
+
+//go:embed expected.html
+var expected string
+
+func Test(t *testing.T) {
+	component := AllTests()
+
+	diff, err := htmldiff.Diff(component, expected)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if diff != "" {
+		t.Error(diff)
+	}
+}
diff --git a/generator/test-script-expressions/template.templ b/generator/test-script-expressions/template.templ
new file mode 100644
index 0000000..22862c2
--- /dev/null
+++ b/generator/test-script-expressions/template.templ
@@ -0,0 +1,27 @@
+package testscriptexpressions
+
+templ Script[T any](name string, data T) {
+	<h1>{ name }</h1>
+	<script>
+		var a = {{ data }}
+		var b = "{{ data }}"
+		var c = '{{ data }}'
+		var d = `{{ data }}`
+	</script>
+	<script>
+		console.log({{ data }})
+	</script>
+}
+
+templ AllTests() {
+	@Script("string data", "hello")
+	@Script("string data with quotes", "hello 'world'")
+	@Script("numeric data", 123)
+	@Script("boolean data", true)
+	@Script("array data", []int{1, 2, 3})
+	@Script("object data", struct {
+		Name string
+		Age  int
+	}{"Alice", 30})
+	@Script[*string]("null data", nil)
+}
diff --git a/generator/test-script-expressions/template_templ.go b/generator/test-script-expressions/template_templ.go
new file mode 100644
index 0000000..60cea56
--- /dev/null
+++ b/generator/test-script-expressions/template_templ.go
@@ -0,0 +1,168 @@
+// Code generated by templ - DO NOT EDIT.
+
+package testscriptexpressions
+
+//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 Script[T any](name string, data T) 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 = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1>")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		var templ_7745c5c3_Var2 string
+		templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(name)
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `generator/test-script-expressions/template.templ`, Line: 4, Col: 11}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</h1><script>\n\t\tvar a = ")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Var3, templ_7745c5c3_Err := templruntime.ScriptContentOutsideStringLiteral(data)
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `generator/test-script-expressions/template.templ`, Line: 6, Col: 17}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\n\t\tvar b = \"")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Var4, templ_7745c5c3_Err := templruntime.ScriptContentInsideStringLiteral(data)
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `generator/test-script-expressions/template.templ`, Line: 7, Col: 18}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\"\n\t\tvar c = '")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Var5, templ_7745c5c3_Err := templruntime.ScriptContentInsideStringLiteral(data)
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `generator/test-script-expressions/template.templ`, Line: 8, Col: 18}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "'\n\t\tvar d = `")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Var6, templ_7745c5c3_Err := templruntime.ScriptContentInsideStringLiteral(data)
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `generator/test-script-expressions/template.templ`, Line: 9, Col: 18}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "`\n\t</script><script>\n\t\tconsole.log(")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Var7, templ_7745c5c3_Err := templruntime.ScriptContentOutsideStringLiteral(data)
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `generator/test-script-expressions/template.templ`, Line: 12, Col: 21}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, ")\n\t</script>")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		return nil
+	})
+}
+
+func AllTests() 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_Var8 := templ.GetChildren(ctx)
+		if templ_7745c5c3_Var8 == nil {
+			templ_7745c5c3_Var8 = templ.NopComponent
+		}
+		ctx = templ.ClearChildren(ctx)
+		templ_7745c5c3_Err = Script("string data", "hello").Render(ctx, templ_7745c5c3_Buffer)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = Script("string data with quotes", "hello 'world'").Render(ctx, templ_7745c5c3_Buffer)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = Script("numeric data", 123).Render(ctx, templ_7745c5c3_Buffer)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = Script("boolean data", true).Render(ctx, templ_7745c5c3_Buffer)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = Script("array data", []int{1, 2, 3}).Render(ctx, templ_7745c5c3_Buffer)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = Script("object data", struct {
+			Name string
+			Age  int
+		}{"Alice", 30}).Render(ctx, templ_7745c5c3_Buffer)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = Script[*string]("null data", nil).Render(ctx, templ_7745c5c3_Buffer)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		return nil
+	})
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/parser/v2/elementparser_test.go b/parser/v2/elementparser_test.go
index 13e37dc..24c4058 100644
--- a/parser/v2/elementparser_test.go
+++ b/parser/v2/elementparser_test.go
@@ -1691,27 +1691,7 @@ func TestElementParserErrors(t *testing.T) {
 		{
 			name:  "element: style must only contain text",
 			input: `<style><button /></style>`,
-			expected: parse.Error("<style>: invalid node contents: script and style attributes must only contain text",
-				parse.Position{
-					Index: 0,
-					Line:  0,
-					Col:   0,
-				}),
-		},
-		{
-			name:  "element: script must only contain text",
-			input: `<script><button /></script>`,
-			expected: parse.Error("<script>: invalid node contents: script and style attributes must only contain text",
-				parse.Position{
-					Index: 0,
-					Line:  0,
-					Col:   0,
-				}),
-		},
-		{
-			name:  "element: script tags cannot contain non-text nodes",
-			input: `<script>{ "value" }</script>`,
-			expected: parse.Error("<script>: invalid node contents: script and style attributes must only contain text",
+			expected: parse.Error("<style>: invalid node contents: style elements must only contain text",
 				parse.Position{
 					Index: 0,
 					Line:  0,
@@ -1721,7 +1701,7 @@ func TestElementParserErrors(t *testing.T) {
 		{
 			name:  "element: style tags cannot contain non-text nodes",
 			input: `<style>{ "value" }</style>`,
-			expected: parse.Error("<style>: invalid node contents: script and style attributes must only contain text",
+			expected: parse.Error("<style>: invalid node contents: style elements must only contain text",
 				parse.Position{
 					Index: 0,
 					Line:  0,
diff --git a/parser/v2/raw_test.go b/parser/v2/raw_test.go
index 26de328..7b9e1f8 100644
--- a/parser/v2/raw_test.go
+++ b/parser/v2/raw_test.go
@@ -54,24 +54,6 @@ func TestRawElementParser(t *testing.T) {
 				Contents: ignoredContent,
 			},
 		},
-		{
-			name:  "script tag",
-			input: `<script type="vbscript">dim x = 1</script>`,
-			expected: RawElement{
-				Name: "script",
-				Attributes: []Attribute{
-					ConstantAttribute{
-						Name:  "type",
-						Value: "vbscript",
-						NameRange: Range{
-							From: Position{Index: 8, Line: 0, Col: 8},
-							To:   Position{Index: 12, Line: 0, Col: 12},
-						},
-					},
-				},
-				Contents: "dim x = 1",
-			},
-		},
 	}
 	for _, tt := range tests {
 		tt := tt
diff --git a/parser/v2/scriptparser_test.go b/parser/v2/scriptparser_test.go
new file mode 100644
index 0000000..2082589
--- /dev/null
+++ b/parser/v2/scriptparser_test.go
@@ -0,0 +1,200 @@
+package parser
+
+import (
+	"testing"
+
+	"github.com/a-h/parse"
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestScriptElementParser(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		expected ScriptElement
+	}{
+		{
+			name:     "script: no content",
+			input:    `<script></script>`,
+			expected: ScriptElement{},
+		},
+		{
+			name:  "script: vbscript",
+			input: `<script type="vbscript">dim x = 1</script>`,
+			expected: ScriptElement{
+				Attributes: []Attribute{
+					ConstantAttribute{
+						Name:  "type",
+						Value: "vbscript",
+						NameRange: Range{
+							From: Position{Index: 8, Line: 0, Col: 8},
+							To:   Position{Index: 12, Line: 0, Col: 12},
+						},
+					},
+				},
+				Contents: []ScriptContents{
+					NewScriptContentsJS("dim x = 1"),
+				},
+			},
+		},
+		{
+			name:  "script: go expression",
+			input: `<script>{{ name }}</script>`,
+			expected: ScriptElement{
+				Contents: []ScriptContents{
+					NewScriptContentsGo(GoCode{
+						Expression: Expression{
+							Value: "name",
+							Range: Range{
+								From: Position{Index: 11, Line: 0, Col: 11},
+								To:   Position{Index: 15, Line: 0, Col: 15},
+							},
+						},
+					}, false),
+				},
+			},
+		},
+		{
+			name: "script: go expression - multiline 1",
+			input: `<script>
+{{ name }}
+</script>`,
+			expected: ScriptElement{
+				Contents: []ScriptContents{
+					NewScriptContentsJS("\n"),
+					NewScriptContentsGo(GoCode{
+						Expression: Expression{
+							Value: "name",
+							Range: Range{
+								From: Position{Index: 12, Line: 1, Col: 3},
+								To:   Position{Index: 16, Line: 1, Col: 7},
+							},
+						},
+						TrailingSpace: SpaceVertical,
+					}, false),
+				},
+			},
+		},
+		{
+			name:  "script: go expression in single quoted string",
+			input: `<script>var x = '{{ name }}';</script>`,
+			expected: ScriptElement{
+				Contents: []ScriptContents{
+					NewScriptContentsJS("var x = '"),
+					NewScriptContentsGo(GoCode{
+						Expression: Expression{
+							Value: "name",
+							Range: Range{
+								From: Position{Index: 20, Line: 0, Col: 20},
+								To:   Position{Index: 24, Line: 0, Col: 24},
+							},
+						},
+					}, true),
+					NewScriptContentsJS("';"),
+				},
+			},
+		},
+		{
+			name:  "script: go expression in double quoted string",
+			input: `<script>var x = "{{ name }}";</script>`,
+			expected: ScriptElement{
+				Contents: []ScriptContents{
+					NewScriptContentsJS("var x = \""),
+					NewScriptContentsGo(GoCode{
+						Expression: Expression{
+							Value: "name",
+							Range: Range{
+								From: Position{Index: 20, Line: 0, Col: 20},
+								To:   Position{Index: 24, Line: 0, Col: 24},
+							},
+						},
+					}, true),
+					NewScriptContentsJS("\";"),
+				},
+			},
+		},
+		{
+			name: "script: go expression in double quoted multiline string",
+			input: `<script>var x = "This is a test \
+{{ name }} \
+to see if it works";</script>`,
+			expected: ScriptElement{
+				Contents: []ScriptContents{
+					NewScriptContentsJS("var x = \"This is a test \\\n"),
+					NewScriptContentsGo(GoCode{
+						Expression: Expression{
+							Value: "name",
+							Range: Range{
+								From: Position{Index: 37, Line: 1, Col: 3},
+								To:   Position{Index: 41, Line: 1, Col: 7},
+							},
+						},
+						TrailingSpace: SpaceHorizontal,
+					}, true),
+					NewScriptContentsJS("\\\nto see if it works\";"),
+				},
+			},
+		},
+		{
+			name:  "script: go expression in backtick quoted string",
+			input: `<script>var x = ` + "`" + "{{ name }}" + "`" + `;</script>`,
+			expected: ScriptElement{
+				Contents: []ScriptContents{
+					NewScriptContentsJS("var x = `"),
+					NewScriptContentsGo(GoCode{
+						Expression: Expression{
+							Value: "name",
+							Range: Range{
+								From: Position{Index: 20, Line: 0, Col: 20},
+								To:   Position{Index: 24, Line: 0, Col: 24},
+							},
+						},
+					}, true),
+					NewScriptContentsJS("`;"),
+				},
+			},
+		},
+		{
+			name: "script: single line commented out go expressions are ignored",
+			input: `<script>
+// {{ name }}
+</script>`,
+			expected: ScriptElement{
+				Contents: []ScriptContents{
+					NewScriptContentsJS("\n"),
+					NewScriptContentsJS("// {{ name }}\n"),
+				},
+			},
+		},
+		{
+			name: "script: multiline commented out go expressions are ignored",
+			input: `<script>
+/* There's some content
+{{ name }}
+but it's commented out */
+</script>`,
+			expected: ScriptElement{
+				Contents: []ScriptContents{
+					NewScriptContentsJS("\n"),
+					NewScriptContentsJS("/* There's some content\n{{ name }}\nbut it's commented out */\n"),
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			input := parse.NewInput(tt.input)
+			result, ok, err := scriptElement.Parse(input)
+			if err != nil {
+				t.Fatalf("parser error: %v", err)
+			}
+			if !ok {
+				t.Fatalf("failed to parse at %d", input.Index())
+			}
+			if diff := cmp.Diff(tt.expected, result); diff != "" {
+				t.Error(diff)
+			}
+		})
+	}
+}
diff --git a/runtime/fuzzing/fuzz_test.go b/runtime/fuzzing/fuzz_test.go
new file mode 100644
index 0000000..33b0128
--- /dev/null
+++ b/runtime/fuzzing/fuzz_test.go
@@ -0,0 +1,121 @@
+package fuzzing
+
+import (
+	"context"
+	"encoding/json"
+	"strings"
+	"testing"
+
+	"golang.org/x/net/html"
+
+	v8 "rogchap.com/v8go"
+)
+
+var iso = v8.NewIsolate()
+
+var testcases = []string{
+	"hello",
+	"<script>console.log('hello')</script>",
+	"text data\n can contain all sorts of \"characters\" & symbols",
+	"123",
+	"</script>",
+}
+
+func FuzzComponentString(f *testing.F) {
+	for _, tc := range testcases {
+		f.Add([]byte(tc))
+	}
+	f.Fuzz(func(t *testing.T, v []byte) {
+		// Render template.
+		buf := new(strings.Builder)
+
+		values := []any{
+			string(v),
+			[]string{string(v)},
+			map[string]string{"value": string(v)},
+		}
+		for _, value := range values {
+			buf.Reset()
+			if err := String(value).Render(context.Background(), buf); err != nil {
+				t.Skip(err)
+			}
+			runTest(t, buf.String())
+		}
+	})
+}
+
+func FuzzComponentAny(f *testing.F) {
+	for _, tc := range testcases {
+		jsonValue, err := json.Marshal(tc)
+		if err != nil {
+			panic(err)
+		}
+		f.Add(jsonValue)
+	}
+	f.Fuzz(func(t *testing.T, v []byte) {
+		// Render template.
+		buf := new(strings.Builder)
+
+		values := []any{
+			string(v),
+			[]string{string(v)},
+			map[string]string{"value": string(v)},
+		}
+		for _, value := range values {
+			buf.Reset()
+			if err := Any(value).Render(context.Background(), buf); err != nil {
+				t.Skip(err)
+			}
+			runTest(t, buf.String())
+		}
+	})
+}
+
+func getFirstScript(n *html.Node) *html.Node {
+	if n.Data == "script" {
+		return n
+	}
+	for c := n.FirstChild; c != nil; c = c.NextSibling {
+		if s := getFirstScript(c); s != nil {
+			return s
+		}
+	}
+	return nil
+}
+
+func runTest(t *testing.T, templateOutput string) {
+	// Parse HTML.
+	n, err := html.Parse(strings.NewReader(templateOutput))
+	if err != nil {
+		t.Fatalf("failed to parse HTML: %v", err)
+	}
+	sn := getFirstScript(n)
+	if sn == nil {
+		t.Fatalf("no script tag found")
+	}
+
+	// Extract JavaScript.
+	script := sn.FirstChild.Data
+
+	// Run JavaScript.
+	v8ctx := v8.NewContext(iso)
+	if _, err = v8ctx.RunScript(script, "component.js"); err != nil {
+		t.Fatalf("failed to parse script: %v", err)
+	}
+	if _, err = v8ctx.RunScript("const result = logValue()", "component.js"); err != nil {
+		t.Fatalf("failed to get value: %v", err)
+	}
+	actual, err := v8ctx.RunScript("result", "component.js")
+	if err != nil {
+		t.Fatalf("failed to get result: %v", err)
+	}
+	defer v8ctx.Close()
+
+	// Assert.
+	if !actual.IsString() {
+		t.Fatalf("expected boolean, got %T", actual.Object().Value)
+	}
+	if actual.String() != "result_ok" {
+		t.Fatalf("expected 'result_ok', got %v", actual.Boolean())
+	}
+}
diff --git a/runtime/fuzzing/testdata/fuzz/FuzzComponentAny/02bc261247f1267d b/runtime/fuzzing/testdata/fuzz/FuzzComponentAny/02bc261247f1267d
new file mode 100644
index 0000000..f2ed949
--- /dev/null
+++ b/runtime/fuzzing/testdata/fuzz/FuzzComponentAny/02bc261247f1267d
@@ -0,0 +1,2 @@
+go test fuzz v1
+[]byte("{\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxaluex")
diff --git a/runtime/fuzzing/testdata/fuzz/FuzzComponentAny/0e3d2540388fc8bd b/runtime/fuzzing/testdata/fuzz/FuzzComponentAny/0e3d2540388fc8bd
new file mode 100644
index 0000000..8dd6cc1
--- /dev/null
+++ b/runtime/fuzzing/testdata/fuzz/FuzzComponentAny/0e3d2540388fc8bd
@@ -0,0 +1,2 @@
+go test fuzz v1
+[]byte("{\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxaluex")
diff --git a/runtime/fuzzing/testdata/fuzz/FuzzComponentAny/0ed510998a1c1a4e b/runtime/fuzzing/testdata/fuzz/FuzzComponentAny/0ed510998a1c1a4e
new file mode 100644
index 0000000..175c8c7
--- /dev/null
+++ b/runtime/fuzzing/testdata/fuzz/FuzzComponentAny/0ed510998a1c1a4e
@@ -0,0 +1,2 @@
+go test fuzz v1
+[]byte("{\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxalue\"x")
diff --git a/runtime/fuzzing/testdata/fuzz/FuzzComponentAny/926b62a033ecc0fd b/runtime/fuzzing/testdata/fuzz/FuzzComponentAny/926b62a033ecc0fd
new file mode 100644
index 0000000..da3b1bf
--- /dev/null
+++ b/runtime/fuzzing/testdata/fuzz/FuzzComponentAny/926b62a033ecc0fd
@@ -0,0 +1,2 @@
+go test fuzz v1
+[]byte("000000000000000000000000000000000000000000000\x128\x06\xef\x01\xb40000000")
diff --git a/runtime/fuzzing/testdata/fuzz/FuzzComponentString/4a59bdc98ee75491 b/runtime/fuzzing/testdata/fuzz/FuzzComponentString/4a59bdc98ee75491
new file mode 100644
index 0000000..168fe58
--- /dev/null
+++ b/runtime/fuzzing/testdata/fuzz/FuzzComponentString/4a59bdc98ee75491
@@ -0,0 +1,2 @@
+go test fuzz v1
+[]byte("&&&&\"0&&&&&00&00&&&0&00&0&8&&&")
diff --git a/runtime/fuzzing/testdata/fuzz/FuzzComponentString/66658924a0ea89b6 b/runtime/fuzzing/testdata/fuzz/FuzzComponentString/66658924a0ea89b6
new file mode 100644
index 0000000..af947db
--- /dev/null
+++ b/runtime/fuzzing/testdata/fuzz/FuzzComponentString/66658924a0ea89b6
@@ -0,0 +1,2 @@
+go test fuzz v1
+[]byte("\"\xf5\x9a\xb7\"\x8a\"\"\x9a\x9a\xf5\x9a\xb7\"\x8a\x8a\"\"\"\"\"\"")
diff --git a/runtime/fuzzing/testdata/fuzz/FuzzComponentString/9fc8b4df9a42170c b/runtime/fuzzing/testdata/fuzz/FuzzComponentString/9fc8b4df9a42170c
new file mode 100644
index 0000000..b66bd2b
--- /dev/null
+++ b/runtime/fuzzing/testdata/fuzz/FuzzComponentString/9fc8b4df9a42170c
@@ -0,0 +1,2 @@
+go test fuzz v1
+[]byte("\x00\xa5\x05\x93ϵ\xa0\xa0\xfe\xee\xcf\x1e\xa7\xcb\xde\x13\x1c\xb9\xb7\xc2\xca^\xb1\xb3\xc0\x1a\x01\xbb\xae\x89\x83\x87\x89\xbb\x1d\xbd\xb7\x95\x8e\\\x91\xe6\x10\xe4\xdf\xec\xf0\xcd\xe6\a\xea/\xa2\xbb\xbd\xef\xf9\v\x87\xd5\xc0\xe6\xa4\xdc\x02\xf8\xfc\x9dأ\xc2\\\xc4\xf3\x87\x04\x01\x18\x16\xc3\xe1\xbc\xc3\xc9\x12\xd5\xc3\xf9\r\x13\x1a\xdbx\xb6\xe8\xe2&\xbb\x81'\xea'\xc56\xb7\xf3;\xb3\xdb\xed\x8c.\xb8\xd4\xcc\xfe\xbcإ\xf0\xb6\xf3\xf1\xdeF\xb8\x12\b\x9f\xb9\xa5\xb4&\xa1\xea\a\x17ɕ\x0e\xe9\xf9\xe0\xb6K\x93\xdd\xee<\xf6\xda\t\xac\xa4\xb9\xb9ƽ\x98\xa6\xeb\xc5\xfe\v\xb9\xdb\xc7\xc4\x05\v\xf5\xb4߫\xd2\xc6\xe7\x0f⨍\xb5\xb4\xc3\xcb\x17\xc1\xf9\x95\x13\xfc\xd4\x1a\xcfԈ\xda\x17\xaa\xd1'\b\xe9\t\xb4\xec\xc2ɹ\xe0\v\x14\xd4h\xa6\x9c\x82\xf0\x06\xd8?\x8a\xf0\xc5\xed\xd1\xd2\xdf\xfb\x02\xab\x84\xf2M\xb5ò\xe9\xc8\xf3\x1c\xae\xfe\xee\xe5쪢\xf6\xe9[\x9e\xcc\x13\x1d\xea\xb5\x1e\xe7<\xc7\xf7\xac\v\xba\x15\xcd\xdd\xe0\x1e\xf7\xf9\x94\x83\xcd\xc7\xe5\xad\xc1\xca?\xb9\xe7\x06\xb1\xd5\x12")
diff --git a/runtime/scriptelement_test.go b/runtime/scriptelement_test.go
new file mode 100644
index 0000000..6520f2f
--- /dev/null
+++ b/runtime/scriptelement_test.go
@@ -0,0 +1,122 @@
+package runtime
+
+import (
+	"errors"
+	"testing"
+)
+
+func TestScriptContent(t *testing.T) {
+	tests := []struct {
+		name                         string
+		input                        any
+		expectedInsideStringLiteral  string
+		expectedOutsideStringLiteral string
+	}{
+		{
+			name:                         "string",
+			input:                        "hello",
+			expectedInsideStringLiteral:  `hello`,
+			expectedOutsideStringLiteral: `"hello"`,
+		},
+		{
+			name:                         "string with single quotes",
+			input:                        "hello 'world'",
+			expectedInsideStringLiteral:  `hello \u0027world\u0027`,
+			expectedOutsideStringLiteral: `"hello 'world'"`,
+		},
+		{
+			name:                         "string with double quotes",
+			input:                        "hello \"world\"",
+			expectedInsideStringLiteral:  `hello \u0022world\u0022`,
+			expectedOutsideStringLiteral: `"hello \"world\""`,
+		},
+		{
+			name:                         "string with backticks",
+			input:                        "hello `world`",
+			expectedInsideStringLiteral:  `hello \u0060world\u0060`,
+			expectedOutsideStringLiteral: "\"hello `world`\"",
+		},
+		{
+			name:                         "int",
+			input:                        1,
+			expectedInsideStringLiteral:  `1`,
+			expectedOutsideStringLiteral: `1`,
+		},
+		{
+			name:                         "float",
+			input:                        1.1,
+			expectedInsideStringLiteral:  `1.1`,
+			expectedOutsideStringLiteral: `1.1`,
+		},
+		{
+			name:                         "bool",
+			input:                        true,
+			expectedInsideStringLiteral:  `true`,
+			expectedOutsideStringLiteral: `true`,
+		},
+		{
+			name:                         "array",
+			input:                        []int{1, 2, 3},
+			expectedInsideStringLiteral:  `[1,2,3]`,
+			expectedOutsideStringLiteral: `[1,2,3]`,
+		},
+		{
+			name:                         "object",
+			input:                        struct{ Name string }{"Alice"},
+			expectedInsideStringLiteral:  `{\u0022Name\u0022:\u0022Alice\u0022}`,
+			expectedOutsideStringLiteral: `{"Name":"Alice"}`,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			t.Run("inside string literal", func(t *testing.T) {
+				actualInsideStringLiteral, err := ScriptContentInsideStringLiteral(tt.input)
+				if err != nil {
+					t.Fatalf("unexpected error: %s", err)
+				}
+				if actualInsideStringLiteral != tt.expectedInsideStringLiteral {
+					t.Errorf("expected:\n%s\ngot:\n%s", tt.expectedInsideStringLiteral, actualInsideStringLiteral)
+				}
+			})
+			t.Run("outside string literal", func(t *testing.T) {
+				actualOutsideStringLiteral, err := ScriptContentOutsideStringLiteral(tt.input)
+				if err != nil {
+					t.Fatalf("unexpected error: %s", err)
+				}
+				if actualOutsideStringLiteral != tt.expectedOutsideStringLiteral {
+					t.Errorf("expected:\n%s\ngot:\n%s", tt.expectedOutsideStringLiteral, actualOutsideStringLiteral)
+				}
+			})
+		})
+	}
+}
+
+func TestScriptContentErrors(t *testing.T) {
+	t.Run("inside string literal", func(t *testing.T) {
+		_, err := ScriptContentInsideStringLiteral("s", errors.New("error"))
+		if err == nil {
+			t.Fatal("expected error")
+		}
+		if err.Error() != "error" {
+			t.Errorf("expected error: %s", err)
+		}
+	})
+	t.Run("outside string literal", func(t *testing.T) {
+		_, err := ScriptContentOutsideStringLiteral("s", errors.New("error"))
+		if err == nil {
+			t.Fatal("expected error")
+		}
+		if err.Error() != "error" {
+			t.Errorf("expected error: %s", err)
+		}
+	})
+	t.Run("unmarshal error", func(t *testing.T) {
+		_, err := ScriptContentInsideStringLiteral(func() string {
+			return "hello"
+		})
+		if err == nil {
+			t.Fatalf("expected unmarshal error, but got %v", err)
+		}
+	})
+}