1<script lang="ts">
2 import { remark } from 'remark';
3 import remarkBreaks from 'remark-breaks';
4 import remarkGfm from 'remark-gfm';
5 import remarkMath from 'remark-math';
6 import rehypeHighlight from 'rehype-highlight';
7 import remarkRehype from 'remark-rehype';
8 import rehypeKatex from 'rehype-katex';
9 import rehypeStringify from 'rehype-stringify';
10 import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
11 import type { Root as MdastRoot } from 'mdast';
12 import { browser } from '$app/environment';
13 import { onDestroy, tick } from 'svelte';
14 import { rehypeRestoreTableHtml } from '$lib/markdown/table-html-restorer';
15 import { rehypeEnhanceLinks } from '$lib/markdown/enhance-links';
16 import { rehypeEnhanceCodeBlocks } from '$lib/markdown/enhance-code-blocks';
17 import { remarkLiteralHtml } from '$lib/markdown/literal-html';
18 import { copyCodeToClipboard, preprocessLaTeX } from '$lib/utils';
19 import '$styles/katex-custom.scss';
20 import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
21 import githubLightCss from 'highlight.js/styles/github.css?inline';
22 import { mode } from 'mode-watcher';
23 import CodePreviewDialog from './CodePreviewDialog.svelte';
24
25 interface Props {
26 content: string;
27 class?: string;
28 }
29
30 interface MarkdownBlock {
31 id: string;
32 html: string;
33 }
34
35 let { content, class: className = '' }: Props = $props();
36
37 let containerRef = $state<HTMLDivElement>();
38 let renderedBlocks = $state<MarkdownBlock[]>([]);
39 let unstableBlockHtml = $state('');
40 let previewDialogOpen = $state(false);
41 let previewCode = $state('');
42 let previewLanguage = $state('text');
43
44 let pendingMarkdown: string | null = null;
45 let isProcessing = false;
46
47 const themeStyleId = `highlight-theme-${(window.idxThemeStyle = (window.idxThemeStyle ?? 0) + 1)}`;
48
49 let processor = $derived(() => {
50 return remark()
51 .use(remarkGfm) // GitHub Flavored Markdown
52 .use(remarkMath) // Parse $inline$ and $$block$$ math
53 .use(remarkBreaks) // Convert line breaks to <br>
54 .use(remarkLiteralHtml) // Treat raw HTML as literal text with preserved indentation
55 .use(remarkRehype) // Convert Markdown AST to rehype
56 .use(rehypeKatex) // Render math using KaTeX
57 .use(rehypeHighlight) // Add syntax highlighting
58 .use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
59 .use(rehypeEnhanceLinks) // Add target="_blank" to links
60 .use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
61 .use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
62 });
63
64 /**
65 * Removes click event listeners from copy and preview buttons.
66 * Called on component destroy.
67 */
68 function cleanupEventListeners() {
69 if (!containerRef) return;
70
71 const copyButtons = containerRef.querySelectorAll<HTMLButtonElement>('.copy-code-btn');
72 const previewButtons = containerRef.querySelectorAll<HTMLButtonElement>('.preview-code-btn');
73
74 for (const button of copyButtons) {
75 button.removeEventListener('click', handleCopyClick);
76 }
77
78 for (const button of previewButtons) {
79 button.removeEventListener('click', handlePreviewClick);
80 }
81 }
82
83 /**
84 * Removes this component's highlight.js theme style from the document head.
85 * Called on component destroy to clean up injected styles.
86 */
87 function cleanupHighlightTheme() {
88 if (!browser) return;
89
90 const existingTheme = document.getElementById(themeStyleId);
91 existingTheme?.remove();
92 }
93
94 /**
95 * Loads the appropriate highlight.js theme based on dark/light mode.
96 * Injects a scoped style element into the document head.
97 * @param isDark - Whether to load the dark theme (true) or light theme (false)
98 */
99 function loadHighlightTheme(isDark: boolean) {
100 if (!browser) return;
101
102 const existingTheme = document.getElementById(themeStyleId);
103 existingTheme?.remove();
104
105 const style = document.createElement('style');
106 style.id = themeStyleId;
107 style.textContent = isDark ? githubDarkCss : githubLightCss;
108
109 document.head.appendChild(style);
110 }
111
112 /**
113 * Extracts code information from a button click target within a code block.
114 * @param target - The clicked button element
115 * @returns Object with rawCode and language, or null if extraction fails
116 */
117 function getCodeInfoFromTarget(target: HTMLElement) {
118 const wrapper = target.closest('.code-block-wrapper');
119
120 if (!wrapper) {
121 console.error('No wrapper found');
122 return null;
123 }
124
125 const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
126
127 if (!codeElement) {
128 console.error('No code element found in wrapper');
129 return null;
130 }
131
132 const rawCode = codeElement.textContent ?? '';
133
134 const languageLabel = wrapper.querySelector<HTMLElement>('.code-language');
135 const language = languageLabel?.textContent?.trim() || 'text';
136
137 return { rawCode, language };
138 }
139
140 /**
141 * Generates a unique identifier for a HAST node based on its position.
142 * Used for stable block identification during incremental rendering.
143 * @param node - The HAST root content node
144 * @param indexFallback - Fallback index if position is unavailable
145 * @returns Unique string identifier for the node
146 */
147 function getHastNodeId(node: HastRootContent, indexFallback: number): string {
148 const position = node.position;
149
150 if (position?.start?.offset != null && position?.end?.offset != null) {
151 return `hast-${position.start.offset}-${position.end.offset}`;
152 }
153
154 return `${node.type}-${indexFallback}`;
155 }
156
157 /**
158 * Handles click events on copy buttons within code blocks.
159 * Copies the raw code content to the clipboard.
160 * @param event - The click event from the copy button
161 */
162 async function handleCopyClick(event: Event) {
163 event.preventDefault();
164 event.stopPropagation();
165
166 const target = event.currentTarget as HTMLButtonElement | null;
167
168 if (!target) {
169 return;
170 }
171
172 const info = getCodeInfoFromTarget(target);
173
174 if (!info) {
175 return;
176 }
177
178 try {
179 await copyCodeToClipboard(info.rawCode);
180 } catch (error) {
181 console.error('Failed to copy code:', error);
182 }
183 }
184
185 /**
186 * Handles preview dialog open state changes.
187 * Clears preview content when dialog is closed.
188 * @param open - Whether the dialog is being opened or closed
189 */
190 function handlePreviewDialogOpenChange(open: boolean) {
191 previewDialogOpen = open;
192
193 if (!open) {
194 previewCode = '';
195 previewLanguage = 'text';
196 }
197 }
198
199 /**
200 * Handles click events on preview buttons within HTML code blocks.
201 * Opens a preview dialog with the rendered HTML content.
202 * @param event - The click event from the preview button
203 */
204 function handlePreviewClick(event: Event) {
205 event.preventDefault();
206 event.stopPropagation();
207
208 const target = event.currentTarget as HTMLButtonElement | null;
209
210 if (!target) {
211 return;
212 }
213
214 const info = getCodeInfoFromTarget(target);
215
216 if (!info) {
217 return;
218 }
219
220 previewCode = info.rawCode;
221 previewLanguage = info.language;
222 previewDialogOpen = true;
223 }
224
225 /**
226 * Processes markdown content into stable and unstable HTML blocks.
227 * Uses incremental rendering: stable blocks are cached, unstable block is re-rendered.
228 * @param markdown - The raw markdown string to process
229 */
230 async function processMarkdown(markdown: string) {
231 if (!markdown) {
232 renderedBlocks = [];
233 unstableBlockHtml = '';
234 return;
235 }
236
237 const normalized = preprocessLaTeX(markdown);
238 const processorInstance = processor();
239 const ast = processorInstance.parse(normalized) as MdastRoot;
240 const processedRoot = (await processorInstance.run(ast)) as HastRoot;
241 const processedChildren = processedRoot.children ?? [];
242 const stableCount = Math.max(processedChildren.length - 1, 0);
243 const nextBlocks: MarkdownBlock[] = [];
244
245 for (let index = 0; index < stableCount; index++) {
246 const hastChild = processedChildren[index];
247 const id = getHastNodeId(hastChild, index);
248 const existing = renderedBlocks[index];
249
250 if (existing && existing.id === id) {
251 nextBlocks.push(existing);
252 continue;
253 }
254
255 const html = stringifyProcessedNode(
256 processorInstance,
257 processedRoot,
258 processedChildren[index]
259 );
260
261 nextBlocks.push({ id, html });
262 }
263
264 let unstableHtml = '';
265
266 if (processedChildren.length > stableCount) {
267 const unstableChild = processedChildren[stableCount];
268 unstableHtml = stringifyProcessedNode(processorInstance, processedRoot, unstableChild);
269 }
270
271 renderedBlocks = nextBlocks;
272 await tick(); // Force DOM sync before updating unstable HTML block
273 unstableBlockHtml = unstableHtml;
274 }
275
276 /**
277 * Attaches click event listeners to copy and preview buttons in code blocks.
278 * Uses data-listener-bound attribute to prevent duplicate bindings.
279 */
280 function setupCodeBlockActions() {
281 if (!containerRef) return;
282
283 const wrappers = containerRef.querySelectorAll<HTMLElement>('.code-block-wrapper');
284
285 for (const wrapper of wrappers) {
286 const copyButton = wrapper.querySelector<HTMLButtonElement>('.copy-code-btn');
287 const previewButton = wrapper.querySelector<HTMLButtonElement>('.preview-code-btn');
288
289 if (copyButton && copyButton.dataset.listenerBound !== 'true') {
290 copyButton.dataset.listenerBound = 'true';
291 copyButton.addEventListener('click', handleCopyClick);
292 }
293
294 if (previewButton && previewButton.dataset.listenerBound !== 'true') {
295 previewButton.dataset.listenerBound = 'true';
296 previewButton.addEventListener('click', handlePreviewClick);
297 }
298 }
299 }
300
301 /**
302 * Converts a single HAST node to an enhanced HTML string.
303 * Applies link and code block enhancements to the output.
304 * @param processorInstance - The remark/rehype processor instance
305 * @param processedRoot - The full processed HAST root (for context)
306 * @param child - The specific HAST child node to stringify
307 * @returns Enhanced HTML string representation of the node
308 */
309 function stringifyProcessedNode(
310 processorInstance: ReturnType<typeof processor>,
311 processedRoot: HastRoot,
312 child: unknown
313 ) {
314 const root: HastRoot = {
315 ...(processedRoot as HastRoot),
316 children: [child as never]
317 };
318
319 return processorInstance.stringify(root);
320 }
321
322 /**
323 * Queues markdown for processing with coalescing support.
324 * Only processes the latest markdown when multiple updates arrive quickly.
325 * @param markdown - The markdown content to render
326 */
327 async function updateRenderedBlocks(markdown: string) {
328 pendingMarkdown = markdown;
329
330 if (isProcessing) {
331 return;
332 }
333
334 isProcessing = true;
335
336 try {
337 while (pendingMarkdown !== null) {
338 const nextMarkdown = pendingMarkdown;
339 pendingMarkdown = null;
340
341 await processMarkdown(nextMarkdown);
342 }
343 } catch (error) {
344 console.error('Failed to process markdown:', error);
345 renderedBlocks = [];
346 unstableBlockHtml = markdown.replace(/\n/g, '<br>');
347 } finally {
348 isProcessing = false;
349 }
350 }
351
352 $effect(() => {
353 const currentMode = mode.current;
354 const isDark = currentMode === 'dark';
355
356 loadHighlightTheme(isDark);
357 });
358
359 $effect(() => {
360 updateRenderedBlocks(content);
361 });
362
363 $effect(() => {
364 const hasRenderedBlocks = renderedBlocks.length > 0;
365 const hasUnstableBlock = Boolean(unstableBlockHtml);
366
367 if ((hasRenderedBlocks || hasUnstableBlock) && containerRef) {
368 setupCodeBlockActions();
369 }
370 });
371
372 onDestroy(() => {
373 cleanupEventListeners();
374 cleanupHighlightTheme();
375 });
376</script>
377
378<div bind:this={containerRef} class={className}>
379 {#each renderedBlocks as block (block.id)}
380 <div class="markdown-block" data-block-id={block.id}>
381 <!-- eslint-disable-next-line no-at-html-tags -->
382 {@html block.html}
383 </div>
384 {/each}
385
386 {#if unstableBlockHtml}
387 <div class="markdown-block markdown-block--unstable" data-block-id="unstable">
388 <!-- eslint-disable-next-line no-at-html-tags -->
389 {@html unstableBlockHtml}
390 </div>
391 {/if}
392</div>
393
394<CodePreviewDialog
395 open={previewDialogOpen}
396 code={previewCode}
397 language={previewLanguage}
398 onOpenChange={handlePreviewDialogOpenChange}
399/>
400
401<style>
402 .markdown-block,
403 .markdown-block--unstable {
404 display: contents;
405 }
406
407 /* Base typography styles */
408 div :global(p:not(:last-child)) {
409 margin-bottom: 1rem;
410 line-height: 1.75;
411 }
412
413 div :global(:is(h1, h2, h3, h4, h5, h6):first-child) {
414 margin-top: 0;
415 }
416
417 /* Headers with consistent spacing */
418 div :global(h1) {
419 font-size: 1.875rem;
420 font-weight: 700;
421 line-height: 1.2;
422 margin: 1.5rem 0 0.75rem 0;
423 }
424
425 div :global(h2) {
426 font-size: 1.5rem;
427 font-weight: 600;
428 line-height: 1.3;
429 margin: 1.25rem 0 0.5rem 0;
430 }
431
432 div :global(h3) {
433 font-size: 1.25rem;
434 font-weight: 600;
435 margin: 1.5rem 0 0.5rem 0;
436 line-height: 1.4;
437 }
438
439 div :global(h4) {
440 font-size: 1.125rem;
441 font-weight: 600;
442 margin: 0.75rem 0 0.25rem 0;
443 }
444
445 div :global(h5) {
446 font-size: 1rem;
447 font-weight: 600;
448 margin: 0.5rem 0 0.25rem 0;
449 }
450
451 div :global(h6) {
452 font-size: 0.875rem;
453 font-weight: 600;
454 margin: 0.5rem 0 0.25rem 0;
455 }
456
457 /* Text formatting */
458 div :global(strong) {
459 font-weight: 600;
460 }
461
462 div :global(em) {
463 font-style: italic;
464 }
465
466 div :global(del) {
467 text-decoration: line-through;
468 opacity: 0.7;
469 }
470
471 /* Inline code */
472 div :global(code:not(pre code)) {
473 background: var(--muted);
474 color: var(--muted-foreground);
475 padding: 0.125rem 0.375rem;
476 border-radius: 0.375rem;
477 font-size: 0.875rem;
478 font-family:
479 ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
480 'Liberation Mono', Menlo, monospace;
481 }
482
483 /* Links */
484 div :global(a) {
485 color: var(--primary);
486 text-decoration: underline;
487 text-underline-offset: 2px;
488 transition: color 0.2s ease;
489 }
490
491 div :global(a:hover) {
492 color: var(--primary);
493 }
494
495 /* Lists */
496 div :global(ul) {
497 list-style-type: disc;
498 margin-left: 1.5rem;
499 margin-bottom: 1rem;
500 }
501
502 div :global(ol) {
503 list-style-type: decimal;
504 margin-left: 1.5rem;
505 margin-bottom: 1rem;
506 }
507
508 div :global(li) {
509 margin-bottom: 0.25rem;
510 padding-left: 0.5rem;
511 }
512
513 div :global(li::marker) {
514 color: var(--muted-foreground);
515 }
516
517 /* Nested lists */
518 div :global(ul ul) {
519 list-style-type: circle;
520 margin-top: 0.25rem;
521 margin-bottom: 0.25rem;
522 }
523
524 div :global(ol ol) {
525 list-style-type: lower-alpha;
526 margin-top: 0.25rem;
527 margin-bottom: 0.25rem;
528 }
529
530 /* Task lists */
531 div :global(.task-list-item) {
532 list-style: none;
533 margin-left: 0;
534 padding-left: 0;
535 }
536
537 div :global(.task-list-item-checkbox) {
538 margin-right: 0.5rem;
539 margin-top: 0.125rem;
540 }
541
542 /* Blockquotes */
543 div :global(blockquote) {
544 border-left: 4px solid var(--border);
545 padding: 0.5rem 1rem;
546 margin: 1.5rem 0;
547 font-style: italic;
548 color: var(--muted-foreground);
549 background: var(--muted);
550 border-radius: 0 0.375rem 0.375rem 0;
551 }
552
553 /* Tables */
554 div :global(table) {
555 width: 100%;
556 margin: 1.5rem 0;
557 border-collapse: collapse;
558 border: 1px solid var(--border);
559 border-radius: 0.375rem;
560 overflow: hidden;
561 }
562
563 div :global(th) {
564 background: hsl(var(--muted) / 0.3);
565 border: 1px solid var(--border);
566 padding: 0.5rem 0.75rem;
567 text-align: left;
568 font-weight: 600;
569 }
570
571 div :global(td) {
572 border: 1px solid var(--border);
573 padding: 0.5rem 0.75rem;
574 }
575
576 div :global(tr:nth-child(even)) {
577 background: hsl(var(--muted) / 0.1);
578 }
579
580 /* User message markdown should keep table borders visible on light primary backgrounds */
581 div.markdown-user-content :global(table),
582 div.markdown-user-content :global(th),
583 div.markdown-user-content :global(td),
584 div.markdown-user-content :global(.table-wrapper) {
585 border-color: currentColor;
586 }
587
588 /* Horizontal rules */
589 div :global(hr) {
590 border: none;
591 border-top: 1px solid var(--border);
592 margin: 1.5rem 0;
593 }
594
595 /* Images */
596 div :global(img) {
597 border-radius: 0.5rem;
598 box-shadow:
599 0 1px 3px 0 rgb(0 0 0 / 0.1),
600 0 1px 2px -1px rgb(0 0 0 / 0.1);
601 margin: 1.5rem 0;
602 max-width: 100%;
603 height: auto;
604 }
605
606 /* Code blocks */
607
608 div :global(.code-block-wrapper) {
609 margin: 1.5rem 0;
610 border-radius: 0.75rem;
611 overflow: hidden;
612 border: 1px solid var(--border);
613 background: var(--code-background);
614 }
615
616 div :global(.code-block-header) {
617 display: flex;
618 justify-content: space-between;
619 align-items: center;
620 padding: 0.5rem 1rem;
621 background: hsl(var(--muted) / 0.5);
622 border-bottom: 1px solid var(--border);
623 font-size: 0.875rem;
624 }
625
626 div :global(.code-language) {
627 color: var(--code-foreground);
628 font-weight: 500;
629 font-family:
630 ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
631 'Liberation Mono', Menlo, monospace;
632 text-transform: uppercase;
633 font-size: 0.75rem;
634 letter-spacing: 0.05em;
635 }
636
637 div :global(.code-block-actions) {
638 display: flex;
639 align-items: center;
640 gap: 0.5rem;
641 }
642
643 div :global(.copy-code-btn),
644 div :global(.preview-code-btn) {
645 display: flex;
646 align-items: center;
647 justify-content: center;
648 padding: 0;
649 background: transparent;
650 color: var(--code-foreground);
651 cursor: pointer;
652 transition: all 0.2s ease;
653 }
654
655 div :global(.copy-code-btn:hover),
656 div :global(.preview-code-btn:hover) {
657 transform: scale(1.05);
658 }
659
660 div :global(.copy-code-btn:active),
661 div :global(.preview-code-btn:active) {
662 transform: scale(0.95);
663 }
664
665 div :global(.code-block-wrapper pre) {
666 background: transparent;
667 padding: 1rem;
668 margin: 0;
669 overflow-x: auto;
670 border-radius: 0;
671 border: none;
672 font-size: 0.875rem;
673 line-height: 1.5;
674 }
675
676 div :global(pre) {
677 background: var(--muted);
678 margin: 1.5rem 0;
679 overflow-x: auto;
680 border-radius: 1rem;
681 border: none;
682 }
683
684 div :global(code) {
685 background: transparent;
686 color: var(--code-foreground);
687 }
688
689 /* Mentions and hashtags */
690 div :global(.mention) {
691 color: hsl(var(--primary));
692 font-weight: 500;
693 text-decoration: none;
694 }
695
696 div :global(.mention:hover) {
697 text-decoration: underline;
698 }
699
700 div :global(.hashtag) {
701 color: hsl(var(--primary));
702 font-weight: 500;
703 text-decoration: none;
704 }
705
706 div :global(.hashtag:hover) {
707 text-decoration: underline;
708 }
709
710 /* Advanced table enhancements */
711 div :global(table) {
712 transition: all 0.2s ease;
713 }
714
715 div :global(table:hover) {
716 box-shadow:
717 0 4px 6px -1px rgb(0 0 0 / 0.1),
718 0 2px 4px -2px rgb(0 0 0 / 0.1);
719 }
720
721 div :global(th:hover),
722 div :global(td:hover) {
723 background: var(--muted);
724 }
725
726 /* Disable hover effects when rendering user messages */
727 .markdown-user-content :global(a),
728 .markdown-user-content :global(a:hover) {
729 color: var(--primary-foreground);
730 }
731
732 .markdown-user-content :global(table:hover) {
733 box-shadow: none;
734 }
735
736 .markdown-user-content :global(th:hover),
737 .markdown-user-content :global(td:hover) {
738 background: inherit;
739 }
740
741 /* Enhanced blockquotes */
742 div :global(blockquote) {
743 transition: all 0.2s ease;
744 position: relative;
745 }
746
747 div :global(blockquote:hover) {
748 border-left-width: 6px;
749 background: var(--muted);
750 transform: translateX(2px);
751 }
752
753 div :global(blockquote::before) {
754 content: '"';
755 position: absolute;
756 top: -0.5rem;
757 left: 0.5rem;
758 font-size: 3rem;
759 color: var(--muted-foreground);
760 font-family: serif;
761 line-height: 1;
762 }
763
764 /* Enhanced images */
765 div :global(img) {
766 transition: all 0.3s ease;
767 cursor: pointer;
768 }
769
770 div :global(img:hover) {
771 transform: scale(1.02);
772 box-shadow:
773 0 10px 15px -3px rgb(0 0 0 / 0.1),
774 0 4px 6px -4px rgb(0 0 0 / 0.1);
775 }
776
777 /* Image zoom overlay */
778 div :global(.image-zoom-overlay) {
779 position: fixed;
780 top: 0;
781 left: 0;
782 right: 0;
783 bottom: 0;
784 background: rgba(0, 0, 0, 0.8);
785 display: flex;
786 align-items: center;
787 justify-content: center;
788 z-index: 1000;
789 cursor: pointer;
790 }
791
792 div :global(.image-zoom-overlay img) {
793 max-width: 90vw;
794 max-height: 90vh;
795 border-radius: 0.5rem;
796 box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
797 }
798
799 /* Enhanced horizontal rules */
800 div :global(hr) {
801 border: none;
802 height: 2px;
803 background: linear-gradient(to right, transparent, var(--border), transparent);
804 margin: 2rem 0;
805 position: relative;
806 }
807
808 div :global(hr::after) {
809 content: '';
810 position: absolute;
811 top: 50%;
812 left: 50%;
813 transform: translate(-50%, -50%);
814 width: 1rem;
815 height: 1rem;
816 background: var(--border);
817 border-radius: 50%;
818 }
819
820 /* Scrollable tables */
821 div :global(.table-wrapper) {
822 overflow-x: auto;
823 margin: 1.5rem 0;
824 border-radius: 0.5rem;
825 border: 1px solid var(--border);
826 }
827
828 div :global(.table-wrapper table) {
829 margin: 0;
830 border: none;
831 }
832
833 /* Responsive adjustments */
834 @media (max-width: 640px) {
835 div :global(h1) {
836 font-size: 1.5rem;
837 }
838
839 div :global(h2) {
840 font-size: 1.25rem;
841 }
842
843 div :global(h3) {
844 font-size: 1.125rem;
845 }
846
847 div :global(table) {
848 font-size: 0.875rem;
849 }
850
851 div :global(th),
852 div :global(td) {
853 padding: 0.375rem 0.5rem;
854 }
855
856 div :global(.table-wrapper) {
857 margin: 0.5rem -1rem;
858 border-radius: 0;
859 border-left: none;
860 border-right: none;
861 }
862 }
863
864 /* Dark mode adjustments */
865 @media (prefers-color-scheme: dark) {
866 div :global(blockquote:hover) {
867 background: var(--muted);
868 }
869 }
870</style>