UNPKG

remark-github-markdown-alerts

Version:

An unifiedjs (remark) plugin to convert GitHub Markdown alerts syntax into actual UI

273 lines (270 loc) 9.76 kB
import { fromHtml } from 'hast-util-from-html'; import { toHtml } from 'hast-util-to-html'; import { h } from 'hastscript'; import { toString } from 'mdast-util-to-string'; import { u } from 'unist-builder'; import { visit } from 'unist-util-visit'; const DEFAULT_CONFIG = { iconElementHtml: '', tags: { container: 'div', icon: 'span', title: 'div', content: 'div', }, classNames: { container: 'markdown-alert', icon: 'markdown-alert-icon', title: 'markdown-alert-title', content: 'markdown-alert-content', }, }; const DEFAULT_ICONS = { note: '', tip: '', important: '', warning: '', caution: '', }; function isAlertBlockquote(node) { if (!node.children || node.children.length === 0) { return { isAlert: false }; } const firstChild = node.children[0]; if (!firstChild || firstChild.type !== 'paragraph') { return { isAlert: false }; } const firstTextNode = firstChild.children[0]; if (!firstTextNode || firstTextNode.type !== 'text') { return { isAlert: false }; } const text = firstTextNode.value; const firstLine = text.split('\n')[0] || ''; const alertMatch = firstLine.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?:\s+(.*))?$/); if (!alertMatch) { return { isAlert: false }; } const alertType = alertMatch[1]; const customTitle = alertMatch[2]?.trim(); return { isAlert: true, type: alertType, title: customTitle || alertType.charAt(0) + alertType.slice(1).toLowerCase(), }; } function detectRenderMode(file) { if (!file) return 'html'; const data = file.data || {}; if (data['reactMarkdown'] === true) { return 'component'; } if (data['mdx'] === true || file.extname === '.mdx') { return 'component'; } if (data['allowDangerousHtml'] === false) { return 'component'; } return 'html'; } function parseIconHtml(iconHtml) { if (!iconHtml || !iconHtml.trim()) { return []; } try { const hastTree = fromHtml(iconHtml, { fragment: true }); if (hastTree.type === 'root' && hastTree.children && hastTree.children.length > 0) { return hastTree.children.filter((child) => child.type === 'element' || child.type === 'text'); } return []; } catch (error) { console.warn('Invalid HTML in icon configuration, skipping icon:', error); return []; } } function createAlertComponent(type, title, children, config) { const alertTypeKey = type.toLowerCase(); const containerClasses = [ config.classNames.container, `${config.classNames.container}-${alertTypeKey}`, ] .filter(Boolean) .join(' '); const iconChildren = parseIconHtml(config.iconElementHtml); return u(config.tags.container, { data: { hName: config.tags.container, hProperties: { className: containerClasses, 'data-alert-type': alertTypeKey, }, }, }, [ u(config.tags.title, { data: { hName: config.tags.title, hProperties: { className: config.classNames.title, }, }, }, [ u(config.tags.icon, { data: { hName: config.tags.icon, hProperties: { className: config.classNames.icon, }, hChildren: iconChildren, }, }), u('text', title), ]), u(config.tags.content, { data: { hName: config.tags.content, hProperties: { className: config.classNames.content, }, }, }, children), ]); } function createAlertHtml(type, title, content, config) { const alertTypeKey = type.toLowerCase(); const containerClasses = [ config.classNames.container, `${config.classNames.container}-${alertTypeKey}`, ] .filter(Boolean) .join(' '); const iconHtml = config.iconElementHtml || DEFAULT_ICONS[alertTypeKey]; let iconElements = []; if (iconHtml) { try { const hastTree = fromHtml(iconHtml, { fragment: true }); if (hastTree.type === 'root' && hastTree.children) { iconElements = hastTree.children.filter((child) => child.type === 'element' || child.type === 'text'); } } catch (error) { console.warn('Invalid HTML in icon configuration, skipping icon:', error); } } const titleElement = h(config.tags.title, { class: config.classNames.title }, [ h(config.tags.icon, { class: config.classNames.icon }, iconElements), title, ]); const contentElement = h(config.tags.content, { class: config.classNames.content }); contentElement.children = [ { type: 'raw', value: content }, ]; const containerElement = h(config.tags.container, { class: containerClasses, }, [titleElement, contentElement]); return toHtml(containerElement, { allowDangerousHtml: true }); } function mergeConfig(defaultConfig, userConfig) { if (!userConfig) return defaultConfig; return { iconElementHtml: userConfig.iconElementHtml ?? defaultConfig.iconElementHtml, tags: { container: userConfig.tags?.container ?? defaultConfig.tags.container, icon: userConfig.tags?.icon ?? defaultConfig.tags.icon, title: userConfig.tags?.title ?? defaultConfig.tags.title, content: userConfig.tags?.content ?? defaultConfig.tags.content, }, classNames: { container: userConfig.classNames?.container ?? defaultConfig.classNames.container, icon: userConfig.classNames?.icon ?? defaultConfig.classNames.icon, title: userConfig.classNames?.title ?? defaultConfig.classNames.title, content: userConfig.classNames?.content ?? defaultConfig.classNames.content, }, }; } function processBlockquoteContent(node) { const firstParagraph = node.children[0]; const firstTextNode = firstParagraph.children[0]; const alertDeclarationMatch = firstTextNode.value.match(/^\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?:\s+.*)?$/); if (alertDeclarationMatch) { const remainingText = firstTextNode.value .replace(/^\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/, '') .trim(); if (remainingText) { firstTextNode.value = remainingText; } else { firstParagraph.children.shift(); if (firstParagraph.children.length === 0) { node.children.shift(); } } } return node.children; } function processBlockquoteAsComponent(node, index, parent, baseConfig, alerts) { if (!parent || typeof index !== 'number') return false; const alertInfo = isAlertBlockquote(node); if (!alertInfo.isAlert || !alertInfo.type || !alertInfo.title) return false; const alertTypeKey = alertInfo.type.toLowerCase(); const alertConfig = mergeConfig(baseConfig, alerts[alertTypeKey]); const processedChildren = processBlockquoteContent(node); const componentNode = createAlertComponent(alertInfo.type, alertInfo.title, processedChildren, alertConfig); if (parent && typeof parent === 'object' && 'children' in parent && Array.isArray(parent.children)) { parent.children[index] = componentNode; } return true; } function processBlockquoteAsHtml(node, index, parent, baseConfig, alerts) { if (!parent || typeof index !== 'number') return false; const alertInfo = isAlertBlockquote(node); if (!alertInfo.isAlert || !alertInfo.type || !alertInfo.title) return false; const alertTypeKey = alertInfo.type.toLowerCase(); const alertConfig = mergeConfig(baseConfig, alerts[alertTypeKey]); processBlockquoteContent(node); const contentHtml = node.children .map(child => { if (child.type === 'paragraph') { const textContent = toString(child); return toHtml(h('p', textContent)); } return ''; }) .join('\n '); const alertHtml = createAlertHtml(alertInfo.type, alertInfo.title, contentHtml, alertConfig); const htmlNode = u('html', alertHtml); if (parent && typeof parent === 'object' && 'children' in parent && Array.isArray(parent.children)) { parent.children[index] = htmlNode; } return true; } function processBlockquote(node, index, parent, baseConfig, alerts, mode = 'html') { if (mode === 'component') { return processBlockquoteAsComponent(node, index, parent, baseConfig, alerts); } return processBlockquoteAsHtml(node, index, parent, baseConfig, alerts); } const remarkGitHubAlerts = (options = {}) => { const { alerts = {}, defaultConfig, mode = 'auto' } = options; const baseConfig = mergeConfig(DEFAULT_CONFIG, defaultConfig); return (tree, file) => { const renderMode = mode === 'auto' ? detectRenderMode(file) : mode; visit(tree, 'blockquote', (node, index, parent) => { processBlockquote(node, index, parent, baseConfig, alerts, renderMode); }); return tree; }; }; export { remarkGitHubAlerts as default, processBlockquote, processBlockquoteAsComponent, processBlockquoteAsHtml, remarkGitHubAlerts }; //# sourceMappingURL=index.esm.js.map