UNPKG

rehype-callouts

Version:

Rehype plugin for processing and rendering blockquote-based callouts (admonitions/alerts).

265 lines (264 loc) 8.58 kB
import { fromHtml } from 'hast-util-from-html'; import { h } from 'hastscript'; import { githubCallouts } from './themes/github/config.js'; import { obsidianCallouts } from './themes/obsidian/config.js'; import { vitepressCallouts } from './themes/vitepress/config.js'; export const calloutRegex = /\[!(?<type>\w+)](?<collapsable>[+-]?)\s*(?<title>.*)/g; export const splitByNewlineRegex = /(?<prefix>[^\n]*)\n(?<suffix>[\S\s]*)/g; export const themes = { github: githubCallouts, obsidian: obsidianCallouts, vitepress: vitepressCallouts, }; export const defaultClassNames = { container: 'callout', title: 'callout-title', content: 'callout-content', titleIcon: 'callout-title-icon', foldIcon: 'callout-fold-icon', titleText: 'callout-title-text', }; /** * Call a function to get a return value or use the value. */ export function createIfNeeded(value, node, type) { return typeof value === 'function' ? value(node, type) : value; } /** * Converts aproperty keys of the object to lowercase. */ function convertKeysToLowercase(object) { const newObject = {}; for (const key of Object.keys(object)) { newObject[key.toLowerCase()] = object[key]; } return newObject; } /** * Constructs the configuration. */ export function getConfig(userOptions) { const defaultOptions = { theme: 'obsidian', callouts: themes.obsidian, aliases: {}, showIndicator: true, tags: { nonCollapsibleContainerTagName: 'div', nonCollapsibleTitleTagName: 'div', contentTagName: 'div', titleIconTagName: 'div', titleTextTagName: 'div', foldIconTagName: 'div', }, props: { containerProps: null, titleProps: null, contentProps: null, titleIconProps: null, titleTextProps: null, foldIconProps: null, }, }; if (userOptions) { const { theme, callouts, aliases } = userOptions; if (callouts) userOptions.callouts = convertKeysToLowercase(callouts); if (aliases) userOptions.aliases = convertKeysToLowercase(aliases); const initCallouts = theme ? themes[theme] : themes.obsidian; const mergedCallouts = { ...initCallouts }; if (userOptions.callouts) { for (const key of Object.keys(userOptions.callouts)) { mergedCallouts[key] = { ...initCallouts[key], ...userOptions.callouts[key], }; } } return { theme: userOptions.theme ?? defaultOptions.theme, callouts: mergedCallouts, aliases: { ...defaultOptions.aliases, ...userOptions.aliases }, showIndicator: userOptions.showIndicator ?? defaultOptions.showIndicator, tags: { ...defaultOptions.tags, ...userOptions.tags, }, props: { ...defaultOptions.props, ...userOptions.props, }, }; } return defaultOptions; } /** * Expands the original callouts object based on aliases. */ export function expandCallouts(callouts, aliases) { if (Object.keys(aliases).length === 0) return {}; const expandedCallouts = structuredClone(callouts); const aliasMap = {}; for (const [key, aliasList] of Object.entries(aliases)) { const lowerKey = key.toLowerCase(); const originalCallout = expandedCallouts[lowerKey]; if (originalCallout) { const processedAliases = new Set(); for (const alias of aliasList) { const lowerAlias = alias.toLowerCase(); if (!processedAliases.has(lowerAlias)) { aliasMap[lowerAlias] = lowerKey; processedAliases.add(lowerAlias); } } } } return aliasMap; } /** * Cleanup due to double spaces after title in Markdown being * converted to <br> tags. */ export function handleBrAfterTitle(children) { return children.filter((child) => { if (child.type === 'element' && child.tagName === 'br') { return false; } return true; }); } /** * Finds the index of the first text node containing a newline. */ export function findFirstNewline(children) { for (const [i, c] of children.entries()) { if (c.type === 'text' && c.value.includes('\n')) { return i; } } return -1; } /** * Checks if a node is a text node. */ function isText(node) { return node.type === 'text'; } /** * Merges consecutive text nodes in a HAST children array * until the first non-text node is encountered. * * In Svelte, the AST will be: * ``` * children: [ * { type: 'text', value: '[!note]', position: [Object] }, * { type: 'text', value: '- xxx', position: [Object] }, * ] * ``` * instead of: * ``` * children: [ * { type: 'text', value: '[!note]- xxx', position: [Object] } * ] * ``` * when markdown is: `![note]- xxx` */ export function mergeConsecutiveTextNodes(children) { const firstNonTextIndex = children.findIndex((node) => !isText(node)); // case 1: if all nodes are text if (firstNonTextIndex === -1) { if (children.length > 1) { const mergedValue = children .map((n) => n.value || '') .join(''); const firstTextNode = children[0]; firstTextNode.value = mergedValue; delete firstTextNode.position; children.splice(1); } return; } // case 2: if there are non-text nodes, // only consider the text nodes before the first non-text node if (firstNonTextIndex > 1) { let mergedValue = ''; for (let i = 0; i < firstNonTextIndex; i++) { const node = children[i]; if (isText(node)) { mergedValue += node.value; } } const firstTextNode = children[0]; firstTextNode.value = mergedValue; delete firstTextNode.position; children.splice(1, firstNonTextIndex - 1); } // case 3: if the first non-text node is preceded // by only one text node or no text node, no need to merge } /** * Merges user-defined `class` or `className` fields * with a default class name, returning a new properties object. */ export function getProperties(props, defaultClassName) { const newProps = props ? { ...props } : {}; const classes = new Set(); const addClasses = (value) => { if (typeof value === 'string') { for (const c of value.split(/\s+/)) { if (c) classes.add(c); } } else if (Array.isArray(value)) { for (const c of value) { if (typeof c === 'string' && c) { classes.add(c); } } } }; if (!newProps.class && !newProps.className) { classes.add(defaultClassName); } if (newProps.class !== undefined && newProps.class !== null) { addClasses(newProps.class); delete newProps.class; } if (newProps.className !== undefined && newProps.className !== null) { addClasses(newProps.className); delete newProps.className; } newProps.className = [...classes]; return newProps; } /** * Fetches a callout's visual indicator. */ export function getIndicator(callouts, type, tag, props) { const indicator = callouts[type]?.indicator; if (!indicator) return null; const indicatorElement = fromHtml(indicator, { space: 'svg', fragment: true, }); const properties = getProperties(props, defaultClassNames.titleIcon); properties['aria-hidden'] = 'true'; return h(tag, properties, indicatorElement); } /** * Get fold icon when callout is collapsible. */ export function getFoldIcon(tag, props) { const icon = '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"></path></svg>'; const foldIconElement = fromHtml(icon, { space: 'svg', fragment: true, }); const properties = getProperties(props, defaultClassNames.foldIcon); properties['aria-hidden'] = 'true'; return h(tag, properties, foldIconElement); }