@docs.plus/extension-hyperlink 1.5.1 → 4.3.0

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/README.md CHANGED
@@ -1,456 +1,354 @@
1
- # Hyperlink
1
+ # @docs.plus/extension-hyperlink
2
2
 
3
3
  [![Version](https://img.shields.io/npm/v/@docs.plus/extension-hyperlink.svg?label=version)](https://www.npmjs.com/package/@docs.plus/extension-hyperlink)
4
4
  [![Downloads](https://img.shields.io/npm/dm/@docs.plus/extension-hyperlink.svg)](https://npmcharts.com/compare/@docs.plus/extension-hyperlink)
5
5
  [![License](https://img.shields.io/npm/l/@docs.plus/extension-hyperlink.svg)](https://www.npmjs.com/package/@docs.plus/extension-hyperlink)
6
6
 
7
- The Link extension adds support for `<a>` tags to the editor. The extension is headless, there is no actual <u>**UI**</u> to add, <u>modify</u> or <u>delete</u> links. The usage example below uses the native JavaScript prompt to show you how that could work.
7
+ > [!IMPORTANT]
8
+ > **v4 is a major rewrite.** Coming from v1.x? The API has been substantially
9
+ > redesigned (renamed options, renamed commands, kebab-case CSS classes,
10
+ > separate stylesheet, hardened XSS guards). Read the
11
+ > [v1 → v4 migration guide](./CHANGELOG.md#migrating-from-v1x-to-v4x)
12
+ > before upgrading.
8
13
 
9
- In a real world application, you would probably add a more sophisticated user interface.
14
+ A Tiptap extension for hyperlinks. Ships with optional prebuilt popovers for creating, previewing, and editing links, plus auto-linking, markdown input rules, and support for 50+ URL schemes (`mailto:`, `tel:`, `zoommtg:`, `vscode:`, `spotify:`, …).
10
15
 
11
- ## Installation
16
+ <p align="center">
17
+ <picture>
18
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/docs-plus/docs.plus/main/packages/extension-hyperlink/docs/screenshots/preview-dark.png">
19
+ <img alt="Hyperlink preview popover anchored to a link, with copy / edit / remove actions" width="640" src="https://raw.githubusercontent.com/docs-plus/docs.plus/main/packages/extension-hyperlink/docs/screenshots/preview-light.png">
20
+ </picture>
21
+ </p>
12
22
 
13
- ````sh
14
- npm install @docsplus/extension-hyperlink
15
- ````
23
+ ## Install
16
24
 
17
- ## Settings
18
-
19
- ### protocols
20
-
21
- Additional custom protocols you would like to be recognized as links.
25
+ ```sh
26
+ npm install @docs.plus/extension-hyperlink
27
+ ```
22
28
 
23
- Default: `[]`
29
+ Peer dependencies:
24
30
 
25
- ````js
26
- Hyperlink.configure({
27
- protocols: ['ftp', 'mailto'],
28
- })
29
- ````
31
+ ```sh
32
+ npm install @tiptap/core @tiptap/pm
33
+ ```
30
34
 
31
- By default, [linkify](https://linkify.js.org/docs/) adds `//` to the end of a protocol however this behavior can be changed by passing `optionalSlashes` option
35
+ ## Usage
32
36
 
33
- ````js
34
- Hyperlink.configure({
35
- protocols: [
36
- {
37
- scheme: 'tel',
38
- optionalSlashes: true
39
- }
37
+ ```ts
38
+ import { useEditor } from '@tiptap/react'
39
+ import StarterKit from '@tiptap/starter-kit'
40
+ import {
41
+ Hyperlink,
42
+ createHyperlinkPopover,
43
+ previewHyperlinkPopover
44
+ } from '@docs.plus/extension-hyperlink'
45
+ import '@docs.plus/extension-hyperlink/styles.css'
46
+
47
+ const editor = useEditor({
48
+ extensions: [
49
+ StarterKit.configure({ link: false }),
50
+ Hyperlink.configure({
51
+ popovers: {
52
+ previewHyperlink: previewHyperlinkPopover,
53
+ createHyperlink: createHyperlinkPopover
54
+ }
55
+ })
40
56
  ]
41
57
  })
42
- ````
43
-
44
- ### autolink
45
-
46
- If enabled, it adds links as you type.
47
-
48
- Default: `true`
58
+ ```
49
59
 
50
- ````js
51
- Hyperlink.configure({
52
- autolink: false,
53
- })
54
- ````
60
+ The `styles.css` import and `popovers` options are optional — see [Popovers](#popovers) and [Styling](#styling) to bring your own UI.
55
61
 
56
- ### openOnClick
62
+ ## Options
57
63
 
58
- If enabled, links will be opened on click.
64
+ | Option | Type | Default | Description |
65
+ | ---------------- | ----------------------------------------- | --------- | ----------------------------------------------------------------------------------------------- |
66
+ | `autolink` | `boolean` | `true` | Convert URLs to links as you type. |
67
+ | `openOnClick` | `boolean` | `true` | Open links on click. Overridden by `popovers.previewHyperlink` when set. |
68
+ | `linkOnPaste` | `boolean` | `true` | Wrap the current selection in a link when a URL is pasted. |
69
+ | `protocols` | `Array<string \| LinkProtocolOptions>` | `[]` | Extra protocols to register with [linkifyjs](https://linkify.js.org). 50+ are already built in. |
70
+ | `HTMLAttributes` | `Record<string, any>` | `{ ... }` | Attributes applied to rendered `<a>` tags. Set a key to `null` to remove it. |
71
+ | `validate` | `(url: string) => boolean` | — | Reject auto-linked URLs when this returns `false`. |
72
+ | `popovers` | `{ previewHyperlink?, createHyperlink? }` | — | Factory functions that return `HTMLElement`. See [Popovers](#popovers). |
59
73
 
60
- Default: `true`
74
+ Example with options:
61
75
 
62
- ````js
76
+ ```ts
63
77
  Hyperlink.configure({
64
- openOnClick: false,
78
+ autolink: true,
79
+ openOnClick: true,
80
+ linkOnPaste: true,
81
+ protocols: ['ftp', { scheme: 'tel', optionalSlashes: true }],
82
+ HTMLAttributes: { rel: 'noopener noreferrer nofollow', target: '_blank' },
83
+ validate: (href) => /^https?:\/\//.test(href)
65
84
  })
66
- ````
67
-
68
- ### linkOnPaste
85
+ ```
69
86
 
70
- Adds a link to the current selection if the pasted content only contains an url.
87
+ ## Commands
71
88
 
72
- Default: `true`
89
+ | Command | Description |
90
+ | -------------------------------------- | ----------------------------------------------------------------------------------------------- |
91
+ | `setHyperlink({ href, target? })` | Create a link on the current selection (or open the `createHyperlink` popover when configured). |
92
+ | `unsetHyperlink()` | Remove the link mark from the current selection. |
93
+ | `editHyperlink({ newURL?, newText? })` | Update the href and/or inner text of the link at the current selection. |
94
+ | `editHyperlinkHref(url)` | Shorthand for href-only edits. |
95
+ | `editHyperlinkText(text)` | Shorthand for text-only edits. |
73
96
 
74
- ````js
75
- Hyperlink.configure({
76
- linkOnPaste: false,
77
- })
78
- ````
97
+ ```ts
98
+ editor.commands.setHyperlink({ href: 'https://example.com' })
99
+ editor.commands.editHyperlink({ newURL: 'https://new.example.com', newText: 'New label' })
100
+ editor.commands.unsetHyperlink()
79
101
 
80
- ### HTMLAttributes
102
+ editor.getAttributes('hyperlink').href // read current href
103
+ ```
81
104
 
82
- Custom HTML attributes that should be added to the rendered HTML tag.
105
+ ## Keyboard shortcuts
83
106
 
84
- ````js
85
- Hyperlink.configure({
86
- HTMLAttributes: {
87
- class: 'my-custom-class',
88
- },
89
- })
90
- ````
107
+ | Shortcut | Action |
108
+ | -------- | ---------------------------------------------------- |
109
+ | `Mod-k` | Create or edit a hyperlink on the current selection. |
91
110
 
92
- ### Modals
111
+ ## Popovers
93
112
 
94
- The Modals configuration option lets you incorporate an interactive user interface similar to Google Docs for **setting** and **previewing** hyperlinks. This provides users with a more intuitive and interactive experience;
113
+ The extension is headless — you decide what the UI looks like. Three paths, from easiest to most flexible:
95
114
 
96
- - [Dive into the code](https://github.com/HMarzban/extension-hyperlink/blob/4f37ffa18237f10d76c316844b1c2ab20b751fe9/packages/nextjs/src/components/Tiptap.tsx#L21-L28)
97
- - [Demo](https://github.com/HMarzban/extension-hyperlink#test-drive-with-our-demo-)
115
+ 1. **Use the prebuilt popovers** (shown in [Usage](#usage)) and import `styles.css`.
116
+ 2. **Restyle the prebuilt popovers** by overriding `--hl-*` custom properties (see [Styling](#styling)).
117
+ 3. **Bring your own popover** — each popover is a factory that returns an `HTMLElement`. The extension handles positioning, outside-click dismissal, keyboard navigation, and cleanup.
98
118
 
99
119
  <details>
100
- <summary>The `previewHyperlinkModal` function</summary>
120
+ <summary><b>Custom <code>previewHyperlink</code></b></summary>
101
121
 
102
122
  ```ts
103
- function previewHyperlinkModal(options) {
104
- const href = options.link.href;
105
-
106
- const hyperlinkModal = document.createElement("div");
107
- const removeButton = document.createElement("button");
108
- const copyButton = document.createElement("button");
109
-
110
- const newBubble = document.createElement("div");
111
- newBubble.classList.add("hyperlink-preview-modal__metadata");
112
-
113
- const hrefTitle = document.createElement("a");
114
- hrefTitle.setAttribute("target", "_blank");
115
- hrefTitle.setAttribute("rel", "noreferrer");
116
- hrefTitle.setAttribute("href", href);
117
- hrefTitle.innerText = href;
118
-
119
- newBubble.append(hrefTitle);
120
-
121
- hyperlinkModal.classList.add("hyperlink-preview-modal");
122
-
123
- removeButton.classList.add("hyperlink-preview-modal__remove-button");
124
- removeButton.innerHTML = "remove";
125
-
126
- copyButton.classList.add("hyperlink-preview-modal__copy-button");
127
- copyButton.innerHTML = "copy";
128
-
129
- removeButton.addEventListener("click", () => {
130
- options.tippy.hide();
131
- return options.editor.chain().focus().unsetHyperlink().run();
132
- });
133
-
134
- copyButton.addEventListener("click", () => {
135
- options.tippy.hide();
136
- navigator.clipboard.writeText(href);
137
- });
138
-
139
- hyperlinkModal.append(newBubble, copyButton, removeButton);
140
-
141
- return hyperlinkModal;
123
+ import {
124
+ Hyperlink,
125
+ hideCurrentToolbar,
126
+ type PreviewHyperlinkOptions
127
+ } from '@docs.plus/extension-hyperlink'
128
+
129
+ function previewHyperlink(options: PreviewHyperlinkOptions): HTMLElement {
130
+ const { editor, attrs } = options
131
+ const root = document.createElement('div')
132
+
133
+ const link = document.createElement('a')
134
+ link.href = attrs.href
135
+ link.textContent = attrs.href
136
+ link.target = '_blank'
137
+ link.rel = 'noreferrer'
138
+
139
+ const remove = document.createElement('button')
140
+ remove.textContent = 'Remove'
141
+ remove.addEventListener('click', () => {
142
+ hideCurrentToolbar()
143
+ editor.chain().focus().unsetHyperlink().run()
144
+ })
145
+
146
+ root.append(link, remove)
147
+ return root
142
148
  }
143
149
 
150
+ Hyperlink.configure({ popovers: { previewHyperlink } })
144
151
  ```
145
152
 
146
153
  </details>
147
154
 
148
155
  <details>
149
- <summary>The `setHyperlinkModal` function</summary>
156
+ <summary><b>Custom <code>createHyperlink</code></b></summary>
150
157
 
151
158
  ```ts
152
- import { Editor } from "@tiptap/core";
153
- import { find } from "linkifyjs";
154
-
155
- let tooltip = undefined;
156
-
157
- function setHyperlinkModal(options) {
158
- // Create the tooltip instance
159
- if (!tooltip) tooltip = new options.Tooltip(options);
160
-
161
- // Initialize the tooltip
162
- let { tippyModal } = tooltip.init();
163
-
164
- const hyperlinkModal = document.createElement("div");
165
- const buttonsWrapper = document.createElement("div");
166
- const inputsWrapper = document.createElement("div");
167
-
168
- hyperlinkModal.classList.add("hyperlink-set-modal");
169
-
170
- buttonsWrapper.classList.add("hyperlink-set-modal__buttons-wrapper");
171
- inputsWrapper.classList.add("hyperlink-set-modal__inputs-wrapper");
172
-
173
- // create a form that contain url input and a button for submit
174
- const form = document.createElement("form");
175
- const input = document.createElement("input");
176
- const button = document.createElement("button");
177
-
178
- input.setAttribute("type", "text");
179
- input.setAttribute("placeholder", "https://example.com");
180
- button.setAttribute("type", "submit");
181
- button.innerText = "Submit";
159
+ import {
160
+ Hyperlink,
161
+ hideCurrentToolbar,
162
+ normalizeHref,
163
+ validateURL,
164
+ type CreateHyperlinkOptions
165
+ } from '@docs.plus/extension-hyperlink'
166
+
167
+ function createHyperlink(options: CreateHyperlinkOptions): HTMLElement {
168
+ const { editor, extensionName, validate } = options
169
+ const form = document.createElement('form')
170
+
171
+ const input = document.createElement('input')
172
+ input.type = 'url'
173
+ input.placeholder = 'https://example.com'
174
+
175
+ const submit = document.createElement('button')
176
+ submit.type = 'submit'
177
+ submit.textContent = 'Apply'
178
+
179
+ form.append(input, submit)
180
+ form.addEventListener('submit', (event) => {
181
+ event.preventDefault()
182
+ const url = input.value.trim()
183
+ if (!validateURL(url, { customValidator: validate })) return
184
+
185
+ // Canonicalize before writing to the mark. `google.com` is a valid
186
+ // link, but stored verbatim it resolves against the host page's
187
+ // origin on click — `normalizeHref` prepends `https://` for bare
188
+ // domains and preserves anything already scheme-prefixed.
189
+ const href = normalizeHref(url)
190
+
191
+ hideCurrentToolbar()
192
+ editor.chain().setMark(extensionName, { href }).setMeta('preventAutolink', true).run()
193
+ })
194
+
195
+ return form
196
+ }
182
197
 
183
- inputsWrapper.append(input);
184
- buttonsWrapper.append(button);
185
- form.append(inputsWrapper, buttonsWrapper);
198
+ Hyperlink.configure({ popovers: { createHyperlink } })
199
+ ```
186
200
 
187
- hyperlinkModal.append(form);
201
+ </details>
188
202
 
189
- tippyModal.innerHTML = "";
190
- tippyModal.append(hyperlinkModal);
191
- tooltip.update(options.editor.view, {
192
- arrow: options.roundArrow,
193
- });
203
+ Popover content can control the floating toolbar via `hideCurrentToolbar()` and `updateCurrentToolbarPosition(ref)`, both exported from the package.
194
204
 
195
- // make sure
196
- setTimeout(() => input.focus();, 100);
205
+ ## Styling
197
206
 
198
- // event listenr for submit button
199
- form.addEventListener("submit", (event) => {
200
- event.preventDefault();
201
- const url = input.value;
207
+ The prebuilt popovers ship with a small, framework-agnostic stylesheet. Import it once:
202
208
 
203
- if (!url) return;
209
+ ```ts
210
+ import '@docs.plus/extension-hyperlink/styles.css'
211
+ ```
204
212
 
205
- const sanitizeURL = find(url)
206
- .filter((link) => link.isLink)
207
- .filter((link) => {
208
- if (options.validate) {
209
- return options.validate(link.value);
210
- }
211
- return true;
212
- })
213
- .at(0);
213
+ The CSS is opt-in — the extension's JavaScript never imports it, so custom UIs pay zero CSS cost.
214
214
 
215
- if (!sanitizeURL?.href) return;
215
+ ### Theming
216
216
 
217
- tooltip?.hide();
217
+ Every visual token is a `--hl-*` custom property. Colors use [`light-dark()`](https://developer.mozilla.org/docs/Web/CSS/color_value/light-dark), so the popover follows the nearest ancestor's `color-scheme` — or the OS preference when none is set.
218
218
 
219
- return options.editor
220
- .chain()
221
- .setMark(options.extentionName, { href: sanitizeURL.href })
222
- .setMeta("preventautohyperlink", true)
223
- .run();
224
- });
219
+ <details>
220
+ <summary>Default values</summary>
221
+
222
+ ```css
223
+ :root {
224
+ --hl-bg: light-dark(#ffffff, #1f2937);
225
+ --hl-fg: light-dark(#111827, #f3f4f6);
226
+ --hl-muted: light-dark(#6b7280, #9ca3af);
227
+ --hl-border: light-dark(#e5e7eb, #374151);
228
+ --hl-hover: light-dark(#f3f4f6, #374151);
229
+ --hl-accent: light-dark(#2563eb, #60a5fa);
230
+ --hl-accent-fg: light-dark(#ffffff, #0b1220);
231
+ --hl-danger: light-dark(#dc2626, #f87171);
232
+ --hl-shadow:
233
+ 0 1px 2px light-dark(rgba(0, 0, 0, 0.06), rgba(0, 0, 0, 0.4)),
234
+ 0 4px 12px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.5));
235
+ --hl-radius: 6px;
236
+ --hl-radius-sm: 4px;
237
+ --hl-font: system-ui, sans-serif;
238
+ --hl-font-size: 14px;
239
+ --hl-z-index: 9999;
240
+ --hl-transition: 120ms ease-in-out;
225
241
  }
226
242
  ```
227
243
 
228
244
  </details>
229
245
 
246
+ #### Switching themes
230
247
 
231
- <details>
232
- <summary>The Hyperlink styles.scss</summary>
233
-
234
- ```scss
235
- .tippy-box {
236
- .hyperlink-preview-modal,
237
- .hyperlink-set-modal,
238
- .hyperlink-edit-modal {
239
- background-color: #fff;
240
- border-radius: 10px;
241
- border: 1px solid #dadce0;
242
- display: flex;
243
- align-items: center;
244
- justify-content: space-between;
245
- padding: 6px 6px;
246
- box-shadow: 0 1px 3px 1px rgba(60, 64, 67, 0.15);
247
- margin-top: -6px;
248
-
249
- &__metadata {
250
- width: 200px;
251
- display: flex;
252
- align-items: center;
253
- justify-content: flex-end;
254
- flex-direction: row-reverse;
255
- a {
256
- font-size: 0.9rem;
257
- margin-right: 6px;
258
- white-space: nowrap;
259
- overflow: hidden;
260
- text-overflow: ellipsis;
261
- }
262
- img {
263
- width: 18px;
264
- height: 18px;
265
- border-radius: 50%;
266
- margin-right: 8px;
267
- }
268
- }
269
-
270
- &__remove-button,
271
- &__edit-button,
272
- &__copy-button,
273
- &__apply-button {
274
- width: 30px;
275
- height: 30px;
276
- border-radius: 50%;
277
- margin: 0 0.25rem;
278
- display: flex;
279
- align-items: center;
280
- justify-content: center;
281
- transition: background-color 0.1s ease-in-out;
282
- &:hover {
283
- background-color: #eee;
284
- }
285
- > svg {
286
- width: 16px;
287
- height: 16px;
288
- }
289
- }
290
-
291
- form {
292
- display: flex;
293
- align-items: flex-end;
294
- width: 100%;
295
- input {
296
- border: 1px solid #dadce0;
297
- border-radius: 6px;
298
- padding: 0.4rem 0.8rem;
299
- margin-bottom: 0.2rem;
300
- width: 100%;
301
- &:last-of-type {
302
- margin-bottom: 0;
303
- }
304
- }
305
- .hyperlink-set-modal__buttons-wrapper,
306
- .hyperlink-edit-modal__buttons-wrapper {
307
- margin-left: 8px;
308
- button {
309
- border-radius: 6px;
310
- padding: 4px 14px;
311
- width: 70px;
312
- margin-bottom: 0.2rem;
313
- color: #1a73e8;
314
- &:hover {
315
- background: rgba(26, 115, 232, 0.04);
316
- color: #174ea6;
317
- }
318
- }
319
- }
320
- }
321
- }
248
+ If your app has a light/dark toggle, set `color-scheme` on the theme root — the popover follows along:
322
249
 
323
- .tippy-svg-arrow {
324
- top: -6px !important;
325
- }
250
+ ```css
251
+ html[data-theme='light'] {
252
+ color-scheme: light;
253
+ }
254
+ html[data-theme='dark'] {
255
+ color-scheme: dark;
326
256
  }
327
257
  ```
328
258
 
329
- </details>
259
+ > **Bundler note.** Some CSS minifiers (lightningcss, for one) down-level `light-dark()` into a `@media (prefers-color-scheme: dark)` block, which pins colors to the OS preference. If that happens, re-declare the tokens on each branch — the attribute selector wins over the media query:
260
+ >
261
+ > ```css
262
+ > html[data-theme='light'] {
263
+ > --hl-bg: #ffffff;
264
+ > --hl-fg: #111827; /* … */
265
+ > }
266
+ > html[data-theme='dark'] {
267
+ > --hl-bg: #1f2937;
268
+ > --hl-fg: #f3f4f6; /* … */
269
+ > }
270
+ > ```
330
271
 
272
+ ### Class names
331
273
 
332
- ````ts
333
- import {
334
- Hyperlink,
335
- previewHyperlinkModal,
336
- setHyperlinkModal
337
- } from "@docs.plus/extension-hyperlink";
274
+ Stable class names for consumers who want to write their own rules:
338
275
 
339
- Hyperlink.configure({
340
- modals: {
341
- previewHyperlink: (data) => {
342
- return previewHyperlinkModal(data);
343
- },
344
- setHyperlink: (data) => {
345
- return setHyperlinkModal(data);
346
- },
347
- },
348
- })
349
- ````
350
-
351
- > Note: The `previewHyperlinkModal` and `setHyperlinkModal` modals are prebuilt modal boxes. You can use them or create your own modal boxes. To learn how to create your own modal boxes, please check the [source code](https://github.com/HMarzban/extension-hyperlink/tree/main/packages/extension-hyperlink/src/modals)
352
-
353
- <Su>
354
-
355
-
356
-
357
- ### Removing and overriding existing html attributes
358
-
359
- You can add `rel: null` to HTMLAttributes to remove the default `rel="noopener noreferrer nofollow"`. You can also override the default by using `rel: "your-value"`.
360
-
361
- This can also be used to change the `target` from the default value of `_blank`.
362
-
363
- ````js
364
- Hyperlink.configure({
365
- HTMLAttributes: {
366
- // Change rel to different value
367
- // Allow search engines to follow links(remove nofollow)
368
- rel: 'noopener noreferrer',
369
- // Remove target entirely so links open in current tab
370
- target: null,
371
- },
372
- })
373
- ````
276
+ | Class | Element |
277
+ | ---------------------------- | ---------------------------------------------------- |
278
+ | `.floating-toolbar` | Toolbar container (adds `.visible` after mount). |
279
+ | `.floating-toolbar-arrow` | Arrow pointing at the reference element. |
280
+ | `.floating-toolbar-content` | Content wrapper inside the toolbar. |
281
+ | `.hyperlink-create-popover` | Create-link popover root. |
282
+ | `.hyperlink-preview-popover` | Preview popover root. |
283
+ | `.hyperlink-edit-popover` | Edit popover root. |
284
+ | `.inputs-wrapper` | Input group container (adds `.error` on validation). |
285
+ | `.buttons-wrapper` | Button group container. |
286
+ | `.search-icon` | Leading icon inside an input group. |
287
+ | `.error-message` | Validation error text (shown with `.show`). |
374
288
 
375
- ### validate
289
+ ## URL handling
376
290
 
377
- A function that validates every autolinked link. If it exists, it will be called with the link href as argument. If it returns `false`, the link will be removed.
291
+ Every write path canonicalizes hrefs before storing them:
378
292
 
379
- Can be used to set rules for example excluding or including certain domains, tlds, etc.
293
+ - **Bare domains** get `https://` prepended: `google.com` → `https://google.com`.
294
+ - **Explicit schemes** pass through as typed: `http://`, `ftp://`, `whatsapp://`, `mailto:`.
295
+ - **Protocol-relative URLs** stay as-is: `//example.com`.
380
296
 
381
- ````js
382
- // only autolink urls with a protocol
383
- Hyperlink.configure({
384
- validate: href => /^https?:\/\//.test(href),
385
- })
386
- ````
297
+ The create popover, markdown input rule, autolink, and paste all produce the same `href` for the same input.
387
298
 
388
- ## Commands
299
+ Validation also rejects scheme-prefixed typos with no real host. `https://googlecom` fails; `http://localhost`, `https://127.0.0.1`, and any registered custom scheme pass.
389
300
 
390
- ### editHyperLinkText(), editHyperLinkHref(), editHyperlink()
301
+ Two helpers are exported for custom popovers:
391
302
 
392
- These commands allow you to edit the text and href value of a hyperlink.
303
+ | Helper | Use when |
304
+ | ------------------------- | ---------------------------------------------------- |
305
+ | `normalizeHref(raw)` | You have a raw URL string. |
306
+ | `normalizeLinkifyHref(m)` | You have a linkifyjs match (e.g. from `find(text)`). |
393
307
 
394
- ```js
395
- this.editor.commands.editHyperLinkText('New Text');
396
- this.editor.commands.editHyperLinkHref('https://new-url.com');
397
- this.editor.commands.editHyperlink({
398
- newText: 'New Text',
399
- newURL: 'https://new-url.com'
400
- });
401
- ```
308
+ ## Security
402
309
 
403
- ### setHyperlink()
310
+ Dangerous schemes (`javascript:`, `data:`, `vbscript:`) are blocked everywhere a link enters or leaves the editor:
404
311
 
405
- Links the selected text.
312
+ - `parseHTML` strips matching `<a>` tags on document load and paste.
313
+ - The markdown input rule rejects `[text](javascript:…)` at creation.
314
+ - The click handler and preview popover short-circuit `window.open(…)` when the stored href matches.
406
315
 
407
- ````js
408
- this.editor.commands.setHyperlink({
409
- href: '<https://example.com>'
410
- });
316
+ On the read side, both handlers prefer the stored mark attribute over the DOM `link.href` property — so a relative href injected via `setContent` won't resolve against the host page's origin.
411
317
 
412
- this.editor.commands.setHyperlink({
413
- href: '<https://example.com>',
414
- target: '_blank'
415
- });
318
+ The shared `DANGEROUS_SCHEME_RE` regex is exported for custom popovers that need the same check.
416
319
 
417
- this.editor.commands.unsetHyperlink();
418
- ````
320
+ ## TypeScript
419
321
 
420
- ### unsetHyperlink()
322
+ Definitions are bundled. Public types: `HyperlinkOptions`, `HyperlinkAttributes`, `PreviewHyperlinkOptions`, `CreateHyperlinkOptions`, `EditHyperlinkModalOptions`, `LinkProtocolOptions`, `FloatingToolbarOptions`, `FloatingToolbarInstance`, `LinkifyMatchLike`.
421
323
 
422
- Removes a Hyperlink.
324
+ ## Testing
423
325
 
424
- ````js
425
- this.editor.commands.unsetHyperlink();
426
- ````
326
+ Cypress runs against the built `dist/` output in a tiny `Bun.serve` playground — the same bytes an npm consumer installs.
427
327
 
428
- ## Keyboard shortcuts
429
-
430
- Doesn’t have a keyboard shortcut
431
- This extension doesn’t bind a specific keyboard shortcut. You would probably open your custom UI on `Mod-k` though.
432
-
433
- ## Get the current value
434
-
435
- Did you know that you can use `getAttributes` to find out which attributes, for example which href, is currently set? Don’t confuse it with a <u>command</u> (which changes the state), it’s just a method. Here is how that could look like:
436
-
437
- ```js
438
- this.editor.getAttributes('link').href
328
+ ```sh
329
+ bun run test # build + run Cypress headless
330
+ bun run test:open # build + open the Cypress runner
331
+ bun run playground # just the playground at http://127.0.0.1:5173
332
+ bun run docs:screenshots # regenerate README hero PNGs in docs/screenshots/
439
333
  ```
440
334
 
441
- ## Sorce code and Example
335
+ Append `?popover=custom` to the playground URL to swap in minimal BYO factories — that's how `custom-popover.cy.ts` exercises the public contract.
336
+
337
+ | Spec | Covers |
338
+ | ---------------------- | ---------------------------------------------------------------------- |
339
+ | `create.cy.ts` | `Mod+K`, Apply state, href canonicalization, host validation, dismiss. |
340
+ | `preview-edit.cy.ts` | Click → preview → edit/copy/remove, Back, Escape lifecycle. |
341
+ | `autolink.cy.ts` | Autolink, paste, and `mailto:` all produce the same `href`. |
342
+ | `xss-guards.cy.ts` | `javascript:` / `data:` / `vbscript:` blocked at every entry point. |
343
+ | `styling.cy.ts` | `styles.css` loaded, `--hl-*` tokens resolve, `light-dark()` branch. |
344
+ | `custom-popover.cy.ts` | BYO factory contract — options, mount lifecycle, exports. |
442
345
 
443
- - Demo:
444
- [packages/extension-hyperlink](https://github.com/HMarzban/extension-hyperlink)
445
- - Extension:
446
- [packages/extension-hyperlink](https://github.com/HMarzban/extension-hyperlink/tree/main/packages/extension-hyperlink)
447
- - Usage: [packages/nextjs/src/components/Tiptap.tsx](https://github.com/HMarzban/extension-hyperlink/blob/59f45eba1886202f4840eb2112c34574c16fe68a/packages/nextjs/src/components/Tiptap.tsx#L19-L29)
346
+ The suite also runs from the repo root via `bun run test:all`, alongside the Jest and webapp Cypress suites.
448
347
 
449
- ## Inspiration and Acknowledgment, Let's Connect
348
+ ## License
450
349
 
451
- Thank you for exploring our Hyperlink extension from docs.plus! We aim to make collaboration and knowledge sharing not just easy, but also enjoyable.
350
+ MIT
452
351
 
453
- Our extension is inspired by Tiptap's [extension-link](https://github.com/ueberdosis/tiptap/tree/main/packages/extension-link). While we've incorporated our own enhancements, we'd like to tip our hats to Tiptap for pioneering the "headless" approach that we admire greatly.
454
- > Please note: We're not affiliated with Tiptap, but we believe in recognizing foundational work.
352
+ ## Credits
455
353
 
456
- Your feedback and interest in docs.plus are invaluable to us. Share your thoughts, suggestions, or dive deeper into our mission at the [docs.plus](https://github.com/docs-plus/docs.plus) repository. Wish to converse? Connect with us [here](https://github.com/docs-plus/docs.plus#-connect-with-us).
354
+ Inspired by Tiptap's [@tiptap/extension-link](https://github.com/ueberdosis/tiptap/tree/main/packages/extension-link). Part of [docs.plus](https://github.com/docs-plus/docs.plus).