From de093778f6d08e22dad0bd928ac108887465743b Mon Sep 17 00:00:00 2001 From: kitos Date: Fri, 29 May 2026 08:44:52 +0200 Subject: [PATCH] feat(markdown): extract MITRE citations into collapsible sources section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (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 --- frontend/src/components/MarkdownText.tsx | 121 ++++++++++++++++++----- 1 file changed, 98 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/MarkdownText.tsx b/frontend/src/components/MarkdownText.tsx index 3068984..115447a 100644 --- a/frontend/src/components/MarkdownText.tsx +++ b/frontend/src/components/MarkdownText.tsx @@ -1,26 +1,63 @@ /** * MarkdownText — renders a markdown string as styled HTML. * - * Uses react-markdown + remark-gfm (tables, strikethrough, etc.). - * External links always open in a new tab with rel="noopener noreferrer". + * Features: + * - 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: * * */ +import { useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { Components } from "react-markdown"; +import { ChevronDown, ChevronUp, BookOpen } from "lucide-react"; -interface MarkdownTextProps { - content: string | null | undefined; - /** Extra classes on the wrapper div */ - className?: string; +/* ── Citation pre-processing ──────────────────────────────────────── */ + +const CITATION_RE = /\s*\(Citation:\s*([^)]+)\)/g; + +const SUPERSCRIPTS = "⁰¹²³⁴⁵⁶⁷⁸⁹"; + +function toSuperscript(n: number): string { + return String(n) + .split("") + .map((d) => SUPERSCRIPTS[parseInt(d)] ?? d) + .join(""); } -const components: Components = { - // Links: always open external URLs in a new tab +interface ProcessedContent { + /** 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 }) => ( ), - // Paragraphs: keep existing text colour, add spacing p: ({ children }) => (

{children}

), - // Bold strong: ({ children }) => ( {children} ), - // Italic - em: ({ children }) => ( - {children} - ), - // Inline code + em: ({ children }) => {children}, code: ({ children, className }) => { const isBlock = className?.startsWith("language-"); if (isBlock) { @@ -60,22 +91,18 @@ const components: Components = { ); }, - // Code blocks pre: ({ children }) => (
       {children}
     
), - // Unordered lists ul: ({ children }) => (
    {children}
), - // Ordered lists ol: ({ children }) => (
    {children}
), li: ({ children }) =>
  • {children}
  • , - // Headings (rarely appear in descriptions but handle them gracefully) h1: ({ children }) => (

    {children}

    ), @@ -85,24 +112,72 @@ const components: Components = { h3: ({ children }) => (

    {children}

    ), - // Blockquotes blockquote: ({ children }) => (
    {children}
    ), - // Horizontal rule hr: () =>
    , }; +/* ── 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 ( +
    + + + {open && ( +
      + {citations.map((cite, i) => ( +
    1. + + {toSuperscript(i + 1)} + + {cite} +
    2. + ))} +
    + )} +
    + ); +} + +/* ── Main component ──────────────────────────────────────────────── */ + +interface MarkdownTextProps { + content: string | null | undefined; + /** Extra classes on the wrapper div */ + className?: string; +} + export default function MarkdownText({ content, className }: MarkdownTextProps) { if (!content) return null; + const { text, citations } = processCitations(content); + return (
    - - {content} + + {text} + {citations.length > 0 && }
    ); }