從 Hexo 3.8 到 Astro 6:一個停更技術部落格的重啟工程

這個部落格停更多年後,我用 Astro 6 重建內容模型、舊網址、SEO、Disqus、搜尋與媒體流程,並逐一驗證 139 個歷史 HTML 路徑。

這個部落格停更了很久,主要原因不是沒有寫技術文章,而是後來幾乎都寫在 iThome 鐵人賽。等到想重新打開它時,問題已經不只是把 Hexo 升級,舊網址、搜尋引擎收錄、Disqus 留言與散落多年的圖片都還留在原地。

所以這次沒有直接套一個 Astro Theme,再把 Markdown 複製過去。我先把舊站當成一份已經對外承諾多年的介面,逐項記下來,再開始換底層。

先盤點,不急著建立 Astro 專案

搬遷前有三份狀態需要分清楚:

  • 本機 Hexo 專案有 36 篇 Markdown。
  • GitHub 與線上網站只有 35 篇公開文章。
  • 線上 master 實際存在 139 個 HTML 檔案。

多出來的 what-did-i-do-in-2021.md 從未公開,因此遷移後仍保持 draft: true。不能因為檔案存在,就把它當成線上內容一起發布。

接著我把 sitemap、線上 HTML、本機 Hexo build、分類、標籤、分頁、靜態資源、canonical 與 Disqus identity 都凍結成 manifest。這一步看起來不像寫網站,但它決定後面能不能回答一個很實際的問題:新站到底少了什麼?

URL 不是字串,是已經發布的相容性契約

舊文章使用 /:category/:title.html。更麻煩的是,線上還同時存在:

  • /CSS/css-flex-hexschool-game.html
  • /css/css-flex-hexschool-game.html
  • /tags/Hexo-searchdb/
  • /tags/hexo-searchdb/

這兩組路徑只差大小寫。在 macOS 常見的大小寫不敏感檔案系統裡,它們會互相覆蓋;到了 GitHub Pages 使用的 Linux 環境,卻是四個不同位置。

我先做 routing spike,確認 Astro 的 build.format: 'preserve' 能同時輸出實體 .html 文章與目錄型 index.html

export default defineConfig({
  site: 'https://magic-panda-engineer.github.io',
  output: 'static',
  trailingSlash: 'ignore',
  build: {
    format: 'preserve'
  }
});

真正驗證時則改在大小寫敏感的 APFS volume 建置。結果很直接:Astro 產生 141 個 HTML,Pagefind 掃描 141 頁並索引 36 頁;同一份內容放在大小寫不敏感目錄時,只看得到 139 個 HTML 與 35 個索引頁。這不是 Astro build 壞掉,而是兩組歷史 alias 在檔案系統層被合併了。

因此,這次所有 139 個既有 HTML 路徑都做 exact-case 比對,不能只抽首頁和幾篇文章測試。

不再用分類推算文章網址

舊 Hexo 設定會從分類與標題產生網址。這種作法剛開始很方便,但日後只要改分類,文章 URL、canonical 與 Disqus identifier 就可能一起變動。

新版使用 Astro Content Collections,每篇文章都明確保存:

{
  title,
  description,
  publishedAt,
  updatedAt,
  category,
  tags,
  legacyPath,
  canonicalPath,
  draft,
  cover,
  disqusIdentifier
}

Content Collection 採用官方的 glob() loader,並以 Zod schema 在 build 前檢查欄位。舊文章的 legacyPath 不由 Astro slug 猜測;新文章則固定使用 /posts/<stable-slug>.html,之後更換分類也不會改網址。

日期同樣不能偷懶。舊文章的發布與更新時間轉成帶有 +08:00 的 ISO 日期,保留 Asia/Taipei 時區,不讓搬遷日期變成所有文章的新更新日期。

圖片先留下原始證據,再做最佳化

舊文章有 76 張本人 S3 圖片,以及一張已經失效的 Facebook CDN 圖片。

76 張自有圖片先完整備份原始 bytes、metadata 與 SHA-256,總共 14,351,714 bytes;接著才產生 76 張文章 WebP 與 76 張 800px 卡片版本。PNG 的文章版使用 lossless WebP,JPEG 則限制最大寬度,避免只為了分數把原圖壓到無法閱讀。

那張拿不到的 Facebook 圖片沒有用生成圖片補洞。文章保留原始連結,並明確告訴讀者來源已失效。無法取得就是無法取得,遷移不應順便改寫歷史。

搜尋、留言與 service worker 都有歷史狀態

新版搜尋改用 Pagefind Extended,語言設為 zh-hant,同時保留舊站需要的 /search.xml。Disqus 仍使用原本大小寫敏感的 page.urlpage.identifier,但改成讀者點擊後才載入第三方 script。

/sw.js 也不能直接刪掉。曾經造訪過舊站的瀏覽器可能仍註冊著 service worker;若路徑突然變成 404,舊 cache 不一定會消失。新版 /sw.js 只做三件事:

  1. 清除舊 Hexo cache。
  2. claim 現有 clients。
  3. 自我註銷,不重新啟用 PWA。

SEO 則保留原有 canonical 行為、Search Console verification、Open Graph 與 og:url,再補上 Article、Person、BreadcrumbList JSON-LD、RSS、sitemap、robots 與品牌 404。失效的 UA Analytics 沒有被搬過來,也沒有為了讓設定看起來完整而捏造 GA4 ID。

我怎麼判斷遷移完成

在加入這次重啟文章之前,遷移基準的本機驗證結果如下:

項目結果
Astro check0 error、0 warning、0 hint
Production build141 個 HTML
Legacy URL139 / 139 exact case
公開文章結構35 / 35
Internal / fragment references4,543 / 275
圖片 variants152 / 152
靜態 accessibility141 / 141
Secret scan0 finding
Pagefind141 頁掃描、36 頁索引、zh-hant
Lighthouse mobilePerformance 99,其餘三項 100
Lighthouse desktop四項皆 100

內容比對不只看文章數量。每篇還會檢查標題、日期、分類、標籤、純文字長度、heading、link、image、iframe 與 code block 數量。Playwright 則在桌機與手機實際打開首頁、程式碼文章、圖片文章、iframe、中文標籤、about、搜尋與 404。

如果 migration 只驗證「新首頁看起來正常」,上面大部分問題都不會被發現。

為什麼這次選 Astro

2019 年選 Hexo,是因為 Node.js 生態系熟悉、外掛與主題完整,而且當時的 NexT Theme 很接近大魔術熊貓工程師的黑白意象。那個選擇在當時有它的條件,不能因為現在改用 Astro,就反過來說當年選錯了。

這次選 Astro,主要是目前的維護需求已經不同:

  • 我需要用 schema 固定文章資料,而不是繼續依賴鬆散 front matter。
  • 歷史路徑必須明確控制,不能跟著分類或 slug 規則變動。
  • 搜尋、SEO、文章卡片與年度導覽需要低耦合元件,方便日後替換設計。
  • 靜態輸出仍能放在 GitHub Pages,不必為了內容網站增加常駐伺服器。

框架只是讓這些決策比較容易落地。若沒有 inventory、相容性 manifest 與驗證腳本,換成任何框架都可能把舊站搬壞。

目前還沒有正式切換

GitHub Pages workflow 已經準備好,但保持手動觸發,並且需要 repository variable 與明確的 DEPLOY_APPROVED 輸入才會部署。Astro 官方也提供 GitHub Pages 部署流程,但「workflow 能執行」和「現在應該切換」是兩件事。

master 會保留作為回退來源。正式切換前仍要再審閱 migration branch、Linux build、139 個路徑與 cutover checklist。

這次重啟讓我重新確認一件事:部落格遷移不是把 Markdown 搬到新資料夾,而是把多年累積的公開行為,從隱含規則變成可以檢查的契約。畫面可以慢慢改,已經被讀者與搜尋引擎使用過的網址不能靠運氣。

Discussion

文章留言

留言服務會連線到 Disqus;只有在你選擇載入後才會建立外部連線。