1. “Caveman”这个名字是怎么来的:从一个折腾到不想折腾的故事说起
先说点背景。这个项目的代号叫“caveman”,起因挺朴素——我手里有一批老旧的笔记、文章草稿、书签还有零散的想法,散落在本地文件夹、云笔记、收藏夹和社交平台的“稍后读”里,找起来极度痛苦。市面上确实有一堆个人知识管理工具,Notion、Obsidian、语雀,功能很全,但用下来总有那么几个点让我不舒服:一是启动慢,动不动一个工作区几百兆,打开要等;二是依赖太重,想从一个平台迁走的时候,导出格式五花八门;三是编辑体验和本地文件隔离,心里没底。
于是我想,能不能做一个纯静态的个人数字仓库,所有内容就是Markdown文件,放在本地目录里,用浏览器打开就能看、能搜、能顺手归档?不需要数据库,不需要构建框架的复杂依赖,最好整个项目连node_modules一起打包都能控制在几MB以内,塞进U盘带走。这个感觉就很“caveman”——原始、简单、不讲究花哨,但管用。
项目做完之后我把它整理成了一个可复用的开源骨架,你要是也有“内容散得到处都是、想归拢到一个极简工具里”的需求,这篇文章会很有参考价值。里面包含完整的项目结构、核心代码、选型思路、构建配置,还有我实际踩过的坑。适合对前端有一点基础、但不想被复杂工程化流程劝退的开发者,也适合纯粹想给自己搭一个“数字洞穴”的非程序员——因为我给的方案里,改内容不需要写代码,改文件就行。
先说结论:这个小项目最终实现的形态是——一个基于Vite + Vue 3 + TypeScript的纯静态个人知识库站点,内容全部由Markdown文件驱动,支持全文搜索、标签筛选、随机翻页、主题切换(原始洞穴风和现代纸面风),构建产物是一个纯静态目录,可以扔到任意静态托管平台或者直接用本地浏览器打开。整个项目跑起来的依赖安装体积不到50MB,生产构建后全部文件加起来900KB左右,加载速度基本是秒开。
2. 目标定清楚:做一个能塞进U盘带走的个人主页
2.1 需求拆解:到底解决什么问题
这个项目启动之前,我把自己最烦的几个场景列了一下:
- 想找一篇以前写的东西,只知道大概几个词,在文件堆里翻了二十分钟没找到
- 换了电脑,笔记软件还在不在都不确定,更别提里面的内容
- 想分享给朋友看,要么得截图,要么发一个在线链接,但我不太想总是依赖某个在线平台
- 我看过的好文章、好工具链接,收藏完就吃灰,再也没有系统性回顾过
- 本地图片、附件、灵感碎片,散落各处,没有一个统一的收纳逻辑
所以我给“caveman”定的需求很简单:一个文件夹 = 一个站点,内容即文件,文件即内容。
2.2 为什么是Markdown而不是数据库
这是核心决策。我自己在本地存过若干年资料,用过的工具都不少,但最后的经验是:文本文件的寿命最长。数据库可能坏,软件可能关停,平台可能改版,但一个note.md放在那里,十年后打开还是能读。
Markdown还有一个隐藏优势:可以被无数工具解析,容易被搜索引擎收录,方便用脚本批处理,和“AI时代的接口”也天然友好——因为LLM处理文本的效率远高于处理专有格式。所以整个项目的内容层锁定为Markdown文件,这一点从始至终没有动摇过。
2.3 功能边界:什么该做,什么坚决不做
项目控制范围也很重要。我列了一个“能做的”和“不做的”清单:
| 该做 | 不该做 |
|---|---|
| 本地目录扫描、索引 | 依赖后端数据库 |
| 纯前端全文搜索 | 需要登录鉴权 |
| 标签分类和聚合 | 多人实时协同 |
| 响应式布局(手机能看) | 复杂富文本编辑器内嵌 |
| 可配置的站点信息 | 插件生态(YAGNI原则) |
善良一点说:在没有后端的情况下,能做好的事情做得足够好,比什么都想加、最后成个四不像强得多。
3. 技术选型里最关键的三个决定和背后的理由
3.1 构建工具:Vite,理由不是“快”而是“省心”
这个项目里Vite的作用不仅是构建,更关键是它的import.meta.glob能力——可以在构建时把指定目录下所有Markdown文件自动注册成模块。这让我可以完全不需要手写路由表,文件夹里新增一篇文章,构建时自动打包进去。
我用的是一个显式的“简单认知模型”:Markdown文件 = 页面节点。目录结构就是站点结构。文件名就是路由名。这比配置数据库记录、手填URL都直白得多。
Vite的import.meta.glob在构建时会把所有匹配的文件读为原始字符串,配合raw后缀导入,配合gray-matter解析前置元数据,就是一套完整的“无路由配置”机制。这个模式强烈建议在小型内容站项目里试试,体验完全不同。
3.2 渲染方案:marked + DOMPurify
Markdown渲染库可选很多,markdown-it也好,remark系列也好,但我最后选了marked,原因是它足够轻量、API简单、生态成熟,配合DOMPurify做XSS过滤。为什么需要DOMPurify?因为知识库里的内容来源复杂,可能包含从网页里复制粘贴的片段、含HTML标签的图片、甚至是别人发给你的带样式代码片段。从未经过滤的Markdown渲染出HTML,等于把XSS漏洞开在自家大门口。我见过不少静态站因为图省事跳过了净化这一步,后来出事的也不在少数。
实操时要注意的是顺序:先让marked把Markdown转成HTML字符串,再用DOMPurify净化,最后再插入DOM。反过来的话(先净化Markdown再渲染)很多HTML标签会被双重处理,导致渲染异常。
3.3 前端框架:Vue 3的取舍思路
为什么用Vue而不是React或者Svelte?坦白说,React也行,Svelte甚至更轻。但我在这个项目里特别看重组合式API对“按需加载”的友好度,而且Vue单文件组件的编写体验对一个“内容多、交互少”的站点来说刚好适配——大部分页面是静态渲染,少数交互点用ref/reactive就解决了,不需要引入状态管理库。Vue在这类场景下的心智负担是最低的。
至于Tailwind和组件库,我选择了“禁用”。原因不是它们不好,而是这个项目的定制化程度不高,基础样式不到300行CSS就能搞定,如果为了一个侧边栏和按钮去引一套原子类体系,违背了“caveman”的初衷。一个原则:凡是不加就能做到的,一律不加。
4. 从零搭建核心功能:文件驱动路由、全文搜索、主题切换
4.1 项目目录结构设计
先看最终的项目骨架,后面所有功能都挂在这棵树上:
caveman/ ├── index.html ├── package.json ├── vite.config.ts ├── tsconfig.json ├── src/ │ ├── main.ts │ ├── App.vue │ ├── styles/ │ │ ├── base.css │ │ ├── theme-cave.css │ │ └── theme-paper.css │ ├── utils/ │ │ ├── markdown.ts │ │ └── search.ts │ ├── components/ │ │ ├── SearchBox.vue │ │ ├── ArticleCard.vue │ │ └── TagFilter.vue │ └── views/ │ ├── HomeView.vue │ ├── ArticleView.vue │ └── TagView.vue └── content/ ├── index.md └── posts/ ├── hello-world.md ├── markdown-guide.md └── ...content/目录是真正的内容仓库,所有写作都在这里。src/目录只负责“怎么展示”,和内容完全解耦。这个分层方式让我在写博文的时候根本不用担心代码逻辑出问题,在改代码的时候也更安全,保证不会误伤内容。
4.2 核心魔法:import.meta.glob实现文件驱动路由
这个功能是整个项目的灵魂。在Vite里,我能用一行代码扫描content/下的所有Markdown文件。为了让Vue组件能拿到这些文件内容,我在vite.config.ts里配置了一个虚拟模块插件,思路是:构建时遍历content目录,生成一个包含所有文章元信息和原始内容的模块,供前端直接调用。
如果你没时间看完整源码,记住这段思路就够用:
- 用Node的
fs.readdirSync递归扫描content/下的所有.md文件 - 用
gray-matter解析每个文件的前置元数据(标题、标签、日期等) - 以文件路径为路由名,把内容和元数据组合成数组
- 通过Vite插件暴露成虚拟模块,前端直接
import
这个设计解决了内容和代码分离的问题:你加文件,路由自动多一页;删文件,页面自动消失。完全不需要在代码里注册任何东西。
为方便你判断这个思路的可行性,我把关键的虚拟模块插件骨架贴出来:
// vite-plugin-content.ts import { Plugin } from 'vite' import fs from 'node:fs' import path from 'node:path' export function contentPlugin(contentDir: string): Plugin { return { name: 'caveman-content', resolveId(id) { if (id === 'virtual:content') return '\0virtual:content' }, load(id) { if (id !== '\0virtual:content') return const files = walk(contentDir) const posts = files.map(file => { const raw = fs.readFileSync(file, 'utf-8') // 实际项目里在这里解析 gray-matter return { route: '/' + path.relative(contentDir, file).replace(/\.md$/, ''), raw, } }) return `export const posts = ${JSON.stringify(posts)}` } } } function walk(dir: string): string[] { const results: string[] = [] for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name) if (entry.isDirectory()) results.push(...walk(full)) else if (entry.name.endsWith('.md')) results.push(full) } return results }然后在main.ts里,路由表是根据virtual:content动态生成的:
import { createRouter, createWebHashHistory } from 'vue-router' import { posts } from 'virtual:content' const routes = [ { path: '/', component: HomeView }, ...posts.map(post => ({ path: post.route, component: ArticleView, props: { raw: post.raw } })) ] const router = createRouter({ history: createWebHashHistory(), routes, })注意这里用的是createWebHashHistory而不是createWebHistory。原因很现实:这个站点最常用的打开方式可能是直接双击index.html——用浏览器从本地文件系统打开。 hash模式可以完美支持file协议,而history模式需要服务器配合rewrite,在静态托管和本地文件两种场景下都不够稳。这个决策是为兼容“塞进U盘带走”这个原始需求而做的。
4.3 全文搜索:不用Elasticsearch也能做到“够用”
很多人一听全文搜索就想到Elasticsearch、Meilisearch这类重型工具,但在这个项目里,全部文章加起来也就几千字到几万字,搜索引擎启动的代价远大于收益。前端单机搜索绰绰有余。
我的实现分三层:
第一层:索引。构建时将每篇文章标题、标签、摘要、正文文本都提取出来,生成一个轻量JSON索引。这一步在虚拟模块里和posts一起返回。
第二层:输入匹配。监听用户输入,做分词和打分。我写了一个极简的匹配器,实现核心是关键词匹配 + TF权重,加上标题、标签加权。
第三层:结果渲染。搜到的结果以卡片列表展示,每项显示标题、摘要、匹配标签、命中片段。点击或回车进入对应文章。
如果不想自己造轮子,用fuse.js也可以,整个库大约10KB gzip,足够处理个人站点的搜索需求。但自己动手实现一遍的好处是能完全控制匹配逻辑——比如中文分词、同义词、忽略常见虚词这些细节,第三方库不一定能贴合你的内容特点。
4.4 主题切换:把“洞穴感”落到样式上
这个项目叫caveman,视觉上当然得有点洞穴的气息。我做两套主题:
theme-cave.css:深色背景、低饱和暖色调、大间距、模拟火光照明感,阅读区字体稍大theme-paper.css:纯白背景、墨色文字、衬线标题,偏向纸质出版物的阅读体验
主题切换的实现不要用复杂的CSS变量方案吗?恰恰相反,我用的是非常老派的做法:在<html>标签上加>:root[data-theme="cave"] { --bg: #1e1e1e; --fg: #c9b99a; --accent: #d4704c; --block-bg: #2a2a2a; --border: #4a3b32; } :root[data-theme="paper"] { --bg: #fafaf8; --fg: #333333; --accent: #a04b2f; --block-bg: #ffffff; --border: #e0ddd5; }
组件里只写var(--bg)这类引用,主题切换就是改一个>export default defineConfig({ base: './', build: { outDir: 'dist', assetsInlineLimit: 4096, chunkSizeWarningLimit: 600, }, plugins: [contentPlugin('content')], })
base: './':保证assets引用是相对路径,这样dist目录切到任何位置、任何域名子路径都能直接跑,不需要改代码。这是支持“塞进U盘带走”的关键配置。assetsInlineLimit: 4096:4KB以下的静态资源直接base64内联,减少请求数。4KB以上的走独立文件,避免包体膨胀。chunkSizeWarningLimit: 600:Vite默认在chunk超过500KB时报警,把限制调高一点,避免因为highlight.js的体积产生误报警告。
5.2 一条龙脚本
package.json里维护了四个脚本:
{ "scripts": { "dev": "vite", "build": "vue-tsc --noEmit && vite build", "preview": "vite preview", "deploy": "npm run build && sh deploy.sh" } }build前先跑vue-tsc --noEmit做类型检查,帮助我在编译前抓出低级错误,比如路由参数类型不对、props漏传等。deploy脚本就是一行rsync,把dist/同步到服务器或对象存储的静态目录,就不展开说了。
5.3 实际产物体积参考
以一个包含12篇文章、5个标签、若干代码块的真实项目构建为例:
| 产物 | 体积 |
|---|---|
| index.html | 0.6KB |
| 主JS(gzip) | 89KB |
| 主CSS(gzip) | 12KB |
| 图片等静态资源 | 约780KB |
| 合计 | 约900KB |
整个dist目录转成压缩包不到400KB,拷到U盘、放到网盘里毫无压力。对比我以前的某个方案:光是依赖就有250MB,构建产物8MB起步,体验高下立判。
6. 排错实战:我在开发中踩过的坑和修复思路
6.1 虚拟模块的加载顺序问题
第一次接入Vite虚拟模块时,遇到一个很典型的报错:[vite] Internal server error: Failed to resolve import "virtual:content"。
排查链路是这么走的:
- 确认插件在
plugins数组里且位置正确(在vue插件之前) - 确认
resolveId返回的是\0virtual:content(带NULL前缀是Vite内部约定,告诉Vite这个ID是虚拟的,不要走文件系统) - 发现
load方法中id参数拿到的值里没有\0,而我的判断条件是id === 'virtual:content',对不上
找到根因后发现其实是自己把两个ID搞混了:resolveId返回值是\0virtual:content,Vite内部处理时会去掉\0再传给load的load钩子,所以load里应该判断id === 'virtual:content',而不是包含\0的版本。这个算是Vite插件开发的小知识点,记下来免得再犯。
同时留意下桌面编辑器或IDE的TypeScript报错滚动:虚拟模块没有对应的.d.ts声明文件,在tsc类型检查阶段会报“Cannot find module”,解决办法是在项目里加一个env.d.ts:
declare module 'virtual:content' { export interface Post { route: string raw: string title: string tags: string[] date: string } export const posts: Post[] }6.2 中文文件名和路由映射的编码坑
文件系统里用中文文件名管理内容很自然:关于-前端工程化的思考.md,但直接把它转成URL时会遇到编码问题。起初我直接用文件名做路由,在开发环境一切正常,但构建部署到服务器后,访问带中文URL的页面刷新就404或乱码。
原因很明确:有些静态托管服务器对非ASCII URL的解析依赖反向代理的转码,配置不当就会失灵。最后我的处理方案是:
- 路由里只用拼音或英文短横线命名,并在Markdown元数据里用
title字段声明中文标题 - 保留原文件名为中文(方便写作时识别),但路由映射重写为英文别名
前端展示层永远从title字段读取真实标题,菜单、面包屑、搜索索引都走元数据,而不是文件名。这一修改之后,所有部署场景都稳定了。
6.3 搜索索引构建时的大小写敏感问题
搜索功能第一次测试时,输入Vue能搜到内容,输入vue就搜不到。调试之后发现是索引构建时把原文和查询词都原样保留,导致大小写严格匹配,中文影响不大,但英文内容会被误判。
修复很直接:在索引阶段把文本统一转为小写,搜索时查询词也做同样处理。同时给英文做了去单词结尾的轻量处理。这一层做完,搜“markdown”和“MarkDown”效果完全一致。
6.4 前后端目录同步:删除文章后路由仍被注册
使用过程中我还发现一个现象:从content/posts/删掉一个文件以后,本地开发环境刷新页面还是会偶尔看到那篇文章。原因是Vite的HMR(热更新)对由Node文件系统驱动扫描的虚拟模块,默认不会触发重新执行load。
解决方法是在Vite插件的watchChange钩子里手动触发模块重新加载:
watchChange() { const mod = this.getModuleInfo('\0virtual:content') if (mod) this.addWatchFile(/* contentDir */) }这里稍微啰嗦一句:Vite的watch机制在处理由插件生成的模块时,不一定能自动感知底层文件变化。需要手动把content目录加为监听范围,并让模块主动失效重载。这个小坑在数据文件驱动站点时经常出现。
7. 进阶玩法:这个骨架还能长出什么
如果你觉得这个caveman骨架已经够用,可以就此打住。但如果你跟我一样,喜欢把地基打好后不断在上面长新东西,下面几个方向值得想想。
7.1 支持图床附件管理
Markdown里的图片路径,如果全站都在本地目录,久了图会散。我在content/assets/下建了一个按时间分层的目录,约定视图代码里写,构建时统一把它映射到dist/assets/。这样图片和文字可以分开备份,但也有的别——如果你要长期维护,可以考虑在构建时自动对图片做压缩和WebP转换,能明显省空间。
7.2 自动生成站点地图和RSS
一个只有本地文件的知识库,如果想到了某天分享出去,建议加RSS输出。静态站点的RSS很简单:构建时把所有Markdown文件的标题、日期、摘要、链接拼成XML字符串。我的做法是再写一个简单的Node脚本挂在build之后,顺便输出sitemap.xml。这样发布出去就能被订阅器抓取。
7.3 让Markdown支持Mermaid以外的小型交互组件
严格说Mermaid是个大的依赖,在页面里渲染思维导图和流程图,会让静态HTML的体积快速增长。我试过两条路:一是用markdown-it插件把```mermaid代码块替换为占位Div,然后引入独立的Mermaid渲染脚本按需加载,但体积不太理想;二是干脆用纯SVG/CSS做极简单的图形化表达,多数情况下够用。结论是没有完美的解法,看你对“丰富表达”和“轻量产物”的权重怎么分。
7.4 数据上报和阅读量统计
如果你想在某天分享给更多人看,但又不想接入Google Analytics这种重型服务,可以在构建时生成一个静态JSON文件,用fetch上报一个极简的计数接口(后端随便拿一个云函数都行),前端只负责显示。这个量级的个人站,不需要复杂的埋点体系。
7.5 和AI知识库打通
这是我自己最近在探索的方向:把content/目录的直接内容作为“知识文本”,配合一个本地向量库做语义检索。因为内容全部是纯文本Markdown,切分非常干净。在这个基础上,可以做一个简单的问答页面:“你问我答,答案来自你的笔记”。目前我能看出来这个方向有潜力,把“caveman”从个人静态仓库升级成“个人第二大脑前端”,后面会单独写一篇分享。
8. 核心文件源码精读:一行一行的取舍
8.1 main.ts:入口和依赖注入的轻量处理
import { createApp } from 'vue' import { createRouter, createWebHashHistory } from 'vue-router' import App from './App.vue' import './styles/base.css' import { posts } from 'virtual:content' import HomeView from './views/HomeView.vue' import ArticleView from './views/ArticleView.vue' const routes = [ { path: '/', component: HomeView }, ...posts.map((post) => ({ path: post.route, component: ArticleView, props: () => ({ post }), })), ] const router = createRouter({ history: createWebHashHistory(), routes, }) createApp(App).use(router).mount('#app')这段核心逻辑值得注意的点:
props: () => ({ post }):我用函数形式的props是为了避免post对象被Vue转成reactive代理,导致每次查询都多一层代理。如果直接用props: { post },post会被DeepReadonly包一层,在某些深拷贝或结构化打开的场景会有性能开销。
设计取舍:这个入口里的routes数组完全由virtual:content驱动,这意味着即便你只有一篇文章,文件驱动路由也能跑通。
8.2 SearchBox.vue:输入、防抖、搜索、结果展示
搜索组件是交互最重的部分,我贴一下核心逻辑:
<script setup lang="ts"> import { ref, computed } from 'vue' import { posts } from 'virtual:content' import { searchIndex } from '../utils/search' const query = ref('') const results = computed(() => query.value ? searchIndex(query.value) : []) function handleInput() { // 简单防抖由v-model配合computed天然完成了,不用额外setTimeout } </script>搜索索引在utils/search里,用了一个简单的评分函数:标题出现关键词给5分,标签出现给3分,正文出现给1分。排序按得分降序。这个“标题>标签>正文”的加权策略很符合人对搜索结果的相关性直觉——标题匹配往往比正文片段稍微出现有代表性得多。
8.3 样式设计:如何只用300行CSS撑起整个站点的美感
没有使用Tailwind之后,我用最基础的方式实现排版和布局:
- 全局定义
--gap、--radius、--font-size等基础变量 - 布局用Flex/Gird 盒模型,避免用负margin或position做奇怪的事
- 所有颜色走
--bg、--fg、--accent这套语义变量,让两套主题无缝切换
配色上的心得是:洞穴风的重点不是“黑”,而是“低饱和、暖棕、暗光”。纯黑背景加白字在长时间阅读时特别累。我选的是#1e1e1e底、#c9b99a字,介于红棕和米黄之间,模拟火光照在岩壁上的视觉——这种对比度大约在7:1左右,符合WCAG AA级别对正文对比度的要求,同时看着不刺眼。
8.4 数据流管理:为什么连Pinia都没用
这个站点所有状态加起来就3个:当前主题、搜索关键词、当前路由。这三者在Vue 3的组合式API里用ref就能表达得清清楚楚,引入Pinia徒增约5KB体积。
如果未来要加评论、阅读历史、多用户数据,那再考虑状态管理库不迟。原则依然是:不加不做。
9. 手把手从零拉一个caveman实例(15分钟跑通)
如果你看完前面很心动但想先动手试试,下面这组命令可以确保在15分钟内得到一个能用的caveman站点。
9.1 初始化项目
mkdir caveman-demo && cd caveman-demo npm create vite@latest . -- --template vue-ts npm install这时你得到的还是一个Vite初始模板,接下来装上核心依赖:
npm install marked gray-matter dompurify highlight.js npm install -D @types/dompurify9.2 接入content目录和虚拟模块
在项目根目录建content/posts/,放一篇文章测试:
--- title: Hello, Caveman date: 2025-02-15 tags: [startup] --- Welcome to the cave. This site is powered by **markdown only**.然后把前面贴过的vite-plugin-content.ts放进plugins/目录,并在vite.config.ts里启用。代码上注意新增env.d.ts声明模块。改完以后,你的App.vue里可以先用最简单的方式渲染:
<script setup lang="ts"> import { posts } from 'virtual:content' </script> <template> <h1>Caveman</h1> <ul> <li v-for="post in posts" :key="post.route"> <a :href="post.route">{{ post.title }}</a> </li> </ul> </template>npm run dev跑到这里应该能看到文章列表了,说明文件驱动路由已经生效。
9.3 部署:把dist扔到任意静态托管
构建并检查产物:
npm run build cd dist python3 -m http.server 8080浏览器访问http://localhost:8080,切几个页面试试没问题。
之后部署到实际空间,直接上传dist/里的内容到任意静态文件服务(对象存储、GitHub Pages、Nginx静态目录均可)。因为base: './',放到二级目录也能正常加载资源。
9.4 关于文章元数据的一个统一认知
需要统一认知的是元数据字段设计。我目前用的字段是:
title:文章标题(必填)date:发布日期(必填,格式YYYY-MM-DD)tags:逗号分隔的标签列表(可空,用于聚合)summary:摘要(可选,默认取正文前80字)aliases:别名路由(可选,为旧链接重定向)
不要在一开始就设计十几个冷门字段,经验是:字段越多,写东西越累,越不想写。四个核心字段已经能支撑起一个干净的知识库,更多的功能(比如封面图、阅读量、自定义布局)完全可以通过content/index.md里的约定性字段来承载。
10. 关于caveman,我最后想说的几句话
当你厌倦了“打开一个工具还得先理解工具的复杂心智模型”时,做点原始的东西挺好的。这个项目带我回到了一种很直接的方法论:你的内容是一堆文件,你的工具只解决两件事——如何展示它们,如何找到它们。仅此而已。
从开发至今,我实际使用这个站点存了大量笔记、书签、想法片段。最让我开心的时刻不是功能跑通,而是有一次我从旧笔记软件导出一堆乱序文本,把它丢进content/posts/,然后打开浏览器看到——所有内容都自动归位了,标签筛得清清楚楚,全文搜索响应在毫秒级。那一刻我觉得这项目值得分享出来。
如果你也想搭一个自己的caveman,以下几个建议是很有价值的:
- 内容先行。先把你的Markdown整理好,再谈技术方案。技术永远应该服务于内容管理,而不是反过来。
- 能本地跑就不上服务器。能静态托管就不上后端。这不是懒,是运维成本的清醒。
- 备份靠Git就行。
content/目录整个提交到Git仓库,换电脑克隆一下,直接满血复活。 - 不要指望第一版就完美。我的第一版连搜索都没有,就是一个文件列表页面,后来才逐渐长出标签、搜索、主题切换。渐进式迭代比一次性设计更持久。
最后再分享一个小技巧:我现在每周抽十五分钟,把这一周读到的值得留存的网页用浏览器插件一键抓成Markdown丢进content目录。这样的维护成本几乎为零,但积累速度意外地快。一年多下来,这个“洞穴”里已经攒了三百多篇结构化文档,而我的所有维护动作就是:复制、粘贴、偶尔重命名。这大概就是“caveman”这个名字最合适的注脚——返璞归真,把工具的复杂度降到最低,把内容的价值提到最高。