Caveman 这个名字听起来很原始,但我要先声明:这不是一篇讲石器时代考古的文章。它是我最近写的一个极简静态博客生成器,名字就叫caveman。起因很现实——我想把平时写在 Markdown 里的笔记整理成网站发出来,但实在不想为了一个博客去装 Node 或者 Ruby,更不想折腾数据库、后台和主题市场。我需要一个能装进口袋的工具:一个命令把.md变成.html,本地双击就能预览,推上服务器就能访问。caveman就是干这个的。如果你也厌倦了动不动就上千个依赖的博客框架,或者只想安安静静在 Markdown 里写点东西,这篇文章应该能给你一些参考。
1. 项目定位与设计思路
1.1 为什么叫 Caveman
取名caveman,本质上是在自嘲,也是在表达一种取舍。洞穴人的生活很简单:生火、打猎、吃饭、睡觉,没有多余的家当。我想要的博客生成器也是这个调性:不需要数据库,不需要 Node_modules,不需要可视化后台,甚至不需要网络就可以完成全站构建。写博客应该像洞穴人刻壁画一样直接,打开编辑器写 Markdown,运行一条命令,剩下的就是 HTML 文件。
这个名字也提醒我:功能不是越多越好。市面上的静态站点生成器多的是,Hugo、Hexo、Jekyll 都很成熟,但随之而来的是主题生态、插件系统、依赖管理、构建缓存。它们很强,但也让人心累。caveman选择了另一条路:把一切都砍到只剩骨头,保留写作和发布这两件最重要的事。说白了,不是做不出复杂功能,而是不想做。一个工具如果让人开始研究“如何配置”而不是“如何写作”,它就已经跑偏了。
1.2 它能做什么,不能做什么
caveman的核心能力很好描述:读取content目录下的 Markdown 文件,按文件名里的日期排序,渲染成 HTML 页面,再生成一个首页文章列表。整套流程下来只需要一行命令python3 caveman.py。运行后会产生一个public目录,里面是静态文件,扔到 Nginx、Apache、对象存储,甚至直接挂到网盘的静态托管上都能访问。
为了把话说明白,我把能做的和刻意不做的列一下:
| 当前支持 | 暂时不支持 |
|---|---|
| 标题、段落、无序列表、行内代码、粗体、链接 | 表格、图片、嵌套列表、脚注 |
| 围栏代码块,按原样保留缩进 | 代码高亮着色 |
中文文件名、YYYY-MM-DD-标题.md自动解析 | 多作者、分类、标签归档 |
| 本地预览,支持浏览器访问 | 增量构建、文件监听热更新 |
| 单页模板,纯字符串替换 | 模板继承、变量表达式、循环语法 |
| 纯 Python 标准库实现 | 外部依赖、插件机制 |
这张表的核心意思是:caveman不是要替代 Hugo 或 Hexo,它只覆盖 80% 的写作场景。个人博客、项目文档、知识笔记,这些场景根本不需要复杂系统。有些朋友可能觉得表格和代码高亮是刚需,但实际上对于以文字为主的博客来说,最需要的是稳定、快速、可持久保存的渲染结果。而且这些缺失功能不是不能加,只是我会在后面说明为什么没有在一开始加入。
1.3 技术选型:只用标准库
很多项目一上来就选框架,选了框架后又顺手加一堆依赖,最后写出来的工具体积比产出内容还大。caveman反着来,整个项目只依赖 Python 自带的标准库:pathlib处理路径,http.server做本地预览,html做转义,re做行内样式的正则替换。不用第三方库的最大好处是零安装、零环境冲突,随便一台有 Python 3 的机器就能跑,也不用担心半年后回来发现某个依赖不再兼容。
有人说,Markdown 渲染不是应该用成熟的解析库吗?如果这是产品级需求,我完全同意。但对一个个人博客生成器来说,我只需要处理自己写的文章,而自己写的文章遵循固定的、少量的 Markdown 语法。与其引入一个庞大的解析器,不如自己写一个只有 50 行的“最小可用子集”。这样做不仅能控制每行代码的行为,还更容易排查问题。出现问题的时候,直接打开源文件看逻辑,而不是去翻一个几万行依赖库的 issue。
2. 核心模块解析与关键代码实现
2.1 目录结构与配置约定
caveman的项目结构非常简单,没有 config 文件,没有 yaml,所有配置都是约定。我喜欢这种“约定优于配置”的方式,因为减少选择本身就是在减少心智负担。默认结构如下:
caveman/ ├── caveman.py ├── content/ │ └── 2024-06-01-hello-caveman.md └── templates/ └── base.htmlcontent目录存放 Markdown 源文件,templates目录存放唯一的页面模板,public目录则由脚本自动生成。文章文件名的前 10 个字符是日期,例如2024-06-01-hello-caveman.md,中间用-分隔标题。这种设计的优势是文章顺序天然从文件名里来,不需要在正文里维护一个 metadata 块,排序直接按字符串比较即可。
如果你已经用过 Hugo 或者 Jekyll,你可能会担心 Front Matter 怎么办。我用文件名代替了 Front Matter,虽然牺牲了自定义字段的灵活性,但换来的是极致的可读性和稳定性。你在文件管理器里看到的所有文章,会自动按时间顺序排列,根本不需要打散文件内容去解释系统。这也是整个项目的核心哲学:能通过约定解决的问题,不引入配置系统。
2.2 一个“够用就好”的 Markdown 渲染器
真正写的时候,我才发现实现一个“最小 Markdown 子集”比想象中有趣。核心思路是按行遍历文本,根据行内容决定输出什么 HTML。受支持的语法包括了标题、段落、无序列表和围栏代码块。行内样式则用正则处理粗体、行内代码和链接。下面是一个精简过的版本,保留了核心逻辑:
import html import re def inline_md(text): # 转义 HTML 后再处理行内标记 text = html.escape(text) text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text) text = re.sub(r"`([^`]+?)`", r"<code>\1</code>", text) text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'<a href="\2">\1</a>', text) return text def render_markdown(text): lines = text.splitlines() out = [] in_code = False code_lines = [] in_list = False for line in lines: # 围栏代码块 if line.strip().startswith("```"): if not in_code: in_code = True code_lines = [] else: escaped_code = html.escape("\n".join(code_lines)) out.append(f"<pre><code>{escaped_code}</code></pre>") in_code = False continue if in_code: code_lines.append(line) continue # 无序列表,连续的多行会合并到一个 <ul> 中 if line.startswith("- "): if not in_list: out.append("<ul>") in_list = True out.append(f"<li>{inline_md(line[2:])}</li>") continue if in_list: out.append("</ul>") in_list = False stripped = line.strip() if stripped.startswith("### "): out.append(f"<h3>{inline_md(stripped[4:])}</h3>") elif stripped.startswith("## "): out.append(f"<h2>{inline_md(stripped[3:])}</h2>") elif stripped.startswith("# "): out.append(f"<h1>{inline_md(stripped[2:])}</h1>") elif stripped == "": out.append("") else: out.append(f"<p>{inline_md(stripped)}</p>") if in_list: out.append("</ul>") if in_code: escaped_code = html.escape("\n".join(code_lines)) out.append(f"<pre><code>{escaped_code}</code></pre>") return "\n".join(out)这里有个容易忽略的点:一定要先对原文做html.escape,再插入我们自己的标签。否则文章中出现的<script>或<img>这类字符串会被浏览器当作真实标签执行,轻则样式砸了,重则 XSS。个人博客虽然攻击价值不高,但养成了转义习惯后,以后写任何工具都会安全不少。列表处理上我也做了“连续项合并”的逻辑,这样段落中间穿插列表时不会出现一堆裸<li>,页面结构更干净。
2.3 模板替换与页面组装
页面渲染没有用 Jinja2 或任何模板引擎,而是使用最原始的字符串替换。模板文件base.html里有{{title}}、{{date}}、{{content}}这样的占位符,渲染时用字典里的值直接替换。今天的场景下这个做法看起来有点“笨”,但这正好呼应了caveman这个名字——一切从简,一眼看穿。
模板核心部分大致是这样的:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{{title}}</title> <style> body { max-width: 720px; margin: 40px auto; padding: 0 20px; font-family: -apple-system, "Segoe UI", sans-serif; line-height: 1.8; color: #333; } pre { background: #f6f8fa; padding: 16px; overflow-x: auto; } code { background: #f6f8fa; padding: 2px 4px; border-radius: 3px; } time { color: #888; margin-right: 12px; } </style> </head> <body> <header><a href="/">Caveman</a></header> <main> {{content}} </main> <footer>Generated by {{generator}}</footer> </body> </html>替换函数也很直白:
from pathlib import Path def render_template(template_path: Path, **context) -> str: tpl = template_path.read_text(encoding="utf-8") for key, value in context.items(): tpl = tpl.replace("{{" + key + "}}", value) return tpl有人会觉得,模板引擎能做的事远不止占位符替换,比如条件判断、循环、变量过滤器。没错,但在一个页面结构不变的博客系统里,这些能力根本用不上。硬要全支持反而会引入复杂的语法学习成本和解析逻辑。我第一次实现时加了循环,后来删掉了,因为我发现整个人类使用场景里只有一个循环——首页文章列表,而它在 Python 里已经拼好了。模板文件只管“长什么样”,数据组装交给脚本本身,两者职责更清晰。
2.4 构建流程与本地预览
构建部分的核心是遍历文章、渲染页面、生成索引。这里的顺序和细节会影响整个工具的使用体验,我踩过几个坑后总结出当前这套逻辑。先读取所有文章,按日期倒序排列,再逐个生成文章详情页,最后用文章元信息拼一个首页列表。
import shutil from pathlib import Path CONTENT = Path("content") PUBLIC = Path("public") TEMPLATE_FILE = Path("templates/base.html") def load_articles(): articles = [] for md in CONTENT.glob("*.md"): stem = md.stem date = stem[:10] title = stem[11:] if len(stem) > 10 else stem articles.append({ "slug": stem, "date": date, "title": title, "raw": md.read_text(encoding="utf-8"), }) articles.sort(key=lambda a: a["date"], reverse=True) return articles def build(): shutil.rmtree(PUBLIC, ignore_errors=True) PUBLIC.mkdir(parents=True) articles = load_articles() for article in articles: body = render_markdown(article["raw"]) page = render_template( TEMPLATE_FILE, title=article["title"], date=article["date"], content=body, generator="caveman", ) (PUBLIC / f"{article['slug']}.html").write_text(page, encoding="utf-8") index_items = [] for article in articles: index_items.append( f'<li><time>{article["date"]}</time>' f'<a href="{article["slug"]}.html">{article["title"]}</a></li>' ) index_html = "<ul>" + "".join(index_items) + "</ul>" (PUBLIC / "index.html").write_text( render_template(TEMPLATE_FILE, title="首页", content=index_html, generator="caveman"), encoding="utf-8", ) print(f"Generated {len(articles)} pages -> {PUBLIC}")删除public再重建,看起来有些“暴力”,但恰恰是静态站点的优势:没有增量状态,不会出现旧页面残留。每次构建都是全新的、确定性的输出。本地预览则利用 Python 内置的HTTPServer,在public目录起一个静态服务,浏览器里刷新就能看到效果。
import os from http.server import HTTPServer, SimpleHTTPRequestHandler def serve(): build() os.chdir(PUBLIC) server = HTTPServer(("127.0.0.1", 8000), SimpleHTTPRequestHandler) print("Preview: http://127.0.0.1:8000") server.serve_forever()整个工具就这样成型了:构建、预览、输出,三个动作,没有第四个。它是静态站点的“最小闭环”,也正好符合我希望的写作节奏。
3. 完整实操:从零搭建并部署一个博客
3.1 初始化项目结构
不用花里胡哨的脚手架,手动创建目录和文件就够了。先建一个文件夹作为项目根目录,然后在里面创建content和templates两个子目录。如果你跟我一样用 Git 管理,建议加一个.gitignore把public/忽略掉,因为生成产物不需要提交到仓库。
mkdir caveman cd caveman mkdir content templates touch caveman.py把上一节里的核心函数汇总到caveman.py,再在templates目录下新建base.html。不要忘记在模板里加上<meta charset="utf-8">,这个会直接影响中文显示,我早期忘了加结果页面上全是乱码。项目初始化完成后,目录结构应该回到第一节里那张图一样。此时public还不存在,第一次执行命令时脚本会自动创建,不需要手动建。
3.2 写文章、渲染并检查结果
在content目录下新建一篇测试文章,文件名用2024-06-01-hello-caveman.md。内容可以是各种写法都有的一段 Markdown,方便测试渲染结果。写完保存后,在项目根目录执行:
python3 caveman.py执行过程中会打印一行日志,告诉你生成了几个页面。此时目录下会出现public文件夹,里面有对应文章名的.html文件和一个index.html。用编辑器或浏览器打开就能看到渲染后的效果。第一次看输出时,我会习惯重点检查三个地方:标题层级是否正确、代码块是否被转义、列表是否被包裹成<ul>。这三处在纯文本里可能看不出问题,但出了错反正不好修。
如果你想快速批量化,可以把历史笔记批量拷贝进content目录,统一按YYYY-MM-DD-标题.md重命名。caveman不会干预正文内容,只依赖文件名。这意味着迁移成本极低,从别的系统导出的 Markdown 文件可以流水线式处理。
3.3 本地预览与调试
构建完直接开浏览器预览,用脚本内置的本地服务器命令:
python3 caveman.py --serve执行后终端会停留在监听状态,浏览器访问http://127.0.0.1:8000就能看到生成的首页。每一次修改文章或模板后,需要重新执行一次--serve,因为脚本没有做文件监听热更新。有人可能觉得这样不够“现代”,但对我而言,热更新只是省了敲命令的一秒钟,却会引入复杂的进程管理和文件监听逻辑。为了简单,我愿意手动刷新。
调试过程中可以配合浏览器的开发者工具看渲染后的 DOM 结构。如果你发现某个段落没有被<p>包裹,大概率是空行处理不对;如果代码块里出现了 HTML 标签,就要检查是不是忘了转义。每次修复后都要重新构建再刷新,这个循环很快,所以体验并不差。
3.4 部署到 Nginx 或对象存储
静态站点的部署是我最喜欢讲的部分,因为它几乎没有部署可言。把public目录里的所有文件传到服务器的某个路径,然后配置 Nginx 指向这个路径就行了。一个最小可用的站点配置是这样的:
server { listen 80; server_name example.com; root /var/www/caveman/public; index index.html; location / { try_files $uri $uri/ =404; } }try_files很关键,它会在请求一个不存在的路径时尝试找同名文件或目录,否则返回 404。如果不配这一行,你访问/hello-caveman.html可能没问题,但访问/hello-caveman这种无扩展名地址就会直接找不到。静态托管场景下,URL 是否带.html影响不大,但如果你想要“漂亮链接”,可以再加一行 rewrite,把/xxx映射到/xxx.html。
如果不想买服务器,把public目录整个拖到对象存储上也行。S3、OSS、Cloudflare R2 这类服务都支持静态网站托管,只需要把根目录指向public的内容,并设置 index 文档为index.html。上传工具可以用命令行,也可以用图形化客户端。实际使用时唯一要注意的是 CDN 缓存:更新文章后要把同名文件的缓存刷新一下,否则老用户可能看到旧内容。
4. 常见问题与排错实录
4.1 中文文件名与 URL 编码
文章文件名里带中文时,生成的 HTML 文件名自然也会带中文。浏览器访问时一般会自动编码,问题不大,但偶尔会有服务器或对象存储对非 ASCII 文件名处理不太友好,导致链接打不开。我现在的习惯是纯拼音或英文做文件名,日期正常用数字,标题只通过一个translate函数映射成链接文本。比如文件名2024-06-01-hello.md,最后页面标题仍然可以是“你好,Caveman”,二者不冲突。
如果你已经有一堆中文文件名,也不建议专门去批量改,因为改文件名会导致 URL 变化,SEO 会受影响。更合理的做法是在load_articles里额外维护一个“标题映射”,用文件名里的哈希或编号作为链接,正文中的一级标题作为展示标题。这个方案兼顾了可读性和稳定性。
4.2 列表和代码块渲染错位
写文章时最容易遇到的就是列表后面紧跟代码块,或者代码块里出现- item这种行。因为脚本是按行遍历的,如果代码块处理顺序不对,代码里的-会被误认为是列表。我在第一版实现时就是先把列表判断放在代码块判断之前,结果代码块内容全被拆成了<li>。后来调整顺序,优先识别围栏代码块,进入代码块状态后,所有行都原样保存,直到遇到结束围栏,问题才消失。
另一个场景是列表后面连续出现多个空行再跟一个段落,我的处理是遇到非列表项时先关闭<ul>。但如果你在列表项之间留了空行,Markdown 标准会把它拆成两个列表,我这里则可能把它合并成一个。好在个人写作很少遇到这种边界情况,但我建议在代码里写清注释,明确当前处理的是“极致简版列表”,别奢望兼容所有 Markdown 花样。
4.3 模板改不动、页面不更新
这个问题的原因基本只有一个:浏览器缓存。模板里改了个标题颜色,重新构建后刷新页面,结果还是旧的。静态站点在浏览器本地会被缓存,尤其 CSS 内嵌在 HTML 里的时候,缓存粒度会到整个页面。解决办法是强制刷新,或者用无痕窗口预览。更省事的方法是在模板里加一个版本号注释,每次改动顺手递增,构建时替换版本号,这样浏览器会认为是新文件。
还有一种情况是构建时没有真的生成了新文件。脚本每次都会删掉整个public再生成,按理说不会出现残留,但如果你在构建过程中按了 Ctrl+C,可能导致目录半删半建。此时手动删除一次public再运行脚本通常就能恢复。这也是为什么我坚持每次构建都删除重建,宁可慢那零点几秒,也不要在状态上偷懒。
4.4 服务器上 404 与路径问题
部署后最常见的 404 是首页能打开,点文章链接却 404。绝大多数原因是根路径不对。Nginx 的root指令指向的是包含index.html的目录,而不是项目根目录。常见错误:把root /var/www/caveman;写成项目根,请求/xxx.html时会在/var/www/caveman/xxx.html找文件,可实际上文件在/var/www/caveman/public/xxx.html,于是只能浪费时间去排查。解决方式就是确认root后面的路径和public的位置对得上。
另一个坑在本地预览时发现不了,因为SimpleHTTPRequestHandler会直接基于当前目录找文件,而网页里以/开头的链接在子目录部署时会失效。不是项目本身的问题,是静态托管到子路径的通用问题。如果要放到example.com/blog/下面,就要在链接前缀上做文章。我的建议是独立域名或独立子域部署,不要放进子目录,否则链接处理会变得复杂。
5. 后续扩展与使用心得
5.1 下一步值得做的功能
如果有时间,我大概率会给caveman补上三个功能,但前提是它们不会破坏“极简”定位。第一个是 RSS 生成,个人博客有一个feed.xml会让订阅和收录都方便不少。实现起来也很简单,因为文章信息已经全部在 Python 字典里了,再用 XML 模板拼一遍就行,跟生成 HTML 没有本质区别。
第二个是标签系统。目前首页只按时间倒序排列,一旦文章超过五六十篇,要找到特定主题就难了。标签不需要数据库,可以在每个文件里用文件名前缀规则,也可以约定第一行# 标题下面加一行tags:开头的注释,然后解析时按行识别。但这就需要引入一点 Front Matter 的雏形,我会谨慎处理,避免孤儿标签。
第三个是代码高亮。目前代码块是原样输出,长代码可读性一般。我不打算引入前端 JS 高亮库,更倾向于用 Python 的pygments在构建阶段生成带高亮 class 的 HTML。不过这会给项目增加一个第三方依赖,和当前“零依赖”原则冲突。所以要不要加,我还没完全想好。
5.2 用了一段时间后的体验
说实话,最开始把博客框架从复杂系统换成caveman的时候,我有点不适应。没有主题切换、没有插件市场、没有命令行交互,一切都得自己改代码。但真实写完几篇文章后,我开始体会到这种“原始工具”的价值:写作的时候我只面对文本,完全不会分心去调样式;发布的时候只需要一条命令,不用等构建服务转圈;搬家的时候整个系统就是一个文件夹,拷到哪都能跑。
如果你现在的博客系统已经让你感到臃肿,或者你压根还没开始写博客,我认为你值得试试这种“返祖”的方式。哪怕不用我实现的代码,只借鉴“文件名存储日期、脚本渲染 Markdown、静态目录直接部署”这一套思路,也能让你从工具焦虑里解脱出来。工具的使命是陪人写作,不是让人研究工具本身。caveman用最笨的办法做到了这一点,而我觉得,这恰恰是它最大的聪明之处。