From bc2f121456d152fafd7d2cbd2c21b273ca4c6862 Mon Sep 17 00:00:00 2001
From: Sarath Francis
Date: Mon, 13 Jul 2026 22:07:06 -0500
Subject: [PATCH 01/10] fix: treat a line of only tabs as a blank line between
paragraphs (#4007)
---
src/rules.ts | 2 +-
test/specs/new/blank_line_with_tabs.html | 4 ++++
test/specs/new/blank_line_with_tabs.md | 10 ++++++++++
3 files changed, 15 insertions(+), 1 deletion(-)
create mode 100644 test/specs/new/blank_line_with_tabs.html
create mode 100644 test/specs/new/blank_line_with_tabs.md
diff --git a/src/rules.ts b/src/rules.ts
index cdbbeadb93..10cdbb9654 100644
--- a/src/rules.ts
+++ b/src/rules.ts
@@ -128,7 +128,7 @@ const lheadingGfm = edit(lheadingCore)
.replace(/html/g, / {0,3}<[^\n>]+>\n/) // block html can interrupt
.replace(/table/g, / {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/) // table can interrupt
.getRegex();
-const _paragraph = /^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/;
+const _paragraph = /^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table|[ \t]+\n)[^\n]+)*)/;
const blockText = /^[^\n]+/;
const _blockLabel = /(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/;
const def = edit(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/)
diff --git a/test/specs/new/blank_line_with_tabs.html b/test/specs/new/blank_line_with_tabs.html
new file mode 100644
index 0000000000..83dc66c022
--- /dev/null
+++ b/test/specs/new/blank_line_with_tabs.html
@@ -0,0 +1,4 @@
+foo
+bar
+baz
+qux
diff --git a/test/specs/new/blank_line_with_tabs.md b/test/specs/new/blank_line_with_tabs.md
new file mode 100644
index 0000000000..a79bd0f899
--- /dev/null
+++ b/test/specs/new/blank_line_with_tabs.md
@@ -0,0 +1,10 @@
+---
+gfm: false
+---
+foo
+
+bar
+
+baz
+
+qux
From d8573496d732a796193170a9e22c14185aff3aee Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 13 Jul 2026 21:07:35 -0600
Subject: [PATCH 02/10] chore(deps-dev): bump @arethetypeswrong/cli from 0.18.4
to 0.18.5 (#4021)
Signed-off-by: dependabot[bot]
---
package-lock.json | 16 ++++++++--------
package.json | 2 +-
2 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index f839fda358..ebd7125ab1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,7 @@
"marked": "bin/marked.js"
},
"devDependencies": {
- "@arethetypeswrong/cli": "^0.18.4",
+ "@arethetypeswrong/cli": "^0.18.5",
"@markedjs/eslint-config": "^1.0.14",
"@markedjs/testutils": "18.0.0-1",
"@semantic-release/commit-analyzer": "^13.0.1",
@@ -98,13 +98,13 @@
"dev": true
},
"node_modules/@arethetypeswrong/cli": {
- "version": "0.18.4",
- "resolved": "https://registry.npmjs.org/@arethetypeswrong/cli/-/cli-0.18.4.tgz",
- "integrity": "sha512-kNWo6LTzGAuLYPpJ7Sgo63whSUeeSuKMlYx6IBgzs4ONEG807gW4hSSENvpeCHzO2H2wIzG5EFl0OKBbqGBAyA==",
+ "version": "0.18.5",
+ "resolved": "https://registry.npmjs.org/@arethetypeswrong/cli/-/cli-0.18.5.tgz",
+ "integrity": "sha512-gM+8vRsQOD/Uc7EnBedUhkG5OCsDWE4uoak5QvomGpMpaky0Eh41p04nIMgrWb8EOmqZUJGc6zz9hsP6E56R7g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@arethetypeswrong/core": "0.18.4",
+ "@arethetypeswrong/core": "0.18.5",
"chalk": "^4.1.2",
"cli-table3": "^0.6.3",
"commander": "^10.0.1",
@@ -133,9 +133,9 @@
}
},
"node_modules/@arethetypeswrong/core": {
- "version": "0.18.4",
- "resolved": "https://registry.npmjs.org/@arethetypeswrong/core/-/core-0.18.4.tgz",
- "integrity": "sha512-M5F0ePyN6h2Z6XxRiyIPqjGbltotXLjR0CKA0uKspsDu0QmgTNYvRb4RSQPMUs2ZXZHCCYpbaZbFbYOXLxCjUA==",
+ "version": "0.18.5",
+ "resolved": "https://registry.npmjs.org/@arethetypeswrong/core/-/core-0.18.5.tgz",
+ "integrity": "sha512-9ytjzGwxjm9Uz7I9avfbt5vlQt6uk9uRRESzJjqrznl6WKvI6dwYTo+vJ3U02Wrq/mR3iql/PzhvHhKdJIAjDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index 24c972f395..60730e8dae 100644
--- a/package.json
+++ b/package.json
@@ -48,7 +48,7 @@
"html"
],
"devDependencies": {
- "@arethetypeswrong/cli": "^0.18.4",
+ "@arethetypeswrong/cli": "^0.18.5",
"@markedjs/eslint-config": "^1.0.14",
"@markedjs/testutils": "18.0.0-1",
"@semantic-release/commit-analyzer": "^13.0.1",
From eee05c6e6c81fccaae226d4cfa74b29c8d52a999 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 13 Jul 2026 21:08:21 -0600
Subject: [PATCH 03/10] chore(deps-dev): bump eslint from 10.6.0 to 10.7.0
(#4019)
Signed-off-by: dependabot[bot]
---
package-lock.json | 8 ++++----
package.json | 2 +-
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index ebd7125ab1..f4844bc3dc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,7 +26,7 @@
"dts-bundle-generator": "^9.5.1",
"esbuild": "^0.28.1",
"esbuild-plugin-umd-wrapper": "^3.0.0",
- "eslint": "^10.6.0",
+ "eslint": "^10.7.0",
"highlight.js": "^11.11.1",
"markdown-it": "14.3.0",
"marked-highlight": "^2.2.4",
@@ -3254,9 +3254,9 @@
}
},
"node_modules/eslint": {
- "version": "10.6.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz",
- "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==",
+ "version": "10.7.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz",
+ "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
"dev": true,
"license": "MIT",
"workspaces": [
diff --git a/package.json b/package.json
index 60730e8dae..607a0900d8 100644
--- a/package.json
+++ b/package.json
@@ -62,7 +62,7 @@
"dts-bundle-generator": "^9.5.1",
"esbuild": "^0.28.1",
"esbuild-plugin-umd-wrapper": "^3.0.0",
- "eslint": "^10.6.0",
+ "eslint": "^10.7.0",
"highlight.js": "^11.11.1",
"markdown-it": "14.3.0",
"marked-highlight": "^2.2.4",
From 12bfa94389b62438fe1c98e085bd72751c860c41 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 13 Jul 2026 21:08:34 -0600
Subject: [PATCH 04/10] chore(deps-dev): bump semantic-release from 25.0.5 to
25.0.7 (#4020)
Signed-off-by: dependabot[bot]
---
package-lock.json | 8 ++++----
package.json | 2 +-
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index f4844bc3dc..0d06d301cb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -33,7 +33,7 @@
"marked-man": "^2.1.1",
"recheck": "^4.5.0",
"rimraf": "^6.1.3",
- "semantic-release": "^25.0.5",
+ "semantic-release": "^25.0.7",
"titleize": "^4.0.0",
"tslib": "^2.8.1",
"typescript": "6.0.3"
@@ -7713,9 +7713,9 @@
"license": "MIT"
},
"node_modules/semantic-release": {
- "version": "25.0.5",
- "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.5.tgz",
- "integrity": "sha512-mn61SUJwtM8ThrWn2WmgLVpwVJeG/hPSupua1psdMoufmwRIPyvRLkRkL0JDXkP67OntlLWUYnBnfVc8EDO3/g==",
+ "version": "25.0.7",
+ "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.7.tgz",
+ "integrity": "sha512-fFiUD7LNFI0TOGkc49WDHUX4GYKrOMDKct5ReSB1EJCZiPsPdbIPIJGys1xb2ILpK1v9JMsRkjJQgTRpbr7DgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index 607a0900d8..f38c10d283 100644
--- a/package.json
+++ b/package.json
@@ -69,7 +69,7 @@
"marked-man": "^2.1.1",
"recheck": "^4.5.0",
"rimraf": "^6.1.3",
- "semantic-release": "^25.0.5",
+ "semantic-release": "^25.0.7",
"titleize": "^4.0.0",
"tslib": "^2.8.1",
"typescript": "6.0.3"
From f056437b389407ab3a29fe9cb61a989f043edb3e Mon Sep 17 00:00:00 2001
From: Anh Hong
Date: Wed, 15 Jul 2026 05:06:36 +0700
Subject: [PATCH 05/10] fix: Recognize setext headings whose first line starts
with # (#4015)
A line like `#1 Goals` or `#hashtag` followed by a setext underline was
rendered as a paragraph plus `
` instead of a heading. A line only
starts an ATX heading when the `#`s are followed by whitespace or EOL, so
these are paragraph text and the underline makes a setext heading. The
lheading ATX-interrupt check was bare ` {0,3}#{1,6}`, missing the
`(?:\s|$)` that the paragraph and ATX heading rules already require.
---
src/rules.ts | 4 ++--
test/specs/new/lheading_hash_prefix.html | 3 +++
test/specs/new/lheading_hash_prefix.md | 8 ++++++++
3 files changed, 13 insertions(+), 2 deletions(-)
create mode 100644 test/specs/new/lheading_hash_prefix.html
create mode 100644 test/specs/new/lheading_hash_prefix.md
diff --git a/src/rules.ts b/src/rules.ts
index 10cdbb9654..beff8badee 100644
--- a/src/rules.ts
+++ b/src/rules.ts
@@ -115,7 +115,7 @@ const lheading = edit(lheadingCore)
.replace(/blockCode/g, /(?: {4}| {0,3}\t)/) // indented code blocks can interrupt
.replace(/fences/g, / {0,3}(?:`{3,}|~{3,})/) // fenced code blocks can interrupt
.replace(/blockquote/g, / {0,3}>/) // blockquote can interrupt
- .replace(/heading/g, / {0,3}#{1,6}/) // ATX heading can interrupt
+ .replace(/heading/g, / {0,3}#{1,6}(?:\s|$)/) // ATX heading can interrupt
.replace(/html/g, / {0,3}<[^\n>]+>\n/) // block html can interrupt
.replace(/\|table/g, '') // table not in commonmark
.getRegex();
@@ -124,7 +124,7 @@ const lheadingGfm = edit(lheadingCore)
.replace(/blockCode/g, /(?: {4}| {0,3}\t)/) // indented code blocks can interrupt
.replace(/fences/g, / {0,3}(?:`{3,}|~{3,})/) // fenced code blocks can interrupt
.replace(/blockquote/g, / {0,3}>/) // blockquote can interrupt
- .replace(/heading/g, / {0,3}#{1,6}/) // ATX heading can interrupt
+ .replace(/heading/g, / {0,3}#{1,6}(?:\s|$)/) // ATX heading can interrupt
.replace(/html/g, / {0,3}<[^\n>]+>\n/) // block html can interrupt
.replace(/table/g, / {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/) // table can interrupt
.getRegex();
diff --git a/test/specs/new/lheading_hash_prefix.html b/test/specs/new/lheading_hash_prefix.html
new file mode 100644
index 0000000000..b5c5ec34aa
--- /dev/null
+++ b/test/specs/new/lheading_hash_prefix.html
@@ -0,0 +1,3 @@
+#1 Goals
+#hashtag
+####### seven
diff --git a/test/specs/new/lheading_hash_prefix.md b/test/specs/new/lheading_hash_prefix.md
new file mode 100644
index 0000000000..4d88c47bf3
--- /dev/null
+++ b/test/specs/new/lheading_hash_prefix.md
@@ -0,0 +1,8 @@
+#1 Goals
+---------
+
+#hashtag
+===
+
+####### seven
+===
\ No newline at end of file
From 0de7188bf1f30511490c961d53865390e7ede956 Mon Sep 17 00:00:00 2001
From: Luke Parker <10430890+Hona@users.noreply.github.com>
Date: Wed, 15 Jul 2026 08:07:23 +1000
Subject: [PATCH 06/10] fix: preserve code spans adjacent to tildes (#4012)
* fix: preserve code spans adjacent to tildes
* perf: isolate mixed delimiter boundaries
* refactor: keep delimiter boundaries in grammar
---
src/rules.ts | 2 +-
test/specs/new/codespan_adjacent_tilde.html | 5 +++++
test/specs/new/codespan_adjacent_tilde.md | 9 +++++++++
3 files changed, 15 insertions(+), 1 deletion(-)
create mode 100644 test/specs/new/codespan_adjacent_tilde.html
create mode 100644 test/specs/new/codespan_adjacent_tilde.md
diff --git a/src/rules.ts b/src/rules.ts
index beff8badee..fd0ee3bb69 100644
--- a/src/rules.ts
+++ b/src/rules.ts
@@ -475,7 +475,7 @@ const inlineGfm: Record = {
.getRegex(),
_backpedal: /(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,
del: /^(~~?)(?=[^\s~])((?:\\[\s\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/,
- text: edit(/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\~code
+before~after
+`strike
+~code ` tick
+deleted code
diff --git a/test/specs/new/codespan_adjacent_tilde.md b/test/specs/new/codespan_adjacent_tilde.md
new file mode 100644
index 0000000000..c7506a61f1
--- /dev/null
+++ b/test/specs/new/codespan_adjacent_tilde.md
@@ -0,0 +1,9 @@
+~`code`
+
+`before`~`after`
+
+`~strike~
+
+~``code ` tick``
+
+~~`deleted code`~~
From 3f144a0e93b515877d5f55b92039d87eea9a6b04 Mon Sep 17 00:00:00 2001
From: spokodev
Date: Tue, 14 Jul 2026 23:08:05 +0100
Subject: [PATCH 07/10] fix: keep empty list after blockquote as a sibling
block (#4004)
A blockquote followed by a bare list marker line (for example
`> foo\n-`) wrongly nested an empty list inside the blockquote.
The blockquote regex reuses the paragraph list-interrupt clause
` {0,3}(?:[*+-]|1[.)])[ \t]+[^ \t\n]`, whose trailing `[ \t]+[^ \t\n]`
requires content after the marker. A bare marker line therefore was
not seen as an interruption and got lazily continued into the
blockquote paragraph, then re-lexed into a nested empty list.
Give the blockquote its own paragraph variant whose list-interrupt
clause also matches a bare marker, so the list ends the blockquote and
becomes a sibling block. The top level paragraph rule is unchanged, so
an empty list still cannot interrupt a paragraph.
---
src/rules.ts | 6 ++++--
test/specs/new/empty_list_after_blockquote.html | 13 +++++++++++++
test/specs/new/empty_list_after_blockquote.md | 6 ++++++
3 files changed, 23 insertions(+), 2 deletions(-)
create mode 100644 test/specs/new/empty_list_after_blockquote.html
create mode 100644 test/specs/new/empty_list_after_blockquote.md
diff --git a/src/rules.ts b/src/rules.ts
index fd0ee3bb69..ff159f09d7 100644
--- a/src/rules.ts
+++ b/src/rules.ts
@@ -177,8 +177,10 @@ const createParagraph = (listInterrupt: RegExp) => edit(_paragraph)
// only non-empty lists starting from 1 can interrupt paragraphs
const paragraph = createParagraph(/ {0,3}(?:[*+-]|1[.)])[ \t]+[^ \t\n]/);
-// blockquotes can be interrupted by lists starting from any number
-const blockquoteParagraph = createParagraph(/ {0,3}(?:[*+-]|\d{1,9}[.)])[ \t]+[^ \t\n]/);
+// inside a blockquote a bare list marker (any number) starts a sibling list,
+// so it must not be lazily continued as paragraph text (unlike a top level
+// paragraph, where an empty list cannot interrupt)
+const blockquoteParagraph = createParagraph(/ {0,3}(?:[*+-]|\d{1,9}[.)])(?:[ \t]|\n|$)/);
const blockquote = edit(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/)
.replace('paragraph', blockquoteParagraph)
diff --git a/test/specs/new/empty_list_after_blockquote.html b/test/specs/new/empty_list_after_blockquote.html
new file mode 100644
index 0000000000..0ea2962633
--- /dev/null
+++ b/test/specs/new/empty_list_after_blockquote.html
@@ -0,0 +1,13 @@
+
+foo
+
+
+
+foo
+bar
+
+
+
+
diff --git a/test/specs/new/empty_list_after_blockquote.md b/test/specs/new/empty_list_after_blockquote.md
new file mode 100644
index 0000000000..33ee472888
--- /dev/null
+++ b/test/specs/new/empty_list_after_blockquote.md
@@ -0,0 +1,6 @@
+> foo
+-
+
+> foo
+> bar
+1.
From f945fc5e106c9bbeac833c60edc543aa5ab3222c Mon Sep 17 00:00:00 2001
From: Anh Hong
Date: Wed, 15 Jul 2026 05:08:51 +0700
Subject: [PATCH 08/10] fix: Avoid O(n^2) backtracking in HTML block close and
tilde interrupt regexes (#4014)
* fix: Avoid O(n^2) backtracking in HTML block close regex
The close branches end in `[^\n]*\n+`; the trailing `\n+` requires a
newline, so at EOF the close can't match and the engine retries every
split of the lazy `[\s\S]*?` before falling through to `$`, which is
O(n^2). `\n*` closes on first match and consumes identical text whenever
a trailing newline is present (the `[^\n]*` was added in #3991).
* fix: Avoid O(n^2) backtracking in tilde paragraph interrupt regex
The backtick branch is guarded by a lookahead but `~{3,}` isn't, and it
overlaps the following `[^\n]*`, so a long newline-less tilde run
backtracks quadratically. Since `~{3,}` is always followed by `[^\n]*`,
`~~~` matches the same strings without the overlap. The real fences
tokenizer is left untouched.
---
src/rules.ts | 14 +++++++-------
test/specs/redos/quadratic_html_block_close.cjs | 4 ++++
.../redos/quadratic_tilde_paragraph_interrupt.cjs | 4 ++++
3 files changed, 15 insertions(+), 7 deletions(-)
create mode 100644 test/specs/redos/quadratic_html_block_close.cjs
create mode 100644 test/specs/redos/quadratic_tilde_paragraph_interrupt.cjs
diff --git a/src/rules.ts b/src/rules.ts
index ff159f09d7..6c42de3083 100644
--- a/src/rules.ts
+++ b/src/rules.ts
@@ -149,11 +149,11 @@ const _tag = 'address|article|aside|base|basefont|blockquote|body|caption'
const _comment = /|$))/;
const html = edit(
'^ {0,3}(?:' // optional indentation
-+ '<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:\\1>[^\\n]*\\n+|$)' // (1)
++ '<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:\\1>[^\\n]*\\n*|$)' // (1)
+ '|comment[^\\n]*(\\n+|$)' // (2)
-+ '|<\\?[\\s\\S]*?(?:\\?>[^\\n]*\\n+|$)' // (3)
-+ '|[^\\n]*\\n+|$)' // (4)
-+ '|[^\\n]*\\n+|$)' // (5)
++ '|<\\?[\\s\\S]*?(?:\\?>[^\\n]*\\n*|$)' // (3)
++ '|[^\\n]*\\n*|$)' // (4)
++ '|[^\\n]*\\n*|$)' // (5)
+ '|?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (6)
+ '|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (7) open tag
+ '|(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (7) closing tag
@@ -169,7 +169,7 @@ const createParagraph = (listInterrupt: RegExp) => edit(_paragraph)
.replace('|lheading', '') // setext headings don't interrupt commonmark paragraphs
.replace('|table', '')
.replace('blockquote', ' {0,3}>')
- .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n')
+ .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~~~)[^\\n]*\\n')
.replace('list', listInterrupt)
.replace('html', '?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)')
.replace('tag', _tag) // pars can be interrupted by type (6) html blocks
@@ -220,7 +220,7 @@ const gfmTable = edit(
.replace('heading', ' {0,3}#{1,6}(?:\\s|$)')
.replace('blockquote', ' {0,3}>')
.replace('code', '(?: {4}| {0,3}\t)[^\\n]')
- .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n')
+ .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~~~)[^\\n]*\\n')
.replace('list', ' {0,3}(?:[*+-]|1[.)])[ \\t]') // any bullet ends the table rows
.replace('html', '?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)')
.replace('tag', _tag) // tables can be interrupted by type (6) html blocks
@@ -236,7 +236,7 @@ const blockGfm: Record = {
.replace('|lheading', '') // setext headings don't interrupt commonmark paragraphs
.replace('table', gfmTable) // interrupt paragraphs with table
.replace('blockquote', ' {0,3}>')
- .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n')
+ .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~~~)[^\\n]*\\n')
.replace('list', ' {0,3}(?:[*+-]|1[.)])[ \\t]+[^ \\t\\n]') // only non-empty lists starting from 1 can interrupt
.replace('html', '?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)')
.replace('tag', _tag) // pars can be interrupted by type (6) html blocks
diff --git a/test/specs/redos/quadratic_html_block_close.cjs b/test/specs/redos/quadratic_html_block_close.cjs
new file mode 100644
index 0000000000..b5c753f6df
--- /dev/null
+++ b/test/specs/redos/quadratic_html_block_close.cjs
@@ -0,0 +1,4 @@
+module.exports = {
+ markdown: ''.repeat(50000),
+ html: ''.repeat(50000),
+};
diff --git a/test/specs/redos/quadratic_tilde_paragraph_interrupt.cjs b/test/specs/redos/quadratic_tilde_paragraph_interrupt.cjs
new file mode 100644
index 0000000000..b9e9c4c096
--- /dev/null
+++ b/test/specs/redos/quadratic_tilde_paragraph_interrupt.cjs
@@ -0,0 +1,4 @@
+module.exports = {
+ markdown: 'intro\n' + '~'.repeat(50000),
+ html: `intro\n${'~'.repeat(50000)}
\n`,
+};
From 9154f8f3cb2fa136d85b61e1eacc4ef381a3b629 Mon Sep 17 00:00:00 2001
From: Anh Hong
Date: Wed, 15 Jul 2026 10:05:11 +0700
Subject: [PATCH 09/10] fix: Avoid O(n^2) masked source rebuild in inline
tokenizer (#4017)
---
src/Lexer.ts | 25 +++++++------------
test/specs/redos/quadratic_inline_masking.cjs | 14 +++++++++++
2 files changed, 23 insertions(+), 16 deletions(-)
create mode 100644 test/specs/redos/quadratic_inline_masking.cjs
diff --git a/src/Lexer.ts b/src/Lexer.ts
index 9efbdd38c8..89e291f19c 100644
--- a/src/Lexer.ts
+++ b/src/Lexer.ts
@@ -304,33 +304,26 @@ export class _Lexer {
this.tokenizer.lexer = this;
// String with links masked to avoid interference with em and strong
let maskedSrc = src;
- let match: RegExpExecArray | null = null;
// Mask out reflinks
if (this.tokens.links) {
const links = Object.keys(this.tokens.links);
if (links.length > 0) {
- while ((match = this.tokenizer.rules.inline.reflinkSearch.exec(maskedSrc)) !== null) {
- if (links.includes(match[0].slice(match[0].lastIndexOf('[') + 1, -1))) {
- maskedSrc = maskedSrc.slice(0, match.index)
- + '[' + 'a'.repeat(match[0].length - 2) + ']'
- + maskedSrc.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex);
- }
- }
+ maskedSrc = maskedSrc.replace(this.tokenizer.rules.inline.reflinkSearch, match0 =>
+ links.includes(match0.slice(match0.lastIndexOf('[') + 1, -1))
+ ? '[' + 'a'.repeat(match0.length - 2) + ']'
+ : match0);
}
}
// Mask out escaped characters
- while ((match = this.tokenizer.rules.inline.anyPunctuation.exec(maskedSrc)) !== null) {
- maskedSrc = maskedSrc.slice(0, match.index) + '++' + maskedSrc.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);
- }
+ maskedSrc = maskedSrc.replace(this.tokenizer.rules.inline.anyPunctuation, '++');
// Mask out other blocks
- let offset;
- while ((match = this.tokenizer.rules.inline.blockSkip.exec(maskedSrc)) !== null) {
- offset = match[2] ? match[2].length : 0;
- maskedSrc = maskedSrc.slice(0, match.index + offset) + '[' + 'a'.repeat(match[0].length - offset - 2) + ']' + maskedSrc.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);
- }
+ maskedSrc = maskedSrc.replace(this.tokenizer.rules.inline.blockSkip, (match0, _link, context) => {
+ const offset = context ? context.length : 0;
+ return match0.slice(0, offset) + '[' + 'a'.repeat(match0.length - offset - 2) + ']';
+ });
// Mask out blocks from extensions
maskedSrc = this.options.hooks?.emStrongMask?.call({ lexer: this }, maskedSrc) ?? maskedSrc;
diff --git a/test/specs/redos/quadratic_inline_masking.cjs b/test/specs/redos/quadratic_inline_masking.cjs
new file mode 100644
index 0000000000..6418a770cd
--- /dev/null
+++ b/test/specs/redos/quadratic_inline_masking.cjs
@@ -0,0 +1,14 @@
+module.exports = [
+ {
+ markdown: '\\.'.repeat(100000),
+ html: `${'.'.repeat(100000)}
`,
+ },
+ {
+ markdown: '`x` '.repeat(60000),
+ html: `${'x '.repeat(60000)}
`,
+ },
+ {
+ markdown: '[a]: x\n\n' + '[a] '.repeat(100000),
+ html: `${'a '.repeat(100000)}
`,
+ },
+];
From c19413b06003ad793e9571604e21d175325f8d87 Mon Sep 17 00:00:00 2001
From: hong4rc
Date: Sun, 19 Jul 2026 09:41:20 +0700
Subject: [PATCH 10/10] fix: Avoid O(n^2) backtracking in GFM email autolink
regexes
---
src/rules.ts | 4 ++--
test/specs/redos/quadratic_email_autolink.cjs | 10 ++++++++++
2 files changed, 12 insertions(+), 2 deletions(-)
create mode 100644 test/specs/redos/quadratic_email_autolink.cjs
diff --git a/src/rules.ts b/src/rules.ts
index 6c42de3083..eb1b8d367d 100644
--- a/src/rules.ts
+++ b/src/rules.ts
@@ -473,11 +473,11 @@ const inlineGfm: Record = {
delRDelim,
url: edit(/^((?:protocol):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/)
.replace('protocol', _caseInsensitiveProtocol)
- .replace('email', /[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/)
+ .replace('email', /[A-Za-z0-9._+-]{1,64}(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/)
.getRegex(),
_backpedal: /(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,
del: /^(~~?)(?=[^\s~])((?:\\[\s\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/,
- text: edit(/^(`+|~+|[^`~])(?:(?=[`~])|(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\${'a_'.repeat(50000)}`,
+ },
+ {
+ markdown: 'a*'.repeat(50000),
+ html: `${'aa'.repeat(25000)}
`,
+ },
+];