从零创建一个 Halo 2.x 主题:从目录结构到上传运行
如果你用过 Halo,可能会想过:能不能不用别人的主题,自己做一个完全符合个人审美的站点?
答案当然是可以。Halo 2.x 的主题系统基于 Thymeleaf 模板引擎,结构清晰,上手成本并不高。这篇文章以一个最小可运行的 Halo 主题为例,讲清楚主题目录、配置文件、模板、静态资源、打包上传和常见坑。
一、先理解 Halo 主题的基本结构
一个最小可运行的 Halo 2.x 主题,通常长这样:
my-theme/
├── theme.yaml
├── settings.yaml
└── templates/
├── layout.html
├── index.html
├── post.html
├── page.html
├── archives.html
├── categories.html
├── category.html
├── tags.html
├── tag.html
├── author.html
├── error/
│ └── error.html
└── assets/
├── css/
│ └── style.css
└── js/
└── main.js其中最重要的文件是:
theme.yaml:主题基础信息settings.yaml:主题后台配置项templates/layout.html:全站公共布局templates/index.html:首页模板templates/post.html:文章页模板templates/page.html:独立页面模板templates/assets/:主题静态资源目录
二、创建 theme.yaml
theme.yaml 是 Halo 识别主题的入口文件。
一个基础示例如下:
apiVersion: theme.halo.run/v1alpha1
kind: Theme
metadata:
name: theme-my-theme
spec:
displayName: 我的第一款主题
author:
name: 你的名字
description: 一个简单干净的 Halo 2.x 主题
logo: /themes/theme-my-theme/assets/images/logo.svg
homepage: https://example.com
repo: https://example.com
issues: https://example.com
settingName: theme-my-theme-setting
configMapName: theme-my-theme-configMap
version: 1.0.0
requires: ">=2.0.0"
license:
- name: GPL-3.0有几个字段需要特别注意。
1. metadata.name
这是主题的唯一标识,建议使用小写英文、数字和中划线:
metadata:
name: theme-my-theme不要使用中文,也不要和已有主题重名。
2. settingName 和 configMapName
这两个名字要和 settings.yaml 中的配置对应起来。
例如:
settingName: theme-my-theme-setting
configMapName: theme-my-theme-configMap那么 settings.yaml 中的 metadata.name 就应该是:
metadata:
name: theme-my-theme-setting3. version
建议从 1.0.0 开始,后续按照语义化版本管理:
1.0.1:小修复1.1.0:新增功能2.0.0:重大改版
三、创建 settings.yaml
settings.yaml 用于定义主题后台的可视化配置项。
例如,你想让用户可以配置品牌名、副标题、浏览器图标、备案号:
apiVersion: v1alpha1
kind: Setting
metadata:
name: theme-my-theme-setting
spec:
forms:
- group: basic
label: 基础信息
formSchema:
- $formkit: text
name: brand_name
label: 站点品牌名
value: 我的博客
- $formkit: text
name: brand_code
label: 品牌副标
value: PERSONAL BLOG
- $formkit: text
name: browser_icon
label: 浏览器图标地址
- $formkit: text
name: apple_touch_icon
label: 苹果设备图标地址
- group: footer
label: 页脚设置
formSchema:
- $formkit: text
name: copyright
label: 版权说明
- $formkit: text
name: icp
label: ICP 备案号在模板中可以这样读取:
<span th:text="${theme.config?.basic?.brand_name ?: '我的博客'}"></span>其中 ?: 表示空值兜底。如果用户没有配置,就显示默认值。
四、创建公共布局 layout.html
layout.html 是整个主题的基础骨架,一般包含:
<head>顶部导航
主内容区域
页脚
公共 CSS
公共 JS
这里有一个非常关键的点:
th:fragment="html (head, content)"Halo 2.x 会校验这个声明。如果你的 layout.html 没有正确声明,后台可能会出现类似提示:
templates/layout.html must declare th:fragment="html (head, content)".一个基础布局如下:
<!doctype html>
<html lang="zh-CN"
xmlns:th="https://www.thymeleaf.org"
th:fragment="html (head, content)"
th:with="menu=${menuFinder.getPrimary()}">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1">
<th:block th:if="${head != null}">
<th:block th:replace="${head}"></th:block>
</th:block>
<link rel="stylesheet"
th:href="@{/assets/css/style.css?v={version}(version=${theme.spec.version})}">
</head>
<body>
<header class="site-header">
<a class="brand" href="/">
<span th:text="${theme.config?.basic?.brand_name ?: '我的博客'}"></span>
</a>
<nav aria-label="主导航">
<ul>
<th:block th:if="${menu != null}">
<li th:each="item : ${menu.menuItems}">
<a th:href="@{${item.status.href}}"
th:text="${item.status.displayName}"></a>
</li>
</th:block>
</ul>
</nav>
</header>
<main>
<th:block th:replace="${content}"></th:block>
</main>
<footer class="site-footer">
<p th:text="${theme.config?.footer?.copyright ?: 'Powered by Halo'}"></p>
</footer>
<script th:src="@{/assets/js/main.js}"></script>
</body>
</html>这个布局做了三件事:
接收子页面传入的
head接收子页面传入的
content提供全站公共导航和页脚
五、创建首页模板 index.html
首页模板可以只关心内容区,然后交给 layout.html 套壳。
<!doctype html>
<html xmlns:th="https://www.thymeleaf.org"
th:replace="~{layout :: html(head = ~{::head}, content = ~{::content})}">
<th:block th:fragment="head">
<title th:text="${site.title}"></title>
</th:block>
<th:block th:fragment="content">
<section class="hero">
<h1 th:text="${site.title}"></h1>
<p>这是我的个人博客</p>
</section>
<section class="post-list">
<h2>最新文章</h2>
<div th:if="${posts != null and not #lists.isEmpty(posts.items)}">
<article th:each="post : ${posts.items}">
<h3>
<a th:href="@{${post.status.permalink}}"
th:text="${post.spec.title}"></a>
</h3>
<p th:text="${post.status.excerpt}"></p>
<span th:if="${post.spec.publishTime != null}"
th:text="${#dates.format(post.spec.publishTime, 'yyyy-MM-dd')}"></span>
</article>
</div>
<div th:unless="${posts != null and not #lists.isEmpty(posts.items)}">
暂无文章
</div>
</section>
</th:block>
</html>这里有两个变量很常用:
${site.title}:站点标题${posts.items}:首页文章列表
发布时间可能为空,所以建议加判断:
th:if="${post.spec.publishTime != null}"六、创建文章页 post.html
文章页的核心是渲染标题、发布时间、标签和正文。
<!doctype html>
<html xmlns:th="https://www.thymeleaf.org"
th:replace="~{layout :: html(head = ~{::head}, content = ~{::content})}">
<th:block th:fragment="head">
<title th:text="${post.spec.title + ' - ' + site.title}"></title>
</th:block>
<th:block th:fragment="content">
<article class="post">
<h1 th:text="${post.spec.title}"></h1>
<div class="post-meta">
<span th:if="${post.spec.publishTime != null}"
th:text="${#dates.format(post.spec.publishTime, 'yyyy-MM-dd HH:mm')}"></span>
<th:block th:if="${post.tags != null}">
<a th:each="tag : ${post.tags}"
th:href="@{${tag.status.permalink}}"
th:text="${tag.spec.displayName}"></a>
</th:block>
</div>
<div class="markdown-body"
th:utext="${post.content.content}"></div>
</article>
</th:block>
</html>注意这里用的是:
th:utext="${post.content.content}"因为文章正文通常是 HTML,需要按 HTML 输出,而不是纯文本。
七、创建独立页面 page.html
独立页面和文章页类似,只是变量从 post 变成了 singlePage。
<!doctype html>
<html xmlns:th="https://www.thymeleaf.org"
th:replace="~{layout :: html(head = ~{::head}, content = ~{::content})}">
<th:block th:fragment="head">
<title th:text="${singlePage.spec.title + ' - ' + site.title}"></title>
</th:block>
<th:block th:fragment="content">
<article class="page">
<h1 th:text="${singlePage.spec.title}"></h1>
<div class="markdown-body"
th:utext="${singlePage.content.content}"></div>
</article>
</th:block>
</html>例如「关于我」「用户协议」「隐私政策」这类页面,一般都会走这个模板。
八、处理静态资源
主题的 CSS、JS、图片通常放在:
templates/assets/例如:
templates/assets/css/style.css
templates/assets/js/main.js
templates/assets/images/logo.svg在模板中可以这样引用:
<link rel="stylesheet"
th:href="@{/assets/css/style.css}">如果你上传主题后出现 404:资源不存在,建议打开浏览器开发者工具,查看请求的实际地址。
有些部署环境下,静态资源最终会变成:
/themes/{metadata.name}/assets/css/style.css例如主题名是:
metadata:
name: theme-my-theme那么可以写成:
<link rel="stylesheet"
th:href="@{/themes/theme-my-theme/assets/css/style.css}">这是新手最常遇到的问题之一。判断标准很简单:看浏览器 Network 面板里的 CSS 请求到底是不是 404。
九、加入浏览器图标和苹果图标
可以在 layout.html 的 <head> 中加入:
<link th:if="${not #strings.isEmpty(theme.config?.basic?.browser_icon)}"
rel="icon"
th:href="${theme.config?.basic?.browser_icon}">
<link th:if="${not #strings.isEmpty(theme.config?.basic?.apple_touch_icon)}"
rel="apple-touch-icon"
th:href="${theme.config?.basic?.apple_touch_icon}">这样用户就可以直接在 Halo 后台上传或填写图标地址,不需要修改主题代码。
十、处理归档、分类和标签页面
归档页通常使用 Halo 提供的 archives 数据:
<th:block th:each="archive : ${archives.items}">
<h2 th:text="${archive.year}"></h2>
<th:block th:each="month : ${archive.months}">
<h3 th:text="${month.month} + ' 月'"></h3>
<ul>
<li th:each="post : ${month.posts}">
<a th:href="@{${post.status.permalink}}"
th:text="${post.spec.title}"></a>
</li>
</ul>
</th:block>
</th:block>分类列表页使用:
<a th:each="category : ${categories}"
th:href="@{${category.status.permalink}}"
th:text="${category.spec.displayName}"></a>标签列表页使用:
<a th:each="tag : ${tags}"
th:href="@{${tag.status.permalink}}"
th:text="${tag.spec.displayName}"></a>分类详情页和标签详情页的结构类似,核心都是渲染当前分类或标签下的文章列表。
十一、添加错误页面
错误页放在:
templates/error/error.html一个简单示例:
<!doctype html>
<html xmlns:th="https://www.thymeleaf.org"
th:replace="~{layout :: html(head = ~{::head}, content = ~{::content})}">
<th:block th:fragment="head">
<title th:text="${'页面不存在 - ' + site.title}"></title>
</th:block>
<th:block th:fragment="content">
<section class="error-page">
<h1>404</h1>
<p>页面不存在或已经移动。</p>
<a href="/">返回首页</a>
</section>
</th:block>
</html>错误页不一定只处理 404,也可以处理 500、403 等异常状态。对个人网站来说,至少要有一个友好的兜底页面。
十二、打包主题
打包时,压缩包内部应该是这样:
theme.yaml
settings.yaml
templates/而不是这样:
my-theme/
theme.yaml
settings.yaml
templates/也就是说,theme.yaml 应该位于压缩包根目录,不要多包一层目录。
如果使用 Python 打包,可以这样:
from pathlib import Path
import zipfile
base = Path("my-theme")
output = Path("theme-my-theme-1.0.0.zip")
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive:
for path in sorted(base.rglob("*")):
if path.is_file():
archive.write(path, arcname=path.relative_to(base).as_posix())打包完成后,可以先检查一下内容:
tar -tf theme-my-theme-1.0.0.zip如果第一行就是 theme.yaml,说明结构正确。
十三、上传到 Halo
进入 Halo 后台:
打开「外观」
进入「主题」
点击「安装」
选择刚才生成的 zip 包
启用主题
安装后不要急着庆祝,先检查这几个地方:
首页能否正常打开
文章页能否正常打开
CSS 是否加载成功
JS 是否加载成功
后台主题设置是否正常显示
移动端布局是否正常
layout.html校验是否通过浏览器图标是否正常显示
十四、常见问题
1. 上传后提示 404
优先检查静态资源路径。
如果 CSS 或 JS 请求返回 404,说明资源路径和 Halo 实际解析路径不一致。
2. 提示 layout.html 校验失败
检查这一行是否完全正确:
th:fragment="html (head, content)"不要写成:
th:fragment="html(head,content)"虽然有些格式也能被解析,但最稳妥的方式是保持标准写法。
3. 后台配置不生效
检查三个地方:
theme.yaml里的settingNamesettings.yaml里的metadata.name模板里读取配置的路径
例如配置组是 basic,字段名是 brand_name,模板就应该读取:
${theme.config?.basic?.brand_name}4. 主题上传失败
常见原因是压缩包结构不对,或者 theme.yaml 不在根目录。
5. 页面空白
可能是 Thymeleaf 表达式执行异常。可以从后台日志里查看具体报错,常见问题包括:
变量名写错
空对象直接取属性
日期格式化时没有判断空值
th:replace写法错误
十五、建议的开发顺序
自己做一个主题时,不建议一开始就把所有页面写完。推荐顺序是:
先完成
theme.yaml再完成
settings.yaml写
layout.html写
index.html确认首页可以正常渲染
写
post.html确认文章页可以正常渲染
再补齐
page.html、归档、分类、标签最后处理错误页和移动端细节
打包上传,做完整检查
先跑通最小闭环,再逐步扩展功能,这样排查问题会容易很多。
结语
Halo 主题开发本质上就是三件事:
用
theme.yaml声明主题用
settings.yaml提供配置能力用 Thymeleaf 模板渲染站点数据
理解这三点之后,剩下的就是设计布局、写样式、处理细节。
如果你打算长期维护自己的博客,自己做一个主题是很值得的。它不只是换一套外观,而是把你的内容结构、阅读习惯和审美偏好,变成一个真正属于自己的网站。