diff --git a/.vscode/settings.json b/.vscode/settings.json
index 25fa6215fdd..81e38fca559 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,3 +1,7 @@
{
- "typescript.tsdk": "node_modules/typescript/lib"
+ "typescript.tsdk": "node_modules/typescript/lib",
+ "conventionalCommits.scopes": [
+ "extension-link",
+ "settings"
+ ]
}
diff --git a/demos/src/Examples/AutolinkValidation/React/index.html b/demos/src/Examples/AutolinkValidation/React/index.html
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/demos/src/Examples/AutolinkValidation/React/index.jsx b/demos/src/Examples/AutolinkValidation/React/index.jsx
new file mode 100644
index 00000000000..5c1484131a5
--- /dev/null
+++ b/demos/src/Examples/AutolinkValidation/React/index.jsx
@@ -0,0 +1,32 @@
+import './styles.scss'
+
+import React from 'react'
+
+import Link from '@tiptap/extension-link'
+import { EditorContent, useEditor } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [
+ StarterKit,
+ Link.configure({
+ validate: link => /^https?:\/\//.test(link),
+ }),
+ ],
+ content: `
+
Hey! Try to type in url with and without a http/s protocol. - Links without a protocol should not get auto linked
+ `,
+ editorProps: {
+ attributes: {
+ spellcheck: 'false',
+ },
+ },
+ })
+
+ return (
+
+
+
+ )
+}
diff --git a/demos/src/Examples/AutolinkValidation/React/index.spec.js b/demos/src/Examples/AutolinkValidation/React/index.spec.js
new file mode 100644
index 00000000000..e1d6b7d13a3
--- /dev/null
+++ b/demos/src/Examples/AutolinkValidation/React/index.spec.js
@@ -0,0 +1,42 @@
+context('/src/Examples/AutolinkValidation/React/', () => {
+ before(() => {
+ cy.visit('/src/Examples/AutolinkValidation/React/')
+ })
+
+ beforeEach(() => {
+ cy.get('.ProseMirror').type('{selectall}{backspace}')
+ })
+
+ const validLinks = [
+ 'https://tiptap.dev',
+ 'http://tiptap.dev',
+ 'https://www.tiptap.dev/',
+ 'http://www.tiptap.dev/',
+ ]
+
+ const invalidLinks = [
+ 'tiptap.dev',
+ 'www.tiptap.dev',
+ ]
+
+ validLinks.forEach(link => {
+ it(`${link} should get autolinked`, () => {
+ cy.get('.ProseMirror').type(link)
+ cy.get('.ProseMirror').should('have.text', link)
+ cy.get('.ProseMirror')
+ .find('a')
+ .should('have.length', 1)
+ .should('have.attr', 'href', link)
+ })
+ })
+
+ invalidLinks.forEach(link => {
+ it(`${link} should NOT get autolinked`, () => {
+ cy.get('.ProseMirror').type(link)
+ cy.get('.ProseMirror').should('have.text', link)
+ cy.get('.ProseMirror')
+ .find('a')
+ .should('have.length', 0)
+ })
+ })
+})
diff --git a/demos/src/Examples/AutolinkValidation/React/styles.scss b/demos/src/Examples/AutolinkValidation/React/styles.scss
new file mode 100644
index 00000000000..9a6d1a7c558
--- /dev/null
+++ b/demos/src/Examples/AutolinkValidation/React/styles.scss
@@ -0,0 +1,54 @@
+/* Basic editor styles */
+.ProseMirror {
+ > * + * {
+ margin-top: 0.75em;
+ }
+
+ ul,
+ ol {
+ padding: 0 1rem;
+ }
+
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: 1.1;
+ }
+
+ code {
+ background-color: rgba(#616161, 0.1);
+ color: #616161;
+ }
+
+ pre {
+ background: #0D0D0D;
+ color: #FFF;
+ font-family: 'JetBrainsMono', monospace;
+ padding: 0.75rem 1rem;
+ border-radius: 0.5rem;
+
+ code {
+ color: inherit;
+ padding: 0;
+ background: none;
+ font-size: 0.8rem;
+ }
+ }
+
+ img {
+ max-width: 100%;
+ height: auto;
+ }
+
+ hr {
+ margin: 1rem 0;
+ }
+
+ blockquote {
+ padding-left: 1rem;
+ border-left: 2px solid rgba(#0D0D0D, 0.1);
+ }
+}
diff --git a/demos/src/Examples/AutolinkValidation/Vue/index.html b/demos/src/Examples/AutolinkValidation/Vue/index.html
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/demos/src/Examples/AutolinkValidation/Vue/index.spec.js b/demos/src/Examples/AutolinkValidation/Vue/index.spec.js
new file mode 100644
index 00000000000..a74965973d2
--- /dev/null
+++ b/demos/src/Examples/AutolinkValidation/Vue/index.spec.js
@@ -0,0 +1,42 @@
+context('/src/Examples/AutolinkValidation/Vue/', () => {
+ before(() => {
+ cy.visit('/src/Examples/AutolinkValidation/Vue/')
+ })
+
+ beforeEach(() => {
+ cy.get('.ProseMirror').type('{selectall}{backspace}')
+ })
+
+ const validLinks = [
+ 'https://tiptap.dev',
+ 'http://tiptap.dev',
+ 'https://www.tiptap.dev/',
+ 'http://www.tiptap.dev/',
+ ]
+
+ const invalidLinks = [
+ 'tiptap.dev',
+ 'www.tiptap.dev',
+ ]
+
+ validLinks.forEach(link => {
+ it(`${link} should get autolinked`, () => {
+ cy.get('.ProseMirror').type(link)
+ cy.get('.ProseMirror').should('have.text', link)
+ cy.get('.ProseMirror')
+ .find('a')
+ .should('have.length', 1)
+ .should('have.attr', 'href', link)
+ })
+ })
+
+ invalidLinks.forEach(link => {
+ it(`${link} should NOT get autolinked`, () => {
+ cy.get('.ProseMirror').type(link)
+ cy.get('.ProseMirror').should('have.text', link)
+ cy.get('.ProseMirror')
+ .find('a')
+ .should('have.length', 0)
+ })
+ })
+})
diff --git a/demos/src/Examples/AutolinkValidation/Vue/index.vue b/demos/src/Examples/AutolinkValidation/Vue/index.vue
new file mode 100644
index 00000000000..b701cf8bef0
--- /dev/null
+++ b/demos/src/Examples/AutolinkValidation/Vue/index.vue
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
diff --git a/docs/api/marks/link.md b/docs/api/marks/link.md
index 3464114565a..1aad36b2f79 100644
--- a/docs/api/marks/link.md
+++ b/docs/api/marks/link.md
@@ -64,6 +64,18 @@ Link.configure({
})
```
+### validate
+A function that validates every autolinked link. If it exists, it will be called with the link href as argument. If it returns `false`, the link will be removed.
+
+Can be used to set rules for example excluding or including certain domains, tlds, etc.
+
+```js
+// only autolink urls with a protocol
+Link.configure({
+ validate: href => /^https?:\/\//.test(href),
+})
+```
+
## Commands
### setLink()
diff --git a/packages/extension-link/src/helpers/autolink.ts b/packages/extension-link/src/helpers/autolink.ts
index bd72b1ce9dc..f413792bb53 100644
--- a/packages/extension-link/src/helpers/autolink.ts
+++ b/packages/extension-link/src/helpers/autolink.ts
@@ -1,15 +1,17 @@
+import { find, test } from 'linkifyjs'
+import { MarkType } from 'prosemirror-model'
+import { Plugin, PluginKey } from 'prosemirror-state'
+
import {
- getMarksBetween,
- findChildrenInRange,
combineTransactionSteps,
+ findChildrenInRange,
getChangedRanges,
+ getMarksBetween,
} from '@tiptap/core'
-import { Plugin, PluginKey } from 'prosemirror-state'
-import { MarkType } from 'prosemirror-model'
-import { find, test } from 'linkifyjs'
type AutolinkOptions = {
type: MarkType,
+ validate?: (url: string) => boolean,
}
export function autolink(options: AutolinkOptions): Plugin {
@@ -70,6 +72,13 @@ export function autolink(options: AutolinkOptions): Plugin {
find(text)
.filter(link => link.isLink)
+ .filter(link => {
+ if (options.validate) {
+ return options.validate(link.value)
+ }
+
+ return true
+ })
// calculate link position
.map(link => ({
...link,
diff --git a/packages/extension-link/src/helpers/pasteHandler.ts b/packages/extension-link/src/helpers/pasteHandler.ts
index fc06a206d86..f6aa323d314 100644
--- a/packages/extension-link/src/helpers/pasteHandler.ts
+++ b/packages/extension-link/src/helpers/pasteHandler.ts
@@ -1,7 +1,8 @@
-import { Editor } from '@tiptap/core'
-import { Plugin, PluginKey } from 'prosemirror-state'
-import { MarkType } from 'prosemirror-model'
import { find } from 'linkifyjs'
+import { MarkType } from 'prosemirror-model'
+import { Plugin, PluginKey } from 'prosemirror-state'
+
+import { Editor } from '@tiptap/core'
type PasteHandlerOptions = {
editor: Editor,
diff --git a/packages/extension-link/src/link.ts b/packages/extension-link/src/link.ts
index 40bd1c932f9..43d6c11f69f 100644
--- a/packages/extension-link/src/link.ts
+++ b/packages/extension-link/src/link.ts
@@ -1,5 +1,7 @@
-import { Mark, markPasteRule, mergeAttributes } from '@tiptap/core'
import { find } from 'linkifyjs'
+
+import { Mark, markPasteRule, mergeAttributes } from '@tiptap/core'
+
import { autolink } from './helpers/autolink'
import { clickHandler } from './helpers/clickHandler'
import { pasteHandler } from './helpers/pasteHandler'
@@ -21,6 +23,12 @@ export interface LinkOptions {
* A list of HTML attributes to be rendered.
*/
HTMLAttributes: Record,
+ /**
+ * A validation function that modifies link verification for the auto linker.
+ * @param url - The url to be validated.
+ * @returns - True if the url is valid, false otherwise.
+ */
+ validate?: (url: string) => boolean,
}
declare module '@tiptap/core' {
@@ -63,6 +71,7 @@ export const Link = Mark.create({
rel: 'noopener noreferrer nofollow',
class: null,
},
+ validate: undefined,
}
},
@@ -123,6 +132,13 @@ export const Link = Mark.create({
return [
markPasteRule({
find: text => find(text)
+ .filter(link => {
+ if (this.options.validate) {
+ return this.options.validate(link.value)
+ }
+
+ return true
+ })
.filter(link => link.isLink)
.map(link => ({
text: link.value,
@@ -143,6 +159,7 @@ export const Link = Mark.create({
if (this.options.autolink) {
plugins.push(autolink({
type: this.type,
+ validate: this.options.validate,
}))
}