title: 我容納眾多(還有三個 Git Repos)
published: true
description: '對 mattstratton.com 與 speaking.mattstratton.com 背後技術堆疊的導覽:一個 monorepo,裡面有兩個 Astro 網站和一個 dev.to 同步工具、二十年的部落格文章,以及能把像這篇文章這樣的內容交叉發布出去的管線。'
tags:

我的部落格比我拿來運作它的大多數工具都還老。最早的文章是在 2000 年代初期的某個時間點先發佈在 LiveJournal 上,之後在 2000 年代中期某個時候被匯入 WordPress,再來在 Hugo 上待了將近十年,然後大約一週前搬進了一個 Astro 5 網站,而且把那些舊 URL 一字不差地完整還原。這裡總共有 2,630 篇文章,跨度從 2001 到 2020,精準地凍結在它們落地的那一刻。

同一時間,我的 演講網站(106 場演講,最早可追溯到 2012 年)也從 Notist 搬走,改成了我真正擁有的東西。而那個把我的 dev.to 草稿和 git 同步的工具,終於也開始照它一開始本來就應該運作的方式運作,而不是像前一陣子那樣,實際上只是勉強湊合著能用。

這三個東西原本是三個獨立的 repo。到了今天早些時候,它們變成了一個 monorepo,而且保留了三者真實的 git 歷史,沒有被壓成「initial commit」就草草了事。以下是這趟導覽:這兩個網站怎麼運作、dev.to 如何融入其中,以及旅途中有哪些很有意思的方式壞掉了。

現在的樣子

三個系統、一個人,現在合併成一個 repo

  • mattstratton.com 是個人網站。它承載了 2,630 篇文章的歷史封存內容,還有一個較新的通用 /writing/ 區塊,放任何我想寫、但不屬於存檔性內容的東西。
  • speaking.mattstratton.com 是演講檔案庫:每份簡報、每支影片、每場活動,全部自架。
  • mattstratton-dev-to 根本不是網站。它是一個小工具,用來讓 dev.to 草稿和這個 git repo 保持同步,而它也是讓一篇在 dev.to 發布的文章能原生出現在 mattstratton.com 上的關鍵,而這正是這篇文章接下來要做的事。

它們現在被合併到同一個 monorepo 裡,但底下依然是三個真正分離的關注點,各自有自己的設定檔,也各自有存在的理由。讓我們一個一個來看。

mattstratton.com:保存二十年的證據

目前的網站是 Astro 5 和 Tailwind v4。這次遷移中影響所有其他決策的唯一原則是:URL 保持不變是最高指令。 astro.config.mjstrailingSlash: 'always' 設好,並用目錄格式來建置,目的就是要完全重現舊的 Hugo URL,而 Hugo 之前又已經重現了 WordPress 的 URL。舊的外部連結,不管是 2009 年的部落格串連,還是 2014 年某場會議網站連到演講回顧的連結,現在依然能正常開啟。沒有人需要因為我改用不同框架重寫網站,就去修一堆死連結。

posts collection 是凍結在 2001 到 2020 的封存區,是從 Hugo 大量轉換過來的。它不會再長大。它只需要繼續存在就好。所有新內容都進到 writing,而這個區塊比我在開始寫這篇文章時預想的還要更複雜一些。

實地指南:我自己的文章,加上我在別處寫的好東西

/writing/ 以前的範圍更窄。這個專案開始前的工作規劃,是把它限定在和 Postgres 相關、建立權威感的內容上,這個想法其實是從一份內容策略文件繼承而來,而那份文件是寫給完全不同於我個人部落格讀者的受眾。最後讓這種範圍劃分被推翻的理由很簡單:我容納眾多,而一個只能放一種主題的個人網站,嚴格來說就不再那麼「個人」了。

不過,/writing/ 現在真正有趣的地方在於:它不只是我的文章。它其實是兩種完全不同東西的合併:

  • 放在 src/content/writing/ 裡的原生條目,由 content.config.ts 中的 schema 定義:titledescriptionpubDatetopicsdraft、用於精選四段式 Postgres 篇章的可選 part,以及當真正的原始來源在別處時使用的可選 canonicalUrl
  • 一份手動維護的外部連結清單,指向我為雇主 Tiger Data 撰寫的文章,由 field-guide.ts 定義:
export interface FieldGuideLink {
  title: string;
  description: string;
  part: "mechanics" | "limits" | "traps" | "decision";
  url: string;
}

目前這樣的專案有 14 篇,全部都指向 tigerdata.com,並且都被分配到原生文章也能選擇加入的同一組四個 part 分類之一。writing.ts 會把這兩種結構都標準化成同一個 GuideItemtitledescriptionhrefexternal),讓模板能以相同方式渲染它們:每個 part 內先顯示原生條目,再顯示外部連結,不依日期交錯。唯一的視覺提示,是外部標題旁邊會有一個小小的「tigerdata.com ↗」徽章。不是另開新分頁,只是老實標示你接下來會到哪裡。

電子報:Buttondown 負責寄送,Astro 只負責提供頁面

/newsletter/ 的運作方式和這個網站大多數部分都一樣:在建置時抓取資料,然後永久提供靜態檔案。buttondown.ts 是一個 Astro content layer loader,它會呼叫一個端點,也就是 Buttondown API 的 GET /emails?ordering=-publish_date,持續追蹤分頁直到沒有更多頁為止,然後把結果當成 newsletter collection 交給 Astro。每一期頁面、封存索引頁、RSS feed,接著都只是一般 getCollection() 呼叫產出的靜態內容,和本地 markdown 檔案沒兩樣。

如果你也想做同樣的事,下面兩點值得特別注意:

  • 如果沒有設定 BUTTONDOWN_API_KEY,loader 會記錄警告並回傳空集合,而不是讓建置失敗。這不是意外:代表任何人都能 clone 這個 repo,並在本機建置,不需要我的 API key。
  • Buttondown 會把每一期的內文存成 Markdown,所以 loader 在進到任何 template 之前,就會先用 marked.parse() 處理它。網站從頭到尾都不會碰 API 回來的原始 HTML。
const key = import.meta.env.BUTTONDOWN_API_KEY ?? process.env.BUTTONDOWN_API_KEY;
if (!key) {
  logger.warn("BUTTONDOWN_API_KEY not set: newsletter archive will build empty.");
  return;
}

speaking.mattstratton.com:把我的投影片從我不擁有的 CDN 拿回來

舊的演講網站架在 Notist 上,而 Notist 最後變成了一個問題,背後有兩個原因,而且這兩個原因最後其實可歸結成同一個問題:影片嵌入已經悄悄開始壞掉,而每份簡報都透過 Notist 的 CDN 提供。帳號到期的那一天,圖片和 PDF 就會一起消失。每一場我講過的演講,作為講者專業身分核心的一部分,竟然住在別人的資料庫裡,只靠一個很薄弱的匯出選項撐著。

所以新網站的整體設計目標就是資產所有權和耐久性。內容放在我自己的 repo、自己的網域上,不依賴任何可能被關掉、或默默過時的第三方。這不是重新設計,是一個可攜性專案。

有趣的部分是投影片管線,因為它完全是本機執行,而且是在 commit 之前就先完成,部署主機上根本不跑:

originals/{id}.pdf --optimize(gs, 300dpi)--> public/slides/{id}.pdf --rasterize--> public/slides/{id}/{n}.webp
   (gitignored, full-res)          (committed, served, downloadable)     (committed, served, viewer images)

它之所以必須在本機執行,是因為靜態主機不會給你 root,而你需要 root(至少也要有真的套件管理工具)才能跑 pdftoppmgs。想在 Netlify build 裡面呼叫 Ghostscript,基本上就是一場你一定會輸的仗。把光柵化放在我自己的電腦上完成,然後把輸出 commit 進去,就能讓真正的部署建置維持在一個無聊、快速、不綁主機的平台無關的 astro build

影片嵌入也用了同樣的「自己擁有,不要轉送」處理方式。不是照著 Notist 的做法複製一個代理層(那個代理也早就開始壞了),而是每場演講都在 frontmatter 裡存一個簡單的 provider 和 video ID,頁面則渲染一個輕量的嵌入外殼,只有在點擊後才載入真正的播放器。

目前的數字:106 場演講,從 2012 到 2026;93 場活動;36 支影片;3,684 張投影片圖片。全部都有 commit,沒有任何一張會因為某個 CDN 掛掉就消失。

讓演講可搜尋

影片是演講網站無法完全自己掌握的唯一資產類型。它可以保存參考資訊,但不能真的把檔案本身放在站上。它能擁有的是逐字稿,而這最後變成了一個非常實用的功能:對我講過的每一場演講做全文搜尋。

原始逐字稿來自 YouTube 的自動產生字幕,透過 scripts/transcripts.tsyt-dlp 抓下來、清理過後,寫入 public/transcripts/{id}.txt。自動字幕通常很粗糙:沒有標點、專有名詞亂掉、每個「um」和「uh」都忠實轉錄下來。transcript-cleanup 這個 Claude Code skill 負責清理步驟,規則非常嚴格:修正標點、大小寫和專有名詞,移除贅語,但絕對不能改寫、意譯或重新排列任何實際說出的內容。拿不準時,指示就是寧可保持原樣,也不要亂猜。

搜尋本身是在 client 端對兩份於部署時建好的 JSON payload 進行:

  • search-index.json.ts 很小:每場只放標題、摘要、活動、標籤、年份。第一次打開搜尋框時載入。
  • deep-index.json.ts 才是重的那份,它會把每場演講的逐字稿和投影片文字串成一個可搜尋的 blob,只有在你真的輸入查詢時才會延遲載入:
const parts = [readTranscript(talk.id), readSlideText(talk.id)].filter(Boolean);
if (!parts.length) continue;
records.push({ url: talkUrl(talk), text: parts.join(" ").replace(/\s+/g, " ") });

Search.astro 會在每個 payload 上建立 Fuse.js 的模糊搜尋索引並合併結果,所以當我搜尋的是我在演講途中真的講過的一句話,而不只是標題裡的一個字,就能找出正確的演講。沒有伺服器、沒有資料庫,只有兩個靜態 JSON 檔和一個模糊比對函式庫在瀏覽器裡完成所有工作。

三個 repo 變成一個

把三個 repo 合成一個 monorepo,其實不是為了整潔。獨立管理三個 repo 當然有點摩擦,但真正的觸發點,是我發現自己在一個凍結的封存區和一個新範圍的 /writing/ 區塊之間,自己的網站上已經沒有地方可以放一般寫作了。當這才是真正的問題時,「合併一些 repo」就變成了「修正架構,而且既然都在做了,就順便把 dev.to 的交叉發布弄到真的可用」。

這次合併本身用的是 git subtree add,不是 squash,也不是 filter-repo rewrite。這樣就能保留兩個來源 repo 的完整 commit 歷史,作為真實、可驗證的祖先關係,而不只是「檔案現在在這裡了,相信我」。兩個來源 repo 都還很年輕(大約分別 50 與 22 個 commit),所以這不是幾十年的考古工作要保護,而是基於原則把事情做對,這本身就很有成就感。

合併過程中有幾件事特別值得提醒,如果你也打算把各自帶著 CI 的 repo 合在一起:

  • GitHub Actions workflow 只會從真正的 repo 根目錄 .github/workflows/ 被辨識,不會從子目錄裡被找到。 一開始合併後,dev.to 的 workflows 還留在子專案自己的 .github/workflows/ 底下,結果 GitHub 完全看不到。沒有錯誤、沒有警告。gh workflow list 根本不顯示它們,它們也就這樣悄悄再也沒跑過。
  • Netlify 不會自己依子目錄範圍重建。 每一次 push 都會觸發所有已連結網站的重建,除非你在各自網站的 netlify.toml 裡加上明確的 ignore 檢查。當 Netlify 因為沒有相關變更而跳過建置時,它會顯示成「Canceled build due to no content change」,這在你的收件匣裡看起來就像失敗了一樣,即使實際上根本沒有問題。
  • 「Base directory」和「Package directory」是 Netlify 的兩個不同設定,而一個沒有 npm workspace 的 repo 在重新連結流程裡只會顯示 Base directory。Package directory 就是完全不會出現。與其假設,不如真的去讀文件;顯然這句話我每隔幾年還是得重新學一次。
  • astro check 不會尊重子專案邊界。 從 monorepo 根目錄執行時,它會用另一個子專案產生的型別,去掃描一個不相關子專案的內容集合,結果噴出 200 多個假的型別錯誤。只要在 tsconfig.json 裡加上一個明確的 exclude,就能把數量降到 3 個,而且那 3 個都是早就存在、而且無關的問題。

如果你接下來要合併的東西,兩個或以上符合上面情況,那就請多預留一個下午。

dev.to:所有權,不是方向

這個 dev.to 同步工具圍繞著一個我真的推薦給任何在平台和 git 之間同步內容的人使用的概念:所有權,不是方向。 完整的心智模型寫在 repo 裡的 CLAUDE.md 中,但簡短版本是:

很容易把這件事想成雙向同步:讓 dev.to 和 git 保持同步,雙方互相推送變更。不要這麼做。那會造成無止盡的幽靈差異循環,兩邊都一直以為自己才是正本。正確做法是:一篇文章在草稿階段屬於 dev.to,git 完全忽略它。等人類手動合併匯入 PR 之後,它就永遠屬於 git,並以 frontmatter 裡出現 id 欄位作為標記。所有權只會在一個刻意選定的時刻轉移一次,而且永遠不會自動反向切回去。

實際執行的是兩個小型 workflow。devto-import.yml 以 cron 執行,抓取任何新發布的 dev.to 文章,並開出一個 PR 供審查,完全不會碰到任何已經有 id 的檔案。devto-publish.yml 則是在 posts/ 底下檔案有 push 時執行,把本地修改推回 dev.to,並在文章第一次發布時把 id 寫回去。兩者的領域完全不重疊,這正是重點。

交叉發布管線(沒錯,這篇文章也會經過它)

一旦文章成為 git 所有,我就可以在 frontmatter 裡加上 crosspost: true,把它交叉發布到 mattstratton.com 的 /writing/ collection 中,並透過 crosspost-devto.ts 把 dev.to 文章的 canonical_url 改回指向 mattstratton.com。這個旗標只有在文章已經有 id 之後才會加上,也就是說它已經先在 dev.to 上線了。這是個刻意的、後續才選擇加入的動作,從來不是初始草稿模板的一部分。這篇文章就是「之前」的好例子:

---
title: "I Contain Multitudes (and Also Three Git Repos)"
published: false
description: "..."
tags: [astro, webdev, showdev]
---

沒有 id、沒有 crosspost、沒有 canonical_url。等這篇發佈後,如果我決定它也應該出現在 mattstratton.com 上,它就會補上這三個欄位,就像 decouple-release-from-deploy.md 已經做到的那樣:

id: 4061454
date: "2026-07-03T15:43:16Z"
crosspost: true
canonical_url: "https://www.mattstratton.com/writing/decouple-release-from-deploy/"

我其實很喜歡的一個設計細節是:這整件事會收斂成一個原子 commit,而不是先發布、再回頭補第二階段的流程。mattstratton.com 上交叉發布文章的 URL,在任何 build 發生之前,就能完全從檔名決定。dev.to 自己的文章 ID 是由伺服器指派、事前無法得知的。所以一個 commit 會同時建立新的 /writing/ 條目,並在同一個 diff 裡改寫 dev.to 原始來源的 canonical_url;而一次 push 則會正確觸發 Netlify 部署和 dev.to 既有的發布流程,沒有人需要盯著第二步。

這裡有個值得點名的陷阱,因為它看起來顯然沒問題,直到真的出問題為止:writing schema 從第一個 Astro 遷移 commit 開始,就一直有一個可選的 canonicalUrl 欄位,對應一種從未真的發生過的情況:一篇原生 /writing/ 條目,其實是某個真實原始來源在別處的複製品,例如鏡像的 Tiger Data 文章。在 crosspost script 出現之前,這個欄位唯一出現過的地方,只是一個 placeholder template 檔案裡被註解掉的一行,# canonicalUrl: ... # set when cross-posted。從來沒有人真的在正式文章上設定過它。但對 dev.to crosspost 而言,mattstratton.com 是正在成為 canonical source,這跟這個欄位原本保留的用途剛好相反,所以 crosspost script 必須知道在它產生的條目上把它留空,而不是習慣性地去套用一個已經命名好的欄位。

幾個怪到不行的 bug,當作調味

這篇文章的重點是「這套系統怎麼架起來」,不是「這裡有一份 bug report」,但過程中有幾個故障實在太精彩,不提一下可惜。

最棒的一個:當某篇文章的 canonical_url 在第一次發布之後又改變時,發布 workflow 開始在所有這類文章上以 422 失敗,錯誤內容是 {"error":"Tried to load unspecified class: Time","status":422}。根因是完整的 YAML round-trip 在 JS YAML 函式庫的規則下,悄悄把一個時間戳記原本的引號拿掉了,而這其實不一定需要引號:

# 修正前(會讓 dev.to 的 Ruby 後端壞掉)
date: 2026-07-03T15:43:16Z
# 修正後(加上引號,雙方都能正常解析)
date: "2026-07-03T15:43:16Z"

依 JS 函式庫的規則,兩種寫法都是完全合法的 YAML。可是 dev.to 的後端是 Ruby,而 Ruby 的 YAML parser 會把這個現在沒有引號、又長得像 ISO-8601 的純量,自動辨識成原生的 Time 物件,然後它的安全載入器又拒絕實例化它。兩個對「同一種」檔案格式的實作,默默對「一段裸字串可以變成什麼」這件事看法不一樣。最後的修法,是只針對 canonical_url 那一行做精準的單行替換,而不是整份檔案重新序列化。

再來幾個小的,快速帶過:

  • 兩個用不同工具編輯的檔案帶進了 Windows 換行,結果 crosspost: true\r 被解析成字串 "true\r",而不是布林值 true。那兩篇文章就這樣默默地從來沒有出現在可發布名單裡。
  • 一個 7 篇系列在這一整套工具出現之前,就有 dev.to 真正的 Series 功能,而且只有其中 2 篇真的有開啟。
  • 在同一個系列裡重寫內部連結時,碰到一個 substring replace 的 bug;剛好有一個打錯的 slug 竟然是另一個正確 slug 的字面前綴。後來靠把替換順序改成「先處理最長 slug」修掉。

有趣的部分:記憶與增益

這裡有兩個小功能值得提一下,因為它們真的很酷,而且也不太適合塞進上面任何一段。

演講網站的 Claude Code skills(新增一場演講、清理逐字稿、重新同步 talk memory)其實不需要 memory.build 才能運作。它們沒有它也能正常跑。但現在它們也把它加進來了,作為 AI agent 的永久記憶層,讓我在和 agent 一起處理內容時,它可以調出自己已經知道的某場演講或前一次 session 的資訊,而不用我每次都重新解釋脈絡。算是加分項,不是不可或缺的依賴。

/fitness 則是在建置時直接從 Liftosaur 的 API 抓取我的運動紀錄。它之所以存在,原因很單純:Liftosaur 自己的公開個人頁面根本沒有顯示我想要的那麼多東西,沒有真的趨勢視圖,也沒有任何我可以連給別人、用來說完整故事的頁面。Liftosaur 回傳的也不是乾淨的 JSON,而是一個精簡的 Liftoscript 文字 blob,所以我寫了一個小 parser 從裡面抽出日期、訓練計畫、動作和組數。個人最佳紀錄和趨勢折線圖都是在渲染時根據這些解析後的資料計算出來的,不是另外打 API 拿來的。

接下來還在待辦的事

現在的交叉發布觸發方式,還是一個我得記得手動執行的 CLI 指令。我真正想要的修法是:既然 dev.to workflow 本來就完全以 PR 為中心運作(匯入會以 PR 送達,id 會在同一次 push 上以自動 commit 的方式寫回去,絕不會再另外生出第二個 PR),那交叉發布這一步也應該採用完全相同的模式。做一個在 pull_request 上運作的 GitHub Action,偵測 crosspost: true,產生 /writing/ 條目,然後把它作為額外的 commit 推到同一個 PR 上,而不是手動跑腳本,或再開一個多餘的第二個 PR。我在寫這篇文章時已經為此開了一個 issue,因為我顯然一直以為它早就存在,結果其實沒有。

另外幾個我其實很期待的專案:在演講網站上做一張我到處講過哪些地方的地圖(活動資料已經有 lat/lng,所以這應該比較像一個有趣的下午,而不是一個專案),還有為 /writing/ 和 legacy archive 做出 真正的 hero 圖和縮圖設計,因為目前 crosspost script 只是把 cover image 直接內嵌到文章內文裡來補洞,而不是正確地渲染它。當然還有一堆例行維護工作(master 改成 main、升級到 Node 24),這些都是真的,但也不算什麼有趣的讀物。

但這些都還不到需要擋下已經能正常運作部分的程度。通常好清單就是這樣慢慢長出來的。


原文出處:https://dev.to/mattstratton/i-contain-multitudes-and-also-three-git-repos-33pf


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝17   💬3   ❤️5
723
🥈
我愛JS
📝2   💬5   ❤️3
144
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登