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