Socket
Socket
Sign inDemoInstall

@tiptap/extension-link

Package Overview
Dependencies
Maintainers
5
Versions
173
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@tiptap/extension-link - npm Package Compare versions

Comparing version 3.0.0-next.0 to 3.0.0-next.1

dist/index.d.cts

639

dist/index.js

@@ -1,339 +0,342 @@

import { combineTransactionSteps, getChangedRanges, findChildrenInRange, getMarksBetween, getAttributes, Mark, mergeAttributes, markPasteRule } from '@tiptap/core';
import { tokenize, find, registerCustomProtocol, reset } from 'linkifyjs';
import { Plugin, PluginKey } from '@tiptap/pm/state';
// src/link.ts
import {
Mark,
markPasteRule,
mergeAttributes
} from "@tiptap/core";
import { find as find2, registerCustomProtocol, reset } from "linkifyjs";
/**
* Check if the provided tokens form a valid link structure, which can either be a single link token
* or a link token surrounded by parentheses or square brackets.
*
* This ensures that only complete and valid text is hyperlinked, preventing cases where a valid
* top-level domain (TLD) is immediately followed by an invalid character, like a number. For
* example, with the `find` method from Linkify, entering `example.com1` would result in
* `example.com` being linked and the trailing `1` left as plain text. By using the `tokenize`
* method, we can perform more comprehensive validation on the input text.
*/
// src/helpers/autolink.ts
import {
combineTransactionSteps,
findChildrenInRange,
getChangedRanges,
getMarksBetween
} from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { tokenize } from "linkifyjs";
function isValidLinkStructure(tokens) {
if (tokens.length === 1) {
return tokens[0].isLink;
}
if (tokens.length === 3 && tokens[1].isLink) {
return ['()', '[]'].includes(tokens[0].value + tokens[2].value);
}
return false;
if (tokens.length === 1) {
return tokens[0].isLink;
}
if (tokens.length === 3 && tokens[1].isLink) {
return ["()", "[]"].includes(tokens[0].value + tokens[2].value);
}
return false;
}
/**
* This plugin allows you to automatically add links to your editor.
* @param options The plugin options
* @returns The plugin instance
*/
function autolink(options) {
return new Plugin({
key: new PluginKey('autolink'),
appendTransaction: (transactions, oldState, newState) => {
/**
* Does the transaction change the document?
*/
const docChanges = transactions.some(transaction => transaction.docChanged) && !oldState.doc.eq(newState.doc);
/**
* Prevent autolink if the transaction is not a document change or if the transaction has the meta `preventAutolink`.
*/
const preventAutolink = transactions.some(transaction => transaction.getMeta('preventAutolink'));
/**
* Prevent autolink if the transaction is not a document change
* or if the transaction has the meta `preventAutolink`.
*/
if (!docChanges || preventAutolink) {
return;
return new Plugin({
key: new PluginKey("autolink"),
appendTransaction: (transactions, oldState, newState) => {
const docChanges = transactions.some((transaction) => transaction.docChanged) && !oldState.doc.eq(newState.doc);
const preventAutolink = transactions.some((transaction) => transaction.getMeta("preventAutolink"));
if (!docChanges || preventAutolink) {
return;
}
const { tr } = newState;
const transform = combineTransactionSteps(oldState.doc, [...transactions]);
const changes = getChangedRanges(transform);
changes.forEach(({ newRange }) => {
const nodesInChangedRanges = findChildrenInRange(
newState.doc,
newRange,
(node) => node.isTextblock
);
let textBlock;
let textBeforeWhitespace;
if (nodesInChangedRanges.length > 1) {
textBlock = nodesInChangedRanges[0];
textBeforeWhitespace = newState.doc.textBetween(
textBlock.pos,
textBlock.pos + textBlock.node.nodeSize,
void 0,
" "
);
} else if (nodesInChangedRanges.length && newState.doc.textBetween(newRange.from, newRange.to, " ", " ").endsWith(" ")) {
textBlock = nodesInChangedRanges[0];
textBeforeWhitespace = newState.doc.textBetween(
textBlock.pos,
newRange.to,
void 0,
" "
);
}
if (textBlock && textBeforeWhitespace) {
const wordsBeforeWhitespace = textBeforeWhitespace.split(" ").filter((s) => s !== "");
if (wordsBeforeWhitespace.length <= 0) {
return false;
}
const lastWordBeforeSpace = wordsBeforeWhitespace[wordsBeforeWhitespace.length - 1];
const lastWordAndBlockOffset = textBlock.pos + textBeforeWhitespace.lastIndexOf(lastWordBeforeSpace);
if (!lastWordBeforeSpace) {
return false;
}
const linksBeforeSpace = tokenize(lastWordBeforeSpace).map((t) => t.toObject(options.defaultProtocol));
if (!isValidLinkStructure(linksBeforeSpace)) {
return false;
}
linksBeforeSpace.filter((link) => link.isLink).map((link) => ({
...link,
from: lastWordAndBlockOffset + link.start + 1,
to: lastWordAndBlockOffset + link.end + 1
})).filter((link) => {
if (!newState.schema.marks.code) {
return true;
}
const { tr } = newState;
const transform = combineTransactionSteps(oldState.doc, [...transactions]);
const changes = getChangedRanges(transform);
changes.forEach(({ newRange }) => {
// Now let’s see if we can add new links.
const nodesInChangedRanges = findChildrenInRange(newState.doc, newRange, node => node.isTextblock);
let textBlock;
let textBeforeWhitespace;
if (nodesInChangedRanges.length > 1) {
// Grab the first node within the changed ranges (ex. the first of two paragraphs when hitting enter).
textBlock = nodesInChangedRanges[0];
textBeforeWhitespace = newState.doc.textBetween(textBlock.pos, textBlock.pos + textBlock.node.nodeSize, undefined, ' ');
}
else if (nodesInChangedRanges.length
// We want to make sure to include the block seperator argument to treat hard breaks like spaces.
&& newState.doc.textBetween(newRange.from, newRange.to, ' ', ' ').endsWith(' ')) {
textBlock = nodesInChangedRanges[0];
textBeforeWhitespace = newState.doc.textBetween(textBlock.pos, newRange.to, undefined, ' ');
}
if (textBlock && textBeforeWhitespace) {
const wordsBeforeWhitespace = textBeforeWhitespace.split(' ').filter(s => s !== '');
if (wordsBeforeWhitespace.length <= 0) {
return false;
}
const lastWordBeforeSpace = wordsBeforeWhitespace[wordsBeforeWhitespace.length - 1];
const lastWordAndBlockOffset = textBlock.pos + textBeforeWhitespace.lastIndexOf(lastWordBeforeSpace);
if (!lastWordBeforeSpace) {
return false;
}
const linksBeforeSpace = tokenize(lastWordBeforeSpace).map(t => t.toObject(options.defaultProtocol));
if (!isValidLinkStructure(linksBeforeSpace)) {
return false;
}
linksBeforeSpace
.filter(link => link.isLink)
// Calculate link position.
.map(link => ({
...link,
from: lastWordAndBlockOffset + link.start + 1,
to: lastWordAndBlockOffset + link.end + 1,
}))
// ignore link inside code mark
.filter(link => {
if (!newState.schema.marks.code) {
return true;
}
return !newState.doc.rangeHasMark(link.from, link.to, newState.schema.marks.code);
})
// validate link
.filter(link => options.validate(link.value))
// Add link mark.
.forEach(link => {
if (getMarksBetween(link.from, link.to, newState.doc).some(item => item.mark.type === options.type)) {
return;
}
tr.addMark(link.from, link.to, options.type.create({
href: link.href,
}));
});
}
});
if (!tr.steps.length) {
return;
return !newState.doc.rangeHasMark(
link.from,
link.to,
newState.schema.marks.code
);
}).filter((link) => options.validate(link.value)).forEach((link) => {
if (getMarksBetween(link.from, link.to, newState.doc).some((item) => item.mark.type === options.type)) {
return;
}
return tr;
},
});
tr.addMark(
link.from,
link.to,
options.type.create({
href: link.href
})
);
});
}
});
if (!tr.steps.length) {
return;
}
return tr;
}
});
}
// src/helpers/clickHandler.ts
import { getAttributes } from "@tiptap/core";
import { Plugin as Plugin2, PluginKey as PluginKey2 } from "@tiptap/pm/state";
function clickHandler(options) {
return new Plugin({
key: new PluginKey('handleClickLink'),
props: {
handleClick: (view, pos, event) => {
var _a, _b;
if (event.button !== 0) {
return false;
}
if (!view.editable) {
return false;
}
let a = event.target;
const els = [];
while (a.nodeName !== 'DIV') {
els.push(a);
a = a.parentNode;
}
if (!els.find(value => value.nodeName === 'A')) {
return false;
}
const attrs = getAttributes(view.state, options.type.name);
const link = event.target;
const href = (_a = link === null || link === void 0 ? void 0 : link.href) !== null && _a !== void 0 ? _a : attrs.href;
const target = (_b = link === null || link === void 0 ? void 0 : link.target) !== null && _b !== void 0 ? _b : attrs.target;
if (link && href) {
window.open(href, target);
return true;
}
return false;
},
},
});
return new Plugin2({
key: new PluginKey2("handleClickLink"),
props: {
handleClick: (view, pos, event) => {
var _a, _b;
if (event.button !== 0) {
return false;
}
if (!view.editable) {
return false;
}
let a = event.target;
const els = [];
while (a.nodeName !== "DIV") {
els.push(a);
a = a.parentNode;
}
if (!els.find((value) => value.nodeName === "A")) {
return false;
}
const attrs = getAttributes(view.state, options.type.name);
const link = event.target;
const href = (_a = link == null ? void 0 : link.href) != null ? _a : attrs.href;
const target = (_b = link == null ? void 0 : link.target) != null ? _b : attrs.target;
if (link && href) {
window.open(href, target);
return true;
}
return false;
}
}
});
}
// src/helpers/pasteHandler.ts
import { Plugin as Plugin3, PluginKey as PluginKey3 } from "@tiptap/pm/state";
import { find } from "linkifyjs";
function pasteHandler(options) {
return new Plugin({
key: new PluginKey('handlePasteLink'),
props: {
handlePaste: (view, event, slice) => {
const { state } = view;
const { selection } = state;
const { empty } = selection;
if (empty) {
return false;
}
let textContent = '';
slice.content.forEach(node => {
textContent += node.textContent;
});
const link = find(textContent, { defaultProtocol: options.defaultProtocol }).find(item => item.isLink && item.value === textContent);
if (!textContent || !link) {
return false;
}
options.editor.commands.setMark(options.type, {
href: link.href,
});
return true;
},
},
});
return new Plugin3({
key: new PluginKey3("handlePasteLink"),
props: {
handlePaste: (view, event, slice) => {
const { state } = view;
const { selection } = state;
const { empty } = selection;
if (empty) {
return false;
}
let textContent = "";
slice.content.forEach((node) => {
textContent += node.textContent;
});
const link = find(textContent, { defaultProtocol: options.defaultProtocol }).find((item) => item.isLink && item.value === textContent);
if (!textContent || !link) {
return false;
}
options.editor.commands.setMark(options.type, {
href: link.href
});
return true;
}
}
});
}
const pasteRegex = /https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z]{2,}\b(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)/gi;
// From DOMPurify
// https://github.com/cure53/DOMPurify/blob/main/src/regexp.js
const ATTR_WHITESPACE = /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g; // eslint-disable-line no-control-regex
const IS_ALLOWED_URI = /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i; // eslint-disable-line no-useless-escape
// src/link.ts
var pasteRegex = /https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z]{2,}\b(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)/gi;
var ATTR_WHITESPACE = /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g;
var IS_ALLOWED_URI = /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i;
function isAllowedUri(uri) {
return !uri || uri.replace(ATTR_WHITESPACE, '').match(IS_ALLOWED_URI);
return !uri || uri.replace(ATTR_WHITESPACE, "").match(IS_ALLOWED_URI);
}
/**
* This extension allows you to create links.
* @see https://www.tiptap.dev/api/marks/link
*/
const Link = Mark.create({
name: 'link',
priority: 1000,
keepOnSplit: false,
exitable: true,
onCreate() {
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,
linkOnPaste: true,
autolink: true,
protocols: [],
defaultProtocol: 'http',
HTMLAttributes: {
target: '_blank',
rel: 'noopener noreferrer nofollow',
class: null,
},
validate: url => !!url,
};
},
addAttributes() {
return {
href: {
default: null,
},
target: {
default: this.options.HTMLAttributes.target,
},
rel: {
default: this.options.HTMLAttributes.rel,
},
class: {
default: this.options.HTMLAttributes.class,
},
};
},
parseHTML() {
return [{
tag: 'a[href]',
getAttrs: dom => {
const href = dom.getAttribute('href');
// prevent XSS attacks
if (!href || !isAllowedUri(href)) {
return false;
}
return { href };
},
}];
},
renderHTML({ HTMLAttributes }) {
// prevent XSS attacks
if (!isAllowedUri(HTMLAttributes.href)) {
// strip out the href
return ['a', mergeAttributes(this.options.HTMLAttributes, { ...HTMLAttributes, href: '' }), 0];
var Link = Mark.create({
name: "link",
priority: 1e3,
keepOnSplit: false,
exitable: true,
onCreate() {
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,
linkOnPaste: true,
autolink: true,
protocols: [],
defaultProtocol: "http",
HTMLAttributes: {
target: "_blank",
rel: "noopener noreferrer nofollow",
class: null
},
validate: (url) => !!url
};
},
addAttributes() {
return {
href: {
default: null,
parseHTML(element) {
return element.getAttribute("href");
}
return ['a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
addCommands() {
return {
setLink: attributes => ({ chain }) => {
return chain().setMark(this.name, attributes).setMeta('preventAutolink', true).run();
},
toggleLink: attributes => ({ chain }) => {
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 = [];
if (text) {
const { validate } = this.options;
const links = find(text).filter(item => item.isLink && validate(item.value));
if (links.length) {
links.forEach(link => (foundLinks.push({
text: link.value,
data: {
href: link.href,
},
index: link.start,
})));
}
}
return foundLinks;
},
target: {
default: this.options.HTMLAttributes.target
},
rel: {
default: this.options.HTMLAttributes.rel
},
class: {
default: this.options.HTMLAttributes.class
}
};
},
parseHTML() {
return [{
tag: "a[href]",
getAttrs: (dom) => {
const href = dom.getAttribute("href");
if (!href || !isAllowedUri(href)) {
return false;
}
return null;
}
}];
},
renderHTML({ HTMLAttributes }) {
if (!isAllowedUri(HTMLAttributes.href)) {
return ["a", mergeAttributes(this.options.HTMLAttributes, { ...HTMLAttributes, href: "" }), 0];
}
return ["a", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
addCommands() {
return {
setLink: (attributes) => ({ chain }) => {
return chain().setMark(this.name, attributes).setMeta("preventAutolink", true).run();
},
toggleLink: (attributes) => ({ chain }) => {
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 = [];
if (text) {
const { validate } = this.options;
const links = find2(text).filter((item) => item.isLink && validate(item.value));
if (links.length) {
links.forEach((link) => foundLinks.push({
text: link.value,
data: {
href: link.href
},
type: this.type,
getAttributes: match => {
var _a;
return {
href: (_a = match.data) === null || _a === void 0 ? void 0 : _a.href,
};
},
}),
];
},
addProseMirrorPlugins() {
const plugins = [];
if (this.options.autolink) {
plugins.push(autolink({
type: this.type,
defaultProtocol: this.options.defaultProtocol,
validate: this.options.validate,
}));
index: link.start
}));
}
}
return foundLinks;
},
type: this.type,
getAttributes: (match) => {
var _a;
return {
href: (_a = match.data) == null ? void 0 : _a.href
};
}
if (this.options.openOnClick === true) {
plugins.push(clickHandler({
type: this.type,
}));
}
if (this.options.linkOnPaste) {
plugins.push(pasteHandler({
editor: this.editor,
defaultProtocol: this.options.defaultProtocol,
type: this.type,
}));
}
return plugins;
},
})
];
},
addProseMirrorPlugins() {
const plugins = [];
if (this.options.autolink) {
plugins.push(
autolink({
type: this.type,
defaultProtocol: this.options.defaultProtocol,
validate: this.options.validate
})
);
}
if (this.options.openOnClick === true) {
plugins.push(
clickHandler({
type: this.type
})
);
}
if (this.options.linkOnPaste) {
plugins.push(
pasteHandler({
editor: this.editor,
defaultProtocol: this.options.defaultProtocol,
type: this.type
})
);
}
return plugins;
}
});
export { Link, Link as default, pasteRegex };
//# sourceMappingURL=index.js.map
// src/index.ts
var src_default = Link;
export {
Link,
src_default as default,
pasteRegex
};
//# sourceMappingURL=index.js.map
{
"name": "@tiptap/extension-link",
"description": "link extension for tiptap",
"version": "3.0.0-next.0",
"version": "3.0.0-next.1",
"homepage": "https://tiptap.dev",

@@ -18,3 +18,3 @@ "keywords": [

".": {
"types": "./dist/packages/extension-link/src/index.d.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js",

@@ -26,4 +26,3 @@ "require": "./dist/index.cjs"

"module": "dist/index.js",
"umd": "dist/index.umd.js",
"types": "dist/packages/extension-link/src/index.d.ts",
"types": "dist/index.d.ts",
"files": [

@@ -37,8 +36,8 @@ "src",

"devDependencies": {
"@tiptap/core": "^3.0.0-next.0",
"@tiptap/pm": "^3.0.0-next.0"
"@tiptap/core": "^3.0.0-next.1",
"@tiptap/pm": "^3.0.0-next.1"
},
"peerDependencies": {
"@tiptap/core": "^3.0.0-next.0",
"@tiptap/pm": "^3.0.0-next.0"
"@tiptap/core": "^3.0.0-next.1",
"@tiptap/pm": "^3.0.0-next.1"
},

@@ -51,5 +50,4 @@ "repository": {

"scripts": {
"clean": "rm -rf dist",
"build": "npm run clean && rollup -c"
"build": "tsup"
}
}

@@ -167,2 +167,5 @@ import {

default: null,
parseHTML(element) {
return element.getAttribute('href')
},
},

@@ -191,3 +194,3 @@ target: {

}
return { href }
return null
},

@@ -194,0 +197,0 @@ }]

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc