把部落格從 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*}
$$a=b+c=d+e在這個地方 \\ 是用來換行的,可是 \ 在 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 對於前端開發者真的是個用起來非常舒服的框架。