blog 加上搜尋功能:選了 pagefind,跟著踩了一次 Vercel + Turborepo 的 cache 坑
想幫 blog 補上搜尋,Google 的 search engine 會跳頁又怕帳單失控,改用 pagefind 做前端全文搜尋,卻意外在 Vercel/Turborepo 的 build cache 踩坑,才把設定補齊,讓索引穩定生效。
- pagefind
- search
- Vercel
- Turborepo
上禮拜整理內容、想下一步該幫 blog 補上什麼功能時,我腦中冒出的第一個念頭是——搜尋。內容型網站大概都會走到這一步,但要怎麼做、選什麼方案,中間藏著不少我一開始沒料到的細節。這篇記錄我怎麼從 Google 現成的方案繞了一圈,最後選定 pagefind,以及中途意外踩到的 Vercel/Turborepo cache 坑。
先評估 Google 的方案,但兩個理由讓我卻步
最先想到的是直接使用 Google Search 提供的 programmable search engine 功能。經過一番調查,發現它有一些限制:Google 爬蟲的執行時間,導致 post 發出之後會有一段延遲才能被搜尋到,這件事我可以接受;但套用之後 search result 會開在另一個分頁,把使用者跳出去的這個操作體驗,是我想避免的。而且更麻煩的是,免費版會夾帶 Google 的廣告,改用 API 呼叫雖然有免費額度,但總難免會擔心一覺醒來帳單炸裂的狀況。
選 pagefind,順便修正一個我對全文搜尋的誤解
所以經過了一番比較之後,我決定使用 pagefind 這個 library 來實作我的 blog 的 post search 功能。它的優點是可以直接在前端做搜尋,沒有延遲時間,也不會跳轉到其他頁面,使用者體驗比較好。
之前沒有深入去評估搜尋功能,我自己原始的理解是在 SSG 架構做搜尋功能,需要把所有內容做成倒序索引,然後傳送到前端才能做全文搜尋。我擔心的點是,如果網站的 post 內容開始變多的時候,這個倒序索引要傳送到前端,是不是會變得很大一包?傳送時間長,然後又浪費流量。但是,pagefind 這一個 library 的做法很聰明,它把需要比對的關鍵字做成一個一個小小的 chunk 索引。當使用者去搜尋到那一個關鍵字的時候,只要下載小小幾 KB 的 chunk,就能夠去搜尋,而不需要一次把所有的索引都抓下來。
更精確地說,pagefind 其實是由兩支程式組成:一支在 build time 跑的 CLI,另一支編譯成 WASM、丟到瀏覽器裡跑。SSG 產生完 HTML 之後,跑一次 npx pagefind --site dist,這支 CLI 會把 dist/ 底下所有 .html 掃過一遍——注意,它讀的是產出的 HTML,不是原始碼或 markdown,所以理論上跟用什麼框架寫內容無關。掃完之後,除了建立倒序索引,還會做一件關鍵的事:把索引依照「字的前綴」切成一包一包的小 chunk,每頁的標題、摘要跟 metadata 也各自存成獨立的 fragment 檔,不跟主索引綁在一起。
這個切法,才是真正讓我放心的地方。
到瀏覽器端,使用者打開搜尋框的那一刻,頁面只載入一小支 pagefind.js,由它再載入 WASM 搜尋引擎——這時候都還沒有下載任何內容索引。真正輸入關鍵字的瞬間,會先透過一個很小的查找表判斷這個字落在哪個 chunk 裡,然後只下載那一個 chunk,不是整包索引一次抓下來。WASM 在 chunk 裡面比對、算分排序,等到真的要顯示某一筆結果的標題跟摘要,才回頭下載對應的 fragment。
換句話說,我原本擔心的「文章一多,索引就會養成一頭巨獸」根本不會發生——使用者要下載多少東西,取決的是這次搜尋命中了幾筆結果,跟網站總共有幾篇文章、內容多大幾乎無關。這不是我第一次因為選了現成套件而省下重造輪子的力氣,上次是幫 blog 補 RSS feed 時也是同樣的心得——但這次省下的還多一層:連我自己一開始想錯的效能疑慮,都被這個 chunk 設計順手解掉了。
Vercel + Turborepo 的 cache 坑:改一份文件也能讓搜尋索引消失
因為我的網站是使用 Vercel 進行 deploy,踩到的問題是 Ignored Build Step 預設為 Automatic,而我的專案是使用 Turborepo 的 monorepo 架構,這個 Automatic 的設定會導致 pagefind 的 build step 在我只有改動外部文件(例如 README.md 或 AI 工具用的專案設定檔 CLAUDE.md 之類的)的時候,build result 被 cache hit 而沒有被執行到。
根因很快就抓到:turbo.json 裡 build task 的 outputs 欄位,只宣告了 .next/** 相關的路徑——沒有宣告 pagefind 產物的輸出目錄,而這個目錄本身又是 gitignored、由 postbuild script 產生的,天生就不在版本控制裡。
問題出在兩層機制被我混為一談:Ignored Build Step 決定的是「這次 build 要不要整個跳過」;Remote Caching 決定的是「build 裡個別 task 要不要重跑,還是直接 replay cache 的 log」——這是兩件完全不同的事,Vercel 匯入 Turborepo 專案時,這兩層預設都是開著的。當我只改動文件類檔案時,這個 monorepo 裡負責 blog 的那個 package 對應的 build task 的 hash 沒變,Turborepo 判斷是 cache hit,直接把上一次的 log 文字原樣印出來,不會真的重新執行一次 postbuild script。這一步本身沒問題——問題是,因為 pagefind 的輸出目錄從來沒有被宣告進 outputs,cache 裡根本沒有東西可以還原。到了一個全新環境,搜尋索引目錄整個不存在,頁面上顯示的是「搜尋索引尚未建立」。
修法說來簡單:把 outputs/inputs 老老實實列完整——pagefind 的產物路徑加進 outputs,blog 依賴的文章內容目錄加進 inputs。
"blog#build": {
"dependsOn": ["^build"],
"inputs": [
"$TURBO_DEFAULT$",
".env*",
"../../packages/content/content/**"
],
"outputs": [".next/**", "!.next/cache/**", "public/pagefind/**"],
"env": ["WEB_URL", "BLOG_URL", "VERCEL_ENV"]
}繞了這一圈,學到的不是 pagefind 本身,而是一個更通用的教訓:設定語意不確定的時候,與其憑文件片段猜,不如直接跑一次 --dry=json,看 Turborepo 實際 resolve 出來的東西——這比自己腦補可靠得多。而且這已經不是我第一次被 turbo cache 咬——之前換 MDX 引擎 時,我自己做過一輪維運面的檢查,當時就已經想過「turbo cache 會不會漏還原圖片資產」這個疑慮,這次總算讓我親眼見識到它變成真的坑是什麼樣子。
data-pagefind-* 的標記規約:我標了 body,排除了 toc
pagefind 認得幾個 data-pagefind-* 的 HTML attribute,我在這次改版只用到 4 個:
data-pagefind-body:標在<article>這層正文容器,劃出「這一頁真正算內文的邊界」——只有容器內的內容才會進索引。這個 attribute 有一個影響範圍很廣的規則:只要網站任何一頁出現過它,沒標記的頁面就會被整頁排除,完全不進索引。data-pagefind-ignore(不帶值):標在目錄(ToC)的<aside>上,預設行為是這塊內容不進全文搜尋結果,但 metadata、filter 還是照常處理;另外還有="all"的變體,連 metadata、filter 都一併跳過。data-pagefind-meta="date":標在文章的<time>上,把裡面的文字擷取出來,存成這一頁的 metadata。data-pagefind-filter="tags":標在 tag 的<li>上,把值登記成可以拿來篩選的分類。
我把 data-pagefind-body 標在 <article>、把 data-pagefind-ignore 標在目錄上——這裡有一個我一開始也沒細想的點:目錄元件本身,內部完全沒有任何 data-pagefind-meta 或 data-pagefind-filter,純粹是連到各標題的連結,所以在我這個案例裡,data-pagefind-ignore 的「預設行為」跟 ="all" 實際效果一模一樣——沒有東西可以被「照常處理」。事後回頭看,="all" 其實才是更精確的寫法,因為讀的人不用翻進目錄元件內部,確認裡面有沒有偷偷帶 meta 或 filter——規約我是弄懂了,但寫的時候還是有 code clarity 上可以更講究的地方。
完整規約分別記在官方文件的 Indexing、Metadata 與 Filtering 三頁裡。
現在的搜尋長什麼樣
現在的狀態是:首頁跟文章列表頁最上方都有一個搜尋框。打字的時候,底下會即時跳出下拉建議(debounce 過,不會每個按鍵都發一次查詢);按下 Enter 送出的話,會導到一個獨立的搜尋結果頁,URL 帶著查詢字串——可以分享、可以加書籤,重新整理也會拿到同一批結果,不是那種重新整理就消失的暫時狀態。
結果列表每一筆顯示標題、內容片段(snippet,命中的關鍵字會被反白)、日期跟 tags,點下去直接進文章頁。如果輸入的是空字串或整段空白,畫面顯示「請輸入搜尋文字」,不會真的送出查詢、也不會報錯;如果查無結果,顯示「找不到符合的文章」。索引只收我在 MDX frontmatter 標記為已發佈的文章,草稿不會出現在任何人的搜尋結果裡。
有一個我接受、但還沒解的限制:中文的搜尋目前是詞級命中,不支援跨詞的子字串比對——換句話說,如果一個詞被斷詞斷開,子字串搜不到,是已知且暫時擱置的 tradeoff。另外開發環境也有一個限制:next dev 不會產生 pagefind 索引,只有 production build 才有真正能測的搜尋,本機開發時看到的是 fallback 文案,不會白屏,只是搜不出東西。
回頭看:選了現成套件,不代表可以不管背後怎麼跑
會選 pagefind,是因為它把「別讓使用者等」跟「別把使用者送出頁面」這兩件事,用一個很聰明的 chunk 索引設計一次解決掉。但這次真正花掉我時間的,不是 pagefind 本身,是它跟 Vercel、跟 Turborepo monorepo 撞在一起時,那個誰都沒寫進文件的中間地帶——Ignored Build Step 是一層,Remote Caching 是另一層,兩層疊在一起才會生出文件改一改、搜尋功能就在生產環境消失這種怪事。
選現成套件省下的,從來只是不用自己刻演算法的力氣,不是不用搞懂它怎麼跟自己的部署管線互動的力氣。這條界線,大概會是我下次評估任何一個新工具時,會提早幾步就先問自己的問題。