master
  1import type { RemarkPlugin } from '@astrojs/markdown-remark';
  2import type { IconifyJSON } from '@iconify/types';
  3import { getIconData, iconToHTML, iconToSVG } from '@iconify/utils';
  4import type { BlockContent, DefinitionContent } from 'mdast';
  5import { visit } from 'unist-util-visit';
  6
  7const calloutRegex = /^\[!(\w+)\]([+-]?)/;
  8
  9const callouts: Record<string, string> = {
 10  note: 'mingcute:pencil-line',
 11  abstract: 'mdi:clipboard-list-outline',
 12  summary: 'mdi:clipboard-list-outline',
 13  tldr: 'mdi:clipboard-list-outline',
 14  info: 'material-symbols:info-outline-rounded',
 15  todo: 'material-symbols:check-circle-outline-rounded',
 16  tip: 'mdi:flame',
 17  hint: 'mdi:flame',
 18  important: 'mdi:flame',
 19  success: 'material-symbols:check-rounded',
 20  check: 'material-symbols:check-rounded',
 21  done: 'material-symbols:check-rounded',
 22  question: 'material-symbols:help-outline-rounded',
 23  help: 'material-symbols:help-outline-rounded',
 24  faq: 'material-symbols:help-outline-rounded',
 25  warning: 'mingcute:alert-fill',
 26  attention: 'mingcute:alert-fill',
 27  caution: 'mingcute:alert-fill',
 28  failure: 'mingcute:close-fill',
 29  missing: 'mingcute:close-fill',
 30  fail: 'mingcute:close-fill',
 31  danger: 'mdi:lightning-bolt-outline',
 32  error: 'mdi:lightning-bolt-outline',
 33  bug: 'mingcute:bug-line',
 34  example: 'mingcute:list-check-line',
 35  quote: 'mdi:format-quote-close-outline',
 36  cite: 'mdi:format-quote-close-outline',
 37};
 38
 39const iconNameRegex = /([\w-]+):([\w-]+)/;
 40
 41const iconSets = await (async () => {
 42  const sets: Record<string, IconifyJSON> = {};
 43  await Promise.all(
 44    Object.values(callouts).map(async (name) => {
 45      const matched = name.match(iconNameRegex);
 46      if (!matched) throw new Error(`Invalid icon name: "${name}"`);
 47      const [, set] = matched;
 48      if (set in sets) return;
 49      const { icons } = (await import(/* @vite-ignore */ `@iconify-json/${set}`)) as {
 50        icons: IconifyJSON;
 51      };
 52      sets[set] = icons;
 53    })
 54  );
 55  return sets;
 56})();
 57
 58const iconCache = new Map<string, string | null>(
 59  Object.keys(callouts).map((key) => [key, null])
 60);
 61
 62function getIconSvg(name: string) {
 63  const matched = name.match(iconNameRegex);
 64  if (!matched) return `<div class="hidden">Invalid Icon: ${name}</div>`;
 65  const [, iconSetName, iconName] = matched;
 66  try {
 67    const data = getIconData(iconSets[iconSetName], iconName);
 68    if (!data)
 69      return `<div class="hidden">Import Icon: ${name} failed: Icon not found in set ${iconSetName}</div>`;
 70    const { attributes, body } = iconToSVG(data);
 71    const svgHtml = iconToHTML(body, attributes);
 72    return svgHtml;
 73  } catch (err) {
 74    return `<div class="hidden">Import Icon: ${name} failed: ${err}</div>`;
 75  }
 76}
 77
 78export const remarkObsidianCallout: RemarkPlugin = function () {
 79  return (tree) => {
 80    visit(tree, (node) => {
 81      if (node.type != 'blockquote') return;
 82
 83      const blockquote = node;
 84      if (blockquote.children[0]?.type != 'paragraph') return;
 85
 86      const paragraph = blockquote.children[0];
 87      if (paragraph.children[0]?.type != 'text') return;
 88
 89      const [firstLine, ...remainLines] = paragraph.children[0].value.split('\n');
 90      const remainContents = remainLines.join('\n');
 91      const matched = firstLine.match(calloutRegex);
 92      if (!matched) return;
 93
 94      const [, calloutType, expandCollapseSign] = matched;
 95      if (!calloutType) return;
 96      const validCalloutType = calloutType.toLowerCase();
 97      const expandable = Boolean(expandCollapseSign);
 98      const expanded = expandCollapseSign === '+';
 99
100      let svg: string | null = null;
101      const calloutKey = validCalloutType in callouts ? validCalloutType : 'note';
102      svg = iconCache.get(calloutKey)!;
103      if (svg === null) {
104        const iconName = callouts[calloutKey];
105        svg = getIconSvg(iconName);
106        iconCache.set(calloutKey, svg);
107      }
108      let titleText = firstLine.slice(matched[0].length).trim();
109      if (titleText.length === 0) titleText = validCalloutType;
110
111      const titleNode: BlockContent | DefinitionContent = {
112        type: 'paragraph',
113        children: [
114          {
115            type: 'html',
116            value: svg,
117          },
118          {
119            type: 'text',
120            value: titleText,
121            data: {
122              hName: 'span',
123            },
124          },
125        ],
126        data: {
127          hProperties: { className: `callout-title${expandable ? ' collapse-title' : ''}` },
128          hName: 'div',
129        },
130      };
131
132      blockquote.children = [
133        {
134          type: 'html',
135          value: expandable
136            ? `<input type="checkbox" ${expanded ? 'checked="true"' : ''}>`
137            : '',
138        },
139        titleNode,
140        {
141          type: 'blockquote',
142          children: [
143            {
144              type: 'paragraph',
145              children: [
146                {
147                  type: 'text',
148                  value: remainContents,
149                },
150                ...paragraph.children.slice(1),
151              ],
152            },
153            ...blockquote.children.slice(1),
154          ],
155          data: {
156            hName: 'div',
157            hProperties: {
158              ...(expandable ? { class: 'collapse-content' } : {}),
159            },
160          },
161        },
162      ];
163
164      blockquote.data = {
165        ...blockquote.data,
166        hProperties: {
167          'data-callout': validCalloutType,
168          ...(expandable ? { class: 'collapse-arrow collapse' } : {}),
169        },
170      };
171    });
172  };
173};