@thomasjahoda-forks/tiptap-extension-link 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/link.ts ADDED
@@ -0,0 +1,458 @@
1
+ import type { PasteRuleMatch } from '@tiptap/core'
2
+ import { Mark, markPasteRule, mergeAttributes } from '@tiptap/core'
3
+ import type { Plugin } from '@tiptap/pm/state'
4
+ import { find, registerCustomProtocol, reset } from 'linkifyjs'
5
+
6
+ import { autolink } from './helpers/autolink.js'
7
+ import { clickHandler } from './helpers/clickHandler.js'
8
+ import { pasteHandler } from './helpers/pasteHandler.js'
9
+ import { UNICODE_WHITESPACE_REGEX_GLOBAL } from './helpers/whitespace.js'
10
+
11
+ export interface LinkProtocolOptions {
12
+ /**
13
+ * The protocol scheme to be registered.
14
+ * @default '''
15
+ * @example 'ftp'
16
+ * @example 'git'
17
+ */
18
+ scheme: string
19
+
20
+ /**
21
+ * If enabled, it allows optional slashes after the protocol.
22
+ * @default false
23
+ * @example true
24
+ */
25
+ optionalSlashes?: boolean
26
+ }
27
+
28
+ export const pasteRegex =
29
+ /https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z]{2,}\b(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)/gi
30
+
31
+ /**
32
+ * @deprecated The default behavior is now to open links when the editor is not editable.
33
+ */
34
+ type DeprecatedOpenWhenNotEditable = 'whenNotEditable'
35
+
36
+ export interface LinkOptions {
37
+ /**
38
+ * If enabled, the extension will automatically add links as you type.
39
+ * @default true
40
+ * @example false
41
+ */
42
+ autolink: boolean
43
+
44
+ /**
45
+ * An array of custom protocols to be registered with linkifyjs.
46
+ * @default []
47
+ * @example ['ftp', 'git']
48
+ */
49
+ protocols: Array<LinkProtocolOptions | string>
50
+
51
+ /**
52
+ * Default protocol to use when no protocol is specified.
53
+ * @default 'http'
54
+ */
55
+ defaultProtocol: string
56
+ /**
57
+ * If enabled, links will be opened on click.
58
+ * @default true
59
+ * @example false
60
+ */
61
+ openOnClick: boolean | DeprecatedOpenWhenNotEditable
62
+ /**
63
+ * If enabled, the link will be selected when clicked.
64
+ * @default false
65
+ * @example true
66
+ */
67
+ enableClickSelection: boolean
68
+ /**
69
+ * Adds a link to the current selection if the pasted content only contains an url.
70
+ * @default true
71
+ * @example false
72
+ */
73
+ linkOnPaste: boolean
74
+
75
+ /**
76
+ * HTML attributes to add to the link element.
77
+ * @default {}
78
+ * @example { class: 'foo' }
79
+ */
80
+ HTMLAttributes: Record<string, any>
81
+
82
+ /**
83
+ * @deprecated Use the `shouldAutoLink` option instead.
84
+ * A validation function that modifies link verification for the auto linker.
85
+ * @param url - The url to be validated.
86
+ * @returns - True if the url is valid, false otherwise.
87
+ */
88
+ validate: (url: string) => boolean
89
+
90
+ /**
91
+ * A validation function which is used for configuring link verification for preventing XSS attacks.
92
+ * Only modify this if you know what you're doing.
93
+ *
94
+ * @returns {boolean} `true` if the URL is valid, `false` otherwise.
95
+ *
96
+ * @example
97
+ * isAllowedUri: (url, { defaultValidate, protocols, defaultProtocol }) => {
98
+ * return url.startsWith('./') || defaultValidate(url)
99
+ * }
100
+ */
101
+ isAllowedUri: (
102
+ /**
103
+ * The URL to be validated.
104
+ */
105
+ url: string,
106
+ ctx: {
107
+ /**
108
+ * The default validation function.
109
+ */
110
+ defaultValidate: (url: string) => boolean
111
+ /**
112
+ * An array of allowed protocols for the URL (e.g., "http", "https"). As defined in the `protocols` option.
113
+ */
114
+ protocols: Array<LinkProtocolOptions | string>
115
+ /**
116
+ * A string that represents the default protocol (e.g., 'http'). As defined in the `defaultProtocol` option.
117
+ */
118
+ defaultProtocol: string
119
+ },
120
+ ) => boolean
121
+
122
+ /**
123
+ * Determines whether a valid link should be automatically linked in the content.
124
+ *
125
+ * @param {string} url - The URL that has already been validated.
126
+ * @returns {boolean} - True if the link should be auto-linked; false if it should not be auto-linked.
127
+ */
128
+ shouldAutoLink: (url: string) => boolean
129
+ }
130
+
131
+ declare module '@tiptap/core' {
132
+ interface Commands<ReturnType> {
133
+ link: {
134
+ /**
135
+ * Set a link mark
136
+ * @param attributes The link attributes
137
+ * @example editor.commands.setLink({ href: 'https://tiptap.dev' })
138
+ */
139
+ setLink: (attributes: {
140
+ href: string
141
+ target?: string | null
142
+ rel?: string | null
143
+ class?: string | null
144
+ }) => ReturnType
145
+ /**
146
+ * Toggle a link mark
147
+ * @param attributes The link attributes
148
+ * @example editor.commands.toggleLink({ href: 'https://tiptap.dev' })
149
+ */
150
+ toggleLink: (attributes?: {
151
+ href: string
152
+ target?: string | null
153
+ rel?: string | null
154
+ class?: string | null
155
+ }) => ReturnType
156
+ /**
157
+ * Unset a link mark
158
+ * @example editor.commands.unsetLink()
159
+ */
160
+ unsetLink: () => ReturnType
161
+ }
162
+ }
163
+ }
164
+
165
+ export function isAllowedUri(uri: string | undefined, protocols?: LinkOptions['protocols']) {
166
+ const allowedProtocols: string[] = ['http', 'https', 'ftp', 'ftps', 'mailto', 'tel', 'callto', 'sms', 'cid', 'xmpp']
167
+
168
+ if (protocols) {
169
+ protocols.forEach(protocol => {
170
+ const nextProtocol = typeof protocol === 'string' ? protocol : protocol.scheme
171
+
172
+ if (nextProtocol) {
173
+ allowedProtocols.push(nextProtocol)
174
+ }
175
+ })
176
+ }
177
+
178
+ return (
179
+ !uri ||
180
+ uri.replace(UNICODE_WHITESPACE_REGEX_GLOBAL, '').match(
181
+ new RegExp(
182
+ // eslint-disable-next-line no-useless-escape
183
+ `^(?:(?:${allowedProtocols.join('|')}):|[^a-z]|[a-z0-9+.\-]+(?:[^a-z+.\-:]|$))`,
184
+ 'i',
185
+ ),
186
+ )
187
+ )
188
+ }
189
+
190
+ export function getEffectiveLinkHref(
191
+ href: string | null | undefined,
192
+ opts: LinkOptions
193
+ ): string | null {
194
+ if (!href) {
195
+ return null
196
+ }
197
+
198
+ if (href.includes('://')) {
199
+ return href
200
+ }
201
+ return `${opts.defaultProtocol}://${href}`
202
+ }
203
+
204
+ /**
205
+ * This extension allows you to create links.
206
+ * @see https://www.tiptap.dev/api/marks/link
207
+ */
208
+ export const Link = Mark.create<LinkOptions>({
209
+ name: 'link',
210
+
211
+ priority: 1000,
212
+
213
+ keepOnSplit: false,
214
+
215
+ exitable: true,
216
+
217
+ onCreate() {
218
+ if (this.options.validate && !this.options.shouldAutoLink) {
219
+ // Copy the validate function to the shouldAutoLink option
220
+ this.options.shouldAutoLink = this.options.validate
221
+ console.warn('The `validate` option is deprecated. Rename to the `shouldAutoLink` option instead.')
222
+ }
223
+ this.options.protocols.forEach(protocol => {
224
+ if (typeof protocol === 'string') {
225
+ registerCustomProtocol(protocol)
226
+ return
227
+ }
228
+ registerCustomProtocol(protocol.scheme, protocol.optionalSlashes)
229
+ })
230
+ },
231
+
232
+ onDestroy() {
233
+ reset()
234
+ },
235
+
236
+ inclusive() {
237
+ return this.options.autolink
238
+ },
239
+
240
+ addOptions() {
241
+ return {
242
+ openOnClick: true,
243
+ enableClickSelection: false,
244
+ linkOnPaste: true,
245
+ autolink: true,
246
+ protocols: [],
247
+ defaultProtocol: 'http',
248
+ HTMLAttributes: {
249
+ target: '_blank',
250
+ rel: 'noopener noreferrer nofollow',
251
+ class: null,
252
+ },
253
+ isAllowedUri: (url, ctx) => !!isAllowedUri(url, ctx.protocols),
254
+ validate: url => !!url,
255
+ shouldAutoLink: url => !!url,
256
+ }
257
+ },
258
+
259
+ addAttributes() {
260
+ return {
261
+ href: {
262
+ default: null,
263
+ parseHTML(element) {
264
+ return element.getAttribute('href')
265
+ },
266
+ },
267
+ target: {
268
+ default: this.options.HTMLAttributes.target,
269
+ },
270
+ rel: {
271
+ default: this.options.HTMLAttributes.rel,
272
+ },
273
+ class: {
274
+ default: this.options.HTMLAttributes.class,
275
+ },
276
+ }
277
+ },
278
+
279
+ parseHTML() {
280
+ return [
281
+ {
282
+ tag: 'a[href]',
283
+ getAttrs: dom => {
284
+ const href = (dom as HTMLElement).getAttribute('href')
285
+
286
+ // prevent XSS attacks
287
+ if (
288
+ !href ||
289
+ !this.options.isAllowedUri(href, {
290
+ defaultValidate: url => !!isAllowedUri(url, this.options.protocols),
291
+ protocols: this.options.protocols,
292
+ defaultProtocol: this.options.defaultProtocol,
293
+ })
294
+ ) {
295
+ return false
296
+ }
297
+ return null
298
+ },
299
+ },
300
+ ]
301
+ },
302
+
303
+ renderHTML({ HTMLAttributes }) {
304
+ // prevent XSS attacks
305
+ if (
306
+ !this.options.isAllowedUri(HTMLAttributes.href, {
307
+ defaultValidate: href => !!isAllowedUri(href, this.options.protocols),
308
+ protocols: this.options.protocols,
309
+ defaultProtocol: this.options.defaultProtocol,
310
+ })
311
+ ) {
312
+ // strip out the href
313
+ return ['a', mergeAttributes(this.options.HTMLAttributes, { ...HTMLAttributes, href: '' }), 0]
314
+ }
315
+
316
+ const effectiveHTMLAttributes = {
317
+ ...HTMLAttributes,
318
+ href: getEffectiveLinkHref(HTMLAttributes.href, this.options),
319
+ }
320
+ return ['a', mergeAttributes(this.options.HTMLAttributes, effectiveHTMLAttributes), 0]
321
+ },
322
+
323
+ addCommands() {
324
+ return {
325
+ setLink:
326
+ attributes =>
327
+ ({ chain }) => {
328
+ const { href } = attributes
329
+
330
+ if (
331
+ !this.options.isAllowedUri(href, {
332
+ defaultValidate: url => !!isAllowedUri(url, this.options.protocols),
333
+ protocols: this.options.protocols,
334
+ defaultProtocol: this.options.defaultProtocol,
335
+ })
336
+ ) {
337
+ return false
338
+ }
339
+
340
+ return chain().setMark(this.name, attributes).setMeta('preventAutolink', true).run()
341
+ },
342
+
343
+ toggleLink:
344
+ attributes =>
345
+ ({ chain }) => {
346
+ const { href } = attributes || {}
347
+
348
+ if (
349
+ href &&
350
+ !this.options.isAllowedUri(href, {
351
+ defaultValidate: url => !!isAllowedUri(url, this.options.protocols),
352
+ protocols: this.options.protocols,
353
+ defaultProtocol: this.options.defaultProtocol,
354
+ })
355
+ ) {
356
+ return false
357
+ }
358
+
359
+ return chain()
360
+ .toggleMark(this.name, attributes, { extendEmptyMarkRange: true })
361
+ .setMeta('preventAutolink', true)
362
+ .run()
363
+ },
364
+
365
+ unsetLink:
366
+ () =>
367
+ ({ chain }) => {
368
+ return chain().unsetMark(this.name, { extendEmptyMarkRange: true }).setMeta('preventAutolink', true).run()
369
+ },
370
+ }
371
+ },
372
+
373
+ addPasteRules() {
374
+ return [
375
+ markPasteRule({
376
+ find: text => {
377
+ const foundLinks: PasteRuleMatch[] = []
378
+
379
+ if (text) {
380
+ const { protocols, defaultProtocol } = this.options
381
+ const links = find(text).filter(
382
+ item =>
383
+ item.isLink &&
384
+ this.options.isAllowedUri(item.value, {
385
+ defaultValidate: href => !!isAllowedUri(href, protocols),
386
+ protocols,
387
+ defaultProtocol,
388
+ }),
389
+ )
390
+
391
+ if (links.length) {
392
+ links.forEach(link =>
393
+ foundLinks.push({
394
+ text: link.value,
395
+ data: {
396
+ href: link.href,
397
+ },
398
+ index: link.start,
399
+ }),
400
+ )
401
+ }
402
+ }
403
+
404
+ return foundLinks
405
+ },
406
+ type: this.type,
407
+ getAttributes: match => {
408
+ return {
409
+ href: match.data?.href,
410
+ }
411
+ },
412
+ }),
413
+ ]
414
+ },
415
+
416
+ addProseMirrorPlugins() {
417
+ const plugins: Plugin[] = []
418
+ const { protocols, defaultProtocol } = this.options
419
+
420
+ if (this.options.autolink) {
421
+ plugins.push(
422
+ autolink({
423
+ type: this.type,
424
+ defaultProtocol: this.options.defaultProtocol,
425
+ validate: url =>
426
+ this.options.isAllowedUri(url, {
427
+ defaultValidate: href => !!isAllowedUri(href, protocols),
428
+ protocols,
429
+ defaultProtocol,
430
+ }),
431
+ shouldAutoLink: this.options.shouldAutoLink,
432
+ }),
433
+ )
434
+ }
435
+
436
+ if (this.options.openOnClick === true) {
437
+ plugins.push(
438
+ clickHandler({
439
+ type: this.type,
440
+ editor: this.editor,
441
+ enableClickSelection: this.options.enableClickSelection,
442
+ }),
443
+ )
444
+ }
445
+
446
+ if (this.options.linkOnPaste) {
447
+ plugins.push(
448
+ pasteHandler({
449
+ editor: this.editor,
450
+ defaultProtocol: this.options.defaultProtocol,
451
+ type: this.type,
452
+ }),
453
+ )
454
+ }
455
+
456
+ return plugins
457
+ },
458
+ })