import remarkRehype from 'remark-rehype';
import rehypeKatex from 'rehype-katex';
import rehypeStringify from 'rehype-stringify';
- import { copyCodeToClipboard, preprocessLaTeX } from '$lib/utils';
- import { rehypeRestoreTableHtml } from '$lib/markdown/table-html-restorer';
+ import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
+ import type { Root as MdastRoot } from 'mdast';
import { browser } from '$app/environment';
+ import { onDestroy, tick } from 'svelte';
+ import { rehypeRestoreTableHtml } from '$lib/markdown/table-html-restorer';
+ import { rehypeEnhanceLinks } from '$lib/markdown/enhance-links';
+ import { rehypeEnhanceCodeBlocks } from '$lib/markdown/enhance-code-blocks';
+ import { remarkLiteralHtml } from '$lib/markdown/literal-html';
+ import { copyCodeToClipboard, preprocessLaTeX } from '$lib/utils';
import '$styles/katex-custom.scss';
-
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import githubLightCss from 'highlight.js/styles/github.css?inline';
import { mode } from 'mode-watcher';
- import { remarkLiteralHtml } from '$lib/markdown/literal-html';
import CodePreviewDialog from './CodePreviewDialog.svelte';
interface Props {
class?: string;
}
+ interface MarkdownBlock {
+ id: string;
+ html: string;
+ }
+
let { content, class: className = '' }: Props = $props();
let containerRef = $state<HTMLDivElement>();
- let processedHtml = $state('');
+ let renderedBlocks = $state<MarkdownBlock[]>([]);
+ let unstableBlockHtml = $state('');
let previewDialogOpen = $state(false);
let previewCode = $state('');
let previewLanguage = $state('text');
- function loadHighlightTheme(isDark: boolean) {
- if (!browser) return;
-
- const existingThemes = document.querySelectorAll('style[data-highlight-theme]');
- existingThemes.forEach((style) => style.remove());
+ let pendingMarkdown: string | null = null;
+ let isProcessing = false;
- const style = document.createElement('style');
- style.setAttribute('data-highlight-theme', 'true');
- style.textContent = isDark ? githubDarkCss : githubLightCss;
-
- document.head.appendChild(style);
- }
-
- $effect(() => {
- const currentMode = mode.current;
- const isDark = currentMode === 'dark';
-
- loadHighlightTheme(isDark);
- });
+ const themeStyleId = `highlight-theme-${(window.idxThemeStyle = (window.idxThemeStyle ?? 0) + 1)}`;
let processor = $derived(() => {
return remark()
.use(rehypeKatex) // Render math using KaTeX
.use(rehypeHighlight) // Add syntax highlighting
.use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
- .use(rehypeStringify); // Convert to HTML string
+ .use(rehypeEnhanceLinks) // Add target="_blank" to links
+ .use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
+ .use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
});
- function enhanceLinks(html: string): string {
- if (!html.includes('<a')) {
- return html;
- }
-
- const tempDiv = document.createElement('div');
- tempDiv.innerHTML = html;
-
- // Make all links open in new tabs
- const linkElements = tempDiv.querySelectorAll('a[href]');
- let mutated = false;
-
- for (const link of linkElements) {
- const target = link.getAttribute('target');
- const rel = link.getAttribute('rel');
+ /**
+ * Removes click event listeners from copy and preview buttons.
+ * Called on component destroy.
+ */
+ function cleanupEventListeners() {
+ if (!containerRef) return;
- if (target !== '_blank' || rel !== 'noopener noreferrer') {
- mutated = true;
- }
+ const copyButtons = containerRef.querySelectorAll<HTMLButtonElement>('.copy-code-btn');
+ const previewButtons = containerRef.querySelectorAll<HTMLButtonElement>('.preview-code-btn');
- link.setAttribute('target', '_blank');
- link.setAttribute('rel', 'noopener noreferrer');
+ for (const button of copyButtons) {
+ button.removeEventListener('click', handleCopyClick);
}
- return mutated ? tempDiv.innerHTML : html;
- }
-
- function enhanceCodeBlocks(html: string): string {
- if (!html.includes('<pre')) {
- return html;
+ for (const button of previewButtons) {
+ button.removeEventListener('click', handlePreviewClick);
}
+ }
- const tempDiv = document.createElement('div');
- tempDiv.innerHTML = html;
-
- const preElements = tempDiv.querySelectorAll('pre');
- let mutated = false;
-
- for (const [index, pre] of Array.from(preElements).entries()) {
- const codeElement = pre.querySelector('code');
-
- if (!codeElement) {
- continue;
- }
-
- mutated = true;
-
- let language = 'text';
- const classList = Array.from(codeElement.classList);
-
- for (const className of classList) {
- if (className.startsWith('language-')) {
- language = className.replace('language-', '');
- break;
- }
- }
-
- const rawCode = codeElement.textContent || '';
- const codeId = `code-${Date.now()}-${index}`;
- codeElement.setAttribute('data-code-id', codeId);
- codeElement.setAttribute('data-raw-code', rawCode);
-
- const wrapper = document.createElement('div');
- wrapper.className = 'code-block-wrapper';
-
- const header = document.createElement('div');
- header.className = 'code-block-header';
-
- const languageLabel = document.createElement('span');
- languageLabel.className = 'code-language';
- languageLabel.textContent = language;
-
- const copyButton = document.createElement('button');
- copyButton.className = 'copy-code-btn';
- copyButton.setAttribute('data-code-id', codeId);
- copyButton.setAttribute('title', 'Copy code');
- copyButton.setAttribute('type', 'button');
-
- copyButton.innerHTML = `
- <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>
- `;
-
- const actions = document.createElement('div');
- actions.className = 'code-block-actions';
-
- actions.appendChild(copyButton);
-
- if (language.toLowerCase() === 'html') {
- const previewButton = document.createElement('button');
- previewButton.className = 'preview-code-btn';
- previewButton.setAttribute('data-code-id', codeId);
- previewButton.setAttribute('title', 'Preview code');
- previewButton.setAttribute('type', 'button');
-
- previewButton.innerHTML = `
- <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>
- `;
-
- actions.appendChild(previewButton);
- }
-
- header.appendChild(languageLabel);
- header.appendChild(actions);
- wrapper.appendChild(header);
-
- const clonedPre = pre.cloneNode(true) as HTMLElement;
- wrapper.appendChild(clonedPre);
-
- pre.parentNode?.replaceChild(wrapper, pre);
- }
+ /**
+ * Removes this component's highlight.js theme style from the document head.
+ * Called on component destroy to clean up injected styles.
+ */
+ function cleanupHighlightTheme() {
+ if (!browser) return;
- return mutated ? tempDiv.innerHTML : html;
+ const existingTheme = document.getElementById(themeStyleId);
+ existingTheme?.remove();
}
- async function processMarkdown(text: string): Promise<string> {
- try {
- let normalized = preprocessLaTeX(text);
- const result = await processor().process(normalized);
- const html = String(result);
- const enhancedLinks = enhanceLinks(html);
+ /**
+ * Loads the appropriate highlight.js theme based on dark/light mode.
+ * Injects a scoped style element into the document head.
+ * @param isDark - Whether to load the dark theme (true) or light theme (false)
+ */
+ function loadHighlightTheme(isDark: boolean) {
+ if (!browser) return;
- return enhanceCodeBlocks(enhancedLinks);
- } catch (error) {
- console.error('Markdown processing error:', error);
+ const existingTheme = document.getElementById(themeStyleId);
+ existingTheme?.remove();
- // Fallback to plain text with line breaks
- return text.replace(/\n/g, '<br>');
- }
+ const style = document.createElement('style');
+ style.id = themeStyleId;
+ style.textContent = isDark ? githubDarkCss : githubLightCss;
+
+ document.head.appendChild(style);
}
+ /**
+ * Extracts code information from a button click target within a code block.
+ * @param target - The clicked button element
+ * @returns Object with rawCode and language, or null if extraction fails
+ */
function getCodeInfoFromTarget(target: HTMLElement) {
const wrapper = target.closest('.code-block-wrapper');
return null;
}
- const rawCode = codeElement.getAttribute('data-raw-code');
-
- if (rawCode === null) {
- console.error('No raw code found');
- return null;
- }
+ const rawCode = codeElement.textContent ?? '';
const languageLabel = wrapper.querySelector<HTMLElement>('.code-language');
const language = languageLabel?.textContent?.trim() || 'text';
return { rawCode, language };
}
+ /**
+ * Generates a unique identifier for a HAST node based on its position.
+ * Used for stable block identification during incremental rendering.
+ * @param node - The HAST root content node
+ * @param indexFallback - Fallback index if position is unavailable
+ * @returns Unique string identifier for the node
+ */
+ function getHastNodeId(node: HastRootContent, indexFallback: number): string {
+ const position = node.position;
+
+ if (position?.start?.offset != null && position?.end?.offset != null) {
+ return `hast-${position.start.offset}-${position.end.offset}`;
+ }
+
+ return `${node.type}-${indexFallback}`;
+ }
+
+ /**
+ * Handles click events on copy buttons within code blocks.
+ * Copies the raw code content to the clipboard.
+ * @param event - The click event from the copy button
+ */
async function handleCopyClick(event: Event) {
event.preventDefault();
event.stopPropagation();
}
}
+ /**
+ * Handles preview dialog open state changes.
+ * Clears preview content when dialog is closed.
+ * @param open - Whether the dialog is being opened or closed
+ */
+ function handlePreviewDialogOpenChange(open: boolean) {
+ previewDialogOpen = open;
+
+ if (!open) {
+ previewCode = '';
+ previewLanguage = 'text';
+ }
+ }
+
+ /**
+ * Handles click events on preview buttons within HTML code blocks.
+ * Opens a preview dialog with the rendered HTML content.
+ * @param event - The click event from the preview button
+ */
function handlePreviewClick(event: Event) {
event.preventDefault();
event.stopPropagation();
previewDialogOpen = true;
}
+ /**
+ * Processes markdown content into stable and unstable HTML blocks.
+ * Uses incremental rendering: stable blocks are cached, unstable block is re-rendered.
+ * @param markdown - The raw markdown string to process
+ */
+ async function processMarkdown(markdown: string) {
+ if (!markdown) {
+ renderedBlocks = [];
+ unstableBlockHtml = '';
+ return;
+ }
+
+ const normalized = preprocessLaTeX(markdown);
+ const processorInstance = processor();
+ const ast = processorInstance.parse(normalized) as MdastRoot;
+ const processedRoot = (await processorInstance.run(ast)) as HastRoot;
+ const processedChildren = processedRoot.children ?? [];
+ const stableCount = Math.max(processedChildren.length - 1, 0);
+ const nextBlocks: MarkdownBlock[] = [];
+
+ for (let index = 0; index < stableCount; index++) {
+ const hastChild = processedChildren[index];
+ const id = getHastNodeId(hastChild, index);
+ const existing = renderedBlocks[index];
+
+ if (existing && existing.id === id) {
+ nextBlocks.push(existing);
+ continue;
+ }
+
+ const html = stringifyProcessedNode(
+ processorInstance,
+ processedRoot,
+ processedChildren[index]
+ );
+
+ nextBlocks.push({ id, html });
+ }
+
+ let unstableHtml = '';
+
+ if (processedChildren.length > stableCount) {
+ const unstableChild = processedChildren[stableCount];
+ unstableHtml = stringifyProcessedNode(processorInstance, processedRoot, unstableChild);
+ }
+
+ renderedBlocks = nextBlocks;
+ await tick(); // Force DOM sync before updating unstable HTML block
+ unstableBlockHtml = unstableHtml;
+ }
+
+ /**
+ * Attaches click event listeners to copy and preview buttons in code blocks.
+ * Uses data-listener-bound attribute to prevent duplicate bindings.
+ */
function setupCodeBlockActions() {
if (!containerRef) return;
}
}
- function handlePreviewDialogOpenChange(open: boolean) {
- previewDialogOpen = open;
+ /**
+ * Converts a single HAST node to an enhanced HTML string.
+ * Applies link and code block enhancements to the output.
+ * @param processorInstance - The remark/rehype processor instance
+ * @param processedRoot - The full processed HAST root (for context)
+ * @param child - The specific HAST child node to stringify
+ * @returns Enhanced HTML string representation of the node
+ */
+ function stringifyProcessedNode(
+ processorInstance: ReturnType<typeof processor>,
+ processedRoot: HastRoot,
+ child: unknown
+ ) {
+ const root: HastRoot = {
+ ...(processedRoot as HastRoot),
+ children: [child as never]
+ };
+
+ return processorInstance.stringify(root);
+ }
+
+ /**
+ * Queues markdown for processing with coalescing support.
+ * Only processes the latest markdown when multiple updates arrive quickly.
+ * @param markdown - The markdown content to render
+ */
+ async function updateRenderedBlocks(markdown: string) {
+ pendingMarkdown = markdown;
+
+ if (isProcessing) {
+ return;
+ }
- if (!open) {
- previewCode = '';
- previewLanguage = 'text';
+ isProcessing = true;
+
+ try {
+ while (pendingMarkdown !== null) {
+ const nextMarkdown = pendingMarkdown;
+ pendingMarkdown = null;
+
+ await processMarkdown(nextMarkdown);
+ }
+ } catch (error) {
+ console.error('Failed to process markdown:', error);
+ renderedBlocks = [];
+ unstableBlockHtml = markdown.replace(/\n/g, '<br>');
+ } finally {
+ isProcessing = false;
}
}
$effect(() => {
- if (content) {
- processMarkdown(content)
- .then((result) => {
- processedHtml = result;
- })
- .catch((error) => {
- console.error('Failed to process markdown:', error);
- processedHtml = content.replace(/\n/g, '<br>');
- });
- } else {
- processedHtml = '';
- }
+ const currentMode = mode.current;
+ const isDark = currentMode === 'dark';
+
+ loadHighlightTheme(isDark);
});
$effect(() => {
- if (containerRef && processedHtml) {
+ updateRenderedBlocks(content);
+ });
+
+ $effect(() => {
+ const hasRenderedBlocks = renderedBlocks.length > 0;
+ const hasUnstableBlock = Boolean(unstableBlockHtml);
+
+ if ((hasRenderedBlocks || hasUnstableBlock) && containerRef) {
setupCodeBlockActions();
}
});
+
+ onDestroy(() => {
+ cleanupEventListeners();
+ cleanupHighlightTheme();
+ });
</script>
<div bind:this={containerRef} class={className}>
- <!-- eslint-disable-next-line no-at-html-tags -->
- {@html processedHtml}
+ {#each renderedBlocks as block (block.id)}
+ <div class="markdown-block" data-block-id={block.id}>
+ <!-- eslint-disable-next-line no-at-html-tags -->
+ {@html block.html}
+ </div>
+ {/each}
+
+ {#if unstableBlockHtml}
+ <div class="markdown-block markdown-block--unstable" data-block-id="unstable">
+ <!-- eslint-disable-next-line no-at-html-tags -->
+ {@html unstableBlockHtml}
+ </div>
+ {/if}
</div>
<CodePreviewDialog
/>
<style>
+ .markdown-block,
+ .markdown-block--unstable {
+ display: contents;
+ }
+
/* Base typography styles */
div :global(p:not(:last-child)) {
margin-bottom: 1rem;
--- /dev/null
+/**
+ * Rehype plugin to enhance code blocks with wrapper, header, and action buttons.
+ *
+ * Wraps <pre><code> elements with a container that includes:
+ * - Language label
+ * - Copy button
+ * - Preview button (for HTML code blocks)
+ *
+ * This operates directly on the HAST tree for better performance,
+ * avoiding the need to stringify and re-parse HTML.
+ */
+
+import type { Plugin } from 'unified';
+import type { Root, Element, ElementContent } from 'hast';
+import { visit } from 'unist-util-visit';
+
+declare global {
+ interface Window {
+ idxCodeBlock?: number;
+ }
+}
+
+const COPY_ICON_SVG = `<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>`;
+
+const PREVIEW_ICON_SVG = `<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>`;
+
+/**
+ * Creates an SVG element node from raw SVG string.
+ * Since we can't parse HTML in HAST directly, we use the raw property.
+ */
+function createRawHtmlElement(html: string): Element {
+ return {
+ type: 'element',
+ tagName: 'span',
+ properties: {},
+ children: [{ type: 'raw', value: html } as unknown as ElementContent]
+ };
+}
+
+function createCopyButton(codeId: string): Element {
+ return {
+ type: 'element',
+ tagName: 'button',
+ properties: {
+ className: ['copy-code-btn'],
+ 'data-code-id': codeId,
+ title: 'Copy code',
+ type: 'button'
+ },
+ children: [createRawHtmlElement(COPY_ICON_SVG)]
+ };
+}
+
+function createPreviewButton(codeId: string): Element {
+ return {
+ type: 'element',
+ tagName: 'button',
+ properties: {
+ className: ['preview-code-btn'],
+ 'data-code-id': codeId,
+ title: 'Preview code',
+ type: 'button'
+ },
+ children: [createRawHtmlElement(PREVIEW_ICON_SVG)]
+ };
+}
+
+function createHeader(language: string, codeId: string): Element {
+ const actions: Element[] = [createCopyButton(codeId)];
+
+ if (language.toLowerCase() === 'html') {
+ actions.push(createPreviewButton(codeId));
+ }
+
+ return {
+ type: 'element',
+ tagName: 'div',
+ properties: { className: ['code-block-header'] },
+ children: [
+ {
+ type: 'element',
+ tagName: 'span',
+ properties: { className: ['code-language'] },
+ children: [{ type: 'text', value: language }]
+ },
+ {
+ type: 'element',
+ tagName: 'div',
+ properties: { className: ['code-block-actions'] },
+ children: actions
+ }
+ ]
+ };
+}
+
+function createWrapper(header: Element, preElement: Element): Element {
+ return {
+ type: 'element',
+ tagName: 'div',
+ properties: { className: ['code-block-wrapper'] },
+ children: [header, preElement]
+ };
+}
+
+function extractLanguage(codeElement: Element): string {
+ const className = codeElement.properties?.className;
+ if (!Array.isArray(className)) return 'text';
+
+ for (const cls of className) {
+ if (typeof cls === 'string' && cls.startsWith('language-')) {
+ return cls.replace('language-', '');
+ }
+ }
+
+ return 'text';
+}
+
+/**
+ * Generates a unique code block ID using a global counter.
+ */
+function generateCodeId(): string {
+ if (typeof window !== 'undefined') {
+ return `code-${(window.idxCodeBlock = (window.idxCodeBlock ?? 0) + 1)}`;
+ }
+ // Fallback for SSR - use timestamp + random
+ return `code-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
+}
+
+/**
+ * Rehype plugin to enhance code blocks with wrapper, header, and action buttons.
+ * This plugin wraps <pre><code> elements with a container that includes:
+ * - Language label
+ * - Copy button
+ * - Preview button (for HTML code blocks)
+ */
+export const rehypeEnhanceCodeBlocks: Plugin<[], Root> = () => {
+ return (tree: Root) => {
+ visit(tree, 'element', (node: Element, index, parent) => {
+ if (node.tagName !== 'pre' || !parent || index === undefined) return;
+
+ const codeElement = node.children.find(
+ (child): child is Element => child.type === 'element' && child.tagName === 'code'
+ );
+
+ if (!codeElement) return;
+
+ const language = extractLanguage(codeElement);
+ const codeId = generateCodeId();
+
+ codeElement.properties = {
+ ...codeElement.properties,
+ 'data-code-id': codeId
+ };
+
+ const header = createHeader(language, codeId);
+ const wrapper = createWrapper(header, node);
+
+ // Replace pre with wrapper in parent
+ (parent.children as ElementContent[])[index] = wrapper;
+ });
+ };
+};