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
JavaScript
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