这个博客是怎么运转的
2026年11月12日 14:53·关于·2974 字 · 约 8 分钟
这个博客是一个纯静态站点:文章是仓库里的 Markdown 文件,构建后变成一组 HTML,传到服务器上就能访问。它没有数据库,没有后台管理界面,也没有常驻的服务端进程,连搜索都是在访客的浏览器里跑完的。
如果你刚拿到这个仓库,按下面的顺序看:先在本地跑起来,再了解它有哪些能力,然后是怎么写、怎么发、怎么维护。
先在本地跑起来
需要 Node 22.18 以上(22、24、26 都可以):
pnpm install
pnpm blog dev
终端会给出一个本地地址,打开就能看。开发模式下草稿也会出现,方便边写边看效果。
其余命令:
| 命令 | 作用 |
|---|---|
pnpm blog dev |
启动开发服务器,草稿可见 |
pnpm blog build |
构建到 dist/,并生成搜索索引 |
pnpm preview |
预览已经构建好的产物 |
pnpm blog new "标题" |
按模板新建一篇文章 |
pnpm blog publish |
构建 → 预检 → 上传 |
pnpm blog publish --dry-run |
只做构建与预检,不上传 |
pnpm preflight |
对已有的 dist/ 单独跑一次预检 |
pnpm test |
跑全部测试 |
站点有哪些能力
搜索
搜索索引由 Pagefind 在构建期生成,放在 dist/pagefind/。访客输入关键词后,浏览器下载索引分片并在本地完成检索,结果浮在搜索框下方,不刷新页面,也不请求任何服务端接口。
索引收录的范围是文章页的标题与正文。日期、分类、字数这类元信息,以及目录、标签、上下篇导航、页头、侧栏、页脚和首页的文章列表,都被排除在外——按正文里的词能搜到,按导航栏里的词不会搜出一堆重复结果。草稿与私密文章在构建期就不生成页面,自然也不会进索引。
索引体积不小,所以它只在第一次搜索时才加载,首页不会为它多付一次请求。
明暗主题
右上角的按钮在太阳与月亮之间切换,选择写进浏览器的 localStorage。首屏由一段内联脚本在样式生效之前定好主题,刷新时不会先闪一下白底。没有做过选择时跟随系统的深色设置。
目录
文章目录由渲染结果里的标题生成,锚点与正文的 id 永远一致。只收 h2 到 h4:h5、h6 通常是段落内的细节,收进来会把目录拉长到盖过正文。目录只保留标题本身的文字,不附加编号,滚动时高亮当前所在的小节。
视口宽于 1550px 时,目录伸到正文容器的左侧空白里,并固定跟随滚动;更窄时它折叠在正文上方,点一下展开。无论哪种情况都不占用正文的宽度。
正文排版
代码块由 Shiki 在构建期高亮,亮色与暗色各一套配色,切换主题时一起换;右上角有复制按钮。数学公式用 KaTeX,行内写 $...$,独立成行写 $$...$$。表格、引用、图片、行内代码都有对应的样式。
归档、分类、标签、时间线
首页按每页 10 篇分页,第 2 页起在 /page/2/。/archives/ 按年份分组列出全部文章,/categories/<分类>/ 是单级分类页,/tags/ 是标签云(字号随文章数变化),/timeline/ 按年月排成时间线。分类和标签都直接作为 URL 路径段。
封面图
在 frontmatter 的 cover 里填一个完整的图片地址,文章流与文章页都会在标题上方显示它:16:9 裁切、铺满正文栏。不填就没有封面,页面和没有这个功能时完全一样。地址不可达时整块封面会消失,不会留下破图。
草稿与私密
两个标记的区别只在于谁能看到:
| 标记 | pnpm blog dev |
构建产物 |
|---|---|---|
draft: true |
可见 | 剔除 |
private: true |
不可见 | 剔除 |
过滤发生在内容查询层,不是模板层。也就是说,隐藏的文章不会出现在首页、归档、分类、标签、时间线里,也不会进搜索索引——不存在“某个页面忘了排除”的可能。发布预检会再核对一遍,把这条承诺变成一条会失败的检查。
写一篇文章
新建
pnpm blog new "文章标题" --category 随笔 --tags 写作,笔记
这会生成 src/content/posts/<slug>.md,frontmatter 已经填好。slug 由中文标题转成拼音得到;转写结果不合法时(比如标题全是符号)回退成 post-日期-随机串,也可以自己用 --slug 指定。
slug 在创建时就写死进 frontmatter,之后改标题不会改变文章地址。这是有意的:链接一旦发出去,就不该因为改了个标题而失效。
也可以直接手写 Markdown 文件,但 slug 字段必须自己填。
frontmatter 字段
| 字段 | 必填 | 说明 |
|---|---|---|
title |
是 | 文章标题 |
date |
是 | 发布日期,可精确到秒,如 2026-11-12T09:30:00;只写日期时按当天 0 点 |
category |
是 | 单级分类,参与 URL |
tags |
否 | 标签列表,默认空 |
slug |
是 | 只能用小写字母、数字与连字符,决定地址 /posts/<slug>/ |
description |
否 | 一句话摘要,写进页面描述与分享信息 |
cover |
否 | 封面图地址,必须是完整 URL |
draft |
否 | 草稿,默认 false |
private |
否 | 私密,默认 false |
字段的契约定义在 src/content.config.ts,校验在构建期执行。写错了构建会直接失败,并指出是哪篇文章的哪个字段,不会带着错误默默上线。
正文里的约定
- 文章按
date倒序排,同一天里由时分秒决定先后;时间也相同时再按 slug,保证每次构建顺序一致。只写日期会被当成当天 0 点,同一天的多篇就都会落到 slug 排序上。 - 代码块不计入字数,避免示例代码把阅读时长撑长。中文按每分钟 400 字、英文按每分钟 200 词估算,向上取整。
- 行内单个
$会被当成公式的开始。要写字面的美元符号,写成\$。 - 图片用外链地址,和封面一样放在图床上。发布预检会逐个检查外链图片是否可达。
- 从 h2 开始组织内容,h2 到 h4 才会进目录。
- 正文里出现的站内链接写成
/posts/<slug>/这样的绝对路径,预检会逐个核对目标文件是否存在。
本地预览
保存 Markdown 文件后开发服务器会自动刷新。写完想确认最终效果,跑一次 pnpm blog build 再 pnpm preview,这样看到的就是压缩过的 CSS、真实的搜索索引和构建期的校验结果。
发布上线
一次性准备
先在要放站点的机器上建好父目录,再把 Nginx 的 root 指到软链接上:
ssh deploy@your-server.com "mkdir -p /var/www/echonest/releases"
root /var/www/echonest/current;
本地则要配置两个环境变量,缺一不可:
export ECHONEST_SSH=deploy@your-server.com
export ECHONEST_TARGET=/var/www/echonest
没有配置时,构建和预检照常进行,但会在上传前报错退出——不会静默跳过上传,也不会让你误以为已经上线。
发布流程
pnpm blog publish
它按顺序做四步:
- 构建:
astro build产出 HTML,Pagefind 再为这些 HTML 生成搜索索引。 - 预检:检查私密内容泄漏、站内断链、外链图片。
- 上传:用
rsync -az --delete把产物写进一个新的时间戳目录。 - 切换:上传成功后,用
mv -Tf原子替换current软链接。
任何一步失败,current 都不会动,线上仍是上一版。服务器上的结构长这样:
/var/www/echonest/
├── releases/
│ ├── 20261112-171500/
│ └── 20261112-190230/
└── current -> releases/20261112-190230
分版本目录的好处是上传过程中不会出现半新半旧的站点,回滚也只是换一个指向:
ssh deploy@your-server.com 'cd /var/www/echonest && ln -sfn releases/20261112-171500 current'
只想检查,不想上线
pnpm blog publish --dry-run
构建与预检都会跑,最后停下。改完配置、或者不确定这次改动会不会断链时,先跑这个。
部署到 Vercel
如果没有自己的服务器,也可以把仓库直接交给 Vercel。根目录的 vercel.json 已经把该配的都配好了:
{
"buildCommand": "pnpm build && pnpm preflight",
"outputDirectory": "dist",
"trailingSlash": true
}
在 Vercel 里导入仓库,不用再装 adapter——产物本来就是一组静态文件。构建时先跑 pnpm build(页面加搜索索引),紧接着跑预检:私密或草稿内容泄漏会让构建失败,部署不会发生;外链图片不可达只算警告。站内链接一律带尾斜杠,trailingSlash 让 Vercel 的跳转与之一致,点链接不会多一次重定向。
站点地址会自动取 Vercel 给出的域名,canonical 与分享信息跟着变。绑定了自有域名后,在项目的环境变量里设置 ECHONEST_SITE_URL 覆盖它。
这条路径和上面基于 rsync 的发布是二选一的关系:Vercel 管构建与托管,pnpm blog publish 管自己的服务器,两者互不影响。
发布预检检查什么
预检在产物生成之后、上传之前跑,只读 dist/,不改任何文件:
- 私密与草稿泄漏:核对每篇可见文章都有对应的页面,每篇隐藏文章都没有页面;然后在所有产物 HTML 和 Pagefind 索引里搜索隐藏文章的标题与路径,防止它们从归档页、标签页或搜索索引里漏出去。
- 站内断链:解析产物里所有相对链接,用产物文件的精确清单做匹配。这里刻意不用文件系统探测,因为 macOS 默认大小写不敏感,会放过只在本地成立、到了 Linux 上就 404 的链接。
- 外链图片不可达:对每个外链图片发一次 HEAD 请求,5 秒超时。这一项只警告不阻断——图床偶尔抽风不该拦住你的发布,但提示里会写清楚图片出现在哪些页面。
日常维护
站点配置集中在一处
站点名、副标题、作者、头像、导航项(含图标)、侧栏卡片顺序、社交链接、友情链接、每页文章数、摘要长度、评论开关,都在 src/site.config.ts。改完重新构建即可,不需要动模板。
侧栏的顺序由 sidebar 数组决定,可选值是 profile、search、categories、tags、recent、links。搜索框默认在页头右上角,只有把 search 加回数组时才会在侧栏再出现一个。
样式与主题
颜色、字号、行高、最大宽度写在 src/styles/global.css 的 @theme 块里,只出现一次;暗色的取值在 .dark 下覆盖同名变量。改一处,全站跟着变。
测试
pnpm test
测试用 Node 自带的 node:test 直接跑 TypeScript 源码,靠 Node 的类型剥离,不需要额外的转译链。覆盖可见性过滤、分页、摘要、目录编号、字数统计、slug 生成、发布命令的参数拼装与预检逻辑。
目录结构
src/
├── site.config.ts 站点身份的唯一来源
├── content.config.ts frontmatter 的校验规则
├── content/posts/ 文章
├── content/pages/ 独立页面
├── lib/ 可见性、排序、分页、摘要、目录、字数
├── components/ Astro 组件与三个 Vue 交互岛
├── layouts/BaseLayout.astro
├── pages/ 路由
└── styles/global.css 主题变量与正文排版
scripts/
├── blog.mjs dev / build / new / publish
├── preflight.mjs 发布预检
└── lib/ 可测试的纯函数
tests/ node:test 测试
只有三个 Vue 交互组件:搜索框、主题切换、目录高亮。其余是纯 Astro 组件和几行原生脚本,浏览器不需要加载框架。
常见问题
本地搜索没反应,提示暂不可用。 开发服务器本身不生成搜索索引,用的是上一次 pnpm blog build 留在 dist/pagefind/ 的那份。改完文章想搜到新内容,先构建一次。
构建失败,说某篇文章的字段不合法。 错误信息里有文件名和字段名,按 frontmatter 字段表核对一下。cover 要求完整的 URL,slug 要求小写字母、数字与连字符,这两项最容易写错。
图片不显示。 外链地址可能已经失效。预检会列出不可达的图片和它出现的页面;封面图失败时整块会消失,正文里的图片则会留下一个空位。
改了标题,文章地址却没变。 这是设计如此:slug 在 frontmatter 里,不随标题走。确实需要改地址时,手动改 slug,并留意旧链接会失效。
发布后看不到变化。 先确认命令确实走到了最后一步(终端会打印上线的新版本目录)。构建产物的文件名带哈希,正常刷新就能拿到新版;剩下的可能性是服务器或 CDN 缓存了页面。
写新文章、跑测试、发上线,日常要用的其实就这几条命令。剩下的能力按需去改 site.config.ts 与 global.css,两处覆盖了大部分个性化需求。
本文由 Leo 写于 2026年11月12日 14:53,版权归作者所有。非商业转载请注明出处与原文链接。