diff --git a/app/assets/javascripts/search/results/components/blob_chunks.vue b/app/assets/javascripts/search/results/components/blob_chunks.vue index 719dd1ce216a413f43056d9df5faaaac911352a0..4256ca41e362421147777f97e741e1061aa40028 100644 --- a/app/assets/javascripts/search/results/components/blob_chunks.vue +++ b/app/assets/javascripts/search/results/components/blob_chunks.vue @@ -3,7 +3,7 @@ import { GlTooltipDirective, GlIcon, GlLink } from '@gitlab/ui'; import GlSafeHtmlDirective from '~/vue_shared/directives/safe_html'; import { s__ } from '~/locale'; import { InternalEvents } from '~/tracking'; -import { initLineHighlight } from '~/search/results/utils'; +import { initLineHighlight, isUnsupportedLanguage } from '~/search/results/utils'; import { EVENT_CLICK_BLOB_RESULT_LINE, EVENT_CLICK_BLOB_RESULT_BLAME_LINE, @@ -64,9 +64,7 @@ export default { }, }, mounted() { - this.chunk.lines.forEach(async (line, index) => { - this.lines[index].richText = await this.codeHighlighting(line); - }); + this.chunk.lines.forEach(this.processLine); }, methods: { codeHighlighting(line) { @@ -74,8 +72,16 @@ export default { line, fileUrl: this.fileUrl, language: this.language.toLowerCase(), + that: this, }); }, + async processLine(line, index) { + if (isUnsupportedLanguage(this.language.toLowerCase())) { + this.lines[index].text = await this.codeHighlighting(line); + return; + } + this.lines[index].richText = await this.codeHighlighting(line); + }, trackLineClick(lineNumber) { this.trackEvent(EVENT_CLICK_BLOB_RESULT_LINE, { property: lineNumber, @@ -136,7 +142,9 @@ export default { class="code highlight gl-flex gl-grow" data-testid="search-blob-line-code-highlighted" > - <code v-safe-html="line.richText" class="gl-leading-normal"> + <code + v-safe-html="line.richText" + class="gl-leading-normal gl-shrink"> </code> </pre> <pre @@ -144,7 +152,7 @@ export default { class="code gl-flex gl-grow" data-testid="search-blob-line-code-non-highlighted" > - <code> + <code class="gl-leading-normal gl-shrink"> <span v-safe-html="line.text" class="line"></span> </code> </pre> diff --git a/app/assets/javascripts/search/results/constants.js b/app/assets/javascripts/search/results/constants.js index 95417ecb8c153bb03f694b46247a2843765ea25a..58c743dc8f6f4b626fdb9c1b28787814d54508a9 100644 --- a/app/assets/javascripts/search/results/constants.js +++ b/app/assets/javascripts/search/results/constants.js @@ -22,3 +22,6 @@ export const HIGHLIGHT_MARK = '​'; export const HIGHLIGHT_MARK_REGEX = '\u200b'; export const HIGHLIGHT_HTML_START = '<b class="hll">'; export const HIGHLIGHT_HTML_END = '</b>'; +export const MAXIMUM_LINE_LENGTH = 3000; +export const ELLIPSIS = '…'; +export const MAX_GAP = 800; diff --git a/app/assets/javascripts/search/results/utils.js b/app/assets/javascripts/search/results/utils.js index 23d0653fba7a346ccfeb0393cd29befb212900d6..5f410b2719c5781ab3bc9c824810e8dbb6552427 100644 --- a/app/assets/javascripts/search/results/utils.js +++ b/app/assets/javascripts/search/results/utils.js @@ -9,8 +9,16 @@ import { HIGHLIGHT_MARK_REGEX, HIGHLIGHT_HTML_START, HIGHLIGHT_HTML_END, + MAXIMUM_LINE_LENGTH, + ELLIPSIS, + MAX_GAP, } from './constants'; +/** + * Checks if a language is unsupported for syntax highlighting + * @param {string} language - The language to check + * @returns {boolean} - True if the language is unsupported + */ export const isUnsupportedLanguage = (language) => { const mappedLanguage = ROUGE_TO_HLJS_LANGUAGE_MAP[language]; const supportedLanguages = Object.keys(languageLoader); @@ -18,10 +26,18 @@ export const isUnsupportedLanguage = (language) => { return LEGACY_FALLBACKS.includes(language) || isUnsupported; }; +/** + * Marks the search terms in a string + * @param {string} str - The string to mark + * @param {Array} highlights - Array of start/end positions for search matches + * @returns {string} - String with search terms marked + */ export const markSearchTerm = (str = '', highlights = []) => { + if (!str || !highlights?.length) return str; + const chars = str.split(''); - [...highlights].reverse().forEach((highligh) => { - const [start, end] = highligh; + [...highlights].reverse().forEach((currentHighlight) => { + const [start, end] = currentHighlight; chars.splice(end + 1, 0, HIGHLIGHT_MARK); chars.splice(start, 0, HIGHLIGHT_MARK); }); @@ -29,26 +45,320 @@ export const markSearchTerm = (str = '', highlights = []) => { return chars.join(''); }; +/** + * Cleans a line of text and marks search terms + * @param {Object} params - Input parameters + * @param {string} params.text - Text to clean + * @param {Array} params.highlights - Highlight positions + * @returns {string} - Cleaned text with search terms marked + */ export const cleanLineAndMark = ({ text, highlights } = {}) => { const parsedText = highlights?.length > 0 ? markSearchTerm(text, highlights) : text; - return parsedText.replace(/\r?\n/, ''); + return parsedText?.replace(/(\r\n|\r|\n)+/g, ''); }; +/** + * Converts invisible markers to HTML highlights + * @param {string} highlightedString - String with invisible markers + * @returns {string} - String with HTML highlights + */ export const highlightSearchTerm = (highlightedString) => { - if (highlightedString.length === 0) { + if (!highlightedString || highlightedString.length === 0) { return ''; } const pattern = new RegExp(`${HIGHLIGHT_MARK_REGEX}(.+?)${HIGHLIGHT_MARK_REGEX}`, 'g'); + return highlightedString.replace(pattern, `${HIGHLIGHT_HTML_START}$1${HIGHLIGHT_HTML_END}`); +}; + +/** + * Sorts highlights by starting position + * @param {Array} highlights - Highlight positions + * @returns {Array} - Sorted highlights + */ +const sortHighlights = (highlights) => { + return [...highlights].sort((a, b) => a[0] - b[0]); +}; + +/** + * Checks if highlights are close enough to be in the same cluster + * @param {Array} prevHighlight - Previous highlight [start, end] + * @param {Array} currentHighlight - Current highlight [start, end] + * @param {number} maxGap - Maximum gap between highlights + * @returns {boolean} - True if highlights are close + */ +const areHighlightsClose = (prevHighlight, currentHighlight, maxGap) => { + return currentHighlight[0] - prevHighlight[1] <= maxGap; +}; + +/** + * Groups highlights into clusters based on proximity + * @param {Array} highlights - Array of highlight positions + * @param {number} maxGap - Maximum gap between highlights + * @returns {Array} - Array of highlight clusters + */ +const findHighlightClusters = (highlights, maxGap = MAX_GAP) => { + if (!highlights?.length) { + return []; + } + + const sortedHighlights = sortHighlights(highlights); + const clusters = []; + let currentCluster = [sortedHighlights[0]]; + + for (let i = 1; i < sortedHighlights.length; i += 1) { + const prevHighlight = currentCluster[currentCluster.length - 1]; + const currentHighlight = sortedHighlights[i]; + + if (areHighlightsClose(prevHighlight, currentHighlight, maxGap)) { + currentCluster.push(currentHighlight); + } else { + clusters.push(currentCluster); + currentCluster = [currentHighlight]; + } + } + + clusters.push(currentCluster); + return clusters; +}; + +/** + * Calculates the total highlighted text length in a cluster + * @param {Array} highlights - Array of highlight positions + * @returns {number} - Total highlighted length + */ +const getTotalHighlightLength = (highlights) => { + return highlights.reduce((sum, [start, end]) => sum + (end - start), 0); +}; + +/** + * Compares two clusters to find the better one + * @param {Array} best - Current best cluster + * @param {Array} current - Cluster to compare + * @returns {Array} - Better cluster + */ +const compareClusters = (best, current) => { + if (current.length > best.length) { + return current; + } + if (current.length === best.length) { + const currentTotal = getTotalHighlightLength(current); + const bestTotal = getTotalHighlightLength(best); + return currentTotal > bestTotal ? current : best; + } + return best; +}; + +/** + * Finds the best cluster of highlights + * @param {Array} clusters - Array of highlight clusters + * @returns {Array} - Best cluster + */ +const findBestCluster = (clusters) => { + return clusters.reduce(compareClusters, clusters[0]); +}; + +/** + * Calculates the center position of a highlight cluster + * @param {Array} cluster - Cluster of highlights + * @returns {number} - Center position + */ +const calculateClusterCenter = (cluster) => { + return cluster.reduce((sum, [start, end]) => sum + (start + end) / 2, 0) / cluster.length; +}; + +/** + * Adjusts truncation boundaries to not break highlights + * @param {number} startPos - Initial start position + * @param {number} endPos - Initial end position + * @param {Array} highlights - Array of highlight positions + * @returns {Object} - Adjusted boundaries + */ +const adjustBoundariesForHighlights = (startPos, endPos, highlights) => { + let adjustedStart = startPos; + let adjustedEnd = endPos; + + highlights.forEach(([start, end]) => { + if (adjustedStart > start && adjustedStart < end) { + adjustedStart = start; + } + if (adjustedEnd > start && adjustedEnd < end) { + adjustedEnd = end; + } + }); + + return { adjustedStart, adjustedEnd }; +}; + +/** + * Determines the optimal starting position for truncation based on highlights + * @param {string} text - The full text + * @param {Array} highlights - Array of highlight positions + * @returns {Object} - Initial start and end positions + */ +const initialPosForHighlights = (text, highlights) => { + const clusters = findHighlightClusters(highlights); + const bestCluster = findBestCluster(clusters); + const clusterCenter = calculateClusterCenter(bestCluster); + + const halfLength = Math.floor(MAXIMUM_LINE_LENGTH / 2); + let initialStartPos; + let initialEndPos; + + if (clusterCenter > text.length - halfLength) { + initialEndPos = text.length; + initialStartPos = Math.max(0, text.length - MAXIMUM_LINE_LENGTH); + } else if (clusterCenter < halfLength) { + initialStartPos = 0; + initialEndPos = Math.min(text.length, MAXIMUM_LINE_LENGTH); + } else { + initialStartPos = Math.max(0, Math.floor(clusterCenter - halfLength)); + initialEndPos = Math.min(text.length, Math.floor(clusterCenter + halfLength)); + } - const result = highlightedString.replace( - pattern, - `${HIGHLIGHT_HTML_START}$1${HIGHLIGHT_HTML_END}`, + return { initialStartPos, initialEndPos }; +}; + +/** + * Determines the optimal text region to keep based on highlights + * @param {string} text - Original text + * @param {Array} highlights - Array of highlight positions + * @returns {Object} - Boundaries and flags for truncation + */ +const determineOptimalTextRegion = (text, highlights) => { + if (!text || text.length <= MAXIMUM_LINE_LENGTH) { + return { + startPos: 0, + endPos: text.length, + addLeadingEllipsis: false, + addTrailingEllipsis: false, + }; + } + + const hasHighlightsNearEnd = highlights?.some(([, end]) => { + return end >= text.length - MAXIMUM_LINE_LENGTH; + }); + + if (!hasHighlightsNearEnd && (!highlights || highlights.length <= 2)) { + return { + startPos: 0, + endPos: MAXIMUM_LINE_LENGTH, + addLeadingEllipsis: false, + addTrailingEllipsis: true, + }; + } + + const { initialStartPos, initialEndPos } = initialPosForHighlights(text, highlights); + const { adjustedStart, adjustedEnd } = adjustBoundariesForHighlights( + initialStartPos, + initialEndPos, + highlights, ); - return result; + return { + startPos: adjustedStart, + endPos: adjustedEnd, + addLeadingEllipsis: adjustedStart > 0, + addTrailingEllipsis: adjustedEnd < text.length, + }; }; +/** + * * Truncates HTML content while preserving HTML structure using DOMParser + * @param {string} html - HTML content to truncate + * @param {string} originalText - Original text before highlighting + * @param {Array} highlights - Array of highlight positions + * @returns {string} - Truncated HTML content + */ +export const truncateHtml = (html, originalText, highlights) => { + if (!html || !html.trim() || html.length <= MAXIMUM_LINE_LENGTH) return html; + + const { startPos, endPos, addLeadingEllipsis, addTrailingEllipsis } = determineOptimalTextRegion( + originalText, + highlights, + ); + + const parser = new DOMParser(); + const doc = parser.parseFromString(`<div>${html}</div>`, 'text/html'); + const container = doc.body.firstChild; + + const textNodes = []; + function collectTextNodes(node) { + if (node.nodeType === Node.TEXT_NODE) { + textNodes.push(node); + } else { + Array.from(node.childNodes).forEach((child) => collectTextNodes(child)); + } + } + collectTextNodes(container); + + let textLength = 0; + let hasStartedTruncating = false; + let hasFinishedTruncating = false; + + for (let i = 0; i < textNodes.length; i += 1) { + const node = textNodes[i]; + const text = node.textContent; + + if (!hasStartedTruncating && textLength < startPos) { + if (textLength + text.length > startPos) { + const offset = startPos - textLength; + node.textContent = (addLeadingEllipsis ? ELLIPSIS : '') + text.substring(offset); + hasStartedTruncating = true; + } else { + node.textContent = ''; + } + textLength += text.length; + // eslint-disable-next-line no-continue + continue; + } + + hasStartedTruncating = true; + + if (textLength + text.length > endPos) { + const remainingLength = endPos - textLength; + node.textContent = text.substring(0, remainingLength) + (addTrailingEllipsis ? ELLIPSIS : ''); + hasFinishedTruncating = true; + + for (let j = i + 1; j < textNodes.length; j += 1) { + textNodes[j].textContent = ''; + } + break; + } + + textLength += text.length; + } + + if (!hasFinishedTruncating && addTrailingEllipsis) { + if (textNodes.length > 0) { + const lastNode = textNodes[textNodes.length - 1]; + lastNode.textContent += ELLIPSIS; + } + } + + function removeEmptyNodes(node) { + const childNodes = Array.from(node.childNodes); + + for (const child of childNodes) { + if (child.nodeType === Node.ELEMENT_NODE) { + removeEmptyNodes(child); + + if (!child.textContent.trim() && !child.querySelector('img, svg, canvas')) { + child.parentNode.removeChild(child); + } + } + } + } + removeEmptyNodes(container); + + return container.innerHTML; +}; + +/** + * Highlights code syntax first, then truncates while preserving HTML structure + * @param {Object} linesData - Data about the line to highlight + * @returns {Promise<string>} - Highlighted and truncated HTML + */ export const initLineHighlight = async (linesData) => { const { line, fileUrl } = linesData; let { language } = linesData; @@ -57,12 +367,19 @@ export const initLineHighlight = async (linesData) => { language = 'gleam'; } + const originalText = line.text; + const highlights = line.highlights || []; + + const cleanedLine = cleanLineAndMark(line); + if (isUnsupportedLanguage(language)) { - return line.text; + const highlightedSearchTerm = highlightSearchTerm(cleanedLine); + return truncateHtml(highlightedSearchTerm, originalText, highlights); } - const resultData = await highlight(null, cleanLineAndMark(line), language); + const resultData = await highlight(null, cleanedLine, language); const withHighlightedSearchTerm = highlightSearchTerm(resultData[0].highlightedContent); - return withHighlightedSearchTerm; + + return truncateHtml(withHighlightedSearchTerm, originalText, highlights); }; diff --git a/spec/frontend/search/results/components/blob_chunks_spec.js b/spec/frontend/search/results/components/blob_chunks_spec.js index fe6094aad6b43de076f37541085976900153448f..2c1901bb4e379f591ac42e310d437f1ed7fb5171 100644 --- a/spec/frontend/search/results/components/blob_chunks_spec.js +++ b/spec/frontend/search/results/components/blob_chunks_spec.js @@ -3,12 +3,18 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import BlobChunks from '~/search/results/components/blob_chunks.vue'; import { useMockInternalEventsTracking } from 'helpers/tracking_internal_events_helper'; +import { isUnsupportedLanguage } from '~/search/results/utils'; import { EVENT_CLICK_BLOB_RESULT_BLAME_LINE, EVENT_CLICK_BLOB_RESULT_LINE, } from '~/search/results/tracking'; import { mockDataForBlobChunk } from '../../mock_data'; +jest.mock('~/search/results/utils', () => ({ + isUnsupportedLanguage: jest.fn(), + initLineHighlight: jest.fn(), +})); + describe('BlobChunks', () => { const { bindInternalEventDocument } = useMockInternalEventsTracking(); let wrapper; @@ -78,6 +84,11 @@ describe('BlobChunks', () => { describe('when frontend highlighting', () => { beforeEach(async () => { + const mockHighlightedText = 'console.log("test")'; + jest.spyOn(BlobChunks.methods, 'codeHighlighting').mockResolvedValue(mockHighlightedText); + + isUnsupportedLanguage.mockReturnValue(false); + createComponent(mockDataForBlobChunk); await waitForPromises(); }); @@ -87,4 +98,54 @@ describe('BlobChunks', () => { expect(findHighlightedLineCode().at(2).text()).toBe('console.log("test")'); }); }); + + describe('processLine method', () => { + const mockHighlightedText = 'highlighted code'; + + beforeEach(() => { + jest.spyOn(BlobChunks.methods, 'codeHighlighting').mockResolvedValue(mockHighlightedText); + }); + + describe('with unsupported language', () => { + beforeEach(async () => { + isUnsupportedLanguage.mockReturnValue(true); + + createComponent({ + ...mockDataForBlobChunk, + language: 'unsupported-lang', + }); + + await waitForPromises(); + }); + + it('sets line text directly when language is unsupported', () => { + const lineElements = wrapper.findAllByTestId('search-blob-line-code-non-highlighted'); + + expect(lineElements.exists()).toBe(true); + expect(wrapper.vm.lines[0].text).toBe(mockHighlightedText); + expect(wrapper.vm.lines[0].richText).toBeNull(); + }); + }); + + describe('with supported language', () => { + beforeEach(async () => { + isUnsupportedLanguage.mockReturnValue(false); + + createComponent({ + ...mockDataForBlobChunk, + language: 'javascript', + }); + + await waitForPromises(); + }); + + it('sets richText when language is supported', () => { + const lineElements = wrapper.findAllByTestId('search-blob-line-code-highlighted'); + + expect(lineElements.exists()).toBe(true); + expect(wrapper.vm.lines[0].richText).toBe(mockHighlightedText); + expect(wrapper.vm.lines[0].text).not.toBe(mockHighlightedText); + }); + }); + }); }); diff --git a/spec/frontend/search/results/utils_spec.js b/spec/frontend/search/results/utils_spec.js index b4b6b4cb8c33f24f4cb8247ae4cffda037d5fa1d..eb8c7247b087c24633167c7ba1b9be028ae599e8 100644 --- a/spec/frontend/search/results/utils_spec.js +++ b/spec/frontend/search/results/utils_spec.js @@ -11,6 +11,7 @@ import { isUnsupportedLanguage, highlightSearchTerm, markSearchTerm, + truncateHtml, } from '~/search/results/utils'; jest.mock('~/vue_shared/components/source_viewer/workers/highlight_utils', () => ({ @@ -29,6 +30,14 @@ describe('Global Search Results Utils', () => { ])('correctly identifies if %s language is unsupported', (language, expected) => { expect(isUnsupportedLanguage(language)).toBe(expected); }); + + it('handles undefined language', () => { + expect(isUnsupportedLanguage(undefined)).toBe(true); + }); + + it('handles null language', () => { + expect(isUnsupportedLanguage(null)).toBe(true); + }); }); describe('initLineHighlight', () => { @@ -36,12 +45,12 @@ describe('Global Search Results Utils', () => { highlight.mockClear(); const result = await initLineHighlight({ - line: { text: 'const test = true;', highlights: [[6, 8]] }, + line: { text: 'const test = true;', highlights: [[6, 9]] }, language: 'txt', fileUrl: 'test.txt', }); - expect(result).toBe('const test = true;'); + expect(result).toBe('const <b class="hll">test</b> = true;'); expect(highlight).not.toHaveBeenCalled(); }); @@ -55,22 +64,22 @@ describe('Global Search Results Utils', () => { expect(highlight).toHaveBeenCalledWith(null, 'const test = true;', 'gleam'); }); - describe('when initLineHighlight returns highlight', () => { - beforeEach(() => { - highlight.mockImplementation((_, input) => - Promise.resolve([{ highlightedContent: input }]), - ); + it('calls highlight with correct parameters', async () => { + await initLineHighlight({ + line: { text: 'const test = true;', highlights: [[6, 9]] }, + language: 'javascript', + fileUrl: 'test.js', }); - it('calls highlight with correct parameters', async () => { - const result = await initLineHighlight({ - line: { text: 'const test = true;', highlights: [[6, 9]] }, - language: 'javascript', - fileUrl: 'test.js', - }); + const expected = `const ${HIGHLIGHT_MARK}test${HIGHLIGHT_MARK} = true;`; + expect(highlight).toHaveBeenCalled(); - expect(result).toBe('const <b class="hll">test</b> = true;'); - }); + const call = highlight.mock.calls[0]; + expect(call[0]).toBe(null); + expect([...call[1]].map((c) => c.charCodeAt(0))).toEqual( + [...expected].map((c) => c.charCodeAt(0)), + ); + expect(call[2]).toBe('javascript'); }); }); @@ -79,6 +88,10 @@ describe('Global Search Results Utils', () => { expect(highlightSearchTerm('')).toBe(''); }); + it('returns original string when no highlights present', () => { + expect(highlightSearchTerm('test string')).toBe('test string'); + }); + it('replaces highlight marks with HTML tags', () => { const input = `console${HIGHLIGHT_MARK}log${HIGHLIGHT_MARK}(true);`; const expected = `console${HIGHLIGHT_HTML_START}log${HIGHLIGHT_HTML_END}(true);`; @@ -92,30 +105,49 @@ describe('Global Search Results Utils', () => { expect(highlightSearchTerm(input)).toBe(expected); }); + + it('handles consecutive highlights', () => { + const input = `${HIGHLIGHT_MARK}test${HIGHLIGHT_MARK}${HIGHLIGHT_MARK}string${HIGHLIGHT_MARK}`; + const expected = `${HIGHLIGHT_HTML_START}test${HIGHLIGHT_HTML_END}${HIGHLIGHT_HTML_START}string${HIGHLIGHT_HTML_END}`; + + expect(highlightSearchTerm(input)).toBe(expected); + }); }); - describe('markSearchTerm', () => { - it('adds highlight marks at correct positions', () => { - const text = 'foobar test foobar test'; - const highlights = [ - [7, 10], - [19, 22], - ]; + describe('cleanLineAndMark', () => { + it('adds single highlight mark at correct position', () => { + const str = 'const testValue = true;\n'; + const highlights = [[6, 14]]; - const result = cleanLineAndMark({ text, highlights }); - const expected = `foobar ${HIGHLIGHT_MARK}test${HIGHLIGHT_MARK} foobar ${HIGHLIGHT_MARK}test${HIGHLIGHT_MARK}`; + const result = cleanLineAndMark({ text: str, highlights }); + const expected = `const ${HIGHLIGHT_MARK}testValue${HIGHLIGHT_MARK} = true;`; expect([...result].map((c) => c.charCodeAt(0))).toEqual( [...expected].map((c) => c.charCodeAt(0)), ); }); - it('adds single highlight mark at correct position', () => { - const text = ' return false unless licensed_and_indexing_enabled?\\n'; - const highlights = [[28, 57]]; + it('returns empty string for empty input', () => { + expect(cleanLineAndMark()).toBe(undefined); + }); + + it('handles empty highlights array', () => { + const str = 'const test = true;'; + + expect(cleanLineAndMark({ text: str, highlights: [] })).toBe(str); + }); + }); - const result = cleanLineAndMark({ text, highlights }); - const expected = ` return false unless ${HIGHLIGHT_MARK}licensed_and_indexing_enabled?${HIGHLIGHT_MARK}\\n`; + describe('markSearchTerm', () => { + it('adds highlight marks at correct positions', () => { + const str = 'foobar test foobar test'; + const highlights = [ + [7, 10], + [19, 23], + ]; + + const result = markSearchTerm(str, highlights); + const expected = `foobar ${HIGHLIGHT_MARK}test${HIGHLIGHT_MARK} foobar ${HIGHLIGHT_MARK}test${HIGHLIGHT_MARK}`; expect([...result].map((c) => c.charCodeAt(0))).toEqual( [...expected].map((c) => c.charCodeAt(0)), @@ -132,4 +164,71 @@ describe('Global Search Results Utils', () => { expect(markSearchTerm(str, [])).toBe(str); }); }); + + describe('truncateHtml', () => { + it('returns original HTML if it is shorter than maximum length', () => { + const html = '<span>Short text</span>'; + const text = 'Short text'; + + expect(truncateHtml(html, text, [])).toBe(html); + }); + + it('truncates HTML around highlights', () => { + const longText = `${'A'.repeat(5000)}HIGHLIGHT${'B'.repeat(500)}`; + const html = `<span>${longText}</span>`; + const highlights = [[5000, 5008]]; + + const result = truncateHtml(html, longText, highlights); + + expect(result).toContain('HIGHLIGHT'); + expect(result.length).toBeLessThan(html.length); + }); + + it('adds ellipsis at both ends when truncating middle', () => { + const prefix = `PREFIX${'A'.repeat(300)}`; + const middle = 'HIGHLIGHT'; + const suffix = `${'B'.repeat(3000)}SUFFIX`; + const longText = prefix + middle + suffix; + const html = `<span>${longText}</span>`; + const highlights = [[prefix.length, prefix.length + middle.length]]; + + const result = truncateHtml(html, longText, highlights); + + expect(result).toContain('…'); + expect(result).toContain('HIGHLIGHT'); + }); + + it('preserves HTML structure when truncating', () => { + const longText = `${'A'.repeat(500)}HIGHLIGHT${'B'.repeat(500)}`; + const html = `<div><span class="c1">${longText}</span></div>`; + const highlights = [[500, 508]]; + + const result = truncateHtml(html, longText, highlights); + + expect(result).toContain('<span class="c1">'); + expect(result).toContain('</span>'); + expect(result).toContain('</div>'); + }); + + it('handles empty or null input', () => { + expect(truncateHtml('', '', [])).toBe(''); + expect(truncateHtml(null, '', [])).toBe(null); + }); + + it('maintains all highlight clusters when possible', () => { + const text1 = `${'A'.repeat(100)}FIRST${'B'.repeat(100)}`; + const text2 = `${'C'.repeat(100)}'SECOND${'D'.repeat(100)}`; + const longText = text1 + text2; + const html = `<span>${longText}</span>`; + const highlights = [ + [100, 105], // FIRST + [305, 311], // SECOND + ]; + + const result = truncateHtml(html, longText, highlights); + + expect(result).toContain('FIRST'); + expect(result).toContain('SECOND'); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 8e6bd461d46b59c5a5ae7e2d0ff4ac15c3d6d610..f506642b512e070712d30702982efc63361d430a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1412,7 +1412,8 @@ resolved "https://registry.yarnpkg.com/@gitlab/fonts/-/fonts-1.3.0.tgz#df89c1bb6714e4a8a5d3272568aa4de7fb337267" integrity sha512-DoMUIN3DqjEn7wvcxBg/b7Ite5fTdF5EmuOZoBRo2j0UBGweDXmNBi+9HrTZs4cBU660dOxcf1hATFcG3npbPg== -"@gitlab/noop@^1.0.1": +"@gitlab/noop@^1.0.1", jackspeak@^3.1.2, "jackspeak@npm:@gitlab/noop@1.0.1": + name jackspeak version "1.0.1" resolved "https://registry.yarnpkg.com/@gitlab/noop/-/noop-1.0.1.tgz#71a831146ee02732b4a61d2d3c11204564753454" integrity sha512-s++4wjMYeDvBp9IO59DBrWjy8SE/gFkjTDO5ck2W0S6Vv7OlqgErwL7pHngAnrSmTJAzyUG8wHGqo0ViS4jn5Q== @@ -9393,11 +9394,6 @@ iterall@^1.2.1: resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.3.0.tgz#afcb08492e2915cbd8a0884eb93a8c94d0d72fea" integrity sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg== -jackspeak@^3.1.2, "jackspeak@npm:@gitlab/noop@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@gitlab/noop/-/noop-1.0.1.tgz#71a831146ee02732b4a61d2d3c11204564753454" - integrity sha512-s++4wjMYeDvBp9IO59DBrWjy8SE/gFkjTDO5ck2W0S6Vv7OlqgErwL7pHngAnrSmTJAzyUG8wHGqo0ViS4jn5Q== - jed@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/jed/-/jed-1.1.1.tgz#7a549bbd9ffe1585b0cd0a191e203055bee574b4"