From af388a25637e5246b3236f34cb9c309e68f5909e Mon Sep 17 00:00:00 2001
From: biss
Date: Fri, 19 Jun 2026 09:58:13 +0800
Subject: [PATCH] =?UTF-8?q?=E6=96=87=E7=AB=A0=E6=B8=B2=E6=9F=93=E4=BC=98?=
=?UTF-8?q?=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
astro.config.mjs | 4 +-
src/lib/remarkButterflyTags.mjs | 1 +
src/lib/remarkHexoLinkCards.mjs | 199 +++++++++++++++++++++--
src/pages/posts/[slug].astro | 9 ++
src/styles/site.css | 271 ++++++++++++++++++++++++++++++++
5 files changed, 471 insertions(+), 13 deletions(-)
create mode 100644 src/lib/remarkButterflyTags.mjs
diff --git a/astro.config.mjs b/astro.config.mjs
index ecb4d5a..800d533 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -3,7 +3,7 @@ import sitemap from '@astrojs/sitemap';
import { unified } from '@astrojs/markdown-remark';
import rehypeMathjax from 'rehype-mathjax';
import remarkMath from 'remark-math';
-import remarkHexoLinkCards from './src/lib/remarkHexoLinkCards.mjs';
+import remarkButterflyTags from './src/lib/remarkButterflyTags.mjs';
import { siteConfig } from './site.config.mjs';
export default defineConfig({
@@ -12,7 +12,7 @@ export default defineConfig({
integrations: [sitemap()],
markdown: {
processor: unified({
- remarkPlugins: [remarkMath, remarkHexoLinkCards],
+ remarkPlugins: [remarkMath, remarkButterflyTags],
rehypePlugins: [rehypeMathjax],
}),
shikiConfig: {
diff --git a/src/lib/remarkButterflyTags.mjs b/src/lib/remarkButterflyTags.mjs
new file mode 100644
index 0000000..7a07ab5
--- /dev/null
+++ b/src/lib/remarkButterflyTags.mjs
@@ -0,0 +1 @@
+export { default } from './remarkHexoLinkCards.mjs';
diff --git a/src/lib/remarkHexoLinkCards.mjs b/src/lib/remarkHexoLinkCards.mjs
index 0f6f286..34e731d 100644
--- a/src/lib/remarkHexoLinkCards.mjs
+++ b/src/lib/remarkHexoLinkCards.mjs
@@ -1,5 +1,9 @@
-const linkTagPattern = /\{%\s*link\s+([\s\S]*?)\s*%\}/g;
+const hexoTagPattern = /\{%\s*(link|btn|inlineImg|label|hideInline)\s+([\s\S]*?)\s*%\}/g;
const markdownLinkPattern = /^\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)$/;
+const noteClasses = new Set(['default', 'primary', 'success', 'info', 'warning', 'danger', 'blue', 'pink', 'red', 'purple', 'orange', 'green']);
+const noteStyles = new Set(['simple', 'modern', 'flat', 'disabled']);
+const labelColors = new Set(['default', 'blue', 'pink', 'red', 'purple', 'orange', 'green']);
+const buttonColors = new Set(['default', 'blue', 'pink', 'red', 'purple', 'orange', 'green']);
function escapeHtml(value) {
return String(value)
@@ -9,19 +13,38 @@ function escapeHtml(value) {
.replace(/"/g, '"');
}
+function escapeAttribute(value) {
+ return escapeHtml(value).replace(/'/g, ''');
+}
+
+function sanitizeClassName(value) {
+ return String(value)
+ .split(/\s+/)
+ .filter((part) => /^[a-z0-9_-]+$/i.test(part))
+ .join(' ');
+}
+
function splitLinkArgs(value) {
const parts = [];
let current = '';
let bracketDepth = 0;
let parenDepth = 0;
+ let quote = '';
- for (const char of value) {
- if (char === '[') bracketDepth += 1;
- if (char === ']' && bracketDepth > 0) bracketDepth -= 1;
- if (char === '(') parenDepth += 1;
- if (char === ')' && parenDepth > 0) parenDepth -= 1;
+ for (let index = 0; index < value.length; index += 1) {
+ const char = value[index];
+ if ((char === '"' || char === "'") && value[index - 1] !== '\\') {
+ quote = quote === char ? '' : quote || char;
+ }
- if (char === ',' && bracketDepth === 0 && parenDepth === 0 && parts.length < 2) {
+ if (!quote) {
+ if (char === '[') bracketDepth += 1;
+ if (char === ']' && bracketDepth > 0) bracketDepth -= 1;
+ if (char === '(') parenDepth += 1;
+ if (char === ')' && parenDepth > 0) parenDepth -= 1;
+ }
+
+ if (char === ',' && !quote && bracketDepth === 0 && parenDepth === 0) {
parts.push(current.trim());
current = '';
} else {
@@ -33,8 +56,29 @@ function splitLinkArgs(value) {
return parts;
}
+function splitSpaceArgs(value) {
+ const parts = [];
+ const pattern = /'([^']*)'|"([^"]*)"|(\S+)/g;
+ let match;
+
+ while ((match = pattern.exec(value))) {
+ parts.push((match[1] ?? match[2] ?? match[3] ?? '').trim());
+ }
+
+ return parts.filter(Boolean);
+}
+
+function unquote(value) {
+ const trimmed = String(value ?? '').trim();
+ if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) {
+ return trimmed.slice(1, -1);
+ }
+
+ return trimmed;
+}
+
function normalizeUrl(value) {
- const trimmed = value.trim();
+ const trimmed = unquote(value);
const markdownLink = trimmed.match(markdownLinkPattern);
return markdownLink ? markdownLink[1] : trimmed;
}
@@ -66,16 +110,147 @@ function renderLinkCard(rawValue) {
`;
}
-function transformText(value) {
+function renderButton(rawValue) {
+ const [rawUrl, rawText, rawIcon = '', rawOptions = ''] = splitLinkArgs(rawValue);
+ const url = normalizeUrl(rawUrl ?? '');
+ const text = unquote(rawText ?? '');
+ const icon = sanitizeClassName(unquote(rawIcon));
+ const options = splitSpaceArgs(rawOptions).map((part) => part.toLowerCase());
+ const color = options.find((part) => buttonColors.has(part)) || 'default';
+ const classes = [
+ 'butterfly-btn',
+ `butterfly-btn--${color}`,
+ ...options
+ .filter((part) => ['outline', 'larger', 'block', 'center', 'right'].includes(part))
+ .map((part) => `is-${part}`),
+ ];
+
+ if (!url || !text) return `{% btn ${rawValue} %}`;
+
+ return `${icon ? `` : ''}${escapeHtml(text)}`;
+}
+
+function renderInlineImage(rawValue) {
+ const [rawSrc, rawHeight] = splitSpaceArgs(rawValue);
+ const src = normalizeUrl(rawSrc ?? '');
+ const height = unquote(rawHeight ?? '');
+ const style = height ? ` style="height:${escapeAttribute(height)}"` : '';
+
+ if (!src) return `{% inlineImg ${rawValue} %}`;
+ return `
`;
+}
+
+function renderLabel(rawValue) {
+ const args = splitSpaceArgs(rawValue);
+ const maybeColor = args.at(-1)?.toLowerCase();
+ const color = labelColors.has(maybeColor) ? maybeColor : 'default';
+ const text = labelColors.has(maybeColor) ? args.slice(0, -1).join(' ') : args.join(' ');
+
+ if (!text) return `{% label ${rawValue} %}`;
+ return `${escapeHtml(unquote(text))}`;
+}
+
+function renderHideInline(rawValue) {
+ const [content, display = 'Click', bg = '', color = ''] = splitLinkArgs(rawValue).map(unquote);
+ const styleParts = [];
+ if (bg) styleParts.push(`--hide-bg:${escapeAttribute(bg)}`);
+ if (color) styleParts.push(`--hide-color:${escapeAttribute(color)}`);
+ const style = styleParts.length > 0 ? ` style="${styleParts.join(';')}"` : '';
+
+ if (!content) return `{% hideInline ${rawValue} %}`;
+ return `${escapeHtml(content.replace(/‚/g, ','))}`;
+}
+
+function renderTag(name, rawValue) {
+ if (name === 'link') return renderLinkCard(rawValue);
+ if (name === 'btn') return renderButton(rawValue);
+ if (name === 'inlineImg') return renderInlineImage(rawValue);
+ if (name === 'label') return renderLabel(rawValue);
+ if (name === 'hideInline') return renderHideInline(rawValue);
+ return `{% ${name} ${rawValue} %}`;
+}
+
+function renderPlainContent(value) {
+ return escapeHtml(value.trim()).replace(/\n{2,}/g, '
').replace(/\n/g, '
');
+}
+
+function transformInlineBlockText(value) {
let matched = false;
- const transformed = value.replace(linkTagPattern, (_, rawValue) => {
+ let transformed = value.replace(/\{%\s*note(?:\s+([\s\S]*?))?\s*%\}\s*(?:\r?\n)?([\s\S]*?)(?:\r?\n)?\s*\{%\s*endnote\s*%\}/g, (_, rawArgs = '', content = '') => {
matched = true;
- return renderLinkCard(rawValue);
+ return `${renderNoteStart(rawArgs)}
${renderPlainContent(content)}
`;
+ });
+
+ transformed = transformed.replace(/\{%\s*hideBlock(?:\s+([\s\S]*?))?\s*%\}\s*(?:\r?\n)?([\s\S]*?)(?:\r?\n)?\s*\{%\s*endhideBlock\s*%\}/g, (_, rawArgs = '', content = '') => {
+ matched = true;
+ return `${renderHideBlockStart(rawArgs)}${renderPlainContent(content)}
`;
});
return matched ? transformed : value;
}
+function transformText(value) {
+ let matched = false;
+ const blockTransformed = transformInlineBlockText(value);
+ const transformed = blockTransformed.replace(hexoTagPattern, (_, name, rawValue) => {
+ matched = true;
+ return renderTag(name, rawValue);
+ });
+
+ return matched || blockTransformed !== value ? transformed : value;
+}
+
+function getParagraphSource(node) {
+ if (node?.type !== 'paragraph' || !Array.isArray(node.children)) return '';
+ return node.children.map(inlineToSource).join('').trim();
+}
+
+function parseNoteArgs(rawValue) {
+ const args = splitSpaceArgs(rawValue).map(unquote);
+ const className = args.find((part) => noteClasses.has(part.toLowerCase()))?.toLowerCase() || 'default';
+ const style = args.find((part) => noteStyles.has(part.toLowerCase()))?.toLowerCase() || 'simple';
+ const noIcon = args.some((part) => part.toLowerCase() === 'no-icon');
+ const customIcon = args.find((part) => /\bfa[srbld]?\b|\bfa-/.test(part) && !noteClasses.has(part.toLowerCase()) && !noteStyles.has(part.toLowerCase()));
+ return { className, style, noIcon, customIcon: sanitizeClassName(customIcon ?? '') };
+}
+
+function renderNoteStart(rawValue) {
+ const { className, style, noIcon, customIcon } = parseNoteArgs(rawValue);
+ const classes = ['butterfly-note', `butterfly-note--${className}`, `butterfly-note--${style}`];
+ if (noIcon) classes.push('is-no-icon');
+
+ return `${!noIcon ? `
` : ''}
`;
+}
+
+function renderHideBlockStart(rawValue) {
+ const [display = 'Click', bg = '', color = ''] = splitLinkArgs(rawValue).map(unquote);
+ const styleParts = [];
+ if (bg) styleParts.push(`--hide-bg:${escapeAttribute(bg)}`);
+ if (color) styleParts.push(`--hide-color:${escapeAttribute(color)}`);
+ const style = styleParts.length > 0 ? ` style="${styleParts.join(';')}"` : '';
+ return `
${escapeHtml(display.replace(/‚/g, ','))}
`;
+}
+
+function transformBlockTags(children) {
+ for (let index = 0; index < children.length; index += 1) {
+ const source = getParagraphSource(children[index]);
+ const noteMatch = source.match(/^\{%\s*note(?:\s+([^\r\n]*?))?\s*%\}$/);
+ const hideBlockMatch = source.match(/^\{%\s*hideBlock(?:\s+([^\r\n]*?))?\s*%\}$/);
+ const isEndNote = /^\{%\s*endnote\s*%\}$/.test(source);
+ const isEndHideBlock = /^\{%\s*endhideBlock\s*%\}$/.test(source);
+
+ if (noteMatch) {
+ children[index] = { type: 'html', value: renderNoteStart(noteMatch[1] ?? '') };
+ } else if (hideBlockMatch) {
+ children[index] = { type: 'html', value: renderHideBlockStart(hideBlockMatch[1] ?? '') };
+ } else if (isEndNote) {
+ children[index] = { type: 'html', value: '
' };
+ } else if (isEndHideBlock) {
+ children[index] = { type: 'html', value: '
' };
+ }
+ }
+}
+
function inlineToSource(node) {
if (node.type === 'text' || node.type === 'inlineCode') return node.value ?? '';
@@ -91,6 +266,8 @@ function inlineToSource(node) {
function visit(node) {
if (!node || typeof node !== 'object') return;
+ if (Array.isArray(node.children)) transformBlockTags(node.children);
+
if (node.type === 'paragraph' && Array.isArray(node.children)) {
const source = node.children.map(inlineToSource).join('');
const transformed = transformText(source);
diff --git a/src/pages/posts/[slug].astro b/src/pages/posts/[slug].astro
index 514cae2..bb4ebd2 100644
--- a/src/pages/posts/[slug].astro
+++ b/src/pages/posts/[slug].astro
@@ -294,6 +294,15 @@ const relatedPosts = allPosts
wrapper.appendChild(table);
});
+ prose.querySelectorAll('.butterfly-hide-inline').forEach((item) => {
+ const button = item.querySelector('.butterfly-hide-toggle');
+ if (!button) return;
+
+ button.addEventListener('click', () => {
+ item.classList.toggle('is-open');
+ });
+ });
+
if (window.Fancybox) {
window.Fancybox.bind('[data-fancybox="article-gallery"]', {
compact: false,
diff --git a/src/styles/site.css b/src/styles/site.css
index 0d83c83..3b21759 100644
--- a/src/styles/site.css
+++ b/src/styles/site.css
@@ -1676,6 +1676,254 @@ time {
font-family: "JetBrains Mono", Consolas, monospace;
}
+.prose :not(pre) > code {
+ border: 1px solid rgba(15, 118, 110, 0.14);
+ border-radius: 6px;
+ padding: 0.12em 0.38em;
+ color: #0f766e;
+ font-size: 0.92em;
+ line-height: 1.4;
+ background: rgba(236, 253, 245, 0.78);
+ overflow-wrap: break-word;
+}
+
+.butterfly-note {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 12px;
+ margin: 1.2em 0;
+ border: 1px solid rgba(15, 118, 110, 0.18);
+ border-left-width: 4px;
+ border-radius: 8px;
+ padding: 14px 16px;
+ color: #32413f;
+ background: rgba(236, 253, 245, 0.58);
+}
+
+.butterfly-note > i {
+ margin-top: 0.28em;
+ color: var(--butterfly-note-color, var(--accent));
+}
+
+.butterfly-note.is-no-icon {
+ grid-template-columns: minmax(0, 1fr);
+}
+
+.butterfly-note__content > :first-child {
+ margin-top: 0;
+}
+
+.butterfly-note__content > :last-child {
+ margin-bottom: 0;
+}
+
+.butterfly-note--default,
+.butterfly-label--default,
+.butterfly-btn--default {
+ --butterfly-note-color: #68717a;
+ --butterfly-color: #68717a;
+}
+
+.butterfly-note--primary,
+.butterfly-note--blue,
+.butterfly-label--blue,
+.butterfly-btn--blue {
+ --butterfly-note-color: #2f80ed;
+ --butterfly-color: #2f80ed;
+}
+
+.butterfly-note--success,
+.butterfly-note--green,
+.butterfly-label--green,
+.butterfly-btn--green {
+ --butterfly-note-color: #2f9e44;
+ --butterfly-color: #2f9e44;
+}
+
+.butterfly-note--info {
+ --butterfly-note-color: #0ea5e9;
+ --butterfly-color: #0ea5e9;
+}
+
+.butterfly-note--warning,
+.butterfly-note--orange,
+.butterfly-label--orange,
+.butterfly-btn--orange {
+ --butterfly-note-color: #f59f00;
+ --butterfly-color: #f59f00;
+}
+
+.butterfly-note--danger,
+.butterfly-note--red,
+.butterfly-label--red,
+.butterfly-btn--red {
+ --butterfly-note-color: #e03131;
+ --butterfly-color: #e03131;
+}
+
+.butterfly-note--pink,
+.butterfly-label--pink,
+.butterfly-btn--pink {
+ --butterfly-note-color: #d63384;
+ --butterfly-color: #d63384;
+}
+
+.butterfly-note--purple,
+.butterfly-label--purple,
+.butterfly-btn--purple {
+ --butterfly-note-color: #7950f2;
+ --butterfly-color: #7950f2;
+}
+
+.butterfly-note {
+ border-left-color: var(--butterfly-note-color, var(--accent));
+}
+
+.butterfly-note--modern {
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.62);
+}
+
+.butterfly-note--flat {
+ border-color: transparent;
+ border-left-color: var(--butterfly-note-color, var(--accent));
+ background: color-mix(in srgb, var(--butterfly-note-color, #0f766e) 13%, transparent);
+}
+
+.butterfly-note--disabled {
+ display: block;
+ border: 0;
+ padding: 0;
+ background: transparent;
+}
+
+.butterfly-label {
+ display: inline-flex;
+ align-items: center;
+ min-height: 1.7em;
+ border-radius: 6px;
+ padding: 0.05em 0.48em;
+ color: #fff;
+ font-size: 0.88em;
+ font-weight: 700;
+ line-height: 1.35;
+ vertical-align: 0.08em;
+ background: var(--butterfly-color, #68717a);
+}
+
+.butterfly-inline-img {
+ display: inline-block !important;
+ width: auto;
+ max-width: min(100%, 18rem);
+ margin: 0 0.18em !important;
+ vertical-align: middle;
+}
+
+.prose .butterfly-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.45em;
+ min-height: 34px;
+ border: 1px solid var(--butterfly-color, var(--accent));
+ border-radius: 8px;
+ padding: 0.4em 0.78em;
+ color: #fff;
+ font-weight: 800;
+ line-height: 1.2;
+ text-decoration: none;
+ background: var(--butterfly-color, var(--accent));
+ transition:
+ color 0.2s ease,
+ background-color 0.2s ease,
+ box-shadow 0.2s ease,
+ transform 0.2s ease;
+}
+
+.prose .butterfly-btn:hover {
+ color: #fff;
+ box-shadow: 0 10px 22px color-mix(in srgb, var(--butterfly-color, #0f766e) 24%, transparent);
+ transform: translateY(-1px);
+}
+
+.prose .butterfly-btn.is-outline {
+ color: var(--butterfly-color, var(--accent));
+ background: transparent;
+}
+
+.prose .butterfly-btn.is-outline:hover {
+ color: #fff;
+ background: var(--butterfly-color, var(--accent));
+}
+
+.prose .butterfly-btn.is-larger {
+ min-height: 40px;
+ padding: 0.56em 1em;
+ font-size: 1.05em;
+}
+
+.prose .butterfly-btn.is-block {
+ display: flex;
+ width: fit-content;
+ margin: 0.9em 0;
+}
+
+.prose .butterfly-btn.is-center {
+ margin-right: auto;
+ margin-left: auto;
+}
+
+.prose .butterfly-btn.is-right {
+ margin-left: auto;
+}
+
+.butterfly-hide-inline {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45em;
+ vertical-align: baseline;
+}
+
+.butterfly-hide-toggle,
+.butterfly-hide-block > summary {
+ border: 0;
+ border-radius: 7px;
+ padding: 0.25em 0.62em;
+ color: var(--hide-color, #fff);
+ font: inherit;
+ font-weight: 800;
+ line-height: 1.3;
+ background: var(--hide-bg, #ff7242);
+ cursor: pointer;
+}
+
+.butterfly-hide-content {
+ display: none;
+ color: var(--text);
+}
+
+.butterfly-hide-inline.is-open .butterfly-hide-content {
+ display: inline;
+}
+
+.butterfly-hide-block {
+ margin: 1.2em 0;
+}
+
+.butterfly-hide-block > summary {
+ display: inline-flex;
+ list-style: none;
+}
+
+.butterfly-hide-block > summary::-webkit-details-marker {
+ display: none;
+}
+
+.butterfly-hide-block__content {
+ margin-top: 0.9em;
+ border-left: 3px solid rgba(15, 118, 110, 0.2);
+ padding-left: 1em;
+}
+
.prose .code-block {
position: relative;
margin: 1.25em 0;
@@ -3357,6 +3605,29 @@ meting-js {
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.24);
}
+:root[data-theme='dark'] .prose :not(pre) > code {
+ border-color: rgba(114, 222, 210, 0.22);
+ color: var(--accent);
+ background: rgba(114, 222, 210, 0.1);
+}
+
+:root[data-theme='dark'] .butterfly-note {
+ color: #dce9e6;
+ background: rgba(114, 222, 210, 0.08);
+}
+
+:root[data-theme='dark'] .butterfly-note--disabled {
+ background: transparent;
+}
+
+:root[data-theme='dark'] .butterfly-hide-content {
+ color: #dce9e6;
+}
+
+:root[data-theme='dark'] .butterfly-hide-block__content {
+ border-left-color: rgba(114, 222, 210, 0.22);
+}
+
:root[data-theme='dark'] .prose table {
color: #dce9e6;
}