Extending Typecho’s Markdown Output Without Replacing the Editor

Published:

Typecho’s default Markdown handling is usable, but it leaves a lot of room for richer presentation on the front end. Without touching or rebuilding the editor itself, you can add several practical rendering enhancements to post content:

  • automatically turn images into <figure><img><figcaption> structures
  • support ruby annotation syntax
  • add spoiler text with blur/reveal behavior
  • generate custom tooltips from Markdown link titles

The overall approach is split into two parts.

Backend parsing and frontend enhancement

There are really two layers involved:

  1. Backend parsing layer (HyperDown) - fix Typecho’s default image alt/title parsing so the generated HTML attributes are correct

  2. Frontend enhancement layer (footer.php + style.css in the theme) - post-process the rendered DOM to wrap images in figure, and convert ruby/spoiler markers into semantic elements - use CSS to handle the visual side of tooltips and spoiler blur transitions


Fixing image output in HyperDown

File:

/var/Utils/HyperDown.php

In Typecho 1.3.0, the built-in image callback incorrectly pushes the title string into alt, which causes two problems:

  • malformed alt content, sometimes including fragments like title="..."
  • the frontend can no longer reliably read the real title and alt values

The fix is around line 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
);

With that corrected, Markdown like this:

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

renders properly.

Preview:

Logo

Theme homepage

The resulting HTML will correctly contain img alt="Logo" title="Markdown", which is exactly what the later figure conversion logic depends on.


Turning standalone images into figure

File:

/usr/themes/HansJack/footer.php

The idea is to run a second pass on the DOM after the article content is rendered.

Examples:

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

Code:

(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);
    });
})();

A few details matter here:

  • only paragraphs containing nothing except the image are converted
  • if the image is wrapped in a link, the link becomes the carrier inside figure
  • the caption prefers title, and falls back to alt
  • the original title is removed from the image after extraction
  • linked images are also normalized with target="_blank" and rel="noopener noreferrer"

Ruby annotation syntax: {base:ruby1|ruby2}

File:

/usr/themes/主题名/footer.php

Syntax:

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

Rendered as:

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

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

The logic is straightforward:

  1. use TreeWalker to traverse article text nodes while skipping code/pre/a/script/style/textarea/ruby/rt
  2. scan for {...} segments that contain :
  3. treat the part before : as the base text, and split the part after it by | into one or more rt annotations
  4. dynamically create <ruby><rt> nodes and replace the original text fragment

Construction function:

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;
}

The important part is the filtering step. If parent nodes such as code or pre are not excluded, ruby syntax inside code blocks will be parsed by mistake.


Spoiler syntax: !!hidden text!!

Files:

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

Syntax:

好喜欢 !!蠢蠢欲动!!

Rendered result:

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

Preview:

好喜欢 蠢蠢欲动

Builder:

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);
}

Variables:

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

This setup supports both hover and keyboard focus, and also leaves room for a click-to-open state via .is-open.


Custom tooltips from Markdown link titles

Files:

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

Markdown syntax:

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

Preview:

Boostnote

The implementation flow is simple:

  1. find all a[title]
  2. read the title value as tooltip text
  3. move it into a custom attribute and remove the native title so the browser’s default tooltip does not interfere
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);
}

One caveat is worth noting: external-link icons are often implemented with a::after. If the tooltip also uses ::after, the two can clash. Using ::before for the tooltip avoids that conflict and lets both effects coexist.


Reference styles for figure and ruby

File:

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;
}

A few common issues

Image captions look garbled or end up in the wrong place

Check the HyperDown fix first. If alt/title output is still broken, the frontend caption logic will not have the correct data to work with.

Tooltip does not appear and only the external-link icon shows

This is usually a pseudo-element conflict. If the link icon already uses a::after, the tooltip should be moved to ::before.

Spoiler text cannot be closed on mobile

That usually means the shared “click outside to close” listener is missing or incomplete.

Ruby gets parsed inside code blocks

When filtering nodes with TreeWalker, code and pre must be excluded.

By splitting the work between a small parser fix and a front-end post-processing layer, Typecho can gain much richer Markdown rendering without replacing the editor itself.