如果你的建置流水線必須等你開啟終端機輸入 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 檔案就是過時的。單一事實來源是個虛構。
正確的工作流程更簡單。你只提交 .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 確定性生成的,那就是建置產物。唯一的例外是外部系統(比如套件 registry)無法執行你的 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 行,而不是指向一個反正就不該存在於版本控制中的生成檔案。
文學化程式設計的目標是單一事實來源。需要人類記住手動步驟的事實來源不是事實來源。它只是個建議。