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