Skip to content

Commit 3912dab

Browse files
fix(unhead): match closing and nested script tag names like the HTML tokenizer
1 parent bdc9d80 commit 3912dab

2 files changed

Lines changed: 90 additions & 19 deletions

File tree

‎packages/unhead/src/parser/index.ts‎

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ const QUOTE_CHAR = 34 // '"'
1919
const APOS_CHAR = 39 // '\''
2020
const EXCLAMATION_CHAR = 33 // '!'
2121
const DASH_CHAR = 45 // '-'
22+
const FORM_FEED_CHAR = 12 // '\f'
23+
const UPPER_A_CHAR = 65 // 'A'
24+
const UPPER_Z_CHAR = 90 // 'Z'
2225

2326
// Map string tag names to numeric constants
2427
export const TagIdMap = /* @__PURE__ */ {
@@ -635,28 +638,18 @@ export function parseHtmlForUnheadExtraction(html: string): PreparedHtmlTemplate
635638

636639
/**
637640
* Find the closing tag for a given tag name
638-
* For script and style tags, content ends at the first closing tag as it does in browsers,
641+
* Content ends at the first closing tag as it does in browsers,
639642
* so quotes in comments or regex literals can't hide it
640643
*/
641644
function findClosingTag(html: string, startPos: number, tagName: string): number {
642-
const tagId = TagIdMap[tagName as keyof typeof TagIdMap]
643-
const isScriptOrStyle = tagId === TAG_SCRIPT || tagId === TAG_STYLE
644-
645-
if (!isScriptOrStyle) {
646-
// Simple search for non-script/style tags
647-
const closingTag = `</${tagName}`
648-
const index = html.indexOf(closingTag, startPos)
649-
return index === -1 ? -1 : index
650-
}
651-
652-
if (tagId === TAG_SCRIPT) {
645+
if (TagIdMap[tagName as keyof typeof TagIdMap] === TAG_SCRIPT) {
653646
return findScriptEnd(html, startPos)
654647
}
655648

656649
const closingTag = `</${tagName}`
657-
let pos = html.indexOf(closingTag, startPos)
650+
let pos = html.indexOf('</', startPos)
658651
while (pos !== -1 && !isTagAt(html, pos, closingTag)) {
659-
pos = html.indexOf(closingTag, pos + closingTag.length)
652+
pos = html.indexOf('</', pos + 2)
660653
}
661654
return pos
662655
}
@@ -697,18 +690,27 @@ function findScriptEnd(html: string, startPos: number): number {
697690
}
698691

699692
/**
700-
* Check for a complete tag name at pos (followed by whitespace, > or the end of input)
693+
* Check for a complete tag name at pos, as the HTML tokenizer reads it:
694+
* ASCII letters match in any case (`tag` is lowercase) and the name ends at
695+
* whitespace, a form feed, / or >, or at the end of input
701696
*/
702697
function isTagAt(html: string, pos: number, tag: string): boolean {
703-
if (!html.startsWith(tag, pos)) {
698+
const afterTagPos = pos + tag.length
699+
if (afterTagPos > html.length) {
704700
return false
705701
}
706-
const afterTagPos = pos + tag.length
707-
if (afterTagPos >= html.length) {
702+
for (let i = 0; i < tag.length; i++) {
703+
const c = html.charCodeAt(pos + i)
704+
const lower = c >= UPPER_A_CHAR && c <= UPPER_Z_CHAR ? c + 32 : c
705+
if (lower !== tag.charCodeAt(i)) {
706+
return false
707+
}
708+
}
709+
if (afterTagPos === html.length) {
708710
return true
709711
}
710712
const nextChar = html.charCodeAt(afterTagPos)
711-
return nextChar === GT_CHAR || isWhitespace(nextChar)
713+
return nextChar === GT_CHAR || nextChar === SLASH_CHAR || nextChar === FORM_FEED_CHAR || isWhitespace(nextChar)
712714
}
713715

714716
/**

‎packages/unhead/test/unit/server/transformHtmlTemplate.test.ts‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it } from 'vitest'
2+
import { parseHtmlForUnheadExtraction } from '../../../src/parser'
23
import { transformHtmlTemplate, transformHtmlTemplateRaw } from '../../../src/server/transformHtmlTemplate'
34
import { createServerHeadWithContext } from '../../util'
45

@@ -194,6 +195,74 @@ document.documentElement.dataset.theme = 'dark'
194195
<script src="/b.js"></script></head><body>Content</body></html>`)
195196
})
196197

198+
it('ends a script at a closing tag in any letter case or followed by a slash or form feed', async () => {
199+
const head = createServerHeadWithContext()
200+
201+
const html = `<html><head><script>a</SCRIPT><script>b</script/><script>c</script\f><script><!-- d </ScRiPt><script src="/b.js"></script></head><body>Content</body></html>`
202+
203+
const result = await transformHtmlTemplate(head, html)
204+
expect(result).toBe(`<html><head><script>a</script>
205+
<script>b</script>
206+
<script>c</script>
207+
<script><!-- d </script>
208+
<script src="/b.js"></script></head><body>Content</body></html>`)
209+
})
210+
211+
it('detects a commented nested script in any letter case or followed by a slash or form feed', async () => {
212+
const head = createServerHeadWithContext()
213+
214+
const html = `<html><head><script><!-- document.write('<SCRIPT src="/a.js"></script>') --></script><script><!-- document.write('<script/></script>') --></script><script><!-- document.write('<script\f></script>') --></script><script src="/b.js"></script></head><body>Content</body></html>`
215+
216+
const result = await transformHtmlTemplate(head, html)
217+
expect(result).toBe(`<html><head><script><!-- document.write('<SCRIPT src="/a.js"><\\/script>') --></script>
218+
<script><!-- document.write('<script/><\\/script>') --></script>
219+
<script><!-- document.write('<script\f><\\/script>') --></script>
220+
<script src="/b.js"></script></head><body>Content</body></html>`)
221+
})
222+
223+
it('leaves a commented nested script at a closing tag in any letter case or followed by a slash or form feed', () => {
224+
const html = `<html><head><script><!-- <script></SCRIPT>a</script><script><!-- <script></script/>b</script><script><!-- <script></script\f>c</script><script src="/b.js"></script></head><body>Content</body></html>`
225+
226+
expect(parseHtmlForUnheadExtraction(html).input.script).toEqual([
227+
{ innerHTML: '<!-- <script></SCRIPT>a' },
228+
{ innerHTML: '<!-- <script></script/>b' },
229+
{ innerHTML: '<!-- <script></script\f>c' },
230+
{ src: '/b.js', innerHTML: '' },
231+
])
232+
})
233+
234+
it('does not treat other tag names after <!-- as a nested script', async () => {
235+
const head = createServerHeadWithContext()
236+
237+
const html = `<html><head><script><!-- <scripts> </script><script><!-- <SCRIPTS> </script><script src="/b.js"></script></head><body>Content</body></html>`
238+
239+
const result = await transformHtmlTemplate(head, html)
240+
expect(result).toBe(`<html><head><script><!-- <scripts> </script>
241+
<script><!-- <SCRIPTS> </script>
242+
<script src="/b.js"></script></head><body>Content</body></html>`)
243+
})
244+
245+
it('ends style content at a closing tag in any letter case or followed by a slash or form feed', async () => {
246+
const head = createServerHeadWithContext()
247+
248+
const html = `<html><head><style>a{}</STYLE><style>b{}</style/><style>c{}</style\f><style>d{}</style></head><body>Content</body></html>`
249+
250+
const result = await transformHtmlTemplate(head, html)
251+
expect(result).toBe(`<html><head><style>a{}</style>
252+
<style>b{}</style>
253+
<style>c{}</style>
254+
<style>d{}</style></head><body>Content</body></html>`)
255+
})
256+
257+
it('ends title content only at a complete closing tag in any letter case', async () => {
258+
const head = createServerHeadWithContext()
259+
260+
const html = `<html><head><title>a</titles> b</TITLE></head><body>Content</body></html>`
261+
262+
const result = await transformHtmlTemplate(head, html)
263+
expect(result).toBe(`<html><head><title>a&lt;&#x2F;titles&gt; b</title></head><body>Content</body></html>`)
264+
})
265+
197266
it('handles style tags with complex CSS', async () => {
198267
const head = createServerHeadWithContext()
199268
head.push({ title: 'Test Title' })

0 commit comments

Comments
 (0)