@docs.plus/extension-hyperlink 1.5.2 → 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,454 +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 @docs.plus/extension-hyperlink
15
- ````
16
-
17
- ## Settings
18
-
19
- ### protocols
23
+ ## Install
20
24
 
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.
58
+ ```
47
59
 
48
- Default: `true`
60
+ The `styles.css` import and `popovers` options are optional — see [Popovers](#popovers) and [Styling](#styling) to bring your own UI.
49
61
 
50
- ````js
51
- Hyperlink.configure({
52
- autolink: false,
53
- })
54
- ````
62
+ ## Options
55
63
 
56
- ### openOnClick
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). |
57
73
 
58
- If enabled, links will be opened on click.
74
+ Example with options:
59
75
 
60
- Default: `true`
61
-
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";
182
-
183
- inputsWrapper.append(input);
184
- buttonsWrapper.append(button);
185
- form.append(inputsWrapper, buttonsWrapper);
186
-
187
- hyperlinkModal.append(form);
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
+ }
188
197
 
189
- tippyModal.innerHTML = "";
190
- tippyModal.append(hyperlinkModal);
191
- tooltip.update(options.editor.view, {
192
- arrow: options.roundArrow,
193
- });
198
+ Hyperlink.configure({ popovers: { createHyperlink } })
199
+ ```
194
200
 
195
- // make sure
196
- setTimeout(() => input.focus();, 100);
201
+ </details>
197
202
 
198
- // event listenr for submit button
199
- form.addEventListener("submit", (event) => {
200
- event.preventDefault();
201
- const url = input.value;
203
+ Popover content can control the floating toolbar via `hideCurrentToolbar()` and `updateCurrentToolbarPosition(ref)`, both exported from the package.
202
204
 
203
- if (!url) return;
205
+ ## Styling
204
206
 
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);
207
+ The prebuilt popovers ship with a small, framework-agnostic stylesheet. Import it once:
214
208
 
215
- if (!sanitizeURL?.href) return;
209
+ ```ts
210
+ import '@docs.plus/extension-hyperlink/styles.css'
211
+ ```
216
212
 
217
- tooltip?.hide();
213
+ The CSS is opt-in — the extension's JavaScript never imports it, so custom UIs pay zero CSS cost.
218
214
 
219
- return options.editor
220
- .chain()
221
- .setMark(options.extentionName, { href: sanitizeURL.href })
222
- .setMeta("preventautohyperlink", true)
223
- .run();
224
- });
225
- }
226
- ```
215
+ ### Theming
227
216
 
228
- </details>
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.
229
218
 
230
219
  <details>
231
- <summary>The Hyperlink styles.scss</summary>
232
-
233
- ```scss
234
- .tippy-box {
235
- .hyperlink-preview-modal,
236
- .hyperlink-set-modal,
237
- .hyperlink-edit-modal {
238
- background-color: #fff;
239
- border-radius: 10px;
240
- border: 1px solid #dadce0;
241
- display: flex;
242
- align-items: center;
243
- justify-content: space-between;
244
- padding: 6px 6px;
245
- box-shadow: 0 1px 3px 1px rgba(60, 64, 67, 0.15);
246
- margin-top: -6px;
247
-
248
- &__metadata {
249
- width: 200px;
250
- display: flex;
251
- align-items: center;
252
- justify-content: flex-end;
253
- flex-direction: row-reverse;
254
- a {
255
- font-size: 0.9rem;
256
- margin-right: 6px;
257
- white-space: nowrap;
258
- overflow: hidden;
259
- text-overflow: ellipsis;
260
- }
261
- img {
262
- width: 18px;
263
- height: 18px;
264
- border-radius: 50%;
265
- margin-right: 8px;
266
- }
267
- }
268
-
269
- &__remove-button,
270
- &__edit-button,
271
- &__copy-button,
272
- &__apply-button {
273
- width: 30px;
274
- height: 30px;
275
- border-radius: 50%;
276
- margin: 0 0.25rem;
277
- display: flex;
278
- align-items: center;
279
- justify-content: center;
280
- transition: background-color 0.1s ease-in-out;
281
- &:hover {
282
- background-color: #eee;
283
- }
284
- > svg {
285
- width: 16px;
286
- height: 16px;
287
- }
288
- }
289
-
290
- form {
291
- display: flex;
292
- align-items: flex-end;
293
- width: 100%;
294
- input {
295
- border: 1px solid #dadce0;
296
- border-radius: 6px;
297
- padding: 0.4rem 0.8rem;
298
- margin-bottom: 0.2rem;
299
- width: 100%;
300
- &:last-of-type {
301
- margin-bottom: 0;
302
- }
303
- }
304
- .hyperlink-set-modal__buttons-wrapper,
305
- .hyperlink-edit-modal__buttons-wrapper {
306
- margin-left: 8px;
307
- button {
308
- border-radius: 6px;
309
- padding: 4px 14px;
310
- width: 70px;
311
- margin-bottom: 0.2rem;
312
- color: #1a73e8;
313
- &:hover {
314
- background: rgba(26, 115, 232, 0.04);
315
- color: #174ea6;
316
- }
317
- }
318
- }
319
- }
320
- }
321
-
322
- .tippy-svg-arrow {
323
- top: -6px !important;
324
- }
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;
325
241
  }
326
242
  ```
327
243
 
328
244
  </details>
329
245
 
330
- ````ts
331
- import {
332
- Hyperlink,
333
- previewHyperlinkModal,
334
- setHyperlinkModal
335
- } from "@docs.plus/extension-hyperlink";
336
-
337
- Hyperlink.configure({
338
- modals: {
339
- previewHyperlink: (data) => {
340
- return previewHyperlinkModal(data);
341
- },
342
- setHyperlink: (data) => {
343
- return setHyperlinkModal(data);
344
- },
345
- },
346
- })
347
- ````
348
-
349
- > 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)
350
-
351
- <Su>
352
-
353
- ### Removing and overriding existing html attributes
246
+ #### Switching themes
354
247
 
355
- 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"`.
248
+ If your app has a light/dark toggle, set `color-scheme` on the theme root — the popover follows along:
356
249
 
357
- This can also be used to change the `target` from the default value of `_blank`.
250
+ ```css
251
+ html[data-theme='light'] {
252
+ color-scheme: light;
253
+ }
254
+ html[data-theme='dark'] {
255
+ color-scheme: dark;
256
+ }
257
+ ```
358
258
 
359
- ````js
360
- Hyperlink.configure({
361
- HTMLAttributes: {
362
- // Change rel to different value
363
- // Allow search engines to follow links(remove nofollow)
364
- rel: 'noopener noreferrer',
365
- // Remove target entirely so links open in current tab
366
- target: null,
367
- },
368
- })
369
- ````
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
+ > ```
370
271
 
371
- ### validate
272
+ ### Class names
372
273
 
373
- 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.
274
+ Stable class names for consumers who want to write their own rules:
374
275
 
375
- Can be used to set rules for example excluding or including certain domains, tlds, etc.
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`). |
376
288
 
377
- ````js
378
- // only autolink urls with a protocol
379
- Hyperlink.configure({
380
- validate: href => /^https?:\/\//.test(href),
381
- })
382
- ````
289
+ ## URL handling
383
290
 
384
- ## Commands
291
+ Every write path canonicalizes hrefs before storing them:
385
292
 
386
- ### editHyperLinkText(), editHyperLinkHref(), editHyperlink()
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`.
387
296
 
388
- These commands allow you to edit the text and href value of a hyperlink.
297
+ The create popover, markdown input rule, autolink, and paste all produce the same `href` for the same input.
389
298
 
390
- ```js
391
- this.editor.commands.editHyperLinkText('New Text');
392
- this.editor.commands.editHyperLinkHref('https://new-url.com');
393
- this.editor.commands.editHyperlink({
394
- newText: 'New Text',
395
- newURL: 'https://new-url.com'
396
- });
397
- ```
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.
398
300
 
399
- ### setHyperlink()
301
+ Two helpers are exported for custom popovers:
400
302
 
401
- Links the selected text.
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)`). |
402
307
 
403
- ````js
404
- this.editor.commands.setHyperlink({
405
- href: '<https://example.com>'
406
- });
308
+ ## Security
407
309
 
408
- this.editor.commands.setHyperlink({
409
- href: '<https://example.com>',
410
- target: '_blank'
411
- });
310
+ Dangerous schemes (`javascript:`, `data:`, `vbscript:`) are blocked everywhere a link enters or leaves the editor:
412
311
 
413
- this.editor.commands.unsetHyperlink();
414
- ````
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.
415
315
 
416
- ### unsetHyperlink()
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.
417
317
 
418
- Removes a Hyperlink.
318
+ The shared `DANGEROUS_SCHEME_RE` regex is exported for custom popovers that need the same check.
419
319
 
420
- ````js
421
- this.editor.commands.unsetHyperlink();
422
- ````
423
-
424
- ## Keyboard shortcuts
320
+ ## TypeScript
425
321
 
426
- Doesn’t have a keyboard shortcut
427
- This extension doesn’t bind a specific keyboard shortcut. You would probably open your custom UI on `Mod-k` though.
322
+ Definitions are bundled. Public types: `HyperlinkOptions`, `HyperlinkAttributes`, `PreviewHyperlinkOptions`, `CreateHyperlinkOptions`, `EditHyperlinkModalOptions`, `LinkProtocolOptions`, `FloatingToolbarOptions`, `FloatingToolbarInstance`, `LinkifyMatchLike`.
428
323
 
429
- ## Get the current value
324
+ ## Testing
430
325
 
431
- 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:
326
+ Cypress runs against the built `dist/` output in a tiny `Bun.serve` playground — the same bytes an npm consumer installs.
432
327
 
433
- ```js
434
- 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/
435
333
  ```
436
334
 
437
- ## 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.
438
336
 
439
- - Demo:
440
- [extension-hyperlink](https://github.com/HMarzban/extension-hyperlink#test-drive-with-our-demo-)
441
- - Extension:
442
- [packages/extension-hyperlink](https://github.com/HMarzban/extension-hyperlink/tree/main/packages/extension-hyperlink)
443
- - Usage: [packages/nextjs/src/components/Tiptap.tsx](https://github.com/HMarzban/extension-hyperlink/blob/59f45eba1886202f4840eb2112c34574c16fe68a/packages/nextjs/src/components/Tiptap.tsx#L19-L29)
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. |
444
345
 
445
- ## Inspiration and Acknowledgment, Let's Connect
346
+ The suite also runs from the repo root via `bun run test:all`, alongside the Jest and webapp Cypress suites.
446
347
 
447
- Thank you for exploring our Hyperlink extension from docs.plus! We aim to make collaboration and knowledge sharing not just easy, but also enjoyable.
348
+ ## License
448
349
 
449
- 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.
450
- > Please note: We're not affiliated with Tiptap, but we believe in recognizing foundational work.
350
+ MIT
451
351
 
452
- 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.
352
+ ## Credits
453
353
 
454
- 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).