feat(baoyu-markdown-to-html): add markdown to HTML converter skill

Converts markdown to styled HTML with inline CSS, code highlighting,
math, PlantUML, footnotes, alerts, and WeChat-compatible themes.
This commit is contained in:
Jim Liu 宝玉
2026-01-28 21:37:30 -06:00
parent 40363194be
commit 5f994589b3
19 changed files with 3595 additions and 0 deletions
@@ -0,0 +1,284 @@
import type { MarkedExtension, Tokens } from 'marked'
export interface AlertOptions {
className?: string
variants?: AlertVariantItem[]
withoutStyle?: boolean
}
export interface AlertVariantItem {
type: string
icon: string
title?: string
titleClassName?: string
}
function ucfirst(str: string) {
return str.slice(0, 1).toUpperCase() + str.slice(1).toLowerCase()
}
/**
* https://github.com/bent10/marked-extensions/tree/main/packages/alert
* To support theme, we need to modify the source code.
* A [marked](https://marked.js.org/) extension to support [GFM alerts](https://github.com/orgs/community/discussions/16925).
*/
export function markedAlert(options: AlertOptions = {}): MarkedExtension {
const { className = `markdown-alert`, variants = [], withoutStyle = false } = options
const resolvedVariants = resolveVariants(variants)
// 提取公共的元数据构建逻辑
function buildMeta(variantType: string, matchedVariant: AlertVariantItem, fromContainer = false) {
return {
className,
variant: variantType,
icon: matchedVariant.icon,
title: matchedVariant.title ?? ucfirst(variantType),
titleClassName: `${className}-title`,
fromContainer,
}
}
// 提取公共的渲染逻辑
function renderAlert(token: any) {
const { meta, tokens = [] } = token
// @ts-expect-error marked renderer context has parser property
const text = this.parser.parse(tokens)
// 新主题系统:使用 CSS 选择器而非内联样式
let tmpl = `<blockquote class="${meta.className} ${meta.className}-${meta.variant}">\n`
tmpl += `<p class="${meta.titleClassName} alert-title-${meta.variant}">`
if (!withoutStyle) {
// 给 SVG 添加 class,通过 CSS 控制颜色
tmpl += meta.icon.replace(
`<svg`,
`<svg class="alert-icon-${meta.variant}"`,
)
}
tmpl += meta.title
tmpl += `</p>\n`
tmpl += text
tmpl += `</blockquote>\n`
return tmpl
}
return {
walkTokens(token) {
if (token.type !== `blockquote`)
return
const matchedVariant = resolvedVariants.find(({ type }) =>
new RegExp(createSyntaxPattern(type), `i`).test(token.text),
)
if (matchedVariant) {
const { type: variantType } = matchedVariant
const typeRegexp = new RegExp(createSyntaxPattern(variantType), `i`)
Object.assign(token, {
type: `alert`,
meta: buildMeta(variantType, matchedVariant),
})
const firstLine = token.tokens?.[0] as Tokens.Paragraph
const firstLineText = firstLine.raw?.replace(typeRegexp, ``).trim()
if (firstLineText) {
const patternToken = firstLine.tokens[0] as Tokens.Text
Object.assign(patternToken, {
raw: patternToken.raw.replace(typeRegexp, ``),
text: patternToken.text.replace(typeRegexp, ``),
})
if (firstLine.tokens[1]?.type === `br`) {
firstLine.tokens.splice(1, 1)
}
}
else {
token.tokens?.shift()
}
}
},
extensions: [
{
name: `alert`,
level: `block`,
renderer: renderAlert,
},
{
name: `alertContainer`,
level: `block`,
start(src) {
return src.match(/^:::/)?.index
},
tokenizer(src, _tokens) {
// eslint-disable-next-line regexp/no-super-linear-backtracking
const match = /^:::\s*(\w+)\s*\n([\s\S]*?)\n:::/.exec(src)
if (match) {
const [raw, variant, content] = match
const matchedVariant = resolvedVariants.find(v => v.type === variant)
if (!matchedVariant)
return
return {
type: `alert`,
raw,
text: content.trim(),
tokens: this.lexer.blockTokens(content.trim()),
meta: buildMeta(variant, matchedVariant, true),
}
}
},
renderer: renderAlert,
},
],
}
}
/**
* The default configuration for alert variants.
*/
const defaultAlertVariant: AlertVariantItem[] = [
{
type: `note`,
icon: `<svg class="octicon octicon-info" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
},
{
type: `info`,
icon: `<svg class="octicon octicon-info" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
},
{
type: `tip`,
icon: `<svg class="octicon octicon-light-bulb" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"></path></svg>`,
},
{
type: `important`,
icon: `<svg class="octicon octicon-report" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
},
{
type: `warning`,
icon: `<svg class="octicon octicon-alert" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
},
{
type: `caution`,
icon: `<svg class="octicon octicon-stop" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
},
// Obsidian-style callouts
{
type: `abstract`,
title: `Abstract`,
icon: `<svg class="octicon octicon-clipboard" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z"></path></svg>`,
},
{
type: `summary`,
title: `Summary`,
icon: `<svg class="octicon octicon-clipboard" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z"></path></svg>`,
},
{
type: `tldr`,
title: `TL;DR`,
icon: `<svg class="octicon octicon-clipboard" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 1a.75.75 0 0 0-.75.75v.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.5a.75.75 0 0 0-.75-.75Zm4.5-1.5a2.25 2.25 0 0 1 2.122 1.5H13a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h.628A2.25 2.25 0 0 1 5.75-.5ZM3.5 3v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5Z"></path></svg>`,
},
{
type: `todo`,
title: `Todo`,
icon: `<svg class="octicon octicon-checklist" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.5 1.75v11.5c0 .138.112.25.25.25h3.17a.75.75 0 0 1 0 1.5H2.75A1.75 1.75 0 0 1 1 13.25V1.75C1 .784 1.784 0 2.75 0h8.5C12.216 0 13 .784 13 1.75v7.736a.75.75 0 0 1-1.5 0V1.75a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm10.97 8.72a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1-1.06 1.06l-1.22-1.22v4.94a.75.75 0 0 1-1.5 0v-4.94l-1.22 1.22a.75.75 0 0 1-1.06-1.06Z"></path></svg>`,
},
{
type: `success`,
title: `Success`,
icon: `<svg class="octicon octicon-check-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0Z"></path></svg>`,
},
{
type: `done`,
title: `Done`,
icon: `<svg class="octicon octicon-check-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0Z"></path></svg>`,
},
{
type: `question`,
title: `Question`,
icon: `<svg class="octicon octicon-question" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
},
{
type: `help`,
title: `Help`,
icon: `<svg class="octicon octicon-question" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
},
{
type: `faq`,
title: `FAQ`,
icon: `<svg class="octicon octicon-question" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>`,
},
{
type: `failure`,
title: `Failure`,
icon: `<svg class="octicon octicon-x-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"></path></svg>`,
},
{
type: `fail`,
title: `Fail`,
icon: `<svg class="octicon octicon-x-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"></path></svg>`,
},
{
type: `missing`,
title: `Missing`,
icon: `<svg class="octicon octicon-x-circle" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"></path></svg>`,
},
{
type: `danger`,
title: `Danger`,
icon: `<svg class="octicon octicon-zap" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M9.504.43a1.516 1.516 0 0 1 2.437 1.713L10.415 5.5h2.123c1.57 0 2.346 1.909 1.22 3.004l-7.34 7.142a1.249 1.249 0 0 1-.871.354h-.302a1.25 1.25 0 0 1-1.157-1.723L5.633 10.5H3.462c-1.57 0-2.346-1.909-1.22-3.004ZM9.98 1.873a.016.016 0 0 0-.016.006L2.252 9.021a.75.75 0 0 0 .488 1.212h3.838a.75.75 0 0 1 .694 1.034L5.545 15.02a.016.016 0 0 0 .003.017.017.017 0 0 0 .018.004h.302a.016.016 0 0 0 .012-.005l7.34-7.142a.75.75 0 0 0-.488-1.212h-3.838a.75.75 0 0 1-.694-1.034l1.527-3.628a.016.016 0 0 0-.003-.017.017.017 0 0 0-.018-.004h-.302Z"></path></svg>`,
},
{
type: `error`,
title: `Error`,
icon: `<svg class="octicon octicon-zap" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M9.504.43a1.516 1.516 0 0 1 2.437 1.713L10.415 5.5h2.123c1.57 0 2.346 1.909 1.22 3.004l-7.34 7.142a1.249 1.249 0 0 1-.871.354h-.302a1.25 1.25 0 0 1-1.157-1.723L5.633 10.5H3.462c-1.57 0-2.346-1.909-1.22-3.004ZM9.98 1.873a.016.016 0 0 0-.016.006L2.252 9.021a.75.75 0 0 0 .488 1.212h3.838a.75.75 0 0 1 .694 1.034L5.545 15.02a.016.016 0 0 0 .003.017.017.017 0 0 0 .018.004h.302a.016.016 0 0 0 .012-.005l7.34-7.142a.75.75 0 0 0-.488-1.212h-3.838a.75.75 0 0 1-.694-1.034l1.527-3.628a.016.016 0 0 0-.003-.017.017.017 0 0 0-.018-.004h-.302Z"></path></svg>`,
},
{
type: `bug`,
title: `Bug`,
icon: `<svg class="octicon octicon-bug" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4.72.22a.75.75 0 0 1 1.06 0l1 .999a3.488 3.488 0 0 1 2.441 0l.999-1a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.775.776c.616.63.995 1.493.995 2.444v.327c0 .1-.009.197-.025.292l.727.726a.75.75 0 1 1-1.06 1.06l-.727-.727a2.17 2.17 0 0 1-.292.026H9.25V7.5a.75.75 0 0 1-1.5 0V6.125H6.875a2.17 2.17 0 0 1-.292-.026l-.727.727a.75.75 0 1 1-1.06-1.06l.727-.726a2.17 2.17 0 0 1-.025-.292V4.5c0-.951.379-1.814.995-2.444l-.775-.776a.75.75 0 0 1 0-1.06Zm6.437 6.003A.608.608 0 0 0 11 6.072v-.026a3.999 3.999 0 0 0-.11-.936 2.488 2.488 0 0 0-5.78 0 3.992 3.992 0 0 0-.11.936v.026c0 .05.008.098.02.147h4.937a.612.612 0 0 0 .2-.02ZM2.25 7.5a.75.75 0 0 0 0 1.5h.5v1.25a4.75 4.75 0 0 0 2.478 4.166l.247.137a.75.75 0 1 0 .722-1.313l-.246-.137A3.25 3.25 0 0 1 4.25 10.25V9h7.5v1.25a3.25 3.25 0 0 1-1.701 2.853l-.246.137a.75.75 0 1 0 .722 1.313l.247-.137A4.75 4.75 0 0 0 13.25 10.25V9h.5a.75.75 0 0 0 0-1.5Z"></path></svg>`,
},
{
type: `example`,
title: `Example`,
icon: `<svg class="octicon octicon-list-unordered" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M5.75 2.5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2 14a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-6a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM2 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>`,
},
{
type: `quote`,
title: `Quote`,
icon: `<svg class="octicon octicon-quote" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25v-8.5C0 2.784.784 2 1.75 2ZM1.5 12.25c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25ZM4 5.25a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5A.75.75 0 0 1 4 5.25Zm0 4a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1-.75-.75Z"></path></svg>`,
},
{
type: `cite`,
title: `Cite`,
icon: `<svg class="octicon octicon-quote" style="margin-right: 0.25em;" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25v-8.5C0 2.784.784 2 1.75 2ZM1.5 12.25c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25ZM4 5.25a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5A.75.75 0 0 1 4 5.25Zm0 4a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1-.75-.75Z"></path></svg>`,
},
]
/**
* Resolves the variants configuration, combining the provided variants with
* the default variants.
*/
export function resolveVariants(variants: AlertVariantItem[]) {
if (!variants.length)
return defaultAlertVariant
return Object.values(
[...defaultAlertVariant, ...variants].reduce(
(map, item) => {
map[item.type] = item
return map
},
{} as { [key: string]: AlertVariantItem },
),
)
}
/**
* Returns regex pattern to match alert syntax.
*/
export function createSyntaxPattern(type: string) {
return `^(?:\\[!${type}])\\s*?\n*`
}
@@ -0,0 +1,89 @@
import type { MarkedExtension, Tokens } from 'marked'
/**
* A marked extension to support footnotes syntax.
* Syntax:
* This is a footnote reference[^1][^2].
*
* [^1]: .....
* [^2]: .....
*/
interface MapContent {
index: number
text: string
}
const fnMap = new Map<string, MapContent>()
export function markedFootnotes(): MarkedExtension {
return {
extensions: [
{
name: `footnoteDef`,
level: `block`,
start(src: string) {
fnMap.clear()
return src.match(/^\[\^/)?.index
},
tokenizer(src: string) {
const match = src.match(/^\[\^(.*)\]:(.*)/)
if (match) {
const [raw, fnId, text] = match
const index = fnMap.size + 1
fnMap.set(fnId, { index, text })
return {
type: `footnoteDef`,
raw,
fnId,
index,
text,
}
}
return undefined
},
renderer(token: Tokens.Generic) {
const { index, text, fnId } = token
const fnInner = `
<code>${index}.</code>
<span>${text}</span>
<a id="fnDef-${fnId}" href="#fnRef-${fnId}" style="color: var(--md-primary-color);">\u21A9\uFE0E</a>
<br>`
if (index === 1) {
return `
<p style="font-size: 80%;margin: 0.5em 8px;word-break:break-all;">${fnInner}`
}
if (index === fnMap.size) {
return `${fnInner}</p>`
}
return fnInner
},
},
{
name: `footnoteRef`,
level: `inline`,
start(src: string) {
return src.match(/\[\^/)?.index
},
tokenizer(src: string) {
const match = src.match(/^\[\^(.*?)\]/)
if (match) {
const [raw, fnId] = match
if (fnMap.has(fnId)) {
return {
type: `footnoteRef`,
raw,
fnId,
}
}
}
},
renderer(token: Tokens.Generic) {
const { fnId } = token
const { index } = fnMap.get(fnId) as MapContent
return `<sup style="color: var(--md-primary-color);">
<a href="#fnDef-${fnId}" id="fnRef-${fnId}">\[${index}\]</a>
</sup>`
},
},
],
}
}
@@ -0,0 +1,10 @@
// Markdown 扩展导出
export * from './alert.js'
export * from './footnotes.js'
export * from './infographic.js'
export * from './katex.js'
export * from './markup.js'
export * from './plantuml.js'
export * from './ruby.js'
export * from './slider.js'
export * from './toc.js'
@@ -0,0 +1,119 @@
import type { MarkedExtension } from 'marked'
interface InfographicOptions {
themeMode?: 'dark' | 'light'
}
async function renderInfographic(containerId: string, code: string, options?: InfographicOptions) {
if (typeof window === 'undefined')
return
try {
const { Infographic, setDefaultFont, setFontExtendFactor, exportToSVG } = await import('@antv/infographic')
setFontExtendFactor(1.1)
setDefaultFont('-apple-system-font, "system-ui", "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif')
const findContainer = (retries = 5, delay = 100) => {
const container = document.getElementById(containerId)
if (container) {
const isDark = options?.themeMode === 'dark'
// 从 CSS 变量中读取主题颜色
const root = document.documentElement
const computedStyle = getComputedStyle(root)
const primaryColor = computedStyle.getPropertyValue('--md-primary-color').trim()
const backgroundColor = computedStyle.getPropertyValue('--background').trim()
// 转换 HSL 格式
const toHSLString = (variant: string) => {
const vars = variant.split(' ')
if (vars.length === 3)
return `hsl(${vars.join(', ')})`
if (vars.length === 4)
return `hsla(${vars.join(', ')})`
return ''
}
const instance = new Infographic({
container,
svg: {
style: {
width: '100%',
height: '100%',
background: isDark ? '#000' : 'transparent',
},
background: false,
},
theme: isDark ? 'dark' : 'default',
themeConfig: {
colorPrimary: primaryColor || undefined,
colorBg: toHSLString(backgroundColor) || undefined,
},
})
instance.on('loaded', ({ node }) => {
exportToSVG(node, { removeIds: true }).then((svg) => {
container.replaceChildren(svg)
})
})
instance.render(code)
return
}
if (retries > 0) {
setTimeout(() => findContainer(retries - 1, delay), delay)
}
}
findContainer()
}
catch (error) {
console.error('Failed to render Infographic:', error)
const container = document.getElementById(containerId)
if (container) {
container.innerHTML = `<div style="color: red; padding: 10px; border: 1px solid red;">Infographic 渲染失败: ${error instanceof Error ? error.message : String(error)}</div>`
}
}
}
export function markedInfographic(options?: InfographicOptions): MarkedExtension {
const className = 'infographic-diagram'
return {
extensions: [
{
name: 'infographic',
level: 'block',
start(src: string) {
return src.match(/^```infographic/m)?.index
},
tokenizer(src: string) {
const match = /^```infographic\r?\n([\s\S]*?)\r?\n```/.exec(src)
if (match) {
return {
type: 'infographic',
raw: match[0],
text: match[1].trim(),
}
}
},
renderer(token: any) {
const id = `infographic-${Math.random().toString(36).slice(2, 11)}`
const code = token.text
renderInfographic(id, code, options)
return `<div id="${id}" class="${className}" style="width: 100%;">正在加载 Infographic...</div>`
},
},
],
walkTokens(token: any) {
if (token.type === 'code' && token.lang === 'infographic') {
token.type = 'infographic'
}
},
}
}
@@ -0,0 +1,162 @@
import type { MarkedExtension } from 'marked'
export interface MarkedKatexOptions {
nonStandard?: boolean
}
const inlineRule = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n$]))\1(?=[\s?!.,:?!。,:]|$)/
const inlineRuleNonStandard = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n$]))\1/ // Non-standard, even if there are no spaces before and after $ or $$, try to parse
const blockRule = /^\s{0,3}(\${1,2})[ \t]*\n([\s\S]+?)\n\s{0,3}\1[ \t]*(?:\n|$)/
// LaTeX style rules for \( ... \) and \[ ... \]
const inlineLatexRule = /^\\\(([^\\]*(?:\\.[^\\]*)*?)\\\)/
const blockLatexRule = /^\\\[([^\\]*(?:\\.[^\\]*)*?)\\\]/
function createRenderer(display: boolean, withStyle: boolean = true) {
return (token: any) => {
// @ts-expect-error MathJax is a global variable
window.MathJax.texReset()
// @ts-expect-error MathJax is a global variable
const mjxContainer = window.MathJax.tex2svg(token.text, { display })
const svg = mjxContainer.firstChild
const width = svg.style[`min-width`] || svg.getAttribute(`width`)
svg.removeAttribute(`width`)
// 行内公式对齐 https://groups.google.com/g/mathjax-users/c/zThKffrrCvE?pli=1
// 直接覆盖 style 会覆盖 MathJax 的样式,需要手动设置
// svg.style = `max-width: 300vw !important; display: initial; flex-shrink: 0;`
if (withStyle) {
svg.style.display = `initial`
svg.style.setProperty(`max-width`, `300vw`, `important`)
svg.style.flexShrink = `0`
svg.style.width = width
}
if (!display) {
// 新主题系统:使用 class 而非内联样式
return `<span class="katex-inline">${svg.outerHTML}</span>`
}
return `<section class="katex-block">${svg.outerHTML}</section>`
}
}
function inlineKatex(options: MarkedKatexOptions | undefined, renderer: any) {
const nonStandard = options && options.nonStandard
const ruleReg = nonStandard ? inlineRuleNonStandard : inlineRule
return {
name: `inlineKatex`,
level: `inline`,
start(src: string) {
let index
let indexSrc = src
while (indexSrc) {
index = indexSrc.indexOf(`$`)
if (index === -1) {
return
}
const f = nonStandard ? index > -1 : index === 0 || indexSrc.charAt(index - 1) === ` `
if (f) {
const possibleKatex = indexSrc.substring(index)
if (possibleKatex.match(ruleReg)) {
return index
}
}
indexSrc = indexSrc.substring(index + 1).replace(/^\$+/, ``)
}
},
tokenizer(src: string) {
const match = src.match(ruleReg)
if (match) {
return {
type: `inlineKatex`,
raw: match[0],
text: match[2].trim(),
displayMode: match[1].length === 2,
}
}
},
renderer,
}
}
function blockKatex(_options: MarkedKatexOptions | undefined, renderer: any) {
return {
name: `blockKatex`,
level: `block`,
tokenizer(src: string) {
const match = src.match(blockRule)
if (match) {
return {
type: `blockKatex`,
raw: match[0],
text: match[2].trim(),
displayMode: match[1].length === 2,
}
}
},
renderer,
}
}
function inlineLatexKatex(_options: MarkedKatexOptions | undefined, renderer: any) {
return {
name: `inlineLatexKatex`,
level: `inline`,
start(src: string) {
const index = src.indexOf(`\\(`)
return index !== -1 ? index : undefined
},
tokenizer(src: string) {
const match = src.match(inlineLatexRule)
if (match) {
return {
type: `inlineLatexKatex`,
raw: match[0],
text: match[1].trim(),
displayMode: false,
}
}
},
renderer,
}
}
function blockLatexKatex(_options: MarkedKatexOptions | undefined, renderer: any) {
return {
name: `blockLatexKatex`,
level: `block`,
start(src: string) {
const index = src.indexOf(`\\[`)
return index !== -1 ? index : undefined
},
tokenizer(src: string) {
const match = src.match(blockLatexRule)
if (match) {
return {
type: `blockLatexKatex`,
raw: match[0],
text: match[1].trim(),
displayMode: true,
}
}
},
renderer,
}
}
export function MDKatex(options: MarkedKatexOptions | undefined, withStyle: boolean = true): MarkedExtension {
return {
extensions: [
inlineKatex(options, createRenderer(false, withStyle)),
blockKatex(options, createRenderer(true, withStyle)),
inlineLatexKatex(options, createRenderer(false, withStyle)),
blockLatexKatex(options, createRenderer(true, withStyle)),
],
}
}
@@ -0,0 +1,87 @@
import type { MarkedExtension } from 'marked'
/**
* 扩展标记语法:
* - 高亮: ==文本==
* - 下划线: ++文本++
* - 波浪线: ~文本~
*/
export function markedMarkup(): MarkedExtension {
return {
extensions: [
// 高亮语法 ==文本==
{
name: `markup_highlight`,
level: `inline`,
start(src: string) {
return src.match(/==(?!=)/)?.index
},
tokenizer(src: string) {
const rule = /^==((?:[^=]|=(?!=))+)==/
const match = rule.exec(src)
if (match) {
return {
type: `markup_highlight`,
raw: match[0],
text: match[1],
}
}
},
renderer(token: any) {
// 新主题系统:使用 class 而非内联样式
return `<span class="markup-highlight">${token.text}</span>`
},
},
// 下划线语法 ++文本++
{
name: `markup_underline`,
level: `inline`,
start(src: string) {
return src.match(/\+\+(?!\+)/)?.index
},
tokenizer(src: string) {
const rule = /^\+\+((?:[^+]|\+(?!\+))+)\+\+/
const match = rule.exec(src)
if (match) {
return {
type: `markup_underline`,
raw: match[0],
text: match[1],
}
}
},
renderer(token: any) {
// 新主题系统:使用 class 而非内联样式
return `<span class="markup-underline">${token.text}</span>`
},
},
// 波浪线语法 ~文本~
{
name: `markup_wavyline`,
level: `inline`,
start(src: string) {
// 查找单个 ~ 但不是连续的 ~~
return src.match(/~(?!~)/)?.index
},
tokenizer(src: string) {
// 匹配 ~文本~ 但确保不是 ~~文本~~
const rule = /^~([^~\n]+)~(?!~)/
const match = rule.exec(src)
if (match) {
return {
type: `markup_wavyline`,
raw: match[0],
text: match[1],
}
}
},
renderer(token: any) {
// 新主题系统:使用 class 而非内联样式
return `<span class="markup-wavyline">${token.text}</span>`
},
},
],
}
}
@@ -0,0 +1,289 @@
import type { MarkedExtension, Tokens } from 'marked'
import { deflateSync } from 'fflate'
export interface PlantUMLOptions {
/**
* PlantUML 服务器地址
* @default 'https://www.plantuml.com/plantuml'
*/
serverUrl?: string
/**
* 渲染格式
* @default 'svg'
*/
format?: `svg` | `png`
/**
* CSS 类名
* @default 'plantuml-diagram'
*/
className?: string
/**
* 是否内嵌SVG内容(用于微信公众号等不支持外链图片的环境)
* @default false
*/
inlineSvg?: boolean
/**
* 自定义样式
*/
styles?: {
container?: Record<string, string | number>
}
}
/**
* PlantUML 专用的 6-bit 编码函数
* 基于官方文档 https://plantuml.com/text-encoding
*/
function encode6bit(b: number): string {
if (b < 10) {
return String.fromCharCode(48 + b)
}
b -= 10
if (b < 26) {
return String.fromCharCode(65 + b)
}
b -= 26
if (b < 26) {
return String.fromCharCode(97 + b)
}
b -= 26
if (b === 0) {
return `-`
}
if (b === 1) {
return `_`
}
return `?`
}
/**
* 将 3 个字节附加到编码字符串中
* 基于官方文档 https://plantuml.com/text-encoding
*/
function append3bytes(b1: number, b2: number, b3: number): string {
const c1 = b1 >> 2
const c2 = ((b1 & 0x3) << 4) | (b2 >> 4)
const c3 = ((b2 & 0xF) << 2) | (b3 >> 6)
const c4 = b3 & 0x3F
let r = ``
r += encode6bit(c1 & 0x3F)
r += encode6bit(c2 & 0x3F)
r += encode6bit(c3 & 0x3F)
r += encode6bit(c4 & 0x3F)
return r
}
/**
* PlantUML 专用的 base64 编码函数
* 基于官方文档 https://plantuml.com/text-encoding
*/
function encode64(data: string): string {
let r = ``
for (let i = 0; i < data.length; i += 3) {
if (i + 2 === data.length) {
r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), 0)
}
else if (i + 1 === data.length) {
r += append3bytes(data.charCodeAt(i), 0, 0)
}
else {
r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), data.charCodeAt(i + 2))
}
}
return r
}
/**
* 使用 fflate 库进行 Deflate 压缩
* 按照官方规范进行压缩
*/
function performDeflate(input: string): string {
try {
// 将字符串转换为字节数组
const inputBytes = new TextEncoder().encode(input)
// 使用 fflate 进行 deflate 压缩(最高压缩级别 9)
const compressed = deflateSync(inputBytes, { level: 9 })
// 将压缩后的字节数组转换为二进制字符串
return String.fromCharCode(...compressed)
}
catch (error) {
console.warn(`Deflate compression failed:`, error)
// 如果压缩失败,返回原始输入
return input
}
}
/**
* 编码 PlantUML 代码为服务器可识别的格式
* 按照官方规范:UTF-8 编码 -> Deflate 压缩 -> PlantUML Base64 编码
*/
function encodePlantUML(plantumlCode: string): string {
try {
// 步骤 1 & 2: UTF-8 编码 + Deflate 压缩
const deflated = performDeflate(plantumlCode)
// 步骤 3: PlantUML 专用的 base64 编码
return encode64(deflated)
}
catch (error) {
// 如果编码失败,回退到简单方案
console.warn(`PlantUML encoding failed, using fallback:`, error)
const utf8Bytes = new TextEncoder().encode(plantumlCode)
const base64 = btoa(String.fromCharCode(...utf8Bytes))
return `~1${base64.replace(/\+/g, `-`).replace(/\//g, `_`).replace(/=/g, ``)}`
}
}
/**
* 生成 PlantUML 图片 URL
*/
function generatePlantUMLUrl(code: string, options: Required<PlantUMLOptions>): string {
const encoded = encodePlantUML(code)
const formatPath = options.format === `svg` ? `svg` : `png`
return `${options.serverUrl}/${formatPath}/${encoded}`
}
/**
* 渲染 PlantUML 图表
*/
function renderPlantUMLDiagram(token: Tokens.Code, options: Required<PlantUMLOptions>): string {
const { text: code } = token
// 检查代码是否包含 PlantUML 标记
const finalCode = (!code.trim().includes(`@start`) || !code.trim().includes(`@end`))
? `@startuml\n${code.trim()}\n@enduml`
: code
const imageUrl = generatePlantUMLUrl(finalCode, options)
// 如果启用了内嵌SVG且格式是SVG
if (options.inlineSvg && options.format === `svg`) {
// 由于marked是同步的,我们需要返回一个占位符,然后异步替换
const placeholder = `plantuml-placeholder-${Math.random().toString(36).slice(2, 11)}`
// 异步获取SVG内容并替换
fetchSvgContent(imageUrl).then((svgContent) => {
const placeholderElement = document.querySelector(`[data-placeholder="${placeholder}"]`)
if (placeholderElement) {
placeholderElement.outerHTML = createPlantUMLHTML(imageUrl, options, svgContent)
}
})
const containerStyles = options.styles.container
? Object.entries(options.styles.container)
.map(([key, value]) => `${key.replace(/([A-Z])/g, `-$1`).toLowerCase()}: ${value}`)
.join(`; `)
: ``
return `<div class="${options.className}" style="${containerStyles}" data-placeholder="${placeholder}">
<div style="color: #666; font-style: italic;">正在加载PlantUML图表...</div>
</div>`
}
return createPlantUMLHTML(imageUrl, options)
}
/**
* 获取SVG内容
*/
async function fetchSvgContent(svgUrl: string): Promise<string> {
try {
const response = await fetch(svgUrl)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const svgContent = await response.text()
// 移除SVG根元素的固定尺寸,使其响应式
return svgContent
// 移除width和height属性
.replace(/(<svg[^>]*)\swidth="[^"]*"/g, `$1`)
.replace(/(<svg[^>]*)\sheight="[^"]*"/g, `$1`)
// 移除style中的width和height
.replace(/(<svg[^>]*style="[^"]*?)width:[^;]*;?/g, `$1`)
.replace(/(<svg[^>]*style="[^"]*?)height:[^;]*;?/g, `$1`)
}
catch (error) {
console.warn(`Failed to fetch SVG content from ${svgUrl}:`, error)
return `<div style="color: #666; font-style: italic;">PlantUML图表加载失败</div>`
}
}
/**
* 创建 PlantUML HTML 元素
*/
function createPlantUMLHTML(imageUrl: string, options: Required<PlantUMLOptions>, svgContent?: string): string {
const containerStyles = options.styles.container
? Object.entries(options.styles.container)
.map(([key, value]) => `${key.replace(/([A-Z])/g, `-$1`).toLowerCase()}: ${value}`)
.join(`; `)
: ``
// 如果有SVG内容,直接嵌入
if (svgContent) {
return `<div class="${options.className}" style="${containerStyles}">
${svgContent}
</div>`
}
// 否则使用图片链接
return `<div class="${options.className}" style="${containerStyles}">
<img src="${imageUrl}" alt="PlantUML Diagram" style="max-width: 100%; height: auto;" />
</div>`
}
/**
* PlantUML marked 扩展
*/
export function markedPlantUML(options: PlantUMLOptions = {}): MarkedExtension {
const resolvedOptions: Required<PlantUMLOptions> = {
serverUrl: options.serverUrl || `https://www.plantuml.com/plantuml`,
format: options.format || `svg`,
className: options.className || `plantuml-diagram`,
inlineSvg: options.inlineSvg || false,
styles: {
container: {
textAlign: `center`,
margin: `16px 8px`,
overflowX: `auto`,
...options.styles?.container,
},
},
}
return {
extensions: [
{
name: `plantuml`,
level: `block`,
start(src: string) {
// 匹配 ```plantuml 代码块
return src.match(/^```plantuml/m)?.index
},
tokenizer(src: string) {
// 匹配完整的 plantuml 代码块
const match = /^```plantuml\r?\n([\s\S]*?)\r?\n```/.exec(src)
if (match) {
const [raw, code] = match
return {
type: `plantuml`,
raw,
text: code.trim(),
}
}
},
renderer(token: any) {
return renderPlantUMLDiagram(token, resolvedOptions)
},
},
],
walkTokens(token: any) {
// 处理现有的代码块,如果语言是 plantuml 就转换类型
if (token.type === `code` && token.lang === `plantuml`) {
token.type = `plantuml`
}
},
}
}
@@ -0,0 +1,125 @@
import type { MarkedExtension } from 'marked'
/**
* 注音/拼音标注扩展
* https://talk.commonmark.org/t/proper-ruby-text-rb-syntax-support-in-markdown/2279
* https://www.w3.org/TR/ruby/
*
* 支持的格式:
* 1. [文字]{注音}
* 2. [文字]^(注音)
*
* 分隔符:
* - `・` (中点)
* - `` (全角句点)
* - `。` (中文句号)
* - `-` (英文减号)
*/
export function markedRuby(): MarkedExtension {
return {
extensions: [
{
name: `ruby`,
level: `inline`,
start(src: string) {
// 匹配以 [ 开头的格式
return src.match(/\[/)?.index
},
tokenizer(src: string) {
// 1. [文字]{注音}
const rule1 = /^\[([^\]]+)\]\{([^}]+)\}/
let match = rule1.exec(src)
if (match) {
return {
type: `ruby`,
raw: match[0],
text: match[1].trim(),
ruby: match[2].trim(),
format: `basic`,
}
}
// 2. [文字]^(注音)
const rule2 = /^\[([^\]]+)\]\^\(([^)]+)\)/
match = rule2.exec(src)
if (match) {
return {
type: `ruby`,
raw: match[0],
text: match[1].trim(),
ruby: match[2].trim(),
format: `basic-hat`,
}
}
return undefined
},
renderer(token: any) {
const { text, ruby, format } = token
// 检查是否有分隔符
const separatorRegex = /[・.。-]/g
const hasSeparators = separatorRegex.test(ruby)
if (hasSeparators) {
// 分割注音部分
const rubyParts = ruby.split(separatorRegex).filter((part: string) => part.trim() !== ``)
const textChars = text.split(``)
const result = []
if (textChars.length >= rubyParts.length) {
// 文字字符数量 >= 注音部分数量
// 按注音部分数量分割文字
let currentIndex = 0
for (let i = 0; i < rubyParts.length; i++) {
const rubyPart = rubyParts[i]
const remainingChars = textChars.length - currentIndex
const remainingParts = rubyParts.length - i
// 计算当前部分应该包含多少个字符,默认为 1
let charCount = 1
if (remainingParts === 1) {
// 最后一个部分,包含所有剩余字符
charCount = remainingChars
}
// 提取当前部分的文字
const currentText = textChars.slice(currentIndex, currentIndex + charCount).join(``)
result.push(`<ruby data-text="${currentText}" data-ruby="${rubyPart}" data-format="${format}">${currentText}<rp>(</rp><rt>${rubyPart}</rt><rp>)</rp></ruby>`)
currentIndex += charCount
}
// 处理剩余的字符
if (currentIndex < textChars.length) {
result.push(textChars.slice(currentIndex).join(``))
}
}
else {
// 文字字符数量 < 注音部分数量
// 每个字符对应一个注音部分,多余的注音被忽略
for (let i = 0; i < textChars.length; i++) {
const char = textChars[i]
const rubyPart = rubyParts[i] || ``
if (rubyPart) {
result.push(`<ruby data-text="${char}" data-ruby="${rubyPart}" data-format="${format}">${char}<rp>(</rp><rt>${rubyPart}</rt><rp>)</rp></ruby>`)
}
else {
result.push(char)
}
}
}
return result.join(``)
}
return `<ruby data-text="${text}" data-ruby="${ruby}" data-format="${format}">${text}<rp>(</rp><rt>${ruby}</rt><rp>)</rp></ruby>`
},
},
],
}
}
@@ -0,0 +1,73 @@
import type { MarkedExtension, Tokens } from 'marked'
/**
* A marked extension to support horizontal sliding images.
* Syntax: <![alt1](url1),![alt2](url2),![alt3](url3)>
*/
export function markedSlider(): MarkedExtension {
return {
extensions: [
{
name: `horizontalSlider`,
level: `block`,
start(src: string) {
return src.match(/^<!\[/)?.index
},
tokenizer(src: string) {
const rule = /^<(!\[.*?\]\(.*?\)(?:,!\[.*?\]\(.*?\))*)>/
const match = src.match(rule)
if (match) {
return {
type: `horizontalSlider`,
raw: match[0],
text: match[1],
}
}
return undefined
},
renderer(token: Tokens.Generic) {
const { text } = token
const imageMatches = text.match(/!\[(.*?)\]\((.*?)\)/g) || []
if (imageMatches.length === 0) {
return ``
}
const images = imageMatches.map((img: string) => {
const altMatch = img.match(/!\[(.*?)\]/) || []
const srcMatch = img.match(/\]\((.*?)\)/) || []
const alt = altMatch[1] || ``
const src = srcMatch[1] || ``
// 新主题系统:不再需要内联样式
return { src, alt }
})
// 使用微信公众号兼容的滑动容器布局
// 使用微信支持的section标签和特殊样式组合
return `
<section style="box-sizing: border-box; font-size: 16px;">
<section data-role="outer" style="font-family: 微软雅黑; font-size: 16px;">
<section data-role="paragraph" style="margin: 0px auto; box-sizing: border-box; width: 100%;">
<section style="margin: 0px auto; text-align: center;">
<section style="display: inline-block; width: 100%;">
<!-- 微信公众号支持的滑动图片容器 -->
<section style="overflow-x: scroll; -webkit-overflow-scrolling: touch; white-space: nowrap; width: 100%; text-align: center;">
${images.map((img: { src: string, alt: string }, _index: number) => `<section style="display: inline-block; width: 100%; margin-right: 0; vertical-align: top;">
<img src="${img.src}" alt="${img.alt}" title="${img.alt}" style="width: 100%; height: auto; border-radius: 4px; vertical-align: top;"/>
<p style="margin-top: 5px; font-size: 14px; color: #666; text-align: center; white-space: normal;">${img.alt}</p>
</section>`).join(``)}
</section>
</section>
</section>
</section>
</section>
<p style="font-size: 14px; color: #999; text-align: center; margin-top: 5px;"><<< 左右滑动看更多 >>></p>
</section>
`
},
},
],
}
}
@@ -0,0 +1,74 @@
import type { MarkedExtension } from 'marked'
/**
* marked 插件:支持 [TOC] 语法,自动生成嵌套目录
*/
export function markedToc(): MarkedExtension {
let headings: { text: string, depth: number, index: number }[] = []
let firstToken = true
return {
walkTokens(token) {
if (firstToken) {
headings = []
firstToken = false
}
if (token.type === `heading`) {
const text = token.text || ``
const depth = token.depth || 1
const index = headings.length
headings.push({ text, depth, index })
}
},
extensions: [
{
name: `toc`,
level: `block`,
start(src) {
// 只匹配独立一行的 [TOC],避免误伤
const match = src.match(/^\s*\[TOC\]\s*$/m)
return match ? match.index : undefined
},
tokenizer(src) {
const match = /^\[TOC\]/.exec(src)
if (match) {
return {
type: `toc`,
raw: match[0],
}
}
},
renderer() {
if (!headings.length)
return ``
let html = `<nav class="markdown-toc"><ul class="toc-ul toc-level-1 pl-4 border-l ml-2">`
let lastDepth = 1
headings.forEach(({ text, depth, index }) => {
if (depth > lastDepth) {
for (let i = lastDepth + 1; i <= depth; i++) {
html += `<ul class="toc-ul toc-level-${i} pl-4 border-l ml-2">`
}
}
else if (depth < lastDepth) {
for (let i = lastDepth; i > depth; i--) {
html += `</ul>`
}
}
html += `<li class="toc-li toc-level-${depth} mb-1"><a class="text-gray-700 hover:text-blue-600 underline transition-colors" href="#${index}">${text}</a></li>`
lastDepth = depth
})
for (let i = lastDepth; i > 1; i--) {
html += `</ul>`
}
html += `</ul></nav>`
firstToken = true
return html
},
},
],
}
}