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 = { info: '', warning: '', danger: '', success: '', }; 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 = {}; let siteUrl = ''; const escape = (value: string) => value.replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]!)); function attrs(source: string): Record { const normalized = source.replace(/[“”]/g, '"').replace(/[‘’]/g, "'"); const result: Record = {}; 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, '$1').replace(/`([^`]+)`/g, '$1'); function normalizeMarkdownHeadings(source: string): string { source = source.replace(/^()\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, 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 `${escape(label || '链接不可用')}`; return `${inline(label || url)}`; } if (name === 'file') { const url = safeUrl(args.href ?? args.url ?? args.link); if (!url) return `${escape(label || '文件链接不可用')}`; const icon = ''; return `${icon}${inline(label || url)}`; } if (name === 'tag' || name === 'label') { const type = TAG_TYPES.has(args.type ?? '') ? args.type : 'default'; const outline = args.outline !== undefined ? ' shortcode-tag-outline' : ''; return `${inline(label)}`; } 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 = ``; const heading = args.title ? `
${icon}

${inline(args.title)}

` : icon; return ``; } if (name === 'collapse') { const open = args.open !== undefined || args.expanded !== undefined ? ' open' : ''; return `
${inline(label || '展开内容')}
${markdown(body, true, true)}
`; } if (name === 'tabs') { const parsed = parseBlocks(body); const tabs = parsed.filter((item) => item.name === 'tab'); if (!tabs.length) return `
${inline(label || '展开内容')}
${markdown(body, true, true)}
`; 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) => ``).join(''); const panels = tabs.map((tab, index) => `
${markdown(tab.body, true, true)}
`).join(''); return `
${buttons}
${panels}
`; } 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 `
  • ${inline(name)}${inline(description.trim())}
  • `; }).filter(Boolean).join(''); return `
    ${label ? `

    ${inline(label)}

    ` : ''}
      ${tools}
    `; } 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 'GitHub 仓库地址不可用'; 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) => `${name} ${escape(value?.trim() || '—')}`; const descriptionMarkup = description ? `

    ${inline(description)}

    ` : ''; const readMoreMarkup = readMore ? `Read More` : ''; const commitMarkup = `Last Commit `; const downloadMarkup = download ? `Download as zip` : ''; const contentMarkup = descriptionMarkup || readMoreMarkup ? `
    ${descriptionMarkup}${readMoreMarkup}
    ` : ''; const footerMarkup = commitMarkup || downloadMarkup ? `
    ${commitMarkup}${downloadMarkup}
    ` : ''; return ``; } return ''; } function parseBlocks(source: string): Array<{ name: string; args: Record; body: string }> { const result: Array<{ name: string; args: Record; body: string }> = []; let fence: string | null = null; let opening: { args: Record; 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; body: string; start: number; end: number }> { const result: Array<{ name: string; args: Record; body: string; start: number; end: number }> = []; let fence: string | null = null; let opening: { args: Record; 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; body: string; start: number; end: number }> { const result: Array<{ name: string; args: Record; body: string; start: number; end: number }> = []; const stack: Array<{ name: string; args: Record; 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(); 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; siteUrl?: string } = {}) { githubData = options.githubData ?? {}; siteUrl = options.siteUrl ?? ''; return (tree: any, file: any) => transform(tree, file?.toString?.() ?? String(file?.value ?? '')); }