Skip to content

Commit 0aadabf

Browse files
authored
Merge pull request #8397 from nextcloud-libraries/fix/noid/external-links
2 parents 2e3ba39 + a5ae090 commit 0aadabf

4 files changed

Lines changed: 85 additions & 40 deletions

File tree

src/components/NcRichText/NcRichText.vue

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,7 @@ import { RouterLink } from 'vue-router'
320320
import NcCheckboxRadioSwitch from '../NcCheckboxRadioSwitch/NcCheckboxRadioSwitch.vue'
321321
import NcReferenceList from './NcReferenceList.vue'
322322
import NcRichTextCopyButton from './NcRichTextCopyButton.vue'
323+
import NcRichTextExternalLink from './NcRichTextExternalLink.vue'
323324
import { createElementId } from '../../utils/createElementId.ts'
324325
import { getRoute, parseUrl, remarkAutolink } from './autolink.ts'
325326
import { remarkPlaceholder } from './remarkPlaceholder.ts'
@@ -532,11 +533,9 @@ export default {
532533
return entry
533534
}
534535
const { component, props } = entry
535-
// do not override class of NcLink
536-
const componentClass = component.name === 'NcLink' ? undefined : 'rich-text--component'
537536
return h(component, {
538537
...props,
539-
class: componentClass,
538+
class: 'rich-text--component',
540539
})
541540
})
542541
}
@@ -632,6 +631,7 @@ export default {
632631
if (String(type) === 'a') {
633632
const route = getRoute(this.$router, props.href)
634633
if (route) {
634+
// Resolved link to this app; render RouterLink
635635
delete props.href
636636
delete props.target
637637
@@ -640,6 +640,18 @@ export default {
640640
to: route,
641641
}, { default: () => children })
642642
}
643+
644+
const isAllowedScheme = /^(https?:\/\/|tel:|mailto:)/.test(props.href)
645+
if (isAllowedScheme) {
646+
// External link; render normally, open in the new tab
647+
props.href = props.href.trim()
648+
return h(NcRichTextExternalLink, props, children)
649+
} else {
650+
// Unresolved relative link that does not belong to this app; render only children
651+
delete props.href
652+
delete props.target
653+
return h('span', props, children)
654+
}
643655
}
644656
return h(type, props, children)
645657
}
@@ -684,13 +696,6 @@ export default {
684696
.rich-text--fallback, .rich-text-component {
685697
display: inline;
686698
}
687-
688-
.rich-text--external-link {
689-
text-decoration: underline;
690-
&:after {
691-
content: '';
692-
}
693-
}
694699
}
695700
696701
/* Markdown styles */
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
<script setup lang="ts">
7+
const { href, decorateExternal = false } = defineProps<{
8+
/** Link attribute to render */
9+
href: string
10+
/** Whether to add the appended arrow decoration */
11+
decorateExternal?: boolean
12+
}>()
13+
</script>
14+
15+
<template>
16+
<a
17+
:href="href"
18+
rel="noopener noreferrer"
19+
target="_blank"
20+
:class="[$style.externalLink, {
21+
[$style.externalLink_decorated]: decorateExternal,
22+
}]">
23+
<slot name="default">
24+
{{ href }}
25+
</slot>
26+
</a>
27+
</template>
28+
29+
<style module lang="scss">
30+
.externalLink {
31+
text-decoration: underline;
32+
}
33+
34+
.externalLink_decorated::after {
35+
content: '';
36+
}
37+
</style>
38+
39+
<docs>
40+
An internal component
41+
</docs>

src/components/NcRichText/autolink.ts

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,10 @@ import type { Router } from 'vue-router'
99
import { getBaseUrl, getRootUrl } from '@nextcloud/router'
1010
import { u } from 'unist-builder'
1111
import { SKIP, visitParents } from 'unist-util-visit-parents'
12-
import { defineComponent, h } from 'vue'
12+
import NcRichTextExternalLink from './NcRichTextExternalLink.vue'
1313
import { logger } from '../../utils/logger.ts'
1414
import { URL_PATTERN_AUTOLINK } from './helpers.js'
1515

16-
const NcLink = defineComponent({
17-
name: 'NcLink',
18-
props: {
19-
href: {
20-
type: String,
21-
required: true,
22-
},
23-
},
24-
render() {
25-
return h('a', {
26-
href: this.href,
27-
rel: 'noopener noreferrer',
28-
target: '_blank',
29-
class: 'rich-text--external-link',
30-
}, [this.href.trim()])
31-
},
32-
})
33-
3416
/**
3517
*
3618
* @param root0
@@ -99,7 +81,7 @@ export function parseUrl(text: string) {
9981
textAfter = lastChar
10082
}
10183
list.push(textBefore)
102-
list.push({ component: NcLink, props: { href } })
84+
list.push({ component: NcRichTextExternalLink, props: { href: href.trim(), decorateExternal: true } })
10385
if (textAfter) {
10486
list.push(textAfter)
10587
}

tests/component/components/NcRichText/markown-rendering.spec.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -420,33 +420,50 @@ test.describe('inline code', () => {
420420
})
421421

422422
test.describe('links', () => {
423+
const TestRouteComponent = {
424+
template: '<div />',
425+
}
426+
423427
const testLink = (key: string, { text, href = text, name = text }) => {
424428
test(key, async ({ mount }) => {
425429
const component = await mount(NcRichText, {
426430
props: {
427431
text,
428432
useExtendedMarkdown: true,
429433
},
434+
hooksConfig: {
435+
routes: [{ path: '/world', component: TestRouteComponent }],
436+
},
430437
})
431438
await expect(component.getByRole('link', { name }))
432439
.toHaveAttribute('href', href)
433440
})
434441
}
435442
testLink('autolink', { text: 'https://autolink.me' })
436-
testLink('relative link', { text: '[hello](world)', href: 'world', name: 'hello' })
443+
testLink('relative link', { text: '[hello](/world)', href: '/world', name: 'hello' })
437444
testLink('absolute link', { text: '[hello](https://nextcloud.com)', href: 'https://nextcloud.com', name: 'hello' })
438445
testLink('tel link', { text: '[hello](tel:+49123456789)', href: 'tel:+49123456789', name: 'hello' })
446+
testLink('mailto link', { text: '[hello](mailto:+49123456789)', href: 'mailto:+49123456789', name: 'hello' })
439447

440-
test('no link to unknown protocols', async ({ mount }) => {
441-
const component = await mount(NcRichText, {
442-
props: {
443-
text: '[link](other:proto)',
444-
useExtendedMarkdown: true,
445-
},
448+
const testNoLink = (key: string, { text, name = text }) => {
449+
test(key, async ({ mount }) => {
450+
const component = await mount(NcRichText, {
451+
props: {
452+
text,
453+
useExtendedMarkdown: true,
454+
},
455+
hooksConfig: {
456+
routes: [{ path: '/world', component: TestRouteComponent }],
457+
},
458+
})
459+
await expect(component).toContainText(name)
460+
await expect(component.getByText(name)).not.toHaveRole('link')
446461
})
447-
await expect(component).toContainText('link')
448-
await expect(component.getByText('link')).not.toHaveRole('link')
449-
})
462+
}
463+
testNoLink('no link to unknown protocols', { text: '[hello](other:proto)', name: 'hello' })
464+
testNoLink('no link to unresolved relative link (by router)', { text: '[hello](world)', name: 'hello' })
465+
testNoLink('no link to relative parameters', { text: '[hello](?parameters=1)', name: 'hello' })
466+
testNoLink('no link to relative anchor', { text: '[hello](#anchor)', name: 'hello' })
450467
})
451468

452469
test.describe('multiline code', () => {

0 commit comments

Comments
 (0)