一颗丁子

符合 DRY 和 DIP 的博客渲染器 <:_>

此文旨在测试博客的 md 渲染能力和内容页功能,也顺便介绍一下我的小博客。

本站点由 vike1 驱动,以 SSG2 模式在构建阶段完成渲染并部署在 GitHub Pages 上。

背景

博客最终还是以内容为中心的,因此我觉得码字体验是高于一切的。我曾尝试过 WordPress、Halo、VitePress、Ghost、Mix Space 等各种形态的博客应用。但这些通用产品都有令我不满的地方,例如:

  • VitePress 虽然是 SSG,但是毕竟还是为产品文档一类需求设计的,写文章还要考虑 Sidebar 位置,不能做到在一个 .md 中就完成全部工作。另外文档标题、正文标题、Sidebar Item 标题又都是分开定义的,很烦人
  • Halo、WordPress、Ghost、Mix Space 等首先都是依赖 Web Server 的全栈应用。其次它也不是专门的博客应用,还兼顾知识库、官网等用例,且为了追赶潮流而大都有各种 AI 功能,对我就显得臃肿。另外写文章还得用 Web 编辑器,不够顺手

因此我就做了这么一个简单的小应用来满足自己的需求。用它,我可以:

  • 直接编辑 markdown 文件,不用为了写个博客去花时间适应那些所见即所得编辑器,且不用引入任何 MCP 就可以适配任何 Agent CLI
  • 产出静态页面,白嫖 GitHub Pages 的服务器,不用为了写博客多花钱
  • 文章直接以文件形式储存在 GitHub 上,安全可靠
  • 文章和博客代码维护在一起,写文章的过程中发现的代码问题可以立即修改并同步发布
  • 从写文章到预览到发布的全流程都可以在一个 VSCode 窗口内完成,很契合我的工作习惯 编辑器
  • 可以自己控制全部样式和布局,因此可以做出现在的这种超级复 ya 古 yi 效果
  • 可以自由扩展我喜欢的阅读器功能,比如 hover 预览

设计理念

DRY 和 DIP

在一个系统中,每一处知识都必须单一、明确、权威地表达。
《The Pragmatic Programmer》

A. 高层次的模块不应该依赖于低层次的模块,两者都应该依赖于抽象接口。
B. 抽象接口不应该依赖于具体实现。而具体实现则应该依赖于抽象接口。
《Agile Software Development: Principles, Patterns, and Practices》

博客系统天然需要 Blog(title, description, content) 这样的实体,但传统的分开定义的方式难免有以下问题:

  • content 里的 # 文章标题 算正文还是标题?渲染和导出时要去重吗?
  • 我习惯在正文的第一段概括全文内容,我为什么非得把它单独写成文档描述,与文档分离?
  • 导出为 md 时标题要拼接到文档头部还是 frontmatter,描述呢?

我认为这是一种违反 DRY3 原则的、讨厌的重复。

VitePress 等很多博客系统引入了 frontmatter 用于定义文档实体的元数据,并让用户可以引用这些数据来解决重复问题:

---
title: 文章标题
---

# {{ $frontmatter.title }}

正文内容

但这就让正文只有在被 VitePress 编译后才能正确渲染,也就是违反了 DIP4。而且我觉得 frontmatter 好丑。

为了避免上述这些问题,我的博客标题取原始 MD AST5 中第一个 heading,而描述则取标题后的第一个 paragraph。得益于此,我不用反复书写相同的标题和描述(DRY),也不用为了编译和渲染器需要的实体结构来改变我的 md 文档结构,而是让 文档 -> 解析器 反转为 文档 -> MD AST <- 解析器 —— 典型的 DIP。

易用性

写文章本来就很费心了,我不想再给自己加任务。目前我的工作流是:

# 创建文档模板
pnpm write second
/workspaces/blog/public/blogs/1781614950277-second/index.md
# 启动开发服务器
pnpm dev

这样就可以在侧边栏实时预览渲染效果,并在 VSCode 中编辑文档。

写完后就直接通过 VSCode 的源代码管理 UI 进行提交和推送即可,GitHub Action 会自动触发并完成测试、构建和发布到 Pages 的工作。

这种两条命令就可以开始写文章的复杂度对我来说是可以接受的,多几个步骤换来的是与编码心智模型相同的码字体验。

站点还仅在 SSG 期间自动生成 sitemap.xml、robots.txt、llms.txt、atom.xml 等辅助检索、阅读的静态文件,同时将部分图片转换为 webp 以降低网络压力。所有这些功能对码字过程都是透明的。

文档优先

我用 vite 原生支持的 Static Assets 机制在 public/blogs/ 目录下管理所有文档。pnpm write <slug> 命令会帮我创建以下格式的博客模板:

<timestamp-slug>
├── assets/
└── index.md

在写文章时,我不用考虑图片等静态文件的放置位置,可以自由地用 public/a/b/c.png、./assets/a.png、../xxx/assets/b.png、../xxx/index.md#title 等各种方式引用 public 目录中的静态资源和其他文章等。在渲染时,一个 remark 插件会完成目录的映射工作,将对文档的引用解析为 /blog/slug/#hash 的形式,而将其他静态资源解析为相对于 / 的 url 形式,以便直接利用 Static Assets 机制引用打包好的静态资源。

同时,文章的创建时间也自然地被通过目录名中的 unix timestamp 维护起来,且可以实现文章自然地按时间顺序在目录下排序。

最后,index.md 和存储它的目录基本上完整描述了一篇博客的的所有数据,无需额外的配置文就可以定义一个 Blog 实体,这种文档优先的做法也进一步降低了码字难度。

当然我还没有引入文档 tags,也主要是 tags 很难像 title 和 description 一样从 AST 中被提取。这里就只能依赖后人的智慧了。

Icon

本站的 favicon 是这个:

favicon

原图是 Code 和 平底锅 两个 SVG 图,自行拼接并改为黑色背景和白色线条。

我认为软件工程犹如烹饪,是纯粹的创造。因此这个博客除了技术内容之外应该也会整理一些原创食谱。


  1. vike 是基于 vite 的元框架,有极高的自由度,不限制前端框架、渲染策略、后端框架和部署方式等 ↩

  2. Static Site Generator,静态站点生成器 ↩

  3. Don't Repeat Yourself,即不要重复自己,对本例来说就是标题写一次就够了 ↩

  4. Dependence Inversion Principle,即依赖倒置原则,对本例来说就是高层文档内容与 vue 风格的模板语法强耦合 ↩

  5. Abstract Syntax Tree,即抽象语法树,博客使用的解析器是 mdast ↩