Repository: a-h/templ (go)

Issue:
Script tags may not contain unmatched single quote (broken in v0.3.856)
**Describe the bug**
Both `templ generate` and templ LSP report "template closing brace not found" when a javscript string contains an unmatched single quote. 


**To Reproduce**
Build the following component with `templ generate`

```
package components

templ TestComponent() {
	<script>
	  function test() {
	   console.log("hello world won't work");
	  }
	</script>
}
```

**Expected behavior**
The component should generate

**Screenshots**

Screen recording in comment below.

**Logs**

(✗) Error [ error=failed to generate code for "/home/adriano/git/breyvo/components/test.templ": /home/adriano/git/breyvo/components/test.templ parsing error: template closing brace not found: line 10, col 0 ]
(✗) Command failed: generation completed with 1 errors

**`templ info` output**

(✓) os [ goos=linux goarch=amd64 ]
(✓) go [ location=/nix/store/kaajk63cgb8yrqvqvlmbss31b6nia854-go-1.23.5/bin/go version=go version go1.23.5 linux/amd64 ]
(✓) gopls [ location=/nix/store/8zzrbbpgbf6mpabqvg36xm61ala771h5-gopls-0.17.1/bin/gopls version=golang.org/x/tools/gopls v0.17.1 ]
(✓) templ [ location=/nix/store/598w114ivgg7k0vc7f89mm9x1m8r06x6-templ/bin/templ version=v0.3.856 ]

**Desktop (please complete the following information):**
 - OS: linux 
 - templ CLI version: v0.3.856
- Go version: go version go1.23.5 linux/amd64
- `gopls` verison: golang.org/x/tools/gopls v0.17.1

**Additional context**


Pull request description:
fix: allow unmatched quotes in script tags, fixes #1110

Patch:
diff --git a/.version b/.version
index 9147266..8b754c4 100644
--- a/.version
+++ b/.version
@@ -1,1 +1,1 @@
-0.3.856
\ No newline at end of file
+0.3.857
\ No newline at end of file
diff --git a/parser/v2/scriptparser.go b/parser/v2/scriptparser.go
index 3fe6644..5b39377 100644
--- a/parser/v2/scriptparser.go
+++ b/parser/v2/scriptparser.go
@@ -8,6 +8,15 @@ import (
 
 var scriptElement = scriptElementParser{}
 
+type jsQuote string
+
+const (
+	jsQuoteNone     jsQuote = ""
+	jsQuoteSingle   jsQuote = `'`
+	jsQuoteDouble   jsQuote = `"`
+	jsQuoteBacktick jsQuote = "`"
+)
+
 type scriptElementParser struct{}
 
 func (p scriptElementParser) Parse(pi *parse.Input) (n Node, ok bool, err error) {
@@ -51,7 +60,7 @@ func (p scriptElementParser) Parse(pi *parse.Input) (n Node, ok bool, err error)
 
 	// Parse the contents, we should get script text or Go expressions up until the closing tag.
 	var sb strings.Builder
-	var isInsideStringLiteral bool
+	var stringLiteralDelimiter jsQuote
 
 loop:
 	for {
@@ -82,7 +91,7 @@ loop:
 			return nil, false, err
 		}
 		if ok {
-			e.Contents = append(e.Contents, NewScriptContentsGo(code.(GoCode), isInsideStringLiteral))
+			e.Contents = append(e.Contents, NewScriptContentsGo(code.(GoCode), stringLiteralDelimiter != jsQuoteNone))
 			continue loop
 		}
 
@@ -108,13 +117,18 @@ loop:
 			if ok {
 				_, isEOF, _ := parse.EOF[string]().Parse(pi)
 				if c == `"` || c == "'" || c == "`" {
-					isInsideStringLiteral = !isInsideStringLiteral
+					// Start or exit a string literal.
+					if stringLiteralDelimiter == jsQuoteNone {
+						stringLiteralDelimiter = jsQuote(c)
+					} else if stringLiteralDelimiter == jsQuote(c) {
+						stringLiteralDelimiter = jsQuoteNone
+					}
 				}
 				peeked, _ := pi.Peek(1)
 				peeked = c + peeked
 
 				breakForGo := peeked == "{{"
-				breakForHTML := !isInsideStringLiteral && (peeked == "</" || peeked == "//" || peeked == "/*")
+				breakForHTML := stringLiteralDelimiter == jsQuoteNone && (peeked == "</" || peeked == "//" || peeked == "/*")
 
 				if isEOF || breakForGo || breakForHTML {
 					if sb.Len() > 0 {
@@ -143,7 +157,21 @@ var endTagStart = parse.String("</")
 
 var jsCharacter = parse.Any(jsEscapedCharacter, parse.AnyRune)
 
-var jsEscapedCharacter = parse.StringFrom(parse.String("\\"), parse.AnyRune)
+// \uXXXX	Unicode code point escape	'\u0061' = 'a'
+var hexDigit = parse.Any(parse.ZeroToNine, parse.RuneIn("abcdef"), parse.RuneIn("ABCDEF"))
+var jsUnicodeEscape = parse.StringFrom(parse.String("\\u"), hexDigit, hexDigit, hexDigit, hexDigit)
+
+// \u{X...}	ES6+ extended Unicode escape	'\u{1F600}' = '😀'
+var jsExtendedUnicodeEscape = parse.StringFrom(parse.String("\\u{"), hexDigit, parse.StringFrom(parse.AtLeast(1, parse.ZeroOrMore(hexDigit))), parse.String("}"))
+
+// \xXX	Hex code (2-digit)	'\x41' = 'A'
+var jsHexEscape = parse.StringFrom(parse.String("\\x"), hexDigit, hexDigit)
+
+// \x Backslash escape	'\\' = '\'
+var jsBackslashEscape = parse.StringFrom(parse.String("\\"), parse.AnyRune)
+
+// All escapes.
+var jsEscapedCharacter = parse.Any(jsBackslashEscape, jsUnicodeEscape, jsHexEscape, jsExtendedUnicodeEscape)
 
 var jsComment = parse.Any(jsSingleLineComment, jsMultiLineComment)
 


Test patch:
diff --git a/parser/v2/scriptparser_test.go b/parser/v2/scriptparser_test.go
index a952508..77300fb 100644
--- a/parser/v2/scriptparser_test.go
+++ b/parser/v2/scriptparser_test.go
@@ -2,6 +2,7 @@ package parser
 
 import (
 	"path/filepath"
+	"strings"
 	"testing"
 
 	_ "embed"
@@ -39,11 +40,20 @@ func TestScriptElementParserPlain(t *testing.T) {
 			if !isScriptElement {
 				t.Fatalf("expected ScriptElement, got %T", result)
 			}
-			if len(se.Contents) != 1 {
-				t.Fatalf("expected 1 content, got %d", len(se.Contents))
+
+			var actual strings.Builder
+			for _, content := range se.Contents {
+				if content.GoCode != nil {
+					t.Fatalf("expected plain text, got GoCode")
+				}
+				if content.Value == nil {
+					t.Fatalf("expected plain text, got nil")
+				}
+				actual.WriteString(*content.Value)
 			}
+
 			expected := clean(a.Files[1].Data)
-			if diff := cmp.Diff(*se.Contents[0].Value, string(expected)); diff != "" {
+			if diff := cmp.Diff(actual.String(), string(expected)); diff != "" {
 				t.Fatalf("%s:\n%s", file, diff)
 			}
 		})
diff --git a/parser/v2/scriptparsertestdata/backtickquote.txt b/parser/v2/scriptparsertestdata/backtickquote.txt
new file mode 100644
index 0000000..b6ae804
--- /dev/null
+++ b/parser/v2/scriptparsertestdata/backtickquote.txt
@@ -0,0 +1,8 @@
+-- in --
+<script>
+window.alert(`This is 'single quoted' and this is "double quoted"`);
+</script>
+-- out --
+
+window.alert(`This is 'single quoted' and this is "double quoted"`);
+
diff --git a/parser/v2/scriptparsertestdata/backtickquote_apostrophe.txt b/parser/v2/scriptparsertestdata/backtickquote_apostrophe.txt
new file mode 100644
index 0000000..9dfd448
--- /dev/null
+++ b/parser/v2/scriptparsertestdata/backtickquote_apostrophe.txt
@@ -0,0 +1,8 @@
+-- in --
+<script>
+window.alert(`You can use isn't and other text`);
+</script>
+-- out --
+
+window.alert(`You can use isn't and other text`);
+
diff --git a/parser/v2/scriptparsertestdata/doublequote.txt b/parser/v2/scriptparsertestdata/doublequote.txt
new file mode 100644
index 0000000..b15e8ad
--- /dev/null
+++ b/parser/v2/scriptparsertestdata/doublequote.txt
@@ -0,0 +1,8 @@
+-- in --
+<script>
+window.alert("This is 'quoted'");
+</script>
+-- out --
+
+window.alert("This is 'quoted'");
+
diff --git a/parser/v2/scriptparsertestdata/doublequote_apostrophe.txt b/parser/v2/scriptparsertestdata/doublequote_apostrophe.txt
new file mode 100644
index 0000000..e18519a
--- /dev/null
+++ b/parser/v2/scriptparsertestdata/doublequote_apostrophe.txt
@@ -0,0 +1,8 @@
+-- in --
+<script>
+window.alert("You can use isn't and other text");
+</script>
+-- out --
+
+window.alert("You can use isn't and other text");
+
diff --git a/parser/v2/scriptparsertestdata/escapechars.txt b/parser/v2/scriptparsertestdata/escapechars.txt
new file mode 100644
index 0000000..8b0844f
--- /dev/null
+++ b/parser/v2/scriptparsertestdata/escapechars.txt
@@ -0,0 +1,34 @@
+-- in --
+<script>
+const singleQuotedBackslashEscape = '\x61'; // a
+const singleQuotedHexEscape = '\x61';
+const singleQuotedUnicodeEscape = '\u0061';
+const singleQuotedExtendedUnicodeEscape = '\u{61}';
+
+const doubleQuotedBackslashEscape = "\x61"; // a
+const doubleQuotedHexEscape = "\x61";
+const doubleQuotedUnicodeEscape = "\u0061";
+const doubleQuotedExtendedUnicodeEscape = "\u{61}";
+
+const backtickQuotedBackslashEscape = `\x61`; // a
+const backtickQuotedHexEscape = `\x61`;
+const backtickQuotedUnicodeEscape = `\u0061`;
+const backtickQuotedExtendedUnicodeEscape = `\u{61}`;
+</script>
+-- out --
+
+const singleQuotedBackslashEscape = '\x61'; // a
+const singleQuotedHexEscape = '\x61';
+const singleQuotedUnicodeEscape = '\u0061';
+const singleQuotedExtendedUnicodeEscape = '\u{61}';
+
+const doubleQuotedBackslashEscape = "\x61"; // a
+const doubleQuotedHexEscape = "\x61";
+const doubleQuotedUnicodeEscape = "\u0061";
+const doubleQuotedExtendedUnicodeEscape = "\u{61}";
+
+const backtickQuotedBackslashEscape = `\x61`; // a
+const backtickQuotedHexEscape = `\x61`;
+const backtickQuotedUnicodeEscape = `\u0061`;
+const backtickQuotedExtendedUnicodeEscape = `\u{61}`;
+
diff --git a/parser/v2/scriptparsertestdata/singlequote.txt b/parser/v2/scriptparsertestdata/singlequote.txt
new file mode 100644
index 0000000..13b8ab7
--- /dev/null
+++ b/parser/v2/scriptparsertestdata/singlequote.txt
@@ -0,0 +1,8 @@
+-- in --
+<script>
+window.alert('This is "quoted"');
+</script>
+-- out --
+
+window.alert('This is "quoted"');
+
diff --git a/parser/v2/scriptparsertestdata/singlequote_apostrophe.txt b/parser/v2/scriptparsertestdata/singlequote_apostrophe.txt
new file mode 100644
index 0000000..07f325c
--- /dev/null
+++ b/parser/v2/scriptparsertestdata/singlequote_apostrophe.txt
@@ -0,0 +1,8 @@
+-- in --
+<script>
+window.alert('An unclosed " is allowed in single quotes');
+</script>
+-- out --
+
+window.alert('An unclosed " is allowed in single quotes');
+