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:
2003
packages/extension-link/CHANGELOG.md
Normal file
2003
packages/extension-link/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
18
packages/extension-link/README.md
Normal file
18
packages/extension-link/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @tiptap/extension-link
|
||||
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-link)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-link)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
## Introduction
|
||||
|
||||
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
|
||||
|
||||
## Official Documentation
|
||||
|
||||
Documentation can be found on the [Tiptap website](https://tiptap.dev).
|
||||
|
||||
## License
|
||||
|
||||
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).
|
||||
55
packages/extension-link/package.json
Normal file
55
packages/extension-link/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@tiptap/extension-link",
|
||||
"version": "3.30.3",
|
||||
"description": "link extension for tiptap",
|
||||
"keywords": [
|
||||
"tiptap",
|
||||
"tiptap extension"
|
||||
],
|
||||
"homepage": "https://tiptap.dev",
|
||||
"bugs": {
|
||||
"url": "https://github.com/ueberdosis/tiptap/issues"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ueberdosis/tiptap",
|
||||
"directory": "packages/extension-link"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": {
|
||||
"import": "./dist/index.d.ts",
|
||||
"require": "./dist/index.d.cts"
|
||||
},
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vp pack"
|
||||
},
|
||||
"dependencies": {
|
||||
"linkifyjs": "^4.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tiptap/core": "workspace:^",
|
||||
"@tiptap/pm": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "workspace:*",
|
||||
"@tiptap/pm": "workspace:*"
|
||||
}
|
||||
}
|
||||
185
packages/extension-link/src/helpers/autolink.ts
Normal file
185
packages/extension-link/src/helpers/autolink.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import type { NodeWithPos } from '@tiptap/core'
|
||||
import {
|
||||
combineTransactionSteps,
|
||||
findChildrenInRange,
|
||||
getChangedRanges,
|
||||
getMarksBetween,
|
||||
} from '@tiptap/core'
|
||||
import type { MarkType } from '@tiptap/pm/model'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import type { MultiToken } from 'linkifyjs'
|
||||
import { tokenize } from 'linkifyjs'
|
||||
|
||||
import { UNICODE_WHITESPACE_REGEX, UNICODE_WHITESPACE_REGEX_END } from './whitespace.js'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function isValidLinkStructure(tokens: Array<ReturnType<MultiToken['toObject']>>) {
|
||||
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
|
||||
}
|
||||
|
||||
type AutolinkOptions = {
|
||||
type: MarkType
|
||||
defaultProtocol: string
|
||||
validate: (url: string) => boolean
|
||||
shouldAutoLink: (url: string) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* This plugin allows you to automatically add links to your editor.
|
||||
* @param options The plugin options
|
||||
* @returns The plugin instance
|
||||
*/
|
||||
export function autolink(options: AutolinkOptions): Plugin {
|
||||
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
|
||||
}
|
||||
|
||||
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: NodeWithPos | undefined
|
||||
let textBeforeWhitespace: string | undefined
|
||||
|
||||
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) {
|
||||
const endText = newState.doc.textBetween(newRange.from, newRange.to, ' ', ' ')
|
||||
if (!UNICODE_WHITESPACE_REGEX_END.test(endText)) {
|
||||
return
|
||||
}
|
||||
textBlock = nodesInChangedRanges[0]
|
||||
textBeforeWhitespace = newState.doc.textBetween(
|
||||
textBlock.pos,
|
||||
newRange.to,
|
||||
undefined,
|
||||
' ',
|
||||
)
|
||||
}
|
||||
|
||||
if (textBlock && textBeforeWhitespace) {
|
||||
const wordsBeforeWhitespace = textBeforeWhitespace
|
||||
.split(UNICODE_WHITESPACE_REGEX)
|
||||
.filter(Boolean)
|
||||
|
||||
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))
|
||||
// check whether should autolink
|
||||
.filter(link => options.shouldAutoLink(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 tr
|
||||
},
|
||||
})
|
||||
}
|
||||
73
packages/extension-link/src/helpers/clickHandler.ts
Normal file
73
packages/extension-link/src/helpers/clickHandler.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import { getAttributes } from '@tiptap/core'
|
||||
import type { MarkType } from '@tiptap/pm/model'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
|
||||
type ClickHandlerOptions = {
|
||||
type: MarkType
|
||||
editor: Editor
|
||||
openOnClick?: boolean
|
||||
enableClickSelection?: boolean
|
||||
}
|
||||
|
||||
export function clickHandler(options: ClickHandlerOptions): Plugin {
|
||||
return new Plugin({
|
||||
key: new PluginKey('handleClickLink'),
|
||||
props: {
|
||||
handleClick: (view, pos, event) => {
|
||||
if (event.button !== 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!view.editable) {
|
||||
return false
|
||||
}
|
||||
|
||||
let link: HTMLAnchorElement | null = null
|
||||
|
||||
if (event.target instanceof HTMLAnchorElement) {
|
||||
link = event.target
|
||||
} else {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
|
||||
const root = options.editor.view.dom
|
||||
|
||||
// Tntentionally limit the lookup to the editor root.
|
||||
// Using tag names like DIV as boundaries breaks with custom NodeViews,
|
||||
link = target.closest<HTMLAnchorElement>('a')
|
||||
|
||||
if (link && !root.contains(link)) {
|
||||
link = null
|
||||
}
|
||||
}
|
||||
|
||||
if (!link) {
|
||||
return false
|
||||
}
|
||||
|
||||
let handled = false
|
||||
|
||||
if (options.enableClickSelection) {
|
||||
const commandResult = options.editor.commands.extendMarkRange(options.type.name)
|
||||
handled = commandResult
|
||||
}
|
||||
|
||||
if (options.openOnClick) {
|
||||
const attrs = getAttributes(view.state, options.type.name)
|
||||
const href = link.href ?? attrs.href
|
||||
const target = link.target ?? attrs.target
|
||||
|
||||
if (href) {
|
||||
window.open(href, target)
|
||||
handled = true
|
||||
}
|
||||
}
|
||||
|
||||
return handled
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
205
packages/extension-link/src/helpers/markdownLink.ts
Normal file
205
packages/extension-link/src/helpers/markdownLink.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import type { InputRuleMatch, PasteRuleMatch } from '@tiptap/core'
|
||||
import { InputRule, markInputRule, markPasteRule, PasteRule } from '@tiptap/core'
|
||||
import type { MarkType } from '@tiptap/pm/model'
|
||||
|
||||
/**
|
||||
* Matches a Markdown link with an optional quoted title.
|
||||
* for ex: [Tiptap](https://tiptap.dev) or [Tiptap](https://tiptap.dev "some title")
|
||||
* the URL may also contain one level of balanced parentheses, as in CommonMark
|
||||
* (titles accept curly quotes too, the Typography extension swaps them in while typing)
|
||||
* the title delimiters must come in matching pairs
|
||||
*/
|
||||
const MARKDOWN_LINK_INPUT_REGEX =
|
||||
/\[([^[\]]+)\]\(((?:[^\s()]|\([^\s()]*\))+)(?:\s+(?:(["'])(.*?)\3|“(.*?)”|‘(.*?)’))?\)$/
|
||||
|
||||
/**
|
||||
* Same as the input regex but global, to find every Markdown link in pasted text.
|
||||
*/
|
||||
const MARKDOWN_LINK_PASTE_REGEX =
|
||||
/\[([^[\]]+)\]\(((?:[^\s()]|\([^\s()]*\))+)(?:\s+(?:(["'])(.*?)\3|“(.*?)”|‘(.*?)’))?\)/g
|
||||
|
||||
export interface MarkdownLinkRuleConfig {
|
||||
type: MarkType
|
||||
|
||||
/**
|
||||
* Return `false` to leave the Markdown syntax untouched.
|
||||
*/
|
||||
isAllowedHref: (href: string) => boolean
|
||||
}
|
||||
|
||||
export interface MarkdownLinkPasteRuleConfig extends MarkdownLinkRuleConfig {
|
||||
/**
|
||||
* Finds plain URLs to link in the same pass. Matches overlapping a
|
||||
* converted Markdown link are dropped so its href is kept.
|
||||
*/
|
||||
findPlainUrls?: (text: string) => PasteRuleMatch[]
|
||||
}
|
||||
|
||||
function isEscaped(text: string, index: number): boolean {
|
||||
let backslashes = 0
|
||||
|
||||
for (let position = index - 1; position >= 0 && text[position] === '\\'; position -= 1) {
|
||||
backslashes += 1
|
||||
}
|
||||
|
||||
return backslashes % 2 === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairs the backtick runs before the match by length, as CommonMark does.
|
||||
* A run left open means the match sits in an unfinished code span.
|
||||
*/
|
||||
function isInsideCodeSpan(text: string, matchIndex: number): boolean {
|
||||
let openRunLength = 0
|
||||
let index = 0
|
||||
|
||||
while (index < matchIndex) {
|
||||
if (text[index] !== '`') {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// escapes only apply outside code spans
|
||||
if (openRunLength === 0 && isEscaped(text, index)) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
let runLength = 0
|
||||
|
||||
while (index < matchIndex && text[index] === '`') {
|
||||
runLength += 1
|
||||
index += 1
|
||||
}
|
||||
|
||||
if (openRunLength === 0) {
|
||||
openRunLength = runLength
|
||||
} else if (runLength === openRunLength) {
|
||||
openRunLength = 0
|
||||
}
|
||||
}
|
||||
|
||||
return openRunLength > 0
|
||||
}
|
||||
|
||||
function isConvertibleLink(
|
||||
text: string,
|
||||
match: RegExpMatchArray,
|
||||
isAllowedHref: MarkdownLinkRuleConfig['isAllowedHref'],
|
||||
): boolean {
|
||||
const [, linkText, href] = match
|
||||
const characterBefore = match.index ? text[match.index - 1] : undefined
|
||||
|
||||
// `!` is the Markdown image syntax, `\` may escape the opening bracket
|
||||
if (characterBefore === '!' || isEscaped(text, match.index ?? 0)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isInsideCodeSpan(text, match.index ?? 0)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !!linkText.trim() && isAllowedHref(href)
|
||||
}
|
||||
|
||||
function toRuleMatch(match: RegExpMatchArray): InputRuleMatch & PasteRuleMatch {
|
||||
const [linkSyntax, linkText, href, , straightQuotedTitle, curlyDoubleTitle, curlySingleTitle] =
|
||||
match
|
||||
const title = straightQuotedTitle ?? curlyDoubleTitle ?? curlySingleTitle
|
||||
|
||||
return {
|
||||
index: match.index ?? 0,
|
||||
text: linkSyntax,
|
||||
replaceWith: linkText,
|
||||
data: {
|
||||
href,
|
||||
// an empty title ("") counts as no title, as in CommonMark
|
||||
title: title || null,
|
||||
markdown: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function matchesOverlap(a: PasteRuleMatch, b: PasteRuleMatch): boolean {
|
||||
return a.index < b.index + b.text.length && b.index < a.index + a.text.length
|
||||
}
|
||||
|
||||
function getMarkdownLinkAttributes(match: { data?: Record<string, any> }) {
|
||||
return {
|
||||
href: match.data?.href,
|
||||
title: match.data?.title ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns typed Markdown link syntax into a link mark as soon as the closing `)` comes in.
|
||||
* The transaction gets flagged so autolink doesn't touch the converted text again.
|
||||
*/
|
||||
export function markdownLinkInputRule(config: MarkdownLinkRuleConfig): InputRule {
|
||||
const rule = markInputRule({
|
||||
find: text => {
|
||||
const match = MARKDOWN_LINK_INPUT_REGEX.exec(text)
|
||||
|
||||
if (!match || !isConvertibleLink(text, match, config.isAllowedHref)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return toRuleMatch(match)
|
||||
},
|
||||
type: config.type,
|
||||
getAttributes: getMarkdownLinkAttributes,
|
||||
})
|
||||
|
||||
return new InputRule({
|
||||
find: rule.find,
|
||||
handler: props => {
|
||||
const result = rule.handler(props)
|
||||
|
||||
if (result !== null && props.state.tr.steps.length) {
|
||||
props.state.tr.setMeta('preventAutolink', true)
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Same for pasting, converts every Markdown link found in the pasted text
|
||||
* and links the plain URLs from `findPlainUrls`.
|
||||
*/
|
||||
export function markdownLinkPasteRule(config: MarkdownLinkPasteRuleConfig): PasteRule {
|
||||
const rule = markPasteRule({
|
||||
find: text => {
|
||||
const markdownMatches: PasteRuleMatch[] = []
|
||||
|
||||
for (const match of text.matchAll(MARKDOWN_LINK_PASTE_REGEX)) {
|
||||
if (isConvertibleLink(text, match, config.isAllowedHref)) {
|
||||
markdownMatches.push(toRuleMatch(match))
|
||||
}
|
||||
}
|
||||
|
||||
const plainUrlMatches = (config.findPlainUrls?.(text) ?? []).filter(
|
||||
urlMatch => !markdownMatches.some(markdownMatch => matchesOverlap(markdownMatch, urlMatch)),
|
||||
)
|
||||
|
||||
return [...markdownMatches, ...plainUrlMatches]
|
||||
},
|
||||
type: config.type,
|
||||
getAttributes: getMarkdownLinkAttributes,
|
||||
})
|
||||
|
||||
return new PasteRule({
|
||||
find: rule.find,
|
||||
handler: props => {
|
||||
const result = rule.handler(props)
|
||||
|
||||
// only Markdown conversions suppress autolink
|
||||
if (result !== null && props.state.tr.steps.length && props.match.data?.markdown) {
|
||||
props.state.tr.setMeta('preventAutolink', true)
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
})
|
||||
}
|
||||
53
packages/extension-link/src/helpers/pasteHandler.ts
Normal file
53
packages/extension-link/src/helpers/pasteHandler.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { MarkType } from '@tiptap/pm/model'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import { find } from 'linkifyjs'
|
||||
|
||||
import type { LinkOptions } from '../link.js'
|
||||
|
||||
type PasteHandlerOptions = {
|
||||
editor: Editor
|
||||
defaultProtocol: string
|
||||
type: MarkType
|
||||
shouldAutoLink?: LinkOptions['shouldAutoLink']
|
||||
}
|
||||
|
||||
export function pasteHandler(options: PasteHandlerOptions): Plugin {
|
||||
return new Plugin({
|
||||
key: new PluginKey('handlePasteLink'),
|
||||
props: {
|
||||
handlePaste: (view, _event, slice) => {
|
||||
const { shouldAutoLink } = options
|
||||
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 ||
|
||||
(shouldAutoLink !== undefined && !shouldAutoLink(link.value))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return options.editor.commands.setMark(options.type, {
|
||||
href: link.href,
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
8
packages/extension-link/src/helpers/whitespace.ts
Normal file
8
packages/extension-link/src/helpers/whitespace.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// From DOMPurify
|
||||
// https://github.com/cure53/DOMPurify/blob/main/src/regexp.ts
|
||||
export const UNICODE_WHITESPACE_PATTERN =
|
||||
'[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]'
|
||||
|
||||
export const UNICODE_WHITESPACE_REGEX = new RegExp(UNICODE_WHITESPACE_PATTERN)
|
||||
export const UNICODE_WHITESPACE_REGEX_END = new RegExp(`${UNICODE_WHITESPACE_PATTERN}$`)
|
||||
export const UNICODE_WHITESPACE_REGEX_GLOBAL = new RegExp(UNICODE_WHITESPACE_PATTERN, 'g')
|
||||
5
packages/extension-link/src/index.ts
Normal file
5
packages/extension-link/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Link } from './link.js'
|
||||
|
||||
export * from './link.js'
|
||||
|
||||
export default Link
|
||||
555
packages/extension-link/src/link.ts
Normal file
555
packages/extension-link/src/link.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
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 { markdownLinkInputRule, markdownLinkPasteRule } from './helpers/markdownLink.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<LinkProtocolOptions | string>
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
/**
|
||||
* If enabled, typing or pasting the Markdown link syntax, e.g. `[Tiptap](https://tiptap.dev)`
|
||||
* or `[Tiptap](https://tiptap.dev "Rich text editor")`, converts it into a link.
|
||||
* @default false
|
||||
* @example true
|
||||
*/
|
||||
markdownLinks: boolean
|
||||
|
||||
/**
|
||||
* HTML attributes to add to the link element.
|
||||
* @default {}
|
||||
* @example { class: 'foo' }
|
||||
*/
|
||||
HTMLAttributes: Record<string, any>
|
||||
|
||||
/**
|
||||
* @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<LinkProtocolOptions | string>
|
||||
/**
|
||||
* 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<ReturnType> {
|
||||
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(
|
||||
`^(?:(?:${allowedProtocols
|
||||
.map(protocol => protocol.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'))
|
||||
.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<LinkOptions>({
|
||||
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,
|
||||
markdownLinks: false, // TODO (major) - default to true on next major version
|
||||
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: {
|
||||
// Coerce `undefined` to `null` because `undefined` is an invalid attribute value
|
||||
default: this.options.HTMLAttributes.target ?? null,
|
||||
},
|
||||
rel: {
|
||||
// Coerce `undefined` to `null` because `undefined` is an invalid attribute value
|
||||
default: this.options.HTMLAttributes.rel ?? null,
|
||||
},
|
||||
class: {
|
||||
// Coerce `undefined` to `null` because `undefined` is an invalid attribute value
|
||||
default: this.options.HTMLAttributes.class ?? null,
|
||||
},
|
||||
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()
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
if (!this.options.markdownLinks) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
markdownLinkInputRule({
|
||||
type: this.type,
|
||||
isAllowedHref: href =>
|
||||
this.options.isAllowedUri(href, {
|
||||
defaultValidate: url => !!isAllowedUri(url, this.options.protocols),
|
||||
protocols: this.options.protocols,
|
||||
defaultProtocol: this.options.defaultProtocol,
|
||||
}),
|
||||
}),
|
||||
]
|
||||
},
|
||||
|
||||
addPasteRules() {
|
||||
const findPlainUrls = (text: string): PasteRuleMatch[] => {
|
||||
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,
|
||||
}),
|
||||
)
|
||||
|
||||
links.forEach(link => {
|
||||
if (!this.options.shouldAutoLink(link.value)) {
|
||||
return
|
||||
}
|
||||
|
||||
foundLinks.push({
|
||||
text: link.value,
|
||||
data: {
|
||||
href: link.href,
|
||||
},
|
||||
index: link.start,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return foundLinks
|
||||
}
|
||||
|
||||
if (this.options.markdownLinks) {
|
||||
return [
|
||||
markdownLinkPasteRule({
|
||||
type: this.type,
|
||||
isAllowedHref: href =>
|
||||
this.options.isAllowedUri(href, {
|
||||
defaultValidate: url => !!isAllowedUri(url, this.options.protocols),
|
||||
protocols: this.options.protocols,
|
||||
defaultProtocol: this.options.defaultProtocol,
|
||||
}),
|
||||
findPlainUrls,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
markPasteRule({
|
||||
find: findPlainUrls,
|
||||
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
|
||||
},
|
||||
})
|
||||
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)')
|
||||
})
|
||||
})
|
||||
})
|
||||
9
packages/extension-link/vite.config.ts
Normal file
9
packages/extension-link/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite-plus'
|
||||
import { basePackConfig } from '../../pack.config.mjs'
|
||||
|
||||
export default defineConfig({
|
||||
pack: {
|
||||
entry: ['src/index.ts'],
|
||||
...basePackConfig(),
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user