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
@@ -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()],
});