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']); 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; 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 ; case 'ol': return
    {children}
; case 'li': return
  • {children}
  • ; case 'a': { const href = safeHref(element.getAttribute('href')); const textLabel = element.textContent?.trim() ?? ''; if (!href && /^(Show\s+Assessment\s+Evidence|Show\s+Rule\s+Result\s+XML|(?:--?\s*)?More)$/i.test(textLabel)) { return {renderTextWithTriggers(textLabel, key, search, onResults)}; } return href ? {children} : {renderTextWithTriggers(element.textContent ?? '', key, search, onResults)}; } default: return {children}; } } function renderInline(tokens: InlineToken[], key: string, search?: string, onResults?: () => void): ReactNode { return tokens.map((token, index) => { const tokenKey = `${key}-${index}`; switch (token.kind) { case 'strong': return {renderInline(token.children, tokenKey, search, onResults)}; case 'emphasis': return {renderInline(token.children, tokenKey, search, onResults)}; case 'code': return isWholePath(token.text) ? : {highlightText(token.text, search)}; case 'link': { const href = safeHref(token.href); const textLabel = token.children.map(c => c.kind === 'text' ? c.text : '').join(''); if (!href && /^(Show\s+Assessment\s+Evidence|Show\s+Rule\s+Result\s+XML|(?:--?\s*)?More)$/i.test(textLabel.trim())) { return {renderTextWithTriggers(textLabel, tokenKey, search, onResults)}; } const children = token.children.map((child, childIndex) => child.kind === 'text' ? {renderTextWithTriggers(child.text, `${tokenKey}-${childIndex}`, search, onResults)} : renderInline([child], `${tokenKey}-${childIndex}`, search, onResults)); return href ? {children} : {children}; } default: return {renderTextWithTriggers(token.text, tokenKey, search, onResults)}; } }); } function renderBlock(block: MarkdownBlock, key: string, search?: string, onResults?: () => void, resizableTables = false): ReactNode { switch (block.kind) { case 'heading': { const Tag = (block.level <= 2 ? 'h5' : 'h6') as 'h5' | 'h6'; return {renderInline(block.content, key, search, onResults)}; } case 'list': return block.ordered ?
      {block.items.map((item, index) =>
    1. {renderInline(item, `${key}-${index}`, search, onResults)}
    2. )}
    : ; case 'table': return
    renderInline(parseInline(header), `${key}-h-${index}`, search, onResults))}> {block.table.rows.map((row, rowIndex) => {row.map((cell, cellIndex) => {renderInline(parseInline(cell), `${key}-${rowIndex}-${cellIndex}`, search, onResults)})} )}
    ; case 'code': return
    {highlightText(block.text, search)}
    ; default: return

    {renderInline(block.content, key, search, onResults)}

    ; } } export function RichTextValue({ value, search, onResults, resizableTables = false, label }: Props) { if (isPathField(label) || isWholePath(value)) return

    ; let source = value; if (encodedTags.test(source)) source = decodeEntities(source); const trimmed = source.trim().replace(/^```\w*\n?/, '').replace(/\n?```$/, '').trim(); // Rohes XML / Code-Fragmente (z. B. Rule Result XML) als Monospace-Codeblock rendern const isXmlCode = /^<\??(?:xml|xccdf|rule-result|evidence)\b/i.test(trimmed) || /))/i.test(trimmed) && !/<(?:p|br|div|table|ul|ol|li)\b/i.test(trimmed))) { return
    {highlightText(trimmed, search)}
    ; } if (formattedTags.test(source)) { const documentNode = new DOMParser().parseFromString(source, 'text/html'); return
    {Array.from(documentNode.body.childNodes).map((node, index) => renderNode(node, String(index), false, search, onResults))}
    ; } if (hasMarkdown(source)) { return
    {parseMarkdownBlocks(source).map((block, index) => renderBlock(block, String(index), search, onResults, resizableTables))}
    ; } return

    {renderTextWithTriggers(source, 'plain', search, onResults)}

    ; }