MxHanks' Blog

奔赴山海,保持热爱

0%

Hexo 博客折腾全记录

回顾这些年折腾 Hexo 博客的经历,从初建到复活再到各种功能配置,踩了不少坑也积累了一些经验。这篇文章把整个过程串联起来,既是记录也是分享。

建站之初

从 2021 年 10 月中旬开始,陆陆续续搭建这个自己的网站。由于不会用 GitHub,也不了解 Hexo、Node.js 这一类东西,所以花的时间比大部分人长。

建站路上遇到最坑的点就是下载的 NexT 主题有问题——需要修改主题目录下的 _config.yml 文件,否则会多一个 %20,删去 || 之间的空格即可。后来才发现原来我用的这个 NexT 已经停止维护了,于是换成了最新的 NexT。

反反复复换了很多次主题,但都不尽如人意,所以最后还是用了 NexT。


第一次复活

2023 年 2 月,寒假结束前,我复活了博客。

这个寒假过得有些混乱,但也有些收获。从家里急事回老家开始,在车上看完了《四月是你的谎言》——本来就弹钢琴的我被触动,买了一台雅马哈 PSR-e373 电子琴。发现电子琴可以作为 MIDI 输入器后,还想写一个 Minecraft mod 做实时 MIDI 转换,可惜老电脑带不动。

后来兴趣又回到了编程上,学习了 Python 的 OpenCV 和 PyQt 写了学校科技创新比赛的作品,又学了一点 Lua 语法准备做饥荒 mod。

一个寒假过去,学习没怎么搞,博客倒是又捡起来了。


第二次复活:全面功能配置

2024 年 2 月,再次清空电脑后,一切又重新开始。惊喜地发现上次复活博客也正好是 2 月 2 日——整整一年前的同一天。

这次趁着重新搭建,把博客的各种功能系统地配置了一遍。

LaTeX 支持

博客中经常需要数学公式(尤其是机器学习相关的笔记),LaTeX 支持是刚需。

方案一:hexo-renderer-kramed(适用于 matery 主题)

Hexo 默认的 hexo-renderer-marked 渲染引擎会将 LaTeX 中的下划线 _ 解析为 HTML 的 <i> 标签。hexo-renderer-kramed 修复了这个问题:

npm uninstall hexo-renderer-marked --save
npm install hexo-renderer-kramed --save

还需要修改 node_modules/kramed/lib/rules/inline.js 解决语义冲突:

// 第 11 行 escape 变量
escape: /^\\([`*\[\]()#$+\-.!_>])/,  // 去掉对 {} 的转义

// 第 20 行 em 变量
em: /^\*((?:\*\*|[\s\S])+?)\*(?!\*)/,  // 禁用 _ 作为斜体标记

方案二:hexo-renderer-markdown-it-plus(推荐)

npm uninstall hexo-renderer-marked --save
npm install hexo-renderer-markdown-it-plus

然后在主题 _config.yml 中启用 KaTeX:

math:
  ...
  katex:
    enable: true
    copy_tex: false

踩坑记录:渲染器冲突

配置完成后发现 LaTeX 还是不生效,排查发现是因为同时安装了三个 Markdown 渲染器,它们互相冲突:

插件 支持 $...$ 最终状态
hexo-renderer-marked ❌ 卸载
hexo-renderer-kramed ✅,但修改 ❌ 卸载
hexo-renderer-markdown-it-plus ✅ 内置 KaTeX ✅ 保留

Hexo 7 中,多个渲染器注册同一扩展名(.md)时,最后注册的胜出。按加载顺序,hexo-renderer-marked 总是覆盖前面的,而它根本不处理 $...$ 数学公式

修复方法:

npm uninstall hexo-renderer-marked hexo-renderer-kramed --save

同时在站点 _config.yml 中配置 markdown_it_plus 插件并显式启用 KaTeX:

markdown_it_plus:
  highlight: true
  html: true
  plugins:
    - plugin:
        name: '@iktakahiro/markdown-it-katex'
        enable: true
        options:
          throwOnError: false

另外,NexT 主题默认 math.per_page: true,只有 front-matter 中声明了 katex: true 的文章才会加载 KaTeX 的 CSS/JS。所以每篇用到数学公式的文章都需要加上:

---
katex: true
---

做完这三步,$E=mc^2$$$\frac{1}{x^2-1}$$ 就能正常渲染了。

需要注意的是,KaTeX 不支持 \begin{equation} 环境,如果文章中用到了需要改写成 \begin{aligned} 等 KaTeX 支持的写法,或者换用 MathJax 引擎。

SSH Deploy Key 配置

GitHub 在国内连接不稳定,终端用 git 命令常失败,所以配置 SSH 密钥来提升部署稳定性。

生成 SSH 密钥对

ssh-keygen -t rsa -N '' -f /home/${USER}/.ssh/id_rsa_github_blog -q

.pub 结尾的是公钥。

添加 Deploy Key

进入 GitHub 仓库设置页,填写标题,粘贴公钥内容,勾选 “Allow write access”,点击 Add key。

配置 SSH Config

Linux~/.ssh/config):

Host github.com
    Hostname ssh.github.com
    IdentityFile ~/.ssh/id_rsa_github_blog

WindowsC:\Users\Administrator\.ssh\config):

配置方法大体相同,如果遇到端口 22 被屏蔽:

Host github.com
    Hostname ssh.github.com
    IdentityFile ~/.ssh/id_rsa_github_blog
    Port 443

出现以下提示时键入 yes 即可:

The authenticity of host '[ssh.github.com]:443' can't be established.
ED25519 key fingerprint is SHA256:...
Are you sure you want to continue connecting (yes/no/[fingerprint])?

LeanCloud 评论与阅读量

评论功能(Valine)

LeanCloud 仪表盘的应用凭证中找到 AppID 和 AppKey,配置 NexT 主题的 _config.yml

# Valine
valine:
  enable: true
  appid: [appid]
  appkey: [appkey]
  notify: false
  verify: false
  placeholder: Just go go
  avatar: mm
  guest_info: nick,mail,link
  pageSize: 10
  language:
  visitor: false
  comment_count: true
  recordIP: true

阅读量功能

在 LeanCloud 中新建应用,数据存储 → 结构化数据 → 新建 Class,命名为 Counter,选择无限制。然后在主题 _config.yml 中配置:

leancloud_visitors:
  enable: true
  app_id: <your app id>
  app_key: <your app key>
  server_url:
  security: false

Gitee / GitHub 镜像同步

虽然 Gitee 的实名认证没通过,但可以设置为从 GitHub 自动同步。

在 Gitee 仓库 → 管理 → 仓库镜像管理 → 添加镜像,设为 Pull 方向,选择镜像仓库和 GitHub 私人令牌。

申请 GitHub Token:用户头像 → Settings → Developer setting → Personal access tokens → Generate new token,勾选 repoadmin:repo_hook 字段。

详细教程参考 Gitee 官方文档

SEO 优化

生成 Sitemap

npm install hexo-generator-sitemap --save
npm install hexo-generator-baidu-sitemap --save

_config.yml 中配置:

sitemap:
  path: sitemap.xml
baidusitemap:
  path: baidusitemap.xml

推送到必应

进入 Bing Webmasters 进行配置。

文件目录管理

随着文章增多以及 Obsidian 记录习惯的养成,决定在 _posts 目录下按类别建立文件夹,每篇文章单独建文件夹,图片和文章放在同一级。

移动后发现 {% post_link %} 标签会报错,改用替代方案后解决。之前配置的拼音地址在分类目录上起了作用——提前铺路总是好事。

适配 Obsidian 双链引用

使用 hexo-backlink 插件将 Obsidian 的 [[WikiLink]] 转换为 Hexo 站内文章链接:

npm install hexo-backlink

_config.yml 中配置:backlink: true

Obsidian 中的设置:设置 → 文件和链接 → 新链接格式选择"文件的相对路径",并开启"使用 [[维基链接]]"。

字数统计

使用 hexo-word-counter 统计文章字数及阅读时间:

npm install hexo-word-counter
hexo clean

在站点 _config.yml 中添加:

symbols_count_time:
  symbols: true
  time: true
  total_symbols: true
  total_time: true
  exclude_codeblock: false
  awl: 4
  wpm: 275
  suffix: "mins."

在 NexT 主题 _config.yml 中设置 item_text_total: true

参数说明:中文为主的博客推荐设置 awl: 2wpm: 300


Hexo 默认生成的文章地址是类似 YEAR/MONTH/DAY/TITLE 的长路径,既不利于 SEO 也不利于链接持久化。

npm install hexo-permalink-pinyin --save
permalink_pinyin:
  enable: true
  separator: "-"

但这个方法仍然依赖文章标题,改了标题链接也会变。

方案二:hexo-abbrlink(短链接,推荐)

hexo-abbrlink 基于文章标题生成固定短链接(CRC16 或 CRC32),文章内容不变链接就不变,不依赖分类和标题。

npm install hexo-abbrlink --save

_config.yml 中配置:

# abbrlink config
abbrlink:
  alg: crc32      # crc16(default) or crc32
  rep: hex        # dec(default) or hex
  force: false    # false(default), 设为 true 会重新生成所有 abbrlink

# 关键:需要在 permalink 中使用 :abbrlink
permalink: posts/:abbrlink/

踩坑记录

之前安装了这个插件但一直没生效,排查后发现原因很简单:permalink 没有使用 :abbrlink

插件在 before_post_render 阶段工作,它会读取(或生成)文章的 abbrlink 值并注入到数据中。但 Hexo 最终生成链接时,只用 permalink 模式中声明的变量。如果 permalink 里只有 :category:title,那 :abbrlink 再好看也不会出现在 URL 里。

所以配置 hexo-abbrlink 的关键就两步:

  1. 安装插件 + 写好 abbrlink 配置段
  2. 修改 permalinkposts/:abbrlink/(或其他含 :abbrlink 的模式)

这样生成的链接就是稳定的短链接了:

https://mxhanks.github.io/posts/48721/    # 而不是 /技术踩坑/hexopostassetfix/
https://mxhanks.github.io/posts/21936/    # 而不是 /软件工程/javabase/

旧链接兼容

开启 abbrlink 后,之前以旧格式发布的所有页面链接都会变化。被搜索引擎收录或外部引用的旧链接会 404。建议:

  • 更新 sitemap 让搜索引擎重新抓取
  • 如果使用 GitHub Pages 或 Nginx,可配置 301 重定向将旧路径永久指向新路径

post_asset_folder 图片路径修复

问题背景

我的博客文章组织和大多数人不太一样——习惯用 Obsidian 写文章,并且让图片和 markdown 文件保持在同一级目录。

目录结构类似:

source/_posts/
├── 人工智能/
│   ├── Regression/
│   │   ├── Regression.md      ← 文章
│   │   ├── output_6_1.png     ← 图片(与 md 同级)
│   │   └── output_15_0.png
│   └── ...

在 Hexo 开启 post_asset_folder: true 的情况下,图片却一直无法正常显示——<img>src 指向了根目录 /output_6_1.png,自然是 404。

原因分析

_config.yml 启用以下配置时:

post_asset_folder: true
marked:
  prependRoot: true
  postAsset: true

Hexo 会为每篇文章创建一个同名子文件夹作为资源目录。例如文章 my-post.md 的预期资源目录是 my-post/

但我的文章本身就在文件夹中,且文件夹名和文章名相同(Regression/Regression.md)。Hexo 计算的 asset_dir 变成了:

source/_posts/人工智能/Regression/Regression/

注意出现了两层 Regression/——第一层是文章所在的目录,第二层是 Hexo 自动追加的同名子文件夹。而我的图片放在第一层(与 Regression.md 同级),不在这个二层子文件夹里。

所以在构建流程中:

  1. Processing 阶段scanAssetDir 扫描 .../Regression/Regression/ → 目录不存在 → 没注册任何 PostAsset
  2. hexo-renderer-marked 渲染postAsset: true 查找 PostAsset.findById("...output_6_1.png") → 没找到 → 回退到 url_for("output_6_1.png") → 输出 <img src="/posts/688035c/output_6_1.png" > → 404

解决方案

核心思路是在渲染之前纠正图片 URL,在生成阶段把图片复制到正确位置。用了一个 Hexo 脚本(scripts/fix_asset_dir.js)实现两个过滤器:

Phase 1:注册 PostAsset(before_generate,优先级 1)

render_post 之前运行,扫描符合"同名目录"模式的文章目录,为同目录下的图片手动注册 PostAsset,让资源生成器把图片复制到 public 目录中。

Phase 2:修复 HTML 中的 <img> 路径(after_post_render

文章渲染完成后,将 <img src="/posts/688035c/output_6_1.png" > 替换为正确的绝对路径。

最终效果

项目 修复前 修复后
<img src> /output_6_1.png /posts/28955/output_6_1.png
图片文件在 public/ 不存在 ❌ 存在 ✅

多篇受影响的文章(Regression、Classification、Regularization、STM32 等)均自动修复,无需修改 markdown 源文件。

为什么不改源文件?

Obsidian 和 Hexo 共享同一套源文件。Obsidian 中图片就是和 md 同级的相对路径,改了 Hexo 的构建逻辑去适配它,比反过来改 Obsidian 配置更可持续。


自建服务器部署

博客一直部署在 GitHub Pages 上,国内偶有不稳定。手头有一台云服务器(CentOS + 宝塔面板),就把博客也同步部署上去,实现双线访问。

需求

  • 保留 GitHub Pages 部署不变
  • 增加自建服务器同步
  • hexo d 一条命令搞定两边
  • 不需要手动上传文件

方案对比

方案 原理 Windows 兼容性
hexo-deployer-rsync 本地 rsync → 远程 rsync over SSH ❌ Windows 无 rsync
hexo-deployer-sftp Node.js ssh2 库直连 SFTP ⚠️ 密钥格式问题
自定义 tar+ssh tar 打包 → SSH pipe → 远端解压

前两个方案在 Windows 上都遇到了问题,最终选择了第三种。

SSH 密钥配置

ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa_blog_server -N "" -C "blog-server"
ssh-copy-id -i ~/.ssh/id_rsa_blog_server root@47.115.49.75

配置 ~/.ssh/config

Host 47.115.49.75
    Hostname 47.115.49.75
    IdentityFile ~/.ssh/id_rsa_blog_server
    User root

Nginx 配置

server {
    listen 80;
    server_name _;
    root /www/wwwroot/default;
    index index.html;
    ...
}

核心:tar + SSH 管道

cd public && tar cf - . | ssh root@47.115.49.75 "tar xf - -C /www/wwwroot/default"

原理:tar cf - . 把当前目录打包输出到 stdout,通过 SSH 管道发送到远端解压。

优点:不需要 rsync、tar 和 ssh 在 Git Bash 中都是原生可用、一条管道效率高。

踩坑:Hexo 7 的事件 vs 过滤器

第一次尝试用 hexo.extend.filter.register('after_deploy', ...) 来挂载服务器部署,但脚本加载了却从不执行。查了 Hexo 7 源码才发现问题——部署完成时触发的是事件而不是过滤器

// node_modules/hexo/dist/plugins/console/deploy.js
this.emit('deployAfter');  // ← 触发的是事件

所以应该用:

// ❌ 没用——Hexo 没有 'after_deploy' 过滤器
hexo.extend.filter.register('after_deploy', function() { ... });

// ✅ 正确——监听 deployAfter 事件
hexo.on('deployAfter', function() { ... });

最终脚本

完整脚本见 scripts/deploy-to-server.js,Hexo 会自动加载 scripts/ 目录下的 .js 文件,无需额外配置。部署时执行顺序:

  1. hexo-deployer-git 推送到 GitHub Pages
  2. Hexo 触发 deployAfter 事件
  3. 脚本执行 tar+ssh 同步到私有服务器

密钥格式教训

Windows 上用 ssh-keygen 默认生成 OpenSSH 格式,但很多 Node.js 的 SSH 库(ssh2、node-ssh 等)只支持 PEM 格式。如果需要转换:

ssh-keygen -p -m PEM -f ~/.ssh/id_rsa_xxx -P "" -N ""

注意转换后公钥不变,不需要重新添加到服务器。

最终效果

项目 说明
GitHub Pages https://mxhanks.github.io
自建服务器 http://47.115.49.75
部署命令 hexo g && hexo d 两边同步

LeanCloud 替代:自建阅读量计数 API

背景

博客一直用 LeanCloud 做文章阅读量统计,NexT 主题内置了 leancloud_visitors 支持,配合前端的 lean-analytics.swig 模板工作得很好。

但 LeanCloud 宣布将在 2027 年 1 月 停止服务,需要找一个替代方案。刚好手头有自建服务器,决定写一个兼容 LeanCloud API 的计数服务。

方案选型

NexT 主题其实内置了三个统计方案:

方案 维护成本 可控性 备注
LeanCloud 即将关停
不蒜子 (Busuanzi) 第三方服务,历史数据无法迁移
Firebase Firestore ⚠️ Google 服务,国内访问慢
自建 API(最终选择) 已有服务器,数据完全可控

自建服务架构

浏览器 (fetch) → Nginx (/counter-api/) → Node.js (Express) → SQLite (counter.db)

NexT 主题的前端 JS(lean-analytics.swig)会调用三个接口:

操作 HTTP 请求
查询某篇文章的计数 GET /1.1/classes/Counter?where={"url":"..."}
首次访问创建记录 POST /1.1/classes/Counter
递增计数 PUT /1.1/classes/Counter/:id {"time":{"__op":"Increment","amount":1}}

用 Express + better-sqlite3 写了个约 100 行的服务,完整模拟了这三个接口。前端代码不需要改一行——只要在主题配置中把 server_url 指向自建服务即可。

数据迁移

LeanCloud 导出的 JSON 中有 44 条计数记录,但因为改过文章链接(从日期路径 /2024/02/12/title/ 和拼音分类路径 /人工智能/classification/ 迁移到 /posts/:abbrlink/),大部分旧 URL 已经失效。

写了一个迁移脚本做三件事:

  1. 删除 time ≤ 1 的记录(只有自己点过一次的无效数据)
  2. 通过 URL 中的拼音关键词匹配现有文章(如 regularzation/posts/9044/
  3. 合并同篇文章的多个旧 URL 计数(同一篇文章可能有过日期路径和拼音路径两条记录)

匹配结果示例:

302  /posts/57901/  ← Windows mklink 使用
 49  /posts/2931/   ← Luogu P5707 题解
 28  /posts/48643/  ← CSP-J 游记
 41  /posts/30549/  ← Python 文件夹映射
   ...

16 篇文章,共 609 次访问记录全部保留。

部署

服务部署在 CentOS Stream 10 上,用 systemd 管理进程:

# 安装 Node.js
dnf install -y nodejs

# 上传服务代码
scp -r server/ root@server:/opt/blog-counter
cd /opt/blog-counter && npm install --production

# systemd 服务:开机自启 + 崩溃重启
systemctl enable blog-counter
systemctl start blog-counter

Nginx 反代配置:

location /counter-api/ {
    proxy_pass http://127.0.0.1:3000/;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
}

主题配置只需改 server_urlthemes/next/_config.yml):

leancloud_visitors:
  enable: true
  server_url: "http://47.115.49.75/counter-api"
  app_id: ""
  app_key: ""
  security: false

踩坑记录

迁移脚本的 Bug:第一次跑迁移时,匹配到的旧记录虽然插入了新 URL,但旧记录本身没有删除。结果数据库里同时存在新旧两条记录,计数翻倍。排查后发现是 toDelete 数组漏加了匹配记录的 rowid,补上后重新跑就正常了。

SSH 连接偶发退出码 255:连续执行多条 SSH 命令时,偶尔会出现 Exit code 255。原因可能是服务器资源紧张或 SSH 会话被复用。解决方法:加 sleep 间隔或将多个命令合并到一次 SSH 连接中执行。

NexT 主题的 hostname 校验导致计数不显示:部署完成后发现页面阅读数始终显示为 0,API 返回数据却是正常的。排查发现 NexT 的 lean-analytics.swig 模板中有这么一行:

if (CONFIG.hostname !== location.hostname) return;

网站 _config.yml 中配置的 url: http://mxhanks.github.io/ 决定了 CONFIG.hostname 的值。但自建服务器部署在 47.115.49.75 上,location.hostname 是 IP 地址,两者不相等,计数脚本直接 return 了。

修复方法很简单——当使用自定义 server_url(自建 API)时,跳过这个 hostname 校验:

if (!server_url && CONFIG.hostname !== location.hostname) return;

这样既保留了原版在 LeanCloud 下的保护行为(防止开发环境污染计数),又让自建 API 场景下能够正常工作。

HTTPS 问题

计数器 API 目前通过 HTTP 提供服务。博客在 GitHub Pages(HTTPS)上访问时,浏览器会因混合内容策略阻止 HTTP 请求,所以计数只在自建服务器版本上生效。

解决方案是申请一个域名配 Let’s Encrypt 证书,备案完成后将 stats.你的域名.com 解析到服务器,即可同时服务两个站点。

完整代码

服务端代码不到 100 行,放在博客项目的 server/ 目录下:

server/
├── package.json
├── index.js        # 主服务(Express + SQLite)
├── import.js       # LeanCloud 数据导入
├── migrate.js      # 旧 URL 迁移匹配
├── counter.db      # SQLite 数据库
└── blog-counter.service  # systemd 单元文件

PageSpeed 优化:hexo-optimize

背景

博客一直缺少 CSS/JS/HTML 的自动化压缩优化,导致页面加载速度和 Google PageSpeed Insights 得分不太理想。Google 的 Lighthouse 审计跑下来,移动端性能分明显偏低,主要扣分项集中在未压缩的 CSS/JS 资源阻塞渲染的 CSS上。

常见的优化手段无非几样:压缩资源、内联关键 CSS、异步加载非关键样式、给静态资源加版本号。这些操作完全可以自动化,没必要每次构建手动处理。

hexo-optimize 是 NexT 官方团队维护的 Hexo 插件,使用 Rust 编写(minify-html 是 Rust 编译的原生模块),专门解决这些问题。

安装

npm install hexo-optimize --save

当前版本 v3.5.0

配置

在站点 _config.yml 中添加 filter_optimize 配置块:

# hexo-optimize
filter_optimize:
  enable: true
  # 静态资源版本号(添加内容哈希,解决缓存问题)
  versioning: true
  css:
    # 压缩所有 CSS
    minify: true
    excludes:
    # 对 Font Awesome 和 Google Fonts 使用 preload 动态加载
    delivery:
      - '@fortawesome/fontawesome-free'
      - 'fonts.googleapis.com'
    # 将关键 CSS 内联到 HTML 中
    inlines:
      - css/main.css
  js:
    # 压缩所有 JS
    minify: true
    excludes:
    # 移除 JS 中的注释
    remove_comments: false
  html:
    # 压缩所有 HTML
    minify: true
    excludes:
  # 插件执行优先级(值越小越先执行,Hexo 默认 10)
  priority: 12

参数说明

参数 作用 建议
versioning 给静态资源文件名追加内容哈希,浏览器缓存过期后自动加载新版本 推荐开启
css.minify 压缩 CSS,删除多余空格和注释 开启
css.delivery 指定哪些 CSS 用 preload 动态加载,不阻塞页面渲染 Font Awesome、Google Fonts 等第三方字体库
css.inlines 指定哪些 CSS 直接内联到 HTML <head> css/main.css(主题核心样式)
js.minify 压缩 JS 文件 开启
html.minify 压缩 HTML,去掉多余空白 开启
priority 插件执行顺序 12(在 Hexo 默认过滤器之后执行)

开发时禁用

构建时压缩会拖慢速度,开发阶段可以通过环境变量临时禁用:

# Linux / macOS
export NODE_ENV=development

# Windows (cmd)
set NODE_ENV=development

# Windows (PowerShell)
$env:NODE_ENV="development"

设置后 hexo generate 会跳过优化流程,预览时不会卡在构建上。

注意事项

  1. 内联 CSS 路径css.inlines 配置的是生成后的绝对路径。NexT 主题的 CSS 在 css/main.css,如果用了其他主题或自定义 CSS,需要对应调整。
  2. CSS delivery 与 inlines 不冲突delivery 是将指定 CSS 改为动态加载(不阻塞渲染),inlines 是将指定 CSS 嵌入 HTML(减少请求)。一般来说主题核心样式内联,第三方字体库动态加载,配合使用效果最好。
  3. priority 的意义:Hexo 的过滤器系统按优先级顺序执行,默认值是 10。设成 12 表示在大部分 Hexo 内置过滤器之后执行优化,确保内容是最终状态再压缩。如果和其他优化插件搭配,需要注意执行顺序。

代码高亮升级:从 highlight.js 切换到 PrismJS

背景

博客的代码块一直"高亮就和没有一样"——关键字和普通文字的对比度极低,颜色几乎看不出来。最初使用的是 Hexo 内置的 highlight.js(syntax_highlighter: highlight.js),配合 hljs: false 和 NexT 主题默认的 highlight_theme: normal(浅灰底 + 深灰文字配色)。

问题出在三个地方叠加:

问题 原因
高亮引擎冲突 syntax_highlighter: highlight.jshexo-renderer-markdown-it-plushighlight: true 同时处理代码块,互相覆盖
hljs 标记关闭 highlight.hljs: false 使用了 Hexo 自己的老式 <figure><table> 包裹结构,而不是标准 CSS 类名
主题配色太淡 highlight_theme: normal 是浅色主题,对比度极低

解决方案

换成 PrismJS,Hexo 7 内置支持,无需额外安装插件。

第一步:修改站点 _config.yml

# 关闭 highlight.js,切换到 PrismJS
syntax_highlighter: prismjs
prismjs:
  preprocess: true
  line_number: true
  tab_replace: ''
highlight:
  enable: false

# 关闭 markdown-it-plus 的高亮,避免双重重叠
markdown_it_plus:
  highlight: false

第二步:修改 NexT 主题配置(可选)

themes/next/_config.ymlcodeblock.highlight_theme 控制的是旧 highlight.js 的配色,对 PrismJS 无影响。可以保持默认或随意设置:

codeblock:
  highlight_theme: night eighties  # 仅对 highlight.js 生效,PrismJS 不受此影响
  copy_button:
    enable: true                   # 开启代码块复制按钮
    show_result: false
    style: flat

第三步:本地托管 PrismJS 资源

由于博客面向国内读者,CDN(如 cdn.jsdelivr.net)在国内经常不稳定甚至被屏蔽。直接从 node_modules 中复制 PrismJS 文件到主题目录下本地托管:

# 创建主题 lib 目录
mkdir -p themes/next/source/lib/prism/

# 复制 CSS 主题(可替换为其他主题:prism.min.css / prism-coy.min.css / prism-okaidia.min.css 等)
cp node_modules/prismjs/themes/prism-coy.min.css themes/next/source/lib/prism/

# 复制 core(需要加 data-manual,见下文说明)
cp node_modules/prismjs/components/prism-core.min.js themes/next/source/lib/prism/

# 复制 line-numbers 的 CSS(行号由 Hexo 构建时生成,只需 CSS 即可)
cp node_modules/prismjs/plugins/line-numbers/prism-line-numbers.min.css themes/next/source/lib/prism/

⚠️ 关键踩坑prism-core.min.js 加载后会自动执行 Prism.highlightAll(),将构建时已着色的 token 全部覆盖为纯文本,导致代码颜色消失。解决方案是在 script 标签上加 data-manual 属性阻止自动执行。详见下一步。

第四步:通过主题自定义文件加载

启用 NexT 的 custom_file_path,创建 source/_data/head.swigsource/_data/body-end.swig

{# head.swig - 插入 <head> #}
<link rel="stylesheet" href="/lib/prism/prism-coy.min.css">
<link rel="stylesheet" href="/lib/prism/prism-line-numbers.min.css">
<style>
  pre[class*="language-"] {
    font-size: 0.85em;
    border-radius: 4px;
  }
  code[class*="language-"] {
    text-shadow: none;
  }
</style>
{# body-end.swig - 插入 </body> 前 #}
<!-- data-manual 阻止自动 highlightAll,避免覆盖构建时生成的 token -->
<script src="/lib/prism/prism-core.min.js" data-manual></script>

<!-- 复制按钮手动实现(NexT 自带的只认 figure.highlight,不兼容 PrismJS) -->
<script>
  (function() {
    document.querySelectorAll('pre[class*="language-"]').forEach(function(pre) {
      if (pre.parentNode && pre.parentNode.classList.contains('highlight-container')) return;
      var container = document.createElement('div');
      container.className = 'highlight-container';
      pre.parentNode.insertBefore(container, pre);
      container.appendChild(pre);
      var btn = document.createElement('div');
      btn.className = 'copy-btn';
      btn.innerHTML = '<i class="fa fa-clipboard fa-fw"></i>';
      container.appendChild(btn);
      btn.addEventListener('click', function() {
        var code = pre.querySelector('code');
        if (!code) return;
        navigator.clipboard.writeText(code.textContent).then(function() {
          btn.innerHTML = '<i class="fa fa-check fa-fw"></i>';
          setTimeout(function() { btn.innerHTML = '<i class="fa fa-clipboard fa-fw"></i>'; }, 2000);
        });
      });
    });
  })();
</script>

为什么不加载 prism-autoloader.min.jsprism-line-numbers.min.js?因为 preprocess: true 模式下,Hexo 在构建时已完成所有着色和行号插入,客户端无需重复处理。加载 prism-core 只是为了给 NexT 的插件体系提供 Prism 命名空间。

最终效果

项目 改造前 改造后
高亮引擎 highlight.js + markdown-it-plus 冲突 PrismJS 构建时预处理(preprocess: true
主题配色 浅灰底 #f7f7f7 + 深灰字 #4d4d4c 可选多种主题(当前为 Coy:白底 #fdfdfd
关键字 几乎看不见的暗色 主题配色,清晰可辨
字符串 不可区分的灰色 主题配色,鲜明区分
行号 有但样式平淡 构建时内联,带分隔线
复制按钮 代码块右上角一键复制
资源加载 CDN(国内可能被墙) 本地静态文件,秒加载
防客户端覆盖 ❌ 未处理 data-manual 阻止 PrismJS 自动重绘

可自由切换 PrismJS 主题:prism.css(默认)、prism-coy.css(当前)、prism-dark.cssprism-funky.cssprism-okaidia.cssprism-solarizedlight.cssprism-twilight.css。只需替换 head.swig 中的 CSS 链接即可。


换电脑:源码与成品分离 + Git 版本管理

2026 年 8 月,大学开学,这台电脑要留在家里。博客却还想继续写——于是第一次认真思考"如何跨机器管理这个项目"。最先想到的就是丢到 GitHub 上,可我的博客本身就在 GitHub Pages 上,一个不小心就会把自己网站搞坏。

核心思路:两个仓库

mxhanks.github.io 这个仓库现在只存放构建后的成品hexo g 生成的 public/ 内容),由 hexo d 推到 master 分支,GitHub Pages 从它读网页。

如果把源码(markdown、_config.yml、主题……)直接推进去,Pages 会开始展示 .md 源码,站点直接报废。所以正确做法是源码与成品分离

仓库 内容 角色
mxhanks.github.io 只有 public/ 网站本体,由 hexo d 写入,不变
mxhanks/blog-source(新建) 整个 Hexo 工程 源码,换电脑后 clone 下来就是完整环境

两个仓库互不干扰,部署逻辑一行都不用改。

迁移清单

换电脑后在新机器上要准备的东西:

  1. git + Node.js(版本以 package.json 里的 hexo 依赖为准)
  2. SSH 私钥~/.ssh/id_rsa_github_blog(推 GitHub Pages)和 ~/.ssh/id_rsa_blog_server(连自建服务器),连同 ~/.ssh/config 一起备份到 U 盘或密码管理器——这是部署的两把钥匙
  3. 源码:clone 源码仓库 → npm install → 照常 npm run deploy:all

.gitignore 早就配好了(node_modules/public/.deploy_git/db.json 都不进仓库),源码仓库会很干净;package-lock.json 记得提交,保证换机后 npm ci 装出一模一样的环境。

themes/next 自带 .git 的坑

项目里 themes/next 是从 GitHub clone 下来的,自带一个 .git 目录。如果不处理,git init 后主题会被当成"子仓库"(gitlink),文件不会被主仓库追踪——提交上去的是一个空引用,换电脑 clone 下来主题是空的,站点直接坏。

解决办法二选一:

  • 简单法(推荐):删掉 themes/next/.git,让主题文件直接跟主仓库走。主题本来就不怎么改它,它自己的历史没有保留价值。
  • 正式法:git submodule add 管起来,但换机后每次都要 --recurse-submodules,麻烦且没必要。

GitHub Actions 一键自动部署

换电脑后,如果还要在新机器上装 Node、配 SSH 密钥才能发文章,多少有点麻烦。干脆把构建和部署都搬进云端——以后只写文章、git push,剩下全自动

2026-09 起已改为仅手动触发workflow_dispatch):push 不再自动部署,需要时去 Actions 页面点 Run workflow。原因见文末「换机实跑」一节。

workflow 文件

在源码仓库下新建 .github/workflows/deploy.yml

name: Deploy

on:
  push:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: deploy
  cancel-in-progress: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - name: 安装依赖并构建
        run: |
          npm ci
          npx hexo generate
      - name: 部署到 GitHub Pages
        uses: peaceiris/actions-gh-pages@v4
        with:
          deploy_key: ${{ secrets.PAGES_DEPLOY_KEY }}
          external_repository: mxhanks/mxhanks.github.io
          publish_branch: master
          publish_dir: ./public
          force_orphan: true
      - name: 部署到自建服务器
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SERVER_SSH_KEY }}" | base64 -d > ~/.ssh/id_rsa_server
          chmod 600 ~/.ssh/id_rsa_server
          ssh -i ~/.ssh/id_rsa_server -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
            root@47.115.49.75 "rm -rf /www/wwwroot/default/* && mkdir -p /www/wwwroot/default"
          tar -C public -cf - . | ssh -i ~/.ssh/id_rsa_server -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
            root@47.115.49.75 "tar -C /www/wwwroot/default -xf -"

流程就是:push 到 main → 云端 npm ci + hexo generate → ① 把 public/ 推到 mxhanks.github.iomaster;② tar+ssh 同步到自建服务器(其实就是把 deploy-server.sh 的逻辑搬进 CI,连 dotfile 一起打包,行为和本地一致)。

两个 Secrets

在源码仓库 Settings → Secrets and variables → Actions 里配置:

Secret 内容 获取方式
PAGES_DEPLOY_KEY 一把只推 mxhanks.github.io 的 SSH 私钥 ssh-keygen -t ed25519 -f ~/.ssh/pages_deploy_key -N "",公钥加到 mxhanks.github.io 仓库 Settings → Deploy keys(勾 Allow write access),私钥内容存进 secret
SERVER_SSH_KEY ~/.ssh/id_rsa_blog_server 的 Base64 base64 -w0 ~/.ssh/id_rsa_blog_server(私钥含换行,直接存会弄坏 YAML,所以编码后再存、workflow 里解码)

为什么 Pages 部署不用 GitHub 内置的 GITHUB_TOKEN?它只能推同一个仓库,而成品在 mxhanks.github.io 是另一个仓库,所以要一把专门授权给那个仓库的 deploy key——比给整个账号权限的 PAT 更安全。

踩坑:updated_option: mtime 在 CI 下的副作用

站点 _config.yml 里配的是 updated_option: 'mtime'(文章"更新日期"取文件修改时间)。本地编辑时这个值很合理,但云端 checkout 时所有文件的 mtime 都等于拉取时刻,导致每次 push 所有文章都会刷新"更新日期"。介意的话改成 updated_option: 'date'(取正文 frontmatter 里的 date,不再被刷新)。


统一图片命名:Obsidian 粘贴图片重命名

用 Obsidian 写文章时直接粘贴截图,会自动命名为 Pasted image 20260803170445.png 这种带空格的长名字。带空格的文件名在 Markdown 引用、URL 和部分环境里都很容易出问题。

约定

博客里的图片统一叫 image-xximage-1.pngimage-2.png……),Obsidian 粘贴产生的图片在发布前需要重命名并同步更新引用。

工具

scripts/convert_wikilink_images.py 一键完成三件事:

  1. Pasted image ... 图片重命名为下一个可用的 image-N
  2. 把文中 ![[旧名.png]] 双链同步改成标准 ![新名](新名)
  3. 剩余的 ![[...]] 全部转成标准语法
python scripts/convert_wikilink_images.py

这次在 STM32Learning 里跑了一次:3 张粘贴图片重命名为 image-27/28/29,文中 3 处引用自动更新,目录里不再有带空格的文件名。

只重命名文件却不更新引用,图片会 404。所以"重命名 + 引用替换"必须是一体的——这正是这个脚本存在的意义。


安全教训:泄漏的 GitHub Token

准备推仓库前扫了一遍项目,发现 _config.yml 末尾的注释里躺着一个真实的 GitHub tokenghp_ 开头)。全局搜了一遍代码,确认它已经没人用了——部署走的是 SSH,不是它。

私有仓库 ≠ 不会泄漏

当时的第一反应是"提交到私有仓库不就行了吗"。对了一半:私有仓库确实不会被公开看到。但真正的风险在于它是一条没人用的悬空凭证,而且 git 历史会永久保留已提交的内容——将来只要发生任一情况就全完了:

  • 仓库哪天改成公开,token 就跟着历史一起曝光
  • 电脑或备份落到别人手里
  • 账号被扫到旧文件泄露

教训

  1. 凭证永远不进仓库,尤其是文本注释。需要密钥的地方用 GitHub Secrets 或 SSH deploy key
  2. 一旦进过 git 历史,光删文件是删不干净的,必须吊销
  3. 处理方式:去 github.com/settings/tokens 吊销 + 删掉注释行。这次是在首次提交之前删的,所以 git 历史里干干净净

换机实跑:从源码仓库恢复双端部署

2026 年 9 月,正式在新电脑上实践当初"两个仓库"的设想:clone 源码仓库,恢复 GitHub Pages + 自建服务器双线部署。理想很丰满,现实是——新机器上什么都没有

新机器到底缺什么

按前面"迁移清单"核对,这台电脑没有 Node、没有 ~/.ssh,git 全局身份也没设。最要命的是两把部署私钥都留在旧电脑上了。结论:换机器意味着环境从零重来,两把私钥重新生成即可,别指望能无缝"搬"过去。

组件 处理方式
Node.js 见下"免安装便携版"
服务器私钥 重新 ssh-keygen -t ed25519,把公钥追加到服务器 ~/.ssh/authorized_keys(宝塔终端里执行)
GitHub 私钥 重新生成 ed25519,公钥加到 GitHub → Settings → SSH keys
git 身份 git config --global user.name/email(不设的话,hexo-deployer-git 向 Pages 仓库提交时会失败)

顺带一个认知:只要源码仓库的 Actions Secrets 还在(PAGES_DEPLOY_KEY / SERVER_SSH_KEY),push 一样能双端部署,根本不需要本地密钥。本地密钥只为"用 hexo d 在本地发"这条习惯服务——两条发布路别搞混。

Node.js 免安装便携版

Windows 的 winget 在无交互的 shell 里会卡住(要先同意源协议、还可能弹 UAC),不适合脚本化安装。干脆不用安装器——直接下官方 win-x64 zip,解压到固定目录,自己加进用户 PATH:

curl -sL -o node.zip https://nodejs.org/dist/v20.20.2/node-v20.20.2-win-x64.zip
tar -xf node.zip && mv node-v20.20.2-win-x64 /f/Workspace/node
# 加入用户 PATH(PowerShell,需重开终端生效)
[Environment]::SetEnvironmentVariable('Path',
  [Environment]::GetEnvironmentVariable('Path','User') + ';F:\Workspace\node', 'User')

版本选 Node 20 LTS,和 Actions workflow 里 setup-nodenode-version: 20 对齐——避免 hexo-renderer-markdown-it-plus 这类老插件撞上新版 Node。

借重装修掉的三个历史遗留

源码里躺着三个问题,平时没炸,这次全暴露了:

1. url 还指向 GitHub Pages

_config.ymlurl: http://mxhanks.github.io/ 是历史遗留。在自建服务器上,它会让主题脚本里 CONFIG.hostname !== location.hostname(就是阅读量计数那节踩过的坑)直接 return,计数失效。这次把 url 改成 http://47.115.49.75/ 后,服务器上 hostname 校验自然通过,canonical / sitemap 也指向了服务器。

代价是 GitHub Pages 那份的 canonical 也一起指向服务器 IP(两站共用同一份 public/)。要让 Pages 版的规范链接正确,得按部署目标拆分 url,或给 Pages / 计数器 API 上 HTTPS——留待后续。

2. scripts/ 目录里混进了 Python 文件

Hexo 会把 scripts/每个文件都当插件加载.py 也不例外。之前把纯手动工具 convert_wikilink_images.py 放在这里,结果每次跑 hexo 命令都先刷一段报错:

ERROR Script load failed: scripts\convert_wikilink_images.py
SyntaxError: Invalid or unexpected token

红字看着吓人,好在非致命(exit 0、不影响产物),但每条命令都刷屏。把它挪到 tools/ 后就清净了。教训:scripts/ 只放真正的 Hexo 脚本(注册过滤器 / 事件的那种),杂项工具放别处。

3. 脚本里写死的旧电脑路径

deploy-server.shPUBLIC_DIR="F:/Projects/Blog-Pages/public" 是旧电脑的目录名,换机必挂。改成从脚本自身定位,走到哪都成立:

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PUBLIC_DIR="${SCRIPT_DIR}/public"

发布前先备份

脚本发布是"先 rm -rf 清空线上、再上传",中途断了线上就空了。上服务器前先留个底:

ssh root@47.115.49.75 "tar czf /root/blog_backup_\$(date +%Y%m%d_%H%M%S).tgz -C /www/wwwroot/default ."

两条发布路,这次总算理清

实测两遍都通,各自的作用边界也清楚了:

发布路 触发 Pages 靠什么 服务器靠什么
本地 hexo g && hexo d hexo-deployer-git(账号 SSH key) deployAfter 事件 → scripts/deploy-to-server.js 的 tar+ssh
云端 push / 手动 Run workflow Actions + deploy key(PAGES_DEPLOY_KEY Actions 里把 deploy-server.sh 的逻辑搬进 CI(SERVER_SSH_KEY

这次把 Actions 从"push 自动触发"改成了"仅手动"

on:
  workflow_dispatch:   # 原来还有 push: { branches: [main] }

理由:既然已经习惯本地 hexo d,每次 git push 还让云端重建一遍属于重复劳动;真想走云端时,Actions 页面点一下 Run workflow 即可。想改回自动,把 on: 加回 push 就行。

⚠️ 两条路并存的血泪提醒:源码仓库的状态必须始终和线上一致。这节的改动(url、脚本路径、挪 py)如果只部署却不 push,哪天 Actions 被随便一推触发,就会用旧源码把线上重新覆盖回旧配置。所以"改完 → 部署 → push"要当成一套动作完成。


从 2021 年初建到现在,博客的形态变了很多次。从一开始连 GitHub 都不会用,到后来自己写 Hexo 脚本、配服务器,每一步踩坑都成了经验。这些折腾的价值或许不只是技术本身——它让我习惯了记录、整理和分享,而这一点大概是写博客最大的收获。

如果你也在折腾 Hexo 博客,希望这篇文章能帮你少走一些弯路。