diff --git a/src/components/NcRichText/NcRichText.vue b/src/components/NcRichText/NcRichText.vue
index fe8cffc88b..af3d8ba9ec 100644
--- a/src/components/NcRichText/NcRichText.vue
+++ b/src/components/NcRichText/NcRichText.vue
@@ -307,6 +307,7 @@ import NcCheckboxRadioSwitch from '../NcCheckboxRadioSwitch/NcCheckboxRadioSwitc
import NcLoadingIcon from '../NcLoadingIcon/NcLoadingIcon.vue'
import { getRoute, remarkAutolink } from './autolink.ts'
import { remarkPlaceholder, prepareTextNode } from './placeholder.js'
+import { remarkUnescape } from './remarkUnescape.js'
import GenRandomId from '../../utils/GenRandomId.js'
import { unified } from 'unified'
@@ -456,6 +457,7 @@ export default {
useMarkdown: this.useMarkdown,
useExtendedMarkdown: this.useExtendedMarkdown,
})
+ .use(remarkUnescape)
.use(this.useExtendedMarkdown ? remarkGfm.value : undefined)
.use(breaks)
.use(remark2rehype, {
@@ -480,7 +482,7 @@ export default {
})
.processSync(this.text
// escape special symbol "<" to not treat text as HTML
- .replace(/]+>/g, (match) => match.replace(/" to parse blockquotes
.replace(/>/gmi, '>'),
)
@@ -506,14 +508,9 @@ export default {
props.key = key
}
// Children should be always an array
- let children = props.children ?? []
+ const children = props.children ?? []
delete props.children
- // unescape special symbol "<" for simple text nodes
- if (typeof children === 'string') {
- children = children.replace(/</gmi, '<')
- }
-
if (!String(type).startsWith('#')) {
let nestedNode = null
if (this.useExtendedMarkdown && remarkGfm.value) {
diff --git a/src/components/NcRichText/remarkUnescape.js b/src/components/NcRichText/remarkUnescape.js
new file mode 100644
index 0000000000..cc8ed8d77a
--- /dev/null
+++ b/src/components/NcRichText/remarkUnescape.js
@@ -0,0 +1,19 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { visit, SKIP } from 'unist-util-visit'
+
+export const remarkUnescape = function() {
+ return function(tree) {
+ visit(tree, (node) => ['text', 'code', 'inlineCode'].includes(node.type),
+ (node, index, parent) => {
+ parent.children.splice(index, 1, {
+ ...node,
+ value: node.value.replace(/</gmi, '<').replace(/>/gmi, '>'),
+ })
+ return [SKIP, index + 1]
+ })
+ }
+}
diff --git a/tests/component/components/NcRichText/markown-rendering.spec.ts b/tests/component/components/NcRichText/markown-rendering.spec.ts
index 0cddfafce7..2aec0fb9ad 100644
--- a/tests/component/components/NcRichText/markown-rendering.spec.ts
+++ b/tests/component/components/NcRichText/markown-rendering.spec.ts
@@ -6,9 +6,57 @@
// Markdown guide: https://www.markdownguide.org/basic-syntax/
// Reference tests: https://github.com/nextcloud-deps/CDMarkdownKit/tree/master/CDMarkdownKitTests
-import { expect, test } from '@playwright/experimental-ct-vue';
+import { expect, test } from '@playwright/experimental-ct-vue'
import NcRichText from '../../../../src/components/NcRichText/NcRichText.vue'
+test.describe('XML-like text (escaped and unescaped)', () => {
+ const TEST = 'text <span>text</span>'
+ test('renders normal text as passed', async ({ mount }) => {
+ const component = await mount(NcRichText, {
+ props: {
+ text: TEST,
+ },
+ })
+ await expect(component.getByText(TEST)).toBeVisible()
+ })
+ test('renders with Markdown, escaping XML', async ({ mount }) => {
+ const component = await mount(NcRichText, {
+ props: {
+ text: TEST,
+ useMarkdown: true,
+ },
+ })
+ await expect(component.getByRole('paragraph')).toContainText('text text')
+ })
+ test('renders with Markdown, escaping XML in code', async ({ mount }) => {
+ const component = await mount(NcRichText, {
+ props: {
+ text: '```\n' + TEST + '\n```',
+ useMarkdown: true,
+ },
+ })
+ await expect(component.getByRole('code')).toContainText('text text' + '\n')
+ })
+ test('renders with Flavored Markdown, escaping XML', async ({ mount }) => {
+ const component = await mount(NcRichText, {
+ props: {
+ text: TEST,
+ useExtendedMarkdown: true,
+ },
+ })
+ await expect(component.getByRole('paragraph')).toContainText('text text')
+ })
+ test('renders with Flavored Markdown, escaping XML in code', async ({ mount }) => {
+ const component = await mount(NcRichText, {
+ props: {
+ text: '```\n' + TEST + '\n```',
+ useExtendedMarkdown: true,
+ },
+ })
+ await expect(component.getByRole('code')).toContainText('text text' + '\n')
+ })
+})
+
test.describe('dividers', () => {
test('dividers with asterisks', async ({ mount }) => {
const component = await mount(NcRichText, {
@@ -179,7 +227,6 @@ test.describe('bold text', () => {
})
})
-
test.describe('italic text', () => {
test('italic text (single with asterisk syntax)', async ({ mount }) => {
const component = await mount(NcRichText, {
@@ -253,7 +300,6 @@ test.describe('italic text', () => {
})
})
-
test.describe('strikethrough text', () => {
test('strikethrough text (with single tilda syntax)', async ({ mount }) => {
const component = await mount(NcRichText, {
@@ -303,7 +349,6 @@ test.describe('strikethrough text', () => {
})
})
-
test.describe('inline code', () => {
test('inline code (single with backticks syntax)', async ({ mount }) => {
const component = await mount(NcRichText, {
@@ -374,7 +419,6 @@ test.describe('inline code', () => {
})
})
-
test.describe('multiline code', () => {
test('multiline code (with triple backticks syntax)', async ({ mount }) => {
const component = await mount(NcRichText, {
@@ -444,7 +488,6 @@ test.describe('multiline code', () => {
})
})
-
test.describe('blockquote', () => {
test('blockquote (with greater then (>) syntax - normal)', async ({ mount }) => {
const component = await mount(NcRichText, {
@@ -527,7 +570,6 @@ test.describe('blockquote', () => {
})
})
-
test.describe('lists', () => {
test('ordered list (with number + `.` syntax divided with space from text)', async ({ mount }) => {
const testCases = [
@@ -586,7 +628,6 @@ test.describe('lists', () => {
})
})
-
test.describe('task lists', () => {
test('task list (with `- [ ]` and `- [x]` syntax divided with space from text)', async ({ mount }) => {
const testCases = [
@@ -606,14 +647,13 @@ test.describe('task lists', () => {
await expect(component.locator('ul')).toHaveCount(1)
await expect(component.getByRole('listitem')).toHaveCount(testCases.length)
- for(const [index, testcase] of testCases.entries()) {
+ for (const [index, testcase] of testCases.entries()) {
await expect(component.getByRole('listitem').nth(index)).toHaveText(testcase.output)
- await expect(component.getByRole('listitem').nth(index).getByRole('checkbox')).toBeChecked({ checked: testcase.checked})
+ await expect(component.getByRole('listitem').nth(index).getByRole('checkbox')).toBeChecked({ checked: testcase.checked })
}
})
})
-
test.describe('tables', () => {
test('table (with `-- | --` syntax)', async ({ mount }) => {
const component = await mount(NcRichText, {