import { Fragment, type ReactNode } from 'react';
import {
hasMarkdown,
parseInline,
parseMarkdownBlocks,
type InlineToken,
type MarkdownBlock
} from '../../shared/markdown';
import { highlightText } from '../HighlightText/highlight-text';
import { ResizableResultTable } from '../ResizableResultTable/ResizableResultTable';
import { PathText, isPathField, isWholePath } from '../PathText/PathText';
interface Props {
value: string;
search?: string;
onResults?: () => void;
resizableTables?: boolean;
label?: string;
}
const formattedTags = /<\/?(?:p|br|b|strong|i|em|u|ul|ol|li|a)\b/i;
const encodedTags = /<\/?(?:p|br|b|strong|i|em|u|ul|ol|li|a)\b/i;
const discardedTags = new Set(['script', 'style', 'iframe', 'object', 'embed', 'svg', 'math']);
/** Preserve inline HTML in a Markdown table without executing imported markup. */
function tableMarkdown(node: Node): string | null {
if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? '';
if (node.nodeType !== Node.ELEMENT_NODE) return '';
const element = node as Element;
const tag = element.tagName.toLowerCase();
if (discardedTags.has(tag)) return '';
if (tag === 'br') return '\n';
if (!['body', 'div', 'p', 'a', 'b', 'strong', 'i', 'em'].includes(tag)) return null;
const parts = Array.from(node.childNodes).map(tableMarkdown);
if (parts.some(part => part === null)) return null;
const text = parts.join('');
if (tag === 'a') {
const href = safeHref(element.getAttribute('href'));
return href ? `[${text}](${href.replace(/\(/g, '%28').replace(/\)/g, '%29')})` : text;
}
if (tag === 'b' || tag === 'strong') return `**${text}**`;
if (tag === 'i' || tag === 'em') return `*${text}*`;
return tag === 'p' || tag === 'div' ? `${text}\n\n` : text;
}
function decodeEntities(value: string): string {
const textarea = document.createElement('textarea');
textarea.innerHTML = value;
return textarea.value;
}
function safeHref(value: string | null): string | undefined {
if (!value) return undefined;
const href = value.trim();
return /^(?:https?:|mailto:)/i.test(href) ? href : undefined;
}
const bareUrl = /https?:\/\/[^\s<>"']+/g;
const trailingPunctuation = /[.,;:!?)\]}]+$/;
const resultTriggerPattern = /(Show\s+Assessment\s+Evidence|Show\s+Rule\s+Result\s+XML|(?:--?\s*)?\bMore\b)/gi;
function linkifyText(text: string, key: string, search?: string): ReactNode {
if (!text || !/https?:\/\//i.test(text)) return ;
const parts: ReactNode[] = [];
let lastIndex = 0;
for (const match of text.matchAll(bareUrl)) {
const raw = match[0];
const href = raw.replace(trailingPunctuation, '');
const start = match.index ?? 0;
if (start > lastIndex) parts.push(highlightText(text.slice(lastIndex, start), search));
parts.push({highlightText(href, search)});
if (href.length < raw.length) parts.push(highlightText(raw.slice(href.length), search));
lastIndex = start + raw.length;
}
if (lastIndex < text.length) parts.push(highlightText(text.slice(lastIndex), search));
return {parts};
}
function renderTextWithTriggers(text: string, key: string, search?: string, onResults?: () => void): ReactNode {
if (!text) return null;
resultTriggerPattern.lastIndex = 0;
if (!resultTriggerPattern.test(text)) return linkifyText(text, key, search);
resultTriggerPattern.lastIndex = 0;
const parts: ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = resultTriggerPattern.exec(text)) !== null) {
const raw = match[0];
const triggerText = match[1];
const start = match.index;
if (start > lastIndex) {
parts.push(linkifyText(text.slice(lastIndex, start), `${key}-p-${start}`, search));
}
const isXml = /rule\s*result\s*xml/i.test(triggerText);
if (isXml) {
const handleXmlClick = (e: React.MouseEvent) => {
e.preventDefault();
const details = document.querySelector('.finding-optional-detail');
if (details) {
details.setAttribute('open', 'true');
details.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
};
parts.push(
);
} else if (onResults) {
parts.push(
);
} else {
parts.push(
{highlightText(raw, search)}
);
}
lastIndex = start + raw.length;
}
if (lastIndex < text.length) {
parts.push(linkifyText(text.slice(lastIndex), `${key}-p-end`, search));
}
return {parts};
}
function renderNode(node: Node, key: string, insideAnchor = false, search?: string, onResults?: () => void): ReactNode {
if (node.nodeType === Node.TEXT_NODE) {
if (insideAnchor) return highlightText(node.textContent ?? '', search);
return {renderInline(parseInline(node.textContent ?? ''), key, search, onResults)};
}
if (node.nodeType !== Node.ELEMENT_NODE) return null;
const element = node as Element;
const tag = element.tagName.toLowerCase();
if (discardedTags.has(tag)) return null;
if (tag === 'p' || tag === 'div') {
const markdown = tableMarkdown(node);
const blocks = markdown === null ? [] : parseMarkdownBlocks(markdown);
if (blocks.some(block => block.kind === 'table')) {
return {blocks.map((block, index) => renderBlock(block, `${key}-${index}`, search, onResults))};
}
}
const children = Array.from(element.childNodes).map((child, index) => renderNode(child, `${key}-${index}`, insideAnchor || tag === 'a', search, onResults));
switch (tag) {
case 'p': return
{children}
;
case 'br': return ;
case 'b':
case 'strong': return {children};
case 'i':
case 'em': return {children};
case 'u': return {children};
case 'ul': return