Commit 1fa3980
Changed files (6)
src
content
posts
styles
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",