Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2025 Henrik Joreteg <henrik@joreteg.com>

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.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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).
Expand Down
58 changes: 37 additions & 21 deletions html-parse-stringify.d.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>;
children: Node[];
}

declare module htmlParseStringify {
export interface htmlParseStringify {
new (): htmlParseStringify
parse_tag(tag: string): IDoc
parse(html: string, options: IOptions): Array<any>
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<string, string | undefined>;
voidElement: boolean;
children: [];
}

export interface IOptions {
components: string[]
type Node = TagNode | TextNode | CommentNode | ComponentNode;

interface ParseOptions {
components?: Record<string, boolean>;
}

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;
}
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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 <henrik@joreteg.com>",
"bugs": {
"url": "https://github.com/henrikjoreteg/html-parse-stringify/issues"
Expand All @@ -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": [
Expand All @@ -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,
Expand Down
7 changes: 2 additions & 5 deletions src/parse-tag.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import lookup from 'void-elements'
const attrRE = /\s([^'"/\s><]+?)[\s/>]|([^\s=]+)=\s?(".*?"|'.*?')/g

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new multiline pattern explicitly handles \n, but . does not match \r. As a result, attributes containing Windows-style CRLF line endings are still not parsed.

For example:

<div title="first
second">Hello</div>

when the line break is \r\n, produces attrs: {} on this branch rather than:

{
  title: 'first\r\nsecond'
}

Please use a pattern that covers every line terminator, such as quote-specific negated character classes, and add a CRLF regression test alongside the LF case.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 274a47c: attrRE now uses quote-specific negated classes ("[^"]*"|'[^']*'), which covers every line terminator (and drops the backtracking-prone (.|\n)*? while at it). CRLF regression tests added for both quote styles.

const attrRE = /\s([^'"/\s><]+?)[\s/>]|([^\s=]+)=\s?("[^"]*"|'[^']*')/g

export default function stringify(tag) {
const res = {
Expand All @@ -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
}

Expand Down
74 changes: 68 additions & 6 deletions src/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('<!--')) return
// count brackets outside quoted attribute values, so `<` inside an
// attribute (e.g. title="1 < 2") can't trigger a bogus split
let lts = 0
let gts = 0
let secondLt = -1
let quote = null
for (let j = 0; j < tag.length; j++) {
const c = tag.charAt(j)
if (quote) {
if (c === quote) quote = null
} else if (c === '"' || c === "'") {
quote = c
} else if (c === '<') {
lts++
if (lts === 2) secondLt = j
} else if (c === '>') {
gts++
}
}
// only split when the remainder is itself a valid tag start; otherwise
// a fragment like `< <!-->` 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 !== '</' + current.name + '>') {
return
Expand All @@ -36,6 +81,13 @@ export default function parse(html, options) {
const isComment = tag.startsWith('<!--')
const start = index + tag.length
const nextChar = html.charAt(start)
const nextMatch = matches[i + 1]
let isText
if (nextChar === '<' && nextMatch) {
const nextTag = html.substring(start, nextMatch.index)
isText = nextTag.split('<').length > nextTag.split('>').length
}

let parent

if (isComment) {
Expand All @@ -48,6 +100,14 @@ export default function parse(html, options) {
}
parent = arr[level]
parent.children.push(comment)

const text = html.slice(start, nextMatch ? nextMatch.index : undefined)
if (text.length > 0) {
parent.children.push({
type: 'text',
content: text,
})
}
return result
}

Expand All @@ -66,9 +126,11 @@ export default function parse(html, options) {
nextChar &&
nextChar !== '<'
) {
// text content runs to the next actual tag match; stray `<`
// characters in between are part of the text
current.children.push({
type: 'text',
content: html.slice(start, html.indexOf('<', start)),
content: html.slice(start, nextMatch ? nextMatch.index : undefined),
})
}

Expand All @@ -95,15 +157,15 @@ export default function parse(html, options) {
// move current up a level to match the end tag
current = level === -1 ? result : arr[level]
}
if (!inComponent && nextChar !== '<' && nextChar) {
if (!inComponent && (nextChar !== '<' || isText) && nextChar) {
// trailing text node
// if we're at the root, push a base text node. otherwise add as
// a child to the current node.
parent = level === -1 ? result : arr[level].children

// calculate correct end of the content slice in case there's
// no tag after the text node.
const end = html.indexOf('<', start)
// the text node runs to the next actual tag match; -1 means
// there's no tag after it (trailing text)
const end = nextMatch ? nextMatch.index : -1
let content = html.slice(start, end === -1 ? undefined : end)
// if a node is nothing but whitespace, collapse it as the spec states:
// https://www.w3.org/TR/html4/struct/text.html#h-9.1
Expand Down
Loading