☰
零依赖静态站点生成器:用Python将Markdown优雅转化为HTML
2026/10/29 5:29:16 网站建设 项目流程

如果你也有过“白天被各种构建工具折腾,晚上只想把一篇 Markdown 变成静态页面”的经历,那你大概率能体会我第一次看到 node_modules 里躺着两千多个文件时的那种无力感。为了彻底摆脱这套繁琐流程,我用一个晚上写出了这个叫caveman的极简静态站点生成器。说它是项目,不如说是一次对过度工程的“返祖”:不装任何依赖、不搞任何配置文件注入、不引入前端框架,只靠一个普通 Python 脚本把 Markdown 转成一套可发布的 HTML。

这篇文章会完整还原 caveman 的构思、核心代码、踩坑记录,以及我后来对它的边界判断。如果你需要的不是“全家桶式的博客框架”,而是一个能看懂每一行、能随时改逻辑、发布后不用伺候依赖的轻量工具,那这篇文章应该对你有用。它包含了我实际的取舍思路,也列出了不少直接可以抄走的代码和约定。

1. 为什么我会把工具命名成 caveman:一次对复杂构建工具的厌倦

1.1 事件起源:当我发现自己被框架反噬的时候

事情的起因有点狼狈。当时我打算给自己搭一个博客。说实话,博客的需求特别简单:写几篇文章、有个文章列表、可能再来个标签页。但我先是按“主流方案”走了一遍,发现光是环境就够忙活半天——先装 Node.js,再初始化某个静态站点框架,然后拉主题、装插件、调构建脚本。等我把那套东西跑通,已经过去一个周末了,而我连一篇正式的文章都还没写。

更让我恼火的是,每次打开项目目录,node_modules里躺着成千上万个文件。你知道那种感觉吗,明明只是想让文字能在浏览器里被人读到,结果引入了一整套工程化体系。那时我突然想到“caveman”这个词——穴居人。如果按照穴居人的思路,静态站点生成器应该是什么样?

大概就是一个文件夹,里面丢几篇 Markdown;再有一个模板文件;然后跑一条命令,生成一个可以直接放进 Nginx 的public目录。没有服务端、没有数据库、没有构建缓存,处理完毕立刻消失。

所以我给这个“原始工具”起名 caveman,其实就是自嘲:别人用火箭炮,我偏要用石头和火把把事办了。我当时的想法很简单,工具是为人服务的,不是让人来伺候工具的。

1.2 我为谁、为什么设计了这套“原始方案”

在动手之前,我给自己画了一个用户画像:技术博客作者、小型文档维护者、以及任何“只想把内容快速变成静态页面”的人。

对于这类用户,最痛苦的不是没有工具,而是工具教条太多。很多框架要求你学它的目录约定、学习它的模板语法、学习它的数据注入方式,哪怕你只是想写一篇 500 字的日记。caveman 选择了完全相反的方向:我只处理四个基本的东西——content/放 Markdown,templates/放 HTML 骨架,build.py负责转换,output/输出结果。剩下的一切,你爱放哪就放哪。

这套方案天然适合以下场景:

  • 个人博客,尤其是以文字为主的博客。
  • 公司内部的技术文档站,页面量不大,更新频率不高。
  • 实验性项目、个人作品集网站。
  • 不想长期维护依赖,希望几年以后依然能一键运行的项目。

我在这里说句掏心窝的话:大多数人的需求根本到不了需要复杂框架的地步。如果你的博客文章不超过一百篇,不想要复杂的主题生态,不需要多语言和多人协作,那么一个 200 行的脚本完全够用,而且未来几乎不需要维护成本。

2. 我对 caveman 架构的三个固执约束,以及背后的取舍

2.1 约束一:整个项目只依赖 Python 标准库,为什么这么“抠”

我开始写 caveman 之前的第一条原则:禁止引入任何第三方库。我选 Python,是因为它的标准库里既有文件系统操作、又有基本的正则表达式、又有简单的 HTTP 服务器,足够完成一套静态生成。

这条约束背后的原因不是“标准库更酷”,而是三个非常现实的问题:

第一,依赖会过时。我用 Go 或 Node 写,过两年再打开,版本可能早就变了,要么升级要么卡住。Python 的标准库则非常稳定,哪怕十年后,这套代码大概率还是能跑。

第二,依赖会隐藏逻辑。框架帮你干了太多事,你反而不知道最终 HTML 是从哪一步生成的。当我想调整输出的某一段结构,直接打开 build.py 看就明白了,不用去翻框架文档,也不用怀疑是不是某个插件在改我的代码。

第三,部署链路短。如果某个工具只需要 Python 3,那么我能部署到几乎所有服务器上。不需要 Docker,不需要环境隔离,直接拷贝代码就能构建。

当然,我为了这个约束付出了代价:Markdown 的转换只能做到“常用的那一部分”,像表格、脚注、复杂嵌套列表这些特性,都没有支持。这就是取舍。对大多数写作场景来说,我支持的那部分语法已经足够。

下面是我处理 Markdown 时用到的核心正则,你也可以直接拿去给你自己的小工具用:

import re def render_markdown(line): line = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", line) line = re.sub(r"\*(.+?)\*", r"<em>\1</em>", line) line = re.sub(r"`(.+?)`", r"<code>\1</code>", line) return line

这里的逻辑很直白:用正则把 Markdown 语法替换成对应的 HTML 标签。没有状态机,没有 AST,就是文本到文本。听起来原始,但胜在“一眼就能看懂”,出了问题也不会组出深层的 bug。

2.2 约束二:构建过程必须是一条命令,并且输出越快越好

第二条约束我不想搞什么“监听模式”“增量构建”,就是一条命令,做完全量构建。因为我希望任何时候从服务器拉下这份代码,都能得到相同的输出,不依赖本地缓存。

实际实现中,我尽量让构建过程做到:

  • 清空output/
  • 遍历content/
  • 渲染页面
  • 复制静态资源

这个过程非常机械,但有个很大的好处——你可以把它放进任何 CI 流程里,和“本地开发环境有缓存”这类问题彻底断开关系。有人可能会问,为什么不做一个 watch 模式,改一篇文章就自动重新生成?我的回答是,真正常见的写作场景里,保存后手动跑一下脚本,或者用编辑器自带的任务面板运行一下,成本低得可以忽略,根本不需要常驻进程。

为了保留一点“现代感”,我还是加了一个参数:python build.py --serve。它会先执行构建,然后自动起http.server,方便你本地预览。这个细节我后面在部署章节还会讲到。

2.3 约束三:约定大于配置,我不做管理后台

第三条约束,也是最有争议的一条:caveman 不提供管理后台,也不需要数据库。你自己用任何编辑器写 Markdown,然后用脚本生成页面。没有后台意味着没有登录、没有富文本编辑器、没有在线保存。而这恰恰是它稳定可靠的原因。

站点的所有元信息,比如标题、日期、标签,我统一放在 Markdown 文件顶部的“前置块”里。格式很简单:

title: 我的第一篇文章 date: 2024-11-20 tags: 技术, 随笔

build.py 解析这个前置块,把变量注入模板。这样做的逻辑也很简单:文本就是数据。你不需要学一门新的配置语言,不需要了解 YAML 或 JSON,只要碰过 Markdown,就能管理整个站点。

3. caveman 核心代码逐段拆解,200 行生成一个可用站点

3.1 目录约定与 config.json

我先给出一个标准目录结构,这段代码之后的工具操作完全依赖这个结构:

caveman-project/ ├── build.py ├── config.json ├── content/ │ ├── index.md │ └── posts/ │ ├── hello-world.md │ └── second-post.md ├── templates/ │ ├── default.html │ └── post.html ├── static/ │ ├── style.css │ └── script.js └── output/

config.json放全站通用的信息,比如站点名称、作者、导航链接。为什么用 JSON?因为 Python 标准库直接支持,解析方便,且不会被误写成分支问题。

{ "site_name": "caveman 站点", "author": "写字的穴居人", "nav": [ { "label": "首页", "url": "index.html" }, { "label": "文章", "url": "posts.html" } ] }

一个不太起眼但很重要的设计:我把posts.html这类“聚合页”也看作普通页面,它们同样由 build.py 扫描内容目录生成。这保证了整套系统只有一个核心逻辑,而不需要额外维护分类页、标签页的特殊生成代码。

3.2 build.py 主流程:遍历 content 目录并渲染页面

整个脚本的主流程是这样的:

import json import os import re import shutil import sys import pathlib def load_config(): with open("config.json", "r", encoding="utf-8") as f: return json.load(f) def parse_front_matter(raw_text): lines = raw_text.splitlines() meta = {} body_start = 0 if lines and lines[0].strip() == "---": body_start = 1 for line in lines[1:]: if line.strip() == "---": body_start += 1 break if ":" in line: key, value = line.split(":", 1) meta[key.strip()] = value.strip() body = "\n".join(lines[body_start:]) else: body = raw_text return meta, body def build(): config = load_config() if os.path.exists("output"): shutil.rmtree("output") os.makedirs("output", exist_ok=True) # ... 后续处理

我刻意把流程写成线性顺序,而不是抽象成“插件链”。因为线性结构最容易追踪:你读到第 10 行,就知道它在清理输出目录;读到第 15 行,就知道它加载了全局配置。对于一个给自己的工具,清晰比优雅重要得多。

3.3 一个可用的模板系统:占位符与 include

模板系统处于“简陋”与“够用”的交界点。我决定采用最简单的{placeholder}占位符替换,同时额外支持<!-- include:xxx -->来实现公共部分复用。

实现如下:

def render_template(template_path, variables): with open(template_path, "r", encoding="utf-8") as f: html = f.read() for key, value in variables.items(): html = html.replace("{{ " + key + " }}", value) # 处理 includes include_pattern = re.compile(r"<!-- include:([a-zA-Z0-9_\-\.]+) -->") for match in include_pattern.findall(html): include_path = os.path.join("templates", "partials", match) if os.path.exists(include_path): with open(include_path, "r", encoding="utf-8") as inc: html = html.replace(f"<!-- include:{match} -->", inc.read()) else: html = html.replace(f"<!-- include:{match} -->", "") return html

这里我能给到的重要经验是:不要为了“更现代的模板语法”而引入 Jinja2 或类似模板引擎。一旦引入语法支持,你就要开始面对“模板继承”“过滤器”“上下文”这些东西,复杂度立刻膨胀。用占位符加 include 的好处是,模板本身仍然是有效的 HTML,你用浏览器直接打开模板文件也能正常看到默版式的效果。

3.4 从 Markdown 到 HTML 的最小转换实现

我没法完整贴出全部代码,但会把这个迷你转换器的核心逻辑列出来。思路是按行扫描,处理标题、列表、段落、代码块四种主要块级语法。

def markdown_to_html(text): lines = text.splitlines() html_parts = [] in_code = False code_block = [] for line in lines: if line.strip().startswith("```"): if not in_code: in_code = True code_block = [] else: in_code = False html_parts.append("<pre><code>" + "\n".join(code_block) + "</code></pre>") continue if in_code: code_block.append(line) continue if line.startswith("### "): html_parts.append("<h3>" + render_markdown(line[4:]) + "</h3>") elif line.startswith("## "): html_parts.append("<h2>" + render_markdown(line[3:]) + "</h2>") elif line.startswith("# "): html_parts.append("<h1>" + render_markdown(line[2:]) + "</h1>") elif line.startswith("- "): html_parts.append("<li>" + render_markdown(line[2:]) + "</li>") elif line.strip() == "": continue else: html_parts.append("<p>" + render_markdown(line) + "</p>") return "\n".join(html_parts)

说实话,这个转换器非常“原始”。它甚至不处理列表包裹标签,所以我还要在后面的聚合适配阶段做一次简单的补全。但正因为所有逻辑都摊在眼前,我可以随意扩展,比如增加“引用块”,只需要再加一个分支。

我用它生成了一个本地文档站的 80 多个页面,速度大约不到 1 秒,完全满足要求。

4. 在真实内容上打磨 caveman:导航、文章列表、标签页这些细节

4.1 基于文件系统生成导航与文章卡片

项目真正费时间的不是实现基本渲染,而是把导航、文章列表、标签归档这些“隐性需求”打磨好。

caveman 处理导航的方式特别土——直接扫描content/posts/目录,把每个.md文件的前置信息读出来,然后生成一个文章数据数组。之后再用这个数组生成文章列表页。

它的基本原理是:

posts = [] for md_path in pathlib.Path("content/posts").glob("*.md"): text = md_path.read_text(encoding="utf-8") meta, _ = parse_front_matter(text) posts.append({ "title": meta.get("title", md_path.stem), "date": meta.get("date", "1970-01-01"), "link": "posts/" + md_path.stem + ".html", "tags": meta.get("tags", "") }) posts.sort(key=lambda x: x["date"], reverse=True)

然后我再把这一组数据直接填入列表页模板。文章排序就靠文件名中读取的 date 字段,简单可靠。

4.2 标签归档与全文搜索的做法

标签归档是常见需求。我没有为它专门建一个标签数据库,而是维护一个 Python 字典:key 是标签名,value 是文章列表。然后生成tags/xxx.html。这些标签页的内容其实每次都是重新生成的,属于纯静态输出。

至于搜索,我的做法可能让你失望:我直接在页面里嵌了一段 JavaScript,把站点全部文章数据当作 JSON 写入一个隐藏的<script>标签中,然后在浏览器里做简单的关键词过滤。这样需要“全文搜索”时,不会请求后端,也不需要构建索引。对于几十篇文章的小站,体验足够流畅。

这段逻辑的技术含量不高,但对用户来说足够“能用”。有时候我们要的不是一个完美的系统,而是能优雅地处理 95% 日常需求。

4.3 一个真实站点的目录结构和页面效果

我用 caveman 做了一个站点的简单示意图,内容大概是:

content/ ├── index.md -> output/index.html ├── about.md -> output/about.html └── posts/ ├── 2024-01-01-hello.md -> output/posts/hello.html └── 2024-01-05-tools.md -> output/posts/tools.html

每篇文章打开后,顶部会有“上一篇 / 下一篇”的链接,这也是根据文章数组排序后直接生成。整个过程我写得很死,但用户其实根本不需要关心实现方式。他们只需要看到,在目录里加一个.md文件,再跑一遍 build.py,页面就自动更新了。

5. 我日常使用 caveman 的完整工作流,以及两次踩坑记录

5.1 从写稿到发布的五分钟操作流

这里是我现在每天都在用的完整流程:

  1. 打开content/posts/,新建一个 Markdown 文件。
  2. 写上title、date、tags。
  3. 用自己习惯的编辑器写作,写完保存。
  4. 终端运行python build.py --serve。
  5. 浏览器打开http://localhost:8000预览效果。
  6. 确认没问题后运行python build.py。
  7. 把output/目录同步到服务器,或者用 Git 推送到托管平台。

如果你问我为什么不用自动化部署,我可能回答你:写作频率不高,同步一条rsync命令就够了,自动化反而增加配置量。你可能觉得这不够“现代”,但对我这种“老派”使用者来说,稳定才是第一要务。

5.2 坑点一:中文文件名带来的系统兼容问题

第一次把站点部署到 Linux 服务器之前,我直接用中文作为 Markdown 文件名,比如关于我.md。本地在 macOS 上跑完全没问题,但到了 Linux 或者某些浏览器里,URL 中的中文编码经常出现不一致,尤其是在文件名里带上空格时。

之后我定了一个铁律:文件名必须使用英文短横线命名,例如about-me.md、hello-world.md。中文标题只放在前置信息里。这样生成的 URL 一定是posts/hello-world.html,跨平台、跨浏览器几乎不会出错。这是一个很基础的建议,但初写博客的人往往要踩一次才能记住。

5.3 坑点二:模板缓存导致的“改了半天没变化”

还有一次,我改了模板里的导航文字,重新运行构建后,页面却没有任何变化。基于之前“依赖越少越不会出问题”的骄傲,我第一反应是浏览器缓存。清了好几次,还是没变。

排查到最后发现,是我在render_template里用了html.replace("{{ site_name }}", config["site_name"]),但模板文件里我写的是{ site_name },少了一层花括号。这种方式错误很隐蔽,因为文本里找不到占位符,最终页面只输出原文而不会报错。我后来加了一个“占位符未替换检测”,在构建结束时扫描所有输出 HTML,如果发现{{残留就打印警告。从那次以后,我再没被这种问题坑过。

6. 用了一年回过头看:caveman 的边界在哪里

6.1 适合 caveman 的场景清单

用了一段时间后,我对 caveman 的适用范围有非常清醒的认识。它适合这些场景:

  • 个人博客,内容以文章和固定页面为主,不需要复杂的交互功能。
  • 小型文档系统,比如开源项目的使用说明、团队内部的接口手册。
  • 个人作品集,挂几个项目链接、放几段介绍视频。
  • 快速原型的落地页,需要在 10 分钟内生成一个可以展示给别人的静态站。

在这种场景里,caveman 的优势非常明显:部署简单、构建快、零依赖、代码可控。

6.2 在哪几类项目上我劝你直接放弃 caveman

如果你有下面这些需求,我会直接劝你不要使用 caveman,也别试图往里面硬塞扩展:

  • 内容量极大,超过几千个页面,此时需要的是真正的静态站点框架,比如 Hugo 或 Astro。
  • 对主题系统有很高要求。caveman 的模板机制太简单,很难实现复杂的主题切换。
  • 需要多人协作,且非技术内容创作者参与。他们需要后台管理界面和可视化编辑,不适合直接写 Markdown。
  • 需要完整的 RSS、Sitemap、Schema 等 SEO 插件,虽然可以手写,但每个细节都要自己维护,成本不低。

这不是在否定 caveman,而是在明确一件事:“原始”并等于万能。可以把 caveman 看作一套“亲手搭建的木质小屋”,住着舒服,也可以改造,但你非要在里面装电梯、装中央空调,那就盖错房子了。

6.3 一个真实的经验收尾

我还记得第一次用 caveman 部署完一个站点的时候,看着浏览器里的页面,感觉非常神奇,因为我清楚知道这个站点从 Markdown 到 HTML 的每一步流程,而不是在心里默念“框架帮我把这事做了”。这段经历让我保持了很长时间的写作动力。

后来我慢慢明白,这种“原始”工具最大的价值不是效率,而是确定感。它不会在我离开三个月后突然启动失败,也不会因为某个依赖升级把我的页面全部打乱。如果你也厌倦了大型框架对小型需求的碾压,我建议你花一个周末亲手写一个类似 caveman 的小工具。别担心它看起来简陋,能用、够用、自己能修,就已经是一种很大的自由了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询