验证说明(读前必看):本文技术细节于 2026-07-30 对照官方文档
docs.halo.run(版本 2.24)及一篇 2026 年官方示例整理。Halo 迭代较快,落地前请以你实际安装版本的官方文档为准。
版本红线:本文以 Halo 2.x 为准。Halo 1.x 的模板引擎是 FreeMarker(.ftl文件),与 2.x 的 Thymeleaf(.html文件)不兼容——1.x 用户请勿直接照抄第 4 章代码。
写作状态:advisory。配置字段、全局变量已对照官方 2.24 文档;模板引擎与静态资源引用写法建议落地前再核一次你所用版本。
用 Halo 建站很爽,但套用现成主题总差点意思:想要的首页布局没有,配色改不动,作者信息块位置别扭。这时候与其继续找主题,不如自己做一个。本文带你从零做出一个能安装的 Halo 主题——不假设你精通 Java,只需要会写 HTML/CSS,并愿意看一点模板语法。读完你不仅能做出第一个主题,还会明白 Halo 到底是怎么把“主题文件”变成用户看到的页面的。
1. 先搞清 Halo 主题到底是什么
一句话:Halo 主题就是一个文件夹,里面放着一份“身份证”(theme.yaml)、若干模板文件和静态资源。Halo 启动时读取这个文件夹,根据你访问的页面(首页 / 文章页 / 分类页),挑对应的模板文件,把数据填进去,渲染成 HTML 发给浏览器。
两个关键角色你需要记住:
- 全局变量:Halo 在渲染时自动塞进模板的“现成数据”,比如站点标题
site.title、当前主题信息theme。不用你查数据库,直接用。 - Finder API:一组查询函数,比如
postFinder(查文章)、categoryFinder(查分类)。你想“取某分类下的前 5 篇文章”,就调它。
理解了“主题 = 文件夹 + 声明文件 + 模板(吃变量、调 API)”,后面的事就都是填空题了。
2. 开发前准备
确定主题放哪。 Halo 2.x 的主题住在运行目录的 themes/ 下。如果你用 Docker 启动(官方推荐),数据卷通常是 ~/.halo2,那么主题路径就是:
~/.halo2/themes/你的主题名/
直接在这个目录下建文件夹,Halo 后台就能“看到”它。
开实时预览。 开发时最怕“改一下刷新没反应”。Docker 运行请加环境变量 SPRING_THYMELEAF_CACHE=false 关掉模板缓存;用源码跑则在配置里加 spring.thymeleaf.cache: false。这样改完模板刷新即可生效。
编辑器与起点。 用 VS Code 即可。强烈建议别从空文件夹开始——官方提供了起步模板,省去目录约定的踩坑:
halo-sigs/theme-starter:最基础的主题模板,含标准目录结构。halo-sigs/theme-vite-starter:集成了 Vite,适合想用现代前端构建流程的人。
先把 theme-starter 克隆下来跑通,再在上面改,效率最高。
3. 主题骨架与必需文件
一个最小可加载的主题,根目录必须有 theme.yaml。下面是一个完整可直接用的示例(字段含义见注释):
apiVersion: theme.halo.run/v1alpha1
kind: Theme
metadata:
name: theme-foo # 必填,且必须与主题文件夹名一致,否则资源可能加载异常
spec:
displayName: 示例主题 # 必填,后台显示的名称
author:
name: halo-dev
website: https://halo.run
description: 一个示例主题
homepage: https://github.com/halo-sigs/theme-foo
repo: https://github.com/halo-sigs/theme-foo.git
issues: https://github.com/halo-sigs/theme-foo/issues
settingName: "theme-foo-setting" # 可选,配合 settings.yaml 做后台配置
configMapName: "theme-foo-configMap" # 可选,配置持久化名
customTemplates: # 可选,注册“自定义模板”供后台选用
post:
- name: 文档
description: 文档类型的文章
screenshot:
file: post_documentation.html
page:
- name: 关于
description: 关于页面
screenshot:
file: page_about.html
version: 1.0.0 # 必填,主题版本
requires: 2.0.0 # 必填,要求的最低 Halo 版本
license:
- name: "GPL-3.0"
url: "https://github.com/halo-sigs/theme-foo/blob/main/LICENSE"
必填四项:metadata.name、spec.displayName、spec.version、spec.requires。其余都是锦上添花。
推荐目录结构(2.x 常见约定,具体以官方“目录结构”文档为准):
theme-foo/
├── theme.yaml # 必需,主题身份证
├── settings.yaml # 可选,后台可配置项
├── templates/ # 模板文件(.html)
│ ├── index.html # 首页 / 文章列表
│ ├── post.html # 文章页
│ ├── category.html # 分类页
│ └── page.html # 独立页面
├── static/ # 静态资源
│ ├── css/
│ ├── js/
│ └── images/
├── i18n/ # 可选,多语言
└── screenshot.png # 后台预览图
提示:改完
theme.yaml后,去后台「主题 → 重载主题配置」才会生效,因为它会被持久化进数据库。
4. 模板怎么写
2.x 的引擎是 Thymeleaf。 模板就是普通 .html 文件,用 th:* 属性把数据“绑”上去,表达式写在 ${...} 里。这和 1.x 的 FreeMarker .ftl 完全不同,别混。
先用全局变量。 最常用的是 site:
site.title站点标题site.subtitle副标题site.url站点访问地址site.logoLogo 地址site.seoSEO 相关设置
一个最小首页(文章列表)index.html:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title th:text="${site.title}">站点标题</title>
<!-- 静态资源引用见第 5 章;路径写法各版本略有差异,请核对官方文档 -->
<link rel="stylesheet" href="/themes/theme-foo/static/css/style.css">
</head>
<body>
<header>
<a th:href="${site.url}" th:text="${site.title}">站点名</a>
<p th:text="${site.subtitle}">副标题</p>
</header>
<main>
<!-- posts.items 是当前页文章列表 -->
<article th:each="post : ${posts.items}">
<h2>
<a th:href="${post.status.permalink}" th:text="${post.spec.title}">文章标题</a>
</h2>
<time th:text="${#dates.format(post.spec.publishTime, 'yyyy-MM-dd')}">日期</time>
</article>
<!-- 分页:posts 自带 prevUrl / nextUrl / hasPrevious() / hasNext() -->
<nav th:if="${posts.hasPrevious() || posts.hasNext()}">
<a th:if="${posts.hasPrevious()}" th:href="${posts.prevUrl}">上一页</a>
<a th:if="${posts.hasNext()}" th:href="${posts.nextUrl}">下一页</a>
</nav>
</main>
</body>
</html>
一个最小文章页 post.html:
<article>
<h1 th:text="${post.spec.title}">标题</h1>
<time th:text="${#dates.format(post.spec.publishTime, 'yyyy-MM-dd')}">日期</time>
<!-- 正文:Halo 会把 Markdown 渲染成 HTML 后放进内容变量,具体变量名以官方“模板变量”文档为准 -->
<div th:utext="${post.spec.content}">正文</div>
<!-- 用 Finder 做分类面包屑 -->
<nav th:with="breadcrumbs = ${categoryFinder.getBreadcrumbs(post.categories[0].metadata.name)}">
<a href="/">首页</a>
<span th:each="bc : ${breadcrumbs}" th:text="${bc.spec.displayName}">分类</span>
</nav>
</article>
Finder API 小结(来自官方示例,2.x 通用):
postFinder.listByCategory(page, size, categoryName)取某分类文章categoryFinder.getByNames(names)按名取分类categoryFinder.getBreadcrumbs(name)取面包屑路径pluginFinder.available('插件名')判断某插件是否安装(用来条件渲染)
说明:上面
post.spec.content、post.spec.title等字段名取自官方 2026 年示例;若你的版本报错,以官方「模板变量」文档列出的字段为准。
自定义模板是 Halo 主题很香的能力:在 theme.yaml 的 spec.customTemplates 下注册后,后台就能给某篇文章 / 分类 / 页面选不同模板(比如给“文档”类文章套一套知识库样式)。注册方式见第 3 章的 customTemplates 示例。
5. 样式与静态资源
CSS / JS / 图片放进 static/ 目录。模板里引用时,最稳的写法是按“主题名 + 路径”拼绝对地址,例如:
<link rel="stylesheet" href="/themes/theme-foo/static/css/style.css">
<script src="/themes/theme-foo/static/js/main.js"></script>
注意:各版本对“静态资源 URI 变量”(如是否存在
theme.staticUri之类的快捷变量)表述不一。上面用硬编码路径最不容易出错;若想用变量,请核对官方「静态资源」文档中与你的版本对应的写法。
本地预览很简单:开着模板缓存关闭的开发模式,改完 static/ 下的文件刷新页面即可;若没生效,先在后台「重载主题配置」一次。
6. 安装、调试与上线
本地启用:把主题文件夹放进 ~/.halo2/themes/(Docker)或对应运行目录的 themes/,后台「主题」里就能看到,点启用即可。
改配置要重载:只要动过 theme.yaml(包括 customTemplates),必须后台「重载主题配置」,否则改的不生效——这是新手最高频的“为啥没变化”原因。
常见排查:
- 页面空白 / 500:多半是模板表达式写错(变量名拼错、Finder 参数不对)。看后台或运行日志的报错行号。
- 样式没加载:检查
static/路径和文件名;确认引用路径与主题文件夹名一致。 - 变量不识别:先确认你用的是 2.x 的 Thymeleaf 写法,而不是 1.x 的 FreeMarker。
发布出去:
- GitHub:仓库名建议
halo-theme-主题名,打上halo、halo-theme两个 topic,方便别人搜到。 - 官方应用市场:按官方提交流程上传,可被更多用户一键安装。
- 打包分发:把整个主题文件夹压缩成 zip 即可分享。
7. 进阶与可复用经验
- 设置项(
settings.yaml):把“每页文章数”“页脚文案”之类做成后台可填的字段,别人用你的主题不用改代码。在theme.yaml用spec.settingName关联。 - 多语言(
i18n/):面向国际用户时,把文案抽到语言文件,比硬编码强太多。 - 性能:列表页别一次性查全站文章;利用
posts自带的分页参数。静态资源尽量合并、压缩。 - 踩坑清单(收好):
metadata.name必须和文件夹名一致,否则资源加载异常。- 改
theme.yaml后一定要“重载主题配置”。 - 1.x 与 2.x 模板引擎不互通,clone 别人主题先看版本。
- 文档迭代快,落地前拿你实际版本的官方文档再对一遍字段。
做完第一个主题,你会发现自己不再被现成主题绑架:想要什么布局,写一段 HTML;想要什么数据,调一个 Finder。主题开发本质上是“用 Halo 给你的变量和 API,拼出你脑子里的页面”——门槛不在语法,在于先跑通一遍上面这七步。
本文写作状态为
advisory:配置字段与全局变量已对照官方 2.24 文档;模板引擎(Thymeleaf)与静态资源引用写法建议结合你安装的具体版本再确认一次。如用于正式发布,请标注“基于 Halo vX.Y 验证”。