如果你的构建流水线必须等你打开终端输入 make tangle 才能运行,那你就没有文学化程序。你有的只是一本绑着编译器的日记。

文学化编程的全部意义在于散文和代码共享单一事实来源。Markdown 文件就是产物。其他一切——可执行源码、渲染后的文档、测试文件——都是派生的。派生产物属于 CI,不属于你的工作记忆。

问题不是 CI 能不能 做 weave 和 tangle。当然能。任何能运行 Python 和 pandoc 的机器都能做。问题是你的仓库结构是否允许 CI 拥有派生文件,还是你仍然在假装 .py 文件是源码,而实际上它们是构建输出。

weave 和 tangle 实际在做什么

Donald Knuth 把这两个操作称为 weavetangle,名字之晦涩以至于人们干脆回避这个话题。

tangle 取叙述文档——.md.w 文件——按照编译器期望的顺序提取代码块。它生成可执行的源文件。weave 则相反。它取同一个文档,生成人类可读的文档,包含 pretty-printed 的代码、交叉引用和目录。

两者都是确定性转换。一个输入产生一致的输出。这就是 CI 应该处理的东西的定义。

为什么大多数团队把流水线建反了

典型的文学化编程工作流是这样的。你写 algorithm.md。你运行本地脚本提取 algorithm.py。你针对 algorithm.py 运行测试。你把 algorithm.mdalgorithm.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 账户提交回来。模式如下:

  1. 开发者推送了对 algorithm.md 的修改。
  2. CI 运行 tangle.py 生成 algorithm.py
  3. 如果提取的代码与 main 中的不同,CI 会开一个包含更新后的 .py 文件的拉取请求。
  4. 人工审查生成的 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 定义了 addgeometry.md 导入了它,要么把两个文件都 tangle 到同一个构建目录,要么把代码结构化为带相对导入的包。CI 任务可以在运行测试前 tangle 所有文件,因此跨文件依赖会自然解析。

让你的 Markdown 文件成为唯一重要的文件

一次性设置好流水线。提交 .md 文件。让 CI 处理其余一切。如果测试失败,失败指向产生代码的 Markdown 行,而不是指向一个反正就不该存在于版本控制中的生成文件。

文学化编程的目标是单一事实来源。需要人类记住手动步骤的事实来源不是事实来源。它只是个建议。