refactor: Clean up & add JSDocs

This commit is contained in:
Aleksander Grygier 2025-12-16 11:18:44 +01:00
parent 179a09e3ac
commit c16d12de27
1 changed files with 206 additions and 130 deletions

View File

@ -26,6 +26,11 @@
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>();
@ -38,25 +43,7 @@
let pendingMarkdown: string | null = null; let pendingMarkdown: string | null = null;
let isProcessing = false; let isProcessing = false;
function loadHighlightTheme(isDark: boolean) { const themeStyleId = `highlight-theme-${uuid()}`;
if (!browser) return;
const existingThemes = document.querySelectorAll('style[data-highlight-theme]');
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()
@ -71,53 +58,42 @@
.use(rehypeStringify); // Convert to HTML string .use(rehypeStringify); // Convert to HTML string
}); });
type MarkdownBlock = {
id: string;
html: string;
};
/** /**
* Helper to process HTML with a temporary DOM element. * Removes click event listeners from copy and preview buttons.
* Returns original HTML if not in browser or tag not found. * Called on component destroy.
*/ */
function processHtml( function cleanupEventListeners() {
html: string, if (!containerRef) return;
tagCheck: string,
processor: (tempDiv: HTMLDivElement) => boolean const copyButtons = containerRef.querySelectorAll<HTMLButtonElement>('.copy-code-btn');
): string { const previewButtons = containerRef.querySelectorAll<HTMLButtonElement>('.preview-code-btn');
if (!browser || !html.includes(tagCheck)) {
return html; 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);
}
const mutated = processor(tempDiv);
return mutated ? tempDiv.innerHTML : html;
} }
function enhanceLinks(html: string): string { /**
return processHtml(html, '<a', (tempDiv) => { * Removes this component's highlight.js theme style from the document head.
const linkElements = tempDiv.querySelectorAll('a[href]'); * Called on component destroy to clean up injected styles.
let mutated = false; */
function cleanupHighlightTheme() {
if (!browser) return;
for (const link of linkElements) { const existingTheme = document.getElementById(themeStyleId);
const target = link.getAttribute('target'); existingTheme?.remove();
const rel = link.getAttribute('rel');
if (target !== '_blank' || rel !== 'noopener noreferrer') {
mutated = true;
}
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener noreferrer');
}
return mutated;
});
} }
/**
* Enhances code blocks with wrapper, header, language label, and action buttons.
* Adds copy button to all code blocks and preview button to HTML blocks.
* @param html - The HTML string containing code blocks to enhance
* @returns Enhanced HTML string with wrapped code blocks
*/
function enhanceCodeBlocks(html: string): string { function enhanceCodeBlocks(html: string): string {
return processHtml(html, '<pre', (tempDiv) => { return processHtml(html, '<pre', (tempDiv) => {
const preElements = tempDiv.querySelectorAll('pre'); const preElements = tempDiv.querySelectorAll('pre');
@ -200,6 +176,56 @@
}); });
} }
/**
* Enhances links to open in new tabs with security attributes.
* Sets target="_blank" and rel="noopener noreferrer" on all anchor elements.
* @param html - The HTML string containing links to enhance
* @returns Enhanced HTML string with modified link attributes
*/
function enhanceLinks(html: string): string {
return processHtml(html, '<a', (tempDiv) => {
const linkElements = tempDiv.querySelectorAll('a[href]');
let mutated = false;
for (const link of linkElements) {
const target = link.getAttribute('target');
const rel = link.getAttribute('rel');
// Only mutate if attributes need to change
if (target !== '_blank' || rel !== 'noopener noreferrer') {
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener noreferrer');
mutated = true;
}
}
return mutated;
});
}
/**
* 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');
@ -228,6 +254,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();
@ -251,6 +299,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();
@ -272,61 +339,32 @@
previewDialogOpen = true; previewDialogOpen = true;
} }
function setupCodeBlockActions() { /**
if (!containerRef) return; * Helper to process HTML with a temporary DOM element.
* Returns original HTML if not in browser or tag not found.
const wrappers = containerRef.querySelectorAll<HTMLElement>('.code-block-wrapper'); */
function processHtml(
for (const wrapper of wrappers) { html: string,
const copyButton = wrapper.querySelector<HTMLButtonElement>('.copy-code-btn'); tagCheck: string,
const previewButton = wrapper.querySelector<HTMLButtonElement>('.preview-code-btn'); processor: (tempDiv: HTMLDivElement) => boolean
): string {
if (copyButton && copyButton.dataset.listenerBound !== 'true') { if (!browser || !html.includes(tagCheck)) {
copyButton.dataset.listenerBound = 'true'; return html;
copyButton.addEventListener('click', handleCopyClick);
}
if (previewButton && previewButton.dataset.listenerBound !== 'true') {
previewButton.dataset.listenerBound = 'true';
previewButton.addEventListener('click', handlePreviewClick);
}
}
}
function handlePreviewDialogOpenChange(open: boolean) {
previewDialogOpen = open;
if (!open) {
previewCode = '';
previewLanguage = 'text';
}
}
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}`; const tempDiv = document.createElement('div');
} tempDiv.innerHTML = html;
function stringifyProcessedNode( const mutated = processor(tempDiv);
processorInstance: ReturnType<typeof processor>,
processedRoot: HastRoot, return mutated ? tempDiv.innerHTML : html;
child: unknown
) {
const root: HastRoot = {
...(processedRoot as HastRoot),
children: [child as never]
};
const html = processorInstance.stringify(root);
return enhanceCodeBlocks(enhanceLinks(html));
} }
/**
* 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) { async function processMarkdown(markdown: string) {
if (!markdown) { if (!markdown) {
renderedBlocks = []; renderedBlocks = [];
@ -372,6 +410,59 @@
unstableBlockHtml = unstableHtml; 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;
const wrappers = containerRef.querySelectorAll<HTMLElement>('.code-block-wrapper');
for (const wrapper of wrappers) {
const copyButton = wrapper.querySelector<HTMLButtonElement>('.copy-code-btn');
const previewButton = wrapper.querySelector<HTMLButtonElement>('.preview-code-btn');
if (copyButton && copyButton.dataset.listenerBound !== 'true') {
copyButton.dataset.listenerBound = 'true';
copyButton.addEventListener('click', handleCopyClick);
}
if (previewButton && previewButton.dataset.listenerBound !== 'true') {
previewButton.dataset.listenerBound = 'true';
previewButton.addEventListener('click', handlePreviewClick);
}
}
}
/**
* 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]
};
const html = processorInstance.stringify(root);
return enhanceCodeBlocks(enhanceLinks(html));
}
/**
* 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) { async function updateRenderedBlocks(markdown: string) {
pendingMarkdown = markdown; pendingMarkdown = markdown;
@ -397,6 +488,13 @@
} }
} }
$effect(() => {
const currentMode = mode.current;
const isDark = currentMode === 'dark';
loadHighlightTheme(isDark);
});
$effect(() => { $effect(() => {
updateRenderedBlocks(content); updateRenderedBlocks(content);
}); });
@ -410,28 +508,6 @@
} }
}); });
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);
}
for (const button of previewButtons) {
button.removeEventListener('click', handlePreviewClick);
}
}
function cleanupHighlightTheme() {
if (!browser) return;
const existingThemes = document.querySelectorAll('style[data-highlight-theme]');
existingThemes.forEach((style) => style.remove());
}
onDestroy(() => { onDestroy(() => {
cleanupEventListeners(); cleanupEventListeners();
cleanupHighlightTheme(); cleanupHighlightTheme();