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:
-
Backend parsing layer (HyperDown) - fix Typecho’s default image
alt/titleparsing so the generated HTML attributes are correct -
Frontend enhancement layer (
footer.php+style.cssin the theme) - post-process the rendered DOM to wrap images infigure, 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
altcontent, sometimes including fragments liketitle="..." - the frontend can no longer reliably read the real
titleandaltvalues
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:

renders properly.
Preview:


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:
-> <figure><img ...><figcaption>Logo</figcaption></figure>[](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 toalt - the original
titleis removed from the image after extraction - linked images are also normalized with
target="_blank"andrel="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:
- use
TreeWalkerto traverse article text nodes while skippingcode/pre/a/script/style/textarea/ruby/rt - scan for
{...}segments that contain: - treat the part before
:as the base text, and split the part after it by|into one or morertannotations - 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:
- find all
a[title] - read the
titlevalue as tooltip text - move it into a custom attribute and remove the native
titleso 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.