最近把博客主题又双叒叕重写了一遍,嘛,生命不息,折腾不止。依然基于 Hugo,但这次把 React 缝合进去了,使用 Vite 构建前端,Tailwind CSS 编写样式,这篇博客记录下整个折腾过程。
新主题在这 https://github.com/sxyazi/hugo-theme-lavias
由于 Hugo 是一个静态站点生成器,而我又希望在前端使用 React,做成 SPA,所以就会有“数据来源”的问题。
为了同时满足对 SEO 的基本要求,我还是选择使用 Hugo 生成 HTML 格式,只不过这些 HTML 只包含最基本的结构:
<!-- https://github.com/sxyazi/hugo-theme-lavias/blob/main/layouts/_default/list.html -->
{{ define "content" }}
<section id="posts">
{{- range (.Paginator (.Site.Params.listPaginate | default 100)).Pages -}}
<article>
<a href="{{ .RelPermalink }}" title="{{ .Title }}">{{ .Title }}</a>
<time datetime="{{ .PublishDate.Format "2006-01-02T15:04:05Z0700" }}">{{ .PublishDate.Format "01-02" }}</time>
</article>
{{- end }}
{{ partial "paginator.html" . }}
</section>
{{ end }}
之后将它们放置在一个 hidden element 里,即下面的 <main>
,而面向用户的实际内容渲染到 <app>
中:
<!-- https://github.com/sxyazi/hugo-theme-lavias/blob/main/layouts/_default/baseof.html -->
<main>{{ block "content" . }}{{ end }}</main>
<app></app>
<noscript>You need to enable JavaScript to run this app.</noscript>
在使用这些数据时,我为每种数据源创建了一个单独的 Hook,这个 Hook 只是简单而单调地,从 main
中以 id
为标识,选择不同的数据,下面以文章列表 posts
为例:
// https://github.com/sxyazi/hugo-theme-lavias/blob/main/src/hooks/usePosts.ts
export type Post = {
title: string
link: string
date: Date
summary?: string
}
export const usePosts = () => {
const [posts, setPosts] = useState<Post[]>([])
const source = useSource()
useEffect(() => {
setPosts(
Array.from(source.querySelectorAll('#posts > article')).map((article) => {
const a = article.querySelector(':scope > a')
const time = article.querySelector(':scope > time')
return {
title: a?.textContent!,
link: a?.getAttribute('href')!,
date: new Date(time?.getAttribute('datetime')!),
summary: article.querySelector(':scope > details')?.textContent ?? undefined,
}
})
)
}, [source])
return posts
}
可以发现,上面代码中使用了 useSource()
这个 Hook,它的返回值是随着前端路由的变化,对最新 main
元素的引用:
// https://github.com/sxyazi/hugo-theme-lavias/blob/main/src/hooks/useSource.ts
export const EMPTY_DIV = document.createElement('div')
export const useSource = () => {
const location = useLocation()
const { source, path } = useContext(AppContext)
if (location.pathname !== path.current?.last) {
return EMPTY_DIV
}
return source
}
这里使用了 AppContext
,这是一个 custom context,主要的逻辑都在这里面实现:
// https://github.com/sxyazi/hugo-theme-lavias/blob/main/src/providers/AppProvider.ts
export const AppContext = createContext<{
source: HTMLElement
path: RefObject<{ entry: string; last: string }>
}>({
source: EMPTY_DIV,
path: createRef(),
})
export const AppProvider = ({ children }: { children: ComponentChildren }) => {
const location = useLocation()
const path = useRef({ entry: location.pathname, last: location.pathname })
const [source, setSource] = useState<HTMLElement>(document.querySelector('main')!)
useEffect(() => {
if (location.pathname === path.current.last) {
return
} else if (location.pathname === path.current.entry) {
path.current.last = path.current.entry
setSource(document.querySelector('main')!)
return
} else {
path.current.last = location.pathname
}
setSource(EMPTY_DIV)
fetch(location.pathname)
.then((res) => res.text())
.then((res) => parse(res).querySelector('main')!)
.then(setSource)
}, [location])
return <AppContext.Provider value={{ source, path }}>{children}</AppContext.Provider>
}
这个 context 被注入到 App 的根部,以便能够在任何位置都能使用它。
这部分是最麻烦的,由于 Vite 对项目结构的严苛要求,使得为了适配这类特殊场景,需要做的工作更多。为此,我写了一个单独的脚本做这件事:
// https://github.com/sxyazi/hugo-theme-lavias/blob/main/cmd.js
const base = dirname(fileURLToPath(import.meta.url))
switch (process.argv[2]) {
case 'dev':
const restore = replaceBase(true)
child_process.execSync('rm -rf dist')
try {
child_process.execSync('hugo -d themes/hugo-theme-lavias/dist --config config.yaml,themes/hugo-theme-lavias/hugo.dev.yaml', {
cwd: resolve(base, '../../'),
})
} finally {
restore()
}
child_process.spawnSync('pnpm', ['exec', 'vite', 'dev', 'dist', '--config', resolve(base, 'vite.config.js')], {
stdio: 'inherit',
})
break
case 'build':
child_process.execSync('rm -rf static/assets static/index.html')
child_process.spawnSync('pnpm', ['exec', 'vite', 'build', 'src', '--config', resolve(base, 'vite.config.js')], {
stdio: 'inherit',
})
replaceBase()
child_process.execSync('rm -rf static/index.html')
break
}
为了进入 dev 模式,在站点的根目录执行 hugo
构建,构建的资产放到 themes/hugo-theme-lavias/dist
,即相对于该主题的 dist
目录下。
构建时我指定了 2 个配置文件,一个是站点本身的,一个是为了 development 而复写的一些配置:
# https://github.com/sxyazi/hugo-theme-lavias/blob/main/hugo.dev.yaml
minify:
disableHTML: true
目前只是把 HTML 压缩关掉了,因为 Hugo 压缩后的 HTML 会删除部分 tag 的结束标记,而导致 babel 解析时出错。之后基于构建出的 dist
,执行 vite dev
,并指定上层的 vite.config.js
为配置文件:
// https://github.com/sxyazi/hugo-theme-lavias/blob/main/vite.config.ts
export default defineConfig({
// ...
resolve: {
alias: {
'/src': resolve(__dirname, 'src'),
},
},
})
这里指定 resolve
的原因,是因为 vite dev dist
会以 dist
目录为 project root 寻找 src
,然而 src
并不在 dist
里,而是与 vite.config.js
在同一层目录。
而对于最终构建,还需要为 Vite 指定构建目录,即构建到位于主题的 static
目录下,这个目录的文件会自动合并到 Hugo 站点中:
// https://github.com/sxyazi/hugo-theme-lavias/blob/main/vite.config.ts
export default defineConfig({
// ...
build: {
emptyOutDir: false,
outDir: resolve(__dirname, 'static'),
},
})
其它像上面出现的 extract
、replaceBase
等函数不再一一赘述,主要用途就是在 dev 时,将 layouts/_default/baseof.html
中的 script 替换为 /src/main.tsx
;并在 build 时,将该 script 替换为实际构建出来的带 hash 的文件名,如 /assets/index.d64dc623.js
,具体可以参考 cmd.js。
现在需要每次在本地执行 pnpm build
,并把构建的文件 push 上去。为了能够构建自动化,我添加了一个 GitHub Actions 的 workflow,它会在我 push 到 main
分支时自动完成构建,省去了本地构建的步骤。
由于这次把主题单独分离到了一个新的 repo,因此博客的构建脚本发生了一些变化,具体在这篇文章。