Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,12 @@ export default defineConfig([
]
}
},
{
files: ['src/components/searchbox/**/*.vue'],
rules: {
'vue/no-v-html': 'error'
}
},
// Browser tests must use comfyPageFixture, not raw @playwright/test test
{
files: ['browser_tests/tests/**/*.spec.ts'],
Expand Down
3 changes: 1 addition & 2 deletions packages/shared-frontend-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"axios": "catalog:",
"dompurify": "catalog:"
"axios": "catalog:"
},
"devDependencies": {
"typescript": "catalog:"
Expand Down
74 changes: 43 additions & 31 deletions packages/shared-frontend-utils/src/formatUtil.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,62 +200,74 @@ describe('formatUtil', () => {
})

describe('highlightQuery', () => {
it('should return text unchanged when query is empty', () => {
expect(highlightQuery('Hello World', '')).toBe('Hello World')
it('should return one plain-text part when query is empty', () => {
expect(highlightQuery('Hello World', '')).toEqual([
{ text: 'Hello World', highlighted: false }
])
})

it('should wrap matching text in highlight span', () => {
const result = highlightQuery('Hello World', 'World')
expect(result).toBe('Hello <span class="highlight">World</span>')
it('should mark matching text for highlighting', () => {
expect(highlightQuery('Hello World', 'World')).toEqual([
{ text: 'Hello ', highlighted: false },
{ text: 'World', highlighted: true }
])
})

it('should be case-insensitive', () => {
const result = highlightQuery('Hello World', 'hello')
expect(result).toBe('<span class="highlight">Hello</span> World')
})

it('should sanitize text by default', () => {
const result = highlightQuery('<script>alert("xss")</script>', 'alert')
expect(result).not.toContain('<script>')
expect(highlightQuery('Hello World', 'hello')).toEqual([
{ text: 'Hello', highlighted: true },
{ text: ' World', highlighted: false }
])
})

it('should skip sanitization when sanitize is false', () => {
const result = highlightQuery('<b>bold</b>', 'bold', false)
expect(result).toContain('<b>')
it('should preserve markup as text parts', () => {
expect(highlightQuery('<script>alert("xss")</script>', 'alert')).toEqual([
{ text: '<script>', highlighted: false },
{ text: 'alert', highlighted: true },
{ text: '("xss")</script>', highlighted: false }
])
})

it('should escape special regex characters in query', () => {
const result = highlightQuery('price is $10.00', '$10')
expect(result).toContain('<span class="highlight">$10</span>')
expect(highlightQuery('price is $10.00', '$10')).toEqual([
{ text: 'price is ', highlighted: false },
{ text: '$10', highlighted: true },
{ text: '.00', highlighted: false }
])
})

it('should highlight multiple occurrences', () => {
const result = highlightQuery('foo bar foo', 'foo')
expect(result).toBe(
'<span class="highlight">foo</span> bar <span class="highlight">foo</span>'
)
expect(highlightQuery('foo bar foo', 'foo')).toEqual([
{ text: 'foo', highlighted: true },
{ text: ' bar ', highlighted: false },
{ text: 'foo', highlighted: true }
])
})

it('should highlight cross-word matches', () => {
const result = highlightQuery('convert image to mask', 'geto', false)
expect(result).toBe(
'convert ima<span class="highlight">ge to</span> mask'
)
expect(highlightQuery('convert image to mask', 'geto')).toEqual([
{ text: 'convert ima', highlighted: false },
{ text: 'ge to', highlighted: true },
{ text: ' mask', highlighted: false }
])
})

it('should not match across line breaks', () => {
const result = highlightQuery('ge\nto', 'geto', false)
expect(result).toBe('ge\nto')
expect(highlightQuery('ge\nto', 'geto')).toEqual([
{ text: 'ge\nto', highlighted: false }
])
})

it('should not match across tabs', () => {
const result = highlightQuery('ge\tto', 'geto', false)
expect(result).toBe('ge\tto')
expect(highlightQuery('ge\tto', 'geto')).toEqual([
{ text: 'ge\tto', highlighted: false }
])
})

it('should not match across multiple spaces', () => {
const result = highlightQuery('ge to', 'geto', false)
expect(result).toBe('ge to')
expect(highlightQuery('ge to', 'geto')).toEqual([
{ text: 'ge to', highlighted: false }
])
})
})

Expand Down
38 changes: 28 additions & 10 deletions packages/shared-frontend-utils/src/formatUtil.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { default as DOMPurify } from 'dompurify'
import type { operations } from '@comfyorg/registry-types'

export function formatCamelCase(str: string): string {
Expand Down Expand Up @@ -64,15 +63,16 @@ export function ensureWorkflowSuffix(
return name + '.' + suffix
}

interface HighlightQueryPart {
text: string
highlighted: boolean
}

export function highlightQuery(
text: string,
query: string,
sanitize: boolean = true
) {
if (!query) return text
if (sanitize) {
text = DOMPurify.sanitize(text)
}
query: string
): HighlightQueryPart[] {
if (!query) return [{ text, highlighted: false }]

// Escape special regex characters, then join with an optional single
// space so cross-word matches (e.g. "geto" → "imaGE TO") are
Expand All @@ -81,8 +81,26 @@ export function highlightQuery(
.map((ch) => ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('[ ]?')

const regex = new RegExp(`(${pattern})`, 'gi')
return text.replace(regex, '<span class="highlight">$1</span>')
const regex = new RegExp(pattern, 'gi')
const parts: HighlightQueryPart[] = []
let lastIndex = 0

for (const match of text.matchAll(regex)) {
if (match.index > lastIndex) {
parts.push({
text: text.slice(lastIndex, match.index),
highlighted: false
})
}
parts.push({ text: match[0], highlighted: true })
lastIndex = match.index + match[0].length
}

if (lastIndex < text.length || parts.length === 0) {
parts.push({ text: text.slice(lastIndex), highlighted: false })
}

return parts
}

export function formatNumberWithSuffix(
Expand Down
3 changes: 0 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions src/components/searchbox/HighlightedText.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<template>
<template v-for="(part, index) in highlightQuery(text, query)" :key="index">
<span v-if="part.highlighted" class="highlight">{{ part.text }}</span>
<template v-else>{{ part.text }}</template>
</template>
</template>

<script setup lang="ts">
import { highlightQuery } from '@/utils/formatUtil'

const { text, query } = defineProps<{
text: string
query: string
}>()
</script>
11 changes: 8 additions & 3 deletions src/components/searchbox/NodeSearchItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@
<span v-if="isBookmarked">
<i class="pi pi-bookmark-fill mr-1 text-sm" />
</span>
<span v-html="highlightQuery(nodeDef.display_name, currentQuery)" />
<span>
<HighlightedText :text="nodeDef.display_name" :query="currentQuery" />
</span>
<span>&nbsp;</span>
<Tag v-if="showIdName" severity="secondary">
<span v-html="highlightQuery(nodeDef.name, currentQuery)" />
<span>
<HighlightedText :text="nodeDef.name" :query="currentQuery" />
</span>
</Tag>
</div>
<div
Expand Down Expand Up @@ -52,12 +56,13 @@ import Chip from 'primevue/chip'
import Tag from 'primevue/tag'
import { computed } from 'vue'

import HighlightedText from '@/components/searchbox/HighlightedText.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeFrequencyStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import { formatNumberWithSuffix, highlightQuery } from '@/utils/formatUtil'
import { formatNumberWithSuffix } from '@/utils/formatUtil'

const settingStore = useSettingStore()
const showCategory = computed(() =>
Expand Down
10 changes: 10 additions & 0 deletions src/components/searchbox/v2/NodeSearchListItem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ describe('NodeSearchListItem', () => {
vi.restoreAllMocks()
})

it('renders node names as text rather than HTML', () => {
const displayName = '<img src=x onerror=alert(1)>Node'
renderItem({
nodeDef: createMockNodeDef({ display_name: displayName })
})

expect(screen.queryByRole('img')).not.toBeInTheDocument()
expect(screen.getByText(displayName)).toBeInTheDocument()
})

describe('id name badge', () => {
it('shows id name when ShowIdName setting is enabled', () => {
useSettingStore().settingValues['Comfy.NodeSearchBoxImpl.ShowIdName'] =
Expand Down
15 changes: 8 additions & 7 deletions src/components/searchbox/v2/NodeSearchListItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,16 @@
>
<i aria-hidden="true" class="pi pi-bookmark-fill mr-1 text-sm" />
</span>
<span
class="truncate"
v-html="highlightQuery(nodeDef.display_name, currentQuery)"
/>
<span class="truncate">
<HighlightedText :text="nodeDef.display_name" :query="currentQuery" />
</span>
<span
v-if="showIdName"
data-testid="node-id-badge"
class="shrink-0 rounded-sm bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
v-html="highlightQuery(nodeDef.name, currentQuery)"
/>
>
<HighlightedText :text="nodeDef.name" :query="currentQuery" />
</span>

<template v-if="showDescription">
<div class="flex-1" />
Expand Down Expand Up @@ -124,14 +124,15 @@ import { computed } from 'vue'
import TextTicker from '@/components/common/TextTicker.vue'
import NodePricingBadge from '@/components/node/NodePricingBadge.vue'
import NodeProviderBadge from '@/components/node/NodeProviderBadge.vue'
import HighlightedText from '@/components/searchbox/HighlightedText.vue'
import { NODE_TO_ESSENTIALS_CATEGORY } from '@/constants/essentialsNodes'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeFrequencyStore } from '@/stores/nodeDefStore'
import { CORE_NODE_MODULES, NodeSourceType } from '@/types/nodeSource'
import { getProviderIcon, getProviderName } from '@/utils/categoryUtil'
import { formatNumberWithSuffix, highlightQuery } from '@/utils/formatUtil'
import { formatNumberWithSuffix } from '@/utils/formatUtil'
import { cn } from '@comfyorg/tailwind-utils'

const {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@
<span class="underline">
{{ t('subscription.videoEstimateTryTemplate') }}
</span>
<span class="no-underline" v-html="'&rarr;'"></span>
<span class="no-underline">→</span>
</a>
</div>
</Popover>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@
<span class="underline">
{{ t('subscription.videoEstimateTryTemplate') }}
</span>
<span class="no-underline" v-html="'&rarr;'"></span>
<span class="no-underline">→</span>
</a>
</div>
</Popover>
Expand Down
Loading