如果你的构建流水线必须等你打开终端输入 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 账户提交回来。模式如下:

  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 定义了 add 而 geometry.md 导入了它,要么把两个文件都 tangle 到同一个构建目录,要么把代码结构化为带相对导入的包。CI 任务可以在运行测试前 tangle 所有文件,因此跨文件依赖会自然解析。

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

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

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