</> 技術筆記Tech Notes

自己寫了一個部落格產生器,現在跑著三個站

我有三個部落格:木工、攝影,還有你現在看的這個技術筆記。文章都是 Markdown,我用 Typora 寫,圖片就丟在文章旁邊的 .assets 資料夾裡。

寫是不麻煩,麻煩的是發佈。三個站三套做法,每次都要重想一遍:這個站的輸出目錄在哪?圖片要不要先壓過?上次到底是怎麼傳上去的?現成的產生器我也試過幾套,不是設定檔要學一堆,就是想改個版面得先搞懂它那套樣板規則。

後來乾脆自己寫一個。我要的其實很單純:選一個 Markdown 目錄、選一套範本、按一個按鈕,網站就上線。

這就是 Velo,一個只做這件事的 macOS App。

Velo 長什麼樣子

Velo 的主畫面:左側專案清單,右側建置與發佈紀錄

用 SwiftUI 寫的,畫面很單純。左邊是專案清單,一個部落格算一個專案;中間放這個專案的來源目錄、範本、輸出目錄;右邊就是一顆「一次發佈」,下面接著即時紀錄和過去的發佈紀錄。

按下去它會一路做完:掃 Markdown、解析 Front Matter、轉成 HTML、處理圖片、建分類樹、套範本、寫檔,最後上傳到 Cloudflare Pages。通常三十秒內就跑完了。

Cloudflare 的 API Token 存在 Keychain,不會變成明文檔案躺在硬碟上;專案設定就是一份 JSON,放在 ~/Library/Application Support/Velo/

三個網站

這三個站是同一個 App、同一套流程做出來的,長得完全不一樣的原因只有一個:範本不同。

木工

木工作品集的首頁

41 件作品,暖色系,標題用襯線字。這個站圖特別多,每篇都有一堆施作過程的照片,整個輸出目錄 152 MB。分類只有「家具」和「生活小物」兩類,但標籤分得很細(榫接、手鉋、卯榫、拼板),實際上我自己回頭找東西都是點標籤,很少點分類。

攝影

攝影作品集的首頁,深色主題

48 篇,深色底配金色。照片放在深色背景上就是比較耐看。這是三個站裡最肥的一個,輸出 258 MB。

技術筆記

技術筆記的首頁

一樣 41 篇,但性質差很多:程式碼區塊多、圖少,整站只有 2.5 MB。分類有九類(系統與硬體、軟體專案、軟體開發、AI、資料庫、設計模式⋯⋯),所以這套範本把分類樹做得比較明顯。你現在讀的這篇,就是從這裡發出去的。

幾個我自己覺得有意思的地方

分類不用自己維護

我很懶,不想每篇文章都在 Front Matter 手寫分類。所以 Velo 的規則是:沒寫 categories 就直接拿資料夾結構當分類。

我的來源目錄長這樣:

原始檔/
├── 系統與硬體/
│   └── 樹莓派/
│       └── 在 Raspberry Pi 5 上部署 PostgreSQL 17.md
├── 軟體專案/
└── 產品/
    └── 自己寫了一個部落格產生器,現在跑著三個站.md

巢狀資料夾會變成階層分類,像「系統與硬體/樹莓派」,首頁的分類樹自己就長出來了,可以展開收合,點了就篩選。想手動指定分類的文章照樣寫 categories,不受影響。

圖片用內容雜湊命名

Markdown 裡的圖片路徑其實很亂。Typora 會把中文路徑寫成 percent-encoded 的樣子,有些舊文章甚至還留著我 Windows 時代的絕對路徑。Velo 會用幾種方式輪流去找檔案,真的找不到就在紀錄裡留一行警告,不會整個建置停掉。這點我後來覺得很值得,因為少一張圖通常不是什麼大事,但建置中斷就得整個重跑。

找到之後,圖片會用內容雜湊重新命名再複製到輸出目錄。同一張圖被三篇文章引用只會存一份;改了圖,檔名跟著變,瀏覽器的快取自然就失效了。

圖片也可以改放 Cloudflare R2 或其他 S3 相容的儲存空間,那段 AWS SigV4 簽章是自己刻的,沒有把 AWS SDK 拉進來。

範本故意做得很陽春

範本引擎只認四種語法:變數、原樣輸出的變數、條件、迴圈。沒有 helper、沒有 partial,也沒有繼承。

會這樣是故意的。一套範本就是一份 index.html 加一份 post.html,CSS 和 JS 全部寫在裡面,因為建置只會輸出 HTML,範本資料夾裡的其他檔案不會被複製過去。想換風格就把整份 HTML 改掉,不用先去學一套樣板語法。

上面三個站看起來差那麼多,差別就只在這兩個檔案。

發佈走 Cloudflare Pages 的 Direct Upload

這部分花我最多時間。官方文件對 Direct Upload 寫得很少,實際的協定我是去翻 wrangler 的原始碼看出來的,大致是:先拿 upload token,再問 Cloudflare「這批檔案你哪些還沒有」,只補傳缺的那些,接著送出完整的檔案清單,最後建立 deployment。

第二步是重點。攝影站有 258 MB,但我改一篇文章通常只動到幾個檔案,伺服器已經有的就不用再傳一次,發佈時間因此從「去泡杯咖啡」變成幾秒鐘。

比較特別的是檔案識別碼不是 SHA-256,而是 BLAKE3:內容先 base64,接上副檔名,再取雜湊值的前 32 個 hex 字元。為了讓 Swift 這邊算出來的跟 wrangler 一模一樣,我把 BLAKE3 的官方 C 實作 vendor 進專案,拿官方的測試向量對過才敢用。

查最久的三個問題

有三個坑還滿有代表性的,記一下。

第一個是 Cloudflare 的 Pages 專案清單,per_page 上限只有 10。帶 11 以上直接回錯誤,不帶參數的預設也是 10。同一組 API 底下,帳號清單可以帶 50、R2 bucket 可以帶 100,就這支不行。專案超過十個就得自己翻頁,不然第 11 個之後永遠看不到。

第二個是 api.cloudflare.com 的回應完全沒有 Cache-Control。這代表 URLSession 會自己判斷要不要快取,而它判斷的結果是「要」。我寫了個小程式驗證,第一次請求之後快取裡就查得到了。症狀是我在 Dashboard 新建一個 Pages 專案,回到 App 按重新整理還是看不到。這種管理用的 API 一律要把本機快取關掉。

第三個跟 Cloudflare 無關,是我自己的錯誤訊息寫得不夠好。有次建置跑到第 22 篇停住,畫面上只有一行 The operation couldn't be completed. (Yams.YamlError error 1.)。41 篇文章,完全看不出是哪一篇有問題。後來另外寫個小程式掃過一遍才找到,原來是某篇的 Front Matter 多打了一個引號:

categories: ["Windows""]

YAML 讀到那個引號就以為字串還沒結束,一路吃到檔案結尾才發現不對。現在這個錯誤會把檔名和 YAML 回報的行號欄號一起帶出來,看一眼就知道要改哪裡。

現在

Velo 自己也簽章、公證過了,Developer ID 簽章、送 Apple 公證、stapler 蓋章都做完,下載下來直接開就好,不用再去「隱私權與安全性」按允許。

三個站現在的流程就是:文章寫完存檔、打開 Velo、按「一次發佈」,然後去做別的事,回來就上線了。

對我來說這樣就夠了。