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

+
+
    +
  1. +
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]*?(?:[^\\n]*\\n+|$)' // (1) ++ '<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\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) + '|)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (6) + '|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (7) open tag + '|(?=[ \\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', ')|<(?: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', ')|<(?: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', ')|<(?: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)}

`, + }, +];