把部落格從 Hexo 遷移到 Astro
這個 blog 原本是基於 Hexo 這套框架結合 NexT 主題所建立的,不過最近我把它整個用 Astro 這個現代的 SSG 框架重寫了一遍。因此這邊文章會記錄一下我遷移的過程和一些心得。
為什麼要遷移?
首先是其實這個 Blog 其實不完全是只靠 Hexo 所建,而是我再後面還使用了 gulp 這個 build system 去幫我做一些額外的改進,包括但不限於:
- 壓縮圖片
- 壓縮 HTML/CSS/JS/SVG/XML/JSON…
- 改寫 HTML 的
<a>
,<h1>
,<h2>
等標籤
而 gulp 本身目前已經是屬於一個不怎麼被維護的框架,例如在我寫這篇文章的當下最後一個 master
上的 commit 已經是 11 個月前了。 同時我自己也有踩到不少 bug,而有不少都是在已經有 PR 的情況下卻沒人 merge,例如這個。 這代表目前基本上它也沒什麼 maintainer 了,未來只會越來越難用。
此外我還有利用 Hexo 本身 plugin 功能去幫我做一些額外功能,例如文章加密等其他功能。 其他一些更 general 的功能則是直接對 NexT 主題做 contribution (在 NexT 的 old repo 上還能找到我加的一些功能)。 這種架構讓我想新增/修改功能都不太容易,因為要改的地方發散,也不好測試,因此整個 blog 的維護性很差。
為什麼是 Astro?
Hugo & Markdown
其實我在大概兩年前也有嘗試過把這個 blog 搬到 Hugo 上,但我遇到的最大障礙是在於它 markdown 的 parser 限制比較大。 因為我這個 blog 有一大半是 CTF writeup,裡面很多的 crypto 題目都會需要使用數學公式,而在 Markdown 中寫數學有個麻煩的問題是 backslash \
的問題。
我們知道在 latex 中的一些特殊 environment 中 \\
是有很多作用的,例如以下這個例子:
$$
\begin{align*}
a & = b + c \\
& = d + e
\end{align*}
$$
在這個地方 \\
是用來換行的,可是 \
在 markdown 中也是個 escape character,因此如果使用的 markdown parser 沒有對數學公式做特殊處裡的話很容易出問題。
我之前遇到的問題就是 Hugo 預設的 markdown parser 處裡不好這件事。而我原本在 Hexo 這邊使用的 markdown parser 是 pandoc,在這部分就做的很好。
看到這邊你可能會想說讓 Hugo 去使用 pandoc 作為 markdown parser 不就好了嗎?
確實,Hugo 也有支援這個功能,但它只能透過一個簡單的選項去啟用/停用 pandoc,沒辦法給 pandoc 額外去設定一些參數。 然而,想增加這個功能的 PR 2021 年就已經存在了,到現在 2025 年 3 月都還沒被 merge 進去。而這個功能對我來說是個 deal breaker,因此我就沒再去嘗試使用 Hugo 了。
選擇的原因
至於其它的 SSG 我就沒什麼能評價的點了,因為除了 Astro 之外我使用過的 SSG 就只有 Hexo 和 Hugo 了。 我之所以會知道 Astro 只是因為隨便去查了一下目前有哪些 SSG,然後發現 Astro 評價還不錯,因此就花了點去了解了一下 Astro 到底在做什麼。
了解之後知道它是個基於 Vite,能夠幫你生成 MPA 的 SSG 框架。自由度很高,從路徑到各種 markdown parser 等等的地方都能夠自訂,只要願意寫 code 基本上沒什麼做不到的東西。 且大部分的 code 都能透過 TypeScript 完成,有靜態類型的優勢也比傳統基於 string based template 的 SSG 好用很多。
當然,另一方面因為 Astro 的自由度很高,它本身也不像 Hexo 和 Hugo 一樣是針對 blog 特化的 SSG 框架,很多的功能都要靠自己去實作。不過幸好官方有寫了一篇怎麼用 Astro 搭建 Blog 的教學。 在結合教學和其他人的遷移心得後很快就能學會怎麼使用 Astro 這套框架了。
遷移需求
要從 Hexo 遷移到 Astro 是個工作量很大的事情,因為這基本上代表是要把整個網站重寫一遍,從 NexT 主題提供的 css & js 還有許多我自己加的其他功能。因此我想先先把有什麼需求先想清楚,避免寫到一半還要做大改動。
具體的需求有以下幾點:
- 完全 Static Site (可放到 Github Pages / Cloudflare Pages …)
- 舊 Blog 的網址都要保留,不要出現不必要的 404,因為 Cool URIs don’t change
- 網站的 Lighthouse Performance 不能變慢
1. 完全 Static Site
透過 Astro 不用多做什麼就能完成,因此不是個問題。
2. 保留舊網址
這部分是因為肯定有人在其他的地方引用我 blog 的文章,而作為一個讀者點一個外部連結出現 404 是個很糟糕的體驗,因此我希望能讓文章都能使用一樣的 url 去存取。
3. Lighthouse Performance
我原本的 blog 之所以會使用 gulp 的原因就是我之前有花過一些時間去對網站效能做些優化,在 lighthouse 上也拿到了不錯的表現,因此我希望新的 blog 也能保持一樣的表現。
舊 blog 在桌面板的 lighthouse 表現如下:
舊 blog 在手機板的 lighthouse 表現如下:
其他
其他我沒特別指定的其實就是隨便來,例如主題部分我沒太多的想法,但我也沒在現有的 Astro 主題中找到我比較喜歡的,因此打算以我原本 NexT 主題使用的 Pisces scheme 為大概的目標去完成。
遷移
安裝部分沒什麼好說的,直接按照官方教學去做就好了。寫這篇文章時 Astro 版本是 v5。
Collection
Astro 中有個 collection 機制可以用來管理類似性質的一些資料,例如 blog post 這種同質性很高的東西。
例如我這邊是希望能在 src/content/article
有個這樣的結構:
src/content/article
├── article-1
│ ├── index.md
│ └── image.jpg
├── article-2
│ ├── index.md
│ └── image.jpg
└── article-3
├── index.md
└── image.jpg
然後每個 md 上面都能放一個 frontmatter 來描述文章的 metadata,而這個 metadata 的 schema 可以自己定義成自己喜歡的架構。 而我本來就有很多寫好的文章,所以就繼續採用 Hexo 本來的格式,然後再額外加一些我本來用 hexo plugin 就有實作的一些額外 metadata。
要達成這個就只要在 src/content.config.ts
中加入以下的 code 即可。
import { defineCollection, z } from 'astro:content'
import { glob } from 'astro/loaders'
const article = defineCollection({
loader: glob({
pattern: '*/index.md',
base: './src/content/article',
generateId: x => {
return x.entry.replace(/\/index.md$/, '')
}
}),
schema: z.object({
title: z.string(),
date: z.coerce.date(),
categories: z.string(),
tags: z.array(z.string()).default(() => []),
excerpt: z.string().optional(),
math: z.boolean().default(() => false),
draft: z.boolean().default(() => false),
private: z
.object({
password: z.string(),
password_message: z.string().default(() => '請輸入密碼'),
wrong_password_message: z.string().default(() => '密碼錯誤'),
fallback_content: z.string().default(() => '本篇文章已被加密')
})
.optional()
})
})
export const collections = { article }
這邊就指定了目標目錄在哪,文章的 id 生成方法,已經 metadata 的 schema。
這麼設計也解決了我之前用 Hexo 的一個痛點,就是 markdown 中引用 image 的 relative path 是要直接指定 Post Asset Folder 中的檔案名稱,而目錄結構是:
article-1.md
article-1
└── image.jpg
這樣就會讓 markdown 編輯器 (e.g. VSCode) 的 preview 失效。 (相關 issue: hexojs/hexo#3245)
Article Model
雖然說在 Astro 要讀取 collection 的資料很簡單,只要一個
const articles = await getCollection('article')
就能達成,但它資料格式的設計其實沒到很好用。例如依照我上面的 metadata 定義的 date
是個 Date
object,而為了方便我希望可以先計算好文章的 year
, month
, date
等等 (url 需要用),所以會需要一個類似 derived property 的機制。
Derived Properties
這部分的一個做法是可以透過 zod 的 transform
+ pipe
完成,而我一開始也確實是這麼做的。 不過這方法的一個缺點是需要改動 content.config.ts
,而按照 Astro 的 caching 機制會導致每次改動 content.config.ts
都要重新 sync (重新讀取所有文章再 parse) 一次,導致開發體驗不好。
另外一點是說如果後來如果有想要透過 Markdown Plugins (Remark & Rehype) 去對 markdown 做額外處裡的話,前面用 zod 的方法就不太好用了。因為那些 plugin 要傳遞資訊給 Astro 的話只能透過 frontmatter
,而這個 frontmatter
和 markdown 的 frontmatter 不完全是同個東西!!!。
按照我的理解,Astro 的 content loader 一開始只會先 parse frontmatter,然後把剩下的 markdown 內容先存下來。之後要到你去呼叫 render
之後才會去 parse markdown 內容,所以此時它才會呼叫到你的 markdown plugins。因此在 plugins 中修改的 frontmatter 是不會反應到 CollectionEntry<'article'>
中的 data
。
正確做法其實要參考官方的 Add reading time 這篇文章,才會知道其實要在呼叫 render
之後從 remarkPluginFrontmatter
才能取得到修改後的 frontmatter。
我之所以會發現到這個是因為 Astro 其實沒有內建支援從文章中做摘要 (excerpt) 的功能,而原本的 blog 是透過 <!--more-->
這樣的 html 註解去從文章中分離出摘要的。 在 Astro 中要做這個功能需要自己寫 markdown plugin 才能做到,這部分可以參考後面的章節。
Defining a Model
那麼正確做法是什麼呢? 我這邊是自己再額外定義一個 model 類型把原本 Astro 給的 CollectionEntry<'article'>
wrap 起來,這樣不僅可以避免掉一直重複 sync 的問題,在需要用到 markdown plugins 傳過來的值時也能做到 eager render 的操作。
具體來說我大概是這麼做的:
import { type CollectionEntry, getCollection, render } from 'astro:content'
import { dateToUTCYMD } from './utils'
export async function getArticles() {
const articles: CollectionEntry<'article'>[] = await getCollection('article', post => !post.data.draft)
// sort by date, descending
articles.sort((a, b) => {
if (a.data.date.getTime() === b.data.date.getTime()) return 0
return a.data.date < b.data.date ? 1 : -1
})
return await Promise.all(
articles.map(async post => {
let excerpt = post.data.excerpt
if (!excerpt) {
// we use a plugin the generate the excerpt
const content = await render(post)
excerpt = content.remarkPluginFrontmatter.excerpt as string
}
const excerptText = excerpt.replace(/<[^>]+>/g, '')
const ymd = dateToUTCYMD(post.data.date)
const permalink = `/${ymd.year}/${ymd.month}/${ymd.date}/${post.id}/`
return {
...post.data,
id: post.id,
permalink,
ymd,
excerpt,
excerptText,
post
}
})
)
}
export type Article = Awaited<ReturnType<typeof getArticles>>[0]
這邊就利用 getCollection
取得文章資料,然後 map 做一些額外的轉換,例如取得 ymd
, permalink
等等,需要時還可以呼叫 render
去取得 markdown plugins 傳的值。
文章摘要
我這邊是透過寫個 rehype (處裡 HTML) 的 plugin,從裡面找到 <!--more-->
這樣的註解,然後把它之前的內容取出來作為文章的摘要。
因為 rehype/remark 都是基於 ast,所以要做到這件事是相當容易的:
import type { RehypePlugin } from '@astrojs/markdown-remark'
import { toHtml } from 'hast-util-to-html'
export const rehypeExcerpt: RehypePlugin = () => {
return (tree, file) => {
if (!file.data.astro) return
if (!file.data.astro.frontmatter) file.data.astro.frontmatter = {}
const { frontmatter } = file.data.astro
if (frontmatter.excerpt) return
const moreIdx = tree.children.findIndex(node => node.type === 'raw' && node.value === '<!--more-->')
const newTree = moreIdx !== -1 ? { ...tree, children: tree.children.slice(0, moreIdx) } : tree
frontmatter.excerpt = toHtml(newTree)
}
}
然後到 astro.config.mjs
中使用該 plugin 即可:
import { rehypeExcerpt } from './plugins/post-excerpt'
export default {
// ...
markdown: {
rehypePlugins: [rehypeExcerpt]
}
}
這樣就能用前面寫在 getArticles
中的 remarkPluginFrontmatter
取得到 excerpt
了。
Routing
文章
原本舊 blog 的文章 url 格式是 /[year]/[month]/[date]/[slug]/
,因此我這邊要沿用就直接建立 src/pages/[year]/[month]/[date]/[slug].astro
即可。
然後在裡面定義 getStaticPaths
讓它去根據目前 collection 中的文章去生成所有的路徑即可。
import { getArticles } from '@/utils/content'
export async function getStaticPaths() {
const articles = await getArticles()
return articles.map(article => {
return {
params: {
year: article.ymd.year,
month: article.ymd.month,
date: article.ymd.date,
slug: article.id
},
props: { article }
}
})
}
const { Content, headings } = await render(article.post)
分頁
首頁部分原本的 blog 是透過 /page/[page]/
這樣的路徑去分頁,每頁 5 篇文章。
在 Astro 中要完成這個也很簡單,首先先建立 src/pages/page/[page].astro
,然後在裡面透過 getStaticPaths
去生成所有的路徑,然後在 getStaticProps
中去取得每頁的文章即可。
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
const articles = await getArticles()
return paginate(articles, { pageSize: 5 })
}
const { page } = Astro.props
然後透過 page
物件把整個頁面 render 出來即可。詳細可參考官方教學。
其他
只要能把文章和分頁寫出來,其他的標籤與分類功能都是以此類推,實作起來沒有難度。因此這邊就不贅述了,因為都是差不多的東西。
主題
主題部分其實沒特別好說的,我這次是採用了 Tailwind CSS 當作 css 框架,然後自己拿著舊 blog 當模板直接慢慢刻出來的。 當然,我自己也有做許多不同的改動,例如手機板網站的 navbar & toc 按鈕的行為就完全是我自己重新設計的。
不過這部分也是最花時間與精力的部分,畢竟寫 css 真的好難 QQ。
基本上主題大部分都會寫在 src/layouts
底下,例如目前我這邊整個網站的通用 layout 都寫在 src/layouts/Layout.astro
中,足足有 300 行左右。HTML/JS/CSS 都有。然後其他頁面就直接 import 過來進來當作 component 使用即可,和寫 react/vue 等現代其端框架類似。
文章搜尋
原本舊的 blog 就有提供一個文章搜尋的功能,而它那個是透過 NexT 主題結合 hexo-generator-searchdb 去提供的。
它的作法是直接生成一個 search.xml
包含了所有文章的內容,然後透過前端的 js 去做搜尋。然而這個作法的最大問題在於 search.xml
大小會變得非常大。以我遷移前的大小的話是 1.3MB compressed, 7.4MB uncompressed。
而這次我就想有沒有更好的解決方案,因此就找到了 pagefind 這個開源 library。它是可以對全靜態網站做 index,然後透過前端 js/wasm 去動態搜尋,不需要後端 server 的任何支援。因此這很適合我這邊全 static website 的需求。
具體實作只要使用別人已經先寫好的 astro-pagefind 這個 integration 即可,很簡單就能在 blog 中加入一個好用的搜尋功能。
其他
其他部分其實還有許多細節,如 RSS, sitemap, robots.txt 等等,不過這些部分其實 Astro 官方教學都有提到,所以這邊也就只是一一實作出來即可。
專案架構
把整個 blog 重新寫好後,在寫這篇文章的當下,整個專案的架構是這樣的:
src
├── about.md
├── assets
│ └── maple3142.jpg
├── components
│ ├── CompactPostList.astro
│ ├── MarkdownContainer.astro
│ ├── Pagination.astro
│ ├── PostBlock.astro
│ ├── PostBody.astro
│ ├── PostFooter.astro
│ ├── PostHeader.astro
│ └── TOC.astro
├── content (去除掉了 article 資料夾)
├── content.config.ts
├── info.ts
├── layouts
│ └── Layout.astro
├── pages
│ ├── 404.astro
│ ├── about.astro
│ ├── archives.astro
│ ├── categories
│ │ ├── [category].astro
│ │ └── index.astro
│ ├── index.astro
│ ├── page
│ │ └── [page].astro
│ ├── robots.txt.ts
│ ├── rss.xml.ts
│ ├── search.astro
│ ├── tags
│ │ ├── index.astro
│ │ └── [tag].astro
│ └── [year]
│ └── [month]
│ └── [date]
│ └── [slug].astro
├── styles
│ └── global.css
└── utils
├── content.ts
├── crypto.ts
└── utils.ts
可以看到整個架構就都很有邏輯,和之前用 react/vue 等框架寫起來其實沒差多少,就一樣是把可重用的部分拆分出來成 component,然後各自 import 進來使用而已。
效能改進
Lighthouse performance 在首頁基本上和舊版是沒特別的變化,因為舊版的 blog 本來在首頁的分數就夠高了,很難有提升空間。不過改進的地方還是有的,就是文章頁面的部分。
以 TSCCTF 2025 Writeups 來說,舊版在 mobile 上只拿到了 85 分的 performance:
而新版在 mobile 上則是拿到了 90 分的 performance:
心得
在重寫完之後我對這次的結果我個人是蠻滿意的,一開始的設定的目標都有達成,而整個 blog 要改動起來也是容易很多。例如我如果未來某天對目前這個主題不滿意,要對 layout 做調整都是非常容易的。
Astro 優點
Astro 的優點我覺得蠻多的,例如以下幾個是我寫文章當下覺得比較明顯的:
- (幾乎)全 TypeScript 覆蓋
這部分讓我寫 code 時隨時想重構些東西都沒有任何障礙。同時模板語言也是用的 JSX,比起傳統的 string based template engine 來說還有 autocomplete + type check 的優勢存在。
- 極高的自由度
至少這點對於之前只用過 Hexo & Hugo 的我來說是非常明顯的,尤其是 Astro 的 collection 的設計更是漂亮。例如哪天我想把我的 My CTF Challenges 整合到我的 blog 中的話,只要多定義一個 collection & custom loader 去讀取那個 repo 的 markdown 就行了。
如果是用 Hexo 的話我自己倒是想不到要怎麼去做到這件事,因為它的 collection 基本上只有一個,就是預先定義好的 posts 而已,很難去做額外的擴展。
- Remark/Rehype AST
AST-based 的 plugins 寫起來真的很方便,不像我之前用 gulp 還需要特別找 HTML parser 在一堆 html 中找出我要的東西去修改,而且效能還很差,跟 AST-based 的工具比起來差的不是一點半點。
Astro 缺點
我自己覺得它最大的坑在於前面 Derived Properties 章節提到的問題,就是沒辦法直接從 collection 給出的 data 中拿到 plugin 修改後的 frontmatter。不過這部分我認為是只要 Astro 有這個 parse 和 render 是兩個分開的階段的設計,就無法避開的問題。
另外的可能缺點是 Astro 和 Hexo & Hugo 等這種 blog 導向的框架來說,對於非前端開發者使用起來算是相當的不友善,因為很多東西都必須靠自己寫 code 去實作 (e.g. 文章摘要)。所以我個人是不會推薦 Astro 給非前端開發者的,不過另一方面來說 Astro 對於前端開發者真的是個用起來非常舒服的框架。