深林照梦 深林照梦

从零创建一个 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-setting

3. 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>

这个布局做了三件事:

  1. 接收子页面传入的 head

  2. 接收子页面传入的 content

  3. 提供全站公共导航和页脚

五、创建首页模板 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 后台:

  1. 打开「外观」

  2. 进入「主题」

  3. 点击「安装」

  4. 选择刚才生成的 zip 包

  5. 启用主题

安装后不要急着庆祝,先检查这几个地方:

  • 首页能否正常打开

  • 文章页能否正常打开

  • 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 里的 settingName

  • settings.yaml 里的 metadata.name

  • 模板里读取配置的路径

例如配置组是 basic,字段名是 brand_name,模板就应该读取:

${theme.config?.basic?.brand_name}

4. 主题上传失败

常见原因是压缩包结构不对,或者 theme.yaml 不在根目录。

5. 页面空白

可能是 Thymeleaf 表达式执行异常。可以从后台日志里查看具体报错,常见问题包括:

  • 变量名写错

  • 空对象直接取属性

  • 日期格式化时没有判断空值

  • th:replace 写法错误

十五、建议的开发顺序

自己做一个主题时,不建议一开始就把所有页面写完。推荐顺序是:

  1. 先完成 theme.yaml

  2. 再完成 settings.yaml

  3. layout.html

  4. index.html

  5. 确认首页可以正常渲染

  6. post.html

  7. 确认文章页可以正常渲染

  8. 再补齐 page.html、归档、分类、标签

  9. 最后处理错误页和移动端细节

  10. 打包上传,做完整检查

先跑通最小闭环,再逐步扩展功能,这样排查问题会容易很多。

结语

Halo 主题开发本质上就是三件事:

  1. theme.yaml 声明主题

  2. settings.yaml 提供配置能力

  3. 用 Thymeleaf 模板渲染站点数据

理解这三点之后,剩下的就是设计布局、写样式、处理细节。

如果你打算长期维护自己的博客,自己做一个主题是很值得的。它不只是换一套外观,而是把你的内容结构、阅读习惯和审美偏好,变成一个真正属于自己的网站。