如果你的构建流水线必须等你打开终端输入 make tangle 才能运行,那你就没有文学化程序。你有的只是一本绑着编译器的日记。
文学化编程的全部意义在于散文和代码共享单一事实来源。Markdown 文件就是产物。其他一切——可执行源码、渲染后的文档、测试文件——都是派生的。派生产物属于 CI,不属于你的工作记忆。
问题不是 CI 能不能 做 weave 和 tangle。当然能。任何能运行 Python 和 pandoc 的机器都能做。问题是你的仓库结构是否允许 CI 拥有派生文件,还是你仍然在假装 .py 文件是源码,而实际上它们是构建输出。
weave 和 tangle 实际在做什么
Donald Knuth 把这两个操作称为 weave 和 tangle,名字之晦涩以至于人们干脆回避这个话题。
tangle 取叙述文档——.md 或 .w 文件——按照编译器期望的顺序提取代码块。它生成可执行的源文件。weave 则相反。它取同一个文档,生成人类可读的文档,包含 pretty-printed 的代码、交叉引用和目录。
两者都是确定性转换。一个输入产生一致的输出。这就是 CI 应该处理的东西的定义。
为什么大多数团队把流水线建反了
典型的文学化编程工作流是这样的。你写 algorithm.md。你运行本地脚本提取 algorithm.py。你针对 algorithm.py 运行测试。你把 algorithm.md 和 algorithm.py 都提交到 Git。然后你打开一个拉取请求。
这本身就坏了。版本控制里有同一份逻辑的两个副本。如果评审者建议修改 algorithm.py,你得记得把它反向移植到 algorithm.md。如果你编辑了 algorithm.md 却忘了重新 tangle,提交的 .py 文件就是过时的。单一事实来源是个 fiction。
正确的工作流更简单。你只提交 .md 文件。CI 运行 tangle 生成 .py 文件,然后对它运行测试套件。测试通过后,CI 可选地运行 weave 发布文档。.py 文件永远不会进入版本控制。它是构建输出,就像 .o 文件或 Docker 镜像。
Markdown 转 Python 的可用 CI 流水线
下面是一个完整的 GitHub Actions 工作流,把 Markdown 文件当作唯一的事实来源。保存为 .github/workflows/literate.yml:
name: Tangle and Test
on:
push:
paths:
- "**/*.md"
pull_request:
paths:
- "**/*.md"
jobs:
tangle:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Tangle code from Markdown
run: python scripts/tangle.py src/*.md --out build/
- name: Run tests on extracted code
run: python -m pytest build/ -v
- name: Upload generated source as artifact
uses: actions/upload-artifact@v4
with:
name: generated-source
path: build/
paths 过滤器很重要。这个工作流只在 Markdown 文件变更时运行,因为 Markdown 是唯一重要的输入。
tangle.py 脚本就是你本地会运行的那个三十行提取器。以下是一个能处理多个文件和输出目录的版本:
import argparse
import re
import sys
from pathlib import Path
def tangle(md_path: Path, out_dir: Path) -> None:
content = md_path.read_text()
files: dict[str, list[str]] = {}
for block in re.findall(r"```python\n(.*?)```", content, re.DOTALL):
lines = block.strip().split("\n")
if lines and lines[0].startswith("# "):
filename = lines[0][2:].strip()
code = lines[1:]
else:
filename = "module.py"
code = lines
files.setdefault(filename, []).extend(code)
out_dir.mkdir(parents=True, exist_ok=True)
for filename, code_lines in files.items():
(out_dir / filename).write_text("\n".join(code_lines) + "\n")
def main() -> None:
parser = argparse.ArgumentParser(description="Extract code from Markdown files.")
parser.add_argument("inputs", nargs="+", type=Path, help="Markdown files to process")
parser.add_argument("--out", type=Path, required=True, help="Output directory")
args = parser.parse_args()
for md in args.inputs:
tangle(md, args.out)
if __name__ == "__main__":
main()
本地运行验证:
$ python scripts/tangle.py src/calculator.md --out build/
$ python -m pytest build/ -v
CI 任务做的完全一样。唯一的区别是 CI 在干净环境中运行,因此隐藏在你工作目录中的过时 .py 文件无法欺骗测试套件。
在同一条流水线中 weave 文档
tangle 产出代码。weave 产出文档。两者都可以在 CI 中运行。
对于 weave,你需要一个把 Markdown 转换成可展示格式的工具。Pandoc 是个无聊但可靠的选择。如果你想要交叉引用、语法高亮和目录,一个小模板就能搞定:
- name: Weave documentation
run: |
mkdir -p docs/output
pandoc src/*.md \
--from markdown \
--to html5 \
--standalone \
--toc \
--highlight-style=tango \
--output docs/output/index.html
- name: Publish to GitHub Pages
uses: actions/upload-pages-artifact@v3
with:
path: docs/output/
渲染后的 HTML 也是派生产物。你可以在每次合并到 main 时把它发布到 GitHub Pages、Netlify 或 S3 存储桶。Markdown 文件保持规范。HTML 是读者看到的东西。
提交与生成之间的权衡
有些团队对此有抵触。他们想把 .py 文件放在 Git 里,因为这样 git grep 能工作,而且 GitHub 的代码评审界面比 Markdown 更懂 Python 语法。
这些是真实问题,但它们是工具问题,不是架构问题。因为代码评审工具不好就提交生成代码,就像因为包管理器慢就提交 node_modules。它管用,但会累积债务。
如果你确实需要 .py 文件在仓库里,在 CI 中生成它们并用 bot 账户提交回来。模式如下:
- 开发者推送了对
algorithm.md的修改。 - CI 运行
tangle.py生成algorithm.py。 - 如果提取的代码与
main中的不同,CI 会开一个包含更新后的.py文件的拉取请求。 - 人工审查生成的 diff 并合并。
这样生成文件留在版本控制中但不会漂移。bot 成为生成输出的单一事实来源,而且 bot 只修改 Markdown 文件要求它改的东西。
CI 自动化什么时候值得,什么时候不值得
自动化的 weave 和 tangle 在散文很长、代码很复杂、多人编辑文档时表现优异。研究代码、库文档和算法实现都受益于一个规范的 Markdown 文件,CI 在每次推送时都会验证它。
对于小脚本或大量样板代码的应用程序代码则不值得。如果你的 Markdown 文件只有两个段落和二十行的函数,CI 的开销、运行器时间和心智模型成本超过你要解决的同步问题。还是好好写个 docstring 吧。
另一个失败场景是你的语言工具链把 .py 文件当成主要的。调试器、性能分析器和覆盖率工具通常想指向生成的源码,而不是 Markdown。你可以在调试前在本地生成文件来绕过这个问题,但那是摩擦。如果你的团队活在调试器里,无论 CI 流水线有多好,文学化编程都可能是错误的选择。
FAQ
我应该提交生成的 .py 文件还是把它们加入 .gitignore?
加入 .gitignore。如果它们是从 Markdown 确定性生成的,那就是构建产物。唯一的例外是外部系统(比如包注册表)无法运行你的 tangle 步骤。这种情况下,使用上面描述的 bot 提交模式。
如果我的文学化文档混合了多种语言怎么办?
tangle.py 脚本与语言无关。Rust 块用 # filename.rs,Go 块用 # filename.go,依此类推。CI 为每种语言运行同一个脚本。你只需要在工作流中为每种语言加一个测试步骤。
这适用于 Knuth 的 WEB 那样的命名代码块吗?
Entangled 等现代工具支持命名块和依赖跟踪。如果你需要那种表达能力,在 CI 中使用 Entangled 而不是自定义脚本。流水线结构相同。只有提取工具变了。
如何处理跨多个 Markdown 文件的导入?
每个 Markdown 文件被 tangle 到自己的输出目录。如果 math.md 定义了 add 而 geometry.md 导入了它,要么把两个文件都 tangle 到同一个构建目录,要么把代码结构化为带相对导入的包。CI 任务可以在运行测试前 tangle 所有文件,因此跨文件依赖会自然解析。
让你的 Markdown 文件成为唯一重要的文件
一次性设置好流水线。提交 .md 文件。让 CI 处理其余一切。如果测试失败,失败指向产生代码的 Markdown 行,而不是指向一个反正就不该存在于版本控制中的生成文件。
文学化编程的目标是单一事实来源。需要人类记住手动步骤的事实来源不是事实来源。它只是个建议。