Skip to content

Commit aa11b91

Browse files
committed
fix(editor): canonicalize pasted profile urls
1 parent 59bcbba commit aa11b91

4 files changed

Lines changed: 134 additions & 6 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import {describe, expect, it, vi} from 'vitest'
2+
import {resolveHypermediaUrl} from './hm-resolver'
3+
4+
const PROFILE_URL = 'https://hyper.media/hm/z6MkwRWdU6HY11Qzi9SQZrwSx3rxFw94ipYyiN7Dtbrcg2yU/:profile'
5+
6+
describe('resolveHypermediaUrl', () => {
7+
it('resolves public gateway profile URLs locally', async () => {
8+
const fetchMock = vi.fn()
9+
vi.stubGlobal('fetch', fetchMock)
10+
11+
const resolved = await resolveHypermediaUrl(PROFILE_URL)
12+
13+
expect(fetchMock).not.toHaveBeenCalled()
14+
expect(resolved?.id).toBe('hm://z6MkwRWdU6HY11Qzi9SQZrwSx3rxFw94ipYyiN7Dtbrcg2yU/:profile')
15+
expect(resolved?.hmId).toMatchObject({
16+
uid: 'z6MkwRWdU6HY11Qzi9SQZrwSx3rxFw94ipYyiN7Dtbrcg2yU',
17+
path: [':profile'],
18+
hostname: 'hyper.media',
19+
latest: true,
20+
})
21+
})
22+
23+
it('resolves public gateway document URLs locally', async () => {
24+
const fetchMock = vi.fn()
25+
vi.stubGlobal('fetch', fetchMock)
26+
27+
const resolved = await resolveHypermediaUrl(
28+
'https://hyper.media/hm/z6MkwRWdU6HY11Qzi9SQZrwSx3rxFw94ipYyiN7Dtbrcg2yU/docs/example?v=bafyDoc#block1',
29+
)
30+
31+
expect(fetchMock).not.toHaveBeenCalled()
32+
expect(resolved?.id).toBe('hm://z6MkwRWdU6HY11Qzi9SQZrwSx3rxFw94ipYyiN7Dtbrcg2yU/docs/example')
33+
expect(resolved?.hmId).toMatchObject({
34+
uid: 'z6MkwRWdU6HY11Qzi9SQZrwSx3rxFw94ipYyiN7Dtbrcg2yU',
35+
path: ['docs', 'example'],
36+
version: 'bafyDoc',
37+
blockRef: 'block1',
38+
latest: false,
39+
})
40+
})
41+
})

frontend/packages/client/src/hm-resolver.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,14 @@ export async function resolveHypermediaUrl(url: string, opts?: ResolveOptions):
4040
let blockRange: {start: number; end: number} | {expanded: boolean} | null = null
4141
let panel: string | null = null
4242
let parsedHostname: string | null = null
43+
let directHmId: UnpackedHypermediaId | null = null
4344
try {
4445
const parsedUrl = new URL(url)
4546
parsedHostname = parsedUrl.hostname
4647
const hasVersion = parsedUrl.searchParams.has('v')
4748
const hasLatest = parsedUrl.searchParams.has('l')
4849
panel = parsedUrl.searchParams.get('panel')
50+
directHmId = parsedUrl.pathname.startsWith('/hm/') ? unpackHmId(url) : null
4951

5052
// Extract blockRef and blockRange from fragment first
5153
if (parsedUrl.hash) {
@@ -67,6 +69,23 @@ export async function resolveHypermediaUrl(url: string, opts?: ResolveOptions):
6769
// If URL parsing fails, continue with defaults
6870
}
6971

72+
// Public gateway URLs already contain the canonical hm id in the path.
73+
// Resolve them locally so paste/autolink keeps working even when the
74+
// gateway does not expose x-hypermedia-* headers for special views such as
75+
// /:profile.
76+
if (directHmId) {
77+
return {
78+
id: directHmId.id,
79+
hmId: directHmId,
80+
version: directHmId.version,
81+
title: null,
82+
target: null,
83+
authors: null,
84+
type: null,
85+
panel,
86+
}
87+
}
88+
7089
// Try domain resolver first (fast, cached, works offline).
7190
if (opts?.domainResolver && parsedHostname) {
7291
try {

frontend/packages/editor/src/tiptap-extension-link/helpers/pasteHandler.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {describe, expect, it} from 'vitest'
2-
import {restoreBlockRangeSuffix} from './pasteHandler'
2+
import {getPastedHypermediaId, restoreBlockRangeSuffix} from './pasteHandler'
33

44
describe('restoreBlockRangeSuffix', () => {
55
it('reattaches a [start:end] block range linkifyjs truncates', () => {
@@ -43,3 +43,23 @@ describe('restoreBlockRangeSuffix', () => {
4343
expect(restoreBlockRangeSuffix(href, full)).toBe('https://site.example/doc#blockId[20:52]')
4444
})
4545
})
46+
47+
describe('getPastedHypermediaId', () => {
48+
const gwUrl = {get: () => 'http://localhost:56001'} as any
49+
50+
it('parses public gateway profile URLs independent of the configured gateway', () => {
51+
const id = getPastedHypermediaId('https://hyper.media/hm/z6Mktest/:profile', gwUrl)
52+
expect(id?.uid).toBe('z6Mktest')
53+
expect(id?.path).toEqual([':profile'])
54+
})
55+
56+
it('parses configured gateway profile URLs', () => {
57+
const id = getPastedHypermediaId('http://localhost:56001/hm/z6Mktest/:profile', gwUrl)
58+
expect(id?.uid).toBe('z6Mktest')
59+
expect(id?.path).toEqual([':profile'])
60+
})
61+
62+
it('returns null for regular web URLs', () => {
63+
expect(getPastedHypermediaId('https://example.com/hm/z6Mktest/:profile', gwUrl)).toBeNull()
64+
})
65+
})

frontend/packages/editor/src/tiptap-extension-link/helpers/pasteHandler.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,7 @@ export function pasteHandler(options: PasteHandlerOptions): Plugin {
186186
}
187187
: null
188188

189-
const unpackedHmId =
190-
isHypermediaScheme(textContent) || isPublicGatewayLink(textContent, options.gwUrl)
191-
? unpackHmId(textContent)
192-
: null
189+
const unpackedHmId = getPastedHypermediaId(textContent, options.gwUrl)
193190

194191
if (!selection.empty && options.linkOnPaste) {
195192
const pastedLink = unpackedHmId
@@ -231,7 +228,13 @@ export function pasteHandler(options: PasteHandlerOptions): Plugin {
231228

232229
// If transformPasted already added a link mark, skip further processing
233230
// unless we have a link that might need special handling (twitter, instagram, video, etc)
234-
if (firstChildIsText && firstChildContainsLinkMark && !(link && selection.empty && !unpackedHmId)) {
231+
// or a hypermedia URL that should be canonicalized into an hm:// link.
232+
if (
233+
firstChildIsText &&
234+
firstChildContainsLinkMark &&
235+
!(link && selection.empty && !unpackedHmId) &&
236+
!(selection.empty && unpackedHmId)
237+
) {
235238
return false
236239
}
237240

@@ -329,6 +332,31 @@ export function pasteHandler(options: PasteHandlerOptions): Plugin {
329332

330333
return true
331334
}
335+
336+
const normalizedHmUrl = packHmId(hmId(unpackedHmId.uid, unpackedHmId))
337+
view.dispatch(
338+
tr.insertText(normalizedHmUrl, pos).addMark(
339+
pos,
340+
pos + normalizedHmUrl.length,
341+
options.editor.schema.mark('link', {
342+
href: normalizedHmUrl,
343+
}),
344+
),
345+
)
346+
347+
view.dispatch(
348+
view.state.tr.scrollIntoView().setMeta(linkMenuPluginKey, {
349+
activate: true,
350+
ref: normalizedHmUrl,
351+
items: getLinkMenuItems({
352+
isLoading: false,
353+
sourceUrl: normalizedHmUrl,
354+
hmId: unpackHmId(normalizedHmUrl),
355+
gwUrl: options.gwUrl,
356+
}),
357+
}),
358+
)
359+
return true
332360
}
333361

334362
// Check if the link is hm link or web URL
@@ -673,6 +701,26 @@ export function restoreBlockRangeSuffix(href: string, fullText: string): string
673701
return match ? href + match[0] : href
674702
}
675703

704+
export function getPastedHypermediaId(text: string, gwUrl: StateStream<string>): UnpackedHypermediaId | null {
705+
const trimmed = text.trim()
706+
if (!trimmed) return null
707+
708+
if (isHypermediaScheme(trimmed) || isPublicGatewayLink(trimmed, gwUrl) || isPublicHypermediaLink(trimmed)) {
709+
return unpackHmId(trimmed)
710+
}
711+
712+
return null
713+
}
714+
715+
function isPublicHypermediaLink(text: string) {
716+
try {
717+
const url = new URL(text)
718+
return (url.protocol === 'https:' || url.protocol === 'http:') && url.hostname === 'hyper.media'
719+
} catch {
720+
return false
721+
}
722+
}
723+
676724
async function fetchEntityTitle(hmId: UnpackedHypermediaId, grpcClient: GRPCClient, blockRef?: string | null) {
677725
const document = await grpcClient.documents.getDocument({
678726
account: hmId.uid,

0 commit comments

Comments
 (0)