如果你的建置流水線必須等你開啟終端機輸入 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 檔案就是過時的。單一事實來源是個虛構。

正確的工作流程更簡單。你只提交 .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 確定性生成的,那就是建置產物。唯一的例外是外部系統(比如套件 registry)無法執行你的 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 行,而不是指向一個反正就不該存在於版本控制中的生成檔案。

文學化程式設計的目標是單一事實來源。需要人類記住手動步驟的事實來源不是事實來源。它只是個建議。