Files
Blog/src/lib/shortcodes.ts
T
2026-08-12 18:04:09 +08:00

347 lines
22 KiB
TypeScript

import { fromMarkdown } from 'mdast-util-from-markdown';
import { gfmFromMarkdown } from 'mdast-util-gfm';
import { toHtml } from 'hast-util-to-html';
import { toHast } from 'mdast-util-to-hast';
import { gfm } from 'micromark-extension-gfm';
import { visit } from 'unist-util-visit';
import { applyExternalLinks } from './external-links';
import { applyResponsiveTables } from './responsive-tables';
import { applyNoticeShortcodes } from './notice-shortcode';
const URL_SCHEME = /^(https?:|mailto:|tel:)/i;
const TAG_TYPES = new Set(['primary', 'success', 'warning', 'danger', 'info', 'default']);
const HINT_TYPES = new Set(['warn', 'warning', 'error', 'danger', 'success', 'info']);
const HINT_ICONS: Record<string, string> = {
info: '<circle cx="12" cy="12" r="10"></circle><path d="M12 16v-4"></path><path d="M12 8h.01"></path>',
warning: '<circle cx="12" cy="12" r="10"></circle><path d="M12 8v4"></path><path d="M12 16h.01"></path>',
danger: '<path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 8.54 21h6.92a2 2 0 0 0 1.73-3Z"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path>',
success: '<path d="m9 12 2 2 4-4"></path><circle cx="12" cy="12" r="10"></circle>',
};
const BLOCK_NAMES = new Set(['hint', 'tip', 'collapse', 'tabs', 'tools']);
const INLINE_NAMES = new Set(['button', 'btn', 'file', 'tag', 'label']);
type GithubData = { stars?: string; forks?: string; description?: string; commitDate?: string; htmlUrl?: string; defaultBranch?: string };
let githubData: Record<string, GithubData> = {};
let siteUrl = '';
const escape = (value: string) => value.replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]!));
function attrs(source: string): Record<string, string> {
const normalized = source.replace(/[“”]/g, '"').replace(/[‘’]/g, "'");
const result: Record<string, string> = {};
const pattern = /([\w-]+)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|([^\s\]]+)))?/g;
for (const match of normalized.matchAll(pattern)) result[match[1].toLowerCase()] = match[2] ?? match[3] ?? match[4] ?? '';
return result;
}
const safeUrl = (value: string | undefined) => {
const candidate = value?.trim();
if (!candidate || candidate.startsWith('//') || /[\u0000-\u001f\u007f]/.test(candidate) || /^(javascript|data|vbscript):/i.test(candidate)) return null;
if (URL_SCHEME.test(candidate) || (candidate.startsWith('/') && !candidate.startsWith('//')) || candidate.startsWith('./') || candidate.startsWith('../') || !/^[\w+.-]+:/.test(candidate)) return candidate;
return null;
};
const safeHttpUrl = (value: string | undefined) => {
const candidate = safeUrl(value);
if (!candidate) return null;
if (!/^[\w+.-]+:/.test(candidate)) return candidate;
if (!/^https?:\/\//i.test(candidate)) return null;
try {
const parsed = new URL(candidate);
return /^https?:$/.test(parsed.protocol) && Boolean(parsed.hostname) ? candidate : null;
} catch {
return null;
}
};
const inline = (value: string) => escape(value).replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/`([^`]+)`/g, '<code>$1</code>');
function normalizeMarkdownHeadings(source: string): string {
source = source.replace(/^(<!--markdown-->)\s*(?=#{1,6})/gm, '$1\n');
let fence: string | null = null;
return source.split('\n').map((line) => {
const trimmed = line.trim();
const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
if (fenceMatch) {
if (!fence) fence = fenceMatch[1][0];
else if (fenceMatch[1][0] === fence) fence = null;
return line;
}
if (fence) return line;
return line.replace(/^(#{1,6})([^ #])/, '$1 $2');
}).join('\n');
}
function markdown(source: string, preserveHtml = true, lineNumbers = true): string {
const normalized = normalizeMarkdownHeadings(source);
const tree = fromMarkdown(normalized, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] });
transform(tree, normalized);
if (!preserveHtml) visit(tree, 'html', (node: any) => { if (!node.data?.shortcode) node.value = ''; });
const hast = toHast(tree, { allowDangerousHtml: true }) as any;
if (lineNumbers) {
visit(hast, 'element', (node: any) => {
if (node.tagName !== 'pre') return;
const code = node.children?.find((child: any) => child.type === 'element' && child.tagName === 'code');
if (!code) return;
const classes = Array.isArray(node.properties?.className) ? node.properties.className : [];
if (!classes.includes('astro-code')) classes.push('astro-code');
if (!classes.includes('shortcode-code')) classes.push('shortcode-code');
if (!classes.includes('no-pangu-spacing')) classes.push('no-pangu-spacing');
node.properties = { ...node.properties, className: classes };
if (code.children?.every((child: any) => child.type === 'text')) {
const value = code.children.map((child: any) => child.value).join('');
const text = value.endsWith('\n') ? value.slice(0, -1) : value;
const lines = text.split('\n');
code.children = lines.map((line: string) => ({
type: 'element',
tagName: 'span',
properties: { className: ['line'] },
children: [
{
type: 'element',
tagName: 'span',
properties: { className: ['line-content'] },
children: line ? [{ type: 'text', value: line }] : [{ type: 'text', value: '\n' }],
}
],
}));
}
});
}
applyNoticeShortcodes(hast);
applyResponsiveTables(hast);
if (siteUrl) applyExternalLinks(hast, siteUrl);
return toHtml(hast, { allowDangerousHtml: true });
}
export function renderMarkdown(source: string): string {
return markdown(source, true);
}
function renderBlock(name: string, args: Record<string, string>, body = ''): string {
const label = args.title ?? args.name ?? args.text ?? body.trim().split('\n')[0] ?? '';
if (name === 'button' || name === 'btn') {
const url = safeUrl(args.href ?? args.url ?? args.link);
if (!url) return `<span class="shortcode-invalid">${escape(label || '链接不可用')}</span>`;
return `<a class="shortcode-button shortcode-${name}" href="${escape(url)}" data-no-text-link>${inline(label || url)}</a>`;
}
if (name === 'file') {
const url = safeUrl(args.href ?? args.url ?? args.link);
if (!url) return `<span class="shortcode-invalid">${escape(label || '文件链接不可用')}</span>`;
const icon = '<svg class="content-file-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline><path d="M10 12v-1"></path><path d="M10 18v-2"></path><path d="M10 7v1"></path><path d="M10 15h.01"></path></svg>';
return `<a class="shortcode-file content-file" href="${escape(url)}" target="_blank" rel="noopener noreferrer" data-no-text-link>${icon}<span class="content-file-filename">${inline(label || url)}</span></a>`;
}
if (name === 'tag' || name === 'label') {
const type = TAG_TYPES.has(args.type ?? '') ? args.type : 'default';
const outline = args.outline !== undefined ? ' shortcode-tag-outline' : '';
return `<span class="shortcode-tag shortcode-tag-${type}${outline}">${inline(label)}</span>`;
}
if (name === 'hint' || name === 'tip') {
const requestedType = args.type ?? [...HINT_TYPES].find((candidate) => args[candidate] !== undefined);
const type = requestedType === 'warn' ? 'warning' : requestedType === 'error' ? 'danger' : HINT_TYPES.has(requestedType ?? '') ? requestedType : 'info';
const icon = `<svg class="content-hint-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${HINT_ICONS[type]}</svg>`;
const heading = args.title ? `<div class="content-hint-heading">${icon}<p class="content-hint-title">${inline(args.title)}</p></div>` : icon;
return `<aside class="shortcode-hint content-hint shortcode-${name} shortcode-hint-${type} hint-${type}${args.title ? ' content-hint-has-title' : ''}" role="note">${heading}${markdown(body, true, true)}</aside>`;
}
if (name === 'collapse') {
const open = args.open !== undefined || args.expanded !== undefined ? ' open' : '';
return `<details class="shortcode-collapse"${open}><summary><span class="shortcode-collapse-marker" aria-hidden="true"></span><span>${inline(label || '展开内容')}</span></summary><div class="shortcode-body"><div class="shortcode-body-inner">${markdown(body, true, true)}</div></div></details>`;
}
if (name === 'tabs') {
const parsed = parseBlocks(body);
const tabs = parsed.filter((item) => item.name === 'tab');
if (!tabs.length) return `<details class="shortcode-collapse"><summary><span class="shortcode-collapse-marker" aria-hidden="true"></span><span>${inline(label || '展开内容')}</span></summary><div class="shortcode-body"><div class="shortcode-body-inner">${markdown(body, true, true)}</div></div></details>`;
const id = `shortcode-tabs-${tabs.length}-${Math.random().toString(36).slice(2, 8)}`;
const selected = Math.max(0, tabs.findIndex((tab) => tab.args.selected !== undefined));
const buttons = tabs.map((tab, index) => `<button type="button" role="tab" aria-controls="${id}-panel-${index}" aria-selected="${index === selected}" id="${id}-tab-${index}" data-shortcode-tab="${id}"${index === selected ? '' : ' tabindex="-1"'}>${inline(tab.args.name ?? tab.args.title ?? `选项 ${index + 1}`)}</button>`).join('');
const panels = tabs.map((tab, index) => `<div role="tabpanel" id="${id}-panel-${index}" aria-labelledby="${id}-tab-${index}"${index === selected ? '' : ' hidden'}>${markdown(tab.body, true, true)}</div>`).join('');
return `<section class="shortcode-tabs" data-shortcode-tabs="${id}"><div class="shortcode-tab-list" role="tablist" aria-label="${escape(label || '选项卡')}">${buttons}</div>${panels}</section>`;
}
if (name === 'tools') {
const tools = body.split(/\r?\n/).map((line) => {
const match = line.trim().match(/^\[([^\]]+)\]\(([^)]+)\)\+(.+)$/);
if (!match) return null;
const [, name, href, payload] = match;
const parenthesized = payload.match(/^\((.+)\)\/\((.*)\)$/);
const separator = payload.lastIndexOf('/');
const icon = parenthesized?.[1] ?? (separator >= 0 ? payload.slice(0, separator) : '');
const description = parenthesized?.[2] ?? (separator >= 0 ? payload.slice(separator + 1) : '');
if (!icon || !description) return null;
const safeHref = safeUrl(href);
const safeIcon = safeUrl(icon);
if (!safeHref || !safeIcon) return null;
return `<li class="shortcode-tool"><a class="shortcode-tool-link" href="${escape(safeHref)}" target="_blank" rel="noopener noreferrer" data-no-text-link><span class="shortcode-tool-icon"><img src="${escape(safeIcon)}" alt="" loading="lazy"></span><span class="shortcode-tool-content"><strong class="shortcode-tool-name">${inline(name)}</strong><span class="shortcode-tool-description">${inline(description.trim())}</span></span></a></li>`;
}).filter(Boolean).join('');
return `<section class="shortcode-tools" aria-label="${escape(label || '工具')}">${label ? `<h3 class="shortcode-tools-title">${inline(label)}</h3>` : ''}<ul class="shortcode-tool-list">${tools}</ul></section>`;
}
if (name === 'github') {
const repository = (args.repo ?? args.repository ?? label).trim();
const repositoryMatch = repository.match(/^([A-Za-z0-9](?:[A-Za-z0-9_.-]{0,38}))\/([A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99}))$/);
if (!repositoryMatch) return '<span class="shortcode-invalid">GitHub 仓库地址不可用</span>';
const [, owner, repo] = repositoryMatch;
const data = githubData[repository.toLowerCase()] ?? {};
const url = safeHttpUrl(data.htmlUrl) ?? `https://github.com/${owner}/${repo}`;
const readMore = args.readmore === undefined ? url : safeHttpUrl(args.readmore);
const branch = data.defaultBranch || 'main';
const download = args.download === undefined ? `${url}/archive/refs/heads/${encodeURIComponent(branch)}.zip` : safeHttpUrl(args.download);
const description = args.description !== undefined ? args.description.trim() : data.description?.trim();
const commitDate = args.lastcommit !== undefined ? args.lastcommit.trim() : args.commitdate !== undefined ? args.commitdate.trim() : data.commitDate?.trim();
const stars = args.stars !== undefined ? args.stars.trim() : data.stars;
const forks = args.forks !== undefined ? args.forks.trim() : data.forks;
const stats = (name: string, value: string | undefined) => `<span class="shortcode-github-stat${value?.trim() ? '' : ' shortcode-github-stat-empty'}"><span>${name}</span> <b>${escape(value?.trim() || '—')}</b></span>`;
const descriptionMarkup = description ? `<p class="shortcode-github-description">${inline(description)}</p>` : '';
const readMoreMarkup = readMore ? `<a class="shortcode-github-read-more" href="${escape(readMore)}" target="_blank" rel="noopener noreferrer" data-no-text-link>Read More</a>` : '';
const commitMarkup = `<span class="shortcode-github-commit${commitDate ? '' : ' shortcode-github-commit-empty'}"><span class="shortcode-github-commit-label">Last Commit</span> <time class="shortcode-github-date">${escape(commitDate || '—')}</time></span>`;
const downloadMarkup = download ? `<a class="shortcode-github-download" href="${escape(download)}" download target="_blank" rel="noopener noreferrer" data-no-text-link>Download as zip</a>` : '';
const contentMarkup = descriptionMarkup || readMoreMarkup ? `<div class="shortcode-github-content">${descriptionMarkup}${readMoreMarkup}</div>` : '';
const footerMarkup = commitMarkup || downloadMarkup ? `<footer class="shortcode-github-footer">${commitMarkup}${downloadMarkup}</footer>` : '';
return `<article class="shortcode-github" aria-label="GitHub repository ${escape(repository)}"><header class="shortcode-github-header"><div class="shortcode-github-title"><svg class="shortcode-github-icon" aria-hidden="true" viewBox="0 0 16 16" role="img"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8" /></svg><h3><a class="shortcode-github-owner" href="https://github.com/${escape(owner)}" target="_blank" rel="noopener noreferrer" data-no-text-link>${escape(owner)}</a>/<a class="shortcode-github-repo" href="${url}" target="_blank" rel="noopener noreferrer" data-no-text-link>${escape(repo)}</a></h3></div><div class="shortcode-github-stats">${stats('Stars', stars)}${stats('Forks', forks)}</div></header>${contentMarkup}${footerMarkup}</article>`;
}
return '';
}
function parseBlocks(source: string): Array<{ name: string; args: Record<string, string>; body: string }> {
const result: Array<{ name: string; args: Record<string, string>; body: string }> = [];
let fence: string | null = null;
let opening: { args: Record<string, string>; bodyStart: number } | null = null;
for (const line of source.matchAll(/^[^\n]*(?:\n|$)/gm)) {
const text = line[0].trim();
const start = line.index!;
const fenceMatch = text.match(/^(`{3,}|~{3,})/);
if (fenceMatch) {
if (!fence) fence = fenceMatch[1][0];
else if (fenceMatch[1][0] === fence) fence = null;
continue;
}
if (fence) continue;
const closing = text.match(/^\[\/tab\]\s*$/i);
if (closing && opening) {
result.push({ name: 'tab', args: opening.args, body: source.slice(opening.bodyStart, start) });
opening = null;
continue;
}
const openingMatch = text.match(/^\[tab([^\]]*)\]\s*$/i);
if (openingMatch && !opening) opening = { args: attrs(openingMatch[1]), bodyStart: start + line[0].length };
}
return result;
}
function findGithubBlocks(source: string): Array<{ name: string; args: Record<string, string>; body: string; start: number; end: number }> {
const result: Array<{ name: string; args: Record<string, string>; body: string; start: number; end: number }> = [];
let fence: string | null = null;
let opening: { args: Record<string, string>; start: number; bodyStart: number } | null = null;
for (const line of source.matchAll(/^[^\n]*(?:\n|$)/gm)) {
const text = line[0].trim();
const start = line.index!;
const fenceMatch = text.match(/^(`{3,}|~{3,})/);
if (fenceMatch) {
if (!fence) fence = fenceMatch[1][0];
else if (fenceMatch[1][0] === fence) fence = null;
continue;
}
if (fence) continue;
const selfClosing = text.match(/^\[github\b([\s\S]*?)\/\s*\]$/i);
if (selfClosing) {
result.push({ name: 'github', args: attrs(selfClosing[1]), body: '', start, end: start + line[0].length });
continue;
}
const closing = text.match(/^\[\/github\]\s*$/i);
if (closing && opening) {
result.push({ name: 'github', args: opening.args, body: source.slice(opening.bodyStart, start), start: opening.start, end: start + line[0].length });
opening = null;
continue;
}
const startTag = text.match(/^\[github\b([^\]]*)\]\s*$/i);
if (startTag) opening = { args: attrs(startTag[1]), start, bodyStart: start + line[0].length };
}
return result;
}
function findBlocks(source: string): Array<{ name: string; args: Record<string, string>; body: string; start: number; end: number }> {
const result: Array<{ name: string; args: Record<string, string>; body: string; start: number; end: number }> = [];
const stack: Array<{ name: string; args: Record<string, string>; start: number; bodyStart: number }> = [];
let fence: string | null = null;
for (const line of source.matchAll(/^[^\n]*(?:\n|$)/gm)) {
const text = line[0].trim();
const start = line.index!;
const fenceMatch = text.match(/^(`{3,}|~{3,})/);
if (fenceMatch) {
if (!fence) fence = fenceMatch[1][0];
else if (fenceMatch[1][0] === fence) fence = null;
continue;
}
if (fence) continue;
const opening = text.match(/^\[([\w!-]+)([^\]]*)\]$/);
const closing = text.match(/^\[\/([\w!-]+)\]$/);
if (closing) {
const name = closing[1].toLowerCase();
const current = stack[stack.length - 1];
if (current?.name === name) { stack.pop(); result.push({ name: current.name, args: current.args, body: source.slice(current.bodyStart, start), start: current.start, end: start + line[0].length }); }
} else if (opening) {
const name = opening[1].toLowerCase();
if (BLOCK_NAMES.has(name)) stack.push({ name, args: attrs(opening[2]), start, bodyStart: start + line[0].length });
else if (INLINE_NAMES.has(name)) result.push({ name, args: attrs(opening[2]), body: '', start, end: start + line[0].length });
}
}
return [...findGithubBlocks(source), ...result].sort((a, b) => a.start - b.start);
}
function replaceInline(source: string): string | null {
const shortcode = /\[(button|btn|file|tag|label|hint|tip)([^\]]*)\]([\s\S]*?)\[\/\1\]/gi;
const replacedShortcode = shortcode.test(source);
if (!replacedShortcode) return null;
return source.replace(shortcode, (_match, name, rawArgs, body) => renderBlock(name.toLowerCase(), attrs(rawArgs), body));
}
function transform(tree: any, raw = '') {
visit(tree, 'root', (root: any) => {
let fence: string | null = null;
for (const line of raw.matchAll(/^[^\n]*(?:\n|$)/gm)) {
const text = line[0].trim();
const fenceMatch = text.match(/^(`{3,}|~{3,})/);
if (fenceMatch) {
if (!fence) fence = fenceMatch[1][0];
else if (fenceMatch[1][0] === fence) fence = null;
} else if (!fence && /\[(?:\/)?hide(?:\s[^\]]*)?\]/i.test(text)) {
throw new Error('Unsupported shortcode [hide]: hide has been removed from Mirages Astro.');
}
}
const blocks = findBlocks(raw).filter((block, _index, all) => !all.some((other) => other !== block && other.start < block.start && other.end >= block.end));
if (blocks.length) {
const replaced = new Set<number>();
for (const block of blocks) {
const indexes = root.children.map((node: any, index: number) => ({ node, index })).filter(({ node }: any) => node.position && node.position.start.offset < block.end && node.position.end.offset > block.start).map(({ index }: any) => index);
const first = indexes[0];
if (first === undefined) continue;
root.children[first] = { type: 'html', value: renderBlock(block.name, block.args, block.body), data: { shortcode: true } };
indexes.slice(1).forEach((index: number) => replaced.add(index));
}
root.children = root.children.filter((_node: any, index: number) => !replaced.has(index));
}
root.children = root.children.map((node: any) => {
if (node.type !== 'paragraph' || !node.position) return node;
const source = raw.slice(node.position.start.offset, node.position.end.offset).trim();
const heading = source.match(/^#{1,6}([^ #].*)$/);
if (!heading || /\n/.test(source)) return node;
const parsed = fromMarkdown(`${source.slice(0, source.length - heading[1].length)} ${heading[1]}`);
return parsed.children[0]?.type === 'heading' ? parsed.children[0] : node;
});
const output: any[] = [];
for (const node of root.children) {
if (node.type !== 'paragraph' && node.type !== 'html') { output.push(node); continue; }
if (node.type === 'html' && node.data?.shortcode) { output.push(node); continue; }
const source = node.type === 'html' ? node.value : node.position ? raw.slice(node.position.start.offset, node.position.end.offset) : '';
const replaced = replaceInline(source.trim());
output.push(replaced ? { type: 'html', value: replaced, data: { shortcode: true } } : node);
}
root.children = output;
});
}
export default function shortcodes(options: { githubData?: Record<string, GithubData>; siteUrl?: string } = {}) {
githubData = options.githubData ?? {};
siteUrl = options.siteUrl ?? '';
return (tree: any, file: any) => transform(tree, file?.toString?.() ?? String(file?.value ?? ''));
}