前言

平时写博客往往要先打开电脑,创建 Markdown 文件,写完再发布。但有些内容其实不需要写成文章:几句想法、一张照片,或者一次折腾的小记录,都更适合发成“说说”。

我希望这个功能满足几个条件:

  • 手机也能发布文字和图片。
  • 自动记录发布时间。
  • 图片使用已有的外部图床。
  • 每条说说可以独立评论。
  • 尽量沿用现有 Hexo 和 GitHub Pages,不增加服务器维护工作。

最终采用的方案是:

Pages CMS 编辑内容,GitHub 保存数据并自动部署,Hexo 展示说说,图床提供图片,Twikoo 保存评论。

从 Memos 到静态发布

最初考虑过搭建 Memos。它有现成的登录、图文编辑和时间线功能,手机发布也方便,但需要额外部署后台和保存数据。

后来发现,手机编辑并不一定需要给博客增加一个运行中的后台。只要能在手机上修改仓库里的内容,再交给 GitHub Actions 自动构建,同样可以完成发布。

因此,我选择了 Pages CMS。它提供网页编辑界面,保存内容时会提交到 GitHub 仓库,适合接入现有的静态博客。

这种方式需要等待构建完成后才能看到更新,不过不需要自己维护新的服务。

实现环境与文件准备

这篇文章记录的是当前博客中的实际实现,环境为 Hexo 8.1.1、Butterfly 5.5.4 和 Node.js 22。模板使用了 Butterfly 的 url_for、safeJSON、主题颜色变量以及已有的 Fancybox 配置,不能直接当作通用 HTML 放到其他主题里运行。

下文路径都相对于博客项目根目录。安装在其他项目时,先确认依赖存在:

1
npm install moment-timezone --save

数据整理代码使用 moment-timezone 解析时区。本项目现有依赖树已经包含它;其他项目显式安装后,还需要一并提交更新的 package.json 和 package-lock.json。

站点 _config.yml 使用:

1
timezone: Asia/Shanghai

代码基于我本地的 Butterfly 目录结构编写。修改主题前建议保存一份 Git 提交,后续升级主题时也要保留这些自定义文件。

说说内容怎么保存

说说单独保存在:

1
source/_data/shuoshuo.json

每条记录包含固定标识、发布时间、正文和图片列表,结构大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
[
{
"id": "7cb0eb08-7460-4b71-a633-968163b4d3ee",
"date": "2026-09-23T18:30:00+08:00",
"content": "今天给博客加上了说说功能。",
"images": [
{
"url": "https://example.com/photo.jpg",
"caption": "记录一下"
}
]
}
]

其中:

  • id 用来长期标识这条说说。
  • date 保存发布时间及其时区。
  • content 保存正文。
  • images 保存图片直链和可选说明。

支持纯文字、纯图片和图文组合。正文按纯文本展示,保留换行,不解析 HTML 或 Markdown。

新建 source/_data/shuoshuo.json 时可以先只写一个空数组:

1
[]

前面的记录只是结构示例,https://example.com/photo.jpg 需要换成自己的图片直链。不要在已有数据文件中直接粘贴 [],否则会覆盖原来的说说。

已有记录如果没有 id,可以先生成一个 UUID,再手动补到相应记录中:

1
node -e "console.log(require('node:crypto').randomUUID())"

每条记录使用不同的 UUID,保存后不再修改它。

用 Pages CMS 在手机上编辑

仓库根目录新增了 .pages.yml,用它定义编辑表单,并指向说说的数据文件。

表单包括:

  • 发布时间:新建时默认当前时间。
  • 正文:多行文本输入。
  • 图片:可以增删的图片链接和说明列表。
  • 固定标识:自动生成 UUID,隐藏在编辑表单中。

手机登录 Pages CMS 后,选择博客源码仓库和 main 分支,就能通过表单维护说说,不需要手动编辑 JSON。

这里要选择的是保存 Hexo 源码的仓库,而不是部署生成页面的 GitHub Pages 仓库。

编辑表单的完整配置

下面是项目根目录 .pages.yml 的完整内容。如果仓库已经配置了其他编辑栏目,只合并 shuoshuo 这一项,不覆盖原来的 content 配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# 图片在图床上传;这里只保存 HTTPS 直链,不配置仓库媒体上传。
content:
- name: shuoshuo
label: 说说
type: file
path: source/_data/shuoshuo.json
format: json
list: true
fields:
- name: id
label: 说说标识
type: uuid
hidden: true
options:
editable: false
generate: false
- name: date
label: 发布时间
type: date
required: true
description: 新增时默认当前时间;修改正文时保留原时间。博客统一显示北京时间。
options:
time: true
format: "yyyy-MM-dd'T'HH:mm:ssxxx"
- name: content
label: 正文
type: text
description: 支持换行。可以只写文字,也可以留空只发图片;不解析 HTML 或 Markdown。
- name: images
label: 图片
type: object
list: true
description: 先在图床登录上传,再逐张添加 HTTPS 图片直链,不要粘贴 Markdown 或相册页面地址。
fields:
- name: url
label: 图片直链
type: string
required: true
pattern:
regex: '^https://[^\s]+$'
message: 请填写完整的 HTTPS 图片直链。
- name: caption
label: 图片说明(可选)
type: string

这里选择 type: file 和 list: true,让整个 JSON 文件成为一个可以增删条目的列表。images 又是一个对象列表,所以每条说说都能添加多张图片。

id 使用 UUID 字段,并关闭编辑和重新生成入口。时间字段使用带偏移量的格式,例如 2026-09-23T18:30:00+08:00,避免构建机器和手机时区不同造成显示偏差。

配置提交后,在 Pages CMS 登录 GitHub,授权博客源码仓库,再选择对应分支,就可以加载这份表单。字段配置参考 Pages CMS 官方文档。

图片继续使用外部图床

考虑到 GitHub 图片的访问速度,我没有启用 Pages CMS 的仓库媒体上传功能。

实际操作是:

  1. 在手机上登录图床并上传照片。
  2. 复制图片的 HTTPS 直链。
  3. 将直链填入说说的图片列表。
  4. 按需填写图片说明。

这样,仓库里只保存文字和图片地址,访客浏览时直接从图床加载图片,也不需要把图床密钥放进博客配置。

Hexo 如何生成页面

博客使用 Butterfly 主题,它本身就带有说说模板。这次保留了原模板,并为 Pages CMS 的数据新增了一套展示模板。

主要文件分工如下:

文件 作用
.pages.yml 定义手机编辑表单
source/_data/shuoshuo.json 保存说说数据
lib/shuoshuo.cjs 校验、整理数据并转换时间
scripts/shuoshuo.js 注册 Hexo 模板辅助函数
source/shuoshuo/index.md 定义说说页面
shuoshuo-public.pug 生成图文卡片
source/css/shuoshuo.css 控制布局和移动端样式

生成页面前,会检查日期、固定标识、图片地址,以及是否存在空白说说。有效数据统一转换为北京时间,并按发布时间倒序排列。

页面的展示方式也做了适配:

  • 单张图片保持原始比例。
  • 多张图片使用网格排列,手机上显示两列。
  • 点击图片可以放大查看。
  • 超过八条时,提供“加载更多”按钮。
  • 浅色和深色模式沿用主题配色。

“加载更多”是在已经生成的页面中逐批显示内容,不会再请求一个说说后台。

数据校验与排序

完整文件:lib/shuoshuo.cjs。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const moment = require('moment-timezone');

// Keep CMS dates independent of the visitor's and build machine's time zones.
module.exports = function prepareShuoshuo(data = [], timezone = 'Asia/Shanghai') {
if (!Array.isArray(data)) throw new Error('说说数据必须是数组');
const ids = new Set();
return data.map((item, index) => {
const fail = message => { throw new Error(`第 ${index + 1} 条说说:${message}`); };
if (!item || typeof item !== 'object') fail('格式错误');
if (typeof item.id !== 'string' || !/^[a-zA-Z0-9_-]{1,100}$/.test(item.id)) fail('缺少有效的固定标识,请通过 Pages CMS 新建说说');
if (ids.has(item.id)) fail('说说标识重复,请勿复制已有标识');
ids.add(item.id);
if (typeof item.date !== 'string') fail('请填写发布时间');
const date = moment.tz(item.date, moment.ISO_8601, true, timezone);
if (!date.isValid()) fail('发布时间无效');
if (item.content != null && typeof item.content !== 'string') fail('正文必须是文字');
const content = (item.content || '').trim();
if (item.images != null && !Array.isArray(item.images)) fail('图片必须是列表');
const images = (item.images || []).map(image => {
if (!image || typeof image.url !== 'string') fail('请填写图片直链');
const url = image.url.trim();
let parsed;
try { parsed = new URL(url); } catch { fail('图片直链无效'); }
if (!url.startsWith('https://') || /\s/.test(url) || parsed.protocol !== 'https:' || parsed.username || parsed.password) {
fail('图片必须使用不含账号密码的 HTTPS 直链');
}
if (image.caption != null && typeof image.caption !== 'string') fail('图片说明必须是文字');
return { url, caption: (image.caption || '').trim() };
});
if (!content && !images.length) fail('正文和图片至少填写一项');
return {
id: item.id, content, images,
timestamp: date.valueOf(),
date: date.toISOString(),
displayDate: date.format('YYYY-MM-DD HH:mm')
};
}).sort((a, b) => b.timestamp - a.timestamp);
};

这一步除了排序,还会拒绝重复标识、无效日期、空白内容和不符合要求的图片地址。图片地址校验只检查 URL 格式,不会在每次构建时下载图片,也不能保证图床外链始终可用。

date 输出为标准时间字符串,用于 HTML 的 datetime 属性;displayDate 用于页面上显示北京时间。排序使用时间戳,避免把不同格式的日期当作普通字符串比较。

注册 Hexo 辅助函数

完整文件:scripts/shuoshuo.js。

1
2
3
4
5
const prepareShuoshuo = require('../lib/shuoshuo.cjs');

hexo.extend.helper.register('publicShuoshuo', function () {
return prepareShuoshuo(this.site.data.shuoshuo, this.config.timezone);
});

Hexo 会将 source/_data/shuoshuo.json 加载为 site.data.shuoshuo。模板通过 publicShuoshuo() 获得已经整理好的数据。

创建页面并接入主题

完整文件:source/shuoshuo/index.md。

1
2
3
4
5
6
7
8
9
---
title: 说说
description: 记录日常,收藏生活里的小片刻。
type: shuoshuo
cms_shuoshuo: true
comments: true
aside: false
toc: false
---

然后找到 themes/butterfly/layout/page.pug 的 when 'shuoshuo' 分支,替换为下面这段。这是局部修改,其他页面分支保留。

1
2
3
4
5
when 'shuoshuo'
if page.cms_shuoshuo
include includes/page/shuoshuo-public.pug
else
include includes/page/shuoshuo.pug

cms_shuoshuo 用来选择新模板,原有的主题说说模板仍然保留。

图文卡片模板

完整文件:themes/butterfly/layout/includes/page/shuoshuo-public.pug。

这份模板已经包含后文要用到的评论按钮和脚本引用,首次复制时可以一起保存,待评论配置完成后统一构建。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
- const entries = publicShuoshuo()
- const useComments = page.comments !== false && theme.comments && theme.comments.use && theme.comments.use.includes('Twikoo') && theme.twikoo && theme.twikoo.envId
link(rel='stylesheet' href=url_for('/css/shuoshuo.css'))
#article-container.public-shuoshuo
p.shuoshuo-intro 记录日常,收藏生活里的小片刻。
if !entries.length
.shuoshuo-item.shuoshuo-empty
i.far.fa-comment-dots(aria-hidden='true')
p 还没有说说,下一次相遇,也许就有新的故事。
each entry, index in entries
article.shuoshuo-item(id='memo-' + entry.id)
.shuoshuo-item-header
.shuoshuo-avatar
img.no-lightbox(src=url_for(theme.avatar.img) alt='' loading='lazy')
.shuoshuo-info
.shuoshuo-author= config.author
time.shuoshuo-date(datetime=entry.date title='北京时间')= entry.displayDate
if entry.content
.shuoshuo-content.shuoshuo-text= entry.content
if entry.images.length
.shuoshuo-images(class=entry.images.length === 1 ? 'is-single' : '')
each picture in entry.images
figure.shuoshuo-photo
a(href=picture.url data-fancybox='shuoshuo-' + index target='_blank' rel='noopener noreferrer')
img(src=picture.url alt=picture.caption || '说说配图' loading='lazy' decoding='async')
if picture.caption
figcaption= picture.caption
if useComments
.shuoshuo-comment-actions
button.shuoshuo-comment-toggle(type='button' data-comment-path=url_for('/shuoshuo/') + '?memo=' + entry.id aria-expanded='false' aria-controls='comments-' + entry.id)
i.far.fa-comment(aria-hidden='true')
span 评论
.shuoshuo-comment-panel(id='comments-' + entry.id hidden)
if useComments && entries.length
script.shuoshuo-comment-config(type='application/json')!= safeJSON({ envId: theme.twikoo.envId, region: theme.twikoo.region, option: theme.twikoo.option, script: url_for(theme.asset.twikoo) })
script(src=url_for('/js/shuoshuo-comments.js') data-pjax defer)
if entries.length > 8
button.shuoshuo-more(type='button' hidden) 加载更多
p.shuoshuo-count(role='status' aria-live='polite')
script.
(() => {
const root = document.querySelector('.public-shuoshuo');
const cards = [...root.querySelectorAll('article.shuoshuo-item')];
const button = root.querySelector('.shuoshuo-more');
const count = root.querySelector('.shuoshuo-count');
let visible = 8;
const update = () => {
cards.forEach((card, index) => { card.hidden = index >= visible; });
button.hidden = visible >= cards.length;
count.textContent = `已显示 ${Math.min(visible, cards.length)} / ${cards.length} 条`;
};
button.addEventListener('click', () => {
visible += 8;
update();
});
update();
})();

正文、作者和图片说明使用 Pug 的 = 输出,保留转义,避免将输入内容当作 HTML 执行。评论配置则通过主题已有的 safeJSON 序列化后放进 JSON 脚本节点。

图片链接带有 data-fancybox 属性,交给主题现有的 Fancybox 处理;没有灯箱脚本时,链接也可以打开原图。本项目的主题配置为 lightbox: fancybox。

超过八条时,内联脚本将后面的卡片隐藏,再通过按钮逐批显示。所有记录仍在 HTML 中,这不是服务端分页;没有 JavaScript 时也能阅读全部记录。

页面样式

完整文件:source/css/shuoshuo.css。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
.type-shuoshuo .layout.hide-aside {
max-width: 860px;
}

#article-container.public-shuoshuo .shuoshuo-intro {
margin: 0 0 24px;
color: var(--font-color);
text-align: center;
}

.public-shuoshuo .shuoshuo-text {
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: 16px;
line-height: 1.9;
}

.public-shuoshuo .shuoshuo-avatar {
flex-shrink: 0;
}

.public-shuoshuo .shuoshuo-images {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-top: 14px;
}

.public-shuoshuo .shuoshuo-images.is-single {
grid-template-columns: minmax(0, 1fr);
}

#article-container.public-shuoshuo .shuoshuo-photo {
min-width: 0;
margin: 0;
}

.public-shuoshuo .shuoshuo-photo > a {
display: block;
overflow: hidden;
border-radius: 10px;
}

#article-container.public-shuoshuo .shuoshuo-photo img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 1;
object-fit: cover;
margin: 0;
}

#article-container.public-shuoshuo .is-single img {
aspect-ratio: auto;
max-height: 640px;
object-fit: contain;
}

.public-shuoshuo figcaption {
margin-top: 6px;
color: var(--font-color);
font-size: 13px;
overflow-wrap: anywhere;
}

.public-shuoshuo .shuoshuo-empty,
.public-shuoshuo .shuoshuo-count {
text-align: center;
}

.public-shuoshuo .shuoshuo-empty > i {
font-size: 32px;
color: var(--btn-bg);
}

.public-shuoshuo .shuoshuo-more {
display: block;
margin: 20px auto 0;
min-height: 44px;
padding: 10px 28px;
border-radius: 8px;
background: var(--btn-bg);
color: var(--btn-color);
cursor: pointer;
}

.public-shuoshuo [hidden] {
display: none !important;
}

.public-shuoshuo article {
scroll-margin-top: 80px;
}

.public-shuoshuo .shuoshuo-comment-actions {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}

.public-shuoshuo .shuoshuo-comment-toggle {
min-height: 44px;
padding: 8px 14px;
border-radius: 8px;
background: var(--btn-bg);
color: var(--btn-color);
cursor: pointer;
}

.public-shuoshuo .shuoshuo-comment-toggle i {
margin-right: 6px;
}

.public-shuoshuo .shuoshuo-comment-toggle:disabled {
opacity: .6;
cursor: wait;
}

.public-shuoshuo .shuoshuo-comment-panel {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--light-grey);
overflow-wrap: anywhere;
}

@media (max-width: 767px) {
#article-container.public-shuoshuo .shuoshuo-item {
padding: 20px 16px;
}

.public-shuoshuo .shuoshuo-images {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}

.public-shuoshuo .shuoshuo-text {
font-size: 15px;
}
}

单图展示原始比例,多图统一缩略图尺寸。white-space: pre-wrap 保留正文换行,overflow-wrap: anywhere 避免长链接撑破手机布局。

最后在 _config.butterfly.yml 的原有 menu 中添加说说入口。以下仅展示相关结构,不要重复新建一个 menu:

1
2
3
4
5
6
7
8
menu:
首页: / || fas fa-home
文字 ||fas fa-fw fas fa-book:
说说: /shuoshuo/ || fas fa-comment-dots
归档: /archives/ || fas fa-archive
标签: /tags/ || fas fa-tags
分类: /categories/ || fas fa-folder-open
TODO: /todolist/ || fas fa-tasks

保存之后如何自动上线

原来的博客已经使用 GitHub Actions 自动部署,但接入 CMS 时还需要调整两处。

第一,原流程限制了触发者必须是仓库所有者。CMS 提交可能由应用身份触发,因此改为允许 main 分支的授权提交进入部署流程。

第二,在部署前增加明确的检查和构建步骤:

1
安装依赖 → 校验说说 → 构建 Hexo → 部署

完整发布过程就变成了:

1
2
3
4
5
6
7
8
9
手机编辑并保存
↓
Pages CMS 提交到 GitHub
↓
GitHub Actions 校验、构建
↓
部署到 GitHub Pages
↓
访客看到新说说

如果数据校验或构建失败,流程不会继续部署。因此,CMS 中保存成功和网站已经更新是两件事,要以 Actions 的执行结果为准。

自动部署需要改哪些代码

这里沿用已经能正常部署博客的 .github/workflows/deploy.yml,保留原来的仓库地址、SSH 密钥设置和 Git 用户设置。下面给出需要修改的部分,不是一份可以脱离原项目直接使用的完整工作流。

触发条件和并发控制放在工作流顶层:

1
2
3
4
5
6
7
8
9
10
11
12
on:
push:
branches:
- main
workflow_dispatch:

permissions:
contents: read

concurrency:
group: blog-deploy
cancel-in-progress: false

构建任务的条件改为:

1
2
3
jobs:
build:
if: github.ref == 'refs/heads/main'

原先“触发者必须等于仓库所有者”的条件可能拦住 CMS 应用的提交。这里仍只处理 main 分支的 push 和手动运行,不把外部 PR 接入部署;谁能写入主分支,仍由 GitHub 仓库权限决定。

在现有 checkout、Node.js 22 和部署密钥配置之后,使用以下步骤:

1
2
3
4
5
6
7
8
- name: Install dependencies
run: npm ci
- name: Validate shuoshuo
run: node --test tests/shuoshuo.test.cjs
- name: Build hexo
run: npm run build
- name: Deploy hexo
run: npm run deploy

上面的测试文件在后文给出。npm ci 依赖仓库中的锁文件,因此添加依赖时要提交 package-lock.json。部署使用原来配置在 GitHub Actions Secrets 中的 HEXO_DEPLOY_PRI。

给每条说说添加独立评论

博客原本已经使用 Twikoo,因此说说直接复用了现有评论服务。

这里最关键的问题是:怎样让评论一直对应同一条说说?

如果用列表下标区分,新增或排序后,下标就会改变;如果用正文或发布时间区分,编辑内容或时间也可能改变评论归属。

所以,每条说说增加了一个保存到数据文件中的固定 id。Twikoo 使用的评论路径类似:

1
/shuoshuo/?memo=固定标识

只要这个标识不变,即使修改正文、图片、时间或者排序,评论仍属于原来的那条说说。已有记录也补上了标识,重复或缺少标识会在校验时被发现。

每张卡片下方有一个“评论”按钮,点击后才加载评论区。考虑到 Twikoo 的全局元素标识,同一时间只挂载一个评论区,切换时使用对应说说的路径重新初始化。

同时处理了快速点击、展开收起和加载失败后的重试。评论审核、回复等操作,继续使用原来的 Twikoo 管理后台。

评论配置与前端代码

先确认 _config.butterfly.yml 中已经启用 Twikoo。以下是相关配置示例,合并到原来的配置中:

1
2
3
4
5
6
7
8
9
10
11
12
comments:
use: Twikoo
text: true
lazyload: false
count: false
card_post_count: false

twikoo:
envId: https://你的评论服务地址
region:
visitor: false
option:

envId 填自己的 Twikoo 环境 ID 或服务地址。本次复用已有服务,没有另外部署评论后端,也没有改动原来的客户端版本。模板从 theme.asset.twikoo 获取主题配置的脚本地址。其他博客需要先保证普通文章评论可用,再接入说说;参数含义可参考 Twikoo 官方前端文档。

完整文件:source/js/shuoshuo-comments.js。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
(() => {
const root = document.querySelector('.public-shuoshuo');
const configElement = root && root.querySelector('.shuoshuo-comment-config');
if (!configElement || root.dataset.commentsReady) return;
root.dataset.commentsReady = 'true';
const config = JSON.parse(configElement.textContent);
const buttons = [...root.querySelectorAll('.shuoshuo-comment-toggle')];
// Twikoo uses global element IDs: keep only one mounted comment area per page.
const mount = document.createElement('div');
mount.id = 'shuoshuo-twikoo';
let active = null;
let busy = false;
let initializedPath = null;

const loadTwikoo = () => {
if (window.twikoo) return Promise.resolve();
if (!window.shuoshuoTwikooScript) {
window.shuoshuoTwikooScript = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = config.script;
script.onload = () => {
if (window.twikoo) resolve();
else { script.remove(); reject(new Error('Twikoo unavailable')); }
};
script.onerror = () => { script.remove(); reject(new Error('Twikoo failed to load')); };
document.head.appendChild(script);
}).catch(error => { window.shuoshuoTwikooScript = null; throw error; });
}
return window.shuoshuoTwikooScript;
};

const setExpanded = (button, expanded) => {
button.setAttribute('aria-expanded', String(expanded));
button.querySelector('span').textContent = expanded ? '收起评论' : '评论';
document.getElementById(button.getAttribute('aria-controls')).hidden = !expanded;
};

root.addEventListener('click', async event => {
const button = event.target.closest('.shuoshuo-comment-toggle');
if (!button || !root.contains(button) || busy) return;
if (active === button) {
setExpanded(button, false);
active = null;
return;
}
if (active) setExpanded(active, false);
active = button;
setExpanded(button, true);
const panel = document.getElementById(button.getAttribute('aria-controls'));
panel.replaceChildren(mount);
const path = button.dataset.commentPath;
if (initializedPath === path) return;
busy = true;
buttons.forEach(item => { item.disabled = true; });
panel.setAttribute('aria-busy', 'true');
const status = document.createElement('p');
status.setAttribute('role', 'status');
status.textContent = '正在加载评论…';
panel.prepend(status);
try {
await loadTwikoo();
if (!root.isConnected) return;
mount.replaceChildren();
await window.twikoo.init({
...(config.option || {}),
envId: config.envId,
region: config.region || undefined,
el: mount,
path
});
initializedPath = path;
status.remove();
} catch (error) {
initializedPath = null;
status.textContent = '评论暂时无法加载,请收起后重试。';
console.error('说说评论加载失败', error);
} finally {
busy = false;
buttons.forEach(item => { item.disabled = false; });
panel.removeAttribute('aria-busy');
}
});

// Links from Twikoo notifications can locate and open the correct memo.
const memo = new URLSearchParams(location.search).get('memo');
const linked = buttons.find(button => new URL(button.dataset.commentPath, location.href).searchParams.get('memo') === memo);
if (memo && linked) {
const card = linked.closest('article');
const cards = [...root.querySelectorAll('article.shuoshuo-item')];
const more = root.querySelector('.shuoshuo-more');
while (card.hidden && more && !more.hidden) more.click();
linked.click();
card.scrollIntoView({ block: 'start' });
}
})();

已有的说说在首次启用评论前要补齐 id。一旦有人留下评论,就不要通过重新生成标识来“整理数据”,否则原有评论仍保留在旧路径下。

验证与使用边界

可运行的测试

完整文件:tests/shuoshuo.test.cjs。这里使用 Node.js 自带的测试运行器,模板渲染用到项目已有的 pug,表单解析用到 js-yaml。其他项目若不能解析这些模块,需要安装对应开发依赖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const pug = require('pug');
const yaml = require('js-yaml');
const prepareRaw = require('../lib/shuoshuo.cjs');
const prepare = data => prepareRaw(data.map((item, index) => ({ id: `test-${index}`, ...item })));
const root = path.resolve(__dirname, '..');
const date = '2026-09-22T12:30:00+08:00';
const render = (entries, comments = false) => pug.renderFile(path.join(root, 'themes/butterfly/layout/includes/page/shuoshuo-public.pug'), {
publicShuoshuo: () => prepare(entries),
url_for: value => value,
page: { comments },
safeJSON: value => JSON.stringify(value).replace(/</g, '\\u003c'),
theme: { avatar: { img: '/avatar.png' }, comments: { use: ['Twikoo'] }, twikoo: { envId: 'https://comments.example.com' }, asset: { twikoo: '/twikoo.js' } },
config: { author: 'Rem' }
});

test('CMS schema points to valid data and only stores external image URLs', () => {
const config = yaml.load(fs.readFileSync(path.join(root, '.pages.yml'), 'utf8'));
assert.equal(config.media, undefined);
const editor = config.content[0];
assert.equal(editor.list, true);
const data = JSON.parse(fs.readFileSync(path.join(root, editor.path), 'utf8'));
assert.doesNotThrow(() => prepareRaw(data));
assert.equal(editor.fields.find(f => f.name === 'images').fields[0].type, 'string');
});

test('comment identities survive edits and sorting, and reject missing or duplicate IDs', () => {
const before = prepareRaw([{id:'first',date,content:'之前'}, {id:'second',date,content:'另一条'}]);
const after = prepareRaw([{id:'second',date,content:'另一条'}, {id:'first',date:'2026-09-23T18:00:00+08:00',content:'之后'}]);
assert.equal(before[0].id, after[0].id);
assert.throws(() => prepareRaw([{date, content:'没有标识'}]), /固定标识/);
assert.throws(() => prepareRaw([{id:'same',date,content:'一'}, {id:'same',date,content:'二'}]), /重复/);
const html = render([{id:'first',date,content:'正文'}, {id:'second',date,content:'正文'}], true);
assert.match(html, /data-comment-path="\/shuoshuo\/\?memo=first"/);
assert.match(html, /data-comment-path="\/shuoshuo\/\?memo=second"/);
assert.match(html, /aria-controls="comments-first"/);
assert.doesNotMatch(render([{date,content:'正文'}]), /shuoshuo-comment-toggle/);
});

test('dates retain their instant, display in Shanghai, and sort newest first', () => {
const data = [{ date, content: '旧' }, { date: '2026-09-22T06:00:00Z', content: '新' }];
const before = JSON.stringify(data);
const result = prepare(data);
assert.equal(result[0].content, '新');
assert.equal(result[0].displayDate, '2026-09-22 14:00');
assert.equal(result[1].date, '2026-09-22T04:30:00.000Z');
assert.equal(JSON.stringify(data), before);
assert.equal(prepare([{date: '2026-09-22T12:30:00', content:'无时区'}])[0].displayDate, '2026-09-22 12:30');
});

test('empty state, text-only, image-only, and multi-image entries render', () => {
assert.match(render([]), /还没有说说/);
assert.match(render([{date, content:'第一行\n第二行'}]), /第一行\n第二行/);
assert.match(render([{date, images:[{url:'https://example.com/a.jpg'}]}]), /is-single/);
const html = render([{date, images:[{url:'https://example.com/a.jpg'}, {url:'https://example.com/b.jpg',caption:'说明'}]}]);
assert.equal((html.match(/<figure/g) || []).length, 2);
assert.match(html, /<figcaption>说明/);
assert.doesNotMatch(html, /is-single/);
});

test('user text and captions are escaped; unsafe or malformed records fail before publication', () => {
const html = render([{date, content:'<script>alert(1)</script>', images:[{url:'https://example.com/a.jpg',caption:'<img onerror=alert(1)>'}]}]);
assert.doesNotMatch(html, /<script>alert/);
assert.match(html, /&lt;script&gt;/);
assert.match(html, /&lt;img/);
for (const url of ['javascript:alert(1)', 'http://example.com/a.jpg', 'https://a:b@example.com/a.jpg', '![图片](https://example.com/a.jpg)']) {
assert.throws(() => prepare([{date, images:[{url}]}]));
}
assert.throws(() => prepare([{date, content:' '}]), /至少填写一项/);
assert.throws(() => prepare([{date:'2026-02-30T12:30:00+08:00', content:'bad'}]), /时间无效/);
});

test('long feeds have progressive load controls and render all cards without JavaScript', () => {
const html = render(Array.from({length: 17}, (_, i) => ({date, content:`内容 ${i}`})));
assert.equal((html.match(/<article /g) || []).length, 17);
assert.match(html, /shuoshuo-more/);
assert.doesNotMatch(render([{date,content:'一条'}]), /shuoshuo-more/);
});

分别运行测试与构建,并确认两者都成功:

1
2
node --test tests/shuoshuo.test.cjs
npm run build

本地预览:

1
npm run server

或者

1
2
hexo g 
hexo s

打开本地 /shuoshuo/,检查纯文字、单图、多图、超过八条的列表,以及评论按钮。测试通过后再提交配置和代码,推送后查看 Actions 的执行结果。

自动测试覆盖数据与模板逻辑,但不代替 Pages CMS 首次登录授权,也不会向真实 Twikoo 服务发送测试评论。

总结

到这里就实现成功了。

以后记录日常时,只需要在手机上打开图床和 Pages CMS,就能完成一次图文发布。博客仍然保持静态部署。