From c9f9787b991c81bebf8ade7ab065801bbb543b59 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:49:41 +0100 Subject: [PATCH] feat: rich markdown renderer with @mention badges and autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add MarkdownRenderer with syntax-highlighted code blocks (prism-react-renderer), GFM tables, and Discord-style theming - Add MentionBadge that resolves user IDs to display names with role colors - Add MentionPopover autocomplete triggered by @ in MessageInput - Fix mention:// URL sanitization — allowlist the scheme in urlTransform so react-markdown v9 passes it through to the component override - Highlight messages that mention the current user (amber border) - Bump rate limit from 60 to 200 req/min --- packages/server/src/index.ts | 2 +- packages/web/package.json | 2 + .../src/components/chat/MarkdownRenderer.tsx | 227 ++++++++++++++++++ .../web/src/components/chat/MentionBadge.tsx | 59 +++++ .../src/components/chat/MentionPopover.tsx | 92 +++++++ packages/web/src/components/chat/Message.tsx | 36 +-- .../web/src/components/chat/MessageInput.tsx | 124 +++++++++- pnpm-lock.yaml | 220 +++++++++++++++++ 8 files changed, 730 insertions(+), 32 deletions(-) create mode 100644 packages/web/src/components/chat/MarkdownRenderer.tsx create mode 100644 packages/web/src/components/chat/MentionBadge.tsx create mode 100644 packages/web/src/components/chat/MentionPopover.tsx diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 92632c87..52204c3f 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -36,7 +36,7 @@ async function main(): Promise { }); await app.register(rateLimit, { - max: 60, + max: 200, timeWindow: '1 minute', keyGenerator: (request) => (request as any).userId || request.ip, }); diff --git a/packages/web/package.json b/packages/web/package.json index 5ab451de..9603a415 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -13,10 +13,12 @@ "@opencord/shared": "workspace:*", "@sapphi-red/web-noise-suppressor": "^0.3.5", "livekit-client": "^2.9.0", + "prism-react-renderer": "^2.4.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", "react-router-dom": "^6.28.0", + "remark-gfm": "^4.0.1", "zustand": "^5.0.2" }, "devDependencies": { diff --git a/packages/web/src/components/chat/MarkdownRenderer.tsx b/packages/web/src/components/chat/MarkdownRenderer.tsx new file mode 100644 index 00000000..dbf7c73a --- /dev/null +++ b/packages/web/src/components/chat/MarkdownRenderer.tsx @@ -0,0 +1,227 @@ +import React from 'react'; +import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { Highlight, themes } from 'prism-react-renderer'; +import type { Components } from 'react-markdown'; +import { MentionBadge } from './MentionBadge'; + +// ─── Remark Plugin: Tag Bare Fenced Blocks ───────────────────────────────── +// react-markdown v9 removed the `inline` prop from . Fenced blocks +// without a language hint have no className, making them indistinguishable +// from inline code. This plugin assigns `lang: 'text'` to bare fenced blocks +// so that `className="language-text"` is always present on block-level code. + +interface MdastNode { + type: string; + lang?: string | null; + children?: MdastNode[]; +} + +function remarkDefaultCodeLang() { + return (tree: MdastNode) => { + walkTree(tree); + }; +} + +function walkTree(node: MdastNode) { + if (node.type === 'code' && !node.lang) { + node.lang = 'text'; + } + if (node.children) { + for (const child of node.children) { + walkTree(child); + } + } +} + +// ─── Pre-processing: Mention Tokens ───────────────────────────────────────── +// remark-parse mangles <@userId> (treats as autolink or escapes the angle +// brackets) before remark plugins can see the text nodes. We solve this by +// converting mention tokens to standard markdown links BEFORE the parser +// runs. The `a` component override then detects the mention:// scheme. +// Code spans and fenced blocks are matched first and preserved as-is. + +function preprocessMentions(raw: string): string { + return raw.replace( + /(```[\s\S]*?```|`[^`]+`)|<@([a-zA-Z0-9_-]+)>/g, + (match, codeBlock: string | undefined, userId: string | undefined) => { + if (codeBlock) return codeBlock; + return `[@${userId}](mention://${userId})`; + }, + ); +} + +// ─── URL Transform: Allow mention:// Scheme ───────────────────────────────── +// react-markdown v9's defaultUrlTransform strips URLs with unknown protocols. +// We whitelist mention:// so the `a` component override receives the full href. + +function urlTransform(url: string): string { + if (url.startsWith('mention://')) return url; + return defaultUrlTransform(url); +} + +// ─── Custom Theme (Discord Dark) ─────────────────────────────────────────── +// Based on One Dark, tuned to match Discord's code block aesthetic. + +const discordDarkTheme = { + ...themes.oneDark, + plain: { + ...themes.oneDark.plain, + backgroundColor: '#2b2d31', + color: '#dcddde', + }, +}; + +// ─── Markdown Component Overrides ────────────────────────────────────────── + +const REMARK_PLUGINS = [remarkGfm, remarkDefaultCodeLang]; + +function CodeBlock({ language, code }: { language: string; code: string }) { + // 'text' means a bare fenced block with no language — render without highlighting + if (language === 'text') { + return ( +
+        {code}
+      
+ ); + } + + return ( + + {({ style, tokens, getLineProps, getTokenProps }) => ( +
+          {tokens.map((line, i) => {
+            const lineProps = getLineProps({ line });
+            return (
+              
+ {line.map((token, key) => ( + + ))} +
+ ); + })} +
+ )} +
+ ); +} + +const MemoizedCodeBlock = React.memo(CodeBlock); + +function buildComponents(): Components { + return { + // Paragraphs → spans to avoid block nesting issues in chat messages + p: ({ children }) => {children}, + + // Links & Mentions + // remark-parse autolinks <@userId> into mailto:@userId before plugins run, + // so we intercept that pattern here instead of using a remark plugin. + a: ({ href, children }) => { + if (href?.startsWith('mention://')) { + return ; + } + const mentionMatch = href?.match(/^(?:mailto:)?@([a-zA-Z0-9_-]+)$/); + if (mentionMatch) { + return ; + } + return ( + + {children} + + ); + }, + + // Fenced code blocks (have className from our remark plugin) vs inline code + code: ({ className, children, ...rest }) => { + const match = /language-(\w+)/.exec(className || ''); + if (match) { + const code = String(children).replace(/\n$/, ''); + return ; + } + // Inline code + return ( + + {children} + + ); + }, + + // Override
 to be a minimal wrapper — the CodeBlock handles all styling
+    pre: ({ children }) => <>{children},
+
+    // Bold / Italic / Strikethrough
+    strong: ({ children }) => {children},
+    em: ({ children }) => {children},
+    del: ({ children }) => {children},
+
+    // Blockquotes
+    blockquote: ({ children }) => (
+      
+ {children} +
+ ), + + // Lists + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + + // Headings — Discord renders these with slightly larger/bolder text + h1: ({ children }) =>
    {children}
    , + h2: ({ children }) =>
    {children}
    , + h3: ({ children }) =>
    {children}
    , + + // Horizontal rules + hr: () =>
    , + + // Images (in markdown content — not attachments) + img: ({ src, alt }) => ( + {alt + ), + + // Tables (GFM) + table: ({ children }) => ( +
    + {children}
    +
    + ), + thead: ({ children }) => {children}, + tbody: ({ children }) => {children}, + tr: ({ children }) => {children}, + th: ({ children }) => {children}, + td: ({ children }) => {children}, + }; +} + +// Build once and reuse — the components object is static +const MARKDOWN_COMPONENTS = buildComponents(); + +// ─── Public Component ────────────────────────────────────────────────────── + +interface MarkdownRendererProps { + content: string; +} + +export const MarkdownRenderer = React.memo(function MarkdownRenderer({ content }: MarkdownRendererProps) { + return ( + + {preprocessMentions(content)} + + ); +}); diff --git a/packages/web/src/components/chat/MentionBadge.tsx b/packages/web/src/components/chat/MentionBadge.tsx new file mode 100644 index 00000000..f6c01a4f --- /dev/null +++ b/packages/web/src/components/chat/MentionBadge.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { useServerStore } from '../../stores/serverStore'; +import { useUIStore } from '../../stores/uiStore'; + +interface MentionBadgeProps { + userId: string; +} + +export const MentionBadge = React.memo(function MentionBadge({ userId }: MentionBadgeProps) { + const members = useServerStore((s) => s.members); + const servers = useServerStore((s) => s.servers); + const currentServerId = useServerStore((s) => s.currentServerId); + const openUserProfile = useUIStore((s) => s.openUserProfile); + + const member = members.find((m) => m.userId === userId); + const server = servers.find((s) => s.id === currentServerId); + const ownerId = server?.ownerId; + + let displayName: string; + let color: string; + + if (member) { + displayName = member.user.displayName ?? member.user.username; + if (member.roles && member.roles.length > 0) { + const sorted = [...member.roles].sort((a, b) => b.position - a.position); + color = sorted[0]!.color; + } else if (ownerId && userId === ownerId) { + color = '#f23f43'; + } else { + color = '#5865f2'; // blurple default + } + } else { + displayName = 'Unknown User'; + color = '#a3a6aa'; // muted fallback + } + + const handleClick = (e: React.MouseEvent) => { + if (!member) return; + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + openUserProfile(member.user, { + top: Math.min(rect.top, window.innerHeight - 450), + left: rect.right + 8, + }); + }; + + // Build inline styles: role-colored text with tinted background + const bgColor = color + '1a'; // ~10% opacity hex + + return ( + + @{displayName} + + ); +}); diff --git a/packages/web/src/components/chat/MentionPopover.tsx b/packages/web/src/components/chat/MentionPopover.tsx new file mode 100644 index 00000000..307e69e1 --- /dev/null +++ b/packages/web/src/components/chat/MentionPopover.tsx @@ -0,0 +1,92 @@ +import React, { useMemo, useRef, useEffect } from 'react'; +import type { MemberWithUser } from '@opencord/shared'; +import { Avatar } from '../ui/Avatar'; +import { useServerStore } from '../../stores/serverStore'; + +const MAX_RESULTS = 8; + +interface MentionPopoverProps { + query: string; + selectedIndex: number; + onSelect: (member: MemberWithUser) => void; +} + +export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopoverProps) { + const members = useServerStore((s) => s.members); + const servers = useServerStore((s) => s.servers); + const currentServerId = useServerStore((s) => s.currentServerId); + const selectedRef = useRef(null); + + const ownerId = servers.find((s) => s.id === currentServerId)?.ownerId; + + const filtered = useMemo(() => { + const q = query.toLowerCase(); + return members + .filter((m) => { + const name = (m.user.displayName ?? m.user.username).toLowerCase(); + const username = m.user.username.toLowerCase(); + return name.includes(q) || username.includes(q); + }) + .slice(0, MAX_RESULTS); + }, [members, query]); + + // Scroll selected item into view + useEffect(() => { + selectedRef.current?.scrollIntoView({ block: 'nearest' }); + }, [selectedIndex]); + + if (filtered.length === 0) return null; + + const getMemberColor = (member: MemberWithUser): string | undefined => { + if (member.roles && member.roles.length > 0) { + const sorted = [...member.roles].sort((a, b) => b.position - a.position); + return sorted[0]!.color; + } + if (ownerId && member.userId === ownerId) return '#f23f43'; + return undefined; + }; + + return ( +
    +
    +
    + Members +
    + {filtered.map((member, i) => { + const displayName = member.user.displayName ?? member.user.username; + const roleColor = getMemberColor(member); + const isSelected = i === selectedIndex; + + return ( +
    onSelect(member)} + className={`flex items-center gap-2.5 px-2 py-1.5 mx-1 rounded cursor-pointer transition-colors ${ + isSelected ? 'bg-[#404249]' : 'hover:bg-[#35373c]' + }`} + > + + + {displayName} + + {member.user.displayName && ( + + @{member.user.username} + + )} +
    + ); + })} +
    +
    + ); +} diff --git a/packages/web/src/components/chat/Message.tsx b/packages/web/src/components/chat/Message.tsx index f01a037b..211a08da 100644 --- a/packages/web/src/components/chat/Message.tsx +++ b/packages/web/src/components/chat/Message.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -import ReactMarkdown from 'react-markdown'; import type { MessageWithUser } from '@opencord/shared'; +import { MarkdownRenderer } from './MarkdownRenderer'; import { Avatar } from '../ui/Avatar'; import { ContextMenu } from '../ui/ContextMenu'; import { useAuthStore } from '../../stores/authStore'; @@ -141,9 +141,16 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { const replyRoleColor = (msg: { userId: string }) => getMemberDisplayColor(msg.userId); + // Self-mention highlighting + const isMentioned = currentUser && message.content?.includes('<@' + currentUser.id + '>'); + const content = (
    setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > @@ -227,30 +234,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
    {message.content && (
    - {children}, - a: ({ href, children }) => ( - - {children} - - ), - code: ({ children }) => ( - - {children} - - ), - pre: ({ children }) => ( -
    -                        {children}
    -                      
    - ), - strong: ({ children }) => {children}, - em: ({ children }) => {children}, - }} - > - {message.content} -
    + {message.editedAt && ( (edited) )} diff --git a/packages/web/src/components/chat/MessageInput.tsx b/packages/web/src/components/chat/MessageInput.tsx index 63cf7a9a..5f3949e3 100644 --- a/packages/web/src/components/chat/MessageInput.tsx +++ b/packages/web/src/components/chat/MessageInput.tsx @@ -1,25 +1,48 @@ -import React, { useState, useRef, useCallback } from 'react'; +import React, { useState, useRef, useCallback, useMemo } from 'react'; import { useChatStore } from '../../stores/chatStore'; -import { isDmChannel } from '../../stores/serverStore'; +import { isDmChannel, useServerStore } from '../../stores/serverStore'; import { wsSend } from '../../hooks/useWebSocket'; import { api } from '../../api/client'; +import { MentionPopover } from './MentionPopover'; +import type { MemberWithUser } from '@opencord/shared'; interface MessageInputProps { channelId: string; channelName: string; } +interface MentionState { + query: string; + startIndex: number; + selectedIndex: number; +} + export function MessageInput({ channelId, channelName }: MessageInputProps) { const [content, setContent] = useState(''); const [files, setFiles] = useState([]); const [isUploading, setIsUploading] = useState(false); + const [mentionState, setMentionState] = useState(null); const fileInputRef = useRef(null); const textareaRef = useRef(null); const sendMessage = useChatStore((s) => s.sendMessage); const replyTo = useChatStore((s) => s.replyTo); const setReplyTo = useChatStore((s) => s.setReplyTo); + const members = useServerStore((s) => s.members); const typingTimeoutRef = useRef>(); + // Filter members for the mention popover + const filteredMembers = useMemo(() => { + if (!mentionState) return []; + const q = mentionState.query.toLowerCase(); + return members + .filter((m) => { + const name = (m.user.displayName ?? m.user.username).toLowerCase(); + const username = m.user.username.toLowerCase(); + return name.includes(q) || username.includes(q); + }) + .slice(0, 8); + }, [members, mentionState]); + const handleTyping = useCallback(() => { if (typingTimeoutRef.current) return; const isDm = isDmChannel(channelId); @@ -38,6 +61,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) { if (!trimmed && files.length === 0) return; setIsUploading(true); + setMentionState(null); try { // Upload files first const attachmentIds: string[] = []; @@ -50,6 +74,11 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) { setContent(''); setFiles([]); + // Reset textarea height + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + } + // Clear typing timeout if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); @@ -62,7 +91,59 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) { } }; + const selectMention = useCallback((member: MemberWithUser) => { + if (!mentionState) return; + const textarea = textareaRef.current; + const cursorPos = textarea?.selectionStart ?? content.length; + const before = content.slice(0, mentionState.startIndex); + const after = content.slice(cursorPos); + const insertion = `<@${member.userId}> `; + const newContent = before + insertion + after; + setContent(newContent); + setMentionState(null); + + // Restore cursor position after React re-renders + const newCursorPos = before.length + insertion.length; + requestAnimationFrame(() => { + if (textarea) { + textarea.focus(); + textarea.selectionStart = newCursorPos; + textarea.selectionEnd = newCursorPos; + } + }); + }, [mentionState, content]); + const handleKeyDown = (e: React.KeyboardEvent) => { + // Mention popover keyboard navigation + if (mentionState && filteredMembers.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setMentionState((prev) => + prev ? { ...prev, selectedIndex: Math.min(prev.selectedIndex + 1, filteredMembers.length - 1) } : null + ); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setMentionState((prev) => + prev ? { ...prev, selectedIndex: Math.max(prev.selectedIndex - 1, 0) } : null + ); + return; + } + if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); + const selected = filteredMembers[mentionState.selectedIndex]; + if (selected) selectMention(selected); + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + setMentionState(null); + return; + } + } + + // Default: Enter to submit if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(); @@ -101,7 +182,31 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) { }; const handleChange = (e: React.ChangeEvent) => { - setContent(e.target.value); + const value = e.target.value; + const cursorPos = e.target.selectionStart; + setContent(value); + + // Detect @mention trigger + const textBeforeCursor = value.slice(0, cursorPos); + const mentionMatch = textBeforeCursor.match(/@([^\s<]*)$/); + + if (mentionMatch) { + const atIndex = cursorPos - mentionMatch[0].length; + // Only trigger at word boundary: start of input, after space, or after newline + const charBefore = atIndex > 0 ? value[atIndex - 1] : undefined; + if (charBefore === undefined || charBefore === ' ' || charBefore === '\n') { + setMentionState({ + query: mentionMatch[1]!, + startIndex: atIndex, + selectedIndex: 0, + }); + } else { + setMentionState(null); + } + } else { + setMentionState(null); + } + handleTyping(); // Auto-resize textarea @@ -118,7 +223,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) { Replying to {replyTo.user.displayName ?? replyTo.user.username}
    -
    )}
    + {/* Mention autocomplete popover */} + {mentionState && filteredMembers.length > 0 && ( + + )} + {/* File previews */} {files.length > 0 && (
    diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb20aabe..8bb6a099 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,6 +114,9 @@ importers: livekit-client: specifier: ^2.9.0 version: 2.17.1(@types/dom-mediacapture-record@1.0.22) + prism-react-renderer: + specifier: ^2.4.1 + version: 2.4.1(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -126,6 +129,9 @@ importers: react-router-dom: specifier: ^6.28.0 version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 zustand: specifier: ^5.0.2 version: 5.0.11(@types/react@18.3.28)(react@18.3.1) @@ -1358,6 +1364,9 @@ packages: '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} + '@types/prismjs@1.26.6': + resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -2225,6 +2234,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} @@ -2833,6 +2846,9 @@ packages: resolution: {integrity: sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + matcher@3.0.0: resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} engines: {node: '>=10'} @@ -2841,9 +2857,30 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + mdast-util-from-markdown@2.0.2: resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} @@ -2875,6 +2912,27 @@ packages: micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} @@ -3272,6 +3330,11 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prism-react-renderer@2.4.1: + resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==} + peerDependencies: + react: '>=16.0.0' + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -3393,12 +3456,18 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} remark-rehype@11.1.2: resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -5024,6 +5093,8 @@ snapshots: xmlbuilder: 15.1.1 optional: true + '@types/prismjs@1.26.6': {} + '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@18.3.28)': @@ -6035,6 +6106,8 @@ snapshots: escape-string-regexp@4.0.0: optional: true + escape-string-regexp@5.0.0: {} + estree-util-is-identifier-name@3.0.0: {} estree-walker@3.0.3: @@ -6741,6 +6814,8 @@ snapshots: map-obj@5.0.0: {} + markdown-table@3.0.4: {} + matcher@3.0.0: dependencies: escape-string-regexp: 4.0.0 @@ -6748,6 +6823,13 @@ snapshots: math-intrinsics@1.1.0: {} + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + mdast-util-from-markdown@2.0.2: dependencies: '@types/mdast': 4.0.4 @@ -6765,6 +6847,63 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.2 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -6860,6 +6999,64 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -7307,6 +7504,12 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + prism-react-renderer@2.4.1(react@18.3.1): + dependencies: + '@types/prismjs': 1.26.6 + clsx: 2.1.1 + react: 18.3.1 + process-nextick-args@2.0.1: {} process-warning@3.0.0: {} @@ -7436,6 +7639,17 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -7453,6 +7667,12 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + require-directory@2.1.1: {} require-from-string@2.0.2: {}