import { visit } from 'unist-util-visit'; import type { Plugin } from 'unified'; import type { Root, Blockquote, Paragraph, Text } from 'mdast'; interface CalloutMapping { type: string; icon: string; title: string; } /** * Recursively extract all text content from a node and its children * Handles text nodes, strong, emphasis, links, and other inline elements */ function extractTextFromNode(node: any): string { if (!node) return ''; if (node.type === 'text') { return node.value || ''; } if (node.children && Array.isArray(node.children)) { return node.children.map((child: any) => extractTextFromNode(child)).join(''); } return ''; } // Official Lucide SVG icon paths for callouts const iconPaths: Record = { 'info': '', 'lightbulb': '', 'star': '', 'triangle-alert': '', 'circle-alert': '', 'circle-x': '', 'circle-help': '', 'circle-check': '', 'bug': '', 'code': '', 'quote': '', 'file-text': '' }; function getIconSVG(iconName: string): string { const path = iconPaths[iconName] || iconPaths['info']; return `${path}`; } const calloutMappings: Record = { note: { type: 'note', icon: 'info', title: 'Note' }, tip: { type: 'tip', icon: 'lightbulb', title: 'Tip' }, important: { type: 'important', icon: 'star', title: 'Important' }, warning: { type: 'warning', icon: 'triangle-alert', title: 'Warning' }, caution: { type: 'caution', icon: 'circle-alert', title: 'Caution' }, danger: { type: 'caution', icon: 'circle-x', title: 'Danger' }, info: { type: 'note', icon: 'info', title: 'Info' }, question: { type: 'important', icon: 'circle-help', title: 'Question' }, success: { type: 'tip', icon: 'circle-check', title: 'Success' }, failure: { type: 'caution', icon: 'circle-x', title: 'Failure' }, bug: { type: 'caution', icon: 'bug', title: 'Bug' }, example: { type: 'tip', icon: 'code', title: 'Example' }, quote: { type: 'note', icon: 'quote', title: 'Quote' }, abstract: { type: 'important', icon: 'file-text', title: 'Abstract' }, summary: { type: 'important', icon: 'file-text', title: 'Summary' }, tldr: { type: 'important', icon: 'file-text', title: 'TL;DR' }, attention: { type: 'warning', icon: 'triangle-alert', title: 'Attention' }, todo: { type: 'note', icon: 'info', title: 'Todo' }, check: { type: 'tip', icon: 'circle-check', title: 'Check' }, done: { type: 'tip', icon: 'circle-check', title: 'Done' }, help: { type: 'important', icon: 'circle-help', title: 'Help' }, faq: { type: 'important', icon: 'circle-help', title: 'FAQ' }, error: { type: 'caution', icon: 'circle-x', title: 'Error' } }; const remarkCallouts: Plugin<[], Root> = () => { return (tree) => { visit(tree, 'blockquote', (node: Blockquote, index, parent) => { // Check if this blockquote contains a callout pattern const firstChild = node.children[0]; if (!firstChild || firstChild.type !== 'paragraph') return; // Extract all text from the first paragraph to find the callout pattern const firstParagraph = firstChild as Paragraph; const paragraphText = extractTextFromNode(firstParagraph); // Match callout pattern at the start of the paragraph text // The custom title capture should stop at newline (title is on same line as callout) const calloutMatch = paragraphText.match(/^\[!([\w-]+)\]([+\-]?)(?:\s+([^\n]+))?/); if (!calloutMatch) return; const [fullMatch, calloutType, collapseState, customTitle] = calloutMatch; const calloutKey = calloutType.toLowerCase(); const mapping = calloutMappings[calloutKey] || { type: 'note', icon: 'info', title: calloutType.charAt(0).toUpperCase() + calloutType.slice(1) }; // Get remaining text after the callout syntax const remainingText = paragraphText.slice(fullMatch.length).trim(); const hasMultipleParagraphs = node.children.length > 1; // CRITICAL: Check if the FIRST text node starts with callout syntax followed by newline // This indicates the callout is on its own line, even if content follows in the same paragraph const firstTextNode = firstParagraph.children.find((child: any) => child.type === 'text') as Text | undefined; const firstTextValue = firstTextNode?.value || ''; // Check if first text node starts with callout syntax followed by newline // Pattern: "[!example]\n" or "[!example] \n" (with optional space before newline) const calloutStartsOnOwnLine = firstTextValue.match(/^\[![\w-]+\][+\-]?\s*\n/); // The callout is on its own line if: // 1. Multiple paragraphs exist, OR // 2. First text node starts with callout syntax + newline (callout on own line, content follows), OR // 3. No remaining text (callout only) const isCalloutOnOwnLine = hasMultipleParagraphs || calloutStartsOnOwnLine !== null || remainingText.length === 0; // Determine the actual title to use - ALWAYS use mapped title when callout is on its own line const calloutTitle = isCalloutOnOwnLine ? mapping.title // Use mapped title when callout is on its own line : (customTitle || mapping.title); // Use custom title if provided, otherwise mapped title // Determine if callout should be collapsible and its initial state const isCollapsible = collapseState === '+' || collapseState === '-'; const isCollapsed = collapseState === '-'; // Process the remaining content let contentChildren = [...node.children]; // Handle content based on structure: // - Multiple paragraphs: Remove first paragraph (callout line separate from content) // - Single paragraph, callout on own line (newline detected): Keep paragraph, remove callout syntax // - Single paragraph, callout only: Remove paragraph (no content) // - Single paragraph, callout with title on same line: Keep paragraph, remove callout syntax if (hasMultipleParagraphs) { // Multiple paragraphs - callout line is separate, remove first paragraph contentChildren = contentChildren.slice(1); } else if (calloutStartsOnOwnLine) { // Single paragraph but callout starts on its own line (has newline after callout) // Keep the paragraph but remove the callout syntax from the first text node if (firstTextNode) { // Remove callout syntax and newline from start of first text node const newlinePattern = /^\[![\w-]+\][+\-]?\s*\n\s*/; firstTextNode.value = firstTextNode.value.replace(newlinePattern, ''); } // Keep the paragraph (contentChildren already has it) } else if (remainingText.length === 0) { // Single paragraph with only callout syntax - remove it contentChildren = contentChildren.slice(1); } else if (remainingText) { // Single paragraph with text after callout - remove callout syntax, keep the text const updateTextNode = (node: any): boolean => { if (node.type === 'text' && node.value) { const textValue = node.value as string; if (textValue.includes(fullMatch)) { const index = textValue.indexOf(fullMatch); const before = textValue.slice(0, index); const after = textValue.slice(index + fullMatch.length).trim(); node.value = (before + (after ? ' ' + after : '')).trim(); return true; } } if (node.children && Array.isArray(node.children)) { for (const child of node.children) { if (updateTextNode(child)) return true; } } return false; }; updateTextNode(firstParagraph); } // Generate toggle button HTML if collapsible const toggleButton = isCollapsible ? `` : ''; // Transform the blockquote into a callout HTML structure const calloutHtml: any = { type: 'html', value: `
${getIconSVG(mapping.icon)} ${calloutTitle} ${toggleButton}
' }; // Replace the blockquote with the callout structure if (parent && typeof index === 'number') { parent.children.splice(index, 1, calloutHtml, ...contentChildren, closeHtml); } }); }; }; export default remarkCallouts;