feat: 初始化 Halo 暗色模式插件

- Halo Plugin 后端(Java/Gradle),含 DarkModePlugin 主类和测试
- Vue 3 + TypeScript 前端 UI,包含主题切换组件和设置页面
- 暗色模式 CSS 变量和覆盖样式(布局/编辑器/表单/滚动条等)
- 设计文档和调查文档
- Halo 插件/主题开发 Agent Skills

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-06 21:03:00 +08:00
commit edbf78f236
115 changed files with 14745 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
---
name: halo-theme-dev
description: >
Use when creating or modifying a Halo CMS theme, writing Thymeleaf templates,
configuring theme.yaml or settings.yaml, calling Finder APIs, using
vite-plugin-halo-theme, defining theme settings forms, referencing static assets,
implementing halo:comment or halo:footer extension points, defining model
annotation fields (AnnotationSetting), adding i18n support, or handling error pages.
Always use this skill when the user mentions themes, templates, Thymeleaf, theme
configuration, or wants to customize the frontend appearance of a Halo site —
even if they do not explicitly say "theme."
---
# Halo Theme Development
Halo is built on **Spring Boot + Spring WebFlux + Thymeleaf**. Themes use Thymeleaf templates for frontend page rendering.
> **Important**: Halo's APIs, VO field names, and template variables evolve across versions. **Do not rely on training data for specific field names, method signatures, or type structures.** When writing code that accesses template variables or calls Finder API methods, always fetch the relevant online doc from the References section below first.
## Thymeleaf Quick Reference
Full docs: https://raw.githubusercontent.com/thymeleaf/thymeleaf-docs/refs/heads/master/docs/tutorials/3.1/usingthymeleaf.md
Core syntax cheatsheet:
```html
<!-- Output text -->
<h1 th:text="${site.title}"></h1>
<!-- Output unescaped HTML -->
<div th:utext="${post.content.content}"></div>
<!-- Links -->
<a th:href="@{${post.status.permalink}}">Post link</a>
<link rel="stylesheet" th:href="@{/assets/dist/style.css}" />
<!-- Loop -->
<li th:each="post : ${posts.items}" th:text="${post.spec.title}"></li>
<!-- Conditionals -->
<div th:if="${posts.hasNext()}">Next page</div>
<div th:unless="${posts.hasNext()}">Last page</div>
<!-- Local variable -->
<div th:with="menu = ${menuFinder.getPrimary()}">...</div>
<!-- Fragment include -->
<div th:replace="~{fragments/header :: header}"></div>
<!-- Layout reuse: pages pass fragments into a parameterized layout -->
<html th:replace="~{layout :: html(head = null, content = ~{::content})}">
<th:block th:fragment="content"><!-- page body --></th:block>
</html>
<!-- Inline JavaScript -->
<script th:inline="javascript">
var url = '[(${#theme.assets("/dist/main.iife.js")})]';
</script>
```
## Development Workflow
1. Create a theme folder under `themes/` in the Halo working directory (must match `metadata.name` in `theme.yaml`)
2. Write `theme.yaml` (required) and `settings.yaml` (optional)
3. Create template files under `templates/`
4. Install and activate the theme in Console → Theme Management
5. Visit the frontend to verify
Disable Thymeleaf caching during development: set env var `SPRING_THYMELEAF_CACHE=false` (Docker), or `spring.thymeleaf.cache: false` in config (source mode).
## Starter Templates
The `assets/` directory provides two ready-to-use theme templates:
- **`assets/theme-minimal/`** — Zero-build-tool minimal theme with all 8 template files; ideal for quick prototyping or simple themes
- **`assets/theme-vite/`** — Vite project template with `vite-plugin-halo-theme` (**recommended for new themes**); includes partial layout reuse and CSS toolchain
Usage: copy the directory into `themes/` in your Halo working directory, ensure the folder name matches `metadata.name` in `theme.yaml`, then install and activate in Console.
## References Index
| File | Content | When to read |
| ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| [references/api-changelog.md](references/api-changelog.md) | High-impact theme API changes by Halo version, with docs routes | Before using version-sensitive APIs or raising `spec.requires` |
| [references/structure-and-config.md](references/structure-and-config.md) | Directory structure, theme.yaml fields, root screenshot, settings.yaml form definition | Creating a theme, configuring theme.yaml/settings.yaml |
| [references/vite-plugin.md](references/vite-plugin.md) | vite-plugin-halo-theme integration guide, include/slot template syntax, TailwindCSS integration | Setting up a Vite-based theme (recommended) |
| [references/templates.md](references/templates.md) | Template route mapping, available variables per template | Writing template files |
| [references/global-variables.md](references/global-variables.md) | Global variables (site, theme, theme.config) and type definitions | Accessing site info or theme setting values |
| [references/finder-apis.md](references/finder-apis.md) | All Finder APIs (postFinder, categoryFinder, tagFinder, menuFinder, singlePageFinder, etc.) | Querying data from any template |
| [references/static-resources.md](references/static-resources.md) | Static asset reference methods (`@{}`, `#theme.assets()`) | Referencing CSS/JS/images in plain HTML themes |
| [references/template-tags.md](references/template-tags.md) | Custom tags (halo:comment extension point, halo:footer injection) | Integrating comment plugins, injecting footer code |
| [references/i18n.md](references/i18n.md) | Internationalization via `.properties` files, `#messages`, `#locale`, frontend i18n injection | Adding multi-language support to a theme |
| [references/official-plugins.md](references/official-plugins.md) | Official plugin integration: pluginFinder.available(), search widget, dark mode color scheme adaptation | Adding search, adapting dark mode for plugin UI |
| [references/annotations.md](references/annotations.md) | AnnotationSetting for model custom fields, `#annotations` utility for reading metadata in templates | Adding custom fields to menu items/posts/categories and using them in templates |
| [references/packaging.md](references/packaging.md) | Packaging a theme as a ZIP using `@halo-dev/theme-package-cli` | Preparing a theme for release or upload |
| [references/thymeleaf-tips.md](references/thymeleaf-tips.md) | Halo-specific Thymeleaf best practices: literal substitutions, safe navigation, meta tag rules, permalink syntax | Writing any template file |
@@ -0,0 +1,14 @@
apiVersion: v1alpha1
kind: Setting
metadata:
name: theme-minimal-setting
spec:
forms:
- group: basic
label: Basic Settings
formSchema:
- $formkit: text
name: custom_footer
label: Custom footer text
value: ""
placeholder: "e.g. Copyright © 2024 My Site"
@@ -0,0 +1,25 @@
<!doctype html>
<html
xmlns:th="https://www.thymeleaf.org"
th:replace="~{layout :: html(head = null, content = ~{::content})}"
>
<th:block th:fragment="content">
<h1>Archives</h1>
<th:block th:each="archive : ${archives.items}">
<h2 th:text="${archive.year}"></h2>
<th:block th:each="month : ${archive.months}">
<h3 th:text="|${archive.year}-${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>
<nav th:if="${archives.hasPrevious() || archives.hasNext()}">
<a th:if="${archives.hasPrevious()}" th:href="@{${archives.prevUrl}}">Previous</a>
<span th:text="|${archives.page} / ${archives.totalPages}|"></span>
<a th:if="${archives.hasNext()}" th:href="@{${archives.nextUrl}}">Next</a>
</nav>
</th:block>
</html>
@@ -0,0 +1,34 @@
body {
max-width: 800px;
margin: 0 auto;
padding: 1rem;
font-family: system-ui, sans-serif;
}
a {
color: #0070f3;
}
header {
display: flex;
gap: 1rem;
align-items: center;
padding-bottom: 1rem;
border-bottom: 1px solid #eee;
}
header a {
text-decoration: none;
color: inherit;
}
nav {
display: flex;
gap: 1rem;
}
footer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #eee;
color: #666;
font-size: 0.875rem;
}
article img {
max-width: 100%;
}
@@ -0,0 +1,26 @@
<!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="|${author.spec.displayName} - ${site.title}|"></title>
</th:block>
<th:block th:fragment="content">
<h1 th:text="${author.spec.displayName}"></h1>
<p th:if="${not #strings.isEmpty(author.spec.bio)}" th:text="${author.spec.bio}"></p>
<ul>
<li th:each="post : ${posts.items}">
<a th:href="@{${post.status.permalink}}" th:text="${post.spec.title}"></a>
<time th:text="${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd')}"></time>
</li>
</ul>
<nav th:if="${posts.hasPrevious() || posts.hasNext()}">
<a th:if="${posts.hasPrevious()}" th:href="@{${posts.prevUrl}}">Previous</a>
<span th:text="|${posts.page} / ${posts.totalPages}|"></span>
<a th:if="${posts.hasNext()}" th:href="@{${posts.nextUrl}}">Next</a>
</nav>
</th:block>
</html>
@@ -0,0 +1,17 @@
<!doctype html>
<html
xmlns:th="https://www.thymeleaf.org"
th:replace="~{layout :: html(head = null, content = ~{::content})}"
>
<th:block th:fragment="content">
<h1>Categories</h1>
<ul>
<li th:each="cat : ${categories.items}">
<a
th:href="@{${cat.status.permalink}}"
th:text="|${cat.spec.displayName} (${cat.postCount})|"
></a>
</li>
</ul>
</th:block>
</html>
@@ -0,0 +1,20 @@
<!doctype html>
<html
xmlns:th="https://www.thymeleaf.org"
th:replace="~{layout :: html(head = null, content = ~{::content})}"
>
<th:block th:fragment="content">
<h1 th:text="|Category: ${category.spec.displayName}|"></h1>
<ul>
<li th:each="post : ${posts.items}">
<a th:href="@{${post.status.permalink}}" th:text="${post.spec.title}"></a>
<time th:text="${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd')}"></time>
</li>
</ul>
<nav th:if="${posts.hasPrevious() || posts.hasNext()}">
<a th:if="${posts.hasPrevious()}" th:href="@{${posts.prevUrl}}">Previous</a>
<span th:text="|${posts.page} / ${posts.totalPages}|"></span>
<a th:if="${posts.hasNext()}" th:href="@{${posts.nextUrl}}">Next</a>
</nav>
</th:block>
</html>
@@ -0,0 +1,19 @@
<!doctype html>
<html
xmlns:th="https://www.thymeleaf.org"
th:replace="~{layout :: html(head = null, content = ~{::content})}"
>
<th:block th:fragment="content">
<ul>
<li th:each="post : ${posts.items}">
<a th:href="@{${post.status.permalink}}" th:text="${post.spec.title}"></a>
<time th:text="${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd')}"></time>
</li>
</ul>
<nav th:if="${posts.hasPrevious() || posts.hasNext()}">
<a th:if="${posts.hasPrevious()}" th:href="@{${posts.prevUrl}}">Previous</a>
<span th:text="|${posts.page} / ${posts.totalPages}|"></span>
<a th:if="${posts.hasNext()}" th:href="@{${posts.nextUrl}}">Next</a>
</nav>
</th:block>
</html>
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en" xmlns:th="https://www.thymeleaf.org" th:fragment="html (head, content)">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title th:text="${site.title}">Site Title</title>
<link rel="stylesheet" th:href="@{/assets/css/style.css?v={v}(v=${theme.spec.version})}" />
<th:block th:if="${head != null}">
<th:block th:replace="${head}" />
</th:block>
</head>
<body>
<header>
<a th:href="@{/}" th:text="${site.title}"></a>
<nav th:with="menu = ${menuFinder.getPrimary()}">
<a
th:each="item : ${menu.menuItems}"
th:href="@{${item.status.href}}"
th:text="${item.status.displayName}"
th:target="${item.spec.target?.value}"
></a>
</nav>
</header>
<main>
<th:block th:replace="${content}" />
</main>
<footer>
<p th:text="${theme.config.basic.custom_footer ?: site.title}"></p>
<halo:footer />
</footer>
</body>
</html>
@@ -0,0 +1,23 @@
<!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>
<h1 th:text="${singlePage.spec.title}"></h1>
<div th:utext="${singlePage.content.content}"></div>
</article>
<div th:if="${haloCommentEnabled}">
<halo:comment
group="content.halo.run"
kind="SinglePage"
th:attr="name=${singlePage.metadata.name}"
/>
</div>
</th:block>
</html>
@@ -0,0 +1,42 @@
<!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>
<h1 th:text="${post.spec.title}"></h1>
<time th:text="${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd')}"></time>
<div th:utext="${post.content.content}"></div>
<div th:if="${not #lists.isEmpty(post.tags)}">
<a
th:each="tag : ${post.tags}"
th:href="@{${tag.status.permalink}}"
th:text="${tag.spec.displayName}"
></a>
</div>
</article>
<nav th:with="cursor = ${postFinder.cursor(post.metadata.name)}">
<a
th:if="${cursor.hasPrevious()}"
th:href="@{${cursor.previous.status.permalink}}"
th:text="${cursor.previous.spec.title}"
>Previous</a
>
<a
th:if="${cursor.hasNext()}"
th:href="@{${cursor.next.status.permalink}}"
th:text="${cursor.next.spec.title}"
>Next</a
>
</nav>
<div th:if="${haloCommentEnabled}">
<halo:comment group="content.halo.run" kind="Post" th:attr="name=${post.metadata.name}" />
</div>
</th:block>
</html>
@@ -0,0 +1,20 @@
<!doctype html>
<html
xmlns:th="https://www.thymeleaf.org"
th:replace="~{layout :: html(head = null, content = ~{::content})}"
>
<th:block th:fragment="content">
<h1 th:text="|Tag: ${tag.spec.displayName}|"></h1>
<ul>
<li th:each="post : ${posts.items}">
<a th:href="@{${post.status.permalink}}" th:text="${post.spec.title}"></a>
<time th:text="${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd')}"></time>
</li>
</ul>
<nav th:if="${posts.hasPrevious() || posts.hasNext()}">
<a th:if="${posts.hasPrevious()}" th:href="@{${posts.prevUrl}}">Previous</a>
<span th:text="|${posts.page} / ${posts.totalPages}|"></span>
<a th:if="${posts.hasNext()}" th:href="@{${posts.nextUrl}}">Next</a>
</nav>
</th:block>
</html>
@@ -0,0 +1,14 @@
<!doctype html>
<html
xmlns:th="https://www.thymeleaf.org"
th:replace="~{layout :: html(head = null, content = ~{::content})}"
>
<th:block th:fragment="content">
<h1>Tags</h1>
<ul>
<li th:each="tag : ${tags.items}">
<a th:href="@{${tag.status.permalink}}" th:text="${tag.spec.displayName}"></a>
</li>
</ul>
</th:block>
</html>
@@ -0,0 +1,20 @@
apiVersion: theme.halo.run/v1alpha1
kind: Theme
metadata:
name: theme-minimal
spec:
displayName: Minimal Theme
author:
name: Your Name
website: https://example.com
description: A minimal Halo theme starter
logo: ""
homepage: ""
repo: ""
issues: ""
settingName: "theme-minimal-setting"
configMapName: "theme-minimal-configmap"
version: 1.0.0
requires: ">=2.0.0"
license:
- name: "MIT"
@@ -0,0 +1,3 @@
node_modules/
templates
dist
@@ -0,0 +1,14 @@
{
"type": "module",
"scripts": {
"dev": "vite build --watch",
"build": "vite build && theme-package",
"build-only": "vite build"
},
"devDependencies": {
"@halo-dev/theme-package-cli": "^1.0.1",
"@halo-dev/vite-plugin-halo-theme": "1.0.3",
"vite": "^8.0.0"
},
"packageManager": "pnpm@10.33.0"
}
@@ -0,0 +1,14 @@
apiVersion: v1alpha1
kind: Setting
metadata:
name: theme-vite-setting
spec:
forms:
- group: basic
label: Basic Settings
formSchema:
- $formkit: text
name: custom_footer
label: Custom footer text
value: ""
placeholder: "e.g. Copyright © 2024 My Site"
@@ -0,0 +1,32 @@
<include src="layout.html">
<template name="head">
<title th:text="|Archives - ${site.title}|">Archives</title>
</template>
<h1>Archives</h1>
<th:block th:each="archive : ${archives.items}">
<div class="archive-year">
<h2 th:text="${archive.year}"></h2>
<th:block th:each="month : ${archive.months}">
<h3 th:text="|${archive.year}-${month.month}|"></h3>
<ul class="post-list">
<li class="post-item" th:each="post : ${month.posts}">
<h2 class="post-title">
<a th:href="@{${post.status.permalink}}" th:text="${post.spec.title}"></a>
</h2>
<p class="post-meta">
<time th:text="${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd')}"></time>
</p>
</li>
</ul>
</th:block>
</div>
</th:block>
<nav class="pagination" th:if="${archives.hasPrevious() || archives.hasNext()}">
<a th:if="${archives.hasPrevious()}" th:href="@{${archives.prevUrl}}">Previous</a>
<span th:text="|${archives.page} / ${archives.totalPages}|"></span>
<a th:if="${archives.hasNext()}" th:href="@{${archives.nextUrl}}">Next</a>
</nav>
</include>
@@ -0,0 +1,16 @@
<include src="layout.html">
<template name="head">
<title th:text="|${author.spec.displayName} - ${site.title}|">Author</title>
</template>
<section>
<h1 th:text="${author.spec.displayName}"></h1>
<p th:if="${not #strings.isEmpty(author.spec.bio)}" th:text="${author.spec.bio}"></p>
<ul class="post-list">
<include src="post-card.html"></include>
</ul>
<include src="pagination.html"></include>
</section>
</include>
@@ -0,0 +1,22 @@
<include src="layout.html">
<template name="head">
<title th:text="|Categories - ${site.title}|">Categories</title>
</template>
<h1>Categories</h1>
<ul class="category-list">
<li th:each="cat : ${categories.items}">
<a
th:href="@{${cat.status.permalink}}"
th:text="|${cat.spec.displayName} (${cat.postCount})|"
class="category"
></a>
</li>
</ul>
<nav class="pagination" th:if="${categories.hasPrevious() || categories.hasNext()}">
<a th:if="${categories.hasPrevious()}" th:href="@{${categories.prevUrl}}">Previous</a>
<span th:text="|${categories.page} / ${categories.totalPages}|"></span>
<a th:if="${categories.hasNext()}" th:href="@{${categories.nextUrl}}">Next</a>
</nav>
</include>
@@ -0,0 +1,14 @@
<include src="layout.html">
<template name="head">
<title th:text="|${category.spec.displayName} - ${site.title}|">Category Archive</title>
</template>
<h1 th:text="|Category: ${category.spec.displayName}|"></h1>
<p th:if="${category.spec.description}" th:text="${category.spec.description}"></p>
<ul class="post-list">
<include src="post-card.html"></include>
</ul>
<include src="pagination.html"></include>
</include>
@@ -0,0 +1,167 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family:
system-ui,
-apple-system,
sans-serif;
line-height: 1.6;
color: #333;
}
a {
color: #0070f3;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 0 1rem;
}
.site-header {
border-bottom: 1px solid #eee;
padding: 1rem 0;
}
.site-header .container {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
}
.site-title {
font-size: 1.25rem;
font-weight: 700;
color: #333;
}
.site-nav ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
gap: 1rem;
}
.site-main {
padding: 2rem 0;
}
.post-list {
list-style: none;
margin: 0;
padding: 0;
}
.post-item {
padding: 1.5rem 0;
border-bottom: 1px solid #eee;
}
.post-item:last-child {
border-bottom: none;
}
.post-title {
margin: 0 0 0.25rem;
font-size: 1.25rem;
}
.post-meta {
color: #666;
font-size: 0.875rem;
margin: 0 0 0.5rem;
}
.post-excerpt {
color: #555;
margin: 0;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 2rem 0;
}
.article-header {
margin-bottom: 2rem;
}
.article-title {
margin: 0 0 0.5rem;
font-size: 2rem;
}
.article-meta {
color: #666;
font-size: 0.875rem;
}
.article-content {
max-width: 100%;
line-height: 1.8;
}
.article-content img {
max-width: 100%;
}
.article-tags,
.article-categories {
margin-top: 1.5rem;
}
.tag,
.category {
display: inline-block;
padding: 0.125rem 0.5rem;
background: #f0f0f0;
border-radius: 4px;
font-size: 0.875rem;
margin: 0.25rem;
}
.post-nav {
display: flex;
justify-content: space-between;
padding: 2rem 0;
border-top: 1px solid #eee;
margin-top: 2rem;
}
.site-footer {
border-top: 1px solid #eee;
padding: 1.5rem 0;
text-align: center;
color: #666;
font-size: 0.875rem;
}
.archive-year {
margin-top: 2rem;
}
.tag-list,
.category-list {
list-style: none;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
@@ -0,0 +1,12 @@
<include src="layout.html">
<template name="head">
<title th:text="${site.title}">Home</title>
<script type="module" src="./js/index.ts"></script>
</template>
<ul class="post-list">
<include src="post-card.html"></include>
</ul>
<include src="pagination.html"></include>
</include>
@@ -0,0 +1 @@
console.log("Hello, Halo Theme!");
@@ -0,0 +1 @@
import "../css/main.css";
@@ -0,0 +1 @@
console.log("Hello, Halo Theme Post!");
@@ -0,0 +1,20 @@
<include src="layout.html">
<template name="head">
<title th:text="|${singlePage.spec.title} - ${site.title}|">Page Title</title>
</template>
<article>
<header class="article-header">
<h1 class="article-title" th:text="${singlePage.spec.title}"></h1>
</header>
<div class="article-content" th:utext="${singlePage.content.content}"></div>
</article>
<div th:if="${haloCommentEnabled}">
<halo:comment
group="content.halo.run"
kind="SinglePage"
th:attr="name=${singlePage.metadata.name}"
/>
</div>
</include>
@@ -0,0 +1,42 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<slot name="head">
<title th:text="${site.title}">Site Title</title>
</slot>
<script type="module" src="./js/main.ts"></script>
</head>
<body>
<header class="site-header">
<div class="container">
<a class="site-title" th:href="@{/}" th:text="${site.title}">Site Title</a>
<nav class="site-nav" th:with="menu = ${menuFinder.getPrimary()}">
<ul>
<li th:each="item : ${menu.menuItems}">
<a
th:href="@{${item.status.href}}"
th:text="${item.status.displayName}"
th:target="${item.spec.target?.value}"
></a>
</li>
</ul>
</nav>
</div>
</header>
<main class="site-main">
<div class="container">
<slot />
</div>
</main>
<footer class="site-footer">
<div class="container">
<p th:text="${theme.config.basic.custom_footer ?: site.title}"></p>
<halo:footer />
</div>
</footer>
</body>
</html>
@@ -0,0 +1,5 @@
<nav class="pagination" th:if="${posts.hasPrevious() || posts.hasNext()}">
<a th:if="${posts.hasPrevious()}" th:href="@{${posts.prevUrl}}">Previous</a>
<span th:text="|${posts.page} / ${posts.totalPages}|"></span>
<a th:if="${posts.hasNext()}" th:href="@{${posts.nextUrl}}">Next</a>
</nav>
@@ -0,0 +1,18 @@
<li class="post-item" th:each="post : ${posts.items}">
<h2 class="post-title">
<a th:href="@{${post.status.permalink}}" th:text="${post.spec.title}"></a>
</h2>
<p class="post-meta">
<time th:text="${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd')}"></time>
<span th:if="${not #lists.isEmpty(post.categories)}">
&nbsp;·&nbsp;
<a
th:each="cat : ${post.categories}"
th:href="@{${cat.status.permalink}}"
th:text="${cat.spec.displayName}"
class="category"
></a>
</span>
</p>
<p class="post-excerpt" th:text="${post.status.excerpt}"></p>
</li>
@@ -0,0 +1,56 @@
<include src="layout.html">
<template name="head">
<title th:text="|${post.spec.title} - ${site.title}|">Post Title</title>
<script type="module" src="./js/post.ts"></script>
</template>
<article>
<header class="article-header">
<h1 class="article-title" th:text="${post.spec.title}"></h1>
<div class="article-meta">
<time th:text="${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd')}"></time>
<span th:if="${not #lists.isEmpty(post.categories)}">
&nbsp;·&nbsp;
<a
th:each="cat : ${post.categories}"
th:href="@{${cat.status.permalink}}"
th:text="${cat.spec.displayName}"
class="category"
></a>
</span>
</div>
</header>
<div class="article-content" th:utext="${post.content.content}"></div>
<div class="article-tags" th:if="${not #lists.isEmpty(post.tags)}">
<a
th:each="tag : ${post.tags}"
th:href="@{${tag.status.permalink}}"
th:text="${tag.spec.displayName}"
class="tag"
></a>
</div>
<nav class="post-nav" th:with="cursor = ${postFinder.cursor(post.metadata.name)}">
<span>
<a
th:if="${cursor.hasPrevious()}"
th:href="@{${cursor.previous.status.permalink}}"
th:text="|← ${cursor.previous.spec.title}|"
></a>
</span>
<span>
<a
th:if="${cursor.hasNext()}"
th:href="@{${cursor.next.status.permalink}}"
th:text="|${cursor.next.spec.title} →|"
></a>
</span>
</nav>
</article>
<div th:if="${haloCommentEnabled}">
<halo:comment group="content.halo.run" kind="Post" th:attr="name=${post.metadata.name}" />
</div>
</include>
@@ -0,0 +1,13 @@
<include src="layout.html">
<template name="head">
<title th:text="|${tag.spec.displayName} - ${site.title}|">Tag Archive</title>
</template>
<h1 th:text="|Tag: ${tag.spec.displayName}|"></h1>
<ul class="post-list">
<include src="post-card.html"></include>
</ul>
<include src="pagination.html"></include>
</include>
@@ -0,0 +1,18 @@
<include src="layout.html">
<template name="head">
<title th:text="|Tags - ${site.title}|">Tags</title>
</template>
<h1>Tags</h1>
<ul class="tag-list">
<li th:each="tag : ${tags.items}">
<a th:href="@{${tag.status.permalink}}" th:text="${tag.spec.displayName}" class="tag"></a>
</li>
</ul>
<nav class="pagination" th:if="${tags.hasPrevious() || tags.hasNext()}">
<a th:if="${tags.hasPrevious()}" th:href="@{${tags.prevUrl}}">Previous</a>
<span th:text="|${tags.page} / ${tags.totalPages}|"></span>
<a th:if="${tags.hasNext()}" th:href="@{${tags.nextUrl}}">Next</a>
</nav>
</include>
@@ -0,0 +1,20 @@
apiVersion: theme.halo.run/v1alpha1
kind: Theme
metadata:
name: theme-vite
spec:
displayName: Vite Theme
author:
name: Your Name
website: https://example.com
description: A Halo theme powered by vite-plugin-halo-theme
logo: ""
homepage: ""
repo: ""
issues: ""
settingName: "theme-vite-setting"
configMapName: "theme-vite-configmap"
version: 1.0.0
requires: ">=2.0.0"
license:
- name: "MIT"
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import { haloThemePlugin } from "@halo-dev/vite-plugin-halo-theme";
export default defineConfig({
plugins: [haloThemePlugin()],
});
@@ -0,0 +1,111 @@
# Model Metadata (Annotations)
Themes can extend built-in Halo models with custom fields via `AnnotationSetting` resources (e.g. adding an icon to menu items, or a download URL to posts), then read those values in templates via the `#annotations` utility.
## Defining a Metadata Form (AnnotationSetting)
Create a file (any name) in the theme root, e.g. `annotation-setting.yaml`, and declare an `AnnotationSetting` resource:
```yaml
apiVersion: v1alpha1
kind: AnnotationSetting
metadata:
name: theme-foo-menuitem-abc123 # recommended: add theme prefix + random suffix to avoid conflicts
spec:
targetRef:
group: ""
kind: MenuItem
formSchema:
- $formkit: text
name: icon
label: Menu icon class
value: ""
```
Multiple models can be declared in the same file separated by `---`:
```yaml
apiVersion: v1alpha1
kind: AnnotationSetting
metadata:
name: theme-foo-post-abc123
spec:
targetRef:
group: content.halo.run
kind: Post
formSchema:
- $formkit: text
name: download_url
label: Download URL
value: ""
---
apiVersion: v1alpha1
kind: AnnotationSetting
metadata:
name: theme-foo-menuitem-abc123
spec:
targetRef:
group: ""
kind: MenuItem
formSchema:
- $formkit: text
name: icon
label: Icon
value: ""
```
### Supported Models
| Model | `group` | `kind` |
| ------------- | ------------------ | ------------ |
| Post | `content.halo.run` | `Post` |
| Single page | `content.halo.run` | `SinglePage` |
| Post category | `content.halo.run` | `Category` |
| Post tag | `content.halo.run` | `Tag` |
| Menu item | `""` | `MenuItem` |
| User | `""` | `User` |
### Notes
- All values in `metadata.annotations` are **strings**, so form values must also be strings.
- Do not use components with non-string output such as `number`, `group`, or `repeater`.
- For `checkbox`, explicitly set `on-value` / `off-value` to string values (e.g. `"true"` / `"false"`).
- Use a theme-name prefix plus a random suffix for `metadata.name` to avoid conflicts with other themes/plugins, e.g. `theme-earth-post-wanfs5`.
## Reading Metadata in Templates
Halo provides a `#annotations` utility object in Thymeleaf with three methods:
### `#annotations.get(object, key)` — Get a value
```html
<div th:with="menu = ${menuFinder.getPrimary()}">
<li th:each="item : ${menu.menuItems}">
<i th:class="${#annotations.get(item, 'icon')}"></i>
<a th:href="${item.status.href}" th:text="${item.status.displayName}"></a>
</li>
</div>
```
### `#annotations.getOrDefault(object, key, defaultValue)` — Get a value with fallback
```html
<i th:class="${#annotations.getOrDefault(menuItem, 'icon', 'fa fa-link')}"></i>
```
### `#annotations.contains(object, key)` — Check if a key exists
```html
<i
th:if="${#annotations.contains(menuItem, 'icon')}"
th:class="${#annotations.get(menuItem, 'icon')}"
></i>
```
## Online Docs
> **`AnnotationSetting` spec and supported models may change across Halo versions. Fetch the relevant doc if unsure about supported `group`/`kind` combinations or form schema constraints.**
- Using metadata in templates: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/annotations.md
- Defining annotation forms: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/annotations-form.md
@@ -0,0 +1,28 @@
# Theme API Changelog
Read the official changelog before using version-sensitive theme APIs or raising
`spec.requires`.
Official docs:
- Theme API changelog: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/api-changelog.md
- Form schema: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/form-schema.md
High-impact changes:
| Halo version | Change | Skill reference |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| 2.25.0 | `select` options support `icon` and `description`; remote selects support `requestOption.iconField` and `descriptionField` | [structure-and-config.md](structure-and-config.md) |
| 2.25.0 | Theme root may include `screenshot.png`, `screenshot.jpeg`, `screenshot.jpg`, or `screenshot.webp`; Halo exposes the first readable image as `Theme.status.screenshot` | [structure-and-config.md](structure-and-config.md) |
| 2.25.0 | `#halo.matchVersion(constraint)` supports conditional rendering for newer Halo-only fragments | [global-variables.md](global-variables.md) |
| 2.25.0 | `postFinder.cursorByCategory(postName)` returns previous/next posts in the current post's primary category | [finder-apis.md](finder-apis.md) |
| 2.24.1 | `postFinder.random(maxSize)` returns random published posts | [finder-apis.md](finder-apis.md) |
| 2.23.0 | `iconify` supports optional `sizing` config | [structure-and-config.md](structure-and-config.md) |
| 2.22.8 | `toggle` FormKit input added | [structure-and-config.md](structure-and-config.md) |
| 2.22.2 | `switch` FormKit input added | [structure-and-config.md](structure-and-config.md) |
| 2.22.0 | `array` FormKit input added and preferred over `repeater`; `attachment` was expanded and the older picker is `attachmentInput` | [structure-and-config.md](structure-and-config.md) |
| 2.22.0 | `postFinder.cursor(postName)` return shape changed: no `current`; `previous`/`next` are `ListedPostVo` | [finder-apis.md](finder-apis.md) |
Prefer `#halo.matchVersion()` for small optional fragments that require a newer
Halo version. Raise `spec.requires` when the whole theme depends on the newer
capability.
@@ -0,0 +1,80 @@
# Finder API
Finder APIs query data from **any template location** regardless of the current route — ideal for sidebars, footers, and other global data needs.
## Available Finders
| Finder | Purpose |
| ------------------- | -------------------------------------------- |
| `postFinder` | Post list / detail / prev-next / archives |
| `categoryFinder` | Category list / tree structure / breadcrumbs |
| `tagFinder` | Tag list / detail |
| `menuFinder` | Menus and menu items |
| `singlePageFinder` | Single page list / detail |
| `commentFinder` | Comments and replies |
| `contributorFinder` | Contributors |
| `siteStatsFinder` | Site statistics |
| `themeFinder` | Theme information |
| `pluginFinder` | Plugin information |
## Key Usage Pattern
Use `th:with` to bind the result in the current scope:
```html
<div th:with="menu = ${menuFinder.getPrimary()}">
<a
th:each="item : ${menu.menuItems}"
th:href="${item.status.href}"
th:text="${item.status.displayName}"
></a>
</div>
```
## Common Notes
- `postFinder.list({...})` is the recommended unified query method (all parameters are optional); it supersedes the deprecated `list(page, size)`, `listByCategory(...)`, etc.
- Halo 2.25+ adds `postFinder.cursorByCategory(postName)` for previous/next posts inside the current post's primary category. It only matches the same category and does not include child categories.
- Halo 2.24.1+ adds `postFinder.random(maxSize)` for random published posts.
- Halo 2.22+ changed `postFinder.cursor(postName)`: the result no longer has `current`; `previous` and `next` are `ListedPostVo`.
- `metadata.name` is the unique resource identifier — it is not the display name (`displayName`/`title`).
- Pair `settings.yaml` `categorySelect`/`tagSelect` inputs with Finder queries so users can configure query parameters in Console instead of hard-coding them in templates.
## Image Thumbnails
Halo 2.19+ generates responsive thumbnails for attachment images. Use `thumbnail.gen(uri, size)` to get a scaled URL:
```html
<img
th:src="${post.spec.cover}"
th:srcset="|${thumbnail.gen(post.spec.cover, 's')} 400w,
${thumbnail.gen(post.spec.cover, 'm')} 800w,
${thumbnail.gen(post.spec.cover, 'l')} 1200w,
${thumbnail.gen(post.spec.cover, 'xl')} 1600w|"
sizes="(max-width: 1600px) 100vw, 1600px"
/>
```
| Size parameter | Width |
| -------------- | ------ |
| `s` | 400px |
| `m` | 800px |
| `l` | 1200px |
| `xl` | 1600px |
> Halo 2.22+ automatically adds responsive image attributes to all `<img>` tags on the page. Only use `thumbnail.gen()` manually when you need custom control over specific images.
## Online Docs
> **Do not rely on training data for Finder API method signatures — Halo evolves across versions and your training data may be outdated or incomplete. Always fetch the relevant doc before writing code that calls a specific Finder method.**
- postFinder: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/finder-apis/post.md
- categoryFinder: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/finder-apis/category.md
- tagFinder: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/finder-apis/tag.md
- menuFinder: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/finder-apis/menu.md
- singlePageFinder: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/finder-apis/single-page.md
- commentFinder: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/finder-apis/comment.md
- contributorFinder: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/finder-apis/contributor.md
- siteStatsFinder: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/finder-apis/site-stats.md
- themeFinder: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/finder-apis/theme.md
- pluginFinder: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/server/finder-for-theme.md
@@ -0,0 +1,162 @@
# Global Variables
The following variables are available in all templates without any additional declaration.
---
## `site` — Site information
Source: Console → System Settings.
```json
{
"title": "Site Title",
"subtitle": "Site Subtitle",
"url": "https://example.com",
"logo": "https://example.com/logo.png",
"favicon": "https://example.com/favicon.ico",
"allowRegistration": false,
"post": {
"postPageSize": 10,
"archivePageSize": 10,
"categoryPageSize": 10,
"tagPageSize": 10
},
"seo": {
"blockSpiders": false,
"keywords": "keywords",
"description": "Site description"
},
"comment": {
"enable": true,
"systemUserOnly": false,
"requireReviewForNew": false
},
"routes": {
"categoriesUri": "/categories",
"tagsUri": "/tags",
"archivesUri": "/archives"
}
}
```
**Common examples**:
```html
<title th:text="${site.title}"></title>
<img th:src="${site.logo}" alt="Logo" />
<meta name="description" th:content="${site.seo.description}" />
```
---
## `theme` — Current theme info
```json
{
"metadata": {
"name": "theme-foo",
"creationTimestamp": "..."
},
"spec": {
"displayName": "My Theme",
"version": "1.0.0",
"author": { "name": "Author", "website": "https://example.com" },
"description": "Theme description",
"logo": "https://example.com/logo.png",
"homepage": "https://github.com/example/theme-foo",
"settingName": "theme-foo-setting",
"configMapName": "theme-foo-configMap"
},
"config": {
"style": { "color_scheme": "system" },
"layout": { "nav": "single" }
}
}
```
**Common examples**:
```html
<!-- Display theme version -->
<span th:text="${theme.spec.version}"></span>
<!-- Static asset reference with version (recommended — prevents caching) -->
<link rel="stylesheet" th:href="@{/assets/dist/style.css?v={v}(v=${theme.spec.version})}" />
<script th:src="@{/assets/dist/main.iife.js?v={v}(v=${theme.spec.version})}"></script>
```
---
## `theme.config` — Theme settings values
Access pattern: `theme.config.[group].[name]`
- `group`: value of `spec.forms[].group` in `settings.yaml`
- `name`: value of `spec.forms[].formSchema[].name`
**Example** (based on the settings.yaml in [structure-and-config.md](structure-and-config.md)):
```html
<body th:class="${theme.config.style.color_scheme}">
<nav th:if="${theme.config.layout.nav == 'single'}">...</nav>
<nav th:if="${theme.config.layout.nav == 'double'}">...</nav>
</body>
```
---
## `#theme.assets()` — Static asset path utility
Returns the full path to a static asset for use in non-attribute contexts (e.g. inside JavaScript).
> Note: the path passed to this function does **not** need an `/assets/` prefix.
```html
<script th:inline="javascript">
var mainJs = '[(${#theme.assets("/dist/main.iife.js")})]';
// Renders as: /themes/theme-foo/assets/dist/main.iife.js
</script>
```
---
## `#halo.matchVersion(constraint)` — Halo version guard
Halo 2.25+ exposes `#halo.matchVersion(constraint)` for conditional rendering
based on semantic version ranges. Use it when only a small fragment needs a
newer Halo feature and raising the whole theme's `spec.requires` would be too
broad.
```html
<div th:if="${#halo.matchVersion('>=2.25.0')}">
<!-- Use Halo 2.25+ only markup here -->
</div>
<div th:if="${#halo.matchVersion('>=2.25.0 & <3.0.0')}">
<!-- Limit rendering to a Halo 2.x range -->
</div>
```
Development builds with version `0.0.0` always match, which keeps local theme
debugging convenient.
---
## Online Docs
> **If you need the exact structure of `site`, `theme`, `theme.config`, or `#halo` helpers, fetch the doc below — do not guess field names from training data.**
https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/global-variables.md
---
## `haloCommentEnabled` — Comment component status
Boolean. Evaluates both "is a comment plugin installed" and "are comments enabled for this page". Use together with the `halo:comment` custom tag:
```html
<div th:if="${haloCommentEnabled}">
<halo:comment group="content.halo.run" kind="Post" th:attr="name=${post.metadata.name}" />
</div>
```
@@ -0,0 +1,109 @@
# Internationalization (i18n)
Halo themes support i18n via `.properties` files under an `i18n/` directory. Thymeleaf provides the `#messages` object for reading translations in templates.
---
## Directory Structure
```
my-theme/
├── i18n/
│ ├── default.properties # Fallback / default language
│ ├── zh_CN.properties # Simplified Chinese
│ ├── zh_TW.properties # Traditional Chinese
│ └── es.properties # Spanish
├── templates/
└── theme.yaml
```
> Halo uses `default.properties` as the fallback when no locale-specific file matches the user's preference.
---
## Properties File Format
Simple key-value pairs:
```properties
# default.properties
page.author.title=Author: {0}
common.previousPage=Previous
common.nextPage=Next
common.noPosts=No posts yet.
```
```properties
# zh_CN.properties
page.author.title=作者:{0}
common.previousPage=上一页
common.nextPage=下一页
common.noPosts=暂无文章。
```
Placeholders `{0}`, `{1}` ... are filled by the arguments passed to the message function.
---
## Using i18n in Templates
### `#messages.msg(key)` — Get a message
```html
<h1 th:text="${#messages.msg('page.author.title', author.spec.displayName)}"></h1>
```
### `#messages.msgOrNull(key)` — Get a message or null if missing
```html
<span th:text="${#messages.msgOrNull('custom.label') ?: 'Default Label'}"></span>
```
### Thymeleaf shorthand `#{key}` — Standard expression
```html
<!-- Equivalent to #messages.msg('common.previousPage') -->
<a th:text="#{common.previousPage}">Previous</a>
<!-- With arguments -->
<title th:text="|#{page.author.title(${author.spec.displayName})} - ${site.title}|"></title>
```
---
## `#locale` — Current Locale
Useful for rendering language-specific UI or setting `<html lang>`:
```html
<html th:lang="${#locale.toLanguageTag()}">
<!-- Language selector -->
<select>
<option value="en" th:selected="${#locale.toLanguageTag() == 'en'}">English</option>
<option value="zh-CN" th:selected="${#locale.toLanguageTag() == 'zh-CN'}">简体中文</option>
</select>
</html>
```
---
## Frontend i18n Pattern
Some themes also need translations in JavaScript. A common pattern is to inject the needed strings into a global object via inline script:
```html
<script th:inline="javascript">
window.i18nResources = {
"common.previousPage": `[(#{common.previousPage})]`,
"common.nextPage": `[(#{common.nextPage})]`,
};
</script>
```
> Note: `[(#{key})]` is Thymeleaf's unescaped inlining syntax. It evaluates the expression and inserts the raw value into the script.
---
## Online Docs
https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/global-variables.md
@@ -0,0 +1,65 @@
# Official Plugin Integration
Halo's official plugins can extend the frontend UI. Themes should adapt to these plugins rather than re-implementing the same functionality.
## Checking Plugin Availability
Use `pluginFinder.available(pluginName)` to conditionally render plugin-dependent UI. This prevents broken UI when the plugin is not installed.
```html
<button
th:if="${pluginFinder.available('PluginSearchWidget')}"
onclick="javascript:SearchWidget.open()"
>
Search
</button>
```
Always guard plugin-dependent elements with `th:if="${pluginFinder.available('...')}"`.
## Search Widget (PluginSearchWidget)
The official search plugin provides a ready-made search UI. Themes do not need to build their own search — just add a trigger button:
```html
<button
th:if="${pluginFinder.available('PluginSearchWidget')}"
onclick="javascript:SearchWidget.open()"
>
Search
</button>
```
## Dark Mode Adaptation
Official plugins that provide UI components (search widget, comment component, etc.) support a shared color scheme system. Themes that implement dark mode should apply the appropriate class or `data-color-scheme` attribute to `<html>` or `<body>` so plugin UI matches the theme's color scheme automatically.
### Method 1: CSS class on `<html>` or `<body>`
| Class | Effect |
| ------------------------------- | ------------------------------------ |
| `color-scheme-auto` | Follows system dark/light preference |
| `color-scheme-dark` or `dark` | Force dark mode |
| `color-scheme-light` or `light` | Force light mode |
```html
<html class="color-scheme-dark">
<!-- or -->
<html class="dark"></html>
</html>
```
### Method 2: `data-color-scheme` attribute on `<html>` or `<body>`
| Value | Effect |
| ------- | ------------------------- |
| `auto` | Follows system preference |
| `dark` | Force dark mode |
| `light` | Force light mode |
```html
<html data-color-scheme="auto"></html>
```
> Both methods achieve the same result. Dark mode switching is typically handled by frontend JavaScript (toggling the class/attribute at runtime). This applies to all official plugins that render UI (search widget, comment component, etc.).
@@ -0,0 +1,39 @@
# Theme Packaging
Use [`@halo-dev/theme-package-cli`](https://github.com/halo-dev/theme-package-cli) to package a theme into a ZIP file for uploading to Halo Console or distributing to others.
## Installation
```bash
npm install -g @halo-dev/theme-package-cli
```
Or run without installing via `npx`:
```bash
npx @halo-dev/theme-package-cli
```
## Usage
Run in the theme root directory (the one containing `theme.yaml`):
```bash
# Package only essential files (recommended)
theme-package
# Package all files (excluding node_modules, dist, .git, etc.)
theme-package --all
```
## Default Package Contents (without `--all`)
| Included | Description |
| ------------------ | ------------------------------------------------- |
| `templates/` | Templates and static assets |
| `*.yaml` / `*.yml` | Config files: `theme.yaml`, `settings.yaml`, etc. |
| `i18n/` | Internationalization files (if present) |
| `README.md` | Documentation file (if present) |
| `LICENSE` | License file (if present) |
> For Vite-based themes, run `npm run build` first to generate the `templates/` output, then run the packaging command.
@@ -0,0 +1,69 @@
# Static Assets & Build Tooling
## Static Asset Directory
All theme static assets **must** be placed under `templates/assets/`:
```
templates/
└── assets/
├── css/
│ └── style.css
├── js/
│ └── main.js
└── images/
└── logo.png
```
---
## Referencing Static Assets in Templates
### Method 1: HTML tag attributes (recommended)
Use Thymeleaf's `@{}` expression:
```html
<!-- CSS -->
<link rel="stylesheet" th:href="@{/assets/dist/style.css}" />
<!-- JavaScript -->
<script th:src="@{/assets/dist/main.iife.js}"></script>
<!-- Image -->
<img th:src="@{/assets/images/logo.png}" alt="Logo" />
```
The path `@{/assets/dist/style.css}` maps to `templates/assets/dist/style.css` and renders as `/themes/theme-foo/assets/dist/style.css`.
**With version query string (strongly recommended — prevents caching)**:
```html
<link rel="stylesheet" th:href="@{/assets/dist/style.css?v={v}(v=${theme.spec.version})}" />
<script th:src="@{/assets/dist/main.iife.js?v={v}(v=${theme.spec.version})}"></script>
```
### Method 2: `#theme.assets()` utility
Use when you need the asset path in a non-attribute context (e.g. a JavaScript variable or inline CSS).
> Note: do **not** include `/assets/` prefix in the path argument.
```html
<script th:inline="javascript">
var logoUrl = '[(${#theme.assets("/images/logo.png")})]';
// Value: /themes/theme-foo/assets/images/logo.png
loadScript('[(${#theme.assets("/dist/main.iife.js")})]');
</script>
```
---
## Vite Integration
For themes using a modern frontend stack (TypeScript, Sass, PostCSS, TailwindCSS, etc.) or needing HTML template reuse (`include`/`slot`), the official Vite plugin is strongly recommended:
**`vite-plugin-halo-theme`**: https://github.com/halo-sigs/vite-plugin-halo-theme
See [vite-plugin.md](vite-plugin.md) for the full integration guide, template syntax, and configuration examples.
@@ -0,0 +1,271 @@
# Directory Structure & Configuration
## Directory Structure
```
my-theme/
├── templates/
│ ├── assets/ # Static assets (CSS/JS/images etc.) — must be placed here
│ │ ├── css/
│ │ │ └── style.css
│ │ └── js/
│ │ └── main.js
│ ├── index.html # Home page
│ ├── post.html # Post detail
│ ├── page.html # Single page detail
│ ├── archives.html # Post archives
│ ├── tags.html # Tag listing
│ ├── tag.html # Tag archive
│ ├── categories.html # Category listing
│ └── category.html # Category archive
├── screenshot.png # Optional Console preview image (Halo 2.25+)
├── theme.yaml # Theme configuration (required)
└── settings.yaml # Theme settings form definition (optional)
```
> **Important**: The theme folder name must match the `metadata.name` field in `theme.yaml`; otherwise some assets may fail to load.
Halo 2.25+ recognizes the first readable root preview image in this order:
`screenshot.png`, `screenshot.jpeg`, `screenshot.jpg`, `screenshot.webp`. The
resolved URL is exposed as `Theme.status.screenshot`.
## theme.yaml
The theme root directory must contain `theme.yaml`. Minimal runnable config:
```yaml
apiVersion: theme.halo.run/v1alpha1
kind: Theme
metadata:
name: theme-foo # must match the theme folder name
spec:
displayName: My Theme
version: 1.0.0
requires: ">=2.0.0"
```
Full field example:
```yaml
apiVersion: theme.halo.run/v1alpha1
kind: Theme
metadata:
name: theme-foo
spec:
displayName: My Theme
author:
name: Author Name
website: https://example.com
description: An example theme
logo: https://example.com/logo.png
homepage: https://github.com/example/theme-foo
repo: https://github.com/example/theme-foo.git
issues: https://github.com/example/theme-foo/issues
settingName: "theme-foo-setting" # must match metadata.name in settings.yaml
configMapName: "theme-foo-configMap"
customTemplates: # optional
post:
- name: Documentation
description: Article in documentation format
screenshot:
file: post_documentation.html
category:
- name: Knowledge Base
description: Knowledge base category
screenshot:
file: category_knowledge.html
page:
- name: About
description: About page
screenshot:
file: page_about.html
version: 1.0.0
requires: ">=2.0.0"
license:
- name: "GPL-3.0"
url: "https://github.com/example/theme-foo/blob/main/LICENSE"
```
### Key Fields
| Field | Description | Required |
| ---------------------- | ------------------------------------------------------------------- | ---------------------------------------- |
| `metadata.name` | Unique theme identifier — **must match the folder name** | Yes |
| `spec.displayName` | Display name | Yes |
| `spec.version` | Theme version | Yes |
| `spec.requires` | Minimum required Halo version | Yes |
| `spec.settingName` | Setting resource name — must match `metadata.name` in settings.yaml | No |
| `spec.configMapName` | ConfigMap name for persisting settings | No (configure together with settingName) |
| `spec.customTemplates` | Custom template configuration | No |
> After modifying `theme.yaml`, click "Reload Theme Configuration" on the theme page in Console for changes to take effect.
## settings.yaml
Defines a form that is auto-rendered on the theme settings page in Console, using [FormKit](https://github.com/formkit/formkit).
```yaml
apiVersion: v1alpha1
kind: Setting
metadata:
name: theme-foo-setting # must match spec.settingName in theme.yaml
spec:
forms:
- group: style # group name (accessed in templates as theme.config.style.xxx)
label: Style
formSchema:
- $formkit: radio
name: color_scheme # field name (accessed as theme.config.style.color_scheme)
label: Default color scheme
value: system
options:
- label: Follow system
value: system
- label: Dark
value: dark
- label: Light
value: light
- $formkit: color
name: background_color
label: Background color
value: "#f2f2f2"
- group: layout
label: Layout
formSchema:
- $formkit: radio
name: nav
label: Navigation layout
value: "single"
options:
- label: Single column
value: "single"
- label: Double column
value: "double"
```
### Using Settings Values in Templates
Pattern: `theme.config.[group].[name]`
```html
<!-- Use color_scheme from the style group -->
<body th:class="${theme.config.style.color_scheme}">
<ul th:if="${theme.config.layout.nav == 'single'}">
Single-column nav
</ul>
<div th:if="${theme.config.layout.nav == 'double'}">Double-column nav</div>
</body>
```
### Native FormKit Input Types
Commonly used native input components in theme settings (see links for full docs):
| `$formkit` value | Purpose | Docs |
| ---------------- | --------------------------------- | -------------------------------------------- |
| `text` | Single-line text | https://formkit.com/inputs/text.md |
| `textarea` | Multi-line text | https://formkit.com/inputs/textarea.md |
| `number` | Number input | https://formkit.com/inputs/number.md |
| `password` | Password input | https://formkit.com/inputs/password.md |
| `radio` | Radio buttons (options list) | https://formkit.com/inputs/radio.md |
| `checkbox` | Checkbox (single or multi-select) | https://formkit.com/inputs/checkbox.md |
| `color` | Color picker (returns hex value) | https://formkit.com/inputs/color.md |
| `range` | Slider range | https://formkit.com/inputs/range.md |
| `date` | Date picker | https://formkit.com/inputs/date.md |
| `datetime-local` | Date-time picker | https://formkit.com/inputs/datetime-local.md |
| `button` | Button | https://formkit.com/inputs/button.md |
| `email` | Email input | https://formkit.com/inputs/email.md |
| `group` | Group (for grouping fields) | https://formkit.com/inputs/group.md |
| `url` | URL input | https://formkit.com/inputs/url.md |
FormKit Inputs overview: https://formkit.com/inputs
FormKit Schema (conditional rendering, loops, expressions, advanced usage): https://formkit.com/essentials/schema.md
> Note: FormKit Pro input components are not supported.
### Halo Extended Input Types
Additional input components provided by Halo on top of FormKit:
| `$formkit` value | Purpose |
| ------------------ | ------------------------------------------------------------------- |
| `select` | Enhanced dropdown with multi-select, search, and remote data source |
| `switch` | Toggle switch (boolean or custom on/off values) |
| `toggle` | Visual toggle supporting image/color/text options |
| `attachment` | Attachment picker (preview, direct upload, select from library) |
| `attachmentInput` | Attachment picker (library popup only) |
| `code` | Code editor (supports yaml/html/js/css/json) |
| `array` | Object array (add/remove/reorder — recommended over repeater) |
| `list` | Primitive array (strings, numbers, etc.) |
| `categorySelect` | Post category selector (returns `metadata.name`) |
| `categoryCheckbox` | Post category checkbox (returns array of `metadata.name`) |
| `tagSelect` | Post tag selector (returns `metadata.name`) |
| `tagCheckbox` | Post tag checkbox (returns array of `metadata.name`) |
| `postSelect` | Post selector |
| `singlePageSelect` | Single page selector |
| `menuSelect` | Menu selector (supports multi-select) |
| `menuCheckbox` | Menu checkbox |
| `menuRadio` | Menu radio |
| `iconify` | Icon picker (Iconify-based, supports svg/dataurl/url/name formats) |
| `secret` | Secret resource selector |
| `verificationForm` | Remote verification form group |
Full parameter reference: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/form-schema.md
Version-sensitive FormKit notes:
- Halo 2.25+: `select` option objects support `icon` and `description`; remote
select data can map those fields via `requestOption.iconField` and
`requestOption.descriptionField`.
- Halo 2.25+: `secret` supports `descriptionPreset`.
- Halo 2.23+: `iconify` supports optional `sizing`.
- Halo 2.22.8+: `toggle` is available.
- Halo 2.22.2+: `switch` is available.
- Halo 2.22+: prefer `array` over `repeater`; the newer `attachment` supports
preview/direct upload/library selection, while the older library-only picker
is `attachmentInput`.
### FormKit Schema Gotchas
**1. Inside `array` / `list` children, use `$value` — not `$get()`**
Within the `children` of an `array`, `list`, or `repeater`, access the current item's data via `$value`, not `$get(id).value`. `$get()` can only reference standalone named input nodes by `id`; it cannot reach the current item in a nested context.
```yaml
# ✅ Correct: use $value.[name] to reference a sibling field
- $formkit: array
name: socials
children:
- $formkit: text
name: platform
label: Platform
- $formkit: text
name: url
label: URL
if: "$value.platform !== ''" # references sibling field "platform"
# ❌ Wrong: $get(platform).value does not work in a nested context
- $formkit: text
name: url
if: "$get(platform).value !== ''"
```
**2. Nodes using `if` must also declare a `key`**
Any `$formkit`/`$el`/`$cmp` node with an `if` attribute must declare a unique `key`. Without it, Vue reuses DOM nodes when the condition toggles, causing stale form values or rendering glitches.
```yaml
# ✅ Correct: add key whenever if is present
- $formkit: text
key: url-field
name: url
label: URL
if: "$value.show_link === true"
# ❌ Wrong: missing key may cause value leakage on toggle
- $formkit: text
name: url
label: URL
if: "$value.show_link === true"
```
@@ -0,0 +1,71 @@
# Custom Template Tags
Halo provides proprietary custom tags for code injection and extension points.
---
## `<halo:comment>` — Comment extension point
### Description
Extension point tag for the comment component. When a plugin (such as the official comment plugin) implements this extension point, it renders the comment component at this tag's location.
### Usage
```html title="templates/post.html"
<!-- haloCommentEnabled checks whether a comment plugin is installed and comments are enabled for this page -->
<div th:if="${haloCommentEnabled}">
<halo:comment group="content.halo.run" kind="Post" th:attr="name=${post.metadata.name}" />
</div>
```
```html title="templates/page.html"
<div th:if="${haloCommentEnabled}">
<halo:comment
group="content.halo.run"
kind="SinglePage"
th:attr="name=${singlePage.metadata.name}"
/>
</div>
```
### Attributes
| Attribute | Description |
| --------- | -------------------------------------------- |
| `group` | Resource group |
| `kind` | Resource type |
| `name` | Unique resource identifier (`metadata.name`) |
### Supported Resource Types
| Resource | `group` | `kind` |
| ----------- | ------------------ | ------------ |
| Post | `content.halo.run` | `Post` |
| Single page | `content.halo.run` | `SinglePage` |
---
## `<halo:footer>` — Footer code injection
### Description
Renders the "Footer Code" configured in Console → System Settings → Code Injection at this tag's location. All themes should include this tag in the footer to ensure full Halo functionality (e.g. analytics scripts injected by plugins).
### Usage
```html title="templates/index.html (bottom of any template)"
<body>
<!-- Page content -->
<footer>
<!-- Theme's own footer content -->
<p>© 2024 My Site</p>
<!-- Halo footer injection point (place before </body>) -->
<halo:footer />
</footer>
</body>
```
> For complete Halo functionality, include this tag in every template that contains a `<body>` element.
@@ -0,0 +1,83 @@
# Template Route Mapping & Template Variables
## Template Route Mapping
| Template file | URL path | Main variables | `_templateId` |
| --------------------------- | ---------------------------- | ------------------- | ------------- |
| `templates/index.html` | `/` | `posts` | `index` |
| `templates/post.html` | `/archives/:slug` | `post` | `post` |
| `templates/page.html` | `/:slug` | `singlePage` | `page` |
| `templates/archives.html` | `/archives[/:year[/:month]]` | `archives` | `archives` |
| `templates/tags.html` | `/tags` | `tags` | `tags` |
| `templates/tag.html` | `/tags/:slug` | `tag`, `posts` | `tag` |
| `templates/categories.html` | `/categories` | `categories` | `categories` |
| `templates/category.html` | `/categories/:slug` | `category`, `posts` | `category` |
| `templates/author.html` | `/authors/:slug` | `author`, `posts` | `author` |
> Route prefixes (`/archives`, `/tags`, `/categories`) can be customized by users in Console system settings.
## Error Templates
Halo supports custom error pages under `templates/error/`:
| Template file | Status code match |
| ---------------------------- | ------------------------------- |
| `templates/error/404.html` | Exact 404 |
| `templates/error/4xx.html` | Any 4xx client error (fallback) |
| `templates/error/500.html` | Exact 500 |
| `templates/error/5xx.html` | Any 5xx server error (fallback) |
| `templates/error/error.html` | Catch-all default |
Resolution order for a 404: `404.html``4xx.html``error.html`
### Error template variables
```html
<div>
<h2 th:text="${error.status}">404</h2>
<p th:text="${#strings.defaultString(error.title, 'Error')}"></p>
<p th:if="${not #strings.isEmpty(error.detail)}" th:text="${error.detail}"></p>
</div>
```
| Variable | Type | Description |
| ---------------- | ------ | ------------------ |
| `error.status` | number | HTTP status code |
| `error.title` | string | Error title |
| `error.detail` | string | Detailed message |
| `error.instance` | string | Error instance URI |
| `error.type` | string | Error type URI |
## Custom Templates
Register additional rendering templates for posts, single pages, or category archives via `spec.customTemplates` in `theme.yaml`. Supported types: `post`, `page`, `category`.
```yaml
spec:
customTemplates:
post:
- name: Documentation
file: post_documentation.html # create under templates/
```
> After modifying theme.yaml, click "Reload Theme Configuration" on the theme page in Console.
## Key Notes
- Use `th:utext` (unescaped) to render post/page body content — never `th:text`.
- List variables (`posts`, `archives`, etc.) are `UrlContextListResult`; use `.hasPrevious()`/`.hasNext()`/`.prevUrl`/`.nextUrl` for pagination.
- `post.content.content` is only available automatically in `post.html`; in other templates fetch it separately via `postFinder.content(postName)`.
## Online Docs
> **Halo's VO types (field names, nested structures) change across versions. Do not guess field names from training data. Always fetch the doc for the relevant template before accessing specific fields on template variables.**
- index: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/template-variables/index_.md
- post: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/template-variables/post.md
- page: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/template-variables/page.md
- archives: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/template-variables/archives.md
- tag: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/template-variables/tag.md
- tags: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/template-variables/tags.md
- category: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/template-variables/category.md
- categories: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/template-variables/categories.md
- author: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/theme/template-variables/author.md
@@ -0,0 +1,85 @@
# Thymeleaf Best Practices for Halo Themes
**1. Prefer literal substitutions over string concatenation**
```html
<!-- ✅ readable, no quoting issues -->
<title th:text="|${post.spec.title} - ${site.title}|"></title>
<!-- ❌ verbose and error-prone -->
<title th:text="${post.spec.title} + ' - ' + ${site.title}"></title>
```
**2. Use safe navigation `?.` to avoid NullPointerException**
```html
<!-- ✅ returns null instead of throwing if target is null -->
<a th:target="${item.spec.target?.value}"></a>
```
**3. Use Elvis operator `?:` for default values**
```html
<p th:text="${theme.config.basic.custom_footer ?: site.title}"></p>
```
**4. Use `th:block` to group without adding extra DOM elements**
```html
<th:block th:each="archive : ${archives.items}">
<h2 th:text="${archive.year}"></h2>
<ul>
...
</ul>
</th:block>
```
**5. Use `th:classappend` for conditional classes**
```html
<!-- ✅ appends "active" without overwriting existing classes -->
<a th:classappend="${item.active} ? 'active'">...</a>
<!-- ❌ replaces all classes -->
<a th:class="${item.active} ? 'nav-link active' : 'nav-link'">...</a>
```
**6. Use `#lists.isEmpty()` and `#strings.isEmpty()` for null-safe checks**
```html
<div th:if="${not #lists.isEmpty(post.tags)}">
<a th:each="tag : ${post.tags}" th:text="${tag.spec.displayName}"></a>
</div>
```
**7. Do not manually add meta tags — Halo injects them automatically**
Only `<title>` needs to be in the theme. Halo automatically injects at runtime:
- `<meta name="description">` and `<meta name="keywords">`
- Open Graph tags (`og:title`, `og:description`, `og:image`, etc.)
- Twitter Card tags and canonical URL
```html
<!-- ✅ correct -->
<head>
<title th:text="${site.title}">Site Title</title>
</head>
<!-- ❌ redundant — conflicts with Halo's auto-injected tags -->
<head>
<title th:text="${site.title}">Site Title</title>
<meta name="description" th:content="${site.seo.description}" />
<meta property="og:title" th:content="${site.title}" />
</head>
```
**8. Use `@{${url}}` for dynamic permalink URLs**
```html
<!-- ✅ correct: wraps runtime URL in Thymeleaf's URL context -->
<a th:href="@{${post.status.permalink}}">...</a>
<!-- ❌ wrong: bypasses URL processing -->
<a th:href="${post.status.permalink}">...</a>
```
@@ -0,0 +1,294 @@
# Vite Integration (Recommended)
> **Strongly recommended** for building theme projects. Compared to plain Thymeleaf, this approach provides a modern frontend development experience (TypeScript, Sass, PostCSS, TailwindCSS) plus lightweight HTML component reuse (`include`/`slot`) — significantly reducing the verbosity of Thymeleaf's native fragment system.
---
## vite-plugin-halo-theme
- GitHub: https://github.com/halo-sigs/vite-plugin-halo-theme
- NPM: `@halo-dev/vite-plugin-halo-theme`
### Key Features
1. **HTML template reuse**: supports `<include>`, `<slot>` (default and named), and `<props>` syntax, preprocessed at Vite build time — fully independent of Thymeleaf.
2. **Automatic multi-page entry scanning**: automatically discovers all `.html` files under `src/` (except `src/partials/`) as Vite build entries, with output going to `templates/`.
3. **Static asset handling**: CSS/JS in `src/` is bundled by Vite into `templates/assets/`; files in `public/` are copied directly to `templates/assets/` without processing.
---
## Directory Convention
```
my-theme/
├── src/
│ ├── css/
│ │ └── main.css # CSS source
│ ├── js/
│ │ └── main.ts # TypeScript source
│ ├── partials/ # Not treated as page entries; referenced via include
│ │ ├── layout.html # Shared layout
│ │ └── post-card.html # Reusable post card component
│ ├── index.html # Home page entry → templates/index.html
│ ├── post.html # → templates/post.html
│ ├── page.html
│ ├── archives.html
│ ├── tags.html
│ ├── tag.html
│ ├── categories.html
│ └── category.html
├── public/ # Copied directly to templates/assets/ (no Vite processing)
├── templates/ # Build output directory (add to .gitignore)
├── vite.config.ts
├── package.json
├── .gitignore
├── theme.yaml
└── settings.yaml
```
---
## Quick Setup
### package.json
```json
{
"type": "module",
"scripts": {
"dev": "vite build --watch",
"build": "vite build"
},
"devDependencies": {
"@halo-dev/vite-plugin-halo-theme": "latest",
"vite": "^6.0.0"
}
}
```
### vite.config.ts
```ts
import { defineConfig } from "vite";
import { haloThemePlugin } from "@halo-dev/vite-plugin-halo-theme";
export default defineConfig({
plugins: [haloThemePlugin()],
});
```
### .gitignore
```gitignore
node_modules/
templates/
!templates/.gitkeep
```
---
## Template Syntax (build-time only — independent of Thymeleaf)
> **Important**: `include`/`slot` syntax is processed at **Vite build time**; Thymeleaf syntax is processed at **server runtime**. The two systems are fully isolated and can be mixed freely.
### `<include>` — Import a partial
```html
<include src="layout.html">
<main>Page content</main>
</include>
```
### Named Slots
Declare slots in a partial (`src/partials/layout.html`):
```html
<head>
<slot name="head">
<title th:text="${site.title}">Default title</title>
</slot>
</head>
<body>
<slot />
</body>
```
Use in a page:
```html
<include src="layout.html">
<template name="head">
<title th:text="${post.spec.title}">Post title</title>
</template>
<article th:utext="${post.content.content}"></article>
</include>
```
### Include Path Resolution
| Syntax | Resolves to |
| ------------------- | ----------------------------------- |
| `foo.html` | `src/partials/foo.html` (preferred) |
| `partials/foo.html` | `src/partials/foo.html` |
| `./foo.html` | Relative to current file |
| `/foo.html` | Relative to `src/` root |
### Static Asset Paths
**All HTML files — both `src/*.html` and `src/partials/*.html` — resolve static asset references relative to `src/`**, regardless of the file's actual location.
```html
<!-- src/partials/layout.html: referencing src/css/main.css -->
<link rel="stylesheet" href="./css/main.css" />
<!-- ✅ correct (relative to src/) -->
<link rel="stylesheet" href="../css/main.css" />
<!-- ❌ wrong (relative to file location) -->
<!-- Same rule in src/index.html -->
<script type="module" src="./js/main.ts"></script>
<!-- ✅ correct -->
```
Always write asset paths as if the file is in `src/`, even when it is inside `src/partials/`.
---
## Full Example: Shared Layout
### `src/partials/layout.html`
```html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<slot name="head">
<title th:text="${site.title}">Site title</title>
</slot>
<link rel="stylesheet" href="./css/main.css" />
</head>
<body>
<header>
<a th:href="@{/}" th:text="${site.title}"></a>
<nav th:with="menu = ${menuFinder.getPrimary()}">
<ul>
<li th:each="item : ${menu.menuItems}">
<a
th:href="${item.status.href}"
th:text="${item.status.displayName}"
th:target="${item.spec.target?.value}"
></a>
</li>
</ul>
</nav>
</header>
<main>
<slot />
</main>
<footer>
<halo:footer />
</footer>
</body>
</html>
```
### `src/index.html`
```html
<include src="layout.html">
<template name="head">
<title th:text="${site.title}">Home</title>
</template>
<section>
<ul>
<li th:each="post : ${posts.items}">
<a th:href="${post.status.permalink}" th:text="${post.spec.title}"></a>
<time th:text="${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd')}"></time>
<p th:text="${post.status.excerpt}"></p>
</li>
</ul>
<nav th:if="${posts.hasPrevious() || posts.hasNext()}">
<a th:if="${posts.hasPrevious()}" th:href="${posts.prevUrl}">Previous</a>
<span th:text="${posts.page} + '/' + ${posts.totalPages}"></span>
<a th:if="${posts.hasNext()}" th:href="${posts.nextUrl}">Next</a>
</nav>
</section>
</include>
```
### `src/post.html`
```html
<include src="layout.html">
<template name="head">
<title th:text="${post.spec.title}">Post title</title>
</template>
<article>
<h1 th:text="${post.spec.title}"></h1>
<time th:text="${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd')}"></time>
<div th:utext="${post.content.content}"></div>
<!-- Prev / next navigation -->
<div th:with="cursor = ${postFinder.cursor(post.metadata.name)}">
<a
th:if="${cursor.hasPrevious()}"
th:href="${cursor.previous.status.permalink}"
th:text="${cursor.previous.spec.title}"
>Previous</a
>
<a
th:if="${cursor.hasNext()}"
th:href="${cursor.next.status.permalink}"
th:text="${cursor.next.spec.title}"
>Next</a
>
</div>
<!-- Comments -->
<div th:if="${haloCommentEnabled}">
<halo:comment group="content.halo.run" kind="Post" th:attr="name=${post.metadata.name}" />
</div>
</article>
</include>
```
---
## TailwindCSS Integration
```bash
pnpm add -D tailwindcss @tailwindcss/vite
```
```ts
// vite.config.ts
import tailwindcss from "@tailwindcss/vite";
import { haloThemePlugin } from "@halo-dev/vite-plugin-halo-theme";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [tailwindcss(), haloThemePlugin()],
});
```
```css
/* src/css/main.css */
@import "tailwindcss";
```
---
## Ready-to-use Templates
The skill's `assets/` directory provides two ready-to-use templates:
- `assets/theme-minimal/` — Zero-build-tool minimal theme; good for quick prototyping or plain HTML/CSS themes
- `assets/theme-vite/` — Full Vite project with vite-plugin-halo-theme; recommended as the starting point for new themes
Usage: copy the directory into `themes/` in your Halo working directory, ensure the folder name matches `metadata.name` in `theme.yaml`, then install and activate in Console.