255 lines
12 KiB
TypeScript
255 lines
12 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 = /<\/?(?: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 <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;
|
|
|
|
if (tag === 'p' || tag === 'div') {
|
|
const markdown = tableMarkdown(node);
|
|
const blocks = markdown === null ? [] : parseMarkdownBlocks(markdown);
|
|
if (blocks.some(block => block.kind === 'table')) {
|
|
return <Fragment key={key}>{blocks.map((block, index) => renderBlock(block, `${key}-${index}`, search, onResults))}</Fragment>;
|
|
}
|
|
}
|
|
|
|
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');
|
|
const markdown = tableMarkdown(documentNode.body);
|
|
const blocks = markdown === null ? [] : parseMarkdownBlocks(markdown);
|
|
if (blocks.some(block => block.kind === 'table')) {
|
|
return <div className="rich-text-value">{blocks.map((block, index) => renderBlock(block, String(index), search, onResults, resizableTables))}</div>;
|
|
}
|
|
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>;
|
|
}
|