diff --git a/packages/agentflow/src/__mocks__/@tiptap/react.ts b/packages/agentflow/src/__mocks__/@tiptap/react.ts index a97da5ba2ae..efda603584e 100644 --- a/packages/agentflow/src/__mocks__/@tiptap/react.ts +++ b/packages/agentflow/src/__mocks__/@tiptap/react.ts @@ -1,5 +1,12 @@ import { createElement, forwardRef } from 'react' +/** Extract all text from a ProseMirror JSON node recursively. */ +function extractPmText(node: { text?: string; content?: unknown[] }): string { + if (node.text) return node.text + if (node.content) return (node.content as { text?: string; content?: unknown[] }[]).map(extractPmText).join('') + return '' +} + export const useEditor = (config?: Record) => { // Track the current content so getMarkdown/getHTML reflect setContent() calls, // mirroring real TipTap behaviour where setContent updates the editor state. @@ -9,12 +16,22 @@ export const useEditor = (config?: Record) => { getMarkdown: () => currentContent, isEmpty: !currentContent, setEditable: jest.fn(), + // Returns a minimal ProseMirror JSON for the current string content. + // Allows unescapeXmlEntities() to walk and mutate the text node. + getJSON: () => ({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: currentContent }] }] + }), commands: { focus: jest.fn(), - // Capture the first argument (the content string) so getMarkdown/getHTML - // return the value that was last loaded into the editor. - setContent: jest.fn((content: string) => { - currentContent = content + // Accepts both string content and ProseMirror JSON objects (second setContent call + // in the two-step XML escape/unescape load sequence). + setContent: jest.fn((content: string | { text?: string; content?: unknown[] }) => { + if (typeof content === 'string') { + currentContent = content + } else if (content && typeof content === 'object') { + currentContent = extractPmText(content) + } }) }, _onUpdate: config?.onUpdate diff --git a/packages/agentflow/src/atoms/ExpandTextDialog.tsx b/packages/agentflow/src/atoms/ExpandTextDialog.tsx index c2e485654be..a2368a67f3f 100644 --- a/packages/agentflow/src/atoms/ExpandTextDialog.tsx +++ b/packages/agentflow/src/atoms/ExpandTextDialog.tsx @@ -4,7 +4,7 @@ import { Box, Button, Dialog, DialogActions, DialogContent, TextField, ToggleBut import { IconCode, IconPencil } from '@tabler/icons-react' import type { Editor } from '@tiptap/react' -import { getEditorMarkdown } from '@/atoms/utils/' +import { getEditorMarkdown, unescapeXmlTags } from '@/atoms/utils/' import { CodeInput } from './CodeInput' import { RichTextEditor } from './RichTextEditor.lazy' @@ -73,7 +73,7 @@ export function ExpandTextDialog({ // When switching to Source, flush the editor's current state to markdown so the // textarea shows markdown rather than a raw HTML string (Gap 3 fix — mirrors PR #6021). if (newMode === 'source' && editorRef.current) { - setLocalValue(getEditorMarkdown(editorRef.current)) + setLocalValue(unescapeXmlTags(getEditorMarkdown(editorRef.current))) } setMode(newMode) } diff --git a/packages/agentflow/src/atoms/RichTextEditor.test.tsx b/packages/agentflow/src/atoms/RichTextEditor.test.tsx index 8bab6239e91..efc68e028e0 100644 --- a/packages/agentflow/src/atoms/RichTextEditor.test.tsx +++ b/packages/agentflow/src/atoms/RichTextEditor.test.tsx @@ -1,4 +1,6 @@ import { render, screen } from '@testing-library/react' +import { Markdown } from '@tiptap/markdown' +import StarterKit from '@tiptap/starter-kit' import { RichTextEditor } from './RichTextEditor' @@ -6,15 +8,18 @@ import { RichTextEditor } from './RichTextEditor' // editor.getMarkdown() is added directly on the Editor interface by @tiptap/markdown // via module augmentation — it is NOT nested under storage.markdown. let capturedOnUpdate: ((args: { editor: { getMarkdown: () => string } }) => void) | undefined +let capturedExtensions: unknown[] | undefined jest.mock('@tiptap/react', () => ({ useEditor: (config: Record) => { // Capture the onUpdate callback so tests can simulate edits capturedOnUpdate = config.onUpdate as typeof capturedOnUpdate + capturedExtensions = config.extensions as unknown[] return { setEditable: jest.fn(), commands: { focus: jest.fn(), setContent: jest.fn() }, - getMarkdown: () => 'mock markdown' + getMarkdown: () => 'mock markdown', + getJSON: () => ({ type: 'doc', content: [] }) } }, EditorContent: ({ editor, ...rest }: { editor: unknown; [key: string]: unknown }) => ( @@ -49,14 +54,16 @@ jest.mock('lowlight', () => ({ createLowlight: jest.fn(() => ({ register: jest.fn() })) })) -const mockOnChange = jest.fn() +describe('RichTextEditor', () => { + let mockOnChange: jest.Mock -beforeEach(() => { - jest.clearAllMocks() - capturedOnUpdate = undefined -}) + beforeEach(() => { + jest.clearAllMocks() + capturedOnUpdate = undefined + capturedExtensions = undefined + mockOnChange = jest.fn() + }) -describe('RichTextEditor', () => { it('should render the editor container', () => { render() @@ -100,4 +107,70 @@ describe('RichTextEditor', () => { expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument() }) + + describe('buildExtensions — useMarkdown flag', () => { + it('includes Markdown extension when useMarkdown is true (default)', () => { + render() + + expect(capturedExtensions).toContain(Markdown) + }) + + it('excludes Markdown extension when useMarkdown is false', () => { + render() + + expect(capturedExtensions).not.toContain(Markdown) + }) + + it('passes link:false to StarterKit when useMarkdown is false', () => { + render() + + expect(StarterKit.configure).toHaveBeenCalledWith(expect.objectContaining({ link: false })) + }) + + it('does not pass link:false to StarterKit when useMarkdown is true (default)', () => { + render() + + expect(StarterKit.configure).not.toHaveBeenCalledWith(expect.objectContaining({ link: false })) + }) + }) + + describe('XML tag preservation in onUpdate', () => { + it('should unescape entity-escaped XML tags before calling onChange', () => { + render() + + // Simulate getMarkdown() returning entity-escaped tags (safety-net path) + capturedOnUpdate!({ + editor: { getMarkdown: () => '<instructions>Be helpful</instructions>' } + }) + + expect(mockOnChange).toHaveBeenCalledWith('Be helpful') + }) + + it('should pass through raw XML tags in markdown unchanged', () => { + render() + + capturedOnUpdate!({ + editor: { getMarkdown: () => 'Be helpful' } + }) + + expect(mockOnChange).toHaveBeenCalledWith('Be helpful') + }) + + it('should preserve XML tags mixed with markdown', () => { + render() + + capturedOnUpdate!({ + editor: { getMarkdown: () => '# Title\n<question>{{input}}</question>\n**bold**' } + }) + + expect(mockOnChange).toHaveBeenCalledWith('# Title\n{{input}}\n**bold**') + }) + + it('should render without error when useMarkdown is false', () => { + // HTML mode is exercised by other tests; this guards the component mounts cleanly. + render() + + expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument() + }) + }) }) diff --git a/packages/agentflow/src/atoms/RichTextEditor.tsx b/packages/agentflow/src/atoms/RichTextEditor.tsx index d9dd0664039..4fe6aa61796 100644 --- a/packages/agentflow/src/atoms/RichTextEditor.tsx +++ b/packages/agentflow/src/atoms/RichTextEditor.tsx @@ -16,7 +16,7 @@ import python from 'highlight.js/lib/languages/python' import typescript from 'highlight.js/lib/languages/typescript' import { createLowlight } from 'lowlight' -import { getEditorMarkdown, isHtmlContent } from '@/atoms/utils/' +import { escapeXmlTags, getEditorMarkdown, isHtmlContent, unescapeXmlEntities, unescapeXmlTags } from '@/atoms/utils/' import { tokens } from '@/core/theme/tokens' const lowlight = createLowlight() @@ -42,11 +42,22 @@ export interface RichTextEditorProps { useMarkdown?: boolean } +/* ── Helpers ── */ + +function loadContent(editor: Editor, value: string, markdown: boolean) { + if (!markdown || isHtmlContent(value)) { + editor.commands.setContent(value, { emitUpdate: false, contentType: 'html' }) + } else { + editor.commands.setContent(escapeXmlTags(value), { emitUpdate: false, contentType: 'markdown' }) + editor.commands.setContent(unescapeXmlEntities(editor.getJSON()), { emitUpdate: false }) + } +} + /* ── TipTap extensions (no mention/variable support — that belongs in features/) ── */ -const buildExtensions = (placeholder?: string) => [ - Markdown, - StarterKit.configure({ codeBlock: false }), +const buildExtensions = (placeholder?: string, useMarkdown = true) => [ + ...(useMarkdown ? [Markdown] : []), + StarterKit.configure({ codeBlock: false, ...(!useMarkdown && { link: false }) }), CodeBlockLowlight.configure({ lowlight, enableTabIndentation: true, tabSize: 2 }), ...(placeholder ? [Placeholder.configure({ placeholder })] : []) ] @@ -174,7 +185,7 @@ export function RichTextEditor({ const initialValueRef = useRef(value) const useMarkdownRef = useRef(useMarkdown) - const extensions = useMemo(() => buildExtensions(placeholder), [placeholder]) + const extensions = useMemo(() => buildExtensions(placeholder, useMarkdown), [placeholder, useMarkdown]) const editor = useEditor({ extensions, @@ -182,9 +193,10 @@ export function RichTextEditor({ editable: !disabled, autofocus: autoFocus ? 'end' : false, onUpdate: ({ editor: ed }) => { - const value = useMarkdown ? getEditorMarkdown(ed) : ed.getHTML() - lastEmittedRef.current = value - onChangeRef.current(value) + const raw = useMarkdown ? getEditorMarkdown(ed) : ed.getHTML() + const emitted = useMarkdown ? unescapeXmlTags(raw) : raw + lastEmittedRef.current = emitted + onChangeRef.current(emitted) } }) @@ -199,16 +211,14 @@ export function RichTextEditor({ // Reads from refs so only `editor` needs to be in the dep array. useEffect(() => { if (!editor || !initialValueRef.current) return - const contentType = !useMarkdownRef.current || isHtmlContent(initialValueRef.current) ? 'html' : 'markdown' - editor.commands.setContent(initialValueRef.current, { emitUpdate: false, contentType }) + loadContent(editor, initialValueRef.current, useMarkdownRef.current) lastEmittedRef.current = initialValueRef.current }, [editor]) // Sync genuine external value changes (e.g. parent resets the field programmatically). useEffect(() => { if (editor && value !== lastEmittedRef.current) { - const contentType = !useMarkdown || isHtmlContent(value) ? 'html' : 'markdown' - editor.commands.setContent(value, { emitUpdate: false, contentType }) + loadContent(editor, value, useMarkdown) lastEmittedRef.current = value } }, [editor, value, useMarkdown]) diff --git a/packages/agentflow/src/atoms/VariableInput.test.tsx b/packages/agentflow/src/atoms/VariableInput.test.tsx index dc808ac08c6..c77e16e9987 100644 --- a/packages/agentflow/src/atoms/VariableInput.test.tsx +++ b/packages/agentflow/src/atoms/VariableInput.test.tsx @@ -1,6 +1,7 @@ import { createTheme, ThemeProvider } from '@mui/material/styles' import { render, screen } from '@testing-library/react' import * as TiptapReact from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' import { VariableInput, type VariableInputProps } from './VariableInput' @@ -98,4 +99,92 @@ describe('VariableInput', () => { // The mock editor has getHTML/getMarkdown/commands etc. expect(onEditorReady).toHaveBeenCalledWith(expect.objectContaining({ getHTML: expect.any(Function) })) }) + + describe('useMarkdown derived from rows — StarterKit link extension', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('passes link:false to StarterKit when rows is not set (single-line field)', () => { + renderVariableInput({ rows: undefined }) + + expect(StarterKit.configure).toHaveBeenCalledWith(expect.objectContaining({ link: false })) + }) + + it('does not pass link:false to StarterKit when rows is set (multiline/markdown field)', () => { + renderVariableInput({ rows: 4 }) + + expect(StarterKit.configure).not.toHaveBeenCalledWith(expect.objectContaining({ link: false })) + }) + }) + + describe('onUpdate always emits markdown regardless of rows', () => { + it('calls onChange with getEditorMarkdown output when rows is not set', () => { + const onChange = jest.fn() + const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor') + renderVariableInput({ onChange, rows: undefined }) + + const config = useEditorSpy.mock.calls[0][0] as { onUpdate: (args: { editor: unknown }) => void } + const mockEditor = { getMarkdown: () => 'plain url text', getHTML: jest.fn() } + config.onUpdate({ editor: mockEditor }) + + expect(onChange).toHaveBeenCalledWith('plain url text') + expect(mockEditor.getHTML).not.toHaveBeenCalled() + useEditorSpy.mockRestore() + }) + + it('calls onChange with getEditorMarkdown output when rows is set', () => { + const onChange = jest.fn() + const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor') + renderVariableInput({ onChange, rows: 4 }) + + const config = useEditorSpy.mock.calls[0][0] as { onUpdate: (args: { editor: unknown }) => void } + const mockEditor = { getMarkdown: () => '**bold**', getHTML: jest.fn() } + config.onUpdate({ editor: mockEditor }) + + expect(onChange).toHaveBeenCalledWith('**bold**') + expect(mockEditor.getHTML).not.toHaveBeenCalled() + useEditorSpy.mockRestore() + }) + }) + + describe('XML tag preservation in onUpdate', () => { + it('should unescape entity-escaped XML tags before calling onChange', () => { + const onChange = jest.fn() + const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor') + renderVariableInput({ onChange }) + + const config = useEditorSpy.mock.calls[0][0] as { onUpdate: (args: { editor: unknown }) => void } + config.onUpdate({ editor: { getMarkdown: () => '<instructions>Be helpful</instructions>', getHTML: jest.fn() } }) + + expect(onChange).toHaveBeenCalledWith('Be helpful') + useEditorSpy.mockRestore() + }) + + it('should pass through raw XML tags unchanged', () => { + const onChange = jest.fn() + const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor') + renderVariableInput({ onChange }) + + const config = useEditorSpy.mock.calls[0][0] as { onUpdate: (args: { editor: unknown }) => void } + config.onUpdate({ editor: { getMarkdown: () => 'What?', getHTML: jest.fn() } }) + + expect(onChange).toHaveBeenCalledWith('What?') + useEditorSpy.mockRestore() + }) + + it('should preserve XML tags mixed with variables', () => { + const onChange = jest.fn() + const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor') + renderVariableInput({ onChange }) + + const config = useEditorSpy.mock.calls[0][0] as { onUpdate: (args: { editor: unknown }) => void } + config.onUpdate({ + editor: { getMarkdown: () => '<context>{{question}}</context>', getHTML: jest.fn() } + }) + + expect(onChange).toHaveBeenCalledWith('{{question}}') + useEditorSpy.mockRestore() + }) + }) }) diff --git a/packages/agentflow/src/atoms/VariableInput.tsx b/packages/agentflow/src/atoms/VariableInput.tsx index f776697e401..9a6091136ff 100644 --- a/packages/agentflow/src/atoms/VariableInput.tsx +++ b/packages/agentflow/src/atoms/VariableInput.tsx @@ -15,7 +15,8 @@ import python from 'highlight.js/lib/languages/python' import typescript from 'highlight.js/lib/languages/typescript' import { createLowlight } from 'lowlight' -import { getEditorMarkdown, isHtmlContent } from '@/atoms/utils/' +import type { JsonNode } from '@/atoms/utils/' +import { escapeXmlTags, getEditorMarkdown, isHtmlContent, restoreTextMentions, unescapeXmlTags } from '@/atoms/utils/' import { CustomMention } from '@/core/primitives/customMention' import { tokens } from '@/core/theme/tokens' @@ -133,6 +134,15 @@ const StyledEditorContent = styled(EditorContent, { } }) +function loadContent(ed: Editor, content: string, hasMentions: boolean): void { + if (isHtmlContent(content)) { + ed.commands.setContent(content, { emitUpdate: false, contentType: 'html' }) + } else { + ed.commands.setContent(escapeXmlTags(content), { emitUpdate: false, contentType: 'markdown' }) + ed.commands.setContent(restoreTextMentions(ed.getJSON() as JsonNode, hasMentions)[0], { emitUpdate: false }) + } +} + /** * A TipTap-based text input with `{{ variable }}` mention support. * @@ -170,16 +180,23 @@ export function VariableInput({ // `editor` without suppressing the exhaustive-deps rule. const initialValueRef = useRef(value) + const useMarkdown = !!rows + const suggestionConfig = useMemo( () => (suggestionItems?.length ? createSuggestionConfig(suggestionItems) : undefined), [suggestionItems] ) + const suggestionConfigRef = useRef(suggestionConfig) + useEffect(() => { + suggestionConfigRef.current = suggestionConfig + }, [suggestionConfig]) const extensions = useMemo( () => [ Markdown, StarterKit.configure({ - codeBlock: false + codeBlock: false, + ...(!useMarkdown && { link: false }) }), CodeBlockLowlight.configure({ lowlight, enableTabIndentation: true, tabSize: 2 }), ...(placeholder ? [Placeholder.configure({ placeholder })] : []), @@ -200,7 +217,7 @@ export function VariableInput({ ] : []) ], - [placeholder, suggestionConfig] + [placeholder, suggestionConfig, useMarkdown] ) const editor = useEditor({ @@ -209,9 +226,9 @@ export function VariableInput({ editable: !disabled, autofocus: autoFocus ? 'end' : false, onUpdate: ({ editor: ed }) => { - const value = getEditorMarkdown(ed) - lastEmittedRef.current = value - onChangeRef.current(value) + const emitted = unescapeXmlTags(getEditorMarkdown(ed)) + lastEmittedRef.current = emitted + onChangeRef.current(emitted) } }) @@ -219,16 +236,14 @@ export function VariableInput({ // Reads from a ref so only `editor` needs to be in the dep array. useEffect(() => { if (!editor || !initialValueRef.current) return - const contentType = isHtmlContent(initialValueRef.current) ? 'html' : 'markdown' - editor.commands.setContent(initialValueRef.current, { emitUpdate: false, contentType }) + loadContent(editor, initialValueRef.current, !!suggestionConfigRef.current) lastEmittedRef.current = initialValueRef.current }, [editor]) // Sync genuine external value changes (e.g. parent resets the field programmatically). useEffect(() => { if (editor && value !== lastEmittedRef.current) { - const contentType = isHtmlContent(value) ? 'html' : 'markdown' - editor.commands.setContent(value, { emitUpdate: false, contentType }) + loadContent(editor, value, !!suggestionConfigRef.current) lastEmittedRef.current = value } }, [editor, value]) diff --git a/packages/agentflow/src/atoms/utils/editorUtils.test.ts b/packages/agentflow/src/atoms/utils/editorUtils.test.ts deleted file mode 100644 index 5fb5da88465..00000000000 --- a/packages/agentflow/src/atoms/utils/editorUtils.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { getEditorMarkdown, isHtmlContent } from '../../atoms/utils/editorUtils' - -describe('editorUtils', () => { - describe('isHtmlContent', () => { - // Falsy / non-string inputs - it('returns false for empty string', () => { - expect(isHtmlContent('')).toBe(false) - }) - - it('returns false for null', () => { - expect(isHtmlContent(null)).toBe(false) - }) - - it('returns false for undefined', () => { - expect(isHtmlContent(undefined)).toBe(false) - }) - - it('returns false for a number', () => { - expect(isHtmlContent(42)).toBe(false) - }) - - // Markdown content (must NOT be detected as HTML) - it('returns false for plain text', () => { - expect(isHtmlContent('You are a helpful assistant.')).toBe(false) - }) - - it('returns false for markdown heading', () => { - expect(isHtmlContent('## My heading')).toBe(false) - }) - - it('returns false for markdown bullet list', () => { - expect(isHtmlContent('- item 1\n- item 2')).toBe(false) - }) - - it('returns false for markdown with variable syntax', () => { - expect(isHtmlContent('Hello {{question}}, today is {{current_date_time}}')).toBe(false) - }) - - it('returns false for markdown code fence', () => { - expect(isHtmlContent('```js\nconsole.log("hello")\n```')).toBe(false) - }) - - it('returns false for markdown bold/italic', () => { - expect(isHtmlContent('**bold** and *italic* text')).toBe(false) - }) - - // Legacy HTML content (MUST be detected) - it('returns true for

tag', () => { - expect(isHtmlContent('

Hello world

')).toBe(true) - }) - - it('returns true for

with variable mention span', () => { - expect(isHtmlContent('

Hello {{question}}

')).toBe(true) - }) - - it('returns true for

heading tag', () => { - expect(isHtmlContent('

Title

')).toBe(true) - }) - - it('returns true for

heading tag', () => { - expect(isHtmlContent('

Section

')).toBe(true) - }) - - it('returns true for
    list tag', () => { - expect(isHtmlContent('
    • item
    ')).toBe(true) - }) - - it('returns true for
     block', () => {
    -            expect(isHtmlContent('
    const x = 1
    ')).toBe(true) - }) - - it('returns true for tag', () => { - expect(isHtmlContent('bold')).toBe(true) - }) - - it('returns true for tag', () => { - expect(isHtmlContent('italic')).toBe(true) - }) - - it('returns true for
    tag', () => { - expect(isHtmlContent('
    quote
    ')).toBe(true) - }) - - it('returns true for multiline HTML with mixed content', () => { - expect(isHtmlContent('

    First paragraph

    \n

    Second paragraph

    ')).toBe(true) - }) - - it('returns true for uppercase tag like

    ', () => { - expect(isHtmlContent('

    uppercase

    ')).toBe(true) - }) - }) - - describe('getEditorMarkdown', () => { - it('returns markdown when getMarkdown() returns a non-empty string', () => { - const editor = { getMarkdown: () => '## heading', getHTML: () => '

    heading

    ', isEmpty: false } - expect(getEditorMarkdown(editor)).toBe('## heading') - }) - - it('returns empty string when getMarkdown() returns "" and editor is empty', () => { - const editor = { getMarkdown: () => '', getHTML: () => '', isEmpty: true } - expect(getEditorMarkdown(editor)).toBe('') - }) - - it('falls back to HTML when getMarkdown() returns "" but editor is not empty', () => { - const editor = { getMarkdown: () => '', getHTML: () => '

    hello

    ', isEmpty: false } - expect(getEditorMarkdown(editor)).toBe('

    hello

    ') - }) - - it('falls back to HTML when getMarkdown() throws', () => { - const editor = { - getMarkdown: () => { - throw new Error('serialization failed') - }, - getHTML: () => '

    fallback

    ', - isEmpty: false - } - expect(getEditorMarkdown(editor)).toBe('

    fallback

    ') - }) - }) -}) diff --git a/packages/agentflow/src/atoms/utils/editorUtils.ts b/packages/agentflow/src/atoms/utils/editorUtils.ts deleted file mode 100644 index fbd511ab60b..00000000000 --- a/packages/agentflow/src/atoms/utils/editorUtils.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Returns true when `content` looks like legacy HTML (saved by the old getHTML() - * serializer), so callers can choose the right TipTap `setContent` contentType. - * - * Ported from packages/ui/src/ui-component/input/RichInput.jsx (PR #6021). - * A simple tag-presence check is intentionally used here — it is fast, has no - * dependencies, and the false-positive/negative risk for agentflow node inputs - * is negligible (users don't normally write raw HTML into prompt fields). - */ -export function isHtmlContent(content: unknown): boolean { - if (!content || typeof content !== 'string') return false - return /<(?:p|div|span|h[1-6]|ul|ol|li|br|code|pre|blockquote|table|strong|em)\b/i.test(content) -} - -/** - * Safely serialise a TipTap editor to markdown, falling back to HTML when - * `getMarkdown()` returns an empty string for a non-empty document — a known - * issue in `@tiptap/markdown` v3 where the MarkdownManager may silently fail - * to serialise certain node types. - */ -export function getEditorMarkdown(editor: { getMarkdown(): string; getHTML(): string; isEmpty: boolean }): string { - try { - const markdown = editor.getMarkdown() - if (markdown) return markdown - if (editor.isEmpty) return '' - return editor.getHTML() - } catch { - return editor.getHTML() - } -} diff --git a/packages/agentflow/src/atoms/utils/index.ts b/packages/agentflow/src/atoms/utils/index.ts index 06c2956759e..5b0dbb535a3 100644 --- a/packages/agentflow/src/atoms/utils/index.ts +++ b/packages/agentflow/src/atoms/utils/index.ts @@ -1 +1,2 @@ -export { getEditorMarkdown, isHtmlContent } from './editorUtils' +export type { JsonNode } from './xmlTagUtils' +export { escapeXmlTags, getEditorMarkdown, isHtmlContent, restoreTextMentions, unescapeXmlEntities, unescapeXmlTags } from './xmlTagUtils' diff --git a/packages/agentflow/src/atoms/utils/xmlTagUtils.test.ts b/packages/agentflow/src/atoms/utils/xmlTagUtils.test.ts new file mode 100644 index 00000000000..34ec42643ed --- /dev/null +++ b/packages/agentflow/src/atoms/utils/xmlTagUtils.test.ts @@ -0,0 +1,482 @@ +import type { JsonNode } from './xmlTagUtils' +import { escapeXmlTags, getEditorMarkdown, isHtmlContent, restoreTextMentions, unescapeXmlEntities, unescapeXmlTags } from './xmlTagUtils' + +// ── isHtmlContent ────────────────────────────────────────────────────────────── + +describe('isHtmlContent', () => { + // Falsy / non-string inputs + it('returns false for empty string', () => { + expect(isHtmlContent('')).toBe(false) + }) + + it('returns false for null', () => { + expect(isHtmlContent(null)).toBe(false) + }) + + it('returns false for undefined', () => { + expect(isHtmlContent(undefined)).toBe(false) + }) + + it('returns false for a number', () => { + expect(isHtmlContent(42)).toBe(false) + }) + + // Markdown content (must NOT be detected as HTML) + it('returns false for plain text', () => { + expect(isHtmlContent('You are a helpful assistant.')).toBe(false) + }) + + it('returns false for markdown heading', () => { + expect(isHtmlContent('## My heading')).toBe(false) + }) + + it('returns false for markdown bullet list', () => { + expect(isHtmlContent('- item 1\n- item 2')).toBe(false) + }) + + it('returns false for markdown with variable syntax', () => { + expect(isHtmlContent('Hello {{question}}, today is {{current_date_time}}')).toBe(false) + }) + + it('returns false for markdown code fence', () => { + expect(isHtmlContent('```js\nconsole.log("hello")\n```')).toBe(false) + }) + + it('returns false for markdown bold/italic', () => { + expect(isHtmlContent('**bold** and *italic* text')).toBe(false) + }) + + // Legacy HTML content (MUST be detected) + it('returns true for

    tag', () => { + expect(isHtmlContent('

    Hello world

    ')).toBe(true) + }) + + it('returns true for

    with variable mention span', () => { + expect(isHtmlContent('

    Hello {{question}}

    ')).toBe(true) + }) + + it('returns true for

    heading tag', () => { + expect(isHtmlContent('

    Title

    ')).toBe(true) + }) + + it('returns true for

    heading tag', () => { + expect(isHtmlContent('

    Section

    ')).toBe(true) + }) + + it('returns true for
      list tag', () => { + expect(isHtmlContent('
      • item
      ')).toBe(true) + }) + + it('returns true for
       block', () => {
      +        expect(isHtmlContent('
      const x = 1
      ')).toBe(true) + }) + + it('returns true for tag', () => { + expect(isHtmlContent('bold')).toBe(true) + }) + + it('returns true for tag', () => { + expect(isHtmlContent('italic')).toBe(true) + }) + + it('returns true for
      tag', () => { + expect(isHtmlContent('
      quote
      ')).toBe(true) + }) + + it('returns true for multiline HTML with mixed content', () => { + expect(isHtmlContent('

      First paragraph

      \n

      Second paragraph

      ')).toBe(true) + }) + + it('returns true for uppercase tag like

      ', () => { + expect(isHtmlContent('

      uppercase

      ')).toBe(true) + }) + + // Anchor fix — a user prompt that *contains* a standard HTML tag but does NOT start with one + it('returns false for a prompt starting with a custom tag that contains HTML inside', () => { + expect(isHtmlContent('
      Test
      ')).toBe(false) + }) + + it('returns false for a custom XML tag like ', () => { + expect(isHtmlContent('Be helpful')).toBe(false) + }) +}) + +// ── getEditorMarkdown ────────────────────────────────────────────────────────── + +describe('getEditorMarkdown', () => { + it('returns markdown when getMarkdown() returns a non-empty string', () => { + const editor = { getMarkdown: () => '## heading', getHTML: () => '

      heading

      ', isEmpty: false } + expect(getEditorMarkdown(editor)).toBe('## heading') + }) + + it('returns empty string when getMarkdown() returns "" and editor is empty', () => { + const editor = { getMarkdown: () => '', getHTML: () => '', isEmpty: true } + expect(getEditorMarkdown(editor)).toBe('') + }) + + it('falls back to HTML when getMarkdown() returns "" but editor is not empty', () => { + const editor = { getMarkdown: () => '', getHTML: () => '

      hello

      ', isEmpty: false } + expect(getEditorMarkdown(editor)).toBe('

      hello

      ') + }) + + it('falls back to HTML when getMarkdown() throws', () => { + const editor = { + getMarkdown: () => { + throw new Error('serialization failed') + }, + getHTML: () => '

      fallback

      ', + isEmpty: false + } + expect(getEditorMarkdown(editor)).toBe('

      fallback

      ') + }) +}) + +// ── escapeXmlTags ────────────────────────────────────────────────────────────── + +describe('escapeXmlTags', () => { + it('should escape opening and closing tags to entities', () => { + expect(escapeXmlTags('text')).toBe('<question>text</question>') + }) + + it('should escape self-closing tags', () => { + expect(escapeXmlTags('')).toBe('<my-separator />') + }) + + it('should escape tags with attributes', () => { + expect(escapeXmlTags('hello')).toBe('<context type="user">hello</context>') + }) + + it('should escape standard HTML tags too', () => { + expect(escapeXmlTags('

      text

      ')).toBe('<div><p>text</p></div>') + }) + + it('should escape all tags in mixed content', () => { + const input = '# Heading\nWhat is {{name}}?\n

      paragraph

      ' + const expected = '# Heading\n<question>What is {{name}}?</question>\n<p>paragraph</p>' + expect(escapeXmlTags(input)).toBe(expected) + }) + + it('should handle nested tags', () => { + const input = 'text' + const expected = '<outer><inner>text</inner></outer>' + expect(escapeXmlTags(input)).toBe(expected) + }) + + it('should return empty string as-is', () => { + expect(escapeXmlTags('')).toBe('') + }) + + it('should handle text with no tags', () => { + expect(escapeXmlTags('just plain text')).toBe('just plain text') + }) + + it('should handle tags with dots and hyphens in names', () => { + expect(escapeXmlTags('text')).toBe('<my.tag>text</my.tag>') + expect(escapeXmlTags('text')).toBe('<my-tag>text</my-tag>') + }) + + it('should not double-escape already-escaped content', () => { + const alreadyEscaped = '<question>text</question>' + expect(escapeXmlTags(alreadyEscaped)).toBe(alreadyEscaped) + }) +}) + +// ── unescapeXmlEntities ──────────────────────────────────────────────────────── + +describe('unescapeXmlEntities', () => { + it('should unescape entities in text nodes', () => { + const json = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: '<question>What?</question>' }] + } + ] + } + unescapeXmlEntities(json) + expect(json.content[0].content[0].text).toBe('What?') + }) + + it('should unescape standard HTML tag entities too', () => { + const json = { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: '<div>content</div>' }] }] + } + unescapeXmlEntities(json) + expect(json.content[0].content[0].text).toBe('
      content
      ') + }) + + it('should handle nodes across multiple paragraphs', () => { + const json = { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: '<outer>' }] }, + { type: 'paragraph', content: [{ type: 'text', text: '<inner>text</inner>' }] }, + { type: 'paragraph', content: [{ type: 'text', text: '</outer>' }] } + ] + } + unescapeXmlEntities(json) + expect(json.content[0].content[0].text).toBe('') + expect(json.content[1].content[0].text).toBe('text') + expect(json.content[2].content[0].text).toBe('') + }) + + it('should not modify nodes without entities', () => { + const json = { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'plain text' }] }] + } + unescapeXmlEntities(json) + expect(json.content[0].content[0].text).toBe('plain text') + }) + + it('should return the same object for chaining', () => { + const json = { type: 'doc', content: [] } + expect(unescapeXmlEntities(json)).toBe(json) + }) +}) + +// ── unescapeXmlTags ──────────────────────────────────────────────────────────── + +describe('unescapeXmlTags', () => { + it('should unescape entity-escaped tags', () => { + expect(unescapeXmlTags('<question>text</question>')).toBe('text') + }) + + it('should unescape standard HTML tags too', () => { + expect(unescapeXmlTags('<div>text</div>')).toBe('
      text
      ') + }) + + it('should unescape tags with attributes', () => { + expect(unescapeXmlTags('<context type="user">hello</context>')).toBe('hello') + }) + + it('should handle mixed content', () => { + const input = '# Heading\n<question>text</question>\nsome markdown' + const expected = '# Heading\ntext\nsome markdown' + expect(unescapeXmlTags(input)).toBe(expected) + }) + + it('should return empty string as-is', () => { + expect(unescapeXmlTags('')).toBe('') + }) + + it('should pass through raw (unescaped) tags unchanged', () => { + expect(unescapeXmlTags('text')).toBe('text') + }) +}) + +// ── roundtrip — escape → unescape ───────────────────────────────────────────── + +describe('roundtrip — escape → unescape', () => { + const cases = [ + 'What is the answer?', + 'You are helpful', + '\n- Step 1\n- Step 2\n', + 'nested', + '# Title\n{{input}}\n**bold** text', + '', + 'No tags here, just **markdown**', + '
      standard html preserved
      ' + ] + + cases.forEach((input) => { + it(`should roundtrip: ${input.substring(0, 50)}`, () => { + expect(unescapeXmlTags(escapeXmlTags(input))).toBe(input) + }) + }) +}) + +// ── markdown formatting preservation ────────────────────────────────────────── + +describe('markdown formatting preservation', () => { + it('should preserve heading syntax', () => { + const input = '## My Task\nDo something useful' + expect(escapeXmlTags(input)).toBe(input) + expect(unescapeXmlTags(input)).toBe(input) + }) + + it('should preserve bold and italic syntax', () => { + const input = 'This is **bold** and *italic* and ***both***' + expect(escapeXmlTags(input)).toBe(input) + expect(unescapeXmlTags(input)).toBe(input) + }) + + it('should preserve code blocks', () => { + const input = '```python\nprint("hello")\n```' + expect(escapeXmlTags(input)).toBe(input) + expect(unescapeXmlTags(input)).toBe(input) + }) + + it('should preserve markdown links', () => { + const input = 'See [the docs](https://example.com/path?q=1) for details' + expect(escapeXmlTags(input)).toBe(input) + expect(unescapeXmlTags(input)).toBe(input) + }) + + it('should roundtrip HTML anchor links', () => { + const input = 'Visit Flowise for docs' + expect(unescapeXmlTags(escapeXmlTags(input))).toBe(input) + }) +}) + +// ── full editor save/reload cycle simulation ─────────────────────────────────── + +describe('full editor save/reload cycle simulation', () => { + /** + * Simulates the load → display → save cycle: + * LOAD: escapeXmlTags(value) → setContent(markdown) → unescapeXmlEntities(json) → setContent(json) + * SAVE: getMarkdown() → unescapeXmlTags(markdown) + */ + function simulateEditorCycle(userInput: string): string { + // LOAD — Step 1: escape tags so marked treats them as text + const escaped = escapeXmlTags(userInput) + // LOAD — Step 2: marked creates text nodes with entities; unescapeXmlEntities fixes them + const mockJson = { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: escaped }] }] + } + unescapeXmlEntities(mockJson) + // Verify editor displays raw angle brackets (no entities remain) + const displayText = mockJson.content[0].content[0].text + expect(displayText).not.toMatch(/<|>/) + // SAVE — getMarkdown() outputs text as-is; unescapeXmlTags is the safety net + return unescapeXmlTags(displayText) + } + + it('should preserve XML-tagged prompt', () => { + expect(simulateEditorCycle('What is the answer?')).toBe('What is the answer?') + }) + + it('should preserve tags with attributes', () => { + expect(simulateEditorCycle('You are helpful')).toBe( + 'You are helpful' + ) + }) + + it('should preserve multiline structured prompt', () => { + expect(simulateEditorCycle('\n- Step 1\n- Step 2\n')).toBe( + '\n- Step 1\n- Step 2\n' + ) + }) + + it('should preserve XML tags mixed with markdown and variables', () => { + expect(simulateEditorCycle('# Title\n{{input}}\n**bold** text')).toBe( + '# Title\n{{input}}\n**bold** text' + ) + }) + + it('should leave plain markdown unchanged', () => { + expect(simulateEditorCycle('No tags here, just **markdown**')).toBe('No tags here, just **markdown**') + }) +}) + +// ── restoreTextMentions ──────────────────────────────────────────────────────── + +describe('restoreTextMentions', () => { + describe('hasMentions: false — XML unescape only', () => { + it('unescapes XML entities in a text node', () => { + const node: JsonNode = { type: 'text', text: '<instructions>hello</instructions>' } + const result = restoreTextMentions(node, false) + expect(result).toEqual([{ type: 'text', text: 'hello' }]) + }) + + it('returns plain text unchanged', () => { + const node: JsonNode = { type: 'text', text: 'hello world' } + expect(restoreTextMentions(node, false)).toEqual([{ type: 'text', text: 'hello world' }]) + }) + + it('does not split {{variable}} patterns when hasMentions is false', () => { + const node: JsonNode = { type: 'text', text: 'Hello {{question}}' } + expect(restoreTextMentions(node, false)).toEqual([{ type: 'text', text: 'Hello {{question}}' }]) + }) + }) + + describe('hasMentions: true — splits {{variable}} into mention nodes', () => { + it('converts a lone {{variable}} into a mention node', () => { + const node: JsonNode = { type: 'text', text: '{{question}}' } + const result = restoreTextMentions(node, true) + expect(result).toEqual([{ type: 'mention', attrs: { id: 'question', label: 'question' } }]) + }) + + it('splits text before a {{variable}} into a text node + mention', () => { + const node: JsonNode = { type: 'text', text: 'Hello {{name}}' } + const result = restoreTextMentions(node, true) + expect(result).toEqual([ + { type: 'text', text: 'Hello ' }, + { type: 'mention', attrs: { id: 'name', label: 'name' } } + ]) + }) + + it('splits text after a {{variable}} into mention + text node', () => { + const node: JsonNode = { type: 'text', text: '{{name}} is here' } + const result = restoreTextMentions(node, true) + expect(result).toEqual([ + { type: 'mention', attrs: { id: 'name', label: 'name' } }, + { type: 'text', text: ' is here' } + ]) + }) + + it('handles multiple {{variables}} in one text node', () => { + const node: JsonNode = { type: 'text', text: '{{a}} and {{b}}' } + const result = restoreTextMentions(node, true) + expect(result).toEqual([ + { type: 'mention', attrs: { id: 'a', label: 'a' } }, + { type: 'text', text: ' and ' }, + { type: 'mention', attrs: { id: 'b', label: 'b' } } + ]) + }) + + it('trims whitespace inside {{ variable }}', () => { + const node: JsonNode = { type: 'text', text: '{{ question }}' } + const result = restoreTextMentions(node, true) + expect(result).toEqual([{ type: 'mention', attrs: { id: 'question', label: 'question' } }]) + }) + + it('restores mention inside a URL text node', () => { + // This is the core bug fix — MarkedJS URL tokenizer swallows {{var}} inside URLs + const node: JsonNode = { type: 'text', text: 'https://example.com/{{question}}' } + const result = restoreTextMentions(node, true) + expect(result).toEqual([ + { type: 'text', text: 'https://example.com/' }, + { type: 'mention', attrs: { id: 'question', label: 'question' } } + ]) + }) + + it('unescapes XML entities AND restores mentions together', () => { + const node: JsonNode = { type: 'text', text: '<context>{{question}}</context>' } + const result = restoreTextMentions(node, true) + expect(result).toEqual([ + { type: 'text', text: '' }, + { type: 'mention', attrs: { id: 'question', label: 'question' } }, + { type: 'text', text: '' } + ]) + }) + }) + + describe('recursive tree walking', () => { + it('walks content arrays and processes nested text nodes', () => { + const doc: JsonNode = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Visit https://example.com/{{question}}' }] + } + ] + } + const [result] = restoreTextMentions(doc, true) + const para = (result.content as JsonNode[])[0] + expect(para.content).toEqual([ + { type: 'text', text: 'Visit https://example.com/' }, + { type: 'mention', attrs: { id: 'question', label: 'question' } } + ]) + }) + + it('passes through non-text, non-content nodes unchanged', () => { + const node: JsonNode = { type: 'hardBreak' } + expect(restoreTextMentions(node, true)).toEqual([{ type: 'hardBreak' }]) + }) + }) +}) diff --git a/packages/agentflow/src/atoms/utils/xmlTagUtils.ts b/packages/agentflow/src/atoms/utils/xmlTagUtils.ts new file mode 100644 index 00000000000..ef01253b415 --- /dev/null +++ b/packages/agentflow/src/atoms/utils/xmlTagUtils.ts @@ -0,0 +1,173 @@ +/** + * Utilities for preserving XML/HTML tags in prompt text through TipTap's markdown round-trip, + * plus editor helpers for content-type detection and markdown serialisation. + * + * Problem: When content like `text` is parsed by marked + * (via @tiptap/markdown), the lexer tokenises tags as HTML tokens. TipTap's parseHTMLToken + * then calls generateJSON which creates DOM elements — unrecognised tags are stripped and + * only inner text survives. + * + * Solution: Three-step process: + * 1. escapeXmlTags — Convert all tags to HTML entities before setContent so marked + * treats them as plain text, not HTML tokens. + * 2. unescapeXmlEntities — After TipTap builds the ProseMirror document, walk the JSON tree + * and decode </> back to in text nodes for proper display. + * 3. unescapeXmlTags — After getMarkdown(), reverse any remaining entity-escaped tags + * in the serialised output (safety net — typically a no-op). + * + * Ported from packages/ui/src/utils/xmlTagUtils.js (PR #6095). + */ + +// ── Content-type detection ───────────────────────────────────────────────────── + +/** + * Returns true when `content` looks like legacy HTML (saved by the old getHTML() + * serialiser), so callers can choose the right TipTap `setContent` contentType. + * + * The regex is anchored with `^\s*` so a user prompt that merely *contains* a + * standard HTML tag (e.g. `
      `) is not + * misclassified as legacy HTML. + * + * Ported from packages/ui/src/ui-component/input/RichInput.jsx (PR #6021). + */ +export function isHtmlContent(content: unknown): boolean { + if (!content || typeof content !== 'string') return false + return /^\s*<(?:p|div|span|h[1-6]|ul|ol|li|br|code|pre|blockquote|table|strong|em)\b/i.test(content) +} + +// ── Markdown serialisation ───────────────────────────────────────────────────── + +/** + * Safely serialise a TipTap editor to markdown, falling back to HTML when + * `getMarkdown()` returns an empty string for a non-empty document — a known + * issue in `@tiptap/markdown` v3 where the MarkdownManager may silently fail + * to serialise certain node types. + */ +export function getEditorMarkdown(editor: { getMarkdown(): string; getHTML(): string; isEmpty: boolean }): string { + try { + const markdown = editor.getMarkdown() + if (markdown) return markdown + if (editor.isEmpty) return '' + return editor.getHTML() + } catch { + return editor.getHTML() + } +} + +// ── XML tag escape / unescape ────────────────────────────────────────────────── + +/** Regex matching opening, closing, and self-closing XML/HTML tags. */ +const XML_TAG_REGEX = /<(\/?)([a-zA-Z][a-zA-Z0-9_.-]*)(\s[^>]*)?(\/?)>/g + +/** + * Escape all XML/HTML tags to HTML entities so marked doesn't parse them as HTML. + * In prompt editing context, users want tags preserved literally, not rendered. + * + * @example + * escapeXmlTags('Be helpful') + * // → '<instructions>Be helpful</instructions>' + */ +export function escapeXmlTags(text: string): string { + if (!text || typeof text !== 'string') return text + return text.replace(XML_TAG_REGEX, (_, slash, tagName, attrs, selfClose) => { + return `<${slash}${tagName}${attrs ?? ''}${selfClose}>` + }) +} + +/** Minimal interface for walking a ProseMirror JSON tree. */ +interface PmNode { + text?: string + content?: PmNode[] + [key: string]: unknown +} + +/** + * ProseMirror JSON node type used when mention nodes (with `attrs`) may be present. + * Superset of PmNode — the index signature covers any additional fields. + */ +export type JsonNode = { type?: string; text?: string; content?: JsonNode[]; attrs?: Record; [k: string]: unknown } + +/** + * Unescape XML tag entities in ProseMirror JSON text nodes. + * Call this after setContent() to fix the visual display in the editor. + * Mutates the JSON in-place and returns it. + * + * @example + * const json = { type: 'doc', content: [ + * { type: 'paragraph', content: [{ type: 'text', text: '<question>What?</question>' }] } + * ]} + * unescapeXmlEntities(json) + * // json.content[0].content[0].text → 'What?' + */ +export function unescapeXmlEntities(json: PmNode): PmNode { + if (json.text) { + json.text = unescapeXmlTags(json.text) + } + if (json.content) { + json.content.forEach(unescapeXmlEntities) + } + return json +} + +/** + * Unescape all entity-escaped XML/HTML tags after markdown serialisation. + * Typically a no-op — used as a safety net for any entities TipTap did not + * convert back to angle brackets during getMarkdown(). + * + * @example + * unescapeXmlTags('<question>text</question>') + * // → 'text' + * + * unescapeXmlTags('text') + * // → 'text' (raw tags pass through unchanged) + */ +export function unescapeXmlTags(text: string): string { + if (!text || typeof text !== 'string') return text + return text.replace(/<(\/?)([a-zA-Z][a-zA-Z0-9_.-]*)(\s.*?)?(\/?)>/g, (_, slash, tagName, attrs, selfClose) => { + return `<${slash}${tagName}${attrs ?? ''}${selfClose}>` + }) +} + +// ── Mention restoration ──────────────────────────────────────────────────────── + +/** + * Post-process the ProseMirror JSON after markdown setContent: + * 1. Unescape XML tag entities in text nodes. + * 2. When `hasMentions` is true, split any remaining `{{label}}` text patterns + * into proper mention nodes. This is needed because MarkedJS's URL tokenizer + * can swallow `{{variable}}` when it appears inside a URL + * (e.g. `https://example.com/{{question}}`), preventing the mention tokenizer + * from running. Walking the resulting JSON and splitting inline fixes the chip. + * + * Returns an array because one text node may expand into [text, mention, text, …]. + */ +export function restoreTextMentions(node: JsonNode, hasMentions: boolean): JsonNode[] { + if (node.type === 'text' && typeof node.text === 'string') { + const unescaped = unescapeXmlTags(node.text) + if (!hasMentions) return [{ ...node, text: unescaped }] + + const regex = /\{\{([^{}]+)\}\}/g + const parts: JsonNode[] = [] + let lastIndex = 0 + let match: RegExpExecArray | null + + while ((match = regex.exec(unescaped)) !== null) { + if (match.index > lastIndex) parts.push({ ...node, text: unescaped.slice(lastIndex, match.index) }) + const label = match[1].trim() + parts.push({ type: 'mention', attrs: { id: label, label } }) + lastIndex = regex.lastIndex + } + + if (parts.length === 0) return [{ ...node, text: unescaped }] + if (lastIndex < unescaped.length) parts.push({ ...node, text: unescaped.slice(lastIndex) }) + return parts + } + + if (Array.isArray(node.content)) { + const newContent: JsonNode[] = [] + for (const child of node.content) newContent.push(...restoreTextMentions(child, hasMentions)) + return [{ ...node, content: newContent }] + } + + return [node] +}