| null
@@ -51,20 +86,11 @@ export default class HTMLParser {
public parse(html: string): void {
const mapCdataTags = this._mapCdataTags
+ const htmlLen = html.length
- const regTag =
- // eslint-disable-next-line no-control-regex
- /<(?:\/([^\s>]+)\s*|!--([\s\S]*?)--|!([^>]*?)|([\w\-:]+)((?:\s+[^\s"'>\/=\x00-\x0F\x7F\x80-\x9F]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]*))?)*?)\s*(\/?))>/g
- const regAttr =
- // eslint-disable-next-line no-control-regex
- /\s*([^\s"'>\/=\x00-\x0F\x7F\x80-\x9F]+)(?:\s*=\s*(?:(")([^"]*)"|(')([^']*)'|([^\s"'>]*)))?/g
- const regLine = /\r?\n/g
-
- let match: RegExpExecArray | null
- let matchIndex: number
let lastIndex = 0
- let tagName: string
- let arrAttrs: Attr[]
+ let tagName = ''
+ let arrAttrs: Attr[] = []
let tagCDATA: string | null = null
let attrsCDATA: Attr[] | undefined
let arrCDATA: string[] = []
@@ -110,14 +136,414 @@ export default class HTMLParser {
arrBlocks.push(data)
this.fire(type, data)
- while (regLine.exec(raw)) {
+ let nlIndex = raw.indexOf('\n')
+ while (nlIndex !== -1) {
line++
- lastLineIndex = pos + regLine.lastIndex
+ lastLineIndex = pos + nlIndex + 1
+ nlIndex = raw.indexOf('\n', nlIndex + 1)
+ }
+ }
+
+ let gtPos = -1
+ const nextGt = (from: number): number => {
+ if (gtPos < from) {
+ const found = html.indexOf('>', from)
+ gtPos = found === -1 ? htmlLen : found
+ }
+ return gtPos
+ }
+
+ let endTagP = -1
+ let endTagQ = -1
+ const scanEndTag = (from: number): number => {
+ if (endTagP < from) {
+ let p = from
+ while (p < htmlLen) {
+ const c = html.charCodeAt(p)
+ if (c === 0x3e || isSpace(c)) {
+ break
+ }
+ p++
+ }
+ let q = p
+ while (q < htmlLen && isSpace(html.charCodeAt(q))) {
+ q++
+ }
+ endTagP = p
+ endTagQ = q
+ }
+ if (
+ endTagP > from &&
+ endTagQ < htmlLen &&
+ html.charCodeAt(endTagQ) === 0x3e
+ ) {
+ return endTagQ + 1
}
+ return -1
}
- while ((match = regTag.exec(html))) {
- matchIndex = match.index
+ let commentCloseK = -1
+ let commentCloseEnd = htmlLen
+ const findCommentClose = (from: number): number => {
+ if (commentCloseK < from) {
+ let k = from
+ while (true) {
+ const dash = html.indexOf('--', k)
+ if (dash === -1 || dash + 2 >= htmlLen) {
+ commentCloseK = htmlLen
+ commentCloseEnd = htmlLen
+ break
+ }
+ const c2 = html.charCodeAt(dash + 2)
+ if (c2 === 0x3e) {
+ commentCloseK = dash
+ commentCloseEnd = dash + 3
+ break
+ }
+ if (
+ c2 === 0x21 &&
+ dash + 3 < htmlLen &&
+ html.charCodeAt(dash + 3) === 0x3e
+ ) {
+ commentCloseK = dash
+ commentCloseEnd = dash + 4
+ break
+ }
+ k = dash + 1
+ }
+ }
+ return commentCloseK
+ }
+
+ let failedStates: Uint8Array | null = null
+ let noDoubleQuoteFrom = htmlLen + 1
+ let noSingleQuoteFrom = htmlLen + 1
+ const dfsStack: number[] = []
+ let spanAttrsEnd = 0
+ let spanClose = ''
+
+ const markFailedState = (state: number) => {
+ if (failedStates === null) {
+ failedStates = new Uint8Array(Math.ceil((htmlLen * 3 + 3) / 8))
+ }
+ failedStates[state >>> 3] |= 1 << (state & 7)
+ }
+
+ const findStartTagSpan = (tagNameEnd: number): number => {
+ dfsStack.length = 0
+ dfsStack.push(tagNameEnd * 3)
+
+ while (dfsStack.length > 0) {
+ const item = dfsStack.pop()!
+ if (item < 0) {
+ markFailedState(~item)
+ continue
+ }
+
+ if (
+ failedStates !== null &&
+ (failedStates[item >>> 3] & (1 << (item & 7))) !== 0
+ ) {
+ continue
+ }
+
+ const role = item % 3
+ const cursor = (item - role) / 3
+ let wsEnd = cursor
+
+ if (role !== 2) {
+ while (wsEnd < htmlLen && isSpace(html.charCodeAt(wsEnd))) {
+ wsEnd++
+ }
+ if (wsEnd >= htmlLen) {
+ markFailedState(item)
+ continue
+ }
+
+ const ch = html.charCodeAt(wsEnd)
+ if (ch === 0x3e) {
+ spanAttrsEnd = cursor
+ spanClose = ''
+ return wsEnd + 1
+ }
+ if (
+ ch === 0x2f &&
+ wsEnd + 1 < htmlLen &&
+ html.charCodeAt(wsEnd + 1) === 0x3e
+ ) {
+ spanAttrsEnd = cursor
+ spanClose = '/'
+ return wsEnd + 2
+ }
+
+ const hasSep = wsEnd > cursor || role === 1
+ if (!hasSep || !isAttrNameChar(ch)) {
+ markFailedState(item)
+ continue
+ }
+ }
+
+ let nameEnd = wsEnd + 1
+ while (nameEnd < htmlLen && isAttrNameChar(html.charCodeAt(nameEnd))) {
+ nameEnd++
+ }
+
+ let eqPos = nameEnd
+ while (eqPos < htmlLen && isSpace(html.charCodeAt(eqPos))) {
+ eqPos++
+ }
+ if (eqPos >= htmlLen || html.charCodeAt(eqPos) !== 0x3d) {
+ dfsStack.push(~item)
+ dfsStack.push(nameEnd * 3)
+ continue
+ }
+
+ let valStart = eqPos + 1
+ while (valStart < htmlLen && isSpace(html.charCodeAt(valStart))) {
+ valStart++
+ }
+ if (valStart >= htmlLen) {
+ markFailedState(item)
+ continue
+ }
+
+ const vch = html.charCodeAt(valStart)
+ if (vch === 0x3e) {
+ spanAttrsEnd = valStart
+ spanClose = ''
+ return valStart + 1
+ }
+
+ if (vch === 0x22) {
+ if (valStart + 1 >= noDoubleQuoteFrom) {
+ markFailedState(item)
+ continue
+ }
+ const closeQuote = html.indexOf('"', valStart + 1)
+ if (closeQuote === -1) {
+ noDoubleQuoteFrom = valStart + 1
+ markFailedState(item)
+ continue
+ }
+ dfsStack.push(~item)
+ dfsStack.push((closeQuote + 1) * 3 + 1)
+ continue
+ }
+
+ if (vch === 0x27) {
+ if (valStart + 1 >= noSingleQuoteFrom) {
+ markFailedState(item)
+ continue
+ }
+ const closeQuote = html.indexOf("'", valStart + 1)
+ if (closeQuote === -1) {
+ noSingleQuoteFrom = valStart + 1
+ markFailedState(item)
+ continue
+ }
+ dfsStack.push(~item)
+ dfsStack.push((closeQuote + 1) * 3 + 1)
+ continue
+ }
+
+ let valEnd = valStart + 1
+ while (
+ valEnd < htmlLen &&
+ isUnquotedAttrValueChar(html.charCodeAt(valEnd))
+ ) {
+ valEnd++
+ }
+
+ dfsStack.push(~item)
+ if (valStart > eqPos + 1 && isAttrNameChar(vch)) {
+ dfsStack.push(valStart * 3 + 2)
+ }
+ dfsStack.push(valEnd * 3)
+ }
+
+ return -1
+ }
+
+ const parseAttrs = (tagNameEnd: number, attrsEnd: number): boolean => {
+ arrAttrs = []
+ let cursor = tagNameEnd
+
+ while (cursor < attrsEnd) {
+ let wsEnd = cursor
+ while (wsEnd < attrsEnd && isSpace(html.charCodeAt(wsEnd))) {
+ wsEnd++
+ }
+ if (wsEnd >= attrsEnd || !isAttrNameChar(html.charCodeAt(wsEnd))) {
+ return false
+ }
+
+ let nameEnd = wsEnd + 1
+ while (nameEnd < attrsEnd && isAttrNameChar(html.charCodeAt(nameEnd))) {
+ nameEnd++
+ }
+ const attrName = html.slice(wsEnd, nameEnd)
+
+ let eqPos = nameEnd
+ while (eqPos < attrsEnd && isSpace(html.charCodeAt(eqPos))) {
+ eqPos++
+ }
+ if (eqPos >= attrsEnd || html.charCodeAt(eqPos) !== 0x3d) {
+ arrAttrs.push({
+ name: attrName,
+ value: '',
+ quote: '',
+ index: cursor - tagNameEnd,
+ raw: html.slice(cursor, nameEnd),
+ })
+ cursor = nameEnd
+ continue
+ }
+
+ let valStart = eqPos + 1
+ while (valStart < attrsEnd && isSpace(html.charCodeAt(valStart))) {
+ valStart++
+ }
+
+ if (valStart < attrsEnd && html.charCodeAt(valStart) === 0x22) {
+ const closeQuote = html.indexOf('"', valStart + 1)
+ if (closeQuote === -1 || closeQuote >= attrsEnd) {
+ return false
+ }
+ arrAttrs.push({
+ name: attrName,
+ value: html.slice(valStart + 1, closeQuote),
+ quote: '"',
+ index: cursor - tagNameEnd,
+ raw: html.slice(cursor, closeQuote + 1),
+ })
+ cursor = closeQuote + 1
+ continue
+ }
+
+ if (valStart < attrsEnd && html.charCodeAt(valStart) === 0x27) {
+ const closeQuote = html.indexOf("'", valStart + 1)
+ if (closeQuote === -1 || closeQuote >= attrsEnd) {
+ return false
+ }
+ arrAttrs.push({
+ name: attrName,
+ value: html.slice(valStart + 1, closeQuote),
+ quote: "'",
+ index: cursor - tagNameEnd,
+ raw: html.slice(cursor, closeQuote + 1),
+ })
+ cursor = closeQuote + 1
+ continue
+ }
+
+ let valEnd = valStart
+ while (
+ valEnd < attrsEnd &&
+ isUnquotedAttrValueChar(html.charCodeAt(valEnd))
+ ) {
+ valEnd++
+ }
+ arrAttrs.push({
+ name: attrName,
+ value: html.slice(valStart, valEnd),
+ quote: '',
+ index: cursor - tagNameEnd,
+ raw: html.slice(cursor, valEnd),
+ })
+ cursor = valEnd
+ }
+
+ return true
+ }
+
+ let searchIndex = 0
+ while (searchIndex < htmlLen) {
+ const matchIndex = html.indexOf('<', searchIndex)
+ if (matchIndex === -1 || matchIndex + 1 >= htmlLen) {
+ break
+ }
+ if (nextGt(matchIndex + 1) === htmlLen) {
+ break
+ }
+
+ const nextCode = html.charCodeAt(matchIndex + 1)
+ let matchEnd = -1
+ let matchedKind: 'end' | 'start' | 'comment' | null = null
+ let commentContent = ''
+ let isLongComment = false
+ let startTagNameEnd = 0
+
+ if (nextCode === 0x2f) {
+ const endPos = scanEndTag(matchIndex + 2)
+ if (endPos !== -1) {
+ tagName = html.slice(matchIndex + 2, endTagP)
+ matchEnd = endPos
+ matchedKind = 'end'
+ }
+ } else if (nextCode === 0x21) {
+ if (
+ matchIndex + 3 < htmlLen &&
+ html.charCodeAt(matchIndex + 2) === 0x2d &&
+ html.charCodeAt(matchIndex + 3) === 0x2d
+ ) {
+ const afterOpen = matchIndex + 4
+ if (afterOpen < htmlLen && html.charCodeAt(afterOpen) === 0x3e) {
+ matchEnd = afterOpen + 1
+ commentContent = ''
+ isLongComment = true
+ matchedKind = 'comment'
+ } else if (
+ afterOpen + 1 < htmlLen &&
+ html.charCodeAt(afterOpen) === 0x2d &&
+ html.charCodeAt(afterOpen + 1) === 0x3e
+ ) {
+ matchEnd = afterOpen + 2
+ commentContent = ''
+ isLongComment = true
+ matchedKind = 'comment'
+ } else {
+ const closeK = findCommentClose(afterOpen)
+ if (closeK < htmlLen) {
+ matchEnd = commentCloseEnd
+ commentContent = html.slice(afterOpen, closeK)
+ isLongComment = true
+ matchedKind = 'comment'
+ }
+ }
+ }
+
+ if (matchedKind === null) {
+ const closeGt = nextGt(matchIndex + 2)
+ if (closeGt < htmlLen) {
+ matchEnd = closeGt + 1
+ commentContent = html.slice(matchIndex + 2, closeGt)
+ isLongComment = false
+ matchedKind = 'comment'
+ }
+ }
+ } else if (isTagNameChar(nextCode)) {
+ let tagNameEnd = matchIndex + 2
+ while (
+ tagNameEnd < htmlLen &&
+ isTagNameChar(html.charCodeAt(tagNameEnd))
+ ) {
+ tagNameEnd++
+ }
+ const endPos = findStartTagSpan(tagNameEnd)
+ if (endPos !== -1) {
+ tagName = html.slice(matchIndex + 1, tagNameEnd)
+ startTagNameEnd = tagNameEnd
+ matchEnd = endPos
+ matchedKind = 'start'
+ }
+ }
+
+ if (matchedKind === null) {
+ searchIndex = matchIndex + 1
+ continue
+ }
+
+ const rawMatch = html.slice(matchIndex, matchEnd)
if (matchIndex > lastIndex) {
// Save the previous text or CDATA
text = html.substring(lastIndex, matchIndex)
@@ -128,9 +554,10 @@ export default class HTMLParser {
saveBlock('text', text, lastIndex)
}
}
- lastIndex = regTag.lastIndex
+ lastIndex = matchEnd
+ searchIndex = matchEnd
- if ((tagName = match[1])) {
+ if (matchedKind === 'end') {
if (tagCDATA && tagName === tagCDATA) {
// Output CDATA before closing the label
text = arrCDATA.join('')
@@ -145,7 +572,7 @@ export default class HTMLParser {
if (!tagCDATA) {
// End of label
- saveBlock('tagend', match[0], matchIndex, {
+ saveBlock('tagend', rawMatch, matchIndex, {
tagName: tagName,
})
continue
@@ -153,64 +580,30 @@ export default class HTMLParser {
}
if (tagCDATA) {
- arrCDATA.push(match[0])
- } else {
- if ((tagName = match[4])) {
- // Label start
- arrAttrs = []
- const attrs = match[5]
- let attrMatch
- let attrMatchCount = 0
-
- while ((attrMatch = regAttr.exec(attrs))) {
- const name = attrMatch[1]
- const quote = attrMatch[2]
- ? attrMatch[2]
- : attrMatch[4]
- ? attrMatch[4]
- : ''
- const value = attrMatch[3]
- ? attrMatch[3]
- : attrMatch[5]
- ? attrMatch[5]
- : attrMatch[6]
- ? attrMatch[6]
- : ''
-
- arrAttrs.push({
- name: name,
- value: value,
- quote: quote,
- index: attrMatch.index,
- raw: attrMatch[0],
- })
- attrMatchCount += attrMatch[0].length
- }
+ arrCDATA.push(rawMatch)
+ } else if (matchedKind === 'start') {
+ if (parseAttrs(startTagNameEnd, spanAttrsEnd)) {
+ saveBlock('tagstart', rawMatch, matchIndex, {
+ tagName: tagName,
+ attrs: arrAttrs,
+ close: spanClose,
+ })
- if (attrMatchCount === attrs.length) {
- saveBlock('tagstart', match[0], matchIndex, {
- tagName: tagName,
- attrs: arrAttrs,
- close: match[6],
- })
-
- if (isMapCdataTagsRequired()) {
- tagCDATA = tagName
- attrsCDATA = arrAttrs.concat()
- arrCDATA = []
- lastCDATAIndex = lastIndex
- }
- } else {
- // If a miss match occurs, the current content is matched to text
- saveBlock('text', match[0], matchIndex)
+ if (isMapCdataTagsRequired()) {
+ tagCDATA = tagName
+ attrsCDATA = arrAttrs.concat()
+ arrCDATA = []
+ lastCDATAIndex = lastIndex
}
- } else if (match[2] || match[3]) {
- // Comment tag
- saveBlock('comment', match[0], matchIndex, {
- content: match[2] || match[3],
- long: match[2] ? true : false,
- })
+ } else {
+ // If a miss match occurs, the current content is matched to text
+ saveBlock('text', rawMatch, matchIndex)
}
+ } else if (isLongComment || commentContent) {
+ saveBlock('comment', rawMatch, matchIndex, {
+ content: commentContent,
+ long: isLongComment,
+ })
}
}
diff --git a/test/htmlparser.spec.js b/test/htmlparser.spec.js
index 77d9755d8..d293c51b5 100644
--- a/test/htmlparser.spec.js
+++ b/test/htmlparser.spec.js
@@ -654,4 +654,171 @@ describe('HTMLParser: Case parse', () => {
})
parser.parse('
')
})
+
+ it('should parse attributes without whitespace between quoted values', (done) => {
+ const parser = new HTMLParser()
+ const arrEvents = []
+ getAllEvents(parser, arrEvents, () => {
+ expect(arrEvents[1]).toEvent('tagstart', {
+ tagName: 'div',
+ close: '',
+ })
+ const attrs = arrEvents[1].attrs
+ expect(attrs.length).toBe(2)
+ expect(attrs[0].name).toBe('id')
+ expect(attrs[0].value).toBe('abc')
+ expect(attrs[1].name).toBe('class')
+ expect(attrs[1].value).toBe('def')
+ expect(arrEvents[3]).toEvent('tagend', {
+ tagName: 'div',
+ })
+ done()
+ })
+ parser.parse('test
')
+ })
+
+ it('should parse single quoted attributes without whitespace between values', (done) => {
+ const parser = new HTMLParser()
+ const arrEvents = []
+ getAllEvents(parser, arrEvents, () => {
+ expect(arrEvents[1]).toEvent('tagstart', {
+ tagName: 'div',
+ })
+ const attrs = arrEvents[1].attrs
+ expect(attrs.length).toBe(2)
+ expect(attrs[0].name).toBe('id')
+ expect(attrs[0].value).toBe('abc')
+ expect(attrs[1].name).toBe('class')
+ expect(attrs[1].value).toBe('def')
+ done()
+ })
+ parser.parse("test
")
+ })
+
+ it('should parse comment ending with --!> as long comment', (done) => {
+ const parser = new HTMLParser()
+ const arrEvents = []
+ getAllEvents(parser, arrEvents, () => {
+ expect(arrEvents[1]).toEvent('comment', {
+ content: 'comment\r\ntest',
+ long: true,
+ })
+ done()
+ })
+ parser.parse('')
+ })
+
+ it('should parse empty comment ending with --!> as long comment', (done) => {
+ const parser = new HTMLParser()
+ const arrEvents = []
+ getAllEvents(parser, arrEvents, () => {
+ expect(arrEvents[1]).toEvent('comment', {
+ content: '',
+ long: true,
+ raw: '',
+ })
+ done()
+ })
+ parser.parse('')
+ })
+
+ it('should not swallow elements after abruptly closed comments', (done) => {
+ const parser = new HTMLParser()
+ const arrEvents = []
+ getAllEvents(parser, arrEvents, () => {
+ expect(arrEvents[1]).toEvent('comment', {
+ content: '',
+ long: true,
+ raw: '',
+ })
+ expect(arrEvents[2]).toEvent('tagstart', {
+ tagName: 'div',
+ })
+ expect(arrEvents[3]).toEvent('comment', {
+ content: '',
+ long: true,
+ raw: '',
+ })
+ expect(arrEvents[4]).toEvent('tagstart', {
+ tagName: 'span',
+ })
+ expect(arrEvents[5]).toEvent('comment', {
+ content: 'x',
+ long: true,
+ raw: '',
+ })
+ expect(arrEvents[6]).toEvent('comment', {
+ content: '--unfinished',
+ long: false,
+ raw: ' before html5 doctype should not result in an error', () => {
+ const code = ''
+ const messages = HTMLHint.verify(code, ruleOptions)
+ expect(messages.length).toBe(0)
+ })
})
diff --git a/test/rules/tag-pair.spec.js b/test/rules/tag-pair.spec.js
index a86bdc327..01f24ec54 100644
--- a/test/rules/tag-pair.spec.js
+++ b/test/rules/tag-pair.spec.js
@@ -35,6 +35,12 @@ describe(`Rules: ${ruleId}`, () => {
expect(messages[0].col).toBe(3)
})
+ it('Attributes without whitespace between quoted values should still pair', () => {
+ const code = 'test
'
+ const messages = HTMLHint.verify(code, ruleOptions)
+ expect(messages.length).toBe(0)
+ })
+
it('No start tag should result in an error', () => {
const code = ' '
const messages = HTMLHint.verify(code, ruleOptions)
@@ -55,4 +61,10 @@ describe(`Rules: ${ruleId}`, () => {
const messages = HTMLHint.verify(code, ruleOptions)
expect(messages.length).toBe(0)
})
+
+ it('Quoted markup inside malformed empty-assignment tag should not report unclosed inner tag', () => {
+ const code = '">'
+ const messages = HTMLHint.verify(code, ruleOptions)
+ expect(messages.length).toBe(0)
+ })
})