import type { PasteRuleMatch } from '@tiptap/core' import { Mark, markPasteRule, mergeAttributes } from '@tiptap/core' import type { Plugin } from '@tiptap/pm/state' import { find, registerCustomProtocol, reset } from 'linkifyjs' import { autolink } from './helpers/autolink.js' import { clickHandler } from './helpers/clickHandler.js' import { pasteHandler } from './helpers/pasteHandler.js' import { UNICODE_WHITESPACE_REGEX_GLOBAL } from './helpers/whitespace.js' export interface LinkProtocolOptions { /** * The protocol scheme to be registered. * @default ''' * @example 'ftp' * @example 'git' */ scheme: string /** * If enabled, it allows optional slashes after the protocol. * @default false * @example true */ optionalSlashes?: boolean } export const pasteRegex = /https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z]{2,}\b(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)/gi /** * @deprecated The default behavior is now to open links when the editor is not editable. */ type DeprecatedOpenWhenNotEditable = 'whenNotEditable' export interface LinkOptions { /** * If enabled, the extension will automatically add links as you type. * @default true * @example false */ autolink: boolean /** * An array of custom protocols to be registered with linkifyjs. * @default [] * @example ['ftp', 'git'] */ protocols: Array /** * Default protocol to use when no protocol is specified. * @default 'http' */ defaultProtocol: string /** * If enabled, links will be opened on click. * @default true * @example false */ openOnClick: boolean | DeprecatedOpenWhenNotEditable /** * If enabled, the link will be selected when clicked. * @default false * @example true */ enableClickSelection: boolean /** * Adds a link to the current selection if the pasted content only contains an url. * @default true * @example false */ linkOnPaste: boolean /** * HTML attributes to add to the link element. * @default {} * @example { class: 'foo' } */ HTMLAttributes: Record /** * @deprecated Use the `shouldAutoLink` option instead. * 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 /** * A validation function which is used for configuring link verification for preventing XSS attacks. * Only modify this if you know what you're doing. * * @returns {boolean} `true` if the URL is valid, `false` otherwise. * * @example * isAllowedUri: (url, { defaultValidate, protocols, defaultProtocol }) => { * return url.startsWith('./') || defaultValidate(url) * } */ isAllowedUri: ( /** * The URL to be validated. */ url: string, ctx: { /** * The default validation function. */ defaultValidate: (url: string) => boolean /** * An array of allowed protocols for the URL (e.g., "http", "https"). As defined in the `protocols` option. */ protocols: Array /** * A string that represents the default protocol (e.g., 'http'). As defined in the `defaultProtocol` option. */ defaultProtocol: string }, ) => boolean /** * Determines whether a valid link should be automatically linked in the content. * * @param {string} url - The URL that has already been validated. * @returns {boolean} - True if the link should be auto-linked; false if it should not be auto-linked. */ shouldAutoLink: (url: string) => boolean } declare module '@tiptap/core' { interface Commands { link: { /** * Set a link mark * @param attributes The link attributes * @example editor.commands.setLink({ href: 'https://tiptap.dev' }) */ setLink: (attributes: { href: string target?: string | null rel?: string | null class?: string | null title?: string | null }) => ReturnType /** * Toggle a link mark * @param attributes The link attributes * @example editor.commands.toggleLink({ href: 'https://tiptap.dev' }) */ toggleLink: (attributes?: { href: string target?: string | null rel?: string | null class?: string | null title?: string | null }) => ReturnType /** * Unset a link mark * @example editor.commands.unsetLink() */ unsetLink: () => ReturnType } } } export function isAllowedUri(uri: string | undefined, protocols?: LinkOptions['protocols']) { const allowedProtocols: string[] = ['http', 'https', 'ftp', 'ftps', 'mailto', 'tel', 'callto', 'sms', 'cid', 'xmpp'] if (protocols) { protocols.forEach(protocol => { const nextProtocol = typeof protocol === 'string' ? protocol : protocol.scheme if (nextProtocol) { allowedProtocols.push(nextProtocol) } }) } return ( !uri || uri.replace(UNICODE_WHITESPACE_REGEX_GLOBAL, '').match( new RegExp( // eslint-disable-next-line no-useless-escape `^(?:(?:${allowedProtocols.join('|')}):|[^a-z]|[a-z0-9+.\-]+(?:[^a-z+.\-:]|$))`, 'i', ), ) ) } /** * This extension allows you to create links. * @see https://www.tiptap.dev/api/marks/link */ export const Link = Mark.create({ name: 'link', priority: 1000, keepOnSplit: false, exitable: true, onCreate() { // TODO: v4 - remove validate option if (this.options.validate && !this.options.shouldAutoLink) { // Copy the validate function to the shouldAutoLink option this.options.shouldAutoLink = this.options.validate console.warn('The `validate` option is deprecated. Rename to the `shouldAutoLink` option instead.') } this.options.protocols.forEach(protocol => { if (typeof protocol === 'string') { registerCustomProtocol(protocol) return } registerCustomProtocol(protocol.scheme, protocol.optionalSlashes) }) }, onDestroy() { reset() }, inclusive() { return this.options.autolink }, addOptions() { return { openOnClick: true, enableClickSelection: false, linkOnPaste: true, autolink: true, protocols: [], defaultProtocol: 'http', HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer nofollow', class: null, }, isAllowedUri: (url, ctx) => !!isAllowedUri(url, ctx.protocols), validate: url => !!url, shouldAutoLink: url => { // URLs with explicit protocols (e.g., https://) should be auto-linked // But not if @ appears before :// (that would be userinfo like user:pass@host) const hasProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(url) const hasMaybeProtocol = /^[a-z][a-z0-9+.-]*:/i.test(url) if (hasProtocol || (hasMaybeProtocol && !url.includes('@'))) { return true } // Strip userinfo (user:pass@) if present, then extract hostname const urlWithoutUserinfo = url.includes('@') ? url.split('@').pop()! : url const hostname = urlWithoutUserinfo.split(/[/?#:]/)[0] // Don't auto-link IP addresses without protocol if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) { return false } // Don't auto-link single-word hostnames without TLD (e.g., "localhost") if (!/\./.test(hostname)) { return false } return true }, } }, addAttributes() { return { href: { default: null, parseHTML(element) { return element.getAttribute('href') }, }, target: { default: this.options.HTMLAttributes.target, }, rel: { default: this.options.HTMLAttributes.rel, }, class: { default: this.options.HTMLAttributes.class, }, title: { default: null, }, } }, parseHTML() { return [ { tag: 'a[href]', getAttrs: dom => { const href = (dom as HTMLElement).getAttribute('href') // prevent XSS attacks if ( !href || !this.options.isAllowedUri(href, { defaultValidate: url => !!isAllowedUri(url, this.options.protocols), protocols: this.options.protocols, defaultProtocol: this.options.defaultProtocol, }) ) { return false } return null }, }, ] }, renderHTML({ HTMLAttributes }) { // prevent XSS attacks if ( !this.options.isAllowedUri(HTMLAttributes.href, { defaultValidate: href => !!isAllowedUri(href, this.options.protocols), protocols: this.options.protocols, defaultProtocol: this.options.defaultProtocol, }) ) { // strip out the href return ['a', mergeAttributes(this.options.HTMLAttributes, { ...HTMLAttributes, href: '' }), 0] } return ['a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] }, markdownTokenName: 'link', parseMarkdown: (token, helpers) => { return helpers.applyMark('link', helpers.parseInline(token.tokens || []), { href: token.href, title: token.title || null, }) }, renderMarkdown: (node, h) => { const href = node.attrs?.href ?? '' const title = node.attrs?.title ?? '' const text = h.renderChildren(node) return title ? `[${text}](${href} "${title}")` : `[${text}](${href})` }, addCommands() { return { setLink: attributes => ({ chain }) => { const { href } = attributes if ( !this.options.isAllowedUri(href, { defaultValidate: url => !!isAllowedUri(url, this.options.protocols), protocols: this.options.protocols, defaultProtocol: this.options.defaultProtocol, }) ) { return false } return chain().setMark(this.name, attributes).setMeta('preventAutolink', true).run() }, toggleLink: attributes => ({ chain }) => { const { href } = attributes || {} if ( href && !this.options.isAllowedUri(href, { defaultValidate: url => !!isAllowedUri(url, this.options.protocols), protocols: this.options.protocols, defaultProtocol: this.options.defaultProtocol, }) ) { return false } return chain() .toggleMark(this.name, attributes, { extendEmptyMarkRange: true }) .setMeta('preventAutolink', true) .run() }, unsetLink: () => ({ chain }) => { return chain().unsetMark(this.name, { extendEmptyMarkRange: true }).setMeta('preventAutolink', true).run() }, } }, addPasteRules() { return [ markPasteRule({ find: text => { const foundLinks: PasteRuleMatch[] = [] if (text) { const { protocols, defaultProtocol } = this.options const links = find(text).filter( item => item.isLink && this.options.isAllowedUri(item.value, { defaultValidate: href => !!isAllowedUri(href, protocols), protocols, defaultProtocol, }), ) if (links.length) { links.forEach(link => { if (!this.options.shouldAutoLink(link.value)) { return } foundLinks.push({ text: link.value, data: { href: link.href, }, index: link.start, }) }) } } return foundLinks }, type: this.type, getAttributes: match => { return { href: match.data?.href, } }, }), ] }, addProseMirrorPlugins() { const plugins: Plugin[] = [] const { protocols, defaultProtocol } = this.options if (this.options.autolink) { plugins.push( autolink({ type: this.type, defaultProtocol: this.options.defaultProtocol, validate: url => this.options.isAllowedUri(url, { defaultValidate: href => !!isAllowedUri(href, protocols), protocols, defaultProtocol, }), shouldAutoLink: this.options.shouldAutoLink, }), ) } plugins.push( clickHandler({ type: this.type, editor: this.editor, openOnClick: this.options.openOnClick === 'whenNotEditable' ? true : this.options.openOnClick, enableClickSelection: this.options.enableClickSelection, }), ) if (this.options.linkOnPaste) { plugins.push( pasteHandler({ editor: this.editor, defaultProtocol: this.options.defaultProtocol, type: this.type, shouldAutoLink: this.options.shouldAutoLink, }), ) } return plugins }, })