Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
789b567
feat(extension-find-and-replace): support capture groups in replacements
Aslam97 Jul 28, 2026
0956eef
add changeset
Aslam97 Jul 28, 2026
b34baa8
correct capture replacement edge cases
Aslam97 Jul 28, 2026
c0fcd3a
Merge branch 'main' of github.com:ueberdosis/tiptap into feat/find-an…
Aslam97 Jul 30, 2026
91e9144
chore: allow regex search to use whole-word together with match case
Aslam97 Jul 30, 2026
a22b4df
update changeset
Aslam97 Jul 30, 2026
5508932
update demo
Aslam97 Jul 30, 2026
728ccc5
add comments for better understanding
Aslam97 Jul 31, 2026
93f1d3d
Merge branch 'main' into feat/find-and-replace-capture-groups
Aslam97 Aug 6, 2026
d7ab36b
feat(extension-find-and-replace): support capture groups in regex rep…
bdbch Aug 10, 2026
e6f8459
feat(extension-find-and-replace): update demo content to better showc…
bdbch Aug 10, 2026
5ee34ed
feat(extension-find-and-replace): update demo content to better showc…
bdbch Aug 10, 2026
d3a392f
refactor(extension-find-and-replace): simplify comments in find-safe-…
bdbch Aug 10, 2026
168338b
refactor(extension-find-and-replace): simplify search-matcher comments
bdbch Aug 10, 2026
359cc82
Update .changeset/moody-sheep-kiss.md
bdbch Aug 10, 2026
523b9e4
test(extension-find-and-replace): add tests for findSafeMatcherMatche…
bdbch Aug 10, 2026
14746b1
docs(extension-find-and-replace): expand JSDoc for findSafeMatcherMat…
bdbch Aug 10, 2026
1094147
test(extension-find-and-replace): remove native RegExp tests and enfo…
bdbch Aug 10, 2026
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
8 changes: 8 additions & 0 deletions .changeset/moody-sheep-kiss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@tiptap/extension-find-and-replace': minor
---

Add JavaScript-style capture-group substitutions to replacement terms in regex mode.
Allow regular-expression searches to use whole-word matching together with match case.
Regex alternatives and quantifiers are resolved by the safe regex engine within Unicode-aware
word boundaries, and capture-based replacements keep their original group numbering.
5 changes: 3 additions & 2 deletions demos/src/Extensions/FindAndReplace/React/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ export default () => {
</p>
<p>
Toggle "Match case" to only find exact matches. Enable "Regex" and search
for colou?r to find both color and colour at once. Hit "Replace" to swap
the current match and jump straight to the next one.
for (color|colour) to find both spellings, then replace with [$1] to reuse
each captured match. Hit "Replace" to swap the current match and jump
straight to the next one.
</p>
`,
})
Expand Down
5 changes: 3 additions & 2 deletions demos/src/Extensions/FindAndReplace/Vue/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,9 @@ const content = `
</p>
<p>
Toggle "Match case" to only find exact matches. Enable "Regex" and search
for colou?r to find both color and colour at once. Hit "Replace" to swap
the current match and jump straight to the next one.
for (color|colour) to find both spellings, then replace with [$1] to reuse
each captured match. Hit "Replace" to swap the current match and jump
straight to the next one.
</p>
`

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { createSearchRegex } from '@tiptap/extension-find-and-replace'
import { describe, expect, it } from 'vitest'

import { expandReplacement, hasReplacementTokens } from '../src/utils/expandReplacement.js'

function createRegex(source: string) {
const regex = createSearchRegex(source, {
caseSensitive: true,
useRegex: true,
wholeWord: false,
})

if (!regex) {
throw new Error(`Could not compile test regex: ${source}`)
}

return regex
}

describe('expandReplacement', () => {
it('detects only supported replacement tokens', () => {
expect(['$$', '$&', '$1', '$01', '$99', '$<word>'].map(hasReplacementTokens)).toEqual([
true,
true,
true,
true,
true,
true,
])
expect(['$', '$0', '$00', '$x', '$`', "$'"].map(hasReplacementTokens)).toEqual([
false,
false,
false,
false,
false,
false,
])
})

it('expands dollar, whole-match, and numbered capture tokens', () => {
const regex = createRegex('(cat)')

expect(expandReplacement(regex, 'cat', '$$ [$&] $1 $9 $x')).toBe('$ [cat] cat $9 $x')
})

it('uses JavaScript two-digit capture fallback semantics', () => {
const regex = createRegex('(a)(b)?')

expect(expandReplacement(regex, 'a', '$1:$2:$12:$99')).toBe('a::a2:$99')
})

it('expands named captures and removes unmatched or unknown named captures', () => {
const regex = createRegex('(?<word>cat)(?<suffix>s)?')

expect(expandReplacement(regex, 'cat', '$<word>:$<suffix>:$<missing>')).toBe('cat::')
})

it('keeps named references literal when the pattern has no named captures', () => {
const regex = createRegex('(cat)')

expect(expandReplacement(regex, 'cat', '$<word>')).toBe('$<word>')
})

it('keeps before-match and after-match tokens literal', () => {
const regex = createRegex('(cat)')

expect(expandReplacement(regex, 'cat', "$`:$'")).toBe("$`:$'")
})

it('supports native regular expressions with the same semantics', () => {
const regex = /(?<word>cat)(s)?/gu

expect(expandReplacement(regex, 'cat', '$<word>:$2:$$:$&')).toBe('cat::$:cat')
})

it('keeps the template unchanged when the text is no longer a full match', () => {
const regex = createRegex('(cat)')

expect(expandReplacement(regex, 'dog', '[$1]')).toBe('[$1]')
})

it('preserves surrounding context for boundary assertions', () => {
const regex = createRegex('(\\Bfoo)')
const nativeRegex = /(\Bfoo)/gu

expect(expandReplacement(regex, 'xfoo', '[$1]', 1, 4)).toBe('[foo]')
expect(expandReplacement(nativeRegex, 'xfoo', '[$1]', 1, 4)).toBe('[foo]')
})

it('matches String.prototype.replace for every supported token form', () => {
const cases = [
{
source: '(a)(b)?',
text: 'a',
replacements: [
'$1',
'$01',
'$2',
'$02',
'$00',
'$001',
'$09',
'$010',
'$10',
'$20',
'$29',
'$100',
'$12',
'$99',
'$$',
'$$1',
'$$$1',
'$$&',
'$$$&',
'$$$$',
'$&',
'$x',
'$0',
'$1-$2',
],
},
{
source: '(?<word>cat)(?<suffix>s)?',
text: 'cat',
replacements: ['$<word>', '$<suffix>', '$<missing>', '$<word>-$2'],
},
{
source: '(cat)',
text: 'cat',
replacements: ['$<word>'],
},
{
source: '(a)(b)(c)(d)(e)(f)(g)(h)(i)(j)(k)(l)',
text: 'abcdefghijkl',
replacements: [
'$12:$1',
'$10:$99',
'$11$12',
...Array.from({ length: 100 }, (_, index) => `$${String(index).padStart(2, '0')}`),
],
},
]

for (const { source, text, replacements } of cases) {
const regex = createRegex(source)
const nativeRegex = new RegExp(source, 'u')

for (const replacement of replacements) {
expect(expandReplacement(regex, text, replacement), `${source} with ${replacement}`).toBe(
text.replace(nativeRegex, replacement),
)
}
}
})

it('matches String.prototype.replace for adjacent supported token combinations', () => {
const regex = createRegex('(?<word>a)(b)?')
const nativeRegex = /(?<word>a)(b)?/u
const parts = [
'$',
'$$',
'$&',
'$1',
'$01',
'$2',
'$02',
'$9',
'$09',
'$<word>',
'$<missing>',
'$x',
'$0',
'x',
]

for (const first of parts) {
for (const second of parts) {
const replacement = `${first}${second}`

expect(expandReplacement(regex, 'a', replacement), replacement).toBe(
'a'.replace(nativeRegex, replacement),
)
}
}
})
})
Loading
Loading