feat(markdown): extract MITRE citations into collapsible sources section
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user