Skip to content

Commit 608b494

Browse files
committed
Merge branch 'release/v0.17.6'
2 parents 0110071 + 5983a5b commit 608b494

7 files changed

Lines changed: 145 additions & 183 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ tmp
66
*.log
77
.out*
88
coverage
9-
Icon?
9+
Icon?
10+
*.cpuprofile

package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "zeed-dom",
33
"type": "module",
4-
"version": "0.17.5",
4+
"version": "0.17.6",
55
"description": "🌱 Lightweight offline DOM",
66
"author": {
77
"name": "Dirk Holtwick",
@@ -61,7 +61,12 @@
6161
"clean": "rm -rf dist",
6262
"lint": "eslint .",
6363
"lint:fix": "eslint . --fix",
64+
"perf": "tsx test/files-bench.ts",
65+
"perf:files": "tsx test/files-bench.ts",
66+
"perf:parser": "tsx test/htmlparser-bench.ts",
6467
"prepublishOnly": "npm test && npm run build",
68+
"profile:parser": "tsx --cpu-prof test/htmlparser-bench.ts",
69+
"inspect:parser": "tsx --inspect-brk test/htmlparser-bench.ts",
6570
"start": "npm run watch",
6671
"test": "vitest src --coverage --globals --run",
6772
"watch": "npm run build:tsup -- --watch"
@@ -78,6 +83,7 @@
7883
"c8": "^10.1.3",
7984
"eslint": "^9.31.0",
8085
"tsup": "^8.5.0",
86+
"tsx": "^4.20.3",
8187
"typedoc": "^0.28.7",
8288
"typescript": "^5.8.3",
8389
"vite": "^7.0.5",

src/htmlparser.ts

Lines changed: 39 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,59 @@
1-
// Taken from https://github.com/creeperyang/html-parser-lite
2-
// and slightly modified. Original also under MIT license. Thanks.
3-
4-
// attribute, like href="javascript:void(0)"
5-
// 1. start with name (not empty and not =)
6-
// 2. and then \s*=\s*
7-
// 3. and value can be "value" | 'value' | value
8-
// 4. 2 and 3 are optional
9-
const attrRe = /([^=\s]+)(\s*=\s*(("([^"]*)")|('([^']*)')|[^>\s]+))?/g
10-
const endTagRe = /^<\/([^>\s]+)[^>]*>/m
11-
// start tag, like <a href="link"> <img/>
12-
// 1. must start with <tagName
13-
// 2. optional attrbutes
14-
// 3. /> or >
15-
const startTagRe = /^<([^>\s/]+)((\s+[^=>\s]+(\s*=\s*(("[^"]*")|('[^']*')|[^>\s]+))?)*)\s*(?:\/\s*)?>/m
16-
const selfCloseTagRe = /\s*\/\s*>\s*$/m
1+
export interface HtmlParserScanner {
2+
characters: (text: string) => void
3+
comment: (text: string) => void
4+
startElement: (tagName: string, attrs: Record<string, any>, isSelfColse: boolean, raw: string) => void
5+
endElement: (tagName: string) => void
6+
}
177

188
/**
199
* This is a simple html parser. Will read and parse html string.
2010
*
2111
* Original code by Erik Arvidsson, Mozilla Public License
2212
* http://erik.eae.net/simplehtmlparser/simplehtmlparser.js
2313
*/
24-
export class HtmlParser {
25-
scanner: any
26-
options: any
27-
attrRe = attrRe
28-
endTagRe = endTagRe
29-
startTagRe = startTagRe
30-
defaults = { ignoreWhitespaceText: false }
31-
32-
constructor(options: {
33-
scanner?: any
34-
ignoreWhitespaceText?: boolean
35-
} = {}) {
36-
this.scanner = options.scanner
37-
// Faster object merge for simple case
38-
this.options = options.ignoreWhitespaceText !== undefined
39-
? options
40-
: { ...this.defaults, ...options }
41-
}
14+
export function createHtmlParser(scanner: HtmlParserScanner) {
15+
const attrRe = /([^=\s]+)(\s*=\s*(("([^"]*)")|('([^']*)')|[^>\s]+))?/g
16+
const endTagRe = /^<\/([^>\s]+)[^>]*>/m
17+
const startTagRe = /^<([^>\s/]+)((\s+[^=>\s]+(\s*=\s*(("[^"]*")|('[^']*')|[^>\s]+))?)*)\s*(?:\/\s*)?>/m
18+
const selfCloseTagRe = /\s*\/\s*>\s*$/m
4219

43-
parse(html: string) {
20+
function parse(html: string) {
4421
let treatAsChars = false
4522
let index, match, characters
46-
// Precompile regex for script/style end tags to avoid repeated creation
4723
let scriptEndRe: RegExp | null = null
4824
let styleEndRe: RegExp | null = null
4925

5026
while (html.length) {
51-
treatAsChars = true // Set default early
27+
treatAsChars = true
5228

53-
// comment - use startsWith for faster string comparison
5429
if (html.startsWith('<!--')) {
5530
index = html.indexOf('-->')
5631
if (index !== -1) {
57-
this.scanner.comment(html.substring(4, index))
32+
scanner.comment(html.substring(4, index))
5833
html = html.slice(index + 3)
5934
treatAsChars = false
6035
}
6136
}
62-
// end tag - use startsWith and avoid deprecated RegExp globals
6337
else if (html.startsWith('</')) {
64-
match = html.match(this.endTagRe)
38+
match = html.match(endTagRe)
6539
if (match) {
6640
html = html.slice(match[0].length)
6741
treatAsChars = false
68-
this.parseEndTag(match[0], match[1])
42+
scanner.endElement(match[1])
6943
}
7044
}
71-
// start tag - avoid charAt for better performance
7245
else if (html[0] === '<') {
73-
match = html.match(this.startTagRe)
46+
match = html.match(startTagRe)
7447
if (match) {
7548
html = html.slice(match[0].length)
7649
treatAsChars = false
77-
const tagName = this.parseStartTag(match[0], match[1], match)
78-
// Optimize script/style handling with precompiled regex
50+
const tagName = parseStartTag(match[0], match[1], match)
7951
if (tagName === 'script') {
8052
if (!scriptEndRe)
8153
scriptEndRe = /<\/script/i
8254
index = html.search(scriptEndRe)
8355
if (index !== -1) {
84-
this.scanner.characters(html.slice(0, index))
56+
scanner.characters(html.slice(0, index))
8557
html = html.slice(index)
8658
treatAsChars = false
8759
}
@@ -91,7 +63,7 @@ export class HtmlParser {
9163
styleEndRe = /<\/style/i
9264
index = html.search(styleEndRe)
9365
if (index !== -1) {
94-
this.scanner.characters(html.slice(0, index))
66+
scanner.characters(html.slice(0, index))
9567
html = html.slice(index)
9668
treatAsChars = false
9769
}
@@ -103,9 +75,8 @@ export class HtmlParser {
10375
index = html.indexOf('<')
10476

10577
if (index === 0) {
106-
// Skip the first '<' and find the next one
10778
index = html.indexOf('<', 1)
108-
characters = html[0] // Just the '<' character
79+
characters = html[0]
10980
html = html.slice(1)
11081
}
11182
else if (index === -1) {
@@ -117,57 +88,59 @@ export class HtmlParser {
11788
html = html.slice(index)
11889
}
11990

120-
// Faster whitespace check - avoid regex for empty strings
121-
if (characters && (!this.options.ignoreWhitespaceText || /[^\s]/.test(characters)))
122-
this.scanner.characters(characters)
91+
if (characters)
92+
scanner.characters(characters)
12393
}
12494

125-
match = null // Clear match for next iteration
95+
match = null
12696
}
12797
}
12898

129-
private parseStartTag(input: string, tagName: string, match: any) {
99+
function parseStartTag(input: string, tagName: string, match: any) {
130100
const isSelfColse = selfCloseTagRe.test(input)
131101
let attrInput = match[2]
132102
if (isSelfColse)
133103
attrInput = attrInput.replace(/\s*\/\s*$/, '')
134-
const attrs = this.parseAttributes(tagName, attrInput)
135-
this.scanner.startElement(tagName, attrs, isSelfColse, match[0])
104+
const attrs = parseAttributes(attrInput)
105+
scanner.startElement(tagName, attrs, isSelfColse, match[0])
136106
return tagName.toLocaleLowerCase()
137107
}
138108

139-
private parseEndTag(input: string, tagName: string) {
140-
this.scanner.endElement(tagName)
141-
}
142-
143-
private parseAttributes(tagName: string, input: string) {
109+
function parseAttributes(input: string) {
144110
const attrs: Record<string, any> = {}
111+
145112
if (!input || !input.trim())
146113
return attrs
147114

148-
// Fast path for simple attributes without quotes
115+
// If there are no quotes in the input, split by whitespace and parse attributes simply
149116
if (!/["']/.test(input)) {
150117
const parts = input.trim().split(/\s+/)
151118
for (const part of parts) {
152119
const eqIndex = part.indexOf('=')
153120
if (eqIndex === -1) {
121+
// Attribute without value (boolean attribute)
154122
attrs[part] = true
155123
}
156124
else {
125+
// Attribute with value (unquoted)
157126
attrs[part.slice(0, eqIndex)] = part.slice(eqIndex + 1)
158127
}
159128
}
160129
return attrs
161130
}
162131

163-
// Fallback to regex for complex attributes
164-
this.attrRe.lastIndex = 0
132+
// Otherwise, use regex to extract attributes with quoted values
133+
attrRe.lastIndex = 0
165134
let match
166135
// eslint-disable-next-line no-cond-assign
167-
while ((match = this.attrRe.exec(input)) !== null) {
136+
while ((match = attrRe.exec(input)) !== null) {
137+
// Destructure the match to get attribute name and value (quoted or unquoted)
168138
const [, name, , value, , valueInQuote, , valueInSingleQuote] = match
139+
// Prefer single-quoted, then double-quoted, then unquoted, then true (boolean attribute)
169140
attrs[name] = valueInSingleQuote ?? valueInQuote ?? value ?? true
170141
}
171142
return attrs
172143
}
144+
145+
return parse
173146
}

src/vdomparser.ts

Lines changed: 34 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { unescapeHTML } from './encoding'
44
import { SELF_CLOSING_TAGS } from './html'
5-
import { HtmlParser } from './htmlparser'
5+
import { createHtmlParser } from './htmlparser'
66
import { hasOwn } from './utils'
77
import { document, VDocType, VDocumentFragment, VElement, VHTMLDocument, VNode, VTextNode } from './vdom'
88

@@ -27,45 +27,43 @@ export function parseHTML(html: string): VDocumentFragment | VHTMLDocument {
2727
const frag = isDoc ? new VHTMLDocument(true) : new VDocumentFragment()
2828
const stack: VNode[] = [frag]
2929

30-
const parser = new HtmlParser({
31-
scanner: {
32-
startElement(tagName: string, attrs: Record<string, string>, isSelfClosing: boolean) {
33-
const lowerTagName = tagName.length === 1 ? tagName : tagName.toLowerCase()
34-
if (lowerTagName === '!doctype') {
35-
frag.docType = new VDocType()
36-
return
37-
}
38-
for (const name in attrs) {
39-
if (hasOwn(attrs, name) && typeof attrs[name] === 'string') {
40-
attrs[name] = unescapeHTML(attrs[name])
41-
}
42-
}
43-
const parentNode = stack[stack.length - 1]
44-
if (parentNode) {
45-
const element = document.createElement(tagName, attrs)
46-
parentNode.appendChild(element)
47-
if (!SELF_CLOSING_TAGS.includes(lowerTagName) && !isSelfClosing) {
48-
stack.push(element)
49-
}
50-
}
51-
},
52-
endElement() {
53-
stack.pop()
54-
},
55-
characters(text: string) {
56-
text = unescapeHTML(text)
57-
const parentNode = stack[stack.length - 1]
58-
if (parentNode?.lastChild?.nodeType === VNode.TEXT_NODE) {
59-
parentNode.lastChild._text += text
30+
const parse = createHtmlParser({
31+
startElement(tagName: string, attrs: Record<string, string>, isSelfClosing: boolean) {
32+
const lowerTagName = tagName.length === 1 ? tagName : tagName.toLowerCase()
33+
if (lowerTagName === '!doctype') {
34+
frag.docType = new VDocType()
35+
return
36+
}
37+
for (const name in attrs) {
38+
if (hasOwn(attrs, name) && typeof attrs[name] === 'string') {
39+
attrs[name] = unescapeHTML(attrs[name])
6040
}
61-
else if (parentNode) {
62-
parentNode.appendChild(new VTextNode(text))
41+
}
42+
const parentNode = stack[stack.length - 1]
43+
if (parentNode) {
44+
const element = document.createElement(tagName, attrs)
45+
parentNode.appendChild(element)
46+
if (!SELF_CLOSING_TAGS.includes(lowerTagName) && !isSelfClosing) {
47+
stack.push(element)
6348
}
64-
},
65-
comment() {},
49+
}
50+
},
51+
endElement() {
52+
stack.pop()
53+
},
54+
characters(text: string) {
55+
text = unescapeHTML(text)
56+
const parentNode = stack[stack.length - 1]
57+
if (parentNode?.lastChild?.nodeType === VNode.TEXT_NODE) {
58+
parentNode.lastChild._text += text
59+
}
60+
else if (parentNode) {
61+
parentNode.appendChild(new VTextNode(text))
62+
}
6663
},
64+
comment() {},
6765
})
68-
parser.parse(html)
66+
parse(html)
6967
return frag
7068
}
7169

0 commit comments

Comments
 (0)