这篇文章记录的是「雨墨」这个站本身的搭建过程:从一张手写原型图,到一份能直接丢给 Nginx 的静态产物。
为什么不做传统 CMS
个人技术站的内容更新频率很低,但每次更新都希望是「写一个 Markdown 文件、提交、部署」这么简单。数据库 + 后台管理界面这套组合,对一个只有作者一人的站点来说是纯粹的运维负担。
于是目标定得很清楚:
- 内容以文件形式存在 Git 里,可 diff、可回溯;
- 构建产物是纯静态文件,不需要 Node 常驻进程;
- 站内没有任何用户输入入口(无评论、无注册、无搜索后端)。
目录结构
最终落地的结构遵循 Nuxt 4 的目录约定,app/ 目录承载全部前端代码:
site/
├─ app/
│ ├─ assets/css/main.css # 设计令牌 + 全站样式
│ ├─ assets/images/hero-ink.svg # 首屏水墨底图
│ ├─ components/ # 页头/页脚/卡片/目录/回到顶部
│ ├─ composables/ # 主题、文章索引、目录高亮
│ ├─ data/site.ts # 站点名、导航、备案号等常量
│ ├─ layouts/default.vue # 页头 + 内容 + 页脚 + 回到顶部
│ └─ pages/ # 首页 / 栏目 / 文章 / 标签 / 关于
├─ content/ # Markdown 内容
├─ public/ # 静态资源(favicon 等)
└─ nuxt.config.ts
内容管线:Markdown 即数据库
Nuxt Content 负责把 content/ 下的 Markdown 变成可查询的集合。schema 用 Zod 声明,写错 frontmatter 会在构建期直接报错,而不是等到渲染时才发现字段缺失。
集合定义
export default defineContentConfig({
collections: {
content: defineCollection({
type: 'page',
source: '**/*.md',
schema: z.object({
title: z.string(),
date: z.string().optional(),
category: z.enum(['tech', 'life', 'project']).optional(),
tags: z.array(z.string()).optional(),
pinned: z.boolean().optional(),
}),
}),
},
})
frontmatter 字段约定
| 字段 | 用途 | 是否必填 |
|---|---|---|
title | 列表卡标题与文章页标题 | 是 |
description | 摘要,用于卡片与 SEO | 否 |
date | 排序依据 | 否 |
category | 决定文章归属栏目 | 否 |
tags | 标签云与相关阅读的关联依据 | 否 |
pinned | 标记精选,卡片上加印刷编号样式 | 否 |
SSG:把整站预渲染成静态文件
nitro.prerender 里显式列出栏目路由,其余文章页交给链接爬取:
nitro: {
preset: 'static',
prerender: {
crawlLinks: true,
routes: ['/', '/tech', '/life', '/projects', '/about', '/tags', '/rss.xml', '/sitemap.xml', '/robots.txt'],
},
}
构建命令是一条:
pnpm generate
产物落在 .output/public/,直接同步到服务器目录即可,不需要在服务器上装任何运行时。
设计令牌:一套变量管两套主题
样式层不引入 UI 框架,令牌集中写在 :root 与 html[data-theme="dark"] 两个作用域里,组件样式只引用变量:
:root {
--bg: #F7F4EE;
--surface: #FFFDF8;
--text: #1A1A1A;
--text-muted: #6B6558;
--accent: #C8492F;
--line: #E2DCCF;
--radius: 2px;
}
html[data-theme="dark"] {
--bg: #131313;
--surface: #1C1C1C;
--text: #E8E4DC;
--text-muted: #9A948A;
--accent: #E4694E;
--line: #2E2E2E;
}
字体走系统栈,不加载任何网络字体 —— 首屏少一次往返,也避免了字体闪烁。
主题切换不闪白
深色偏好在 localStorage 里,若只靠客户端 hydration 后切换,首屏一定闪一下浅色。解法是把一段极短的判断脚本内联进 <head>,在 CSS 生效前就把 data-theme 写到 <html> 上:
(function () {
try {
var saved = localStorage.getItem('rainink-theme')
var dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
document.documentElement.setAttribute('data-theme', saved || (dark ? 'dark' : 'light'))
} catch (e) {
document.documentElement.setAttribute('data-theme', 'light')
}
})()
try/catch 是为了无痕模式下 localStorage 抛异常时仍能兜底成浅色。
踩坑清单
string[]与string一字之差:项目陈列的stack一开始声明成字符串,写数组时构建直接失败——这是好事,schema 就是用来挡这类错误的。~/别名指向app/:Nuxt 4 里~/assets/...实际解析到app/assets/...,public/例外。- 首屏底图要内联:SVG 用
?raw导入后内联渲染,才能跟着data-theme换墨色;换成<img>就失去主题联动能力。
小结
对一个内容更新不快、样式要求却很具体的个人站来说,「Markdown 文件 + 静态产物」是维护成本最低的组合。真正花时间的不是框架接入,而是设计令牌和交互细节的打磨 —— 那部分下一篇再说。