Ghost适配高级Markdown样式
Ghost默认是不支持公式的代码块之类的高级markdown样式的。它仅支持markdown-it插件能渲染的样式。
我们需要自定义样式的加载。
Ghost 作为一个 Headless CMS,其设计哲学是“极致的精简与轻量”。但这对于技术博主来说往往意味着阵痛:默认主题通常缺乏对复杂 Markdown 语法的支持。
本文复盘在 Ghost (v5.x) 中实现以下功能的完整技术路径:
- 表格:修复主题缺失的边框与排版。
- 代码块:实现 Prism.js 高亮、亮/暗色自适应、以及汉化的常驻复制按钮。
- 数学公式:解决 Ghost 后端解析器“污染” LaTeX 语法的核心难题。
核心思路:Code Injection
Ghost 官方不支持类似 WordPress 的“插件”上传。所有的前端样式修正,都必须通过 Settings -> Code Injection 中的 Site Header (CSS) 和 Site Footer (JS) 来实现。
一、 表格不渲染问题
在 Ghost 编辑器中,表格上方必须保留一个物理空行。如果表格紧挨着上一段文字,Ghost 的解析器会将其识别为普通文本。
二、 代码高亮与交互体验
Ghost 自带的高亮非常基础。为了实现“生产力级”的代码阅读体验,我们选择 Prism.js 方案,但需要解决三个问题。
1. 静态资源的 SRI 报错
直接引用 CDN (cdnjs) 时,如果携带 integrity 校验码,可能会因为节点文件差异导致浏览器拦截资源,表现为“样式全无”。
- 对策:在 Code Injection 中移除
<link>和<script>标签中的integrity属性。
2. 复制按钮
原生 Prism 插件的按钮通常需要 Hover 触发。
- 修改 CSS
opacity: 1强制按钮常驻,提升移动端和非鼠标操作的体验。
三、复杂数学公式
这是排查过程中耗时最长的部分。Ghost 的后端解析器(Koenig)为了提升阅读兼容性,会抢在前端渲染引擎(KaTeX/MathJax)介入前,将 Markdown 特殊字符转义为 HTML 标签。
问题的本质:
大模型(如 ChatGPT、DeepSeek)输出的块级公式标准格式通常包含换行。Ghost 会将这些换行处理为 <br> 标签。更严重的是,它会将 LaTeX 中的指数符号 ^ 识别为 HTML 的上标语法。
- 输入:
e^{-x^2} - Ghost 解析后:
e<sup>{-x</sup>2} - 结果:渲染器由于无法在 DOM 中找到合法的 LaTeX 字符串,导致渲染崩溃或产生排版错误。
解决方案:正则表达式外科手术
我们通过在 Site Footer 注入一段脚本,在页面加载的一瞬间(渲染引擎启动前),对内容区域进行“逆向还原”:
- 精准锁定:利用正则
/\$\$([\s\S]*?)\$\$/gm仅处理块级公式,避免误伤正文。 - 暴力清洗:
- 将
<sup>和</sup>强制替换回^。 - 将
<br>还原为换行符。 - 还原被转义的
$(即$)。
- 时序同步:使用
setTimeout(..., 50)确保 DOM 树重绘完成后,再调用renderMathInElement进行渲染。
四、 亮暗模式
在处理代码块配色时,最初的方案是使用 CSS 媒体查询 @media (prefers-color-scheme: dark)。但在实际应用中,这种方案存在缺陷:它只能响应操作系统的设置,无法感知用户在博客页面上点击的主题切换按钮。
优化路径:
通过观察,我们确定 Ghost 主题(如 Casper、Edition)在切换亮暗模式时,会动态修改 <html> 标签的 data-theme 属性或 class。
- 对策:将 CSS 变量(
--c-bg等)直接绑定在html[data-theme="dark"]选择器下。 - 效果:实现代码块背景色、高亮配色与主题状态的毫秒级同步,而非死板地跟随系统。
五、 审美上的收尾:常驻按钮与页脚阻断
- 常驻复制按钮:
为了提升交互感,我们将按钮从opacity: 0(hover触发) 修改为常驻显示,并固定在div.code-toolbar的右上角。这在移动端及平板设备上尤为重要。 - 页脚清理:
如果你追求极致的纯粹感,不希望显示默认的 "Published with Ghost" 字样,可以通过 CSS 暴力阻断。这种方法比修改主题.hbs模板更稳健,不会在更新主题时丢失配置。
六、 最终代码配置汇总
直接粘贴到你的代码注入对应位置即可。
1. Site Header
<link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/KaTeX/0.16.9/katex.min.css" />
<style>
/* =========================================================
1) 主题变量:默认亮色,data-theme="dark" 时切换暗色
========================================================= */
:root {
--c-bg: #f6f8fa;
--c-text: #24292e;
--c-border: #e1e4e8;
--btn-bg: #d1d5da;
--btn-text: #24292e;
--tok-comment: #6a737d;
--tok-keyword: #d73a49;
--tok-string: #032f62;
--tok-number: #005cc5;
--tok-boolean: #005cc5;
--tok-function: #6f42c1;
--tok-class: #e36209;
--tok-builtin: #005cc5;
--tok-variable: #e36209;
--tok-property: #005cc5;
--tok-operator: #d73a49;
--tok-punctuation: #586069;
--tok-regex: #22863a;
--tok-constant: #005cc5;
--tok-namespace: #6f42c1;
--tok-decorator: #6f42c1;
}
html[data-theme="dark"] {
--c-bg: #0d1117;
--c-text: #c9d1d9;
--c-border: #30363d;
--btn-bg: #30363d;
--btn-text: #f0f6fc;
--tok-comment: #8b949e;
--tok-keyword: #ff7b72;
--tok-string: #a5d6ff;
--tok-number: #79c0ff;
--tok-boolean: #79c0ff;
--tok-function: #d2a8ff;
--tok-class: #ffa657;
--tok-builtin: #79c0ff;
--tok-variable: #ffa657;
--tok-property: #79c0ff;
--tok-operator: #ff7b72;
--tok-punctuation: #c9d1d9;
--tok-regex: #7ee787;
--tok-constant: #79c0ff;
--tok-namespace: #d2a8ff;
--tok-decorator: #d2a8ff;
}
/* =========================================================
2) 块级代码:只让 pre 做外壳,避免双层框
========================================================= */
div.code-toolbar > pre[class*="language-"],
.gh-content pre[class*="language-"],
pre[class*="language-"] {
background-color: var(--c-bg) !important;
color: var(--c-text) !important;
border: 1px solid var(--c-border) !important;
border-radius: 8px !important;
padding: 1.25em 1.35em !important;
margin: 1.5em 0 !important;
font-family: "Fira Code", Consolas, Monaco, monospace !important;
font-size: 0.95rem !important;
line-height: 1.65 !important;
overflow: auto !important;
box-shadow: none !important;
text-shadow: none !important;
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
tab-size: 4 !important;
}
/* 内层 code 完全透明,避免第二层框 */
div.code-toolbar > pre[class*="language-"] > code[class*="language-"],
.gh-content pre[class*="language-"] > code[class*="language-"],
pre[class*="language-"] > code[class*="language-"] {
display: block !important;
background: transparent !important;
color: inherit !important;
border: 0 !important;
border-radius: 0 !important;
padding: 0 !important;
margin: 0 !important;
box-shadow: none !important;
text-shadow: none !important;
font: inherit !important;
white-space: pre !important;
}
/* 避免主题对 pre code 追加块级底色 */
.gh-content pre code {
background: transparent !important;
border: 0 !important;
padding: 0 !important;
margin: 0 !important;
box-shadow: none !important;
}
/* =========================================================
3) 行内 code
========================================================= */
.gh-content :not(pre) > code,
.gh-content :not(pre) > code[class*="language-"] {
background: rgba(127, 127, 127, 0.12) !important;
color: var(--c-text) !important;
border: 1px solid var(--c-border) !important;
border-radius: 4px !important;
padding: 0.15em 0.35em !important;
font-family: "Fira Code", Consolas, Monaco, monospace !important;
font-size: 0.92em !important;
white-space: normal !important;
}
/* =========================================================
4) token 配色:覆盖 Prism 大部分常见 token
========================================================= */
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: var(--tok-comment) !important;
font-style: italic;
}
.token.punctuation {
color: var(--tok-punctuation) !important;
opacity: 0.9;
}
.token.namespace {
color: var(--tok-namespace) !important;
opacity: 0.95;
}
.token.property,
.token.tag,
.token.constant,
.token.symbol,
.token.deleted {
color: var(--tok-property) !important;
}
.token.boolean {
color: var(--tok-boolean) !important;
}
.token.number {
color: var(--tok-number) !important;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.inserted {
color: var(--tok-string) !important;
}
.token.operator,
.token.entity,
.token.url {
color: var(--tok-operator) !important;
}
.token.atrule,
.token.attr-value,
.token.function {
color: var(--tok-function) !important;
}
.token.keyword {
color: var(--tok-keyword) !important;
}
.token.class-name {
color: var(--tok-class) !important;
}
.token.regex,
.token.important {
color: var(--tok-regex) !important;
}
.token.builtin {
color: var(--tok-builtin) !important;
}
.token.variable {
color: var(--tok-variable) !important;
}
.token.annotation,
.token.decorator {
color: var(--tok-decorator) !important;
}
.token.important,
.token.bold {
font-weight: 700 !important;
}
.token.italic {
font-style: italic !important;
}
.token.entity {
cursor: help !important;
}
/* =========================================================
5) Toolbar / Copy
========================================================= */
div.code-toolbar {
position: relative !important;
}
div.code-toolbar > .toolbar {
position: absolute !important;
top: 10px !important;
right: 10px !important;
opacity: 1 !important;
visibility: visible !important;
z-index: 20 !important;
}
div.code-toolbar > .toolbar .toolbar-item {
display: inline-flex !important;
}
div.code-toolbar > .toolbar button,
div.code-toolbar > .toolbar a,
div.code-toolbar > .toolbar span {
background: var(--btn-bg) !important;
color: var(--btn-text) !important;
border: none !important;
border-radius: 5px !important;
padding: 4px 10px !important;
font-size: 12px !important;
font-weight: 600 !important;
line-height: 1.4 !important;
cursor: pointer !important;
box-shadow: 0 1px 2px rgba(0,0,0,0.12) !important;
text-decoration: none !important;
}
/* 复制按钮 hover */
div.code-toolbar > .toolbar button:hover,
div.code-toolbar > .toolbar a:hover {
filter: brightness(0.96);
}
/* =========================================================
6) 代码滚动条(可选)
========================================================= */
pre[class*="language-"]::-webkit-scrollbar {
height: 10px;
width: 10px;
}
pre[class*="language-"]::-webkit-scrollbar-thumb {
background: rgba(127,127,127,0.35);
border-radius: 999px;
}
pre[class*="language-"]::-webkit-scrollbar-track {
background: transparent;
}
</style>
2. Site Footer
<script>
window.Prism = window.Prism || {};
window.Prism.manual = true;
window.Prism.plugins = window.Prism.plugins || {};
window.Prism.plugins.toolbar = window.Prism.plugins.toolbar || {};
window.Prism.plugins.toolbar.copy = '复制';
</script>
<script src="https://cdn.bootcdn.net/ajax/libs/prism/1.29.0/prism.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/prism/1.29.0/plugins/toolbar/prism-toolbar.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/prism/1.29.0/plugins/copy-to-clipboard/prism-copy-to-clipboard.min.js"></script>
<script defer src="https://cdn.bootcdn.net/ajax/libs/KaTeX/0.16.9/katex.min.js"></script>
<script defer src="https://cdn.bootcdn.net/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function() {
if (window.Prism && Prism.plugins && Prism.plugins.autoloader) {
Prism.plugins.autoloader.languages_path =
"https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/";
Prism.plugins.autoloader.use_minified = true;
}
// --- 模块 1: 强制主题跟随系统 (Auto-Dark Mode) ---
function autoSyncTheme() {
const html = document.documentElement;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const applyTheme = (e) => {
if (e.matches) {
// 系统是暗色
html.classList.add('dark-mode');
html.setAttribute('data-theme', 'dark');
} else {
// 系统是亮色
html.classList.remove('dark-mode');
html.setAttribute('data-theme', 'light');
}
};
// 初始化执行
applyTheme(mediaQuery);
// 监听系统设置变化
mediaQuery.addEventListener('change', applyTheme);
}
autoSyncTheme();
// --- 模块 2: DOM 深度清洗与渲染 (修复公式与高亮) ---
function cleanAndRender() {
const content = document.querySelector('.gh-content, .post-content, .post-full-content, article, .c-content');
if (!content) return;
let html = content.innerHTML;
html = html.replace(/$/g, '$'); // 解码转义的 $
// 仅处理 $$ ... $$ 块,保护行内公式
html = html.replace(/\$\$([\s\S]*?)\$\$/gm, function(match, inner) {
let clean = inner.replace(/<br\s*\/?>/gi, '\n') // 还原换行
.replace(/<\/?p[^>]*>/gi, '') // 去除 P 标签
.replace(/<\/?sup[^>]*>/gi, '^') // 修复上标 e^{-x^2}
.replace(/<\/?sub[^>]*>/gi, '_') // 修复下标
.replace(/<\/?em[^>]*>/gi, '_') // 修复错判的斜体
.replace(/<\/?span[^>]*>/gi, ''); // 去除 span
const textArea = document.createElement('textarea');
textArea.innerHTML = clean;
return `$$ ${textArea.value} $$`;
});
content.innerHTML = html;
setTimeout(function() {
// 确保 Prism 对象存在再执行高亮
if (window.Prism) {
window.Prism.highlightAll();
}
if (typeof renderMathInElement !== 'undefined') {
renderMathInElement(document.body, {
delimiters: [
{left: '$$', right: '$$', display: true},
{left: '\\[', right: '\\]', display: true},
{left: '$', right: '$', display: false},
{left: '\\(', right: '\\)', display: false}
],
throwOnError : false,
ignoredTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code']
});
}
}, 50);
}
cleanAndRender();
});
</script>