feat: rich markdown renderer with @mention badges and autocomplete

- 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
This commit is contained in:
Jannis Braun
2026-02-25 23:49:41 +01:00
parent 0a157de162
commit c9f9787b99
8 changed files with 730 additions and 32 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ async function main(): Promise<void> {
});
await app.register(rateLimit, {
max: 60,
max: 200,
timeWindow: '1 minute',
keyGenerator: (request) => (request as any).userId || request.ip,
});
+2
View File
@@ -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": {
@@ -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 <code>. 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 (
<pre className="mt-1 p-3 bg-[#2b2d31] border border-[#1e1f22]/50 rounded text-[0.875rem] leading-[1.125rem] font-mono overflow-x-auto whitespace-pre">
<code>{code}</code>
</pre>
);
}
return (
<Highlight theme={discordDarkTheme} code={code} language={language}>
{({ style, tokens, getLineProps, getTokenProps }) => (
<pre
className="mt-1 rounded border border-[#1e1f22]/50 text-[0.875rem] leading-[1.125rem] font-mono overflow-x-auto"
style={{ ...style, padding: '0.625rem 0.75rem', margin: 0 }}
>
{tokens.map((line, i) => {
const lineProps = getLineProps({ line });
return (
<div key={i} {...lineProps} style={{ ...lineProps.style, minHeight: '1.125rem' }}>
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</div>
);
})}
</pre>
)}
</Highlight>
);
}
const MemoizedCodeBlock = React.memo(CodeBlock);
function buildComponents(): Components {
return {
// Paragraphs → spans to avoid block nesting issues in chat messages
p: ({ children }) => <span className="block">{children}</span>,
// 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 <MentionBadge userId={href.slice('mention://'.length)} />;
}
const mentionMatch = href?.match(/^(?:mailto:)?@([a-zA-Z0-9_-]+)$/);
if (mentionMatch) {
return <MentionBadge userId={mentionMatch[1]!} />;
}
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-[#00aff4] hover:underline"
>
{children}
</a>
);
},
// 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 <MemoizedCodeBlock language={match[1]!} code={code} />;
}
// Inline code
return (
<code
className="px-[0.35em] py-[0.15em] bg-[#2b2d31] rounded-[3px] text-[0.875em] font-mono text-[#e8912d]"
{...rest}
>
{children}
</code>
);
},
// Override <pre> to be a minimal wrapper — the CodeBlock handles all styling
pre: ({ children }) => <>{children}</>,
// Bold / Italic / Strikethrough
strong: ({ children }) => <strong className="font-bold text-[#f2f3f5]">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
del: ({ children }) => <del className="line-through text-[#a3a6aa]">{children}</del>,
// Blockquotes
blockquote: ({ children }) => (
<blockquote className="pl-3 border-l-[3px] border-[#4e5058] my-0.5">
{children}
</blockquote>
),
// Lists
ul: ({ children }) => <ul className="list-disc pl-6 my-0.5 space-y-0.5">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-6 my-0.5 space-y-0.5">{children}</ol>,
li: ({ children }) => <li>{children}</li>,
// Headings — Discord renders these with slightly larger/bolder text
h1: ({ children }) => <div className="text-[1.5rem] font-bold text-[#f2f3f5] mt-2 mb-1">{children}</div>,
h2: ({ children }) => <div className="text-[1.25rem] font-bold text-[#f2f3f5] mt-2 mb-1">{children}</div>,
h3: ({ children }) => <div className="text-[1.1rem] font-bold text-[#f2f3f5] mt-1 mb-0.5">{children}</div>,
// Horizontal rules
hr: () => <hr className="border-[#3f4147] my-2" />,
// Images (in markdown content — not attachments)
img: ({ src, alt }) => (
<img
src={src}
alt={alt ?? ''}
className="max-w-full max-h-[350px] rounded-md mt-1"
loading="lazy"
/>
),
// Tables (GFM)
table: ({ children }) => (
<div className="overflow-x-auto my-1">
<table className="border-collapse text-[0.875rem]">{children}</table>
</div>
),
thead: ({ children }) => <thead className="border-b border-[#3f4147]">{children}</thead>,
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => <tr className="border-b border-[#3f4147]/50">{children}</tr>,
th: ({ children }) => <th className="px-3 py-1.5 text-left text-[#f2f3f5] font-semibold">{children}</th>,
td: ({ children }) => <td className="px-3 py-1.5">{children}</td>,
};
}
// 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 (
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS} urlTransform={urlTransform}>
{preprocessMentions(content)}
</ReactMarkdown>
);
});
@@ -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 (
<span
onClick={handleClick}
className="inline-flex items-center rounded-[3px] px-[2px] font-medium cursor-pointer transition-colors hover:brightness-125"
style={{ color, backgroundColor: bgColor }}
>
@{displayName}
</span>
);
});
@@ -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<HTMLDivElement>(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 (
<div className="absolute bottom-full left-0 w-[280px] mb-1 z-50">
<div className="bg-[#2b2d31] rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.15),0_8px_16px_rgba(0,0,0,0.24)] overflow-hidden max-h-[320px] overflow-y-auto scrollbar-thin">
<div className="px-2 py-1.5 text-[11px] font-bold text-discord-text-muted uppercase tracking-wider">
Members
</div>
{filtered.map((member, i) => {
const displayName = member.user.displayName ?? member.user.username;
const roleColor = getMemberColor(member);
const isSelected = i === selectedIndex;
return (
<div
key={member.userId}
ref={isSelected ? selectedRef : undefined}
onClick={() => 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]'
}`}
>
<Avatar
src={member.user.avatar}
name={displayName}
size={24}
status={member.user.status}
/>
<span
className="text-[14px] font-medium truncate"
style={roleColor ? { color: roleColor } : undefined}
>
{displayName}
</span>
{member.user.displayName && (
<span className="text-[12px] text-discord-text-muted truncate">
@{member.user.username}
</span>
)}
</div>
);
})}
</div>
</div>
);
}
+10 -26
View File
@@ -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 = (
<div
className={`group relative flex px-4 py-0.5 hover:bg-discord-modifier-hover transition-colors ${isFirstInGroup || message.replyTo ? 'mt-[1.0625rem]' : ''}`}
className={`group relative flex px-4 py-0.5 transition-colors ${isFirstInGroup || message.replyTo ? 'mt-[1.0625rem]' : ''} ${
isMentioned
? 'bg-[#3c3829] border-l-2 border-l-[#f0b132] hover:bg-[#45402f]'
: 'hover:bg-discord-modifier-hover'
}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
@@ -227,30 +234,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
<div className="flex flex-col gap-1">
{message.content && (
<div className="text-discord-text-normal text-[16px] leading-[1.375rem] break-words whitespace-pre-wrap selection:bg-discord-blurple/30">
<ReactMarkdown
components={{
p: ({ children }) => <span>{children}</span>,
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-discord-text-link hover:underline">
{children}
</a>
),
code: ({ children }) => (
<code className="px-1 py-0.5 bg-discord-bg-tertiary rounded text-[14px] font-mono">
{children}
</code>
),
pre: ({ children }) => (
<pre className="mt-1 p-3 bg-discord-bg-tertiary border border-discord-bg-tertiary/50 rounded-md text-[14px] font-mono overflow-x-auto">
{children}
</pre>
),
strong: ({ children }) => <strong className="font-bold text-discord-text-primary">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
}}
>
{message.content}
</ReactMarkdown>
<MarkdownRenderer content={message.content} />
{message.editedAt && (
<span className="text-[10px] text-discord-text-muted ml-1 select-none font-medium">(edited)</span>
)}
@@ -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<File[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [mentionState, setMentionState] = useState<MentionState | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(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<ReturnType<typeof setTimeout>>();
// 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<HTMLTextAreaElement>) => {
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) {
<span className="opacity-60">Replying to</span>
<span className="font-bold">{replyTo.user.displayName ?? replyTo.user.username}</span>
</div>
<button
<button
onClick={() => setReplyTo(null)}
className="text-discord-text-muted hover:text-discord-text-primary transition-colors"
>
@@ -129,10 +234,19 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
</div>
)}
<div
className={`bg-discord-bg-input ${replyTo ? 'rounded-b-lg' : 'rounded-lg'} overflow-hidden`}
className={`relative bg-discord-bg-input ${replyTo ? 'rounded-b-lg' : 'rounded-lg'} overflow-visible`}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* Mention autocomplete popover */}
{mentionState && filteredMembers.length > 0 && (
<MentionPopover
query={mentionState.query}
selectedIndex={mentionState.selectedIndex}
onSelect={selectMention}
/>
)}
{/* File previews */}
{files.length > 0 && (
<div className="p-4 flex flex-wrap gap-4 bg-discord-bg-secondary/30">
+220
View File
@@ -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: {}