如何给 Typecho 支持额外 Markdown 渲染

在不重写 Typecho 编辑器的前提下,给前台文章内容增加更丰富的 Markdown 渲染能力:

  • 图片自动转 <figure><img><figcaption> 1
  • Ruby 注音语法 2
  • Spoiler(文本高斯模糊,hover/点击显示)3
  • Tooltip(从 Markdown 链接 title 生成气泡提示)4

Ⅰ. 改造思路

分两层:

  1. 后端解析层(HyperDown)
  • 修正 Typecho 默认图片 alt/title 解析问题,确保输出 HTML 属性正确。
  1. 前端增强层(主题 footer.php + style.css
  • DOM 二次处理:把图片包装成 figure,把 ruby/spoiler 文本标记转换成结构化标签。
  • CSS 视觉:tooltip 气泡、spoiler 模糊动画。

Ⅱ. 图片解析:修正 HyperDown 输出

目录:/var/Utils/HyperDown.php

Typecho 原有1.3.0版本里,图片回调会把 title 字符串错误塞进 alt,导致:

  • alt 异常(例如出现 title="..." 残片)
  • 后续前端拿不到正确 title/alt

修正代码(约487行):

// image
$text = preg_replace_callback(
    "/!\\[((?:[^\\]]|\\\\\\]|\\\\\\[)*?)\\]\\(((?:[^\\)]|\\\\\\)|\\\\\\()+?)\\)/",
    function ($matches) {
        $escaped = htmlspecialchars($this->escapeBracket($matches[1]));
        $url = $this->escapeBracket($matches[2]);
        [$url, $title] = $this->cleanUrl($url, true);
        $title = empty($title) ? '' : " title=\"{$title}\"";

        return $this->makeHolder(
            "<img src=\"{$url}\" alt=\"{$escaped}\"{$title}>"
        );
    },
    $text
);

修正结果:

![Logo](/favicon.ico "Markdown")

预览:

Logo

主题首页

会得到正确的 img alt="Logo" title="Markdown",供后续 figure 逻辑使用。


Ⅲ. 图片转 figure:前端 DOM 二次包装

目录:/usr/themes/HansJack/footer.php

  • ![Logo](/favicon.ico)
    -> <figure><img ...><figcaption>Logo</figcaption></figure>
  • [![Logo](/favicon.ico "Markdown")](https://commonmark.org/)
    -> <figure><a ...><img ...></a><figcaption>Markdown</figcaption></figure>

代码:

(function () {
    var content = document.querySelector(".hj-article-content");
    if (!content) return;

    function hasOnlyImageParagraph(p, carrier) {
        var nodes = p.childNodes || [];
        for (var i = 0; i < nodes.length; i++) {
            var n = nodes[i];
            if (!n) continue;
            if (n.nodeType === 3 && String(n.textContent || "").trim() !== "") return false;
            if (n.nodeType === 1 && n !== carrier) return false;
        }
        return true;
    }

    var imgs = Array.prototype.slice.call(content.querySelectorAll("img"));
    imgs.forEach(function (img) {
        if (!img || !img.parentNode) return;
        if (img.closest && img.closest("figure")) return;

        var carrier = img;
        var p = img.parentNode;
        if (p && p.tagName === "A") {
            carrier = p;
            p = p.parentNode;
        }
        if (!p || p.tagName !== "P" || !hasOnlyImageParagraph(p, carrier)) return;

        var caption = String(img.getAttribute("title") || "").trim();
        if (!caption) caption = String(img.getAttribute("alt") || "").trim();

        img.setAttribute("tabindex", "0");
        img.setAttribute("loading", "lazy");
        if (img.hasAttribute("title")) img.removeAttribute("title");

        if (carrier.tagName === "A") {
            carrier.setAttribute("target", "_blank");
            carrier.setAttribute("rel", "noopener noreferrer");
        }

        var figure = document.createElement("figure");
        figure.appendChild(carrier);
        if (caption) {
            var figcaption = document.createElement("figcaption");
            figcaption.textContent = caption;
            figure.appendChild(figcaption);
        }
        p.parentNode.replaceChild(figure, p);
    });
})();

Ⅳ. Ruby 语法:{base:ruby1|ruby2}

目录:/usr/themes/主题名/footer.php

语法:

{正反対な君と僕:相反的你和我}

渲染:

<ruby>正反対な君と僕<rt>相反的你和我</rt></ruby>

预览:
{正反対な君と僕:相反的你和我}

逻辑:

  1. TreeWalker 遍历文章文本节点(跳过 code/pre/a/script/style/textarea/ruby/rt)。
  2. 扫描 {...} 且内部包含 :
  3. : 左侧为 base,右侧按 | 切分成多个 rt
  4. 动态创建 <ruby><rt> 节点替换原文本片段。

构建:

function buildRuby(baseText, annotations) {
    var ruby = document.createElement("ruby");
    ruby.appendChild(document.createTextNode(baseText));
    for (var i = 0; i < annotations.length; i++) {
        var rt = document.createElement("rt");
        rt.textContent = annotations[i];
        ruby.appendChild(rt);
    }
    return ruby;
}

Ⅴ. Spoiler 语法:!!隐藏内容!!

文件:/usr/themes/主题名/footer.php + style.css

语法:

好喜欢 !!蠢蠢欲动!!

渲染结果:

<span class="hj-term hj-term-spoiler spoiler">蠢蠢欲动</span>

好喜欢 !!蠢蠢欲动!!

构建:

function buildSpoiler(text) {
    var span = document.createElement("span");
    span.className = "hj-term hj-term-spoiler spoiler";
    span.setAttribute("tabindex", "0");
    span.textContent = text;
    return span;
}

CSS:

.hj-article-content .hj-term-spoiler,
.hj-article-content .spoiler {
    cursor: pointer;
    filter: blur(var(--hj-spoiler-blur));
    transition: filter var(--hj-spoiler-transition) ease;
}

.hj-article-content .hj-term-spoiler:hover,
.hj-article-content .hj-term-spoiler:focus-visible,
.hj-article-content .hj-term-spoiler.is-open,
.hj-article-content .spoiler:hover,
.hj-article-content .spoiler:focus-visible,
.hj-article-content .spoiler.is-open {
    filter: blur(0);
}

变量:

.hj-article-content {
    --hj-spoiler-blur: 3.5px;
    --hj-spoiler-transition: 0.24s;
}

Ⅵ. Tooltip:Markdown 链接 title


文件:/usr/themes/主题名/footer.php + style.css

写法:

[Boostnote](https://github.com/BoostIO/Boostnote "This is Boostnote's repository")

Boostnote

逻辑:

  1. 找到 a[title]
  2. 读取 title 内容作为提示文案
  3. 转成自定义属性并移除原生 title(避免浏览器默认 tooltip)
var titleLinks = content.querySelectorAll("a[title]");
for (var i = 0; i < titleLinks.length; i++) {
    var a = titleLinks[i];
    var tip = String(a.getAttribute("title") || "").trim();
    if (!tip) continue;
    a.classList.add("hj-term-tooltip");
    a.setAttribute("data-hj-term", tip);
    a.removeAttribute("title");
}

CSS:

.hj-article-content .hj-term-tooltip {
    display: inline-block;
    cursor: help;
    text-decoration-line: underline;
    text-decoration-style: dotted;
}

.hj-article-content .hj-term-tooltip::before {
    content: attr(data-hj-term);
    position: absolute;
    left: 50%;
    bottom: calc(100% - 0.02rem);
    transform: translate(-50%, 0.04rem);
    opacity: 0;
    visibility: hidden;
    transition: opacity 0.14s ease, transform 0.14s ease, visibility 0.14s ease;
}

.hj-article-content .hj-term-tooltip:hover::before,
.hj-article-content .hj-term-tooltip:focus-visible::before,
.hj-article-content .hj-term-tooltip.is-open::before {
    opacity: 1;
    visibility: visible;
    transform: translate(-50%, 0);
}

注意:

  • 文章外链图标常用 a::after,tooltip 也用 ::after 会冲突;
  • 这里把 tooltip 改成 ::before,可与外链图标共存。

Ⅶ. 全部样式参考

文件:blog/usr/themes/HansJack/style.css

figure

.hj-article-content figure {
    display: block;
    width: fit-content;
    max-width: 100%;
    margin: 1rem 0;
}

.hj-article-content figure img {
    display: block;
    max-width: 100%;
    height: auto;
    margin: 0;
}

.hj-article-content figcaption {
    margin-top: 0.45rem;
    font-size: 0.88rem;
    text-align: center;
}

ruby

.hj-article-content ruby {
    ruby-position: over;
}

.hj-article-content ruby rt {
    font-size: 0.66em;
    line-height: 1.05;
}

Ⅷ. 常见问题

  1. 图片 caption 乱码或错位
  • 先确认 HyperDown 是否已修 alt/title 输出。
  1. Tooltip 不显示,只显示外链图标
  • 检查是否与 a::after 冲突;tooltip 建议用 ::before
  1. Spoiler 在移动端无法收起
  • 确认有“点击外部关闭”的统一监听逻辑。
  1. Ruby 在代码块内被误解析
  • TreeWalker 过滤父节点时必须跳过 code/pre