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://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css" />
<style>
/* --- 1. 颜色变量:自适应主题 data-theme 属性 --- */
:root {
--c-bg: #f6f8fa; /* 亮色背景 */
--c-text: #24292e; /* 亮色文字 */
--c-border: #e1e4e8; /* 亮色边框 */
--btn-bg: #d1d5da;
--btn-text: #24292e;
--tok-keyword: #d73a49;
--tok-func: #6f42c1;
--tok-str: #032f62;
--tok-comment: #6a737d;
}
/* 当主题切换为深色时 (监听 data-theme 属性) */
html[data-theme="dark"] {
--c-bg: #1e1e1e; /* 暗色背景 */
--c-text: #c9d1d9; /* 暗色文字 */
--c-border: #30363d; /* 暗色边框 */
--btn-bg: #4b5263;
--btn-text: #ffffff;
--tok-keyword: #ff7b72;
--tok-func: #d2a8ff;
--tok-str: #a5d6ff;
--tok-comment: #8b949e;
}
/* --- 2. 代码框主体样式 --- */
div.code-toolbar > pre,
.gh-content pre {
background-color: var(--c-bg) !important;
color: var(--c-text) !important;
border: 1px solid var(--c-border) !important;
border-radius: 6px !important;
padding: 1.5em !important;
margin: 1.5em 0 !important;
font-family: 'Fira Code', Consolas, monospace !important;
line-height: 1.6 !important;
transition: background-color 0.2s ease, color 0.2s ease;
}
div.code-toolbar > pre code {
background: transparent !important;
color: inherit !important;
}
/* --- 3. 语法高亮映射 --- */
.token.comment { color: var(--tok-comment) !important; font-style: italic; }
.token.keyword, .token.operator { color: var(--tok-keyword) !important; }
.token.function { color: var(--tok-func) !important; }
.token.string { color: var(--tok-str) !important; }
.token.punctuation { color: var(--c-text) !important; opacity: 0.7; }
/* --- 4. 复制按钮:强制常驻右上角 --- */
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: 10;
}
div.code-toolbar > .toolbar button {
background: var(--btn-bg) !important;
color: var(--btn-text) !important;
border: none !important;
border-radius: 4px !important;
padding: 4px 10px !important;
font-size: 12px !important;
font-weight: 600 !important;
cursor: pointer !important;
box-shadow: 0 1px 2px rgba(0,0,0,0.1) !important;
}
/* --- 5. 彻底隐藏页脚 --- */
.gh-foot,
.site-footer,
footer,
.gh-copyright,
.gh-powered-by {
display: none !important;
}
</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://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/toolbar/prism-toolbar.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/copy-to-clipboard/prism-copy-to-clipboard.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function() {
// --- 模块 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'); // 兼容部分第三方主题
html.setAttribute('class', html.getAttribute('class') + ' dark-mode'); // 暴力追加
} else {
// 系统是亮色:强制移除暗色类名
html.classList.remove('dark-mode');
html.setAttribute('data-theme', 'light');
// 暴力清理 class 字符串中的 dark-mode
html.setAttribute('class', html.getAttribute('class').replace('dark-mode', '').trim());
}
};
// 1. 初始化执行
applyTheme(mediaQuery);
// 2. 监听系统设置变化 (无需刷新页面,即刻生效)
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
// 解码 HTML 实体
const textArea = document.createElement('textarea');
textArea.innerHTML = clean;
return `$$ ${textArea.value} $$`;
});
content.innerHTML = html;
setTimeout(function() {
if (typeof Prism !== 'undefined') 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>