Skip to content

Commit 57133c8

Browse files
committed
feat(extension-hyperlink): add publish guards and v4 migration docs
Wire the prepublishOnly + prepack lifecycle hooks for the @docs.plus/extension-hyperlink@4.3.0 npm publish: - scripts/preflight.ts (prepublishOnly) asserts the publisher is Bun (peerDeps use catalog: which only Bun resolves), all dist artifacts are present, and no literal "catalog:" string leaked into bundles. - scripts/prepack.ts (prepack) copies the canonical root LICENSE into the package directory before pack. Symlinks fail (bun pack drops them) and hard links fail (git stores as drifting copies); copy-on-pack is the only reliable option. - publishConfig.access: "public" so scoped publishes don't 402. - files array picks up CHANGELOG.md and LICENSE for the tarball. README.md gains a v4 migration banner; CHANGELOG.md gains a "Migrating from v1.x to v4.x" section so consumers landing on the npm page understand the breaking surface. Made-with: Cursor
1 parent 651c5f3 commit 57133c8

6 files changed

Lines changed: 261 additions & 2 deletions

File tree

packages/extension-hyperlink/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,8 @@ dist
77
cypress/screenshots
88
cypress/videos
99
cypress/downloads
10+
11+
# Generated by scripts/prepack.ts before each `bun publish` / `bun pm pack`.
12+
# Single source of truth lives at the monorepo root.
13+
# Anchored to package root so a vendored dep's nested LICENSE isn't silently ignored.
14+
/LICENSE

packages/extension-hyperlink/CHANGELOG.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,125 @@
55
All notable changes to `@docs.plus/extension-hyperlink` are documented here.
66
This project follows [Semantic Versioning](https://semver.org/) and [Conventional Commits](https://conventionalcommits.org). Section headings (`Added` / `Changed` / `Fixed` / `Security`) intentionally repeat per version, per the [Keep a Changelog](https://keepachangelog.com/) convention.
77

8+
---
9+
10+
## Migrating from v1.x to v4.x
11+
12+
The last npm-published v1 release was `1.5.2`. Versions `2.x` and `3.x` were internal monorepo refactors and were never published. If you are upgrading from `^1.5.2` directly to `4.x`, the API has been substantially redesigned. This section is the short, actionable diff; the full per-version detail lives under [4.0.0](#400--2026-04-15) (renames + popover contract), [4.1.0](#410--2026-04-15) (XSS hardening + bug fixes), [4.2.0](#420--2026-04-15) (stylesheet + clean-room test harness), and [4.3.0](#430--2026-04-16) (unified href canonicalization).
13+
14+
### What changed at a glance
15+
16+
- **Options renamed** to align with Tiptap conventions
17+
(`autoHyperlink``autolink`, `hyperlinkOnPaste``linkOnPaste`).
18+
- **Commands renamed** with consistent casing
19+
(`editHyperLinkText``editHyperlinkText`, `editHyperLinkHref``editHyperlinkHref`).
20+
- **CSS classes renamed** from camelCase to kebab-case
21+
(`.hyperlinkCreatePopover``.hyperlink-create-popover`, …).
22+
- **Default stylesheet ships separately**`import '@docs.plus/extension-hyperlink/styles.css'`.
23+
v1 inlined CSS via JS; v4 ships a small opt-in CSS file so fully-custom UIs pay zero cost.
24+
- **Popover contract changed**`createHyperlink` callback now returns
25+
`HTMLElement | null` (was `void`); the extension owns positioning, you own content.
26+
- **Meta key renamed**`tr.setMeta('preventAutoHyperlink', …)``tr.setMeta('preventAutolink', …)`.
27+
- **Type augmentation fixed** — commands are augmented under `hyperlink:` (was `link:`).
28+
- **Hardened XSS guards**`javascript:`, `data:`, and `vbscript:` are now blocked
29+
at every entry point (parse, paste, input rule, click, popover open). If you
30+
intentionally stored such URLs, they will now be rejected.
31+
- **Plausible-host validation**`validateURL` now rejects typo URLs like
32+
`https://googlecom` (no TLD dot, not localhost, not an IP literal).
33+
34+
### Code diff
35+
36+
```diff
37+
Hyperlink.configure({
38+
- autoHyperlink: true,
39+
- hyperlinkOnPaste: true,
40+
+ autolink: true,
41+
+ linkOnPaste: true,
42+
popovers: {
43+
previewHyperlink: myPreviewFn,
44+
- createHyperlink: myCreateFn, // was (options) => void
45+
+ createHyperlink: myCreateFn, // now (options) => HTMLElement | null
46+
}
47+
})
48+
49+
// Commands
50+
-editor.commands.editHyperLinkText('New Text')
51+
-editor.commands.editHyperLinkHref('https://example.com')
52+
+editor.commands.editHyperlinkText('New Text')
53+
+editor.commands.editHyperlinkHref('https://example.com')
54+
55+
// Meta key in transactions
56+
-tr.setMeta('preventAutoHyperlink', true)
57+
+tr.setMeta('preventAutolink', true)
58+
```
59+
60+
```css
61+
/* CSS selectors */
62+
- .hyperlinkCreatePopover { … }
63+
- .hyperlinkPreviewPopover { … }
64+
- .hyperlinkEditPopover { … }
65+
- .buttonsWrapper { … }
66+
- .inputsWrapper { … }
67+
- .textWrapper { … }
68+
- .hrefWrapper { … }
69+
- .backButton { … }
70+
- .btn_applyModal { … }
71+
+ .hyperlink-create-popover { … }
72+
+ .hyperlink-preview-popover { … }
73+
+ .hyperlink-edit-popover { … }
74+
+ .buttons-wrapper { … }
75+
+ .inputs-wrapper { … }
76+
+ .text-wrapper { … }
77+
+ .href-wrapper { … }
78+
+ .back-button { … }
79+
+ .apply-button { … }
80+
```
81+
82+
```ts
83+
// Popover types — no more hand-rolled types
84+
import type {
85+
PreviewHyperlinkOptions,
86+
CreateHyperlinkOptions
87+
} from '@docs.plus/extension-hyperlink'
88+
```
89+
90+
### One-shot rename script
91+
92+
For the rename-only changes, run this in your project root and commit the diff:
93+
94+
```bash
95+
rg -l "autoHyperlink|hyperlinkOnPaste|editHyperLinkText|editHyperLinkHref|preventAutoHyperlink|hyperlinkCreatePopover|hyperlinkPreviewPopover|hyperlinkEditPopover|buttonsWrapper|inputsWrapper|textWrapper|hrefWrapper|backButton|btn_applyModal" \
96+
| xargs sed -i.bak \
97+
-e 's/autoHyperlink/autolink/g' \
98+
-e 's/hyperlinkOnPaste/linkOnPaste/g' \
99+
-e 's/editHyperLinkText/editHyperlinkText/g' \
100+
-e 's/editHyperLinkHref/editHyperlinkHref/g' \
101+
-e 's/preventAutoHyperlink/preventAutolink/g' \
102+
-e 's/hyperlinkCreatePopover/hyperlink-create-popover/g' \
103+
-e 's/hyperlinkPreviewPopover/hyperlink-preview-popover/g' \
104+
-e 's/hyperlinkEditPopover/hyperlink-edit-popover/g' \
105+
-e 's/buttonsWrapper/buttons-wrapper/g' \
106+
-e 's/inputsWrapper/inputs-wrapper/g' \
107+
-e 's/textWrapper/text-wrapper/g' \
108+
-e 's/hrefWrapper/href-wrapper/g' \
109+
-e 's/backButton/back-button/g' \
110+
-e 's/btn_applyModal/apply-button/g'
111+
```
112+
113+
The rename script handles the mechanical changes. The semantic changes that
114+
require code review — popover contract returning `HTMLElement | null`, the
115+
new `styles.css` import, and the stricter URL validation — are not safely
116+
automatable. Read the [4.0.0](#400--2026-04-15) and [4.1.0](#410--2026-04-15)
117+
sections to confirm those before shipping the upgrade.
118+
119+
### Need help?
120+
121+
Open an issue at <https://github.com/docs-plus/docs.plus/issues> with the
122+
label `extension-hyperlink` + `migration` and a snippet of the v1 config
123+
you're upgrading from.
124+
125+
---
126+
8127
## [4.3.0] — 2026-04-16
9128

10129
### Added

packages/extension-hyperlink/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@
44
[![Downloads](https://img.shields.io/npm/dm/@docs.plus/extension-hyperlink.svg)](https://npmcharts.com/compare/@docs.plus/extension-hyperlink)
55
[![License](https://img.shields.io/npm/l/@docs.plus/extension-hyperlink.svg)](https://www.npmjs.com/package/@docs.plus/extension-hyperlink)
66

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.
13+
714
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:`, …).
815

916
<p align="center">

packages/extension-hyperlink/package.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,18 @@
4646
"test:open": "bunx start-server-and-test playground http://127.0.0.1:5173 cy:open",
4747
"cy:run": "bunx cypress run",
4848
"cy:open": "bunx cypress open",
49-
"docs:screenshots": "bun run build && bunx start-server-and-test playground http://127.0.0.1:5173 'bunx cypress run --config specPattern=cypress/docs/**/*.cy.ts'"
49+
"docs:screenshots": "bun run build && bunx start-server-and-test playground http://127.0.0.1:5173 'bunx cypress run --config specPattern=cypress/docs/**/*.cy.ts'",
50+
"prepack": "bun run scripts/prepack.ts",
51+
"prepublishOnly": "bun run scripts/preflight.ts"
5052
},
5153
"files": [
52-
"dist"
54+
"dist",
55+
"CHANGELOG.md",
56+
"LICENSE"
5357
],
58+
"publishConfig": {
59+
"access": "public"
60+
},
5461
"keywords": [
5562
"tiptap",
5663
"tiptap extension",
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Pre-publish guard for @docs.plus/extension-hyperlink.
4+
*
5+
* peerDependencies in package.json use the `catalog:` protocol, which is a
6+
* Bun / pnpm workspace feature. Only `bun publish` (and `pnpm publish`) resolve
7+
* `catalog:` to a concrete semver range at pack time. `npm publish` would
8+
* ship a literal "catalog:" string in the published package.json, breaking
9+
* every consumer install with `Invalid Version: catalog:`.
10+
*
11+
* This script runs from the `prepublishOnly` lifecycle hook (which Bun, npm,
12+
* yarn, and pnpm all honor) and fails fast if anything other than Bun is
13+
* driving the publish. It also asserts that the freshly built artifacts the
14+
* publish will ship actually exist.
15+
*/
16+
17+
import { existsSync, readFileSync } from 'node:fs'
18+
import { join } from 'node:path'
19+
20+
const RED = '\x1b[31m'
21+
const GREEN = '\x1b[32m'
22+
const YELLOW = '\x1b[33m'
23+
const RESET = '\x1b[0m'
24+
25+
const fail = (msg: string): never => {
26+
console.error(`\n${RED}✗ preflight failed${RESET}${msg}\n`)
27+
process.exit(1)
28+
}
29+
30+
const ok = (msg: string) => {
31+
console.error(`${GREEN}${RESET} ${msg}`)
32+
}
33+
34+
// ---------------------------------------------------------------------------
35+
// 1. Publisher check — must be Bun
36+
// ---------------------------------------------------------------------------
37+
const userAgent = process.env.npm_config_user_agent ?? ''
38+
if (!userAgent.startsWith('bun/')) {
39+
fail(
40+
[
41+
`peerDependencies use the \`catalog:\` protocol, which only Bun resolves`,
42+
`at pack time. Detected publisher: ${YELLOW}${userAgent || '(unknown)'}${RESET}`,
43+
``,
44+
` Run: ${GREEN}bun publish${RESET}`,
45+
` Not: ${RED}npm publish / yarn publish / pnpm publish${RESET}`,
46+
``,
47+
`If you really need to publish from npm, substitute \`catalog:\` with the`,
48+
`concrete semver range from the root package.json's \`catalog\` field first.`
49+
].join('\n ')
50+
)
51+
}
52+
ok(`publisher is Bun (${userAgent.split(' ')[0]})`)
53+
54+
// ---------------------------------------------------------------------------
55+
// 2. Build artifact check — files the `exports` map points at must exist
56+
// ---------------------------------------------------------------------------
57+
const distFiles = [
58+
'dist/index.js',
59+
'dist/index.cjs',
60+
'dist/index.d.ts',
61+
'dist/index.d.cts',
62+
'dist/styles.css'
63+
]
64+
65+
for (const f of distFiles) {
66+
if (!existsSync(join(process.cwd(), f))) {
67+
fail(`missing build artifact: ${f}\n\n Run \`bun run build\` first.`)
68+
}
69+
}
70+
ok(`all ${distFiles.length} dist artifacts present`)
71+
72+
// ---------------------------------------------------------------------------
73+
// 3. Defense in depth — no literal `catalog:` survived into dist
74+
// ---------------------------------------------------------------------------
75+
// tsup bundles dependencies' versions into the dist; a `catalog:` leak here
76+
// would mean a peerDep slipped past Bun's resolver. Cheap belt-and-suspenders.
77+
for (const f of ['dist/index.js', 'dist/index.cjs']) {
78+
const content = readFileSync(join(process.cwd(), f), 'utf8')
79+
if (content.includes('"catalog:"') || content.includes("'catalog:'")) {
80+
fail(`literal "catalog:" string found in ${f} — bundle resolution failed`)
81+
}
82+
}
83+
ok('no `catalog:` leakage in built bundles')
84+
85+
console.error(`\n${GREEN}preflight passed${RESET} — safe to publish\n`)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* `prepack` lifecycle hook — runs before `bun publish` AND `bun pm pack`.
4+
*
5+
* Syncs the canonical root `LICENSE` into the package directory so it is
6+
* included in the published tarball. The per-package `LICENSE` is
7+
* `.gitignore`d so the source tree has a single committed copy at the
8+
* monorepo root, while every published npm tarball still ships its own
9+
* `LICENSE` (npm convention; consumed by SPDX scanners and license audit
10+
* tools at e.g. `node_modules/<pkg>/LICENSE`).
11+
*
12+
* Symlinks and hard links don't work for this:
13+
* - Symlinks: bun pack silently drops them from the tarball.
14+
* - Hard links: work locally for pack, but git stores them as two
15+
* independent files, so contributors who clone the repo would get
16+
* two copies that can drift silently.
17+
*
18+
* `prepack` runs in both pack flows; `prepublishOnly` runs only on
19+
* publish. Keeping the LICENSE sync here (not in preflight) means
20+
* `bun pm pack --dry-run` (used for verification) also produces a
21+
* realistic tarball with LICENSE included.
22+
*/
23+
24+
import { copyFileSync, existsSync } from 'node:fs'
25+
import { join } from 'node:path'
26+
27+
const ROOT_LICENSE = join(import.meta.dirname, '..', '..', '..', 'LICENSE')
28+
const PKG_LICENSE = join(import.meta.dirname, '..', 'LICENSE')
29+
30+
if (!existsSync(ROOT_LICENSE)) {
31+
console.error(`\n\x1b[31m✗ prepack failed\x1b[0m — root LICENSE not found at ${ROOT_LICENSE}\n`)
32+
process.exit(1)
33+
}
34+
35+
copyFileSync(ROOT_LICENSE, PKG_LICENSE)
36+
console.error(`\x1b[32m✓\x1b[0m prepack: synced LICENSE from monorepo root`)

0 commit comments

Comments
 (0)