Skip to content

Commit 9ce2087

Browse files
committed
Fix smartquotes perfomance
1 parent 02e73b8 commit 9ce2087

3 files changed

Lines changed: 41 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
- More strict entities decode to avoid false positives `;`, #1096.
1818
- Restore block parser state on fail in `lheading` rule, #1131.
1919

20+
### Security
21+
- Fixed poor smartquotes perfomance on > 70k quotes in single block
22+
2023

2124
## [14.1.1] - 2026-01-11
2225
### Security

lib/rules_core/smartquotes.mjs

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,36 @@ const QUOTE_TEST_RE = /['"]/
77
const QUOTE_RE = /['"]/g
88
const APOSTROPHE = '\u2019' /* ’ */
99

10-
function replaceAt (str, index, ch) {
11-
return str.slice(0, index) + ch + str.slice(index + 1)
10+
function addReplacement (replacements, tokenIdx, pos, ch) {
11+
if (!replacements[tokenIdx]) {
12+
replacements[tokenIdx] = []
13+
}
14+
15+
replacements[tokenIdx].push({ pos, ch })
16+
}
17+
18+
function applyReplacements (str, replacements) {
19+
let result = ''
20+
let lastPos = 0
21+
22+
replacements.sort((a, b) => a.pos - b.pos)
23+
24+
for (let i = 0; i < replacements.length; i++) {
25+
const replacement = replacements[i]
26+
27+
result += str.slice(lastPos, replacement.pos) + replacement.ch
28+
lastPos = replacement.pos + 1
29+
}
30+
31+
return result + str.slice(lastPos)
1232
}
1333

1434
function process_inlines (tokens, state) {
1535
let j
1636

1737
const stack = []
38+
// token index -> list of replacements in the original token content
39+
const replacements = {}
1840

1941
for (let i = 0; i < tokens.length; i++) {
2042
const token = tokens[i]
@@ -28,9 +50,9 @@ function process_inlines (tokens, state) {
2850

2951
if (token.type !== 'text') { continue }
3052

31-
let text = token.content
53+
const text = token.content
3254
let pos = 0
33-
let max = text.length
55+
const max = text.length
3456

3557
/* eslint no-labels:0,block-scoped-var:0 */
3658
OUTER:
@@ -122,7 +144,7 @@ function process_inlines (tokens, state) {
122144
if (!canOpen && !canClose) {
123145
// middle of word
124146
if (isSingle) {
125-
token.content = replaceAt(token.content, t.index, APOSTROPHE)
147+
addReplacement(replacements, i, t.index, APOSTROPHE)
126148
}
127149
continue
128150
}
@@ -145,18 +167,8 @@ function process_inlines (tokens, state) {
145167
closeQuote = state.md.options.quotes[1]
146168
}
147169

148-
// replace token.content *before* tokens[item.token].content,
149-
// because, if they are pointing at the same token, replaceAt
150-
// could mess up indices when quote length != 1
151-
token.content = replaceAt(token.content, t.index, closeQuote)
152-
tokens[item.token].content = replaceAt(
153-
tokens[item.token].content, item.pos, openQuote)
154-
155-
pos += closeQuote.length - 1
156-
if (item.token === i) { pos += openQuote.length - 1 }
157-
158-
text = token.content
159-
max = text.length
170+
addReplacement(replacements, i, t.index, closeQuote)
171+
addReplacement(replacements, item.token, item.pos, openQuote)
160172

161173
stack.length = j
162174
continue OUTER
@@ -172,10 +184,14 @@ function process_inlines (tokens, state) {
172184
level: thisLevel
173185
})
174186
} else if (canClose && isSingle) {
175-
token.content = replaceAt(token.content, t.index, APOSTROPHE)
187+
addReplacement(replacements, i, t.index, APOSTROPHE)
176188
}
177189
}
178190
}
191+
192+
Object.keys(replacements).forEach(function (tokenIdx) {
193+
tokens[tokenIdx].content = applyReplacements(tokens[tokenIdx].content, replacements[tokenIdx])
194+
})
179195
}
180196

181197
export default function smartquotes (state) {

test/pathological.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,5 +170,9 @@ describe('Pathological sequences speed', () => {
170170
{ linkify: true }
171171
)
172172
})
173+
174+
it('a lot of smartquotes', async () => {
175+
await test_pattern('"'.repeat(160000), { typographer: true })
176+
})
173177
})
174178
})

0 commit comments

Comments
 (0)