Skip to content

Commit f9ca69b

Browse files
authoredMar 25, 2025··
fix: allow unmatched quotes in script tags, fixes #1110 (#1111)
1 parent 9d3bc66 commit f9ca69b

10 files changed

+129
-9
lines changed
 

‎.version

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.3.856
1+
0.3.857

‎parser/v2/scriptparser.go

+33-5
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ import (
88

99
var scriptElement = scriptElementParser{}
1010

11+
type jsQuote string
12+
13+
const (
14+
jsQuoteNone jsQuote = ""
15+
jsQuoteSingle jsQuote = `'`
16+
jsQuoteDouble jsQuote = `"`
17+
jsQuoteBacktick jsQuote = "`"
18+
)
19+
1120
type scriptElementParser struct{}
1221

1322
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)
5160

5261
// Parse the contents, we should get script text or Go expressions up until the closing tag.
5362
var sb strings.Builder
54-
var isInsideStringLiteral bool
63+
var stringLiteralDelimiter jsQuote
5564

5665
loop:
5766
for {
@@ -82,7 +91,7 @@ loop:
8291
return nil, false, err
8392
}
8493
if ok {
85-
e.Contents = append(e.Contents, NewScriptContentsGo(code.(GoCode), isInsideStringLiteral))
94+
e.Contents = append(e.Contents, NewScriptContentsGo(code.(GoCode), stringLiteralDelimiter != jsQuoteNone))
8695
continue loop
8796
}
8897

@@ -108,13 +117,18 @@ loop:
108117
if ok {
109118
_, isEOF, _ := parse.EOF[string]().Parse(pi)
110119
if c == `"` || c == "'" || c == "`" {
111-
isInsideStringLiteral = !isInsideStringLiteral
120+
// Start or exit a string literal.
121+
if stringLiteralDelimiter == jsQuoteNone {
122+
stringLiteralDelimiter = jsQuote(c)
123+
} else if stringLiteralDelimiter == jsQuote(c) {
124+
stringLiteralDelimiter = jsQuoteNone
125+
}
112126
}
113127
peeked, _ := pi.Peek(1)
114128
peeked = c + peeked
115129

116130
breakForGo := peeked == "{{"
117-
breakForHTML := !isInsideStringLiteral && (peeked == "</" || peeked == "//" || peeked == "/*")
131+
breakForHTML := stringLiteralDelimiter == jsQuoteNone && (peeked == "</" || peeked == "//" || peeked == "/*")
118132

119133
if isEOF || breakForGo || breakForHTML {
120134
if sb.Len() > 0 {
@@ -143,7 +157,21 @@ var endTagStart = parse.String("</")
143157

144158
var jsCharacter = parse.Any(jsEscapedCharacter, parse.AnyRune)
145159

146-
var jsEscapedCharacter = parse.StringFrom(parse.String("\\"), parse.AnyRune)
160+
// \uXXXX Unicode code point escape '\u0061' = 'a'
161+
var hexDigit = parse.Any(parse.ZeroToNine, parse.RuneIn("abcdef"), parse.RuneIn("ABCDEF"))
162+
var jsUnicodeEscape = parse.StringFrom(parse.String("\\u"), hexDigit, hexDigit, hexDigit, hexDigit)
163+
164+
// \u{X...} ES6+ extended Unicode escape '\u{1F600}' = '😀'
165+
var jsExtendedUnicodeEscape = parse.StringFrom(parse.String("\\u{"), hexDigit, parse.StringFrom(parse.AtLeast(1, parse.ZeroOrMore(hexDigit))), parse.String("}"))
166+
167+
// \xXX Hex code (2-digit) '\x41' = 'A'
168+
var jsHexEscape = parse.StringFrom(parse.String("\\x"), hexDigit, hexDigit)
169+
170+
// \x Backslash escape '\\' = '\'
171+
var jsBackslashEscape = parse.StringFrom(parse.String("\\"), parse.AnyRune)
172+
173+
// All escapes.
174+
var jsEscapedCharacter = parse.Any(jsBackslashEscape, jsUnicodeEscape, jsHexEscape, jsExtendedUnicodeEscape)
147175

148176
var jsComment = parse.Any(jsSingleLineComment, jsMultiLineComment)
149177

‎parser/v2/scriptparser_test.go

+13-3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package parser
22

33
import (
44
"path/filepath"
5+
"strings"
56
"testing"
67

78
_ "embed"
@@ -39,11 +40,20 @@ func TestScriptElementParserPlain(t *testing.T) {
3940
if !isScriptElement {
4041
t.Fatalf("expected ScriptElement, got %T", result)
4142
}
42-
if len(se.Contents) != 1 {
43-
t.Fatalf("expected 1 content, got %d", len(se.Contents))
43+
44+
var actual strings.Builder
45+
for _, content := range se.Contents {
46+
if content.GoCode != nil {
47+
t.Fatalf("expected plain text, got GoCode")
48+
}
49+
if content.Value == nil {
50+
t.Fatalf("expected plain text, got nil")
51+
}
52+
actual.WriteString(*content.Value)
4453
}
54+
4555
expected := clean(a.Files[1].Data)
46-
if diff := cmp.Diff(*se.Contents[0].Value, string(expected)); diff != "" {
56+
if diff := cmp.Diff(actual.String(), string(expected)); diff != "" {
4757
t.Fatalf("%s:\n%s", file, diff)
4858
}
4959
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- in --
2+
<script>
3+
window.alert(`This is 'single quoted' and this is "double quoted"`);
4+
</script>
5+
-- out --
6+
7+
window.alert(`This is 'single quoted' and this is "double quoted"`);
8+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- in --
2+
<script>
3+
window.alert(`You can use isn't and other text`);
4+
</script>
5+
-- out --
6+
7+
window.alert(`You can use isn't and other text`);
8+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- in --
2+
<script>
3+
window.alert("This is 'quoted'");
4+
</script>
5+
-- out --
6+
7+
window.alert("This is 'quoted'");
8+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- in --
2+
<script>
3+
window.alert("You can use isn't and other text");
4+
</script>
5+
-- out --
6+
7+
window.alert("You can use isn't and other text");
8+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
-- in --
2+
<script>
3+
const singleQuotedBackslashEscape = '\x61'; // a
4+
const singleQuotedHexEscape = '\x61';
5+
const singleQuotedUnicodeEscape = '\u0061';
6+
const singleQuotedExtendedUnicodeEscape = '\u{61}';
7+
8+
const doubleQuotedBackslashEscape = "\x61"; // a
9+
const doubleQuotedHexEscape = "\x61";
10+
const doubleQuotedUnicodeEscape = "\u0061";
11+
const doubleQuotedExtendedUnicodeEscape = "\u{61}";
12+
13+
const backtickQuotedBackslashEscape = `\x61`; // a
14+
const backtickQuotedHexEscape = `\x61`;
15+
const backtickQuotedUnicodeEscape = `\u0061`;
16+
const backtickQuotedExtendedUnicodeEscape = `\u{61}`;
17+
</script>
18+
-- out --
19+
20+
const singleQuotedBackslashEscape = '\x61'; // a
21+
const singleQuotedHexEscape = '\x61';
22+
const singleQuotedUnicodeEscape = '\u0061';
23+
const singleQuotedExtendedUnicodeEscape = '\u{61}';
24+
25+
const doubleQuotedBackslashEscape = "\x61"; // a
26+
const doubleQuotedHexEscape = "\x61";
27+
const doubleQuotedUnicodeEscape = "\u0061";
28+
const doubleQuotedExtendedUnicodeEscape = "\u{61}";
29+
30+
const backtickQuotedBackslashEscape = `\x61`; // a
31+
const backtickQuotedHexEscape = `\x61`;
32+
const backtickQuotedUnicodeEscape = `\u0061`;
33+
const backtickQuotedExtendedUnicodeEscape = `\u{61}`;
34+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- in --
2+
<script>
3+
window.alert('This is "quoted"');
4+
</script>
5+
-- out --
6+
7+
window.alert('This is "quoted"');
8+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- in --
2+
<script>
3+
window.alert('An unclosed " is allowed in single quotes');
4+
</script>
5+
-- out --
6+
7+
window.alert('An unclosed " is allowed in single quotes');
8+

0 commit comments

Comments
 (0)
Please sign in to comment.