从零 Vibe Coding 出一个博客:技术复盘

复盘AI

为什么从零开始

市面上的博客框架,Hexo、Hugo、Jekyll 这几个,用起来都差不多。选个主题,改改配置,写 Markdown,部署。可一旦你想做点超出主题预设范围的事,就会发现自己是在跟主题的模板系统较劲,真正要写的代码反而写不痛快。

我想要的是一种像 flomo 那样的笔记流式博客。左边放热力图和标签筛选,右边是时间线式的卡片,内容分随想、文章、转载三种,暗色主题优先,还得有个记录统计面板,再加上好几种浏览和检索的方式。这些需求用现成主题很难凑得正好,所以我不打算套主题了,改成用 Vibe Coding 的方式从零搭。

技术栈

Astro 7 是核心依赖。构建时它把 Markdown 渲染成静态 HTML,首页没有任何 JavaScript 运行时开销。内容层(Content Layer)用 glob loader 加载文章,再用 Zod schema 校验 frontmatter。字段写错会在构建阶段就报错,不会等到上线才暴露。文章渲染统一走 render() API,纯静态输出。

Tailwind CSS 4@tailwindcss/typography 解决样式问题。CSS-first 配置(@theme 定义字体、@custom-variant 声明暗色 class 策略)和手写的 CSS 变量主题共存,互不干扰。Typography 插件让 Markdown 渲染出来的 HTML 自动获得排版样式,不用手写每个 h1pcode 的样式。

Shiki 处理代码高亮。配置 { dark: 'github-dark', light: 'github-light' } 双主题后,构建时会同时输出两套颜色变量,前端通过 CSS 变量切换。

部署方案走的是双仓库架构。源码仓库 rd-zzzz/Blog(私有)放博客源文件,Pages 仓库 rd-zzzz/rd-zzzz.github.io(公开)只放构建后的静态文件。每次 push 到 main 分支,GitHub Actions 自动跑 npm ciastro check(类型门禁)→ astro build → 推送静态文件到 Pages 仓库,整个流程不需要人插手。

架构设计

src/
├── content.config.ts       # 内容集合定义(glob loader + Zod schema)
├── content/posts/          # Markdown 文章(40 篇)
├── pages/
│   ├── index.astro         # 首页时间线
│   ├── about.astro         # 关于页(全站访问量)
│   ├── posts/[slug].astro  # 文章详情页(含旧 URL 重定向)
│   └── rss.xml.ts          # RSS
├── components/
│   ├── Sidebar.astro       # 侧边栏主组件(数据处理 + 交互逻辑)
│   ├── Heatmap.astro       # 12 周热力图
│   ├── StatPanel.astro     # 统计面板(月/年/总三视图)
│   ├── FilterDropdown.astro # 筛选下拉菜单
│   ├── TagList.astro       # 标签列表
│   ├── DateScrubber.astro  # 右侧日期导航条
│   ├── SEO.astro           # OG / Twitter meta
│   └── ThemeToggle.astro   # 暗色/亮色切换
├── layouts/
│   ├── HomeLayout.astro    # 首页布局
│   └── BaseLayout.astro    # 文章详情布局
└── styles/
    ├── global.css          # CSS 变量主题 + Tailwind 4 配置
    └── sidebar.css         # 侧边栏样式

侧边栏由 8 个职责单一的组件构成,每个组件通过 props 传递数据,职责清晰。

三种内容类型

frontmatter 里的 type 字段区分 note(随想)、article(文章)、repost(转载)。这个字段直接决定了首页怎么渲染。

随想在首页就直接渲染全文,因为它们通常很短,没必要按需加载。文章和转载只摆标题和摘要,点”展开全文”之后,脚本通过 AJAX 去详情页把内容抓回来注入。这套做法让首页 HTML 稳定在 200 KB 上下。

CSS 变量驱动主题

所有颜色定义为 CSS 变量(--bg--card-bg--text-emphasize 等),暗色为默认值,亮色通过 html:not(.dark) 覆盖。主题切换只需在 <html> 上 toggle dark class,偏好存储在 localStorage,多标签页通过 storage 事件同步。Tailwind 的 dark: 变体用 @custom-variant 声明为同一 class 策略,两套系统指向同一个开关。

字号系统用了差不多的思路。正文相关的元素用 rem 单位,跟着根字号等比缩放。UI 元素(按钮、标签、导航条)保持 px 不变。这样用户调大字号时,正文会变大,界面却不会跟着发肿。

核心实现

首页数据流

构建时,getCollection('posts') 获取所有文章,过滤 draft: true,按 pinned 优先加 date 降序排序。随想类型调用 render() 预渲染为 HTML 直接嵌入。文章和转载的正文容器留空,等待客户端 AJAX 填充。

运行时,页面加载完会做两件初始化工作。一是检测随想卡片的 scrollHeight,超过阈值(大约 9 行高度)的就自动折叠,加上渐变遮罩,字体加载完或者滚动时都会重新测量。二是初始化分页,每页 10 条,超出的部分先藏起来。

筛选逻辑用了一套统一状态模型。搜索、标签、类型、日期这四类条件共享同一个状态源,任何条件一变,就对全部条件求交集再重算显示。这样能避开一个老毛病。多套筛选逻辑各自覆盖 display,选了标签再搜索,状态就丢了,这类问题不会再出现。

AJAX 按需加载

用户点”展开全文”的时候,脚本先去 contentCache(一个 Map<string, string>)里查。有缓存就直接用,没有就 fetch('/posts/{slug}/'),交给 DOMParser 解析,提取正文容器,再过 DOMPurify 净化,最后存进缓存。DOMPurify 用动态 import() 按需加载,首页一开始不下载它。内容按 1500 字符切段,先注入第一段,剩下靠”加载更多”按钮一段段追加。加载失败会留一个重试入口,再点一次就能重新拉。

详情页正文在构建时还经过 rehype-sanitize 净化,首页抓取后再过一遍 DOMPurify,两端各有一层防护。

日期导航条(DateScrubber)

这是整个项目里最核心的浏览功能。文章一多,想找某天的笔记就只能不停往下滚,这条导航条就是来解决这个问题的。

导航条在构建时由首页根据所有文章的 date 字段统计生成。前端用 rebuild() 在页面加载和每次筛选时动态创建刻度标记,按固定间距用 position: absolute 排在 track 内部。所以不管文章有多少,标记都等距排开,不会互相叠住。

涟漪效果是这样做的。光标停在导航条区域里,脚本找到最近的刻度,以它为中心,左右各 3 个刻度按距离衰减设置 data-factor 属性(0 到 100)。CSS 用 data-factor 属性选择器控制宽度和颜色。最近的刻度宽度翻倍,从 8px 变成 16px,最远的衰减到 1.2 倍。颜色从灰色插值到品牌绿,插值色取自 CSS 变量,亮色和暗色主题各一套。tooltip 用单个 position: fixed 元素,悬浮时定在光标左侧,通过 findNearest 函数匹配最近的刻度。这样光标滑在两个刻度之间时,tooltip 也不会断。

点击刻度后,脚本计算目标文章的精确位置(加上顶栏高度偏移),再检查文章是不是还卡在分页折叠状态。如果是,先展开足够的分页让目标文章露出来,再平滑滚过去。导航条还会跟着主页滚动同步高亮当前位置(rAF 节流,避免滚动时全量重排),滚动高亮和分页展开状态始终对得上。

统计面板与年视图动效(StatPanel)

记录统计是一个全屏遮罩模态面板,里面可以切三个视图。

  • 月视图,12 个月份卡片加一张日历热力图,展示每月的笔记密度。
  • 年视图,三张独立的柱状图(笔记数 blue / 字数 green / 活跃天数 red),各 12 列按月份对齐,下方附带月度热力图网格。
  • 总视图,若干关键指标卡片,比如总笔记数、总标签数、记录跨度天数。

年视图有两处动效设计。

入场动效。切到「年」视图时,三个柱状图的数据从 0 平滑涨到各自的当前值。柱高由 CSS 变量 --pct 驱动,即使没有 JS 也能显示正确高度。切换时先瞬时归零并强制回流,再用 requestAnimationFrame 统一从 0 涨到目标高度,并按列错峰(transitionDelay),让三张图同步呈”列状”上扬。

悬浮高亮加 tooltip。光标停在某个月份的柱子上,白色半透明高亮块会包住那张图里对应的整月列。同时弹出 tooltip 显示那个月的数据,只显示被悬浮图表自己的指标,零值也会高亮并标出 0。高亮按光标的 x 坐标去命中月份列,所以鼠标沿月份横向滑过去时,高亮和数据会持续刷新。

随机漫步

侧边栏底部有个「随机漫步」按钮,是逛长博客时的轻松入口。点一下,脚本先把所有筛选重置(类型、标签、搜索回到全部,分页复位),再从全站笔记里随机抽一篇,复用 DateScrubber 的滚动逻辑平滑滚过去,并且只展开目标所在的分页,不会一次性把隐藏的文章全摊开。

为了让它在任何环境下都能用,滚动逻辑会自动判断真正的滚动容器。桌面端由 .main-content 自己滚,移动端整页交给 window 滚,两边行为一致。点击的那一瞬,按钮文字变成”漫步中…”作为反馈。

Shiki 双主题

Shiki 配置成 { dark: 'github-dark', light: 'github-light' } 之后,构建时会在每个 <pre class="astro-code"> 上同时输出两套颜色。亮色主题的 colorbackground-color 作为内联样式,暗色主题的 --shiki-dark--shiki-dark-bg 作为 CSS 变量。

再用一组带 !important 的 CSS 覆盖规则,在 html.dark 下用 CSS 变量压住内联样式的颜色,完成暗亮主题的切换。

html.dark .astro-code {
  background-color: var(--shiki-dark-bg) !important;
}
html.dark .astro-code .line,
html.dark .astro-code .line span {
  color: var(--shiki-dark) !important;
}

管理后台

admin/ 目录下有一个纯前端的管理后台,基于 File System Access API 操作本地 src/content/posts/ 目录下的 Markdown 文件。支持创建、编辑、删除文章,上传图片,编辑器为本地化的 Vditor(离线可用)。编辑保存会保留 frontmatter 中未知的自定义字段与原始顺序,含引号或换行的标题和描述会被正确转义,不会产出破坏构建的无效 YAML。

后台配套一个本地 Python API 服务器(静态文件服务加 git 推送)。推送前会校验请求来源(防止任意网页跨站触发提交),还会检测本地是否落后远程,避免产生推不上去的提交。这个后台只在本地用,不部署到线上,所以放在 .gitignore 里排除掉。

工程细节

有几处决策容易被忽略,却很影响长期维护。文章 URL 用不带 .md 后缀的规范路径,旧链接自动生成重定向页,外链和 RSS 订阅都不会失效。访问量统计由 Cloudflare Workers 加 KV 提供,按 IP 加日期去重,管理端点用 Bearer token 鉴权,token 走 wrangler secret 注入,不进代码库。CI 在部署前跑 astro check 类型门禁,客户端脚本的类型错误没法悄悄上线。字体做了自托管(Inter + Noto Sans SC + Noto Serif SC),离线和隐私都有保障。

Vibe Coding 工作流

用量 1

整个项目是全程 AI 辅助完成的。工作流大致是这样。

需求描述,AI 生成初始代码,本地预览,发现问题,把截图或描述丢给 AI,AI 修复,再预览,再反馈。这是一个很快的循环。人负责在每一步判断”对不对”,AI 负责把判断落成代码。

这个过程里,人的角色从”写代码”变成了”审代码”和”定方向”。AI 管实现细节,人管产品判断。比如这个折叠阈值合不合适,这个动画时长自不自然,这个颜色该亮到什么程度。

写代码本身不怎么耗时间,真正耗时间的是那些”判断什么是对的”的取舍。这个动画该持续多久,这个交互该在哪里停,这个导航条该撑满整屏还是紧凑居中。这些判断始终是人来做。

写代码的方式在变,但知道自己要什么,这件事始终没变。