Commit 1fa3980

HPCesia <me@hpcesia.com>
2025-04-17 16:55:21
feat: Obsidian callout
1 parent 05c9ebb
src/content/posts/markdown.md
@@ -82,20 +82,44 @@ You can also add footnotes[^1] or [reference links][refer].
 
 [GitHub blockquote alerts](https://github.com/orgs/community/discussions/16925) is also supported:
 
-> [!NOTE]
-> This is a note.
+> [!note]
+> Lorem ipsum dolor sit amet
 
-> [!TIP]
-> This is a tip.
+> [!abstract] With Title
+> Lorem ipsum dolor sit amet
 
-> [!IMPORTANT]
-> This is important.
+> [!info]- Default Collapsed
+> Lorem ipsum dolor sit amet
 
-> [!WARNING]
-> This is a warning.
+> [!todo]+ Default Expanded
+> Lorem ipsum dolor sit amet
 
-> [!CAUTION]
-> This is a caution.
+> [!tip]
+> Lorem ipsum dolor sit amet
+
+> [!success]
+> Lorem ipsum dolor sit amet
+
+> [!question]
+> Lorem ipsum dolor sit amet
+
+> [!warning]
+> Lorem ipsum dolor sit amet
+
+> [!failure]
+> Lorem ipsum dolor sit amet
+
+> [!danger]
+> Lorem ipsum dolor sit amet
+
+> [!bug]
+> Lorem ipsum dolor sit amet
+
+> [!example]
+> Lorem ipsum dolor sit amet
+
+> [!quote]
+> Lorem ipsum dolor sit amet
 
 And that's it!
 
src/plugins/remark-github-blockquote.ts
@@ -1,164 +0,0 @@
-import type { RemarkPlugin } from '@astrojs/markdown-remark';
-import { icons as IC } from '@iconify-json/ic';
-import { icons as MaterialSymbols } from '@iconify-json/material-symbols';
-import { icons as MDI } from '@iconify-json/mdi';
-import type { ExtendedIconifyIcon } from '@iconify/types';
-import { getIconData, iconToHTML, iconToSVG } from '@iconify/utils';
-import type { BlockContent, Data, DefinitionContent } from 'mdast';
-import type { Node } from 'unist';
-import { visit } from 'unist-util-visit';
-
-type TextFilter = string | RegExp | ((title: string) => string);
-type ClassNames = string | string[];
-type ClassNameMap = ClassNames | ((title: string) => ClassNames);
-
-export interface Config {
-  titleFilter: TextFilter[];
-  titleTextMap: (title: string) => {
-    displayTitle: (string | Node)[];
-    checkedTitle: string;
-  };
-  dataMaps: {
-    title: (data: Data) => Data;
-    block: (data: Data) => Data;
-  };
-  classNameMaps: {
-    title: ClassNameMap;
-    block: ClassNameMap;
-  };
-}
-
-const defaultConfig: Config = {
-  titleFilter: [
-    /\[!NOTE(:.*?)?\]/,
-    /\[!TIP(:.*?)?\]/,
-    /\[!IMPORTANT(:.*?)?\]/,
-    /\[!CAUTION(:.*?)?\]/,
-    /\[!WARNING(:.*?)?\]/,
-  ],
-  titleTextMap: (title) => {
-    const [type, text] = title.substring(2, title.length - 1).split(':');
-    let iconData: ExtendedIconifyIcon | null;
-    switch (type) {
-      case 'NOTE':
-        iconData = getIconData(MaterialSymbols, 'info-outline-rounded');
-        break;
-      case 'TIP':
-        iconData = getIconData(IC, 'outline-tips-and-updates');
-        break;
-      case 'IMPORTANT':
-        iconData = getIconData(MaterialSymbols, 'chat-info-outline-rounded');
-        break;
-      case 'CAUTION':
-        iconData = getIconData(MDI, 'alert-octagon-outline');
-        break;
-      case 'WARNING':
-        iconData = getIconData(MaterialSymbols, 'warning-rounded');
-        break;
-      default:
-        iconData = getIconData(MaterialSymbols, 'info-outline-rounded');
-        break;
-    }
-    if (!iconData) {
-      console.error(`GitHub blockquote icon not found: ${type}`);
-      return {
-        displayTitle: [text || type],
-        checkedTitle: type,
-      };
-    }
-    const { attributes, body } = iconToSVG(iconData);
-    const icon = {
-      type: 'html',
-      value: iconToHTML(body, attributes),
-    } as Node;
-    return {
-      displayTitle: [icon, text || type],
-      checkedTitle: type,
-    };
-  },
-  dataMaps: {
-    title: (data) => data,
-    block: (data) => data,
-  },
-  classNameMaps: {
-    title: 'admonition-title',
-    block: (title) => `admonition admonition-${title.toLowerCase()}`,
-  },
-};
-
-function nameFilter(fliters: TextFilter[]): (title: string) => boolean {
-  return (title) => {
-    for (const filter of fliters) {
-      if (typeof filter == 'string') {
-        if (title.startsWith(filter)) return true;
-      } else if (filter instanceof RegExp) {
-        if (filter.test(title)) return true;
-      } else if (typeof filter == 'function') {
-        if (filter(title)) return true;
-      }
-    }
-    return false;
-  };
-}
-
-function classNameMap(gen: ClassNameMap): (title: string) => string {
-  return (title) => {
-    const classNames = typeof gen == 'function' ? gen(title) : gen;
-    return typeof classNames == 'object' ? classNames.join(' ') : classNames;
-  };
-}
-
-export const remarkGithubBlockquote: RemarkPlugin<Partial<Config>[]> = function (...params) {
-  const providedConfig = params.reduce((a, b) => ({ ...a, ...b }), {});
-  const config = { ...defaultConfig, ...providedConfig };
-  return (tree) => {
-    visit(tree, (node) => {
-      if (node.type != 'blockquote') return;
-      const blockquote = node;
-      if (blockquote.children[0]?.type != 'paragraph') return;
-      const paragraph = blockquote.children[0];
-      if (paragraph.children[0]?.type != 'text') return;
-      const text = paragraph.children[0];
-      let title;
-      const titleEnd = text.value.indexOf('\n');
-      if (titleEnd < 0) {
-        if (paragraph.children.length > 1) {
-          if (paragraph.children.at(1)?.type == 'break') {
-            paragraph.children.splice(1, 1);
-          } else {
-            return;
-          }
-        }
-        title = text.value;
-        if (!nameFilter(config.titleFilter)(title)) return;
-        paragraph.children.shift();
-      } else {
-        const textBody = text.value.substring(titleEnd + 1);
-        title = text.value.substring(0, titleEnd);
-        const m = /[ \t\v\f\r]+$/.exec(title);
-        if (m) {
-          title = title.substring(0, title.length - m[0].length);
-        }
-        if (!nameFilter(config.titleFilter)(title)) return;
-        text.value = textBody;
-      }
-      const { displayTitle, checkedTitle } = config.titleTextMap(title);
-      const paragraphTitleTexts = displayTitle.map((text) =>
-        typeof text == 'string' ? { type: 'text', value: text } : text
-      );
-      const paragraphTitle = {
-        type: 'paragraph',
-        children: [...paragraphTitleTexts],
-        data: config.dataMaps.title({
-          hProperties: { className: classNameMap(config.classNameMaps.title)(checkedTitle) },
-        }),
-      } as BlockContent | DefinitionContent;
-      blockquote.children.unshift(paragraphTitle);
-      blockquote.data = config.dataMaps.block({
-        ...blockquote.data,
-        hProperties: { className: classNameMap(config.classNameMaps.block)(checkedTitle) },
-        hName: 'div',
-      });
-    });
-  };
-};
src/plugins/remark-obsidian-callout.ts
@@ -0,0 +1,171 @@
+import type { RemarkPlugin } from '@astrojs/markdown-remark';
+import type { IconifyJSON } from '@iconify/types';
+import { getIconData, iconToHTML, iconToSVG } from '@iconify/utils';
+import type { BlockContent, DefinitionContent } from 'mdast';
+import { visit } from 'unist-util-visit';
+
+const calloutRegex = /^\[!(\w+)\]([+-]?)/;
+
+const callouts: Record<string, string> = {
+  note: 'mingcute:pencil-line',
+  abstract: 'mdi:clipboard-list-outline',
+  summary: 'mdi:clipboard-list-outline',
+  tldr: 'mdi:clipboard-list-outline',
+  info: 'material-symbols:info-outline-rounded',
+  todo: 'material-symbols:check-circle-outline-rounded',
+  tip: 'mdi:flame',
+  hint: 'mdi:flame',
+  important: 'mdi:flame',
+  success: 'material-symbols:check-rounded',
+  check: 'material-symbols:check-rounded',
+  done: 'material-symbols:check-rounded',
+  question: 'material-symbols:help-outline-rounded',
+  help: 'material-symbols:help-outline-rounded',
+  faq: 'material-symbols:help-outline-rounded',
+  warning: 'mingcute:alert-fill',
+  attention: 'mingcute:alert-fill',
+  caution: 'mingcute:alert-fill',
+  failure: 'mingcute:close-fill',
+  missing: 'mingcute:close-fill',
+  fail: 'mingcute:close-fill',
+  danger: 'mdi:lightning-bolt-outline',
+  error: 'mdi:lightning-bolt-outline',
+  bug: 'mingcute:bug-line',
+  example: 'mingcute:list-check-line',
+  quote: 'mdi:format-quote-close-outline',
+  cite: 'mdi:format-quote-close-outline',
+};
+
+const iconNameRegex = /([\w-]+):([\w-]+)/;
+
+const iconSets = await (async () => {
+  const sets: Record<string, IconifyJSON> = {};
+  await Promise.all(
+    Object.values(callouts).map(async (name) => {
+      const matched = name.match(iconNameRegex);
+      if (!matched) throw new Error(`Invalid icon name: "${name}"`);
+      const [, set] = matched;
+      if (set in sets) return;
+      const { icons } = (await import(/* @vite-ignore */ `@iconify-json/${set}`)) as {
+        icons: IconifyJSON;
+      };
+      sets[set] = icons;
+    })
+  );
+  return sets;
+})();
+
+const iconCache = new Map<string, string | null>(
+  Object.keys(callouts).map((key) => [key, null])
+);
+
+function getIconSvg(name: string) {
+  const matched = name.match(iconNameRegex);
+  if (!matched) return `<div class="hidden">Invalid Icon: ${name}</div>`;
+  const [, iconSetName, iconName] = matched;
+  try {
+    const data = getIconData(iconSets[iconSetName], iconName);
+    if (!data)
+      return `<div class="hidden">Import Icon: ${name} failed: Icon not found in set ${iconSetName}</div>`;
+    const { attributes, body } = iconToSVG(data);
+    const svgHtml = iconToHTML(body, attributes);
+    return svgHtml;
+  } catch (err) {
+    return `<div class="hidden">Import Icon: ${name} failed: ${err}</div>`;
+  }
+}
+
+export const remarkObsidianCallout: RemarkPlugin = function () {
+  return (tree) => {
+    visit(tree, (node) => {
+      if (node.type != 'blockquote') return;
+
+      const blockquote = node;
+      if (blockquote.children[0]?.type != 'paragraph') return;
+
+      const paragraph = blockquote.children[0];
+      if (paragraph.children[0]?.type != 'text') return;
+
+      const [firstLine, ...remainLines] = paragraph.children[0].value.split('\n');
+      const remainContents = remainLines.join('\n');
+      const matched = firstLine.match(calloutRegex);
+      if (!matched) return;
+
+      const [, calloutType, expandCollapseSign] = matched;
+      if (!calloutType) return;
+      const validCalloutType = calloutType.toLowerCase();
+      const expandable = Boolean(expandCollapseSign);
+      const expanded = expandCollapseSign === '+';
+
+      let svg: string | null = null;
+      const calloutKey = validCalloutType in callouts ? validCalloutType : 'note';
+      svg = iconCache.get(calloutKey)!;
+      if (svg === null) {
+        const iconName = callouts[calloutKey];
+        svg = getIconSvg(iconName);
+        iconCache.set(calloutKey, svg);
+      }
+      let titleText = firstLine.slice(matched[0].length).trim();
+      if (titleText.length === 0) titleText = validCalloutType;
+
+      const titleNode: BlockContent | DefinitionContent = {
+        type: 'paragraph',
+        children: [
+          {
+            type: 'html',
+            value: svg,
+          },
+          {
+            type: 'text',
+            value: titleText,
+            data: {
+              hName: 'span',
+            },
+          },
+        ],
+        data: {
+          hProperties: { className: `callout-title${expandable ? ' collapse-title' : ''}` },
+          hName: 'div',
+        },
+      };
+
+      blockquote.children = [
+        {
+          type: 'html',
+          value: expandable
+            ? `<input type="checkbox" ${expanded ? 'checked="true"' : ''}>`
+            : '',
+        },
+        titleNode,
+        {
+          type: 'blockquote',
+          children: [
+            {
+              type: 'paragraph',
+              children: [
+                {
+                  type: 'text',
+                  value: remainContents,
+                },
+              ],
+            },
+          ],
+          data: {
+            hName: 'div',
+            hProperties: {
+              ...(expandable ? { class: 'collapse-content' } : {}),
+            },
+          },
+        },
+      ];
+
+      blockquote.data = {
+        ...blockquote.data,
+        hProperties: {
+          'data-callout': validCalloutType,
+          ...(expandable ? { class: 'collapse-arrow collapse' } : {}),
+        },
+      };
+    });
+  };
+};
src/styles/markdown.css
@@ -238,69 +238,84 @@ article {
 
   /* 引用块样式 */
   blockquote {
-    padding: 0.25rem 0.25rem 0.25rem 0.75rem;
-
-    @apply border-primary bg-primary/10 my-2 rounded-sm border-l-4;
-  }
+    @apply border-l-4;
 
-  /* GitHub Alert 样式 */
-  .admonition {
+    --color-blockquote-callout: var(--color-neutral-500);
     padding: 0.25rem 0.25rem 0.25rem 0.75rem;
+    margin-block: 0.5rem;
+    border-radius: var(--radius-sm, 0.25rem);
+    background-color: color-mix(in oklab, var(--color-blockquote-callout) 10%, transparent);
+    border-color: var(--color-blockquote-callout);
 
-    @apply my-4 rounded-sm border-l-4;
+    /* Obsidian Callout Style */
+    &[data-callout] {
+      @apply grid grid-cols-1;
 
-    .admonition-title {
-      @apply inline-flex items-center gap-1 text-lg font-bold;
-    }
+      > .callout-title {
+        @apply flex flex-row items-center gap-2 p-0 text-lg font-semibold;
 
-    &.admonition-note {
-      @apply border-info bg-info/10;
+        color: var(--color-blockquote-callout);
 
-      --color-primary: var(--color-info);
-
-      .admonition-title {
-        @apply text-info;
-      }
-    }
-
-    &.admonition-tip {
-      @apply border-success bg-success/10;
+        svg {
+          @apply h-5 w-5;
+        }
 
-      --color-primary: var(--color-success);
+        &:not(.collapse-title) {
+          @apply mb-2;
+        }
 
-      .admonition-title {
-        @apply text-success;
+        &.collapse-title {
+          @apply min-h-9;
+        }
       }
-    }
-
-    &.admonition-important {
-      @apply border-accent bg-accent/10;
 
-      --color-primary: var(--color-accent);
-
-      .admonition-title {
-        @apply text-accent;
+      &.collapse > input[type='checkbox'] {
+        @apply min-h-9;
       }
-    }
-
-    &.admonition-warning {
-      @apply border-warning bg-warning/10;
 
-      --color-primary: var(--color-warning);
+      &.collapse-arrow .collapse-title::after {
+        top: 1.3rem;
+      }
 
-      .admonition-title {
-        @apply text-warning;
+      > .collapse-content {
+        @apply p-0;
       }
     }
 
-    &.admonition-caution {
-      @apply border-error bg-error/10;
-
-      --color-primary: var(--color-error);
-
-      .admonition-title {
-        @apply text-error;
-      }
+    &[data-callout='tip'],
+    &[data-callout='hint'],
+    &[data-callout='important'] {
+      --color-blockquote-callout: var(--color-primary);
+    }
+    &[data-callout='info'],
+    &[data-callout='abstract'],
+    &[data-callout='summary'],
+    &[data-callout='tldr'],
+    &[data-callout='todo'],
+    &[data-callout='help'],
+    &[data-callout='faq'] {
+      --color-blockquote-callout: var(--color-info);
+    }
+    &[data-callout='success'],
+    &[data-callout='check'],
+    &[data-callout='done'] {
+      --color-blockquote-callout: var(--color-success);
+    }
+    &[data-callout='warning'],
+    &[data-callout='attention'],
+    &[data-callout='caution'],
+    &[data-callout='question'] {
+      --color-blockquote-callout: var(--color-warning);
+    }
+    &[data-callout='danger'],
+    &[data-callout='bug'],
+    &[data-callout='failure'],
+    &[data-callout='fail'],
+    &[data-callout='missing'] {
+      --color-blockquote-callout: var(--color-error);
+    }
+    &[data-callout='example'] {
+      --color-blockquote-callout: var(--color-indigo-500);
     }
   }
 
astro.config.mjs
@@ -6,9 +6,9 @@ import { rehypeWrapTables } from './src/plugins/rehype-wrap-tables.ts';
 import { remarkArticleReferences } from './src/plugins/remark-article-references';
 import { remarkCreateTime } from './src/plugins/remark-create-time.ts';
 import { remarkExcerpt } from './src/plugins/remark-excerpt.ts';
-import { remarkGithubBlockquote } from './src/plugins/remark-github-blockquote.ts';
 // import { remarkHeadingShift } from './src/plugins/remark-heading-shift.ts';
 import { remarkImageProcess } from './src/plugins/remark-image-process.ts';
+import { remarkObsidianCallout } from './src/plugins/remark-obsidian-callout.ts';
 import { remarkReadingTime } from './src/plugins/remark-reading-time.ts';
 import { wrapCode } from './src/plugins/shiki-transformers.ts';
 import { rehypeHeadingIds } from '@astrojs/markdown-remark';
@@ -64,7 +64,7 @@ export default defineConfig({
       remarkReadingTime,
       remarkExcerpt,
       remarkImageProcess,
-      remarkGithubBlockquote,
+      remarkObsidianCallout,
       remarkArticleReferences,
     ],
     rehypePlugins: [
package.json
@@ -18,7 +18,6 @@
     "@astrojs/rss": "^4.0.11",
     "@astrojs/sitemap": "^3.3.0",
     "@astrojs/vue": "^5.0.10",
-    "@iconify-json/ic": "^1.2.2",
     "@iconify-json/material-symbols": "^1.2.19",
     "@iconify-json/mdi": "^1.2.3",
     "@iconify-json/mingcute": "^1.2.3",