- 主题太多,选择太多,反而迷失了自我
- 本身就讨厌 npm 和 node_modules,每次编译很烦,懒得折腾
- 配置项太多了,非常麻烦
- 同 hexo,用 npm 编译,看到 node_modules 就害怕
- 编译产物比 hexo 还大
- 左找右找,正好当时在学 Rust,看到 Mdbook 不错
- 看了一下官方文档,配置项也特别少,适合自己这种懒人,就改为 mdbook 了
- 刚开始是出于懒选择的 mdbook,后面发现自己做的工作其实不比其他静态网站生成框架少
- 切换成 mdbook 本身没什么困难,所有困难主要集中在附属模块上
- mdbook 本身的困难只有一次,由于 mdbook 版本升级带来的 index.hbs 带来的预置代码变动
- 详见这篇博客和本博客 Github 上源码关于 index.hbs 带来的更改,另外,Rust 语言圣经也遇到了这个问题,其更改也可以参考
- 切换到 mdbook 首先问题就是没有文章目录
- 习惯了先浏览文章大纲,建立框架再补充文章细节,很不习惯
- 浏览了网上所有关于 mdbook 的 toc 方法,都不太满意
- 大多数是在每篇文章顶部添加 toc 链接
- 这会占据文章的内容空间,不仅不优雅,每次想跳到另一个章节的时候,还要滚回文章顶部,非常麻烦
- 正巧当时在学 rust,Rust 语言圣经的 mdbook 里就有 toc 侧边栏,很好奇其实现,就去翻源码
- 照着实现 toc 和研究 hbs 代码花了很多时间(因为当时不懂 hbs,
其实现在也不懂)
- 途中还遇到了一个怎么都解决不了的难题,最后发现是文件名为 head.hbs,mdbook 会视该文件仅为页面的 head 部分,改成 index.hbs 就好了
- 评论模块也是参照 Rust 语言圣经,使用 giscus 实现,不过直到现在都没有人用
(挺正常的)
- 评论模块没有太大的问题
- 之前还附带有 view counter,但是负责 count 的那个网站似乎被炸了,无法完成功能,所以也就把相关代码删了
- mdbook 不按照时间进行倒序排列,而是按照书一样分章节组织
- 所以改动了哪里也不知道,对于读者来说可能有点烦,每次都要跑到 github 上看代码提交
- 写脚本花了一段时间,也犯了比较多的错,直到现在这个脚本都有 bug,而且不通用,不过也懒得改了
- 每次出 bug 的时候,比如 sed 找不到代表代码块的 ``` 标志,就会直接把 last-updated.md 全删了,再 add && commit,主打一个懒
- mdbook 的中文搜索和 telegram 的中文搜索一样烂,自己写了太多东西记不到,只记得关键词,搜不到就很烦
- mdbook 的搜索,根据西方文字自身的空格作为分词符,但中文根本不用空格这种东西,就没法分词
- 机缘巧合找到了 docsearch,申请了一下,通过了,官方也给了教程怎么搭建
- 不过 docsearch 很长一段时间我都没用到,因为搜索框死活出不来
- 本来人就懒,就一直在摆烂,后面有一次有时间了,也实在忍不了搜索之殇了,就好好排查了一下问题
- 结果发现是 index.hbs 里面,关于 docsearch 的配置少了一个逗号,排查出来自己都想笑,怎么犯了这么低级的错误
- docsearch 是 algolia 的子服务,要先注册 algolia,然后一步一步配置
- 甚至要自己配置 algolia 的 crawler(爬虫),自定义爬取的内容,才能加到 algolia 的倒排索引里面,也才能搜索到
- 本来就懒,简直逆了天了,照着网上稍微配置了一下,自定义了一下就没管了
- 有一些配置上的 bug 到现在都还没修……
- docsearch 用起来倒是挺好用
- 配置在附录,如果有需要可以参考一下
- 后来想把网站加到搜索引擎里,想着可能会帮到别人
- 一进 Google 和 Bing 的站长工具,又要在 html 的 head 里面加东西,很麻烦
- 加好了之后,又说需要站点地图才能爬网,当时连站点地图都不知道是啥
- 随便搜了一个生成站点地图的工具 static-sitemap-cli,还好用起来不复杂
- 可惜又是依赖 node,用 js 写的工具……
- 每次本地编译完了之后,就会用这个工具生成站点地图,都在脚本里,自动化一下就可以了
- 注意,随着文章越来越多,站点地图 sitemap.xml 和 sitemap.txt 会越来越大,而且 xml 文件每次编译里面的时间都会变,所以 last-updated.md 会越拖越长,这点在写脚本的时候尤其要注意
- mdbook 博客搭建好的时候,github pages 的“通过 github actions 部署”还是 experimental(实验性的),采取了主流方案:Deploy from branch(通过分支部署)
- 但是当时也写了一个 github actions 每次推送的时候自动编译,然后推送到 gh-pages 分支上,就不用自己本地编译了
- 本地编译一遍主要是因为站点地图和生成 last-updated.md 需要,如果不做这两个工作可以直接推送到 remote
- 当时用的别人的 actions,自己也没注意到 git 会 tracing 编译后的产物,这些产物都在 gh-pages 里,导致仓库越来越大,仅 .git 文件夹就有 800 多 MB,加上编译产物就有 1G 之大,被迫瘦身
- 原计划是清理 gh-pages 分支,删除所有 git history
- 也确实这么做了,从 834MB 减到了 26.8MB,详见这篇博客
- 但是发现这个时候“通过 Github Actions 部署”已经不再是实验性的了
- 经过提醒,于是从 Deploy from branch 改为 Github Actions,所以 gh-pages 分支也就用不到了,直接删除
- 参照 mdbook wiki 里的官方部署教程,自己重写了 Github Actions 文件
- 这个想法是源于,经常给国内没法翻墙的朋友发博客内容很不方便,半天转不出来,想方便一下国内的朋友
- 找了很多静态网站托管服务,要么被墙要么要钱,很搞人,自己甚至还开了一个月腾讯云的静态网站托管服务,结果域名要备案,还要上传 ssl 证书,纯纯大冤种
- gitea + drone CI/CD,腾讯云,vercel,netlify,Gitee Pages 都试过了,没啥用
- 最哈皮的还是 Gitee,这玩意儿居然会从源代码里面审查违规内容
- 而且规则不透明,上一次部署能通过的文章,下一次部署就通过不了了
- 甚至林清玄的文章都是违规内容
- 也不告诉你哪儿错了,就告诉你这篇文章有问题,叫人自己排查
- 开源中国的反馈 repository,里面至少一半多的 issue 都是反应自己无法部署
- Cloudflare Pages 刚开始用了一下,但是没有 mdbook 的选项,也就不了了之,结果后面搜出来有一种比较 dirty 的做法可以曲线救国
- Cloudflare Pages 直接导入 Github 仓库
- 构建配置的框架预设选
无
- 构建命令填
curl -L https://github.com/rust-lang/mdBook/releases/download/v0.4.37/mdbook-v0.4.37-x86_64-unknown-linux-gnu.tar.gz | tar xvz && ./mdbook build
- 构建输出目录填 book
- 本解决方案参考这篇同样使用 mdbook 作为博客的博文
- Cloudflare Pages 官方说支持 mdbook 构建,但实际上构建选项里并没有 mdbook
- 有人在对此的 issue 里提出了一个比较 dirty 的做法,还真有用……
- 为什么说 dirty,因为这个方法本质上是在 Cloudflare Pages 的构建服务器上,暴力拉 mdbook 的 binary 下来,构建命令当成注入到别人服务器里的 hack 命令用,看一下构建 log 就知道了(构建日志详见附录)
- 重点在
00:14:26.223 那一行,构建命令实际上是一个 user command,按理来说可以乱填,这里拉 mdbook binary 下来就是不太合规的做法
- 不过能用就行……
- Cloudflare Pages 在国内的访问速度其实一般,不过好歹能访问,不像 Github Pages
- 感谢一只昀喵辛勤的指导,受教了
new Crawler({
// ⚠️ 三条硬性兼容要求(改动前请先看 assets/algolia-crawler.md 第三节):
// 1. 第 1 行必须就是 new Crawler({,前面不能有注释或代码(algolia/docsearch#2190)
// 2. 不用模板字符串
// 3. 配置内部不写嵌套函数/回调:编辑器的静态检查解析不了嵌套作用域里的
// 参数绑定,会把回调参数报成未定义标识符(曾把 .each((_, el) => ...) 的
// el 标红)。所以下面全部用普通下标循环 + 直筒代码,没有闭包。
appId: 'GX9RTL51BH',
// 保持你现有 Crawler 的 apiKey(不要把这个可写 key 提交进公开仓库)
apiKey: 'PlaceHolder',
rateLimit: 8,
maxDepth: 10,
maxUrls: 5000,
startUrls: [
'https://tinysnow.github.io',
// ⚠️ toc.html 必须留在 startUrls 里:站点静态 HTML 里首页只有
// print.html / last-updated.html 两个站内链接、没有任何 prev/next,
// 而 toc.html 里有 2359 条站内链接,是全站唯一的「发现入口」。
// 少了它,实测覆盖率会从 2360 个页面掉到 297 个(只剩「生活碎笔」那条章节链)。
// 它自己不会进索引:见下面的 pathsToMatch 排除 + recordExtractor 里的 SKIP_PATHS。
'https://tinysnow.github.io/toc.html',
],
renderJavaScript: false,
// 故意不用站点的 sitemap:它的 2360 条 URL 全部不带 .html(如 /个人简介),
// 而 GitHub Pages 对 /个人简介 和 /个人简介.html 都直接返回 200(不是 301 跳转),
// 用它会变成每个页面被索引两份。全站覆盖靠 toc.html 这个入口 + 站内链接。
sitemaps: [],
ignoreCanonicalTo: false,
// 排除不需要下载的页面:print.html 是整本书合订本(19MB),404.html 没有内容。
// ⚠️ 千万不要把 toc.html 放进来——它是发现入口,排除它等于掐掉全站链接。
exclusionPatterns: [
'https://tinysnow.github.io/print.html',
'https://tinysnow.github.io/404.html',
],
discoveryPatterns: ['https://tinysnow.github.io/**'],
schedule: 'at 13:14 on Tuesday',
actions: [
{
indexName: 'tinysnowio',
// 抓但不建索引的三个页面:toc.html 只用来发现链接,print/404 不是内容
pathsToMatch: [
'https://tinysnow.github.io/**',
'!https://tinysnow.github.io/toc.html',
'!https://tinysnow.github.io/print.html',
'!https://tinysnow.github.io/404.html',
],
recordExtractor: ({ url, $, contentLength, helpers }) => {
/* ============================ 开关 ============================ */
const SKIP_PATHS = ['/print.html', '/toc.html', '/404.html'];
const MAX_PAGE_BYTES = 4 * 1024 * 1024; // 超过这个体积的页面跳过
const INDEX_IMAGE_ALT = true; // 把图片 alt 并入正文
const PAGE_TITLE_FALLBACK = true; // 没有 h1 的页面用 <title> 补 lvl1
const PRE_SEGMENT = true; // 额外写一份「按词」的属性
const SEGMENT_MAX_CHARS = 3000; // 分词副本截断长度
const MAX_RECORDS_PER_PAGE = 700; // 爬虫单页硬上限是 750
const MERGE_MAX_CHARS = 1200;
const LEVELS = ['lvl0', 'lvl1', 'lvl2', 'lvl3', 'lvl4', 'lvl5', 'lvl6'];
const HAS_CJK = /[\u3400-\u9FFF\uF900-\uFAFF]/;
// 中文分词器:ICU 词典,与 Algolia 引擎声称使用的 CJK 词典同源。
// 环境不支持 Intl.Segmenter 时退化为不分词,不影响爬取。
let segmenter = null;
try {
if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
segmenter = new Intl.Segmenter('zh-Hans', { granularity: 'word' });
}
} catch (e) { /* 忽略:走不分词分支 */ }
/* ========================== 跳过规则 ========================== */
if (SKIP_PATHS.indexOf(url.pathname) !== -1) return [];
if (contentLength && contentLength > MAX_PAGE_BYTES) return [];
// 抽取主体不存在就直接跳过,避免抽出菜单栏之类的垃圾
if ($('main').length === 0 && $('#mdbook-content').length === 0) return [];
/* ======================= 图片 alt 并入正文 ======================= */
// 用下标循环,不用回调,避免嵌套作用域
if (INDEX_IMAGE_ALT) {
const images = $('main img[alt]');
for (let i = 0; i < images.length; i++) {
const image = images[i];
const alt = ($(image).attr('alt') || '').trim();
// 只保留有信息量的 alt:含连续中文,或含连续英文且不像文件名。
// 实测本站 114 个去重 alt 里有 21 个是 image-20260531144550242、
// Snipaste_2023-10-22 这类文件名,靠下面这行挡掉。
const meaningful =
/[\u4e00-\u9fff]{2,}/.test(alt) ||
(/[A-Za-z]{2,}/.test(alt) && !/[_0-9]/.test(alt));
if (!meaningful) continue;
const safe = alt.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
$(image).after('<p>' + safe + '</p>');
}
}
/* =========================== 抽取记录 =========================== */
const records = helpers.docsearch({
recordProps: {
// 故意不用站点标题 h1.menu-title:否则「微雪」会命中每一个页面
lvl0: {
selectors: '',
defaultValue: '博客',
},
// 页面标题 = <main> 里的 h1。
// ⚠️ 千万不要把 'head > title' 之类交给 helper 当兜底:它会在所有候选
// 选择器里取「文档顺序最靠前」的那个,<title> 在 <head> 里永远排第一,
// 结果 lvl1 全变成「重装系统 - 微雪博客」——标题带上站点名,而且
// 「微雪」「博客」会命中每一个页面(实测 2342/2342)。
// 没有 h1 的那 23 个页面改在下面用 <title> 补,见 PAGE_TITLE_FALLBACK。
lvl1: ['main h1', '#mdbook-content h1'],
lvl2: ['main h2', '#mdbook-content h2'],
lvl3: ['main h3', '#mdbook-content h3'],
lvl4: ['main h4', '#mdbook-content h4'],
lvl5: ['main h5', '#mdbook-content h5'],
lvl6: ['main h6', '#mdbook-content h6'],
// 正文:只取 <main> 里的块级元素。去掉了 span/a/em/strong/ul/ol
// (同一句话会被抽 3~4 遍),补上 td/th(表格)与 dd/dt,
// code 同时覆盖块级和行内代码。
content: [
'main p, main li, main td, main th, main code, main dd, main dt',
'#mdbook-content p, #mdbook-content li, #mdbook-content td, #mdbook-content th, #mdbook-content code, #mdbook-content dd, #mdbook-content dt',
],
// 语言交给 helper 写进每条记录
lang: { defaultValue: 'zh-CN' },
},
aggregateContent: true,
indexHeadings: true,
recordVersion: 'v3',
});
/* ================== 单页记录数保护(内联,无函数)================== */
// 爬虫单页超过 750 条记录会直接报 Extractors returned too many records。
// 超过阈值时把「同一锚点、类型为 content」的相邻记录合并,标题原样保留;
// 在副本上合并,不改动 helper 返回的原对象。
let finalRecords = records;
if (records.length > MAX_RECORDS_PER_PAGE) {
const packed = [];
let pending = null;
for (let i = 0; i < records.length; i++) {
const record = records[i];
const isContent = record.type === 'content' && typeof record.content === 'string';
const canMerge =
pending &&
isContent &&
pending.url === record.url &&
pending.content.length + record.content.length < MERGE_MAX_CHARS;
if (canMerge) {
pending.content += '\n' + record.content;
continue;
}
if (pending) packed.push(pending);
if (isContent) {
pending = Object.assign({}, record);
} else {
pending = null;
packed.push(record);
}
}
if (pending) packed.push(pending);
finalRecords = packed;
}
/* ================ 写一份按词切分的副本(内联,无函数)================ */
// 先给没有标题的记录补 lvl1:这类页面 <main> 里没有 h1(实测 23 个),
// 只能用 <title> 兜底,并去掉「 - 微雪博客」这个站点名后缀。
// 放在分词之前,这样 hierarchy_words.lvl1 也会一起生成。
if (PAGE_TITLE_FALLBACK) {
const pageTitle = ($('head > title').text() || '').replace(/ - 微雪博客$/, '').trim();
if (pageTitle) {
for (let i = 0; i < finalRecords.length; i++) {
const titleHierarchy = finalRecords[i].hierarchy;
if (titleHierarchy && !titleHierarchy.lvl1) {
titleHierarchy.lvl1 = pageTitle;
}
}
}
}
// 把所有要分词的文本收集起来,统一分词后再写回,
// 这样正文和各级标题共用同一段逻辑,且不需要定义函数。
if (PRE_SEGMENT) {
const texts = [];
const targets = [];
for (let i = 0; i < finalRecords.length; i++) {
const record = finalRecords[i];
if (record.content) {
texts.push(String(record.content));
targets.push([i, '']);
}
const hierarchy = record.hierarchy || {};
for (let j = 0; j < LEVELS.length; j++) {
if (hierarchy[LEVELS[j]]) {
texts.push(String(hierarchy[LEVELS[j]]));
targets.push([i, LEVELS[j]]);
}
}
}
const segmented = [];
for (let i = 0; i < texts.length; i++) {
const text = texts[i];
let result = text;
if (segmenter && HAS_CJK.test(text)) {
// 只保留词元,用空格连接:重装系统 8->7 → 重装 系统 8 7
const parts = Array.from(segmenter.segment(text));
const words = [];
for (let k = 0; k < parts.length; k++) {
if (parts[k].isWordLike) words.push(parts[k].segment);
}
result = words.join(' ');
if (result.length > SEGMENT_MAX_CHARS) result = result.slice(0, SEGMENT_MAX_CHARS);
}
segmented.push(result);
}
for (let i = 0; i < finalRecords.length; i++) {
finalRecords[i].hierarchy_words = {};
}
for (let i = 0; i < targets.length; i++) {
const record = finalRecords[targets[i][0]];
const level = targets[i][1];
if (level) {
record.hierarchy_words[level] = segmented[i];
} else {
record.content_words = segmented[i];
}
}
}
return finalRecords;
},
},
],
safetyChecks: { beforeIndexPublishing: { maxLostRecordsPercentage: 100 } },
initialIndexSettings: {
tinysnowio: {
attributesForFaceting: ['type', 'lang'],
attributesToRetrieve: [
'hierarchy',
'content',
'anchor',
'url',
'url_without_anchor',
'type',
'lang',
],
attributesToHighlight: ['hierarchy', 'content'],
attributesToSnippet: ['content:10'],
camelCaseAttributes: ['hierarchy', 'content'],
// 前 8 项是按词切分的副本(优先命中),后 8 项是按字的原文(兜底)。
// 若把上面的 PRE_SEGMENT 改成 false,请删掉前 8 项。
searchableAttributes: [
'unordered(hierarchy_words.lvl0)',
'unordered(hierarchy_words.lvl1)',
'unordered(hierarchy_words.lvl2)',
'unordered(hierarchy_words.lvl3)',
'unordered(hierarchy_words.lvl4)',
'unordered(hierarchy_words.lvl5)',
'unordered(hierarchy_words.lvl6)',
'content_words',
'unordered(hierarchy.lvl0)',
'unordered(hierarchy.lvl1)',
'unordered(hierarchy.lvl2)',
'unordered(hierarchy.lvl3)',
'unordered(hierarchy.lvl4)',
'unordered(hierarchy.lvl5)',
'unordered(hierarchy.lvl6)',
'content',
],
distinct: true,
// 按「页面」去重:同一篇文章只出一条命中,避免单页刷屏
// (原配置是 'url',含锚点,等于按段落去重)
attributeForDistinct: 'url_without_anchor',
customRanking: ['desc(weight.pageRank)', 'desc(weight.level)', 'asc(weight.position)'],
ranking: ['words', 'filters', 'typo', 'attribute', 'proximity', 'exact', 'custom'],
highlightPreTag: '<span class="algolia-docsearch-suggestion--highlight">',
highlightPostTag: '</span>',
minWordSizefor1Typo: 3,
minWordSizefor2Typos: 7,
allowTyposOnNumericTokens: false,
minProximity: 1,
ignorePlurals: true,
// 中文按词切分的开关(原配置完全没有这两项)
indexLanguages: ['zh', 'en'],
queryLanguages: ['zh', 'en'],
advancedSyntax: true,
attributeCriteriaComputedByMinProximity: true,
removeWordsIfNoResults: 'allOptional',
},
},
});
00:14:23.159 Cloning repository...
00:14:24.014 From https://github.com/TinySnow/TinySnow.github.io
00:14:24.015 * branch 3685b9c14d244ab2a2888d820486e261e5174aea -> FETCH_HEAD
00:14:24.015
00:14:24.139 HEAD is now at 3685b9c Added some Pharmacology content
00:14:24.139
00:14:24.222
00:14:24.222 Using v2 root directory strategy
00:14:24.250 Success: Finished cloning repository files
00:14:25.960 Checking for configuration in a wrangler.toml configuration file (BETA)
00:14:25.960
00:14:26.071 No wrangler.toml file found. Continuing.
00:14:26.223 Detected the following tools from environment:
00:14:26.223 Executing user command: curl -L https://github.com/rust-lang/mdBook/releases/download/v0.4.30/mdbook-v0.4.30-x86_64-unknown-linux-gnu.tar.gz | tar xvz && ./mdbook build
00:14:26.245 % Total % Received % Xferd Average Speed Time Time Time Current
00:14:26.246 Dload Upload Total Spent Left Speed
00:14:26.455
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0
00:14:26.831 mdbook
00:14:27.065
0 5031k 0 32768 0 0 59463 0 0:01:26 --:--:-- 0:01:26 59463
100 5031k 100 5031k 0 0 6130k 0 --:--:-- --:--:-- --:--:-- 18.1M
00:14:27.136 2024-05-02 16:14:27 [INFO] (mdbook::book): Book building has started
00:14:27.146 2024-05-02 16:14:27 [INFO] (mdbook::book): Running the html backend
00:14:36.384 Finished
00:14:36.385 Note: No functions dir at /functions found. Skipping.
00:14:36.385 Validating asset output directory
00:14:37.224 Deploying your site to Cloudflare's global network...
00:14:42.448 Uploading... (1254/1254)
00:14:42.449 ✨ Success! Uploaded 0 files (1254 already uploaded) (0.65 sec)
00:14:42.449
00:14:43.060 ✨ Upload complete!
00:14:45.569 Success: Assets published!
00:14:46.752 Success: Your site was deployed!