Skip to content

Commit 9c6e7e6

Browse files
authored
Merge pull request #8467 from nextcloud-libraries/backport/8397/stable8
2 parents ed7419b + ee3ba36 commit 9c6e7e6

6 files changed

Lines changed: 97 additions & 48 deletions

File tree

cypress/component/richtext.cy.ts

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
// Reference tests: https://github.com/nextcloud-deps/CDMarkdownKit/tree/master/CDMarkdownKitTests
88

99
import { mount } from 'cypress/vue2'
10+
import Vue from 'vue'
11+
import VueRouter from 'vue-router'
1012
import NcRichText from '../../src/components/NcRichText/NcRichText.vue'
1113

1214
describe('NcRichText', () => {
@@ -402,34 +404,56 @@ describe('NcRichText', () => {
402404
})
403405

404406
describe('links', () => {
407+
const TestRouteComponent = {
408+
template: '<div />',
409+
}
410+
411+
const mountRichText = (text: string) => {
412+
Vue.use(VueRouter)
413+
const routes = [{ path: '/world', component: TestRouteComponent }]
414+
const router = new VueRouter({
415+
mode: 'history',
416+
routes,
417+
})
418+
419+
mount(NcRichText, {
420+
propsData: {
421+
text,
422+
useMarkdown: true,
423+
},
424+
extensions: {
425+
plugins: [router],
426+
},
427+
router,
428+
})
429+
}
430+
405431
const testLink = (key: string, { text, href = text, name = text }) => {
406432
it(key, () => {
407-
mount(NcRichText, {
408-
propsData: {
409-
text,
410-
useMarkdown: true,
411-
},
412-
})
433+
mountRichText(text)
413434
cy.get('a').should('have.text', name)
414435
cy.get('a').invoke('attr', 'href').should('eq', href)
415436
})
416437
}
417438

418439
testLink('autolink', { text: 'https://autolink.me' })
419-
testLink('relative link', { text: '[hello](world)', href: 'world', name: 'hello' })
440+
testLink('relative link', { text: '[hello](/world)', href: '/world', name: 'hello' })
420441
testLink('absolute link', { text: '[hello](https://nextcloud.com)', href: 'https://nextcloud.com', name: 'hello' })
421442
testLink('tel link', { text: '[hello](tel:+49123456789)', href: 'tel:+49123456789', name: 'hello' })
443+
testLink('mailto link', { text: '[hello](mailto:+49123456789)', href: 'mailto:+49123456789', name: 'hello' })
422444

423-
it('no link to unknown protocols', () => {
424-
mount(NcRichText, {
425-
propsData: {
426-
text: '[link](other:proto)',
427-
useMarkdown: true,
428-
},
445+
const testNoLink = (key: string, { text, name = text }) => {
446+
it(key, () => {
447+
mountRichText(text)
448+
449+
cy.get('body').should('contain', name)
450+
cy.get('a').should('not.exist')
429451
})
430-
cy.get('body').should('contain', name)
431-
cy.get('a').should('not.exist')
432-
})
452+
}
453+
testNoLink('no link to unknown protocols', { text: '[hello](other:proto)', name: 'hello' })
454+
testNoLink('no link to unresolved relative link (by router)', { text: '[hello](world)', name: 'hello' })
455+
testNoLink('no link to relative parameters', { text: '[hello](?parameters=1)', name: 'hello' })
456+
testNoLink('no link to relative anchor', { text: '[hello](#anchor)', name: 'hello' })
433457
})
434458

435459
describe('multiline code', () => {

src/components/NcRichText/NcRichText.vue

Lines changed: 14 additions & 0 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 GenRandomId from '../../utils/GenRandomId.js'
324325
import { getRoute, remarkAutolink } from './autolink.js'
325326
import { prepareTextNode, remarkPlaceholder } from './placeholder.js'
@@ -556,6 +557,7 @@ export default {
556557
if (tag === 'a') {
557558
const route = getRoute(this.$router, attrs.attrs.href)
558559
if (route) {
560+
// Resolved link to this app; render RouterLink
559561
delete attrs.attrs.href
560562
delete attrs.attrs.target
561563
@@ -566,6 +568,18 @@ export default {
566568
},
567569
}, children)
568570
}
571+
572+
const isAllowedScheme = /^(https?:\/\/|tel:|mailto:)/.test(attrs.attrs.href)
573+
if (isAllowedScheme) {
574+
// External link; render normally, open in the new tab
575+
attrs.attrs.href = attrs.attrs.href.trim()
576+
return h(NcRichTextExternalLink, attrs, children)
577+
} else {
578+
// Unresolved relative link that does not belong to this app; render only children
579+
delete attrs.attrs.href
580+
delete attrs.attrs.target
581+
return h('span', attrs, children)
582+
}
569583
}
570584
571585
return h(tag, attrs, children)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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">{{ href }}</slot>
24+
</a>
25+
</template>
26+
27+
<style module lang="scss">
28+
.externalLink {
29+
text-decoration: underline;
30+
}
31+
32+
.externalLink_decorated::after {
33+
content: '';
34+
}
35+
</style>
36+
37+
<docs>
38+
An internal component
39+
</docs>

src/components/NcRichText/autolink.js

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,10 @@
66
import { getBaseUrl, getRootUrl } from '@nextcloud/router'
77
import { u } from 'unist-builder'
88
import { SKIP, visitParents } from 'unist-util-visit-parents'
9+
import NcRichTextExternalLink from './NcRichTextExternalLink.vue'
910
import { logger } from '../../utils/logger.ts'
1011
import { URL_PATTERN_AUTOLINK } from './helpers.js'
1112

12-
const NcLink = {
13-
name: 'NcLink',
14-
props: {
15-
href: {
16-
type: String,
17-
required: true,
18-
},
19-
},
20-
render(h) {
21-
return h('a', {
22-
attrs: {
23-
href: this.href,
24-
rel: 'noopener noreferrer',
25-
target: '_blank',
26-
class: 'rich-text--external-link',
27-
},
28-
}, [this.href.trim()])
29-
},
30-
}
31-
3213
/**
3314
* Remark plugin for autolink parsing
3415
*
@@ -95,7 +76,7 @@ export function parseUrl(text) {
9576
textAfter = lastChar
9677
}
9778
list.push(textBefore)
98-
list.push({ component: NcLink, props: { href } })
79+
list.push({ component: NcRichTextExternalLink, props: { href: href.trim(), decorateExternal: true } })
9980
if (textAfter) {
10081
list.push(textAfter)
10182
}

src/components/NcRichText/placeholder.js

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export function remarkPlaceholder() {
1717
/**
1818
*
1919
* @param {object} node The node
20-
* @param {array} ancestors The parent nodes
20+
* @param {Array} ancestors The parent nodes
2121
*/
2222
function visitor(node, ancestors) {
2323
const parent = ancestors.at(-1)
@@ -59,11 +59,9 @@ export function prepareTextNode({ h, context }, text) {
5959
return entry
6060
}
6161
const { component, props } = entry
62-
// do not override class of NcLink
63-
const componentClass = component.name === 'NcLink' ? undefined : 'rich-text--component'
6462
return h(component, {
6563
props,
66-
class: componentClass,
64+
class: 'rich-text--component',
6765
})
6866
})
6967
}

src/components/NcRichText/richtext.scss

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,6 @@
1414
.rich-text--fallback, .rich-text-component {
1515
display: inline;
1616
}
17-
18-
.rich-text--external-link {
19-
text-decoration: underline;
20-
&:after {
21-
content: '';
22-
}
23-
}
2417
}
2518

2619
/* Markdown styles */

0 commit comments

Comments
 (0)