Assessor_2.0/src/components/RichTextValue/RichTextValue.tsx

221 lines
10 KiB
TypeScript

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 = /&lt;\/?(?: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 <PathText value={text} search={search} />;
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(<a key={`${key}-url-${start}`} href={href} target="_blank" rel="noopener noreferrer">{highlightText(href, search)}</a>);
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 <Fragment key={key}>{parts}</Fragment>;
}
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(
<button key={`${key}-trig-${start}`} type="button" className="inline-results-link" onClick={handleXmlClick} title="Show Rule Result XML">
{highlightText(raw, search)}
</button>
);
} else if (onResults) {
parts.push(
<button key={`${key}-trig-${start}`} type="button" className="inline-results-link" onClick={onResults} title="Show in Results popup">
{highlightText(raw, search)}
</button>
);
} else {
parts.push(
<span key={`${key}-trig-${start}`} className="inline-results-link-disabled">
{highlightText(raw, search)}
</span>
);
}
lastIndex = start + raw.length;
}
if (lastIndex < text.length) {
parts.push(linkifyText(text.slice(lastIndex), `${key}-p-end`, search));
}
return <Fragment key={key}>{parts}</Fragment>;
}
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 <Fragment key={key}>{renderInline(parseInline(node.textContent ?? ''), key, search, onResults)}</Fragment>;
}
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 <p key={key}>{children}</p>;
case 'br': return <br key={key} />;
case 'b':
case 'strong': return <strong key={key}>{children}</strong>;
case 'i':
case 'em': return <em key={key}>{children}</em>;
case 'u': return <u key={key}>{children}</u>;
case 'ul': return <ul key={key}>{children}</ul>;
case 'ol': return <ol key={key}>{children}</ol>;
case 'li': return <li key={key}>{children}</li>;
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 <Fragment key={key}>{renderTextWithTriggers(textLabel, key, search, onResults)}</Fragment>;
}
return href
? <a key={key} href={href} target="_blank" rel="noopener noreferrer">{children}</a>
: <Fragment key={key}>{renderTextWithTriggers(element.textContent ?? '', key, search, onResults)}</Fragment>;
}
default: return <Fragment key={key}>{children}</Fragment>;
}
}
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 <strong key={tokenKey}>{renderInline(token.children, tokenKey, search, onResults)}</strong>;
case 'emphasis': return <em key={tokenKey}>{renderInline(token.children, tokenKey, search, onResults)}</em>;
case 'code': return isWholePath(token.text) ? <PathText key={tokenKey} value={token.text} search={search} /> : <code key={tokenKey}>{highlightText(token.text, search)}</code>;
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 <Fragment key={tokenKey}>{renderTextWithTriggers(textLabel, tokenKey, search, onResults)}</Fragment>;
}
const children = token.children.map((child, childIndex) => child.kind === 'text'
? <Fragment key={`${tokenKey}-${childIndex}`}>{renderTextWithTriggers(child.text, `${tokenKey}-${childIndex}`, search, onResults)}</Fragment>
: renderInline([child], `${tokenKey}-${childIndex}`, search, onResults));
return href
? <a key={tokenKey} href={href} target="_blank" rel="noopener noreferrer">{children}</a>
: <Fragment key={tokenKey}>{children}</Fragment>;
}
default: return <Fragment key={tokenKey}>{renderTextWithTriggers(token.text, tokenKey, search, onResults)}</Fragment>;
}
});
}
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 <Tag key={key}>{renderInline(block.content, key, search, onResults)}</Tag>;
}
case 'list': return block.ordered
? <ol key={key}>{block.items.map((item, index) => <li key={`${key}-${index}`}>{renderInline(item, `${key}-${index}`, search, onResults)}</li>)}</ol>
: <ul key={key}>{block.items.map((item, index) => <li key={`${key}-${index}`}>{renderInline(item, `${key}-${index}`, search, onResults)}</li>)}</ul>;
case 'table': return <div className="rich-text-table-wrap" key={key}>
<ResizableResultTable enabled={resizableTables} headers={block.table.headers.map((header, index) => renderInline(parseInline(header), `${key}-h-${index}`, search, onResults))}>
{block.table.rows.map((row, rowIndex) => <tr key={rowIndex}>
{row.map((cell, cellIndex) => <td key={cellIndex}>{renderInline(parseInline(cell), `${key}-${rowIndex}-${cellIndex}`, search, onResults)}</td>)}
</tr>)}
</ResizableResultTable>
</div>;
case 'code': return <pre className="rich-text-code" key={key}>{highlightText(block.text, search)}</pre>;
default: return <p key={key}>{renderInline(block.content, key, search, onResults)}</p>;
}
}
export function RichTextValue({ value, search, onResults, resizableTables = false, label }: Props) {
if (isPathField(label) || isWholePath(value)) return <p><PathText value={value} search={search} label={label} /></p>;
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) || /<xccdf:rule-result\b/i.test(trimmed);
if (isXmlCode || (/^<(?:\?xml|[\w:.-]+(?:\s|>))/i.test(trimmed) && !/<(?:p|br|div|table|ul|ol|li)\b/i.test(trimmed))) {
return <pre className="rich-text-code">{highlightText(trimmed, search)}</pre>;
}
if (formattedTags.test(source)) {
const documentNode = new DOMParser().parseFromString(source, 'text/html');
return <div className="rich-text-value">{Array.from(documentNode.body.childNodes).map((node, index) => renderNode(node, String(index), false, search, onResults))}</div>;
}
if (hasMarkdown(source)) {
return <div className="rich-text-value">{parseMarkdownBlocks(source).map((block, index) => renderBlock(block, String(index), search, onResults, resizableTables))}</div>;
}
return <p>{renderTextWithTriggers(source, 'plain', search, onResults)}</p>;
}