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};