webui: Fix selecting generated output issues during active streaming (#18091)
* draft: incremental markdown rendering with stable blocks * refactor: Logic improvements * refactor: DRY Markdown post-processing logic * refactor: ID generation improvements * fix: Remove runes * refactor: Clean up & add JSDocs * chore: update webui static output * fix: Add tick to prevent race conditions for rendering Markdown blocks Suggestion from @ServeurpersoCom Co-authored-by: Pascal <admin@serveurperso.com> * chore: Run `npm audit fix` * chore: update webui static output * feat: Improve performance using global counter & id instead of UUID * refactor: Enhance Markdown rendering with link and code features * chore: update webui static output * fix: Code block content extraction * chore: update webui static output * chore: update webui static output --------- Co-authored-by: Pascal <admin@serveurperso.com>
This commit is contained in:
co-authored by
Pascal
parent
900316da4e
commit
9ce64aed7d
Binary file not shown.
Generated
+6
-6
@@ -2109,9 +2109,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sveltejs/kit": {
|
"node_modules/@sveltejs/kit": {
|
||||||
"version": "2.48.5",
|
"version": "2.49.2",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.48.5.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.2.tgz",
|
||||||
"integrity": "sha512-/rnwfSWS3qwUSzvHynUTORF9xSJi7PCR9yXkxUOnRrNqyKmCmh3FPHH+E9BbgqxXfTevGXBqgnlh9kMb+9T5XA==",
|
"integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -5797,9 +5797,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mdast-util-to-hast": {
|
"node_modules/mdast-util-to-hast": {
|
||||||
"version": "13.2.0",
|
"version": "13.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
|
||||||
"integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==",
|
"integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/hast": "^3.0.0",
|
"@types/hast": "^3.0.0",
|
||||||
|
|||||||
Vendored
+7
@@ -124,3 +124,10 @@ declare global {
|
|||||||
SettingsConfigType
|
SettingsConfigType
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
idxThemeStyle?: number;
|
||||||
|
idxCodeBlock?: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,15 +7,19 @@
|
|||||||
import remarkRehype from 'remark-rehype';
|
import remarkRehype from 'remark-rehype';
|
||||||
import rehypeKatex from 'rehype-katex';
|
import rehypeKatex from 'rehype-katex';
|
||||||
import rehypeStringify from 'rehype-stringify';
|
import rehypeStringify from 'rehype-stringify';
|
||||||
import { copyCodeToClipboard, preprocessLaTeX } from '$lib/utils';
|
import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
|
||||||
import { rehypeRestoreTableHtml } from '$lib/markdown/table-html-restorer';
|
import type { Root as MdastRoot } from 'mdast';
|
||||||
import { browser } from '$app/environment';
|
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 '$styles/katex-custom.scss';
|
||||||
|
|
||||||
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
||||||
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
||||||
import { mode } from 'mode-watcher';
|
import { mode } from 'mode-watcher';
|
||||||
import { remarkLiteralHtml } from '$lib/markdown/literal-html';
|
|
||||||
import CodePreviewDialog from './CodePreviewDialog.svelte';
|
import CodePreviewDialog from './CodePreviewDialog.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -23,33 +27,24 @@
|
|||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MarkdownBlock {
|
||||||
|
id: string;
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
let { content, class: className = '' }: Props = $props();
|
let { content, class: className = '' }: Props = $props();
|
||||||
|
|
||||||
let containerRef = $state<HTMLDivElement>();
|
let containerRef = $state<HTMLDivElement>();
|
||||||
let processedHtml = $state('');
|
let renderedBlocks = $state<MarkdownBlock[]>([]);
|
||||||
|
let unstableBlockHtml = $state('');
|
||||||
let previewDialogOpen = $state(false);
|
let previewDialogOpen = $state(false);
|
||||||
let previewCode = $state('');
|
let previewCode = $state('');
|
||||||
let previewLanguage = $state('text');
|
let previewLanguage = $state('text');
|
||||||
|
|
||||||
function loadHighlightTheme(isDark: boolean) {
|
let pendingMarkdown: string | null = null;
|
||||||
if (!browser) return;
|
let isProcessing = false;
|
||||||
|
|
||||||
const existingThemes = document.querySelectorAll('style[data-highlight-theme]');
|
const themeStyleId = `highlight-theme-${(window.idxThemeStyle = (window.idxThemeStyle ?? 0) + 1)}`;
|
||||||
existingThemes.forEach((style) => style.remove());
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
let processor = $derived(() => {
|
let processor = $derived(() => {
|
||||||
return remark()
|
return remark()
|
||||||
@@ -61,139 +56,64 @@
|
|||||||
.use(rehypeKatex) // Render math using KaTeX
|
.use(rehypeKatex) // Render math using KaTeX
|
||||||
.use(rehypeHighlight) // Add syntax highlighting
|
.use(rehypeHighlight) // Add syntax highlighting
|
||||||
.use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
|
.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')) {
|
* Removes click event listeners from copy and preview buttons.
|
||||||
return html;
|
* Called on component destroy.
|
||||||
|
*/
|
||||||
|
function cleanupEventListeners() {
|
||||||
|
if (!containerRef) return;
|
||||||
|
|
||||||
|
const copyButtons = containerRef.querySelectorAll<HTMLButtonElement>('.copy-code-btn');
|
||||||
|
const previewButtons = containerRef.querySelectorAll<HTMLButtonElement>('.preview-code-btn');
|
||||||
|
|
||||||
|
for (const button of copyButtons) {
|
||||||
|
button.removeEventListener('click', handleCopyClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tempDiv = document.createElement('div');
|
for (const button of previewButtons) {
|
||||||
tempDiv.innerHTML = html;
|
button.removeEventListener('click', handlePreviewClick);
|
||||||
|
|
||||||
// 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');
|
|
||||||
|
|
||||||
if (target !== '_blank' || rel !== 'noopener noreferrer') {
|
|
||||||
mutated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
link.setAttribute('target', '_blank');
|
|
||||||
link.setAttribute('rel', 'noopener noreferrer');
|
|
||||||
}
|
|
||||||
|
|
||||||
return mutated ? tempDiv.innerHTML : html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function enhanceCodeBlocks(html: string): string {
|
|
||||||
if (!html.includes('<pre')) {
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return mutated ? tempDiv.innerHTML : html;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
return enhanceCodeBlocks(enhancedLinks);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Markdown processing error:', error);
|
|
||||||
|
|
||||||
// Fallback to plain text with line breaks
|
|
||||||
return text.replace(/\n/g, '<br>');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
const existingTheme = document.getElementById(themeStyleId);
|
||||||
|
existingTheme?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
const existingTheme = document.getElementById(themeStyleId);
|
||||||
|
existingTheme?.remove();
|
||||||
|
|
||||||
|
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) {
|
function getCodeInfoFromTarget(target: HTMLElement) {
|
||||||
const wrapper = target.closest('.code-block-wrapper');
|
const wrapper = target.closest('.code-block-wrapper');
|
||||||
|
|
||||||
@@ -209,12 +129,7 @@
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawCode = codeElement.getAttribute('data-raw-code');
|
const rawCode = codeElement.textContent ?? '';
|
||||||
|
|
||||||
if (rawCode === null) {
|
|
||||||
console.error('No raw code found');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const languageLabel = wrapper.querySelector<HTMLElement>('.code-language');
|
const languageLabel = wrapper.querySelector<HTMLElement>('.code-language');
|
||||||
const language = languageLabel?.textContent?.trim() || 'text';
|
const language = languageLabel?.textContent?.trim() || 'text';
|
||||||
@@ -222,6 +137,28 @@
|
|||||||
return { rawCode, language };
|
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) {
|
async function handleCopyClick(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -245,6 +182,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
function handlePreviewClick(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -266,6 +222,61 @@
|
|||||||
previewDialogOpen = true;
|
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() {
|
function setupCodeBlockActions() {
|
||||||
if (!containerRef) return;
|
if (!containerRef) return;
|
||||||
|
|
||||||
@@ -287,40 +298,97 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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]
|
||||||
|
};
|
||||||
|
|
||||||
if (!open) {
|
return processorInstance.stringify(root);
|
||||||
previewCode = '';
|
}
|
||||||
previewLanguage = 'text';
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(() => {
|
$effect(() => {
|
||||||
if (content) {
|
const currentMode = mode.current;
|
||||||
processMarkdown(content)
|
const isDark = currentMode === 'dark';
|
||||||
.then((result) => {
|
|
||||||
processedHtml = result;
|
loadHighlightTheme(isDark);
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to process markdown:', error);
|
|
||||||
processedHtml = content.replace(/\n/g, '<br>');
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
processedHtml = '';
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (containerRef && processedHtml) {
|
updateRenderedBlocks(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const hasRenderedBlocks = renderedBlocks.length > 0;
|
||||||
|
const hasUnstableBlock = Boolean(unstableBlockHtml);
|
||||||
|
|
||||||
|
if ((hasRenderedBlocks || hasUnstableBlock) && containerRef) {
|
||||||
setupCodeBlockActions();
|
setupCodeBlockActions();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
cleanupEventListeners();
|
||||||
|
cleanupHighlightTheme();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={containerRef} class={className}>
|
<div bind:this={containerRef} class={className}>
|
||||||
<!-- eslint-disable-next-line no-at-html-tags -->
|
{#each renderedBlocks as block (block.id)}
|
||||||
{@html processedHtml}
|
<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>
|
</div>
|
||||||
|
|
||||||
<CodePreviewDialog
|
<CodePreviewDialog
|
||||||
@@ -331,6 +399,11 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.markdown-block,
|
||||||
|
.markdown-block--unstable {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
/* Base typography styles */
|
/* Base typography styles */
|
||||||
div :global(p:not(:last-child)) {
|
div :global(p:not(:last-child)) {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Rehype plugin to enhance links with security attributes.
|
||||||
|
*
|
||||||
|
* Adds target="_blank" and rel="noopener noreferrer" to all anchor elements,
|
||||||
|
* ensuring external links open in new tabs safely.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Plugin } from 'unified';
|
||||||
|
import type { Root, Element } from 'hast';
|
||||||
|
import { visit } from 'unist-util-visit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rehype plugin that adds security attributes to all links.
|
||||||
|
* This plugin ensures external links open in new tabs safely by adding:
|
||||||
|
* - target="_blank"
|
||||||
|
* - rel="noopener noreferrer"
|
||||||
|
*/
|
||||||
|
export const rehypeEnhanceLinks: Plugin<[], Root> = () => {
|
||||||
|
return (tree: Root) => {
|
||||||
|
visit(tree, 'element', (node: Element) => {
|
||||||
|
if (node.tagName !== 'a') return;
|
||||||
|
|
||||||
|
const props = node.properties ?? {};
|
||||||
|
|
||||||
|
// Only modify if href exists
|
||||||
|
if (!props.href) return;
|
||||||
|
|
||||||
|
props.target = '_blank';
|
||||||
|
props.rel = 'noopener noreferrer';
|
||||||
|
node.properties = props;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user