3.1.0: long-standing community fixes + release prep#67
Conversation
Fixes #61
There was a problem hiding this comment.
Pull request overview
This PR prepares the 3.1.0 maintenance release by merging several long-standing community fixes (parser correctness, TypeScript types, packaging, docs) and doing release-prep cleanup to keep compatibility and improve coverage.
Changes:
- Updates the HTML parsing logic to better handle edge cases like
<appearing in text and text following comment nodes, including additional regression tests. - Extends attribute parsing to support multi-line attribute values.
- Adds/ships TypeScript declarations, adds LICENSE, and updates README changelog/repo-maintenance note.
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/parse.js |
Refactors tag scanning and adjusts text/comment handling to address multiple parsing edge cases. |
src/parse-tag.js |
Updates attribute regex to support multi-line quoted values. |
test/parse.js |
Adds/updates regression tests for comment-following text and <10 ... text vs tag parsing. |
html-parse-stringify.d.ts |
Reworks TypeScript typings and publishes them via package.json. |
package.json |
Ships .d.ts in the npm tarball and sets the types entrypoint. |
README.md |
Adds 3.1.0 changelog entry and maintenance notice pointing to the i18next org repo. |
LICENSE |
Adds the MIT license file to the repository/distribution. |
Comments suppressed due to low confidence (1)
src/parse.js:73
- When parsing a comment node at the root level (level < 0), the code returns early after pushing the comment, so any text immediately following the comment (e.g.
<!--c-->text) will be dropped. Also,html.slice(start, html.indexOf('<', start))truncates the last character when there is no subsequent<(becauseindexOfreturns -1 andslice(..., -1)drops the last char). Consider pushing trailing text for both root and nested comments, and handle the-1case the same way the trailing-text logic does.
if (isComment) {
const comment = parseTag(tag)
// if we're at root, push new base node
if (level < 0) {
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Both Copilot nits addressed in 25a332c (assertion messages now describe what each test actually validates). |
| if (!tag) return | ||
| const amountOfLts = tag.split('<').length | ||
| const amountOfGts = tag.split('>').length | ||
| if (amountOfLts > 0 && amountOfLts > amountOfGts) { |
There was a problem hiding this comment.
This count includes < characters inside quoted attribute values, causing valid inputs to be corrupted.
For example:
<div title="1 < 2">Hello</div>This parses correctly on master, but on this branch it returns a node with:
{
name: '',
attrs: {}
}It then stringifies as:
<>Hello</>Please make this detection quote-aware and add regression tests for < inside both single- and double-quoted attribute values.
There was a problem hiding this comment.
Great catch, thank you! Fixed in f324559: the pre-pass now counts brackets outside quoted sections only, and comment matches are skipped entirely (comments containing < hit the same bogus split). Regression tests added for single- and double-quoted attribute values, mixed </> inside attributes, and comments containing < — plus re-verified the corpus diff against published 3.0.1 (your cases now parse identically to master) and react-i18next's full suite (493/493).
The lts/gts counting introduced in #64 also counted brackets inside quoted attribute values, corrupting valid inputs like <div title="1 < 2"> into <>...</>. Comments containing < hit the same bogus split. The pre-pass now scans outside quotes only and leaves comment matches alone. Found by @frontmesh in #67 review. Adds regression tests for single- and double-quoted attribute values, mixed brackets, and comments containing <.
| function stringify(doc: Node[]): string; | ||
| } | ||
|
|
||
| export default HtmlParseStringify; |
There was a problem hiding this comment.
The package’s CommonJS entry point exports the API directly:
module.exports = {
parse,
stringify
}However, this declaration describes a default export. When TypeScript targets CommonJS without esModuleInterop, a default import is emitted as:
require('html-parse-stringify').defaultThe runtime module has no .default property, so this evaluates to undefined. This declaration also rejects the CommonJS-compatible TypeScript syntax:
import HTML = require('html-parse-stringify')The previous export = HtmlParseStringify declaration matched the CommonJS runtime. Please retain that export shape or update the runtime/package exports to provide a real default export.
There was a problem hiding this comment.
Fixed in 274a47c: the declaration now uses export = with a namespace, matching the actual module.exports = { parse, stringify } shape, so it's correct both with and without esModuleInterop (and import HTML = require(...) works again).
|
|
||
| export interface IOptions { | ||
| components: string[] | ||
| export type Node = TagNode | TextNode | ComponentNode; |
There was a problem hiding this comment.
parse() returns comment nodes at runtime:
{
type: 'comment',
comment: string
}stringify() also handles this node type, but the new Node union excludes it. Consequently, TypeScript consumers cannot represent valid parser output as Node or pass a manually constructed comment node to stringify() without a cast.
Please add a corresponding type and include it in the union:
export interface CommentNode {
type: 'comment';
comment: string;
}
export type Node =
| TagNode
| TextNode
| ComponentNode
| CommentNode;There was a problem hiding this comment.
Fixed in 274a47c: CommentNode added and included in the Node union.
| @@ -1,5 +1,5 @@ | |||
| import lookup from 'void-elements' | |||
| const attrRE = /\s([^'"/\s><]+?)[\s/>]|([^\s=]+)=\s?(".*?"|'.*?')/g | |||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| indexOfPossibleContent + possibleContent.length + 1 | ||
| const nextLt = html.indexOf('<', startAfterPossibleContent) | ||
| const nextGt = html.indexOf('>', startAfterPossibleContent) | ||
| if (nextLt > -1 && nextLt < nextGt) { |
There was a problem hiding this comment.
The new text-recovery logic extends the content only as far as the next <, so it handles one literal < but still truncates text containing several.
For example:
<div>1 < 2 < 3</div>This branch produces:
{
type: 'text',
content: '1 < 2 '
}The remaining < 3 is discarded. Please continue scanning until the next actual tag match rather than stopping at the second <, and add a regression test containing multiple literal less-than characters.
There was a problem hiding this comment.
Fixed in 274a47c: text nodes now extend to the next actual tag match instead of the next literal < (applied to all three text paths: child text, trailing text, and text after comments). Interesting side effect: this also fixed two long-standing quirks that predate this PR — unclosed trailing text dropped its last character (<div>text parsed as tex), and text was truncated at < followed by > (<div>5 < 6 and 7 > 3</div> parsed as 5 ). Regression tests added, corpus re-diffed against 3.0.1 (every diff is an intended fix), react-i18next 493/493.
- types: export = declaration matching the CommonJS module shape (works with and without esModuleInterop), add missing CommentNode to the union - parse-tag: quote-specific character classes in attrRE so attribute values with CRLF (or any line terminator) parse correctly - parse: text nodes now extend to the next actual tag match instead of the next literal <, fixing truncation of text with multiple < and, as a side effect, two long-standing quirks (last character dropped from unclosed trailing text, text truncated at < followed by >) Regression tests for each case.
A 30k-case differential fuzz against 3.0.1 found one crash: the mismatched-bracket split could produce a fragment (e.g. `< <!-->`) whose parsed name is a comment while the walker treats the match as an open tag, crashing on the missing children array. The split now only happens when the remainder is itself a valid tag start. Side effect: parse/stringify is now round-trip stable across the entire fuzz corpus (the invalid splits were the only source of instability). Regression tests added.
|
Extra hardening round: ran a 30,000-case seeded differential fuzz of this branch against published 3.0.1 (plus a 52-input edge-case matrix around every changed code path, timing probes on adversarial inputs, and round-trip stability checks). Found and fixed one crash on pathological input (split fragment parsing as a comment; regression tests added). After the fix the branch has zero crashes and is parse/stringify round-trip stable across the entire fuzz corpus, which 3.0.1 only achieves by silently dropping text. All timing probes flat (≤20ms on 50KB adversarial inputs), react-i18next suite still 493/493. |
frontmesh
left a comment
There was a problem hiding this comment.
Thanks for addressing all the review findings. Nice work!
There was a problem hiding this comment.
Let's update the version number here
There was a problem hiding this comment.
Done — bumped to 3.1.0 (package.json + lockfile version field).
This is the maintenance release announced in #65 (posted July 10, no objections in the 10-day window).
Branch protection requires one approving review, and direct pushes to master are blocked, so this PR bundles the whole release. One approval from any collaborator with write access merges everything. Happy to split it up if anyone prefers.
What's inside
Merge commits for the long-standing community PRs (authorship preserved; GitHub will mark them merged automatically):
<no longer truncated, closes can't handle parse vue sfc content correctly #59Plus one cleanup commit on top:
matchAllwith an exec loop (matchAllis ES2020; keeps the ES5 API compatibility the package always had)Verification