diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..91a25ce --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Henrik Joreteg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index b29323a..ec00fa0 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # html-parse-stringify +> **Note:** development of this package continues at [i18next/html-parse-stringify](https://github.com/i18next/html-parse-stringify), where version 4.x and later are maintained. 3.1.0 is the final release from this repository; the npm package name stays `html-parse-stringify`. See [issue #65](https://github.com/HenrikJoreteg/html-parse-stringify/issues/65) for the background. + This is an _experimental lightweight approach_ to enable quickly parsing HTML into an AST and stringify'ing it back to the original string. As it turns out, if you can make a the simplifying assumptions about HTML that all tags must be closed or self-closing. Which is OK for _this_ particular application. You can write a super light/fast parser in JS with regex. @@ -135,6 +137,7 @@ properties: ## changelog +- `3.1.0` Maintenance release, merging long-standing community PRs: LICENSE file shipped in the npm package (#66 by @monholm, closes #61), text containing `<` no longer truncated (#64, closes #59), multi-line attribute values (#63 by @steffanhalv, closes #62), TypeScript declaration shipped and improved (#51/#52 by @jiangfengming, closes #56), text after comment nodes no longer discarded (#53 by @tohosaku). Development continues at [i18next/html-parse-stringify](https://github.com/i18next/html-parse-stringify). - `3.0.1` Merged #47 which makes void elements check case insensitive. Thanks again, [@adrai](https://github.com/adrai) for this contribution! - `3.0.0` Merged #46 which fixed an issue with handling of whitespace. Doing major version bump since this changes behavior if you have whitespace only nodes (see merged PR and #45 for more details). Thanks [@adrai](https://github.com/adrai) for this contribution! - `2.1.1` Merged #41 which fixed an issue with tag nesting. Thanks [@ericponto](https://github.com/ericponto). diff --git a/html-parse-stringify.d.ts b/html-parse-stringify.d.ts index f3a6e9d..fea6b13 100644 --- a/html-parse-stringify.d.ts +++ b/html-parse-stringify.d.ts @@ -1,27 +1,43 @@ -declare var htmlParseStringify: htmlParseStringify.htmlParseStringify +declare module 'html-parse-stringify' { + namespace HTML { + interface TagNode { + type: 'tag'; + name: string; + voidElement: boolean; + attrs: Record; + children: Node[]; + } -declare module htmlParseStringify { - export interface htmlParseStringify { - new (): htmlParseStringify - parse_tag(tag: string): IDoc - parse(html: string, options: IOptions): Array - stringify(doc: IDoc): string - } + interface TextNode { + type: 'text'; + content: string; + } - export interface IDoc { - type: string - content?: string - voidElement: boolean - name: string - attrs: {} - children: IDoc[] - } + interface CommentNode { + type: 'comment'; + comment: string; + } + + interface ComponentNode { + type: 'component'; + name: string; + attrs: Record; + voidElement: boolean; + children: []; + } - export interface IOptions { - components: string[] + type Node = TagNode | TextNode | CommentNode | ComponentNode; + + interface ParseOptions { + components?: Record; + } + + function parse(html: string, options?: ParseOptions): Node[]; + function stringify(doc: Node[]): string; } -} -declare module 'html-parse-stringify' { - export = htmlParseStringify + // the CommonJS build assigns `module.exports = { parse, stringify }` with + // no `default` property, so `export =` is the only declaration shape that + // is correct both with and without esModuleInterop + export = HTML; } diff --git a/package-lock.json b/package-lock.json index 72e098b..c90cd13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "html-parse-stringify", - "version": "3.0.1", + "version": "3.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 609d8e2..8363e28 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "html-parse-stringify", "description": "Parses well-formed HTML (meaning all tags closed) into an AST and back. quickly.", - "version": "3.0.1", + "version": "3.1.0", "author": "Henrik Joreteg ", "bugs": { "url": "https://github.com/henrikjoreteg/html-parse-stringify/issues" @@ -17,7 +17,8 @@ "tape": "5.0.1" }, "files": [ - "dist" + "dist", + "html-parse-stringify.d.ts" ], "homepage": "https://github.com/henrikjoreteg/html-parse-stringify", "keywords": [ @@ -31,6 +32,7 @@ "module": "dist/html-parse-stringify.module.js", "source": "src/index.js", "unpkg": "dist/html-parse-stringify.umd.js", + "types": "html-parse-stringify.d.ts", "prettier": { "arrowParens": "avoid", "singleQuote": true, diff --git a/src/parse-tag.js b/src/parse-tag.js index 570436d..675cf1d 100644 --- a/src/parse-tag.js +++ b/src/parse-tag.js @@ -1,5 +1,5 @@ import lookup from 'void-elements' -const attrRE = /\s([^'"/\s><]+?)[\s/>]|([^\s=]+)=\s?(".*?"|'.*?')/g +const attrRE = /\s([^'"/\s><]+?)[\s/>]|([^\s=]+)=\s?("[^"]*"|'[^']*')/g export default function stringify(tag) { const res = { @@ -13,10 +13,7 @@ export default function stringify(tag) { const tagMatch = tag.match(/<\/?([^\s]+?)[/\s>]/) if (tagMatch) { res.name = tagMatch[1] - if ( - lookup[tagMatch[1]] || - tag.charAt(tag.length - 2) === '/' - ) { + if (lookup[tagMatch[1]] || tag.charAt(tag.length - 2) === '/') { res.voidElement = true } diff --git a/src/parse.js b/src/parse.js index 8caaf64..27b81dc 100644 --- a/src/parse.js +++ b/src/parse.js @@ -24,7 +24,52 @@ export default function parse(html, options) { }) } - html.replace(tagRE, function (tag, index) { + // collect matches with an exec loop instead of matchAll to keep ES5 API compat + const matches = [] + let m + while ((m = tagRE.exec(html))) { + matches.push(m) + } + matches.forEach(function (match, i) { + const tag = match[0] + if (!tag) return + // comments are handled by parseTag as a whole + if (tag.startsWith('` desyncs the string-level isComment check + // from parseTag's name-based comment detection and crashes the walker + const validSplit = + secondLt > -1 && /[a-zA-Z0-9\-!/]/.test(tag.charAt(secondLt + 1)) + if (lts > gts && validSplit) { + const firstPart = tag.substring(0, secondLt) + const secondPart = tag.substring(firstPart.length) + matches[i][0] = secondPart + matches[i].index += firstPart.length + } + }) + matches.forEach(function (match, i) { + const tag = match[0] + if (!tag) return + const index = match.index if (inComponent) { if (tag !== '') { return @@ -36,6 +81,13 @@ export default function parse(html, options) { const isComment = tag.startsWith(' Hi Something' + parsed = HTML.parse(html) + t.deepEqual(parsed, [ + { + type: 'tag', + name: 'div', + voidElement: false, + attrs: {}, + children: [ + { type: 'text', content: ' ' }, + { type: 'comment', comment: ' First comment ' }, + { type: 'text', content: ' ' }, + { + type: 'tag', + name: 'span', + voidElement: false, + attrs: {}, + children: [{ type: 'text', content: 'Hi' }], + }, + { type: 'text', content: ' ' }, + { type: 'comment', comment: ' Another comment ' }, + { type: 'text', content: ' Something' }, + ], + }, + ]) + html = '
oh hello there! How are you?
' parsed = HTML.parse(html) @@ -724,7 +751,7 @@ test('parse', function (t) { children: [ { content: - "\n !function() {\n var cookies = document.cookie ? document.cookie.split(';') : [];\n // | this less than is triggering probems\n for (var i = 0; i ", + "\n !function() {\n var cookies = document.cookie ? document.cookie.split(';') : [];\n // | this less than is triggering probems\n for (var i = 0; i < cookies.length; i++) {\n var splitted = cookies[i].split('=');\n var name = splitted[0];\n }\n }();\n ", type: 'text', }, ], @@ -890,48 +917,57 @@ test('ReDoS vulnerability reported by Sam Sanoop of Snyk', function (t) { test('whitespace', function (t) { let html = '
\n' let parsed = HTML.parse(html) - t.deepEqual(parsed, [{ - type: 'tag', - name: 'div', - attrs: {}, - voidElement: false, - children: [] - }], 'should not explode on trailing whitespace') + t.deepEqual( + parsed, + [ + { + type: 'tag', + name: 'div', + attrs: {}, + voidElement: false, + children: [], + }, + ], + 'should not explode on trailing whitespace' + ) html = '
Hi
\n\n There \t
' parsed = HTML.parse(html) - t.deepEqual(parsed, [{ - type: 'tag', - name: 'div', - attrs: {}, - voidElement: false, - children: [ - { type: 'text', content: 'Hi' } - ] - },{ - type: 'text', - content: ' ' - }, - { - type: 'tag', - name: 'span', - attrs: {}, - voidElement: false, - children: [ - { type: 'text', content: 'There' } - ] - },{ - type: 'text', - content: ' ' - },{ - type: 'tag', - name: 'div', - attrs: {}, - voidElement: false, - children: [ - { type: 'text', content: ' ' } - ] - }], 'should collapse whitespace') + t.deepEqual( + parsed, + [ + { + type: 'tag', + name: 'div', + attrs: {}, + voidElement: false, + children: [{ type: 'text', content: 'Hi' }], + }, + { + type: 'text', + content: ' ', + }, + { + type: 'tag', + name: 'span', + attrs: {}, + voidElement: false, + children: [{ type: 'text', content: 'There' }], + }, + { + type: 'text', + content: ' ', + }, + { + type: 'tag', + name: 'div', + attrs: {}, + voidElement: false, + children: [{ type: 'text', content: ' ' }], + }, + ], + 'should collapse whitespace' + ) // See https://www.w3.org/TR/html4/struct/text.html#h-9.1 t.end() @@ -940,35 +976,225 @@ test('whitespace', function (t) { test('uppercase tags', function (t) { const html = '<0>click here for more' const parsed = HTML.parse(html) - t.deepEqual(parsed, [ - { - "type": "tag", - "name": "0", - "voidElement": false, - "attrs": {}, - "children": [ - { - "type": "text", - "content": "click " - }, - { - "type": "tag", - "name": "Link", - "voidElement": false, - "attrs": {}, - "children": [ - { - "type": "text", - "content": "here" - } - ] - }, - { - "type": "text", - "content": " for more" - } - ] - } - ], 'should handle uppercase tags correctly') + t.deepEqual( + parsed, + [ + { + type: 'tag', + name: '0', + voidElement: false, + attrs: {}, + children: [ + { + type: 'text', + content: 'click ', + }, + { + type: 'tag', + name: 'Link', + voidElement: false, + attrs: {}, + children: [ + { + type: 'text', + content: 'here', + }, + ], + }, + { + type: 'text', + content: ' for more', + }, + ], + }, + ], + 'should handle uppercase tags correctly' + ) t.end() -}) \ No newline at end of file +}) + +test('open tag in html string', function (t) { + const html = '<0>hello under <10
under 10
ok?' + const parsed = HTML.parse(html) + t.deepEqual( + parsed, + [ + { + type: 'tag', + name: '0', + voidElement: false, + attrs: {}, + children: [ + { + type: 'text', + content: 'hello under <10 ', + }, + { + type: 'tag', + name: 'div', + voidElement: false, + attrs: {}, + children: [ + { + type: 'text', + content: 'under 10', + }, + ], + }, + { + type: 'text', + content: ' ok?', + }, + ], + }, + ], + 'should keep text containing < as text content' + ) + t.end() +}) + +test('open tag in html string complex', function (t) { + const html = + 'hello under ten<10 this text after the sign should be renderedEND' + const parsed = HTML.parse(html) + t.deepEqual( + parsed, + [ + { + type: 'text', + content: 'hello ', + }, + { + type: 'tag', + name: 'italic', + voidElement: false, + attrs: {}, + children: [ + { + type: 'text', + content: 'under ten', + }, + ], + }, + { + type: 'text', + content: '<10 this text after the sign should be rendered', + }, + { + type: 'tag', + name: 'bold', + voidElement: false, + attrs: {}, + children: [ + { + type: 'text', + content: 'END', + }, + ], + }, + ], + 'should treat <10 followed by text as text, not as a tag' + ) + t.end() +}) + +test('lt inside quoted attribute values (#67 review)', function (t) { + let html = '
Hello
' + t.deepEqual( + HTML.parse(html), + [ + { + type: 'tag', + name: 'div', + attrs: { title: '1 < 2' }, + voidElement: false, + children: [{ type: 'text', content: 'Hello' }], + }, + ], + 'should keep < inside double-quoted attribute values' + ) + t.equal(HTML.stringify(HTML.parse(html)), html, 'should round-trip') + + html = "
Hello
" + t.deepEqual( + HTML.parse(html)[0].attrs, + { title: '1 < 2' }, + 'should keep < inside single-quoted attribute values' + ) + + html = '
x
' + t.deepEqual( + HTML.parse(html)[0].attrs, + { title: 'a > b < c' }, + 'should keep mixed brackets inside quoted attribute values' + ) + + html = '
x
' + t.deepEqual( + HTML.parse(html)[0].children[0], + { type: 'comment', comment: ' a < b ' }, + 'should not corrupt comments containing <' + ) + t.end() +}) + +test('multiple literal < in text and CRLF attributes (#67 review round 2)', function (t) { + let html = '
1 < 2 < 3
' + t.deepEqual( + HTML.parse(html)[0].children, + [{ type: 'text', content: '1 < 2 < 3' }], + 'should keep text with multiple literal < intact' + ) + t.equal(HTML.stringify(HTML.parse(html)), html, 'should round-trip') + + t.deepEqual( + HTML.parse('x1 < 2 < 3'), + [ + { + type: 'tag', + name: 'span', + attrs: {}, + voidElement: false, + children: [{ type: 'text', content: 'x' }], + }, + { type: 'text', content: '1 < 2 < 3' }, + ], + 'should keep trailing text with multiple literal < intact' + ) + + t.deepEqual( + HTML.parse('
1 < 2
')[0].children, + [ + { type: 'comment', comment: ' c ' }, + { type: 'text', content: '1 < 2' }, + ], + 'should keep text with < after comments intact' + ) + + t.deepEqual( + HTML.parse('
Hello
')[0].attrs, + { title: 'first\r\nsecond' }, + 'should parse CRLF inside double-quoted attribute values' + ) + t.deepEqual( + HTML.parse("
x
")[0].attrs, + { title: 'first\r\nsecond' }, + 'should parse CRLF inside single-quoted attribute values' + ) + t.end() +}) + +test('pathological split fragments must not crash (#67 fuzz)', function (t) { + // the mismatched-bracket split used to produce fragments that parseTag + // saw as comments while the walker treated them as open tags + t.doesNotThrow(function () { + HTML.parse("'") + }, 'split fragment parsing as comment') + t.doesNotThrow(function () { + HTML.parse("<3 a < b =
'") + }, 'fuzzer-found crash input') + t.doesNotThrow(function () { + HTML.parse('
a') + }, 'chained stray < runs') + t.end() +})