文章渲染优化

This commit is contained in:
biss committed 2026-06-19 09:58:13 +08:00
1 parent 0690ceb9cb
commit af388a2563
5 files changed
+471 -13

No files matched your search

+2 -2
View File
@@ -3,7 +3,7 @@ import sitemap from '@astrojs/sitemap';
import { unified } from '@astrojs/markdown-remark'; import { unified } from '@astrojs/markdown-remark';
import rehypeMathjax from 'rehype-mathjax'; import rehypeMathjax from 'rehype-mathjax';
import remarkMath from 'remark-math'; 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'; import { siteConfig } from './site.config.mjs';
export default defineConfig({ export default defineConfig({
@@ -12,7 +12,7 @@ export default defineConfig({
integrations: [sitemap()], integrations: [sitemap()],
markdown: { markdown: {
processor: unified({ processor: unified({
remarkPlugins: [remarkMath, remarkHexoLinkCards], remarkPlugins: [remarkMath, remarkButterflyTags],
rehypePlugins: [rehypeMathjax], rehypePlugins: [rehypeMathjax],
}), }),
shikiConfig: { shikiConfig: {
+1
View File
@@ -0,0 +1 @@
export { default } from './remarkHexoLinkCards.mjs';
+188 -11
View File
@@ -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 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) { function escapeHtml(value) {
return String(value) return String(value)
@@ -9,19 +13,38 @@ function escapeHtml(value) {
.replace(/"/g, '"'); .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) { function splitLinkArgs(value) {
const parts = []; const parts = [];
let current = ''; let current = '';
let bracketDepth = 0; let bracketDepth = 0;
let parenDepth = 0; let parenDepth = 0;
let quote = '';
for (const char of value) { for (let index = 0; index < value.length; index += 1) {
if (char === '[') bracketDepth += 1; const char = value[index];
if (char === ']' && bracketDepth > 0) bracketDepth -= 1; if ((char === '"' || char === "'") && value[index - 1] !== '\\') {
if (char === '(') parenDepth += 1; quote = quote === char ? '' : quote || char;
if (char === ')' && parenDepth > 0) parenDepth -= 1; }
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()); parts.push(current.trim());
current = ''; current = '';
} else { } else {
@@ -33,8 +56,29 @@ function splitLinkArgs(value) {
return parts; 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) { function normalizeUrl(value) {
const trimmed = value.trim(); const trimmed = unquote(value);
const markdownLink = trimmed.match(markdownLinkPattern); const markdownLink = trimmed.match(markdownLinkPattern);
return markdownLink ? markdownLink[1] : trimmed; return markdownLink ? markdownLink[1] : trimmed;
} }
@@ -66,16 +110,147 @@ function renderLinkCard(rawValue) {
</a>`; </a>`;
} }
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 `<a class="${classes.join(' ')}" href="${escapeAttribute(url)}" target="_blank" rel="external nofollow noopener noreferrer">${icon ? `<i class="${escapeAttribute(icon)}" aria-hidden="true"></i>` : ''}<span>${escapeHtml(text)}</span></a>`;
}
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 `<img class="butterfly-inline-img" src="${escapeAttribute(src)}" alt="" loading="lazy" decoding="async"${style}>`;
}
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 `<span class="butterfly-label butterfly-label--${color}">${escapeHtml(unquote(text))}</span>`;
}
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 `<span class="butterfly-hide-inline"${style}><button type="button" class="butterfly-hide-toggle">${escapeHtml(display)}</button><span class="butterfly-hide-content">${escapeHtml(content.replace(/&sbquo;/g, ','))}</span></span>`;
}
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, '</p><p>').replace(/\n/g, '<br>');
}
function transformInlineBlockText(value) {
let matched = false; 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; matched = true;
return renderLinkCard(rawValue); return `${renderNoteStart(rawArgs)}<p>${renderPlainContent(content)}</p></div></div>`;
});
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)}<p>${renderPlainContent(content)}</p></div></details>`;
}); });
return matched ? transformed : value; 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 `<div class="${classes.join(' ')}">${!noIcon ? `<i class="${escapeAttribute(customIcon || 'fa-solid fa-circle-info')}" aria-hidden="true"></i>` : ''}<div class="butterfly-note__content">`;
}
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 `<details class="butterfly-hide-block"${style}><summary>${escapeHtml(display.replace(/&sbquo;/g, ','))}</summary><div class="butterfly-hide-block__content">`;
}
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: '</div></div>' };
} else if (isEndHideBlock) {
children[index] = { type: 'html', value: '</div></details>' };
}
}
}
function inlineToSource(node) { function inlineToSource(node) {
if (node.type === 'text' || node.type === 'inlineCode') return node.value ?? ''; if (node.type === 'text' || node.type === 'inlineCode') return node.value ?? '';
@@ -91,6 +266,8 @@ function inlineToSource(node) {
function visit(node) { function visit(node) {
if (!node || typeof node !== 'object') return; if (!node || typeof node !== 'object') return;
if (Array.isArray(node.children)) transformBlockTags(node.children);
if (node.type === 'paragraph' && Array.isArray(node.children)) { if (node.type === 'paragraph' && Array.isArray(node.children)) {
const source = node.children.map(inlineToSource).join(''); const source = node.children.map(inlineToSource).join('');
const transformed = transformText(source); const transformed = transformText(source);
Loaded 3 of 5 files, more files were not shown because too many files have changed in this diff. Show more