From 8a4278b702251be749d772b5059a8987347f18b6 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Tue, 7 Apr 2026 11:47:27 +0200 Subject: [PATCH 1/2] fix: extract NcRichTextExternalLink component - add noExtDecoration prop for reusing the component Signed-off-by: Maksim Sukharev --- .../NcRichText/NcRichTextExternalLink.vue | 41 +++++++++++++++++++ src/components/NcRichText/autolink.js | 23 +---------- src/components/NcRichText/placeholder.js | 4 +- src/components/NcRichText/richtext.scss | 7 ---- 4 files changed, 44 insertions(+), 31 deletions(-) create mode 100644 src/components/NcRichText/NcRichTextExternalLink.vue diff --git a/src/components/NcRichText/NcRichTextExternalLink.vue b/src/components/NcRichText/NcRichTextExternalLink.vue new file mode 100644 index 0000000000..ae48831b4a --- /dev/null +++ b/src/components/NcRichText/NcRichTextExternalLink.vue @@ -0,0 +1,41 @@ + + + + + + + + + +An internal component + diff --git a/src/components/NcRichText/autolink.js b/src/components/NcRichText/autolink.js index 360cd1a67d..d3761e7b2b 100644 --- a/src/components/NcRichText/autolink.js +++ b/src/components/NcRichText/autolink.js @@ -6,29 +6,10 @@ import { getBaseUrl, getRootUrl } from '@nextcloud/router' import { u } from 'unist-builder' import { SKIP, visitParents } from 'unist-util-visit-parents' +import NcRichTextExternalLink from './NcRichTextExternalLink.vue' import { logger } from '../../utils/logger.ts' import { URL_PATTERN_AUTOLINK } from './helpers.js' -const NcLink = { - name: 'NcLink', - props: { - href: { - type: String, - required: true, - }, - }, - render(h) { - return h('a', { - attrs: { - href: this.href, - rel: 'noopener noreferrer', - target: '_blank', - class: 'rich-text--external-link', - }, - }, [this.href.trim()]) - }, -} - /** * Remark plugin for autolink parsing * @@ -95,7 +76,7 @@ export function parseUrl(text) { textAfter = lastChar } list.push(textBefore) - list.push({ component: NcLink, props: { href } }) + list.push({ component: NcRichTextExternalLink, props: { href: href.trim(), decorateExternal: true } }) if (textAfter) { list.push(textAfter) } diff --git a/src/components/NcRichText/placeholder.js b/src/components/NcRichText/placeholder.js index 5f83ddec87..49ca754ccc 100644 --- a/src/components/NcRichText/placeholder.js +++ b/src/components/NcRichText/placeholder.js @@ -59,11 +59,9 @@ export function prepareTextNode({ h, context }, text) { return entry } const { component, props } = entry - // do not override class of NcLink - const componentClass = component.name === 'NcLink' ? undefined : 'rich-text--component' return h(component, { props, - class: componentClass, + class: 'rich-text--component', }) }) } diff --git a/src/components/NcRichText/richtext.scss b/src/components/NcRichText/richtext.scss index 3a170964e9..32f197fb13 100644 --- a/src/components/NcRichText/richtext.scss +++ b/src/components/NcRichText/richtext.scss @@ -14,13 +14,6 @@ .rich-text--fallback, .rich-text-component { display: inline; } - - .rich-text--external-link { - text-decoration: underline; - &:after { - content: ' ↗'; - } - } } /* Markdown styles */ From 887f74cb8ccc65c2087616ecbdaf39874b595c64 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Tue, 7 Apr 2026 13:29:50 +0200 Subject: [PATCH 2/2] fix(NcRichText)!: do not render non-resolved relative links - still works: external links, internal absolute links, and internal relative links, that do have a router match for current app - internal relative links without match (also ?query and #anchor) only render an inner text - allow tel: and mail: to reduce breaking changes Signed-off-by: Maksim Sukharev --- cypress/component/richtext.cy.ts | 56 +++++++++++++++++------- src/components/NcRichText/NcRichText.vue | 14 ++++++ 2 files changed, 54 insertions(+), 16 deletions(-) diff --git a/cypress/component/richtext.cy.ts b/cypress/component/richtext.cy.ts index 0cdc6229b1..adbfa173c4 100644 --- a/cypress/component/richtext.cy.ts +++ b/cypress/component/richtext.cy.ts @@ -7,6 +7,8 @@ // Reference tests: https://github.com/nextcloud-deps/CDMarkdownKit/tree/master/CDMarkdownKitTests import { mount } from 'cypress/vue2' +import Vue from 'vue' +import VueRouter from 'vue-router' import NcRichText from '../../src/components/NcRichText/NcRichText.vue' describe('NcRichText', () => { @@ -402,34 +404,56 @@ describe('NcRichText', () => { }) describe('links', () => { + const TestRouteComponent = { + template: '
', + } + + const mountRichText = (text: string) => { + Vue.use(VueRouter) + const routes = [{ path: '/world', component: TestRouteComponent }] + const router = new VueRouter({ + mode: 'history', + routes, + }) + + mount(NcRichText, { + propsData: { + text, + useMarkdown: true, + }, + extensions: { + plugins: [router], + }, + router, + }) + } + const testLink = (key: string, { text, href = text, name = text }) => { it(key, () => { - mount(NcRichText, { - propsData: { - text, - useMarkdown: true, - }, - }) + mountRichText(text) cy.get('a').should('have.text', name) cy.get('a').invoke('attr', 'href').should('eq', href) }) } testLink('autolink', { text: 'https://autolink.me' }) - testLink('relative link', { text: '[hello](world)', href: 'world', name: 'hello' }) + testLink('relative link', { text: '[hello](/world)', href: '/world', name: 'hello' }) testLink('absolute link', { text: '[hello](https://nextcloud.com)', href: 'https://nextcloud.com', name: 'hello' }) testLink('tel link', { text: '[hello](tel:+49123456789)', href: 'tel:+49123456789', name: 'hello' }) + testLink('mailto link', { text: '[hello](mailto:+49123456789)', href: 'mailto:+49123456789', name: 'hello' }) - it('no link to unknown protocols', () => { - mount(NcRichText, { - propsData: { - text: '[link](other:proto)', - useMarkdown: true, - }, + const testNoLink = (key: string, { text, name = text }) => { + it(key, () => { + mountRichText(text) + + cy.get('body').should('contain', name) + cy.get('a').should('not.exist') }) - cy.get('body').should('contain', name) - cy.get('a').should('not.exist') - }) + } + testNoLink('no link to unknown protocols', { text: '[hello](other:proto)', name: 'hello' }) + testNoLink('no link to unresolved relative link (by router)', { text: '[hello](world)', name: 'hello' }) + testNoLink('no link to relative parameters', { text: '[hello](?parameters=1)', name: 'hello' }) + testNoLink('no link to relative anchor', { text: '[hello](#anchor)', name: 'hello' }) }) describe('multiline code', () => { diff --git a/src/components/NcRichText/NcRichText.vue b/src/components/NcRichText/NcRichText.vue index 8dfb11ebaf..3e5008e09f 100644 --- a/src/components/NcRichText/NcRichText.vue +++ b/src/components/NcRichText/NcRichText.vue @@ -320,6 +320,7 @@ import { RouterLink } from 'vue-router' import NcCheckboxRadioSwitch from '../NcCheckboxRadioSwitch/NcCheckboxRadioSwitch.vue' import NcReferenceList from './NcReferenceList.vue' import NcRichTextCopyButton from './NcRichTextCopyButton.vue' +import NcRichTextExternalLink from './NcRichTextExternalLink.vue' import GenRandomId from '../../utils/GenRandomId.js' import { getRoute, remarkAutolink } from './autolink.js' import { prepareTextNode, remarkPlaceholder } from './placeholder.js' @@ -556,6 +557,7 @@ export default { if (tag === 'a') { const route = getRoute(this.$router, attrs.attrs.href) if (route) { + // Resolved link to this app; render RouterLink delete attrs.attrs.href delete attrs.attrs.target @@ -566,6 +568,18 @@ export default { }, }, children) } + + const isAllowedScheme = /^(https?:\/\/|tel:|mailto:)/.test(attrs.attrs.href) + if (isAllowedScheme) { + // External link; render normally, open in the new tab + attrs.attrs.href = attrs.attrs.href.trim() + return h(NcRichTextExternalLink, attrs, children) + } else { + // Unresolved relative link that does not belong to this app; render only children + delete attrs.attrs.href + delete attrs.attrs.target + return h('span', attrs, children) + } } return h(tag, attrs, children)