MarkdownContent.svelte 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771
  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 { copyCodeToClipboard } from '$lib/utils/copy';
  11. import { rehypeRestoreTableHtml } from '$lib/markdown/table-html-restorer';
  12. import { preprocessLaTeX } from '$lib/utils/latex-protection';
  13. import { browser } from '$app/environment';
  14. import '$styles/katex-custom.scss';
  15. import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
  16. import githubLightCss from 'highlight.js/styles/github.css?inline';
  17. import { mode } from 'mode-watcher';
  18. import { remarkLiteralHtml } from '$lib/markdown/literal-html';
  19. import CodePreviewDialog from './CodePreviewDialog.svelte';
  20. interface Props {
  21. content: string;
  22. class?: string;
  23. }
  24. let { content, class: className = '' }: Props = $props();
  25. let containerRef = $state<HTMLDivElement>();
  26. let processedHtml = $state('');
  27. let previewDialogOpen = $state(false);
  28. let previewCode = $state('');
  29. let previewLanguage = $state('text');
  30. function loadHighlightTheme(isDark: boolean) {
  31. if (!browser) return;
  32. const existingThemes = document.querySelectorAll('style[data-highlight-theme]');
  33. existingThemes.forEach((style) => style.remove());
  34. const style = document.createElement('style');
  35. style.setAttribute('data-highlight-theme', 'true');
  36. style.textContent = isDark ? githubDarkCss : githubLightCss;
  37. document.head.appendChild(style);
  38. }
  39. $effect(() => {
  40. const currentMode = mode.current;
  41. const isDark = currentMode === 'dark';
  42. loadHighlightTheme(isDark);
  43. });
  44. let processor = $derived(() => {
  45. return remark()
  46. .use(remarkGfm) // GitHub Flavored Markdown
  47. .use(remarkMath) // Parse $inline$ and $$block$$ math
  48. .use(remarkBreaks) // Convert line breaks to <br>
  49. .use(remarkLiteralHtml) // Treat raw HTML as literal text with preserved indentation
  50. .use(remarkRehype) // Convert Markdown AST to rehype
  51. .use(rehypeKatex) // Render math using KaTeX
  52. .use(rehypeHighlight) // Add syntax highlighting
  53. .use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
  54. .use(rehypeStringify); // Convert to HTML string
  55. });
  56. function enhanceLinks(html: string): string {
  57. if (!html.includes('<a')) {
  58. return html;
  59. }
  60. const tempDiv = document.createElement('div');
  61. tempDiv.innerHTML = html;
  62. // Make all links open in new tabs
  63. const linkElements = tempDiv.querySelectorAll('a[href]');
  64. let mutated = false;
  65. for (const link of linkElements) {
  66. const target = link.getAttribute('target');
  67. const rel = link.getAttribute('rel');
  68. if (target !== '_blank' || rel !== 'noopener noreferrer') {
  69. mutated = true;
  70. }
  71. link.setAttribute('target', '_blank');
  72. link.setAttribute('rel', 'noopener noreferrer');
  73. }
  74. return mutated ? tempDiv.innerHTML : html;
  75. }
  76. function enhanceCodeBlocks(html: string): string {
  77. if (!html.includes('<pre')) {
  78. return html;
  79. }
  80. const tempDiv = document.createElement('div');
  81. tempDiv.innerHTML = html;
  82. const preElements = tempDiv.querySelectorAll('pre');
  83. let mutated = false;
  84. for (const [index, pre] of Array.from(preElements).entries()) {
  85. const codeElement = pre.querySelector('code');
  86. if (!codeElement) {
  87. continue;
  88. }
  89. mutated = true;
  90. let language = 'text';
  91. const classList = Array.from(codeElement.classList);
  92. for (const className of classList) {
  93. if (className.startsWith('language-')) {
  94. language = className.replace('language-', '');
  95. break;
  96. }
  97. }
  98. const rawCode = codeElement.textContent || '';
  99. const codeId = `code-${Date.now()}-${index}`;
  100. codeElement.setAttribute('data-code-id', codeId);
  101. codeElement.setAttribute('data-raw-code', rawCode);
  102. const wrapper = document.createElement('div');
  103. wrapper.className = 'code-block-wrapper';
  104. const header = document.createElement('div');
  105. header.className = 'code-block-header';
  106. const languageLabel = document.createElement('span');
  107. languageLabel.className = 'code-language';
  108. languageLabel.textContent = language;
  109. const copyButton = document.createElement('button');
  110. copyButton.className = 'copy-code-btn';
  111. copyButton.setAttribute('data-code-id', codeId);
  112. copyButton.setAttribute('title', 'Copy code');
  113. copyButton.setAttribute('type', 'button');
  114. copyButton.innerHTML = `
  115. <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy-icon lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
  116. `;
  117. const actions = document.createElement('div');
  118. actions.className = 'code-block-actions';
  119. actions.appendChild(copyButton);
  120. if (language.toLowerCase() === 'html') {
  121. const previewButton = document.createElement('button');
  122. previewButton.className = 'preview-code-btn';
  123. previewButton.setAttribute('data-code-id', codeId);
  124. previewButton.setAttribute('title', 'Preview code');
  125. previewButton.setAttribute('type', 'button');
  126. previewButton.innerHTML = `
  127. <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eye lucide-eye-icon"><path d="M2.062 12.345a1 1 0 0 1 0-.69C3.5 7.73 7.36 5 12 5s8.5 2.73 9.938 6.655a1 1 0 0 1 0 .69C20.5 16.27 16.64 19 12 19s-8.5-2.73-9.938-6.655"/><circle cx="12" cy="12" r="3"/></svg>
  128. `;
  129. actions.appendChild(previewButton);
  130. }
  131. header.appendChild(languageLabel);
  132. header.appendChild(actions);
  133. wrapper.appendChild(header);
  134. const clonedPre = pre.cloneNode(true) as HTMLElement;
  135. wrapper.appendChild(clonedPre);
  136. pre.parentNode?.replaceChild(wrapper, pre);
  137. }
  138. return mutated ? tempDiv.innerHTML : html;
  139. }
  140. async function processMarkdown(text: string): Promise<string> {
  141. try {
  142. let normalized = preprocessLaTeX(text);
  143. const result = await processor().process(normalized);
  144. const html = String(result);
  145. const enhancedLinks = enhanceLinks(html);
  146. return enhanceCodeBlocks(enhancedLinks);
  147. } catch (error) {
  148. console.error('Markdown processing error:', error);
  149. // Fallback to plain text with line breaks
  150. return text.replace(/\n/g, '<br>');
  151. }
  152. }
  153. function getCodeInfoFromTarget(target: HTMLElement) {
  154. const wrapper = target.closest('.code-block-wrapper');
  155. if (!wrapper) {
  156. console.error('No wrapper found');
  157. return null;
  158. }
  159. const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
  160. if (!codeElement) {
  161. console.error('No code element found in wrapper');
  162. return null;
  163. }
  164. const rawCode = codeElement.getAttribute('data-raw-code');
  165. if (rawCode === null) {
  166. console.error('No raw code found');
  167. return null;
  168. }
  169. const languageLabel = wrapper.querySelector<HTMLElement>('.code-language');
  170. const language = languageLabel?.textContent?.trim() || 'text';
  171. return { rawCode, language };
  172. }
  173. async function handleCopyClick(event: Event) {
  174. event.preventDefault();
  175. event.stopPropagation();
  176. const target = event.currentTarget as HTMLButtonElement | null;
  177. if (!target) {
  178. return;
  179. }
  180. const info = getCodeInfoFromTarget(target);
  181. if (!info) {
  182. return;
  183. }
  184. try {
  185. await copyCodeToClipboard(info.rawCode);
  186. } catch (error) {
  187. console.error('Failed to copy code:', error);
  188. }
  189. }
  190. function handlePreviewClick(event: Event) {
  191. event.preventDefault();
  192. event.stopPropagation();
  193. const target = event.currentTarget as HTMLButtonElement | null;
  194. if (!target) {
  195. return;
  196. }
  197. const info = getCodeInfoFromTarget(target);
  198. if (!info) {
  199. return;
  200. }
  201. previewCode = info.rawCode;
  202. previewLanguage = info.language;
  203. previewDialogOpen = true;
  204. }
  205. function setupCodeBlockActions() {
  206. if (!containerRef) return;
  207. const wrappers = containerRef.querySelectorAll<HTMLElement>('.code-block-wrapper');
  208. for (const wrapper of wrappers) {
  209. const copyButton = wrapper.querySelector<HTMLButtonElement>('.copy-code-btn');
  210. const previewButton = wrapper.querySelector<HTMLButtonElement>('.preview-code-btn');
  211. if (copyButton && copyButton.dataset.listenerBound !== 'true') {
  212. copyButton.dataset.listenerBound = 'true';
  213. copyButton.addEventListener('click', handleCopyClick);
  214. }
  215. if (previewButton && previewButton.dataset.listenerBound !== 'true') {
  216. previewButton.dataset.listenerBound = 'true';
  217. previewButton.addEventListener('click', handlePreviewClick);
  218. }
  219. }
  220. }
  221. function handlePreviewDialogOpenChange(open: boolean) {
  222. previewDialogOpen = open;
  223. if (!open) {
  224. previewCode = '';
  225. previewLanguage = 'text';
  226. }
  227. }
  228. $effect(() => {
  229. if (content) {
  230. processMarkdown(content)
  231. .then((result) => {
  232. processedHtml = result;
  233. })
  234. .catch((error) => {
  235. console.error('Failed to process markdown:', error);
  236. processedHtml = content.replace(/\n/g, '<br>');
  237. });
  238. } else {
  239. processedHtml = '';
  240. }
  241. });
  242. $effect(() => {
  243. if (containerRef && processedHtml) {
  244. setupCodeBlockActions();
  245. }
  246. });
  247. </script>
  248. <div bind:this={containerRef} class={className}>
  249. <!-- eslint-disable-next-line no-at-html-tags -->
  250. {@html processedHtml}
  251. </div>
  252. <CodePreviewDialog
  253. open={previewDialogOpen}
  254. code={previewCode}
  255. language={previewLanguage}
  256. onOpenChange={handlePreviewDialogOpenChange}
  257. />
  258. <style>
  259. /* Base typography styles */
  260. div :global(p:not(:last-child)) {
  261. margin-bottom: 1rem;
  262. line-height: 1.75;
  263. }
  264. /* Headers with consistent spacing */
  265. div :global(h1) {
  266. font-size: 1.875rem;
  267. font-weight: 700;
  268. margin: 1.5rem 0 0.75rem 0;
  269. line-height: 1.2;
  270. }
  271. div :global(h2) {
  272. font-size: 1.5rem;
  273. font-weight: 600;
  274. margin: 1.25rem 0 0.5rem 0;
  275. line-height: 1.3;
  276. }
  277. div :global(h3) {
  278. font-size: 1.25rem;
  279. font-weight: 600;
  280. margin: 1.5rem 0 0.5rem 0;
  281. line-height: 1.4;
  282. }
  283. div :global(h4) {
  284. font-size: 1.125rem;
  285. font-weight: 600;
  286. margin: 0.75rem 0 0.25rem 0;
  287. }
  288. div :global(h5) {
  289. font-size: 1rem;
  290. font-weight: 600;
  291. margin: 0.5rem 0 0.25rem 0;
  292. }
  293. div :global(h6) {
  294. font-size: 0.875rem;
  295. font-weight: 600;
  296. margin: 0.5rem 0 0.25rem 0;
  297. }
  298. /* Text formatting */
  299. div :global(strong) {
  300. font-weight: 600;
  301. }
  302. div :global(em) {
  303. font-style: italic;
  304. }
  305. div :global(del) {
  306. text-decoration: line-through;
  307. opacity: 0.7;
  308. }
  309. /* Inline code */
  310. div :global(code:not(pre code)) {
  311. background: var(--muted);
  312. color: var(--muted-foreground);
  313. padding: 0.125rem 0.375rem;
  314. border-radius: 0.375rem;
  315. font-size: 0.875rem;
  316. font-family:
  317. ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
  318. 'Liberation Mono', Menlo, monospace;
  319. }
  320. /* Links */
  321. div :global(a) {
  322. color: var(--primary);
  323. text-decoration: underline;
  324. text-underline-offset: 2px;
  325. transition: color 0.2s ease;
  326. }
  327. div :global(a:hover) {
  328. color: var(--primary);
  329. }
  330. /* Lists */
  331. div :global(ul) {
  332. list-style-type: disc;
  333. margin-left: 1.5rem;
  334. margin-bottom: 1rem;
  335. }
  336. div :global(ol) {
  337. list-style-type: decimal;
  338. margin-left: 1.5rem;
  339. margin-bottom: 1rem;
  340. }
  341. div :global(li) {
  342. margin-bottom: 0.25rem;
  343. padding-left: 0.5rem;
  344. }
  345. div :global(li::marker) {
  346. color: var(--muted-foreground);
  347. }
  348. /* Nested lists */
  349. div :global(ul ul) {
  350. list-style-type: circle;
  351. margin-top: 0.25rem;
  352. margin-bottom: 0.25rem;
  353. }
  354. div :global(ol ol) {
  355. list-style-type: lower-alpha;
  356. margin-top: 0.25rem;
  357. margin-bottom: 0.25rem;
  358. }
  359. /* Task lists */
  360. div :global(.task-list-item) {
  361. list-style: none;
  362. margin-left: 0;
  363. padding-left: 0;
  364. }
  365. div :global(.task-list-item-checkbox) {
  366. margin-right: 0.5rem;
  367. margin-top: 0.125rem;
  368. }
  369. /* Blockquotes */
  370. div :global(blockquote) {
  371. border-left: 4px solid var(--border);
  372. padding: 0.5rem 1rem;
  373. margin: 1.5rem 0;
  374. font-style: italic;
  375. color: var(--muted-foreground);
  376. background: var(--muted);
  377. border-radius: 0 0.375rem 0.375rem 0;
  378. }
  379. /* Tables */
  380. div :global(table) {
  381. width: 100%;
  382. margin: 1.5rem 0;
  383. border-collapse: collapse;
  384. border: 1px solid var(--border);
  385. border-radius: 0.375rem;
  386. overflow: hidden;
  387. }
  388. div :global(th) {
  389. background: hsl(var(--muted) / 0.3);
  390. border: 1px solid var(--border);
  391. padding: 0.5rem 0.75rem;
  392. text-align: left;
  393. font-weight: 600;
  394. }
  395. div :global(td) {
  396. border: 1px solid var(--border);
  397. padding: 0.5rem 0.75rem;
  398. }
  399. div :global(tr:nth-child(even)) {
  400. background: hsl(var(--muted) / 0.1);
  401. }
  402. /* Horizontal rules */
  403. div :global(hr) {
  404. border: none;
  405. border-top: 1px solid var(--border);
  406. margin: 1.5rem 0;
  407. }
  408. /* Images */
  409. div :global(img) {
  410. border-radius: 0.5rem;
  411. box-shadow:
  412. 0 1px 3px 0 rgb(0 0 0 / 0.1),
  413. 0 1px 2px -1px rgb(0 0 0 / 0.1);
  414. margin: 1.5rem 0;
  415. max-width: 100%;
  416. height: auto;
  417. }
  418. /* Code blocks */
  419. div :global(.code-block-wrapper) {
  420. margin: 1.5rem 0;
  421. border-radius: 0.75rem;
  422. overflow: hidden;
  423. border: 1px solid var(--border);
  424. background: var(--code-background);
  425. }
  426. div :global(.code-block-header) {
  427. display: flex;
  428. justify-content: space-between;
  429. align-items: center;
  430. padding: 0.5rem 1rem;
  431. background: hsl(var(--muted) / 0.5);
  432. border-bottom: 1px solid var(--border);
  433. font-size: 0.875rem;
  434. }
  435. div :global(.code-language) {
  436. color: var(--code-foreground);
  437. font-weight: 500;
  438. font-family:
  439. ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
  440. 'Liberation Mono', Menlo, monospace;
  441. text-transform: uppercase;
  442. font-size: 0.75rem;
  443. letter-spacing: 0.05em;
  444. }
  445. div :global(.code-block-actions) {
  446. display: flex;
  447. align-items: center;
  448. gap: 0.5rem;
  449. }
  450. div :global(.copy-code-btn),
  451. div :global(.preview-code-btn) {
  452. display: flex;
  453. align-items: center;
  454. justify-content: center;
  455. padding: 0;
  456. background: transparent;
  457. color: var(--code-foreground);
  458. cursor: pointer;
  459. transition: all 0.2s ease;
  460. }
  461. div :global(.copy-code-btn:hover),
  462. div :global(.preview-code-btn:hover) {
  463. transform: scale(1.05);
  464. }
  465. div :global(.copy-code-btn:active),
  466. div :global(.preview-code-btn:active) {
  467. transform: scale(0.95);
  468. }
  469. div :global(.code-block-wrapper pre) {
  470. background: transparent;
  471. padding: 1rem;
  472. margin: 0;
  473. overflow-x: auto;
  474. border-radius: 0;
  475. border: none;
  476. font-size: 0.875rem;
  477. line-height: 1.5;
  478. }
  479. div :global(pre) {
  480. background: var(--muted);
  481. margin: 1.5rem 0;
  482. overflow-x: auto;
  483. border-radius: 1rem;
  484. border: none;
  485. }
  486. div :global(code) {
  487. background: transparent;
  488. color: var(--code-foreground);
  489. }
  490. /* Mentions and hashtags */
  491. div :global(.mention) {
  492. color: hsl(var(--primary));
  493. font-weight: 500;
  494. text-decoration: none;
  495. }
  496. div :global(.mention:hover) {
  497. text-decoration: underline;
  498. }
  499. div :global(.hashtag) {
  500. color: hsl(var(--primary));
  501. font-weight: 500;
  502. text-decoration: none;
  503. }
  504. div :global(.hashtag:hover) {
  505. text-decoration: underline;
  506. }
  507. /* Advanced table enhancements */
  508. div :global(table) {
  509. transition: all 0.2s ease;
  510. }
  511. div :global(table:hover) {
  512. box-shadow:
  513. 0 4px 6px -1px rgb(0 0 0 / 0.1),
  514. 0 2px 4px -2px rgb(0 0 0 / 0.1);
  515. }
  516. div :global(th:hover),
  517. div :global(td:hover) {
  518. background: var(--muted);
  519. }
  520. /* Enhanced blockquotes */
  521. div :global(blockquote) {
  522. transition: all 0.2s ease;
  523. position: relative;
  524. }
  525. div :global(blockquote:hover) {
  526. border-left-width: 6px;
  527. background: var(--muted);
  528. transform: translateX(2px);
  529. }
  530. div :global(blockquote::before) {
  531. content: '"';
  532. position: absolute;
  533. top: -0.5rem;
  534. left: 0.5rem;
  535. font-size: 3rem;
  536. color: var(--muted-foreground);
  537. font-family: serif;
  538. line-height: 1;
  539. }
  540. /* Enhanced images */
  541. div :global(img) {
  542. transition: all 0.3s ease;
  543. cursor: pointer;
  544. }
  545. div :global(img:hover) {
  546. transform: scale(1.02);
  547. box-shadow:
  548. 0 10px 15px -3px rgb(0 0 0 / 0.1),
  549. 0 4px 6px -4px rgb(0 0 0 / 0.1);
  550. }
  551. /* Image zoom overlay */
  552. div :global(.image-zoom-overlay) {
  553. position: fixed;
  554. top: 0;
  555. left: 0;
  556. right: 0;
  557. bottom: 0;
  558. background: rgba(0, 0, 0, 0.8);
  559. display: flex;
  560. align-items: center;
  561. justify-content: center;
  562. z-index: 1000;
  563. cursor: pointer;
  564. }
  565. div :global(.image-zoom-overlay img) {
  566. max-width: 90vw;
  567. max-height: 90vh;
  568. border-radius: 0.5rem;
  569. box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
  570. }
  571. /* Enhanced horizontal rules */
  572. div :global(hr) {
  573. border: none;
  574. height: 2px;
  575. background: linear-gradient(to right, transparent, var(--border), transparent);
  576. margin: 2rem 0;
  577. position: relative;
  578. }
  579. div :global(hr::after) {
  580. content: '';
  581. position: absolute;
  582. top: 50%;
  583. left: 50%;
  584. transform: translate(-50%, -50%);
  585. width: 1rem;
  586. height: 1rem;
  587. background: var(--border);
  588. border-radius: 50%;
  589. }
  590. /* Scrollable tables */
  591. div :global(.table-wrapper) {
  592. overflow-x: auto;
  593. margin: 1.5rem 0;
  594. border-radius: 0.5rem;
  595. border: 1px solid var(--border);
  596. }
  597. div :global(.table-wrapper table) {
  598. margin: 0;
  599. border: none;
  600. }
  601. /* Responsive adjustments */
  602. @media (max-width: 640px) {
  603. div :global(h1) {
  604. font-size: 1.5rem;
  605. }
  606. div :global(h2) {
  607. font-size: 1.25rem;
  608. }
  609. div :global(h3) {
  610. font-size: 1.125rem;
  611. }
  612. div :global(table) {
  613. font-size: 0.875rem;
  614. }
  615. div :global(th),
  616. div :global(td) {
  617. padding: 0.375rem 0.5rem;
  618. }
  619. div :global(.table-wrapper) {
  620. margin: 0.5rem -1rem;
  621. border-radius: 0;
  622. border-left: none;
  623. border-right: none;
  624. }
  625. }
  626. /* Dark mode adjustments */
  627. @media (prefers-color-scheme: dark) {
  628. div :global(blockquote:hover) {
  629. background: var(--muted);
  630. }
  631. }
  632. </style>