snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
This commit is contained in:
488
packages/extension-link/tests/integration/link.test.ts
Normal file
488
packages/extension-link/tests/integration/link.test.ts
Normal file
@@ -0,0 +1,488 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import Link, { isAllowedUri } from '@tiptap/extension-link'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
describe('extension-link', () => {
|
||||
const editorElClass = 'tiptap'
|
||||
let editor: Editor | null = null
|
||||
|
||||
const createEditorEl = () => {
|
||||
const editorEl = document.createElement('div')
|
||||
|
||||
editorEl.classList.add(editorElClass)
|
||||
document.body.appendChild(editorEl)
|
||||
return editorEl
|
||||
}
|
||||
const getEditorEl = () => document.querySelector(`.${editorElClass}`)
|
||||
|
||||
const validUrls = [
|
||||
'https://example.com',
|
||||
'http://example.com',
|
||||
'/same-site/index.html',
|
||||
'../relative.html',
|
||||
'mailto:info@example.com',
|
||||
'ftp://info@example.com',
|
||||
]
|
||||
|
||||
it('does output href tag for valid JSON schemas', () => {
|
||||
validUrls.forEach(url => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Link],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'hello world!',
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: url,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(editor.getHTML()).toContain(url)
|
||||
expect(JSON.stringify(editor.getJSON())).toContain(url)
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
})
|
||||
|
||||
it('does output href tag for valid HTML schemas', () => {
|
||||
validUrls.forEach(url => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Link],
|
||||
content: `<p><a href="${url}">hello world!</a></p>`,
|
||||
})
|
||||
|
||||
expect(editor.getHTML()).toContain(url)
|
||||
expect(JSON.stringify(editor.getJSON())).toContain(url)
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
})
|
||||
|
||||
// We have to disable the oxlint rule here because we're trying to purposely test eval urls
|
||||
// Examples inspired by: https://portswigger.net/web-security/cross-site-scripting/cheat-sheet#protocols
|
||||
const invalidUrls = [
|
||||
// A standard JavaScript protocol
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'javascript:alert(window.origin)',
|
||||
|
||||
// The protocol is not case sensitive
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'jAvAsCrIpT:alert(window.origin)',
|
||||
|
||||
// Characters \x01-\x20 are allowed before the protocol
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x00javascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x01javascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x02javascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x03javascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x04javascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x05javascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x06javascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x07javascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x08javascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x09javascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x0ajavascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x0bjavascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x0cjavascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x0djavascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x0ejavascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x0fjavascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x10javascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x11javascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x12javascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x13javascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x14javascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x15javascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x16javascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x17javascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x18javascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x19javascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x1ajavascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x1bjavascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x1cjavascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x1djavascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x1ejavascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'\x1fjavascript:alert(window.origin)',
|
||||
|
||||
// Characters \x09,\x0a,\x0d are allowed inside the protocol
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'java\x09script:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'java\x0ascript:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'java\x0dscript:alert(window.origin)',
|
||||
|
||||
// Characters \x09,\x0a,\x0d are allowed after protocol name before the colon
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'javascript\x09:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'javascript\x0a:alert(window.origin)',
|
||||
// oxlint-disable-next-line no-script-url
|
||||
'javascript\x0d:alert(window.origin)',
|
||||
]
|
||||
|
||||
it('does not output href for :javascript links in JSON schema', () => {
|
||||
invalidUrls.forEach(url => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Link],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'hello world!',
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: url,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(editor.getHTML()).not.toContain(url)
|
||||
// Unfortunately, if the content is provided as JSON, it stays in the editor instance until it's destroyed
|
||||
// At least, it cannot be outputted as HTML into a page
|
||||
// expect(JSON.stringify(editor.getJSON())).not.toContain(url)
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not output href for :javascript links in HTML schema', () => {
|
||||
invalidUrls.forEach(url => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Link],
|
||||
content: `<p><a href="${url}">hello world!</a></p>`,
|
||||
})
|
||||
|
||||
expect(editor.getHTML()).not.toContain(url)
|
||||
expect(JSON.stringify(editor.getJSON())).not.toContain(url)
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAllowedUri', () => {
|
||||
it('allows whitelisted protocols', () => {
|
||||
expect(isAllowedUri('https://example.com')).toBeTruthy()
|
||||
expect(isAllowedUri('http://example.com')).toBeTruthy()
|
||||
expect(isAllowedUri('ftp://example.com')).toBeTruthy()
|
||||
expect(isAllowedUri('mailto:info@example.com')).toBeTruthy()
|
||||
expect(isAllowedUri('tel:+1234567890')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('allows relative URLs (paths, queries, anchors)', () => {
|
||||
expect(isAllowedUri('/relative/path')).toBeTruthy()
|
||||
expect(isAllowedUri('../parent.html')).toBeTruthy()
|
||||
expect(isAllowedUri('?query=1')).toBeTruthy()
|
||||
expect(isAllowedUri('#anchor')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rejects unknown protocols with hyphens (https://github.com/ueberdosis/tiptap/issues/7929)', () => {
|
||||
expect(isAllowedUri('unknown:test')).toBeFalsy()
|
||||
// Hyphens are valid scheme chars per RFC 3986. They previously slipped
|
||||
// past the regex because the source `\-` was consumed by the JS
|
||||
// template parser.
|
||||
expect(isAllowedUri('unknown-protocol://test')).toBeFalsy()
|
||||
expect(isAllowedUri('unknown-protocol:test')).toBeFalsy()
|
||||
expect(isAllowedUri('foo-bar-baz://payload')).toBeFalsy()
|
||||
expect(isAllowedUri('tg-protocol:start')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('still rejects script-style protocols', () => {
|
||||
// oxlint-disable-next-line no-script-url
|
||||
expect(isAllowedUri('javascript:alert(1)')).toBeFalsy()
|
||||
expect(isAllowedUri('data:text/html,<script>alert(1)</script>')).toBeFalsy()
|
||||
expect(isAllowedUri('vbscript:msgbox(1)')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('honours additional protocols passed via the second argument', () => {
|
||||
expect(isAllowedUri('custom://thing', ['custom'])).toBeTruthy()
|
||||
expect(isAllowedUri('another-custom://thing', [{ scheme: 'another-custom' }])).toBeTruthy()
|
||||
// A protocol added via the argument list must still be matched as a
|
||||
// whole; a different unknown protocol must remain rejected.
|
||||
expect(isAllowedUri('different-custom://thing', ['custom'])).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom protocols', () => {
|
||||
it('allows using additional custom protocols', () => {
|
||||
;['custom://test.css', 'another-custom://protocol.html', ...validUrls].forEach(url => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
Link.configure({
|
||||
protocols: ['custom', { scheme: 'another-custom' }],
|
||||
}),
|
||||
],
|
||||
content: `<p><a href="${url}">hello world!</a></p>`,
|
||||
})
|
||||
|
||||
expect(editor.getHTML()).toContain(url)
|
||||
expect(JSON.stringify(editor.getJSON())).toContain(url)
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should return false when clicking on non-link elements', () => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
Image,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
}),
|
||||
],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'image',
|
||||
attrs: {
|
||||
src: 'https://placehold.co/400',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const editorEl = getEditorEl()
|
||||
const img = editorEl?.querySelector('img')
|
||||
|
||||
expect(img).toBeTruthy()
|
||||
|
||||
const clickEvent = new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
})
|
||||
|
||||
const wasDefaultPrevented = !img?.dispatchEvent(clickEvent)
|
||||
|
||||
// The event should not be prevented by the link handler
|
||||
expect(wasDefaultPrevented).toBe(false)
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
it('should return false when clicking non-link elements with enableClickSelection', () => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
// inline so the image is valid content inside the paragraph (pos 1)
|
||||
Image.configure({ inline: true }),
|
||||
Link.configure({
|
||||
enableClickSelection: true,
|
||||
}),
|
||||
],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'image', attrs: { src: 'https://placehold.co/400' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const img = getEditorEl()?.querySelector('img')
|
||||
|
||||
expect(img).toBeTruthy()
|
||||
|
||||
// A left click whose target is the image itself (not a link).
|
||||
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 })
|
||||
Object.defineProperty(clickEvent, 'target', { value: img })
|
||||
|
||||
// The link plugin must report the click unhandled (falsy) so the editor's
|
||||
// default NodeSelection on the image can run.
|
||||
const handled = editor.view.someProp('handleClick', fn => fn(editor!.view, 1, clickEvent))
|
||||
|
||||
expect(handled).toBeFalsy()
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
describe('shouldAutoLink', () => {
|
||||
it('default shouldAutoLink rejects bare hostnames without TLD', () => {
|
||||
// Test using Link extension's default options
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Link],
|
||||
})
|
||||
|
||||
// Access the default shouldAutoLink function through the extension options
|
||||
const linkExtension = editor.extensionManager.extensions.find(ext => ext.name === 'link')
|
||||
const shouldAutoLink = linkExtension?.options?.shouldAutoLink
|
||||
|
||||
expect(shouldAutoLink).toBeDefined()
|
||||
if (shouldAutoLink) {
|
||||
// Should reject bare hostnames without TLD
|
||||
expect(shouldAutoLink('localhost')).toBe(false)
|
||||
expect(shouldAutoLink('myserver')).toBe(false)
|
||||
expect(shouldAutoLink('intranet')).toBe(false)
|
||||
|
||||
// Should allow URLs with protocols
|
||||
expect(shouldAutoLink('http://localhost')).toBe(true)
|
||||
expect(shouldAutoLink('https://localhost')).toBe(true)
|
||||
expect(shouldAutoLink('http://127.0.0.1')).toBe(true)
|
||||
expect(shouldAutoLink('ftp://myserver')).toBe(true)
|
||||
|
||||
// Should allow URLs with TLD
|
||||
expect(shouldAutoLink('example.com')).toBe(true)
|
||||
expect(shouldAutoLink('test.example.com')).toBe(true)
|
||||
expect(shouldAutoLink('https://example.com')).toBe(true)
|
||||
|
||||
// Should correctly handle URLs with userinfo (user:pass@host)
|
||||
expect(shouldAutoLink('user:pass@example.com')).toBe(true)
|
||||
expect(shouldAutoLink('user@example.com')).toBe(true)
|
||||
expect(shouldAutoLink('user:pass@localhost')).toBe(false)
|
||||
}
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
it('default shouldAutoLink rejects bare IP addresses', () => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Link],
|
||||
})
|
||||
|
||||
const linkExtension = editor.extensionManager.extensions.find(ext => ext.name === 'link')
|
||||
const shouldAutoLink = linkExtension?.options?.shouldAutoLink
|
||||
|
||||
expect(shouldAutoLink).toBeDefined()
|
||||
if (shouldAutoLink) {
|
||||
// Should reject bare IP addresses
|
||||
expect(shouldAutoLink('127.0.0.1')).toBe(false)
|
||||
expect(shouldAutoLink('192.168.1.1')).toBe(false)
|
||||
expect(shouldAutoLink('10.0.0.1')).toBe(false)
|
||||
expect(shouldAutoLink('0.0.0.0')).toBe(false)
|
||||
|
||||
// Should allow IP addresses with protocols
|
||||
expect(shouldAutoLink('http://127.0.0.1')).toBe(true)
|
||||
expect(shouldAutoLink('https://192.168.1.1')).toBe(true)
|
||||
expect(shouldAutoLink('http://10.0.0.1:8080')).toBe(true)
|
||||
}
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
it('allows custom shouldAutoLink to override default behavior', () => {
|
||||
// Custom shouldAutoLink that allows all URLs (old behavior)
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
Link.configure({
|
||||
shouldAutoLink: () => true,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
const linkExtension = editor.extensionManager.extensions.find(ext => ext.name === 'link')
|
||||
const shouldAutoLink = linkExtension?.options?.shouldAutoLink
|
||||
|
||||
expect(shouldAutoLink).toBeDefined()
|
||||
if (shouldAutoLink) {
|
||||
// With custom shouldAutoLink, localhost should be allowed
|
||||
expect(shouldAutoLink('localhost')).toBe(true)
|
||||
expect(shouldAutoLink('127.0.0.1')).toBe(true)
|
||||
}
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
})
|
||||
})
|
||||
506
packages/extension-link/tests/integration/markdownLink.test.ts
Normal file
506
packages/extension-link/tests/integration/markdownLink.test.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
import { Editor, Mark } from '@tiptap/core'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { afterEach, describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
const Code = Mark.create({
|
||||
name: 'code',
|
||||
excludes: '_',
|
||||
code: true,
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'code' }]
|
||||
},
|
||||
|
||||
renderHTML() {
|
||||
return ['code', 0]
|
||||
},
|
||||
})
|
||||
|
||||
describe('markdown links', () => {
|
||||
const editorElClass = 'tiptap'
|
||||
let editor: Editor | null = null
|
||||
|
||||
const createEditorEl = () => {
|
||||
const editorEl = document.createElement('div')
|
||||
|
||||
editorEl.classList.add(editorElClass)
|
||||
document.body.appendChild(editorEl)
|
||||
return editorEl
|
||||
}
|
||||
const getEditorEl = () => document.querySelector(`.${editorElClass}`)
|
||||
|
||||
const createEditor = (options: Partial<ConstructorParameters<typeof Editor>[0]> = {}) => {
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [Document, Text, Paragraph, Link.configure({ markdownLinks: true })],
|
||||
...options,
|
||||
})
|
||||
|
||||
return editor
|
||||
}
|
||||
|
||||
// Simulates typing a character at the end of the document,
|
||||
// the way the input rules plugin receives real keystrokes.
|
||||
// Returns whether a rule handled it.
|
||||
const typeAtEnd = (currentEditor: Editor, character: string) => {
|
||||
currentEditor.commands.setTextSelection(currentEditor.state.doc.nodeSize)
|
||||
|
||||
return currentEditor.view.someProp('handleTextInput', f =>
|
||||
f(
|
||||
currentEditor.view,
|
||||
currentEditor.state.selection.from,
|
||||
currentEditor.state.selection.from,
|
||||
character,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
describe('input rule', () => {
|
||||
it('converts the typed Markdown link syntax into a link', () => {
|
||||
createEditor({ content: '<p>Check out [Tiptap](https://tiptap.dev</p>' })
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(editor!.getHTML()).toContain('href="https://tiptap.dev"')
|
||||
expect(editor!.getHTML()).toContain('>Tiptap</a>')
|
||||
expect(editor!.getHTML()).not.toContain('[')
|
||||
expect(editor!.state.doc.textContent).toBe('Check out Tiptap')
|
||||
})
|
||||
|
||||
it('supports titles in straight quotes', () => {
|
||||
createEditor({ content: '<p>[Tiptap](https://tiptap.dev "Rich text editor"</p>' })
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(editor!.getHTML()).toContain('href="https://tiptap.dev"')
|
||||
expect(editor!.getHTML()).toContain('title="Rich text editor"')
|
||||
expect(editor!.state.doc.textContent).toBe('Tiptap')
|
||||
})
|
||||
|
||||
it('treats an empty title as no title, as in CommonMark', () => {
|
||||
createEditor({ content: '<p>[Tiptap](https://tiptap.dev ""</p>' })
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(editor!.getHTML()).toContain('href="https://tiptap.dev"')
|
||||
expect(editor!.getHTML()).not.toContain('title=')
|
||||
expect(editor!.state.doc.textContent).toBe('Tiptap')
|
||||
})
|
||||
|
||||
it('supports titles in curly quotes, as replaced by the Typography extension', () => {
|
||||
createEditor({ content: '<p>[Tiptap](https://tiptap.dev “Rich text editor”</p>' })
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(editor!.getHTML()).toContain('title="Rich text editor"')
|
||||
})
|
||||
|
||||
it('supports titles in curly single quotes', () => {
|
||||
createEditor({ content: '<p>[Tiptap](https://tiptap.dev ‘Rich text editor’</p>' })
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(editor!.getHTML()).toContain('title="Rich text editor"')
|
||||
})
|
||||
|
||||
it('supports titles containing the other quote character', () => {
|
||||
createEditor({ content: `<p>[Tiptap](https://tiptap.dev "editor's title"</p>` })
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(editor!.getHTML()).toContain(`title="editor's title"`)
|
||||
})
|
||||
|
||||
it('does not convert a title with mismatched quotes', () => {
|
||||
createEditor({ content: `<p>[Tiptap](https://tiptap.dev “Rich text editor'</p>` })
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBeFalsy()
|
||||
expect(editor!.getHTML()).not.toContain('<a')
|
||||
})
|
||||
|
||||
it('does not convert a title closed by a different straight quote', () => {
|
||||
createEditor({ content: `<p>[Tiptap](https://tiptap.dev "Rich text editor'</p>` })
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBeFalsy()
|
||||
expect(editor!.getHTML()).not.toContain('<a')
|
||||
})
|
||||
|
||||
it('converts URLs containing balanced parentheses', () => {
|
||||
createEditor({ content: '<p>[Wiki](https://en.wikipedia.org/wiki/Node_(disambiguation)</p>' })
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(editor!.getHTML()).toContain(
|
||||
'href="https://en.wikipedia.org/wiki/Node_(disambiguation)"',
|
||||
)
|
||||
expect(editor!.state.doc.textContent).toBe('Wiki')
|
||||
})
|
||||
|
||||
it('does not fire on the closing parenthesis of a URL containing parentheses', () => {
|
||||
createEditor({ content: '<p>[Wiki](https://en.wikipedia.org/wiki/Node_(disambiguation</p>' })
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBeFalsy()
|
||||
expect(editor!.getHTML()).not.toContain('<a')
|
||||
})
|
||||
|
||||
it('converts only the trailing link when another link precedes it', () => {
|
||||
createEditor({
|
||||
content: '<p>[alpha](https://alpha.example.com)[beta](https://beta.example.com</p>',
|
||||
})
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(editor!.getHTML()).toContain('href="https://beta.example.com"')
|
||||
expect(editor!.getHTML()).not.toContain('href="https://alpha.example.com"')
|
||||
expect(editor!.state.doc.textContent).toBe('[alpha](https://alpha.example.com)beta')
|
||||
})
|
||||
|
||||
it('converts non-http protocols allowed by default', () => {
|
||||
createEditor({ content: '<p>[Email](mailto:info@example.com</p>' })
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(editor!.getHTML()).toContain('href="mailto:info@example.com"')
|
||||
})
|
||||
|
||||
it('converts relative URLs', () => {
|
||||
createEditor({ content: '<p>[Docs](/docs/introduction</p>' })
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(editor!.getHTML()).toContain('href="/docs/introduction"')
|
||||
})
|
||||
|
||||
it('does not convert disallowed protocols', () => {
|
||||
// oxlint-disable-next-line no-script-url
|
||||
createEditor({ content: '<p>[click](javascript:alert(window.origin</p>' })
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBeFalsy()
|
||||
expect(editor!.getHTML()).not.toContain('<a')
|
||||
})
|
||||
|
||||
it('does not convert the Markdown image syntax', () => {
|
||||
createEditor({ content: '<p>
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBeFalsy()
|
||||
expect(editor!.getHTML()).not.toContain('<a')
|
||||
})
|
||||
|
||||
it('does not convert escaped brackets', () => {
|
||||
createEditor({ content: '<p>\\[Tiptap](https://tiptap.dev</p>' })
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBeFalsy()
|
||||
expect(editor!.getHTML()).not.toContain('<a')
|
||||
})
|
||||
|
||||
it('does not convert whitespace-only link text', () => {
|
||||
createEditor({ content: '<p>[ ](https://tiptap.dev</p>' })
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBeFalsy()
|
||||
expect(editor!.getHTML()).not.toContain('<a')
|
||||
})
|
||||
|
||||
it('does not convert after an unclosed backtick', () => {
|
||||
createEditor({ content: '<p>`[Tiptap](https://tiptap.dev</p>' })
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBeFalsy()
|
||||
expect(editor!.getHTML()).not.toContain('<a')
|
||||
})
|
||||
|
||||
it('does not convert inside a code span still being typed', () => {
|
||||
createEditor({ content: '<p>`some code [Tiptap](https://tiptap.dev</p>' })
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBeFalsy()
|
||||
expect(editor!.getHTML()).not.toContain('<a')
|
||||
})
|
||||
|
||||
it('converts after a closed pair of backticks', () => {
|
||||
createEditor({ content: '<p>`some code` [Tiptap](https://tiptap.dev</p>' })
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(editor!.getHTML()).toContain('href="https://tiptap.dev"')
|
||||
})
|
||||
|
||||
it('does not convert inside an unclosed double-backtick code span', () => {
|
||||
createEditor({ content: '<p>``some code [Tiptap](https://tiptap.dev</p>' })
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBeFalsy()
|
||||
expect(editor!.getHTML()).not.toContain('<a')
|
||||
})
|
||||
|
||||
it('treats a single backtick inside a double-backtick span as literal', () => {
|
||||
createEditor({ content: '<p>``some ` code [Tiptap](https://tiptap.dev</p>' })
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBeFalsy()
|
||||
expect(editor!.getHTML()).not.toContain('<a')
|
||||
})
|
||||
|
||||
it('converts after an escaped backtick', () => {
|
||||
createEditor({ content: '<p>\\`[Tiptap](https://tiptap.dev</p>' })
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(editor!.getHTML()).toContain('href="https://tiptap.dev"')
|
||||
})
|
||||
|
||||
it('closes a code span on a backtick preceded by a backslash', () => {
|
||||
createEditor({ content: '<p>`code\\` [Tiptap](https://tiptap.dev</p>' })
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(editor!.getHTML()).toContain('href="https://tiptap.dev"')
|
||||
})
|
||||
|
||||
it('converts after a closed double-backtick span containing a backtick', () => {
|
||||
createEditor({ content: '<p>`` ` `` [Tiptap](https://tiptap.dev</p>' })
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(editor!.getHTML()).toContain('href="https://tiptap.dev"')
|
||||
})
|
||||
|
||||
it('does not convert inside code marks', () => {
|
||||
createEditor({
|
||||
extensions: [Document, Text, Paragraph, Code, Link.configure({ markdownLinks: true })],
|
||||
content: '<p><code>[Tiptap](https://tiptap.dev</code></p>',
|
||||
})
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBeFalsy()
|
||||
expect(editor!.getHTML()).not.toContain('<a')
|
||||
})
|
||||
|
||||
it('does not convert unless the markdownLinks option is enabled', () => {
|
||||
createEditor({
|
||||
extensions: [Document, Text, Paragraph, Link],
|
||||
content: '<p>[Tiptap](https://tiptap.dev</p>',
|
||||
})
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBeFalsy()
|
||||
expect(editor!.getHTML()).not.toContain('<a')
|
||||
})
|
||||
|
||||
it('respects a custom isAllowedUri option', () => {
|
||||
createEditor({
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
Link.configure({ markdownLinks: true, isAllowedUri: () => false }),
|
||||
],
|
||||
content: '<p>[Tiptap](https://tiptap.dev</p>',
|
||||
})
|
||||
|
||||
const handled = typeAtEnd(editor!, ')')
|
||||
|
||||
expect(handled).toBeFalsy()
|
||||
expect(editor!.getHTML()).not.toContain('<a')
|
||||
})
|
||||
})
|
||||
|
||||
describe('paste rule', () => {
|
||||
it('converts pasted Markdown links', () => {
|
||||
createEditor()
|
||||
|
||||
editor!.view.pasteText('Check out [Tiptap](https://tiptap.dev) today')
|
||||
|
||||
expect(editor!.getHTML()).toContain('href="https://tiptap.dev"')
|
||||
expect(editor!.getHTML()).toContain('>Tiptap</a>')
|
||||
expect(editor!.state.doc.textContent).toBe('Check out Tiptap today')
|
||||
})
|
||||
|
||||
it('converts multiple pasted Markdown links', () => {
|
||||
createEditor()
|
||||
|
||||
editor!.view.pasteText(
|
||||
'See [alpha](https://alpha.example.com) and [beta](https://beta.example.com)',
|
||||
)
|
||||
|
||||
expect(editor!.getHTML()).toContain('href="https://alpha.example.com"')
|
||||
expect(editor!.getHTML()).toContain('href="https://beta.example.com"')
|
||||
expect(editor!.state.doc.textContent).toBe('See alpha and beta')
|
||||
})
|
||||
|
||||
it('converts adjacent Markdown links', () => {
|
||||
createEditor()
|
||||
|
||||
editor!.view.pasteText('[alpha](https://alpha.example.com)[beta](https://beta.example.com)')
|
||||
|
||||
expect(editor!.getHTML()).toContain('href="https://alpha.example.com"')
|
||||
expect(editor!.getHTML()).toContain('href="https://beta.example.com"')
|
||||
expect(editor!.state.doc.textContent).toBe('alphabeta')
|
||||
})
|
||||
|
||||
it('converts pasted Markdown links with titles', () => {
|
||||
createEditor()
|
||||
|
||||
editor!.view.pasteText('[Tiptap](https://tiptap.dev "Rich text editor")')
|
||||
|
||||
expect(editor!.getHTML()).toContain('href="https://tiptap.dev"')
|
||||
expect(editor!.getHTML()).toContain('title="Rich text editor"')
|
||||
expect(editor!.state.doc.textContent).toBe('Tiptap')
|
||||
})
|
||||
|
||||
it('keeps a pasted link with a mismatched title quote as plain text', () => {
|
||||
createEditor()
|
||||
|
||||
editor!.view.pasteText(`[Tiptap](https://tiptap.dev “Rich text editor')`)
|
||||
|
||||
// the bare URL inside may still get autolinked, we only care that the syntax survives
|
||||
expect(editor!.getHTML()).not.toContain('>Tiptap</a>')
|
||||
expect(editor!.state.doc.textContent).toBe(`[Tiptap](https://tiptap.dev “Rich text editor')`)
|
||||
})
|
||||
|
||||
it('keeps the Markdown href when the link text looks like a URL', () => {
|
||||
createEditor()
|
||||
|
||||
editor!.view.pasteText('Read [tiptap.dev](https://tiptap.dev/docs) now')
|
||||
|
||||
expect(editor!.getHTML()).toContain('href="https://tiptap.dev/docs"')
|
||||
expect(editor!.getHTML()).not.toContain('href="http://tiptap.dev"')
|
||||
expect(editor!.state.doc.textContent).toBe('Read tiptap.dev now')
|
||||
})
|
||||
|
||||
it('still links bare URLs pasted outside of Markdown syntax', () => {
|
||||
createEditor()
|
||||
|
||||
editor!.view.pasteText('visit https://example.com and [Tiptap](https://tiptap.dev)')
|
||||
|
||||
expect(editor!.getHTML()).toContain('href="https://example.com"')
|
||||
expect(editor!.getHTML()).toContain('href="https://tiptap.dev"')
|
||||
expect(editor!.state.doc.textContent).toBe('visit https://example.com and Tiptap')
|
||||
})
|
||||
|
||||
it('does not convert disallowed protocols', () => {
|
||||
createEditor()
|
||||
|
||||
// oxlint-disable-next-line no-script-url
|
||||
editor!.view.pasteText('[click](javascript:alert(window.origin))')
|
||||
|
||||
expect(editor!.getHTML()).not.toContain('<a')
|
||||
expect(editor!.state.doc.textContent).toContain('[click](')
|
||||
})
|
||||
|
||||
it('keeps a Markdown link inside a pasted code span as plain text', () => {
|
||||
createEditor()
|
||||
|
||||
editor!.view.pasteText('`[Tiptap](https://tiptap.dev)` renders a link')
|
||||
|
||||
expect(editor!.getHTML()).not.toContain('>Tiptap</a>')
|
||||
expect(editor!.state.doc.textContent).toBe('`[Tiptap](https://tiptap.dev)` renders a link')
|
||||
})
|
||||
|
||||
it('converts a Markdown link pasted after a closed code span', () => {
|
||||
createEditor()
|
||||
|
||||
editor!.view.pasteText('see `some code` then read [the docs](https://tiptap.dev/docs)')
|
||||
|
||||
expect(editor!.getHTML()).toContain('href="https://tiptap.dev/docs"')
|
||||
expect(editor!.state.doc.textContent).toBe('see `some code` then read the docs')
|
||||
})
|
||||
|
||||
it('keeps a Markdown link inside an unclosed double-backtick span as plain text', () => {
|
||||
createEditor()
|
||||
|
||||
editor!.view.pasteText('``some code [Tiptap](https://tiptap.dev)')
|
||||
|
||||
expect(editor!.getHTML()).not.toContain('>Tiptap</a>')
|
||||
expect(editor!.state.doc.textContent).toBe('``some code [Tiptap](https://tiptap.dev)')
|
||||
})
|
||||
|
||||
it('converts a Markdown link pasted after an escaped backtick', () => {
|
||||
createEditor()
|
||||
|
||||
editor!.view.pasteText('\\`not code [Tiptap](https://tiptap.dev)')
|
||||
|
||||
expect(editor!.getHTML()).toContain('href="https://tiptap.dev"')
|
||||
expect(editor!.state.doc.textContent).toBe('\\`not code Tiptap')
|
||||
})
|
||||
|
||||
it('converts a Markdown link pasted after a closed double-backtick span', () => {
|
||||
createEditor()
|
||||
|
||||
editor!.view.pasteText('`` ` `` then read [the docs](https://tiptap.dev/docs)')
|
||||
|
||||
expect(editor!.getHTML()).toContain('href="https://tiptap.dev/docs"')
|
||||
expect(editor!.state.doc.textContent).toBe('`` ` `` then read the docs')
|
||||
})
|
||||
|
||||
it('updates the href when a bare URL is pasted inside an existing link', () => {
|
||||
createEditor({ content: '<p><a href="https://old.example.com">click here</a></p>' })
|
||||
|
||||
editor!.commands.setTextSelection(6)
|
||||
editor!.view.pasteText('https://new.example.com')
|
||||
|
||||
expect(editor!.getHTML()).toContain('href="https://new.example.com"')
|
||||
expect(editor!.getHTML()).toContain('href="https://old.example.com"')
|
||||
})
|
||||
|
||||
it('keeps the Markdown image syntax as plain text', () => {
|
||||
createEditor()
|
||||
|
||||
editor!.view.pasteText('')
|
||||
|
||||
// the bare URL inside may still get autolinked, we only care that the brackets survive
|
||||
expect(editor!.state.doc.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('does not convert unless the markdownLinks option is enabled', () => {
|
||||
createEditor({
|
||||
extensions: [Document, Text, Paragraph, Link],
|
||||
})
|
||||
|
||||
editor!.view.pasteText('[Tiptap](https://tiptap.dev)')
|
||||
|
||||
expect(editor!.state.doc.textContent).toBe('[Tiptap](https://tiptap.dev)')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user