feat(markdown): extract MITRE citations into collapsible sources section
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

(Citation: ...) patterns are stripped from body text, replaced with
Unicode superscript numbers (¹²³), and shown in a compact "Sources"
section below — collapsed when there are more than 3, expanded otherwise.
Deduplication ensures the same citation reference appears only once.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-05-29 08:44:52 +02:00
parent 34340a67eb
commit de093778f6

View File

@@ -1,26 +1,63 @@
/** /**
* MarkdownText — renders a markdown string as styled HTML. * MarkdownText — renders a markdown string as styled HTML.
* *
* Uses react-markdown + remark-gfm (tables, strikethrough, etc.). * Features:
* External links always open in a new tab with rel="noopener noreferrer". * - react-markdown + remark-gfm (tables, strikethrough, etc.)
* - External links open in a new tab.
* - MITRE (Citation: ...) patterns are extracted from the body text,
* replaced with superscript numbers ¹²³, and shown as a compact
* collapsible "Sources" section below.
* *
* Usage: * Usage:
* <MarkdownText content={technique.description} /> * <MarkdownText content={technique.description} />
* <MarkdownText content={test.red_summary} className="text-sm text-gray-400" /> * <MarkdownText content={test.red_summary} className="text-sm text-gray-400" />
*/ */
import { useState } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown"; import type { Components } from "react-markdown";
import { ChevronDown, ChevronUp, BookOpen } from "lucide-react";
interface MarkdownTextProps { /* ── Citation pre-processing ──────────────────────────────────────── */
content: string | null | undefined;
/** Extra classes on the wrapper div */ const CITATION_RE = /\s*\(Citation:\s*([^)]+)\)/g;
className?: string;
const SUPERSCRIPTS = "⁰¹²³⁴⁵⁶⁷⁸⁹";
function toSuperscript(n: number): string {
return String(n)
.split("")
.map((d) => SUPERSCRIPTS[parseInt(d)] ?? d)
.join("");
} }
const components: Components = { interface ProcessedContent {
// Links: always open external URLs in a new tab /** Text with (Citation: …) replaced by ¹²³ superscript numbers */
text: string;
/** Ordered list of unique citation strings */
citations: string[];
}
function processCitations(raw: string): ProcessedContent {
const citations: string[] = [];
const text = raw.replace(CITATION_RE, (_, cite: string) => {
const trimmed = cite.trim();
let idx = citations.indexOf(trimmed);
if (idx === -1) {
citations.push(trimmed);
idx = citations.length - 1;
}
return toSuperscript(idx + 1);
});
return { text: text.trim(), citations };
}
/* ── ReactMarkdown component overrides ───────────────────────────── */
const mdComponents: Components = {
a: ({ href, children, ...props }) => ( a: ({ href, children, ...props }) => (
<a <a
href={href} href={href}
@@ -32,19 +69,13 @@ const components: Components = {
{children} {children}
</a> </a>
), ),
// Paragraphs: keep existing text colour, add spacing
p: ({ children }) => ( p: ({ children }) => (
<p className="mb-2 last:mb-0 leading-relaxed">{children}</p> <p className="mb-2 last:mb-0 leading-relaxed">{children}</p>
), ),
// Bold
strong: ({ children }) => ( strong: ({ children }) => (
<strong className="font-semibold text-gray-100">{children}</strong> <strong className="font-semibold text-gray-100">{children}</strong>
), ),
// Italic em: ({ children }) => <em className="italic text-gray-300">{children}</em>,
em: ({ children }) => (
<em className="italic text-gray-300">{children}</em>
),
// Inline code
code: ({ children, className }) => { code: ({ children, className }) => {
const isBlock = className?.startsWith("language-"); const isBlock = className?.startsWith("language-");
if (isBlock) { if (isBlock) {
@@ -60,22 +91,18 @@ const components: Components = {
</code> </code>
); );
}, },
// Code blocks
pre: ({ children }) => ( pre: ({ children }) => (
<pre className="my-2 overflow-x-auto rounded-md bg-gray-800 p-3 text-xs"> <pre className="my-2 overflow-x-auto rounded-md bg-gray-800 p-3 text-xs">
{children} {children}
</pre> </pre>
), ),
// Unordered lists
ul: ({ children }) => ( ul: ({ children }) => (
<ul className="mb-2 list-disc pl-5 space-y-0.5">{children}</ul> <ul className="mb-2 list-disc pl-5 space-y-0.5">{children}</ul>
), ),
// Ordered lists
ol: ({ children }) => ( ol: ({ children }) => (
<ol className="mb-2 list-decimal pl-5 space-y-0.5">{children}</ol> <ol className="mb-2 list-decimal pl-5 space-y-0.5">{children}</ol>
), ),
li: ({ children }) => <li className="leading-relaxed">{children}</li>, li: ({ children }) => <li className="leading-relaxed">{children}</li>,
// Headings (rarely appear in descriptions but handle them gracefully)
h1: ({ children }) => ( h1: ({ children }) => (
<h1 className="mb-2 text-base font-semibold text-white">{children}</h1> <h1 className="mb-2 text-base font-semibold text-white">{children}</h1>
), ),
@@ -85,24 +112,72 @@ const components: Components = {
h3: ({ children }) => ( h3: ({ children }) => (
<h3 className="mb-1 text-sm font-medium text-gray-200">{children}</h3> <h3 className="mb-1 text-sm font-medium text-gray-200">{children}</h3>
), ),
// Blockquotes
blockquote: ({ children }) => ( blockquote: ({ children }) => (
<blockquote className="my-2 border-l-2 border-cyan-500/40 pl-3 text-gray-400 italic"> <blockquote className="my-2 border-l-2 border-cyan-500/40 pl-3 text-gray-400 italic">
{children} {children}
</blockquote> </blockquote>
), ),
// Horizontal rule
hr: () => <hr className="my-3 border-gray-700" />, hr: () => <hr className="my-3 border-gray-700" />,
}; };
/* ── Citations footer sub-component ─────────────────────────────── */
function CitationsFooter({ citations }: { citations: string[] }) {
// Auto-expand if few citations; collapse for many
const [open, setOpen] = useState(citations.length <= 3);
return (
<div className="mt-3 border-t border-gray-800 pt-2">
<button
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1.5 text-[11px] text-gray-500 hover:text-gray-400 transition-colors select-none"
>
<BookOpen className="h-3 w-3" />
<span>
{citations.length} source{citations.length !== 1 ? "s" : ""}
</span>
{open ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
</button>
{open && (
<ol className="mt-1.5 space-y-0.5 list-none">
{citations.map((cite, i) => (
<li key={i} className="flex items-baseline gap-1.5 text-[11px] text-gray-500">
<span className="shrink-0 text-gray-600 select-none">
{toSuperscript(i + 1)}
</span>
<span className="font-mono">{cite}</span>
</li>
))}
</ol>
)}
</div>
);
}
/* ── Main component ──────────────────────────────────────────────── */
interface MarkdownTextProps {
content: string | null | undefined;
/** Extra classes on the wrapper div */
className?: string;
}
export default function MarkdownText({ content, className }: MarkdownTextProps) { export default function MarkdownText({ content, className }: MarkdownTextProps) {
if (!content) return null; if (!content) return null;
const { text, citations } = processCitations(content);
return ( return (
<div className={className}> <div className={className}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
{content} {text}
</ReactMarkdown> </ReactMarkdown>
{citations.length > 0 && <CitationsFooter citations={citations} />}
</div> </div>
); );
} }