Yoshen's Blog

这个博客是怎么搭起来的

5 分钟技术向项目

这个博客是怎么搭起来的

这篇文章写的是 yoshen.me 的技术骨架:一个 Next.js 16 纯静态博客, 没有评论、数据库、管理后台和追踪脚本——写 Markdown,push,一分钟内上线并被搜索引擎收录。 只讲决策与事实,不讲 Next.js 教程。

现状

  • 内容:4 篇文章、3 个标签,影音、作品集、Now、吉祥物园等板块全部由数据文件驱动;
  • 形态:纯静态导出(output: "export"),线上零服务端进程;
  • 部署:push 后 GitHub Actions 跑门禁;主站(AWS Lightsail + Caddy)另跑「一键发布」更新, 备线 Vercel 自动部署;同一产物同时上传 artifact,可手动托管到 EdgeOne Pages / CloudBase(不向托管平台授予仓库权限);
  • 收录:sitemap(17 个 URL)/ robots / RSS 由内容自动生成,Google 已验证并提交, Bing 验证文件就位;
  • 仓库私有,源码只在本地与 GitHub 之间流动。

一、内容管道先行

第一行业务代码不是页面,是 src/lib/content.ts:

// 内容目录:生产固定 content/posts;测试可通过 BLOG_CONTENT_DIR 注入 fixture
function postsDir(): string {
  return process.env.BLOG_CONTENT_DIR
    ? path.resolve(process.cwd(), process.env.BLOG_CONTENT_DIR)
    : path.join(process.cwd(), "content", "posts");
}

它把 content/posts/*.md 变成四件套:getAllPosts / getPostBySlug / getAllTags / getPostsByTag,页面只是这层 API 的消费者。

先让「写一篇 Markdown → 出现在列表」跑通,再画页面。数据结构由内容决定: 标签体系决定聚合逻辑,种子文章的类型(长文 / 短文 / 代码 / 表格)决定渲染器要 覆盖什么。先写页面再补内容,必然返工。

二、单一事实源

站点名、tagline、简介、URL、导航收在 siteConfig 一个对象里,其余全部派生。

收益兑现过两次:域名换过两回(从 Vercel 占位域名到自有域名 wendylala.com, 再于 2026-09 迁到 yoshen.me),每次只改一行 url, canonical、sitemap、robots、RSS 全部自动修正;简介文案迭代多轮(从「热爱学习」 到英文角色标签),同样只改一处。凡是「同一个值出现在多处」,一律抽成单一来源。

三、构建期校验:坏数据死在构建时

frontmatter 缺 title / date / 标签为空,构建直接抛错并指出文件:

[content] content/posts/tmp-invalid-no-title.md: missing or invalid required field "title"

作品集、影音数据同一哲学:实体不合法 = 构建失败,而不是上线后静默空白。

四、门禁体系:让机器守住质量

{
  "build": "tsc --noEmit && next build",
  "typecheck": "tsc --noEmit",
  "lint": "eslint",
  "test": "node --test --experimental-strip-types --test-isolation=none \"src/lib/content.test.ts\""
}

四道命令,任何一道红不许上线;node:test 8 个用例覆盖排序、draft 过滤、 _ 前缀忽略、标签聚合、校验报错;CHANGELOG.md 记录构建耗时基线。

门禁两次抓到真实问题:第一次是 React 19 新 lint 规则 set-state-in-effect (改用 useSyncExternalStore 订阅外部状态);第二次是 About 页 JSX 未转义引号 触发 react/no-unescaped-entities,CI 9 秒红,修成全角引号后转绿。 两次都说明同一件事:门禁真的在工作

五、主题与动效

  • 主题链:<head> 内联脚本在渲染前读 localStorage / 系统偏好打 dark class (防闪白)→ CSS class 策略 → 资产跟随(吉祥物两套配色交叉淡入、favicon 双版 SVG);
  • 动效语言:一条缓动 cubic-bezier(0.22, 1, 0.36, 1),几个基元 (fade-up / fade-in / fade-down / pop-in);位移缩放全走 transform, 颜色走 transition;prefers-reduced-motion 全局降级为瞬时;
  • 时长纪律:60fps 下流畅区间为 300–600ms(18–36 帧)。整页开场拉链幕取 0.65s ≈ 39 帧——再短像闪一下,再长就拖;
  • 第三方动效取舍:开场拉链幕、Hero 的「猫看花」小剧场移植自 yui540/css-animations(MIT)。选择标准:纯 CSS、能随主题色、不喧宾夺主、 尊重减动效偏好。现成的效果不值得自绘,但要在代码里署名来源;
  • 首页取舍:曾放「精选项目」区块,后移除——首页只留核心内容 (问候 → Now → 文章 → 影音),项目由导航独立承载。

六、SEO 与分享卡

  • sitemap(17 个 URL)全部由内容生成,手工零参与;Google Search Console 用 网域属性 + DNS TXT 验证(零代码),Bing 用 BingSiteAuth.xml;过时的 google*.html 验证文件事后清理,验证状态不受影响;
  • OG 卡:最终形态是构建期生成的 1200×630 静态 PNG(灰度统一风格, scripts/gen-og.mts),绝对 URL + 正确 MIME。演进过三轮:静态 PNG(修 MIME) → 时间段轮换变体套装 → 变体占用构建被移除 → 统一单卡;
  • 平台预览差异:Telegram 按 URL 强缓存、无部分回退(差一项素材整卡不显示)、 会「记住」旧的失败状态;iMessage 每次现抓、更宽松。调试统一用 curl -A "TelegramBot (like TwitterBot)" 看服务器实际返回,发布后用 @WebpageBot 强制重爬。

七、踩坑(精选)

解法 / 结论
受限环境不能 fork 子进程,Next 构建报 EPERM experimental.workerThreads 走 worker 线程 + 类型检查外置 + dev 入口进程内启动,不依赖 spawn
Turbopack dev 中文参数不解码 入口安全解码;生产为静态页面,不受影响
删路由后 typegen 残留 .next;少依赖生成物,props 用显式类型
OG 图无扩展名/平台拒收 静态文件名带 .png 约定 + 确认返回 MIME
Vercel 默认不跳 www Domains → Redirect 手动设置跳 apex
换域名后旧 URL 残留 全仓库搜索旧域名:数据、代码示例、文档一处不漏
仓库私有化 Vercel 走 GitHub App 授权,与公开/私有无关

八、可带走的清单

  1. 内容先于代码,数据结构由内容决定;
  2. 一个值只写一次,单点修改、全局派生;
  3. 坏数据死在构建时,并指出文件;
  4. 门禁先于上线——它会抓到真实 bug 来证明自己;
  5. 动效克制而一致,时长按帧数算,尊重减动效偏好;
  6. 纯静态做到头:构建期做校验、派生、分享卡,线上零服务端;
  7. 平台预览都有缓存,上线前先当 bot 自测;
  8. 发布成本趋零之后,写作本身就是产品。