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;
tableRowClassName?: (headers: string[], cells: string[]) => string | undefined;
}
const formattedTags = /<\/?(?:p|br|b|strong|i|em|u|ul|ol|li|a|table|thead|tbody|tr|th|td)\b/i;
const encodedTags = /<\/?(?:p|br|b|strong|i|em|u|ul|ol|li|a|table|thead|tbody|tr|th|td)\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(
{highlightText(raw, search)}
);
} else if (onResults) {
parts.push(
{highlightText(raw, search)}
);
} 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, resizableTables = false, tableRowClassName?: Props['tableRowClassName']): 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 === 'table') {
const table = element as HTMLTableElement;
const rows = Array.from(table.rows);
const headerRow = table.tHead?.rows[0] ?? (rows[0]?.querySelector('th') ? rows[0] : undefined);
const headers = headerRow ? Array.from(headerRow.cells).map(cell => cell.textContent ?? '')
: Array.from({ length: rows.reduce((count, row) => Math.max(count, row.cells.length), 0) }, (_, index) => `Column ${index + 1}`);
return
{rows.filter(row => row !== headerRow).map((row, index) => {
const values = Array.from(row.cells).map(cell => cell.textContent ?? '');
return {Array.from(row.cells).map((cell, column) =>
{Array.from(cell.childNodes).map((child, childIndex) => renderNode(child, `${key}-${index}-${column}-${childIndex}`, false, search, onResults, resizableTables, tableRowClassName))} )} ;
})}
;
}
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, resizableTables, tableRowClassName))} ;
}
}
const children = Array.from(element.childNodes).map((child, index) => renderNode(child, `${key}-${index}`, insideAnchor || tag === 'a', search, onResults, resizableTables, tableRowClassName));
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, tableRowClassName?: Props['tableRowClassName']): 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) => {renderInline(item, `${key}-${index}`, search, onResults)} )}
: {block.items.map((item, index) => {renderInline(item, `${key}-${index}`, search, onResults)} )} ;
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, tableRowClassName }: 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');
const markdown = tableMarkdown(documentNode.body);
const blocks = markdown === null ? [] : parseMarkdownBlocks(markdown);
if (blocks.some(block => block.kind === 'table')) {
return {blocks.map((block, index) => renderBlock(block, String(index), search, onResults, resizableTables, tableRowClassName))}
;
}
return {Array.from(documentNode.body.childNodes).map((node, index) => renderNode(node, String(index), false, search, onResults, resizableTables, tableRowClassName))}
;
}
if (hasMarkdown(source)) {
return {parseMarkdownBlocks(source).map((block, index) => renderBlock(block, String(index), search, onResults, resizableTables, tableRowClassName))}
;
}
return {renderTextWithTriggers(source, 'plain', search, onResults)}
;
}