From f252a39197b43cde047930584e8b1895722aeb42 Mon Sep 17 00:00:00 2001 From: Suguru Inoue Date: Wed, 9 Jul 2025 19:29:16 +0200 Subject: [PATCH] fix(editor): Make search work for "rendered" display type (#16910) --- .../editor-ui/src/components/RunData.vue | 7 +- .../src/components/RunDataAi/utils.ts | 53 +++++++++ .../components/RunDataParsedAiContent.test.ts | 102 ++++++++++++++++++ .../src/components/RunDataParsedAiContent.vue | 21 +++- .../src/components/TextWithHighlights.vue | 27 +---- .../__snapshots__/VirtualSchema.test.ts.snap | 12 --- .../editor-ui/src/utils/stringUtils.test.ts | 35 ++++++ .../editor-ui/src/utils/stringUtils.ts | 23 ++++ 8 files changed, 240 insertions(+), 40 deletions(-) create mode 100644 packages/frontend/editor-ui/src/utils/stringUtils.test.ts create mode 100644 packages/frontend/editor-ui/src/utils/stringUtils.ts diff --git a/packages/frontend/editor-ui/src/components/RunData.vue b/packages/frontend/editor-ui/src/components/RunData.vue index d09679ae9d..c5fb8d3a70 100644 --- a/packages/frontend/editor-ui/src/components/RunData.vue +++ b/packages/frontend/editor-ui/src/components/RunData.vue @@ -1885,7 +1885,12 @@ defineExpose({ enterEditMode }); - + diff --git a/packages/frontend/editor-ui/src/components/RunDataAi/utils.ts b/packages/frontend/editor-ui/src/components/RunDataAi/utils.ts index 7c9af91ef4..45e8c81982 100644 --- a/packages/frontend/editor-ui/src/components/RunDataAi/utils.ts +++ b/packages/frontend/editor-ui/src/components/RunDataAi/utils.ts @@ -7,6 +7,10 @@ import { type NodeConnectionType, type Workflow, } from 'n8n-workflow'; +import { splitTextBySearch } from '@/utils/stringUtils'; +import { escapeHtml } from 'xss'; +import type MarkdownIt from 'markdown-it'; +import { unescapeAll } from 'markdown-it/lib/common/utils'; export interface AIResult { node: string; @@ -198,3 +202,52 @@ export function getConsumedTokens(outputRun: IAiDataContent | undefined): LlmTok return tokenUsage; } + +export function createHtmlFragmentWithSearchHighlight( + text: string, + search: string | undefined, +): string { + const escaped = escapeHtml(text); + + return search + ? splitTextBySearch(escaped, search) + .map((part) => (part.isMatched ? `${part.content}` : part.content)) + .join('') + : escaped; +} + +export function createSearchHighlightPlugin(search: string | undefined) { + return (md: MarkdownIt) => { + md.renderer.rules.text = (tokens, idx) => + createHtmlFragmentWithSearchHighlight(tokens[idx].content, search); + + md.renderer.rules.code_inline = (tokens, idx, _, __, slf) => + `${createHtmlFragmentWithSearchHighlight(tokens[idx].content, search)}`; + + md.renderer.rules.code_block = (tokens, idx, _, __, slf) => + `${createHtmlFragmentWithSearchHighlight(tokens[idx].content, search)}\n`; + + md.renderer.rules.fence = (tokens, idx, options, _, slf) => { + const token = tokens[idx]; + const info = token.info ? unescapeAll(token.info).trim() : ''; + let langName = ''; + let langAttrs = ''; + + if (info) { + const arr = info.split(/(\s+)/g); + langName = arr[0]; + langAttrs = arr.slice(2).join(''); + } + + const highlighted = + options.highlight?.(token.content, langName, langAttrs) ?? + createHtmlFragmentWithSearchHighlight(token.content, search); + + if (highlighted.indexOf('${highlighted}\n`; + }; + }; +} diff --git a/packages/frontend/editor-ui/src/components/RunDataParsedAiContent.test.ts b/packages/frontend/editor-ui/src/components/RunDataParsedAiContent.test.ts index b29bc8e836..d6ca6da50f 100644 --- a/packages/frontend/editor-ui/src/components/RunDataParsedAiContent.test.ts +++ b/packages/frontend/editor-ui/src/components/RunDataParsedAiContent.test.ts @@ -46,6 +46,29 @@ describe('RunDataParsedAiContent', () => { expect(rendered.getByText('markdown', { selector: 'em' })).toBeInTheDocument(); }); + it('highlight matches to the search keyword in markdown', () => { + const rendered = renderComponent({ + renderType: 'rendered', + content: [ + { + raw: {}, + parsedContent: { + type: 'markdown', + data: 'The **quick** brown fox jumps over the ~~lazy~~ dog', + parsed: true, + }, + }, + ], + search: 'the', + }); + + const marks = rendered.container.querySelectorAll('mark'); + + expect(marks).toHaveLength(2); + expect(marks[0]).toHaveTextContent('The'); + expect(marks[1]).toHaveTextContent('the'); + }); + it('renders AI content parsed as JSON', () => { const rendered = renderComponent({ renderType: 'rendered', @@ -71,4 +94,83 @@ describe('RunDataParsedAiContent', () => { rendered.getByText('{ "key": "value" }', { selector: 'code', exact: false }), ).toBeInTheDocument(); }); + + it('highlight matches to the search keyword in inline code in markdown', () => { + const rendered = renderComponent({ + renderType: 'rendered', + content: [ + { + raw: {}, + parsedContent: { + type: 'markdown', + data: 'The `quick brown fox` jumps over the lazy dog', + parsed: true, + }, + }, + ], + search: 'fox', + }); + + const marks = rendered.container.querySelectorAll('mark'); + + expect(marks).toHaveLength(1); + expect(marks[0]).toHaveTextContent('fox'); + }); + + it('highlight matches to the search keyword in a code block in markdown', () => { + const rendered = renderComponent({ + renderType: 'rendered', + content: [ + { + raw: {}, + parsedContent: { + type: 'markdown', + data: 'Code:\n\n quickFox.jump({ over: lazyDog });\n', + parsed: true, + }, + }, + ], + search: 'fox', + }); + + const marks = rendered.container.querySelectorAll('mark'); + + expect(marks).toHaveLength(1); + expect(marks[0]).toHaveTextContent('Fox'); + }); + + it('highlight matches to the search keyword in fence syntax in markdown', () => { + const rendered = renderComponent({ + renderType: 'rendered', + content: [ + { + raw: {}, + parsedContent: { + type: 'markdown', + data: '```\nquickFox.jump({ over: lazyDog });\n```', + parsed: true, + }, + }, + ], + search: 'fox', + }); + + const marks = rendered.container.querySelectorAll('mark'); + + expect(marks).toHaveLength(1); + expect(marks[0]).toHaveTextContent('Fox'); + }); + + it('highlight matches to the search keyword in raw data', () => { + const rendered = renderComponent({ + renderType: 'rendered', + content: [{ raw: { key: 'value' }, parsedContent: null }], + search: 'key', + }); + + const marks = rendered.container.querySelectorAll('mark'); + + expect(marks).toHaveLength(1); + expect(marks[0]).toHaveTextContent('key'); + }); }); diff --git a/packages/frontend/editor-ui/src/components/RunDataParsedAiContent.vue b/packages/frontend/editor-ui/src/components/RunDataParsedAiContent.vue index d187922474..83fef2a825 100644 --- a/packages/frontend/editor-ui/src/components/RunDataParsedAiContent.vue +++ b/packages/frontend/editor-ui/src/components/RunDataParsedAiContent.vue @@ -7,14 +7,18 @@ import { N8nIconButton } from '@n8n/design-system'; import { type IDataObject } from 'n8n-workflow'; import VueMarkdown from 'vue-markdown-render'; import hljs from 'highlight.js/lib/core'; +import { computed } from 'vue'; +import { createSearchHighlightPlugin } from '@/components/RunDataAi/utils'; const { content, compact = false, renderType, + search, } = defineProps<{ content: ParsedAiContent; compact?: boolean; + search?: string; renderType: 'rendered' | 'json'; }>(); @@ -22,6 +26,8 @@ const i18n = useI18n(); const clipboard = useClipboard(); const { showMessage } = useToast(); +const vueMarkdownPlugins = computed(() => [createSearchHighlightPlugin(search)]); + function isJsonString(text: string) { try { JSON.parse(text); @@ -39,7 +45,7 @@ const markdownOptions = { } catch {} } - return ''; // use external default escaping + return undefined; // use external default escaping }, }; @@ -108,17 +114,20 @@ function onCopyToClipboard(object: IDataObject | IDataObject[]) { :source="jsonToMarkdown(parsedContent.data as JsonMarkdown)" :class="$style.markdown" :options="markdownOptions" + :plugins="vueMarkdownPlugins" /> -

@@ -131,7 +140,11 @@ function onCopyToClipboard(object: IDataObject | IDataObject[]) { icon="files" @click="onCopyToClipboard(raw)" /> - + diff --git a/packages/frontend/editor-ui/src/components/TextWithHighlights.vue b/packages/frontend/editor-ui/src/components/TextWithHighlights.vue index 1d6164ef3d..fa6fcfc96b 100644 --- a/packages/frontend/editor-ui/src/components/TextWithHighlights.vue +++ b/packages/frontend/editor-ui/src/components/TextWithHighlights.vue @@ -1,4 +1,5 @@