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, {