feat: 插件适配页面与系统完善(v0.1.0)

- 修复 /categories、/tags 因变量为 List 误用 .items 导致的 500
- 新增 layout.html 布局契约,消除插件页面 fallback 警告
- 适配 plugin-moments / plugin-photos / plugin-links(含友链申请表单与 RSS 订阅页)
- 适配 halo-weread-plugin(书架页)、留言板单页模板
- 适配 plugin-extra-api 的代码高亮与暗色样式
- 修复暗黑模式评论区不可见(补充 color-scheme 与兜底样式)
- 更新元数据:作者暖心向阳335、版本 0.1.0、requires >=2.26.0
This commit is contained in:
2026-09-28 16:50:09 +08:00
parent 2ed0c768a4
commit 8e6dccbd09
35 changed files with 3986 additions and 301 deletions
+77 -53
View File
@@ -1,76 +1,100 @@
# Theme Vite Starter # Whitespace 留白
面向 [Halo](https://www.halo.run/) 的主题脚手架:在 `src/` 中编写模板与前端资源,经构建生成 Halo 实际读取的 `templates/` 目录。 一个移植自 Typecho 主题 [whitespace 留白](https://kehua.me) 的 [Halo](https://www.halo.run/) 主题,主打极简留白风格与清晰的排版。
官方主题开发指南:<https://docs.halo.run/developer-guide/theme/prepare> > 主题名 `whitespace`,显示名 **Whitespace 留白**,版本 `0.1.0`,作者 **暖心向阳335**。
> 要求 Halo ≥ 2.26.0。源码在 `src/`,构建产物在 `templates/`。
> 源码仓库:https://git.nxxy335.top/nxxy335/halo-theme-whitespace
## 技术栈 ## 特性
| 类别 | 说明 | - 极简留白风格,自带浅色 / 深色双主题(跟随系统或手动切换,记忆访客选择)
| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | - 首页可选展示最新一篇置顶文章(`featured` 设置)
| 运行时 | Halo 使用 **Thymeleaf** 渲染主题;模板变量与 Finder API 随 Halo 版本演进,请以[官方文档](https://docs.halo.run/developer-guide/theme/prepare)为准。 | - 文章 / 页面 / 归档 / 分类 / 标签 / 作者 / 关于 等完整页面
| 构建 | Vite,同时集成 [Vite Plus](https://viteplus.dev/),集成格式化、Lint 等功能 | - 文章页结构化数据(Schema.org `BlogPosting` JSON-LD)与社交分享标签(canonical / Open Graph / Twitter Card),均可在主题设置中关闭
| 语言 | **TypeScript**(`tsc` 参与 `build` 脚本) | - 内置搜索按钮(依赖官方 `PluginSearchWidget` 插件,未安装则不显示)
| 主题插件 | [`@halo-dev/vite-plugin-halo-theme`](https://www.npmjs.com/package/@halo-dev/vite-plugin-halo-theme) — 源码见 [halo-sigs/vite-plugin-halo-theme](https://github.com/halo-sigs/vite-plugin-halo-theme) | - 阅读进度条、代码块复制、相对时间、回到顶部、移动端菜单等交互
| 打包发布 | [`@halo-dev/theme-package-cli`](https://github.com/halo-dev/theme-package-cli) — 将主题打成 ZIP 供控制台上传 | - 多语言:`i18n/default.properties`(回退)+ `i18n/zh_CN.properties`
| 包管理 | **pnpm**(版本见 `package.json` 的 `packageManager`) | - 提供 `layout.html` 布局契约,供使用布局契约的插件页面复用主题导航 / 页脚 / 暗色样式
## 插件适配
主题已为以下插件提供适配页面(在 `src/` 下以同名模板提供,构建后进入 `templates/`):
- **plugin-moments**:`/moments` 瞬间页
- **plugin-photos**:`/photos` 图库页
- **plugin-links**:`/links` 友链页;并额外提供
- `links-apply.html`:访客友链申请表单(调用插件公共 REST API,含图形验证码)
- `links-subscribe.html`:RSS 订阅动态聚合页(使用 `linkFeedFinder`)
- **halo-weread-plugin**:`weread.html` 微信读书书架页(前端拉取插件 `/shelf-html` 并注入)
- **留言板**:`page_guestbook.html` 以评论区作为留言板的单页模板
- **plugin-extra-api**:API 扩展包(无独立页面),主题已适配其 shiki 代码高亮与暗色样式
> `weread` / `留言板` / `友链申请` / `链接订阅` 均为「自定义页面模板」,在控制台新建单页并选择对应模板即可启用。
## 目录结构 ## 目录结构
- **`src/` 是源码目录**:在此维护 `.html` 页面与 `partials/` 片段、`css/`、`js/`(含 TS)等。插件在 **Vite 构建时** 处理 `<include>` / `<slot>` 等语法(与服务器上 **Thymeleaf 运行时** 互不替代,可同时使用)。
- **`templates/` 是构建产物**:Halo 只认主题根目录下的 `templates/`(内含页面 HTML 与 `templates/assets/` 等)。**不要**把 `src/` 当作 Halo 直接读取的路径;开发或发版前需执行构建,使 `templates/` 与当前源码一致。
``` ```
. src/ 源码目录(在此编辑)
├── src/ # 源码:页面 HTML、partials、css、js ├── partials/
├── public/ # 可选;目录内容原样复制到 templates/ 根目录 │ ├── layout.html 公共布局(导航 / 页脚 / 主题切换 / 脚本入口)
├── templates/ # 构建生成 │ └── seo.html SEO 片段(canonical / OG / Twitter,受开关控制)
├── theme.yaml # 主题元数据(必填) ├── modules/
├── settings.yaml # 控制台主题设置表单(可选) │ ├── post-card.html 文章列表卡片
├── vite.config.ts │ ├── post-meta.html 文章元信息(时间 / 分类 / 浏览量)
└── package.json │ └── pagination.html 分页导航
├── error/ 错误页(404 / 通用错误)
│ ├── 404.html
│ └── error.html
├── css/main.css 全部样式(含双主题变量、响应式、打印)
├── js/main.ts 全站脚本
├── js/post.ts 文章页脚本(代码复制 / 阅读进度)
├── index.html post.html page.html page_about.html
├── archives.html category.html tag.html categories.html tags.html author.html
└── i18n/ 多语言消息
theme.yaml 主题元数据(必填)
settings.yaml 控制台主题设置表单
templates/ 构建产物(Halo 实际读取,勿手动编辑)
``` ```
### `@halo-dev/vite-plugin-halo-theme` > `src/` 与 `templates/` 的关系:`src/` 经 Vite 构建生成 `templates/`。开发或发版前务必执行构建,使 `templates/` 与源码一致。**不要**手动修改 `templates/`。
1. **多页入口**:自动将 `src/` 下(除 `src/partials/` 外)的 `.html` 作为入口,输出到 `templates/` 下同名文件(例如 `src/index.html` → `templates/index.html`)。 ## 开发与构建
2. **静态资源**:`src/` 中的 CSS/JS 由 Vite 打包进 `templates/assets/`;`public/` 的目录内容会原样复制到 `templates/` 根目录(不经打包)。例如,需要生成 `templates/assets/images/logo.png` 时,应将源文件放在 `public/assets/images/logo.png`。
3. **模板复用**:支持构建期 `<include>`、`<slot>`,减轻纯 Thymeleaf 片段的重复书写。
4. **资源路径约定**:所有 HTML(含 `partials`)里引用静态资源时,路径按 **`src/` 根** 解析,而不是按当前文件所在子目录;说明见 [vite-plugin-halo-theme](https://github.com/halo-sigs/vite-plugin-halo-theme)。
## 开发
```bash ```bash
git clone git@github.com:halo-dev/theme-vite-starter.git ~/halo2-dev/themes/theme-vite-starter pnpm install # 安装依赖
cd ~/halo2-dev/themes/theme-vite-starter pnpm dev # 监听模式构建(开发时用)
pnpm install pnpm build-only # 仅构建(tsc + vp build),生成 templates/
pnpm dev pnpm build # 构建并打包为可上传的 ZIP(theme-package-cli)
pnpm check # 格式化 + Lint 修复
``` ```
`pnpm dev` 等价于对主题执行 `vp build --watch`:修改 `src/` 后会持续重新生成 `templates/`。将主题目录链到或复制到 Halo 的 `themes/<metadata.name>/` 后,在控制台安装并启用主题即可预览。 构建输出:
开发 Halo 端建议关闭 Thymeleaf 缓存(例如环境变量 `SPRING_THYMELEAF_CACHE=false` 或配置 `spring.thymeleaf.cache: false`),便于模板热更新调试。 - 页面与片段 → `templates/*.html`、`templates/modules/*.html`、`templates/error/*.html`
- 静态资源 → `templates/assets/*`(文件名带内容哈希,运行时以 `/themes/whitespace/assets/...` 提供)
## 构建与打包 ## 主题设置
```bash 在 Halo 控制台「主题 → 设置」中可配置:
pnpm build
```
该命令会执行 TypeScript 检查、`vp build` 生成 `templates/`,并调用 `theme-package` 生成可分发的 ZIP。默认会打包 `templates/`、`theme.yaml` / `settings.yaml` 等必要文件,详见 [theme-package-cli](https://github.com/halo-dev/theme-package-cli)。 | 分组 | 字段 | 说明 |
| ------- | ------------------ | -------------------------------------------- |
| basic | color_scheme | 默认配色:跟随系统 / 浅色 / 深色 |
| basic | footer_copyright | 页脚自定义版权(支持 HTML,留空不显示) |
| featured| enable | 首页是否展示置顶文章 |
| about | avatar / bio / github_url / twitter_url / contact_email | 关于页(自定义模板 `page_about.html`)内容 |
| seo | enable_metadata | 输出 canonical / Open Graph / Twitter Card |
| seo | enable_structured_data | 输出文章页 BlogPosting JSON-LD |
仅需要产物目录、不需要 ZIP 时: ## 约定与说明
```bash - Logo / favicon / SEO description·keywords / 代码注入优先使用 Halo 系统设置;主题仅输出 canonical、OG、Twitter 与结构化数据(可在 `seo` 分组关闭,避免与插件重复)。
pnpm build-only - 社交分享 / 结构化数据中用 `status.permalink` 与 `site.routes.*` 拼出地址,不写死可配置路由。
``` - 搜索由官方 `PluginSearchWidget` 插件提供,按钮仅在插件可用时出现。
- 浏览量来自 Halo 原生统计(`post.stats.visit`),无需主题自行计数。
## 其他脚本 ## 致谢
| 命令 | 作用 | - 原主题:[whitespace 留白](https://kehua.me) by kehua
| ------------ | ---------------------------------------- | - 基于 Halo 官方 [theme-vite-starter](https://github.com/halo-dev/theme-vite-starter) 脚手架移植
| `pnpm check` | `vp check --fix`(执行格式化和代码检查) |
## Agent Skills
仓库在 `.agents/skills/` 下内置了 **Halo 主题开发** 相关的 Agent Skill(例如 `halo-theme-dev`),整理了 Thymeleaf 要点、Finder API、`theme.yaml` / `settings.yaml`、vite 插件用法等参考材料。若在 Cursor 等支持 Agent Skills 的环境里开发,启用后可让 AI 更贴合 Halo 主题的约定来辅助编写与修改主题。
+35
View File
@@ -0,0 +1,35 @@
nav.home=Home
nav.archives=Archives
nav.categories=Categories
nav.tags=Tags
search.title=Search
theme.toggle=Toggle theme
mobile.menu=Menu
back.to.top=Back to top
post.views={0} views
post.continue=Continue reading
pagination.previous=Previous
pagination.next=Next
pagination.page=Page {0} of {1}
list.empty=Nothing found here.
home.featured=Featured
archive.title=Archives
archive.stats.posts=Posts
archive.stats.categories=Categories
archive.stats.tags=Tags
archive.stats.years=Years
categories.title=Categories
tags.title=Tags
tag.count=posts
category.title=Category
tag.title=Tag
author.title=Author
search.keyword=Keyword
error.404.title=Page not found
error.404.message=Sorry, the page you visited does not exist or has been deleted.
error.back.home=Back to home
error.back.prev=Go back
footer.theme=Theme by {0}
about.social.github=GitHub
about.social.twitter=Twitter
about.social.email=Email
+35
View File
@@ -0,0 +1,35 @@
nav.home=首页
nav.archives=归档
nav.categories=分类
nav.tags=标签
search.title=搜索
theme.toggle=切换主题
mobile.menu=菜单
back.to.top=回到顶部
post.views={0} 次浏览
post.continue=继续阅读
pagination.previous=上一页
pagination.next=下一页
pagination.page=第 {0} 页 / 共 {1} 页
list.empty=没有找到相关内容
home.featured=置顶
archive.title=归档
archive.stats.posts=篇文章
archive.stats.categories=个分类
archive.stats.tags=个标签
archive.stats.years=年
categories.title=分类
tags.title=标签
tag.count=篇
category.title=分类
tag.title=标签
author.title=作者
search.keyword=关键词
error.404.title=页面未找到
error.404.message=抱歉,您访问的页面不存在或已被删除。
error.back.home=返回首页
error.back.prev=返回上页
footer.theme=Theme by {0}
about.social.github=GitHub
about.social.twitter=Twitter
about.social.email=邮箱
+70 -7
View File
@@ -1,13 +1,76 @@
apiVersion: v1alpha1 apiVersion: v1alpha1
kind: Setting kind: Setting
metadata: metadata:
name: theme-vite-starter-setting name: whitespace-setting
spec: spec:
forms: forms:
- group: styles - group: basic
label: 样式 label: 基础
formSchema: formSchema:
- $formkit: color - $formkit: radio
name: background_color name: color_scheme
label: 背景颜色 label: 默认配色
value: "#f2f2f2" value: system
help: 首次访问时使用的配色,访客手动切换后以访客选择为准
options:
- label: 跟随系统
value: system
- label: 浅色
value: light
- label: 深色
value: dark
- $formkit: textarea
name: footer_copyright
label: 自定义版权信息
value: ""
placeholder: 支持编写 HTML,留空则不显示
help: 显示在页脚的自定义版权信息
- group: featured
label: 置顶文章
formSchema:
- $formkit: checkbox
name: enable
label: 首页展示置顶文章
value: true
help: 在首页顶部以大标题样式展示最新的一篇置顶文章(需在文章设置中开启置顶)
- group: about
label: 关于页面
formSchema:
- $formkit: attachment
name: avatar
label: 博主头像
value: ""
help: 关于页面展示的头像,留空则使用站点 Logo
- $formkit: textarea
name: bio
label: 个人简介
value: "一个热爱生活、热爱技术的普通人"
help: 关于页面头像下方显示的简介
- $formkit: text
name: github_url
label: GitHub 链接
value: ""
placeholder: https://github.com/yourname
- $formkit: text
name: twitter_url
label: Twitter 链接
value: ""
placeholder: https://x.com/yourname
- $formkit: text
name: contact_email
label: 联系邮箱
value: ""
placeholder: mail@example.com
- group: seo
label: SEO
formSchema:
- $formkit: checkbox
name: enable_metadata
label: 输出 canonical 和社交分享标签
value: true
help: 输出 canonical、Open Graph 与 Twitter Card 标签;若已由插件提供,请关闭以避免重复
- $formkit: checkbox
name: enable_structured_data
label: 输出文章结构化数据
value: true
help: 在文章页输出 Schema.org BlogPosting JSON-LD;若已由插件提供,请关闭
+78 -26
View File
@@ -1,32 +1,84 @@
<include src="layout.html"> <include src="partials/layout.html">
<template name="head"> <template name="head">
<title th:text="|Archives - ${site.title}|">Archives</title> <title th:text="|#{archive.title} - ${site.title}|">Archives</title>
<th:block
th:with="seoTitle=#{archive.title}, seoUrl=${site.url + site.routes.archivesUri}, seoDesc=null, seoImage=${site.logo}, seoType='website'"
>
<include src="seo.html"></include>
</th:block>
</template> </template>
<h1>Archives</h1> <div class="container">
<!-- 归档标题 -->
<th:block th:each="archive : ${archives.items}"> <div class="category-header">
<div class="archive-year"> <h1 class="category-title" th:text="#{archive.title}">Archives</h1>
<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> </div>
</th:block>
<nav class="pagination" th:if="${archives.hasPrevious() || archives.hasNext()}"> <!-- 归档统计 -->
<a th:if="${archives.hasPrevious()}" th:href="@{${archives.prevUrl}}">Previous</a> <div
<span th:text="|${archives.page} / ${archives.totalPages}|"></span> class="archive-stats"
<a th:if="${archives.hasNext()}" th:href="@{${archives.nextUrl}}">Next</a> th:with="stats = ${siteStatsFinder.getStats()}, tags = ${tagFinder.listAll()}"
</nav> th:if="${stats != null}"
>
<div class="archive-stat">
<div class="archive-stat-number" th:text="${stats.post}">0</div>
<div class="archive-stat-label" th:text="#{archive.stats.posts}">
Posts
</div>
</div>
<div class="archive-stat">
<div class="archive-stat-number" th:text="${stats.category}">0</div>
<div class="archive-stat-label" th:text="#{archive.stats.categories}">
Categories
</div>
</div>
<div class="archive-stat" th:if="${tags != null}">
<div class="archive-stat-number" th:text="${#lists.size(tags)}">0</div>
<div class="archive-stat-label" th:text="#{archive.stats.tags}">
Tags
</div>
</div>
</div>
<!-- 按年归档 -->
<th:block th:each="archive : ${archives.items}">
<div class="archive-year">
<h2 class="archive-year-title" th:text="${archive.year}">2024</h2>
<ul class="archive-list">
<th:block th:each="month : ${archive.months}">
<li
class="archive-item"
th:each="post : ${month.posts}"
th:with="postCategories = ${post.categories}"
>
<span
class="archive-date"
th:if="${post.spec.publishTime != null}"
th:text="${#temporals.format(post.spec.publishTime, 'MM-dd')}"
>01-01</span
>
<span class="archive-title">
<a th:href="@{${post.status.permalink}}"
th:text="${post.spec.title}">Post title</a>
</span>
<span
class="archive-category"
th:if="${not #lists.isEmpty(postCategories)}"
>
<a
th:href="@{${postCategories[0].status.permalink}}"
th:text="${postCategories[0].spec.displayName}"
></a>
</span>
</li>
</th:block>
</ul>
</div>
</th:block>
<!-- 分页 -->
<th:block
th:replace="~{modules/pagination :: pagination(context = |${site.routes.archivesUri}/|, prevUrl = ${archives.prevUrl}, nextUrl = ${archives.nextUrl}, totalPages = ${archives.totalPages}, page = ${archives.page}, hasPrevious = ${archives.hasPrevious()}, hasNext = ${archives.hasNext()})}"
></th:block>
</div>
</include> </include>
+53
View File
@@ -0,0 +1,53 @@
<include src="partials/layout.html">
<template name="head">
<title th:text="|${author.spec.displayName} - ${site.title}|">
Author
</title>
<th:block
th:with="seoTitle=${author.spec.displayName}, seoUrl=${site.url + author.status.permalink}, seoDesc=${author.spec.bio}, seoImage=${author.spec.avatar ?: site.logo}, seoType='website'"
>
<include src="seo.html"></include>
</th:block>
</template>
<div class="container">
<!-- 作者信息 -->
<div class="category-header">
<div class="author-header">
<div
class="about-avatar author-avatar"
th:if="${not #strings.isEmpty(author.spec.avatar)}"
>
<img th:src="${author.spec.avatar}" th:alt="${author.spec.displayName}" />
</div>
<h1 class="category-title" th:text="${author.spec.displayName}">
Author
</h1>
<p
class="category-description"
th:if="${not #strings.isEmpty(author.spec.bio)}"
th:text="${author.spec.bio}"
></p>
</div>
</div>
<!-- 文章列表 -->
<div class="post-list" th:if="${not #lists.isEmpty(posts.items)}">
<th:block th:each="post : ${posts.items}">
<th:block
th:replace="~{modules/post-card :: post-card(post=${post})}"
></th:block>
</th:block>
</div>
<!-- 空状态 -->
<div class="no-results" th:if="${#lists.isEmpty(posts.items)}">
<p th:text="#{list.empty}">Nothing found here.</p>
</div>
<!-- 分页 -->
<th:block
th:replace="~{modules/pagination :: pagination(context = |${author.status.permalink}/|, prevUrl = ${posts.prevUrl}, nextUrl = ${posts.nextUrl}, totalPages = ${posts.totalPages}, page = ${posts.page}, hasPrevious = ${posts.hasPrevious()}, hasNext = ${posts.hasNext()})}"
></th:block>
</div>
</include>
+23 -17
View File
@@ -1,22 +1,28 @@
<include src="layout.html"> <include src="partials/layout.html">
<template name="head"> <template name="head">
<title th:text="|Categories - ${site.title}|">Categories</title> <title th:text="|#{categories.title} - ${site.title}|">Categories</title>
<th:block
th:with="seoTitle=#{categories.title}, seoUrl=${site.url + site.routes.categoriesUri}, seoDesc=null, seoImage=${site.logo}, seoType='website'"
>
<include src="seo.html"></include>
</th:block>
</template> </template>
<h1>Categories</h1> <div class="container">
<ul class="category-list"> <div class="category-header">
<li th:each="cat : ${categories.items}"> <h1 class="category-title" th:text="#{categories.title}">
<a Categories
th:href="@{${cat.status.permalink}}" </h1>
th:text="|${cat.spec.displayName} (${cat.postCount})|" </div>
class="category"
></a>
</li>
</ul>
<nav class="pagination" th:if="${categories.hasPrevious() || categories.hasNext()}"> <div class="category-list" th:if="${not #lists.isEmpty(categories)}">
<a th:if="${categories.hasPrevious()}" th:href="@{${categories.prevUrl}}">Previous</a> <th:block
<span th:text="|${categories.page} / ${categories.totalPages}|"></span> th:replace="~{modules/category-tree :: category-tree(cats=${categories})}"
<a th:if="${categories.hasNext()}" th:href="@{${categories.nextUrl}}">Next</a> ></th:block>
</nav> </div>
<div class="no-results" th:if="${#lists.isEmpty(categories)}">
<p th:text="#{list.empty}">Nothing found here.</p>
</div>
</div>
</include> </include>
+39 -8
View File
@@ -1,14 +1,45 @@
<include src="layout.html"> <include src="partials/layout.html">
<template name="head"> <template name="head">
<title th:text="|${category.spec.displayName} - ${site.title}|">Category Archive</title> <title th:text="|${category.spec.displayName} - ${site.title}|">
Category
</title>
<th:block
th:with="seoTitle=${category.spec.displayName}, seoUrl=${site.url + category.status.permalink}, seoDesc=${category.spec.description}, seoImage=${category.spec.cover}, seoType='website'"
>
<include src="seo.html"></include>
</th:block>
</template> </template>
<h1 th:text="|Category: ${category.spec.displayName}|"></h1> <div class="container">
<p th:if="${category.spec.description}" th:text="${category.spec.description}"></p> <!-- 分类标题 -->
<div class="category-header">
<h1 class="category-title" th:text="${category.spec.displayName}">
Category
</h1>
<p
class="category-description"
th:if="${not #strings.isEmpty(category.spec.description)}"
th:text="${category.spec.description}"
></p>
</div>
<ul class="post-list"> <!-- 文章列表 -->
<include src="post-card.html"></include> <div class="post-list" th:if="${not #lists.isEmpty(posts.items)}">
</ul> <th:block th:each="post : ${posts.items}">
<th:block
th:replace="~{modules/post-card :: post-card(post=${post})}"
></th:block>
</th:block>
</div>
<include src="pagination.html"></include> <!-- 空状态 -->
<div class="no-results" th:if="${#lists.isEmpty(posts.items)}">
<p th:text="#{list.empty}">Nothing found here.</p>
</div>
<!-- 分页 -->
<th:block
th:replace="~{modules/pagination :: pagination(context = |${category.status.permalink}/|, prevUrl = ${posts.prevUrl}, nextUrl = ${posts.nextUrl}, totalPages = ${posts.totalPages}, page = ${posts.page}, hasPrevious = ${posts.hasPrevious()}, hasNext = ${posts.hasNext()})}"
></th:block>
</div>
</include> </include>
+1415 -81
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
<include src="partials/layout.html">
<template name="head">
<title th:text="|#{error.404.title} - ${site.title}|">404</title>
</template>
<div class="container">
<!-- 404 内容 -->
<div class="error-page">
<div class="error-code" th:text="${error.status != null ? error.status : 404}">
404
</div>
<h1 class="error-title" th:text="#{error.404.title}">Page not found</h1>
<p class="error-message" th:text="#{error.404.message}">
Sorry, the page you visited does not exist.
</p>
<div class="error-actions">
<a th:href="@{/}" class="error-btn" th:text="#{error.back.home}"
>Back to home</a
>
<button
type="button"
class="error-btn error-btn-secondary"
onclick="history.back()"
th:text="#{error.back.prev}"
>
Go back
</button>
</div>
</div>
</div>
</include>
+38
View File
@@ -0,0 +1,38 @@
<include src="partials/layout.html">
<template name="head">
<title th:text="|${error.status} - ${site.title}|">Error</title>
</template>
<div class="container">
<!-- 通用错误内容 -->
<div class="error-page">
<div class="error-code" th:text="${error.status != null ? error.status : 500}">
500
</div>
<h1
class="error-title"
th:text="${not #strings.isEmpty(error.title) ? error.title : 'Error'}"
>
Error
</h1>
<p
class="error-message"
th:if="${not #strings.isEmpty(error.detail)}"
th:text="${error.detail}"
></p>
<div class="error-actions">
<a th:href="@{/}" class="error-btn" th:text="#{error.back.home}"
>Back to home</a
>
<button
type="button"
class="error-btn error-btn-secondary"
onclick="history.back()"
th:text="#{error.back.prev}"
>
Go back
</button>
</div>
</div>
</div>
</include>
+56 -7
View File
@@ -1,12 +1,61 @@
<include src="layout.html"> <include src="partials/layout.html">
<template name="head"> <template name="head">
<title th:text="${site.title}">Home</title> <title th:text="${site.title}">Site Title</title>
<script type="module" src="./js/index.ts"></script> <th:block
th:with="seoTitle=${site.title}, seoUrl=${site.url}, seoDesc=null, seoImage=${site.logo}, seoType='website'"
>
<include src="seo.html"></include>
</th:block>
</template> </template>
<ul class="post-list"> <div class="container">
<include src="post-card.html"></include> <!-- 置顶文章:首页第一页时展示最新一篇置顶文章 -->
</ul> <th:block
th:if="${theme.config?.featured?.enable != null and theme.config.featured.enable and posts.first}"
th:with="featuredList = ${postFinder.list({'pinned': true, 'size': 1})}"
>
<th:block
th:if="${featuredList != null and not #lists.isEmpty(featuredList.items)}"
th:with="featured = ${featuredList.items[0]}"
>
<article class="post post-featured">
<h2 class="post-title">
<a th:href="@{${featured.status.permalink}}"
th:text="${featured.spec.title}">Featured post</a>
</h2>
<p
class="post-excerpt"
th:if="${not #strings.isEmpty(featured.status.excerpt)}"
th:text="${featured.status.excerpt}"
></p>
<th:block
th:replace="~{modules/post-meta :: post-meta(post=${featured}, dateClass='post-date')}"
></th:block>
</article>
</th:block>
</th:block>
<include src="pagination.html"></include> <!-- 文章列表 -->
<div
class="post-list"
th:if="${not #lists.isEmpty(posts.items)}"
th:with="postList = ${posts.items}"
>
<th:block th:each="post : ${postList}">
<th:block
th:replace="~{modules/post-card :: post-card(post=${post})}"
></th:block>
</th:block>
</div>
<!-- 空状态 -->
<div class="no-results" th:if="${#lists.isEmpty(posts.items)}">
<p th:text="#{list.empty}">Nothing found here.</p>
</div>
<!-- 分页 -->
<th:block
th:replace="~{modules/pagination :: pagination(context = '/', prevUrl = ${posts.prevUrl}, nextUrl = ${posts.nextUrl}, totalPages = ${posts.totalPages}, page = ${posts.page}, hasPrevious = ${posts.hasPrevious()}, hasNext = ${posts.hasNext()})}"
></th:block>
</div>
</include> </include>
-1
View File
@@ -1 +0,0 @@
console.log("Hello, Halo Theme!");
+273
View File
@@ -1 +1,274 @@
import "../css/main.css"; import "../css/main.css";
/**
* whitespace 留白主题 - 全站脚本
* 移植自 Typecho 主题 whitespace 的 main.js
*/
// ========================================
// 主题切换
// ========================================
function currentTheme(): string {
return document.documentElement.getAttribute("data-theme") || "light";
}
function toggleTheme(): void {
const newTheme = currentTheme() === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", newTheme);
try {
localStorage.setItem("theme", newTheme);
} catch {
/* ignore */
}
}
function watchSystemTheme(): void {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", (e) => {
let stored: string | null = null;
try {
stored = localStorage.getItem("theme");
} catch {
/* ignore */
}
if (!stored) {
document.documentElement.setAttribute(
"data-theme",
e.matches ? "dark" : "light",
);
}
});
}
// ========================================
// 搜索(plugin-search-widget)
// ========================================
function initSearch(): void {
const searchToggle = document.getElementById("search-toggle");
if (!searchToggle) return;
searchToggle.addEventListener("click", () => {
const widget = (window as unknown as Record<string, unknown>).SearchWidget as
| { open?: () => void }
| undefined;
if (widget && typeof widget.open === "function") {
widget.open();
}
});
}
// ========================================
// 移动端导航
// ========================================
function initMobileNav(): void {
const mobileMenuToggle = document.getElementById("mobile-menu-toggle");
const mobileNav = document.getElementById("mobile-nav");
if (!mobileMenuToggle || !mobileNav) return;
const menuIcon = `
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
`;
const closeIcon = `
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
`;
const icon = mobileMenuToggle.querySelector(".icon");
const closeMobileNav = () => {
if (mobileNav.classList.contains("active")) {
mobileNav.classList.remove("active");
if (icon) icon.innerHTML = menuIcon;
}
};
mobileMenuToggle.addEventListener("click", () => {
mobileNav.classList.toggle("active");
if (icon) icon.innerHTML = mobileNav.classList.contains("active") ? closeIcon : menuIcon;
});
window.addEventListener("resize", () => {
if (window.innerWidth > 768) closeMobileNav();
});
document.addEventListener("click", (e) => {
const target = e.target as Node;
if (!mobileNav.contains(target) && !mobileMenuToggle.contains(target)) {
closeMobileNav();
}
});
}
// ========================================
// 回到顶部
// ========================================
function initBackToTop(): void {
const backToTop = document.getElementById("back-to-top");
if (!backToTop) return;
backToTop.addEventListener("click", () => {
window.scrollTo({ top: 0, behavior: "smooth" });
});
const handleScroll = () => {
if (window.scrollY > 300) {
backToTop.classList.add("visible");
} else {
backToTop.classList.remove("visible");
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll();
}
// ========================================
// 相对时间
// ========================================
function initRelativeTime(): void {
const formatter = new Intl.RelativeTimeFormat(navigator.language, {
numeric: "always",
});
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
["year", 31536000],
["month", 2592000],
["day", 86400],
["hour", 3600],
["minute", 60],
];
document.querySelectorAll<HTMLElement>("[data-relative-time]").forEach((el) => {
const datetime = el.getAttribute("datetime");
if (!datetime) return;
const time = Date.parse(datetime);
if (Number.isNaN(time)) return;
const diffSeconds = Math.floor((Date.now() - time) / 1000);
// 仅对 24 小时内的内容显示相对时间,其余保留日期显示
if (diffSeconds < 0 || diffSeconds >= 86400) return;
if (diffSeconds < 60) {
el.textContent = formatter.format(-diffSeconds, "second");
return;
}
for (const [unit, seconds] of units) {
if (diffSeconds >= seconds) {
el.textContent = formatter.format(-Math.floor(diffSeconds / seconds), unit);
break;
}
}
});
}
// ========================================
// 图片懒加载
// ========================================
function initLazyImages(): void {
const images = document.querySelectorAll('img[loading="lazy"]');
if (!("IntersectionObserver" in window)) return;
const imageObserver = new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
img.classList.add("loaded");
observer.unobserve(img);
}
});
},
{ rootMargin: "50px 0px" },
);
images.forEach((img) => imageObserver.observe(img));
}
// ========================================
// 平滑滚动
// ========================================
function initSmoothScroll(): void {
document.querySelectorAll<HTMLAnchorElement>('a[href^="#"]').forEach((anchor) => {
anchor.addEventListener("click", (e) => {
const href = anchor.getAttribute("href");
if (!href || href === "#") return;
const target = document.querySelector(href);
if (target) {
e.preventDefault();
const header = document.querySelector(".header");
const headerHeight = header ? header.getBoundingClientRect().height : 0;
const targetPosition =
target.getBoundingClientRect().top + window.scrollY - headerHeight - 20;
window.scrollTo({ top: targetPosition, behavior: "smooth" });
}
});
});
}
// ========================================
// 外部链接处理
// ========================================
function initExternalLinks(): void {
document.querySelectorAll<HTMLAnchorElement>(".article-content a").forEach((link) => {
const href = link.getAttribute("href");
if (
href &&
href.startsWith("http") &&
!href.includes(window.location.hostname)
) {
link.setAttribute("target", "_blank");
link.setAttribute("rel", "noopener noreferrer");
}
});
}
// ========================================
// 键盘快捷键
// ========================================
function initKeyboardShortcuts(): void {
document.addEventListener("keydown", (e) => {
const searchToggle = document.getElementById("search-toggle");
if ((e.ctrlKey || e.metaKey) && e.key === "k" && searchToggle) {
e.preventDefault();
searchToggle.click();
}
if ((e.ctrlKey || e.metaKey) && e.key === "d") {
e.preventDefault();
toggleTheme();
}
});
}
// ========================================
// 初始化
// ========================================
function init(): void {
const themeToggle = document.getElementById("theme-toggle");
if (themeToggle) {
themeToggle.addEventListener("click", toggleTheme);
}
watchSystemTheme();
initSearch();
initMobileNav();
initBackToTop();
initRelativeTime();
initLazyImages();
initSmoothScroll();
initExternalLinks();
initKeyboardShortcuts();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
+82 -1
View File
@@ -1 +1,82 @@
console.log("Hello, Halo Theme Post!"); /**
* whitespace 留白主题 - 文章页脚本
* 代码块复制 + 阅读进度条
*/
// ========================================
// 代码块复制
// ========================================
function initCodeCopy(): void {
const codeBlocks = document.querySelectorAll<HTMLElement>(
".article-content pre",
);
codeBlocks.forEach((pre) => {
const copyBtn = document.createElement("button");
copyBtn.className = "code-copy-btn";
copyBtn.type = "button";
copyBtn.textContent = "复制";
pre.style.position = "relative";
pre.appendChild(copyBtn);
copyBtn.addEventListener("click", async () => {
const code = pre.querySelector("code");
const text = code ? (code.textContent ?? "") : (pre.textContent ?? "");
try {
await navigator.clipboard.writeText(text);
copyBtn.textContent = "已复制";
copyBtn.classList.add("copied");
setTimeout(() => {
copyBtn.textContent = "复制";
copyBtn.classList.remove("copied");
}, 2000);
} catch {
console.error("复制失败");
}
});
});
}
// ========================================
// 阅读进度条
// ========================================
function initReadingProgress(): void {
const article = document.querySelector<HTMLElement>(".article-content");
if (!article) return;
const progressBar = document.createElement("div");
progressBar.className = "reading-progress";
document.body.appendChild(progressBar);
const updateProgress = () => {
const articleTop = article.offsetTop;
const articleHeight = article.offsetHeight;
const windowHeight = window.innerHeight;
const scrollY = window.scrollY;
const start = articleTop - windowHeight;
const end = articleTop + articleHeight;
const current = scrollY;
let progress = ((current - start) / (end - start)) * 100;
progress = Math.max(0, Math.min(100, progress));
progressBar.style.width = `${progress}%`;
};
window.addEventListener("scroll", updateProgress, { passive: true });
updateProgress();
}
function init(): void {
initCodeCopy();
initReadingProgress();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
+200
View File
@@ -0,0 +1,200 @@
<!doctype html>
<html
lang="zh-CN"
xmlns:th="https://www.thymeleaf.org"
th:lang="${#locale.toLanguageTag}"
th:fragment="html (head, content)"
>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title th:if="${head == null}" th:text="${site.title}">Site Title</title>
<script th:inline="javascript">
(function () {
var preferred = /*[[${theme.config?.basic?.color_scheme ?: 'system'}]]*/ "system";
var stored = null;
try {
stored = localStorage.getItem("theme");
} catch (e) {
/* ignore */
}
var resolved = stored || preferred;
if (resolved === "system") {
resolved = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
document.documentElement.setAttribute("data-theme", resolved);
})();
</script>
<link rel="stylesheet" href="/css/main.css" />
<th:block th:if="${head != null}" th:replace="${head}" />
<script type="module" src="/js/main.ts"></script>
</head>
<body>
<!-- 头部导航 -->
<header class="header">
<div class="header-inner">
<a th:href="@{/}" class="logo">
<img
th:if="${not #strings.isEmpty(site.logo)}"
th:src="${site.logo}"
th:alt="${site.title}"
class="logo-img"
/>
<span
th:if="${#strings.isEmpty(site.logo)}"
class="logo-text"
th:text="${site.title}"
>Site Title</span
>
</a>
<nav class="nav">
<th:block th:with="primaryMenu = ${menuFinder.getPrimary()}">
<a
th:if="${primaryMenu == null}"
class="nav-link"
th:href="@{/}"
th:text="#{nav.home}"
>Home</a
>
<th:block th:if="${primaryMenu != null}">
<a
th:each="item : ${primaryMenu.menuItems}"
th:href="@{${item.status.href}}"
th:text="${item.status.displayName}"
th:target="${item.spec.target?.value}"
class="nav-link"
></a>
</th:block>
</th:block>
</nav>
<div class="header-actions">
<button
class="btn-icon"
id="theme-toggle"
type="button"
th:title="#{theme.toggle}"
>
<svg
class="icon icon-sun"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
<svg
class="icon icon-moon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
<button
th:if="${pluginFinder.available('PluginSearchWidget')}"
class="btn-icon"
id="search-toggle"
type="button"
th:title="#{search.title}"
>
<svg
class="icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</button>
<button
class="btn-icon mobile-menu-toggle"
id="mobile-menu-toggle"
type="button"
th:title="#{mobile.menu}"
>
<svg
class="icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
</div>
</div>
</header>
<!-- 移动端导航菜单 -->
<div class="mobile-nav" id="mobile-nav">
<th:block th:with="primaryMenu = ${menuFinder.getPrimary()}">
<a
th:if="${primaryMenu == null}"
class="mobile-nav-link"
th:href="@{/}"
th:text="#{nav.home}"
>Home</a
>
<th:block th:if="${primaryMenu != null}">
<a
th:each="item : ${primaryMenu.menuItems}"
th:href="@{${item.status.href}}"
th:text="${item.status.displayName}"
th:target="${item.spec.target?.value}"
class="mobile-nav-link"
></a>
</th:block>
</th:block>
</div>
<main class="main">
<th:block th:replace="${content}" />
</main>
<!-- 页脚 -->
<footer class="footer">
<div class="container">
<div
class="footer-copyright"
th:if="${not #strings.isEmpty(theme.config?.basic?.footer_copyright)}"
th:utext="${theme.config.basic.footer_copyright}"
></div>
<p class="footer-theme">
Theme by
<a href="https://kehua.me" target="_blank" rel="noopener">kehua</a>
</p>
<halo:footer />
</div>
</footer>
<!-- 回到顶部 -->
<button
class="back-to-top"
id="back-to-top"
type="button"
th:title="#{back.to.top}"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
</button>
</body>
</html>
+201
View File
@@ -0,0 +1,201 @@
<include src="partials/layout.html">
<template name="head">
<title
th:text="|${singlePage.spec.title} - ${site.title}|"
th:if="${singlePage != null}"
>
申请友链
</title>
<style>
.apply-form {
max-width: 32rem;
margin: 0 auto;
}
.apply-form .form-row {
margin-bottom: 1rem;
}
.apply-form label {
display: block;
margin-bottom: 0.35rem;
font-weight: 600;
color: var(--color-text);
}
.apply-form input,
.apply-form textarea {
width: 100%;
padding: 0.6rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font: inherit;
}
.apply-form textarea {
min-height: 5rem;
resize: vertical;
}
.apply-captcha {
display: flex;
gap: 0.75rem;
align-items: center;
}
.apply-captcha img {
height: 2.75rem;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
cursor: pointer;
}
.apply-status {
margin-top: 1rem;
padding: 0.75rem 1rem;
border-radius: var(--radius-sm);
display: none;
}
.apply-status.ok {
display: block;
background: rgba(46, 160, 67, 0.12);
color: #2ea043;
border: 1px solid rgba(46, 160, 67, 0.4);
}
.apply-status.err {
display: block;
background: rgba(229, 57, 53, 0.12);
color: #e53935;
border: 1px solid rgba(229, 57, 53, 0.4);
}
</style>
</template>
<div class="container">
<section class="apply-form">
<h1 th:text="${singlePage.spec.title}" th:if="${singlePage != null}">申请友链</h1>
<p
class="apply-intro"
th:if="${singlePage.spec.description != null}"
th:utext="${singlePage.spec.description}"
></p>
<form id="link-apply-form" novalidate>
<div class="form-row">
<label for="f-url">网站地址 *</label>
<input id="f-url" name="url" type="url" required placeholder="https://example.com" />
</div>
<div class="form-row">
<label for="f-name">网站名称 *</label>
<input id="f-name" name="displayName" type="text" required placeholder="我的站点" />
</div>
<div class="form-row">
<label for="f-logo">Logo 地址</label>
<input id="f-logo" name="logo" type="url" placeholder="https://example.com/logo.png" />
</div>
<div class="form-row">
<label for="f-desc">网站描述</label>
<input id="f-desc" name="description" type="text" placeholder="一句话介绍你的站点" />
</div>
<div class="form-row">
<label for="f-email">联系邮箱</label>
<input id="f-email" name="email" type="email" placeholder="you@example.com" />
</div>
<div class="form-row">
<label for="f-backlink">反链页面</label>
<input id="f-backlink" name="backlink" type="url" placeholder="含本站链接的页面地址" />
</div>
<div class="form-row">
<label for="f-feeds">RSS / Atom 地址(每行一个)</label>
<textarea id="f-feeds" name="feedUrls" placeholder="https://example.com/feed.xml"></textarea>
</div>
<div class="form-row">
<label for="f-captcha">验证码 *</label>
<div class="apply-captcha">
<img id="captcha-img" alt="验证码" title="点击刷新" />
<input id="f-captcha" name="captchaCode" type="text" maxlength="5" required autocomplete="off" />
</div>
</div>
<button class="btn btn-primary" type="submit">提交申请</button>
</form>
<div class="apply-status" id="apply-status" role="alert"></div>
</section>
</div>
<script th:inline="none">
(function () {
var API = "/apis/api.link.halo.run/v1alpha1/link-applications";
var CAPTCHA = "/apis/api.link.halo.run/v1alpha1/link-applications/captcha";
var form = document.getElementById("link-apply-form");
var img = document.getElementById("captcha-img");
var status = document.getElementById("apply-status");
var challengeId = null;
function loadCaptcha() {
fetch(CAPTCHA, { method: "POST" })
.then(function (r) {
if (!r.ok) throw new Error("captcha");
return r.json();
})
.then(function (data) {
challengeId = data.challengeId;
img.src = data.image;
})
.catch(function () {
status.className = "apply-status err";
status.textContent = "验证码加载失败,请刷新页面重试。";
});
}
img.addEventListener("click", loadCaptcha);
form.addEventListener("submit", function (e) {
e.preventDefault();
status.className = "apply-status";
status.textContent = "";
if (!challengeId) {
status.className = "apply-status err";
status.textContent = "请等待验证码加载完成。";
return;
}
var fd = new FormData(form);
var feeds = (fd.get("feedUrls") || "")
.split(/\r?\n/)
.map(function (s) { return s.trim(); })
.filter(Boolean);
var payload = {
url: fd.get("url"),
displayName: fd.get("displayName"),
logo: fd.get("logo") || undefined,
description: fd.get("description") || undefined,
email: fd.get("email") || undefined,
backlink: fd.get("backlink") || undefined,
feedUrls: feeds,
challengeId: challengeId,
captchaCode: fd.get("captchaCode")
};
fetch(API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
})
.then(function (r) {
if (r.status === 201) {
form.reset();
challengeId = null;
loadCaptcha();
status.className = "apply-status ok";
status.textContent = "申请已提交,等待管理员审核。";
} else {
return r.json().then(function (err) {
throw err;
});
}
})
.catch(function () {
loadCaptcha();
status.className = "apply-status err";
status.textContent = "提交失败,请检查信息后重试(验证码已刷新)。";
});
});
loadCaptcha();
})();
</script>
</include>
+95
View File
@@ -0,0 +1,95 @@
<include src="partials/layout.html">
<template name="head">
<title
th:text="|${singlePage.spec.title} - ${site.title}|"
th:if="${singlePage != null}"
>
链接订阅
</title>
<style>
.feed-list {
list-style: none;
margin: 0;
padding: 0;
}
.feed-item {
padding: 1.25rem 0;
border-bottom: 1px solid var(--color-border);
}
.feed-item-title {
font-size: 1.05rem;
font-weight: 600;
}
.feed-item-title a {
color: var(--color-link);
}
.feed-item-meta {
margin: 0.35rem 0;
font-size: 0.8rem;
color: var(--color-text-muted);
}
.feed-item-summary {
margin: 0.5rem 0 0;
color: var(--color-text-secondary);
line-height: 1.7;
}
</style>
</template>
<div class="container">
<section class="feed">
<h1 th:text="${singlePage.spec.title}" th:if="${singlePage != null}">链接订阅</h1>
<p
class="feed-intro"
th:if="${singlePage.spec.description != null}"
th:utext="${singlePage.spec.description}"
></p>
<div
th:if="${linkFeedFinder != null}"
th:with="feedPage=${linkFeedFinder.list({limit: 30})}"
>
<ul th:if="${not #lists.isEmpty(feedPage.items)}" class="feed-list">
<li class="feed-item" th:each="item : ${feedPage.items}">
<div class="feed-item-title">
<a th:href="@{${item.url}}" target="_blank" rel="noopener noreferrer">
<span th:text="${item.title}">标题</span>
</a>
</div>
<p class="feed-item-meta">
<span th:text="${item.linkName}">来源</span>
<span th:if="${item.author != null and item.author != ''}">
· <span th:text="${item.author}">作者</span></span
>
<span
th:if="${item.publishedAt != null}"
th:with="pt=${item.publishedAt}"
>
· <time
th:datetime="${pt}"
th:text="${#temporals.format(pt, 'yyyy-MM-dd HH:mm')}"
>时间</time
></span
>
</p>
<p
class="feed-item-summary"
th:if="${not #strings.isEmpty(item.summary)}"
th:utext="${item.summary}"
></p>
</li>
</ul>
<p th:if="${#lists.isEmpty(feedPage.items)}" class="no-results">
暂无订阅动态。请在 plugin-links 设置中开启「公开 RSS 订阅动态」。
</p>
</div>
<p
class="no-results"
th:if="${linkFeedFinder == null}"
>
未检测到 plugin-links,无法加载订阅动态。
</p>
</section>
</div>
</include>
+105
View File
@@ -0,0 +1,105 @@
<include src="partials/layout.html">
<template name="head">
<title th:text="|${linksTitle} - ${site.title}|">链接</title>
<style>
.links-nav {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin: 1rem 0 1.5rem;
}
.links-nav a {
color: var(--color-link);
}
.links-nav a:hover,
.links-nav a.active {
color: var(--color-link-hover);
font-weight: 600;
}
.links-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(18rem, 100%), 1fr));
gap: 1rem;
}
.links-list li {
margin: 0;
}
.links-list a {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
color: var(--color-text);
transition: border-color var(--transition-fast);
}
.links-list a:hover {
border-color: var(--color-primary);
}
.links-list img {
width: 2.5rem;
height: 2.5rem;
border-radius: var(--radius-sm);
object-fit: cover;
flex-shrink: 0;
}
.links-list .links-name {
font-weight: 600;
}
.links-list .links-desc {
margin: 0.25rem 0 0;
font-size: 0.85rem;
color: var(--color-text-secondary);
}
</style>
</template>
<div class="container">
<section class="links">
<h1 th:text="${linksTitle}">链接</h1>
<nav class="links-nav" aria-label="链接分组">
<a th:href="@{/links}" th:classappend="${group == null} ? 'active'">全部</a>
<a
th:each="item : ${simpleGroups}"
th:href="@{/links(group=${item.metadata.name})}"
th:classappend="${group == item.metadata.name} ? 'active'"
th:text="${item.spec.displayName}"
></a>
</nav>
<ul th:if="${not #lists.isEmpty(links)}" class="links-list">
<li th:each="link : ${links}">
<a th:href="@{${link.spec.url}}" target="_blank" rel="noopener noreferrer">
<img
th:if="${not #strings.isEmpty(link.spec.logo)}"
th:src="@{${link.spec.logo}}"
alt=""
loading="lazy"
/>
<span>
<span class="links-name" th:text="${link.spec.displayName}"
>链接名称</span
>
<p
class="links-desc"
th:if="${not #strings.isEmpty(link.spec.description)}"
th:text="${link.spec.description}"
></p>
</span>
</a>
</li>
</ul>
<p th:unless="${not #lists.isEmpty(links)}">暂无链接</p>
</section>
<div class="comments-section" th:if="${haloCommentEnabled}">
<halo:comment group="plugin.halo.run" kind="Plugin" th:attr="name=${pluginName}" />
</div>
</div>
</include>
+22
View File
@@ -0,0 +1,22 @@
<th:block th:fragment="category-tree(cats)">
<ul class="category-tree">
<li class="category-tree-item" th:each="cat : ${cats}">
<a th:href="@{${cat.status.permalink}}" class="category-tree-link">
<span class="category-tree-name" th:text="${cat.spec.displayName}">Category</span>
<span
class="category-tree-count"
th:text="${cat.postCount}"
th:if="${cat.postCount != null and cat.postCount > 0}"
>0</span
>
</a>
<th:block
th:if="${cat.children != null and !#lists.isEmpty(cat.children)}"
>
<th:block
th:replace="~{modules/category-tree :: category-tree(cats=${cat.children})}"
></th:block>
</th:block>
</li>
</ul>
</th:block>
+37
View File
@@ -0,0 +1,37 @@
<!--
分页导航。
context 为当前列表的路由前缀(如 '/'、'/archives/'、分类或标签 permalink + '/'),
页码链接由 context + 'page/' + n 构造,不硬编码可配置路由。
-->
<nav
class="pagination"
th:fragment="pagination(context, prevUrl, nextUrl, totalPages, page, hasPrevious, hasNext)"
th:if="${hasPrevious || hasNext}"
>
<a
th:if="${hasPrevious}"
th:href="@{${prevUrl}}"
class="pagination-item pagination-prev"
>
<span>←</span>
<span th:text="#{pagination.previous}">Previous</span>
</a>
<span class="pagination-pages">
<a
th:each="i : ${#numbers.sequence(1, totalPages)}"
th:href="@{${i == 1 ? context : (context + 'page/' + i)}}"
th:text="${i}"
class="pagination-item"
th:classappend="${i == page} ? 'active'"
>1</a
>
</span>
<a
th:if="${hasNext}"
th:href="@{${nextUrl}}"
class="pagination-item pagination-next"
>
<span th:text="#{pagination.next}">Next</span>
<span>→</span>
</a>
</nav>
+25
View File
@@ -0,0 +1,25 @@
<!-- 文章列表卡片 -->
<article
class="post"
th:fragment="post-card(post)"
th:classappend="${not #strings.isEmpty(post.spec.cover)} ? 'post-with-thumb'"
>
<div class="post-thumb" th:if="${not #strings.isEmpty(post.spec.cover)}">
<a th:href="@{${post.status.permalink}}">
<img th:src="${post.spec.cover}" th:alt="${post.spec.title}" loading="lazy" />
</a>
</div>
<div class="post-content">
<h2 class="post-title">
<a th:href="@{${post.status.permalink}}" th:text="${post.spec.title}"
>Post title</a
>
</h2>
<p
class="post-excerpt"
th:if="${not #strings.isEmpty(post.status.excerpt)}"
th:text="${post.status.excerpt}"
></p>
<th:block th:replace="~{modules/post-meta :: post-meta(post=${post}, dateClass='post-date')}"></th:block>
</div>
</article>
+26
View File
@@ -0,0 +1,26 @@
<!-- 文章元信息(时间 / 分类 / 浏览量),供列表卡片与详情页复用 -->
<div class="post-meta" th:fragment="post-meta(post, dateClass)">
<time
th:if="${post.spec.publishTime != null}"
th:class="${dateClass}"
th:datetime="${post.spec.publishTime}"
th:text="${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd')}"
data-relative-time
></time>
<th:block th:if="${not #lists.isEmpty(post.categories)}">
<span class="post-meta-sep">·</span>
<span class="post-category">
<a
th:each="cat : ${post.categories}"
th:href="@{${cat.status.permalink}}"
th:text="${cat.spec.displayName}"
></a>
</span>
</th:block>
<th:block th:if="${post.stats != null and post.stats.visit != null and post.stats.visit > 0}">
<span class="post-meta-sep">·</span>
<span class="post-views" th:text="#{post.views(${post.stats.visit})}"
>1 views</span
>
</th:block>
</div>
+139
View File
@@ -0,0 +1,139 @@
<include src="partials/layout.html">
<template name="head">
<title th:text="|${title} - ${site.title}|">瞬间</title>
<style>
.moments > nav {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin: 1.5rem 0;
}
.moments > nav a {
color: var(--color-link);
}
.moments > nav a:hover {
color: var(--color-link-hover);
}
.moments > ul {
margin: 0;
padding: 0;
list-style: none;
}
.moments article {
padding: 1.5rem 0;
border-bottom: 1px solid var(--color-border);
}
.moments article header {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 0.75rem;
opacity: 0.75;
color: var(--color-text-secondary);
}
.moments .moment-owner {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.moments .moment-owner img {
width: 2rem;
height: 2rem;
border-radius: 50%;
object-fit: cover;
}
.moments .moment-media img,
.moments .moment-media video {
display: block;
max-width: 100%;
height: auto;
margin-top: 1rem;
border-radius: var(--radius-md);
}
.moments .moment-media audio {
width: 100%;
margin-top: 1rem;
}
</style>
</template>
<div class="container">
<section class="moments">
<h1 th:text="${title}">瞬间</h1>
<nav th:if="${not #lists.isEmpty(tags)}" aria-label="瞬间标签">
<a th:href="@{/moments}">全部</a>
<a
th:each="tag : ${tags}"
th:href="@{${tag.permalink}}"
th:text="|${tag.name} (${tag.momentCount})|"
></a>
</nav>
<ul th:if="${not #lists.isEmpty(moments.items)}">
<li th:each="moment : ${moments.items}">
<article th:with="content=${moment.spec.content}">
<header>
<span class="moment-owner" th:if="${moment.owner != null}">
<img
th:if="${not #strings.isEmpty(moment.owner.avatar)}"
th:src="${moment.owner.avatar}"
alt=""
loading="lazy"
/>
<span th:text="${moment.owner.displayName}">用户名</span>
</span>
<a th:href="@{/moments/{name}(name=${moment.metadata.name})}">
<time
th:if="${moment.spec.releaseTime != null}"
th:datetime="${moment.spec.releaseTime}"
th:text="${#temporals.format(moment.spec.releaseTime, 'yyyy-MM-dd HH:mm')}"
>发布时间</time
>
</a>
</header>
<div
th:if="${not #strings.isEmpty(content.html)}"
th:utext="${content.html}"
></div>
<div
class="moment-media"
th:if="${not #lists.isEmpty(content.medium)}"
>
<th:block th:each="medium : ${content.medium}">
<img
th:if="${medium.type.name == 'PHOTO'}"
th:src="${medium.url}"
alt="瞬间图片"
loading="lazy"
/>
<video
th:if="${medium.type.name == 'VIDEO'}"
th:src="${medium.url}"
controls
></video>
<audio
th:if="${medium.type.name == 'AUDIO'}"
th:src="${medium.url}"
controls
></audio>
</th:block>
</div>
</article>
</li>
</ul>
<p th:if="${#lists.isEmpty(moments.items)}">暂无瞬间</p>
<nav
th:if="${moments.hasPrevious() or moments.hasNext()}"
aria-label="瞬间分页"
>
<a th:if="${moments.hasPrevious()}" th:href="@{${moments.prevUrl}}">上一页</a>
<span th:text="${moments.page}">1</span>
<a th:if="${moments.hasNext()}" th:href="@{${moments.nextUrl}}">下一页</a>
</nav>
</section>
</div>
</include>
+35 -14
View File
@@ -1,20 +1,41 @@
<include src="layout.html"> <include src="partials/layout.html">
<template name="head"> <template name="head">
<title th:text="|${singlePage.spec.title} - ${site.title}|">Page Title</title> <title th:text="|${singlePage.spec.title} - ${site.title}|">
Page Title
</title>
<th:block
th:with="seoTitle=${singlePage.spec.title}, seoUrl=${site.url + singlePage.status.permalink}, seoDesc=${singlePage.status.excerpt}, seoImage=${singlePage.spec.cover}, seoType='website'"
>
<include src="seo.html"></include>
</th:block>
</template> </template>
<article> <div class="container">
<header class="article-header"> <!-- 默认页面样式 -->
<h1 class="article-title" th:text="${singlePage.spec.title}"></h1> <article class="article-detail">
</header> <header class="article-header">
<div class="article-content" th:utext="${singlePage.content.content}"></div> <h1 class="article-title" th:text="${singlePage.spec.title}">
</article> Page title
</h1>
</header>
<div th:if="${haloCommentEnabled}"> <div
<halo:comment class="article-cover"
group="content.halo.run" th:if="${not #strings.isEmpty(singlePage.spec.cover)}"
kind="SinglePage" >
th:attr="name=${singlePage.metadata.name}" <img th:src="${singlePage.spec.cover}" th:alt="${singlePage.spec.title}" />
/> </div>
<div class="article-content" th:utext="${singlePage.content.content}"></div>
</article>
<!-- 评论区 -->
<div th:if="${haloCommentEnabled}" class="comments-section">
<halo:comment
group="content.halo.run"
kind="SinglePage"
th:attr="name=${singlePage.metadata.name}"
/>
</div>
</div> </div>
</include> </include>
+92
View File
@@ -0,0 +1,92 @@
<include src="partials/layout.html">
<template name="head">
<title th:text="|${singlePage.spec.title} - ${site.title}|">
About
</title>
<th:block
th:with="seoTitle=${singlePage.spec.title}, seoUrl=${site.url + singlePage.status.permalink}, seoDesc=${singlePage.status.excerpt}, seoImage=${singlePage.spec.cover}, seoType='website'"
>
<include src="seo.html"></include>
</th:block>
</template>
<div class="container">
<!-- 关于页面样式 -->
<div class="page-content about-page">
<div class="about-header">
<div class="about-avatar">
<img
th:src="${not #strings.isEmpty(theme.config?.about?.avatar) ? theme.config.about.avatar : ((singlePage.owner != null and not #strings.isEmpty(singlePage.owner.avatar)) ? singlePage.owner.avatar : site.logo)}"
th:alt="${singlePage.owner != null ? singlePage.owner.displayName : site.title}"
/>
</div>
<h1
class="about-name"
th:text="${singlePage.owner != null ? singlePage.owner.displayName : site.title}"
>
About
</h1>
<p
class="about-bio"
th:if="${not #strings.isEmpty(theme.config?.about?.bio)}"
th:text="${theme.config.about.bio}"
></p>
<div
class="about-social"
th:if="${not #strings.isEmpty(theme.config?.about?.github_url) or not #strings.isEmpty(theme.config?.about?.twitter_url) or not #strings.isEmpty(theme.config?.about?.contact_email)}"
>
<a
th:if="${not #strings.isEmpty(theme.config?.about?.github_url)}"
th:href="${theme.config.about.github_url}"
class="social-link"
th:title="#{about.social.github}"
target="_blank"
rel="noopener"
>
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
/>
</svg>
</a>
<a
th:if="${not #strings.isEmpty(theme.config?.about?.twitter_url)}"
th:href="${theme.config.about.twitter_url}"
class="social-link"
th:title="#{about.social.twitter}"
target="_blank"
rel="noopener"
>
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"
/>
</svg>
</a>
<a
th:if="${not #strings.isEmpty(theme.config?.about?.contact_email)}"
th:href="|mailto:${theme.config.about.contact_email}|"
class="social-link"
th:title="#{about.social.email}"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"
></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
</a>
</div>
</div>
<div class="about-content article-content">
<th:block th:utext="${singlePage.content.content}"></th:block>
</div>
</div>
</div>
</include>
+53
View File
@@ -0,0 +1,53 @@
<include src="partials/layout.html">
<template name="head">
<title
th:text="|${singlePage.spec.title} - ${site.title}|"
th:if="${singlePage != null}"
>
留言板
</title>
<style>
.guestbook-intro {
color: var(--color-text-secondary);
margin-bottom: var(--spacing-xl);
line-height: 1.7;
}
.guestbook-comments {
margin-top: var(--spacing-2xl);
border-top: 1px solid var(--color-border);
padding-top: var(--spacing-xl);
}
.guestbook-comments h2 {
font-size: 1.25rem;
margin: 0 0 var(--spacing-md);
}
</style>
</template>
<div class="container">
<article class="page-content guestbook-page">
<h1 th:text="${singlePage.spec.title}" th:if="${singlePage != null}">留言板</h1>
<div
class="article-content guestbook-intro"
th:if="${singlePage.status.excerpt != null}"
th:utext="${singlePage.status.excerpt}"
></div>
<div
class="article-content"
th:if="${singlePage.content != null}"
th:utext="${singlePage.content.content}"
></div>
<section class="guestbook-comments" th:if="${haloCommentEnabled}">
<h2>留言</h2>
<halo:comment
group="content.halo.run"
kind="SinglePage"
th:attr="name=${singlePage.metadata.name}"
/>
</section>
</article>
</div>
</include>
+173 -16
View File
@@ -1,42 +1,199 @@
<!doctype html> <!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"> <html
lang="zh-CN"
xmlns:th="https://www.thymeleaf.org"
th:lang="${#locale.toLanguageTag}"
>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<slot name="head"> <slot name="head">
<title th:text="${site.title}">Site Title</title> <title th:text="${site.title}">Site Title</title>
</slot> </slot>
<script type="module" src="./js/main.ts"></script> <script th:inline="javascript">
(function () {
var preferred = /*[[${theme.config?.basic?.color_scheme ?: 'system'}]]*/ "system";
var stored = null;
try {
stored = localStorage.getItem("theme");
} catch (e) {
/* ignore */
}
var resolved = stored || preferred;
if (resolved === "system") {
resolved = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
document.documentElement.setAttribute("data-theme", resolved);
})();
</script>
<script type="module" src="/js/main.ts"></script>
</head> </head>
<body> <body>
<header class="site-header"> <!-- 头部导航 -->
<div class="container"> <header class="header">
<a class="site-title" th:href="@{/}" th:text="${site.title}">Site Title</a> <div class="header-inner">
<nav class="site-nav" th:with="menu = ${menuFinder.getPrimary()}"> <a th:href="@{/}" class="logo">
<ul> <img
<li th:each="item : ${menu.menuItems}"> th:if="${not #strings.isEmpty(site.logo)}"
th:src="${site.logo}"
th:alt="${site.title}"
class="logo-img"
/>
<span
th:if="${#strings.isEmpty(site.logo)}"
class="logo-text"
th:text="${site.title}"
>Site Title</span
>
</a>
<nav class="nav">
<th:block th:with="primaryMenu = ${menuFinder.getPrimary()}">
<a
th:if="${primaryMenu == null}"
class="nav-link"
th:href="@{/}"
th:text="#{nav.home}"
>Home</a
>
<th:block th:if="${primaryMenu != null}">
<a <a
th:each="item : ${primaryMenu.menuItems}"
th:href="@{${item.status.href}}" th:href="@{${item.status.href}}"
th:text="${item.status.displayName}" th:text="${item.status.displayName}"
th:target="${item.spec.target?.value}" th:target="${item.spec.target?.value}"
class="nav-link"
></a> ></a>
</li> </th:block>
</ul> </th:block>
</nav> </nav>
<div class="header-actions">
<button
class="btn-icon"
id="theme-toggle"
type="button"
th:title="#{theme.toggle}"
>
<svg
class="icon icon-sun"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
<svg
class="icon icon-moon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
<button
th:if="${pluginFinder.available('PluginSearchWidget')}"
class="btn-icon"
id="search-toggle"
type="button"
th:title="#{search.title}"
>
<svg
class="icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</button>
<button
class="btn-icon mobile-menu-toggle"
id="mobile-menu-toggle"
type="button"
th:title="#{mobile.menu}"
>
<svg
class="icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
</div>
</div> </div>
</header> </header>
<main class="site-main"> <!-- 移动端导航菜单 -->
<div class="container"> <div class="mobile-nav" id="mobile-nav">
<slot /> <th:block th:with="primaryMenu = ${menuFinder.getPrimary()}">
</div> <a
th:if="${primaryMenu == null}"
class="mobile-nav-link"
th:href="@{/}"
th:text="#{nav.home}"
>Home</a
>
<th:block th:if="${primaryMenu != null}">
<a
th:each="item : ${primaryMenu.menuItems}"
th:href="@{${item.status.href}}"
th:text="${item.status.displayName}"
th:target="${item.spec.target?.value}"
class="mobile-nav-link"
></a>
</th:block>
</th:block>
</div>
<main class="main">
<slot />
</main> </main>
<footer class="site-footer"> <!-- 页脚 -->
<footer class="footer">
<div class="container"> <div class="container">
<p th:text="${theme.config.basic.custom_footer ?: site.title}"></p> <div
class="footer-copyright"
th:if="${not #strings.isEmpty(theme.config?.basic?.footer_copyright)}"
th:utext="${theme.config.basic.footer_copyright}"
></div>
<p class="footer-theme">
Theme by
<a href="https://kehua.me" target="_blank" rel="noopener">kehua</a>
</p>
<halo:footer /> <halo:footer />
</div> </div>
</footer> </footer>
<!-- 回到顶部 -->
<button
class="back-to-top"
id="back-to-top"
type="button"
th:title="#{back.to.top}"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
</button>
</body> </body>
</html> </html>
+40
View File
@@ -0,0 +1,40 @@
<!--
公共 SEO 片段:输出 canonical、Open Graph 与 Twitter Card。
由各页面通过 th:with 提供 seoTitle / seoUrl / seoDesc / seoImage / seoType 变量。
description / keywords 由 Halo 自动注入,此处不重复输出。
-->
<th:block th:fragment="seo" th:if="${theme.config?.seo?.enable_metadata}">
<link rel="canonical" th:href="@{${seoUrl}}" />
<meta property="og:site_name" th:content="${site.title}" />
<meta
property="og:type"
th:content="${seoType != null ? seoType : 'website'}"
/>
<meta property="og:title" th:content="${seoTitle}" />
<meta property="og:url" th:content="${seoUrl}" />
<meta
th:if="${seoDesc != null and not #strings.isEmpty(seoDesc)}"
property="og:description"
th:content="${seoDesc}"
/>
<meta
th:if="${seoImage != null and not #strings.isEmpty(seoImage)}"
property="og:image"
th:content="${seoImage}"
/>
<meta
name="twitter:card"
th:content="${seoImage != null and not #strings.isEmpty(seoImage)} ? 'summary_large_image' : 'summary'"
/>
<meta name="twitter:title" th:content="${seoTitle}" />
<meta
th:if="${seoDesc != null and not #strings.isEmpty(seoDesc)}"
name="twitter:description"
th:content="${seoDesc}"
/>
<meta
th:if="${seoImage != null and not #strings.isEmpty(seoImage)}"
name="twitter:image"
th:content="${seoImage}"
/>
</th:block>
+189
View File
@@ -0,0 +1,189 @@
<include src="partials/layout.html">
<template name="head">
<title th:text="|${title} - ${site.title}|">图库</title>
<style>
:where(.photos-groups) {
margin: 1.5rem 0;
}
:where(.photos-groups__list, .photos-grid, .photos-card__tags) {
margin: 0;
padding: 0;
list-style: none;
}
:where(.photos-groups__list, .photos-card__tags) {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
:where(.photos-group__link) {
display: inline-flex;
gap: 0.25rem;
align-items: baseline;
color: var(--color-link);
}
:where(.photos-group__link):hover {
color: var(--color-link-hover);
}
:where(.photos-group.is-active .photos-group__link) {
font-weight: 600;
}
:where(.photos-grid) {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(16rem, 100%), 1fr));
gap: 1.25rem;
}
:where(.photos-card__image) {
display: block;
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
}
:where(.photos-card__title) {
margin: 0.75rem 0 0.25rem;
font-size: 1rem;
}
:where(.photos-card__description, .photos-card__tags) {
margin-top: 0.5rem;
}
:where(.photos-card__tags) {
gap: 0.5rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
:where(.photos-pagination) {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 2rem;
}
</style>
</template>
<div class="container">
<section
class="photos-page"
th:attr="data-template-id=${_templateId},data-group=${group},data-page=${photos.page},data-size=${photos.size},data-total=${photos.total}"
>
<header class="photos-header">
<h1 class="photos-header__title" th:text="${title}">图库</h1>
</header>
<nav class="photos-groups" aria-label="照片分组">
<ul class="photos-groups__list">
<li
class="photos-group"
th:classappend="${#strings.isEmpty(group)} ? 'is-active' : ''"
>
<a
class="photos-group__link"
th:href="${photoUrl.list()}"
th:attr="aria-current=${#strings.isEmpty(group) ? 'page' : null}"
>全部</a
>
</li>
<li
class="photos-group"
th:each="photoGroup : ${groups}"
th:classappend="${group == photoGroup.metadata.name} ? 'is-active' : ''"
th:attr="data-group=${photoGroup.metadata.name}"
>
<a
class="photos-group__link"
th:href="${photoUrl.list(photoGroup.metadata.name)}"
th:attr="aria-current=${group == photoGroup.metadata.name ? 'page' : null}"
>
<span
class="photos-group__name"
th:text="${photoGroup.spec.displayName}"
>分组</span
>
<span
class="photos-group__count"
th:if="${photoGroup.status != null && photoGroup.status.photoCount != null}"
th:text="|(${photoGroup.status.photoCount})|"
></span>
</a>
</li>
</ul>
</nav>
<ul class="photos-grid" th:if="${!#lists.isEmpty(photos.items)}">
<li
class="photos-grid__item"
th:each="photo : ${photos.items}"
th:attr="data-photo-name=${photo.metadata.name},data-group=${photo.spec.groupName},data-priority=${photo.spec.priority}"
>
<article class="photos-card">
<a class="photos-card__media" th:href="${photoUrl.detail(photo)}">
<img
class="photos-card__image"
th:src="${#strings.isEmpty(photo.spec.cover) ? photo.spec.url : photo.spec.cover}"
th:alt="${photo.spec.displayName}"
loading="lazy"
/>
</a>
<div class="photos-card__content">
<h2 class="photos-card__title">
<a
class="photos-card__title-link"
th:href="${photoUrl.detail(photo)}"
th:text="${photo.spec.displayName}"
>照片</a
>
</h2>
<p
class="photos-card__description"
th:if="${!#strings.isEmpty(photo.spec.description)}"
th:text="${photo.spec.description}"
></p>
<ul
class="photos-card__tags"
th:if="${!#lists.isEmpty(photo.spec.tags)}"
aria-label="照片标签"
>
<li
class="photos-card__tag"
th:each="tag : ${photo.spec.tags}"
th:text="${tag}"
></li>
</ul>
</div>
</article>
</li>
</ul>
<p
class="photos-empty"
th:if="${#lists.isEmpty(photos.items)}"
role="status"
>
暂无照片
</p>
<nav
class="photos-pagination"
th:if="${photos.hasPrevious() || photos.hasNext()}"
aria-label="分页"
>
<a
class="photos-pagination__previous"
th:if="${photos.hasPrevious()}"
th:href="${photos.prevUrl}"
>上一页</a
>
<span class="photos-pagination__status">
第 <strong th:text="${photos.page}">1</strong> 页,共
<strong th:text="${photos.totalPages}">1</strong> 页
</span>
<a
class="photos-pagination__next"
th:if="${photos.hasNext()}"
th:href="${photos.nextUrl}"
>下一页</a
>
</nav>
</section>
</div>
</include>
+92 -37
View File
@@ -1,56 +1,111 @@
<include src="layout.html"> <include src="partials/layout.html">
<template name="head"> <template name="head">
<title th:text="|${post.spec.title} - ${site.title}|">Post Title</title> <title th:text="|${post.spec.title} - ${site.title}|">Post Title</title>
<script type="module" src="./js/post.ts"></script> <th:block
th:with="seoTitle=${post.spec.title}, seoUrl=${site.url + post.status.permalink}, seoDesc=${post.status.excerpt}, seoImage=${post.spec.cover}, seoType='article'"
>
<include src="seo.html"></include>
</th:block>
<th:block th:if="${theme.config?.seo?.enable_structured_data}">
<script type="application/ld+json" th:inline="javascript">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": /*[[${post.spec.title}]]*/ "",
"url": /*[[${site.url + post.status.permalink}]]*/ "",
"description": /*[[${post.status.excerpt}]]*/ "",
"image": /*[[${post.spec.cover}]]*/ "",
"datePublished": /*[[${post.spec.publishTime != null ? #temporals.format(post.spec.publishTime, "yyyy-MM-dd'T'HH:mm:ssXXX") : null}]]*/ "",
"dateModified": /*[[${post.status.lastModifyTime != null ? #temporals.format(post.status.lastModifyTime, "yyyy-MM-dd'T'HH:mm:ssXXX") : null}]]*/ "",
"author": {
"@type": "Person",
"name": /*[[${post.owner?.displayName}]]*/ ""
}
}
</script>
</th:block>
<script type="module" src="/js/post.ts"></script>
</template> </template>
<article> <div class="container">
<header class="article-header"> <!-- 文章内容 -->
<h1 class="article-title" th:text="${post.spec.title}"></h1> <article class="article-detail">
<div class="article-meta"> <header class="article-header">
<time th:text="${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd')}"></time> <h1 class="article-title" th:text="${post.spec.title}">Post title</h1>
<span th:if="${not #lists.isEmpty(post.categories)}"> <div class="article-meta">
&nbsp;·&nbsp; <time
<a th:if="${post.spec.publishTime != null}"
th:each="cat : ${post.categories}" class="article-date"
th:href="@{${cat.status.permalink}}" th:datetime="${post.spec.publishTime}"
th:text="${cat.spec.displayName}" th:text="${#temporals.format(post.spec.publishTime, 'yyyy-MM-dd')}"
class="category" data-relative-time
></a> ></time>
</span> <th:block th:if="${not #lists.isEmpty(post.categories)}">
<span class="article-meta-sep">·</span>
<span class="article-category">
<a
th:each="cat : ${post.categories}"
th:href="@{${cat.status.permalink}}"
th:text="${cat.spec.displayName}"
></a>
</span>
</th:block>
<th:block
th:if="${post.stats != null and post.stats.visit != null and post.stats.visit > 0}"
>
<span class="article-meta-sep">·</span>
<span
class="article-views"
th:text="#{post.views(${post.stats.visit})}"
>1 views</span
>
</th:block>
</div>
</header>
<div class="article-cover" th:if="${not #strings.isEmpty(post.spec.cover)}">
<img th:src="${post.spec.cover}" th:alt="${post.spec.title}" />
</div> </div>
</header>
<div class="article-content" th:utext="${post.content.content}"></div> <div class="article-content" th:utext="${post.content.content}"></div>
<div class="article-tags" th:if="${not #lists.isEmpty(post.tags)}"> <!-- 文章标签 -->
<a <div class="article-tags" th:if="${not #lists.isEmpty(post.tags)}">
th:each="tag : ${post.tags}" <span class="tag-label" th:text="#{tag.title}">Tags</span>
th:href="@{${tag.status.permalink}}" <a
th:text="${tag.spec.displayName}" th:each="tag : ${post.tags}"
class="tag" th:href="@{${tag.status.permalink}}"
></a> th:text="${tag.spec.displayName}"
</div> ></a>
</div>
<nav class="post-nav" th:with="cursor = ${postFinder.cursor(post.metadata.name)}"> <!-- 文章导航 -->
<span> <nav
class="article-nav"
th:with="cursor = ${postFinder.cursor(post.metadata.name)}"
>
<a <a
th:if="${cursor.hasPrevious()}" th:if="${cursor.hasPrevious()}"
class="article-nav-item article-nav-prev"
th:href="@{${cursor.previous.status.permalink}}" th:href="@{${cursor.previous.status.permalink}}"
th:text="|← ${cursor.previous.spec.title}|" th:text="${cursor.previous.spec.title}"
></a> ></a>
</span>
<span>
<a <a
th:if="${cursor.hasNext()}" th:if="${cursor.hasNext()}"
class="article-nav-item article-nav-next"
th:href="@{${cursor.next.status.permalink}}" th:href="@{${cursor.next.status.permalink}}"
th:text="|${cursor.next.spec.title} →|" th:text="${cursor.next.spec.title}"
></a> ></a>
</span> </nav>
</nav> </article>
</article>
<div th:if="${haloCommentEnabled}"> <!-- 评论区 -->
<halo:comment group="content.halo.run" kind="Post" th:attr="name=${post.metadata.name}" /> <div th:if="${haloCommentEnabled}" class="comments-section">
<halo:comment
group="content.halo.run"
kind="Post"
th:attr="name=${post.metadata.name}"
/>
</div>
</div> </div>
</include> </include>
+30 -7
View File
@@ -1,13 +1,36 @@
<include src="layout.html"> <include src="partials/layout.html">
<template name="head"> <template name="head">
<title th:text="|${tag.spec.displayName} - ${site.title}|">Tag Archive</title> <title th:text="|${tag.spec.displayName} - ${site.title}|">Tag</title>
<th:block
th:with="seoTitle=${tag.spec.displayName}, seoUrl=${site.url + tag.status.permalink}, seoDesc=null, seoImage=${tag.spec.cover ?: site.logo}, seoType='website'"
>
<include src="seo.html"></include>
</th:block>
</template> </template>
<h1 th:text="|Tag: ${tag.spec.displayName}|"></h1> <div class="container">
<!-- 标签标题 -->
<div class="category-header">
<h1 class="category-title" th:text="${tag.spec.displayName}">Tag</h1>
</div>
<ul class="post-list"> <!-- 文章列表 -->
<include src="post-card.html"></include> <div class="post-list" th:if="${not #lists.isEmpty(posts.items)}">
</ul> <th:block th:each="post : ${posts.items}">
<th:block
th:replace="~{modules/post-card :: post-card(post=${post})}"
></th:block>
</th:block>
</div>
<include src="pagination.html"></include> <!-- 空状态 -->
<div class="no-results" th:if="${#lists.isEmpty(posts.items)}">
<p th:text="#{list.empty}">Nothing found here.</p>
</div>
<!-- 分页 -->
<th:block
th:replace="~{modules/pagination :: pagination(context = |${tag.status.permalink}/|, prevUrl = ${posts.prevUrl}, nextUrl = ${posts.nextUrl}, totalPages = ${posts.totalPages}, page = ${posts.page}, hasPrevious = ${posts.hasPrevious()}, hasNext = ${posts.hasNext()})}"
></th:block>
</div>
</include> </include>
+25 -13
View File
@@ -1,18 +1,30 @@
<include src="layout.html"> <include src="partials/layout.html">
<template name="head"> <template name="head">
<title th:text="|Tags - ${site.title}|">Tags</title> <title th:text="|#{tags.title} - ${site.title}|">Tags</title>
<th:block
th:with="seoTitle=#{tags.title}, seoUrl=${site.url + site.routes.tagsUri}, seoDesc=null, seoImage=${site.logo}, seoType='website'"
>
<include src="seo.html"></include>
</th:block>
</template> </template>
<h1>Tags</h1> <div class="container">
<ul class="tag-list"> <div class="category-header">
<li th:each="tag : ${tags.items}"> <h1 class="category-title" th:text="#{tags.title}">Tags</h1>
<a th:href="@{${tag.status.permalink}}" th:text="${tag.spec.displayName}" class="tag"></a> </div>
</li>
</ul>
<nav class="pagination" th:if="${tags.hasPrevious() || tags.hasNext()}"> <div class="sidebar-tags tag-cloud" th:if="${not #lists.isEmpty(tags)}">
<a th:if="${tags.hasPrevious()}" th:href="@{${tags.prevUrl}}">Previous</a> <a
<span th:text="|${tags.page} / ${tags.totalPages}|"></span> th:each="tag : ${tags}"
<a th:if="${tags.hasNext()}" th:href="@{${tags.nextUrl}}">Next</a> th:href="@{${tag.status.permalink}}"
</nav> class="tag"
th:text="${tag.spec.displayName}"
th:title="${tag.postCount != null ? tag.postCount + ' ' + #{'tag.count'} : ''}"
></a>
</div>
<div class="no-results" th:if="${#lists.isEmpty(tags)}">
<p th:text="#{list.empty}">Nothing found here.</p>
</div>
</div>
</include> </include>
+67
View File
@@ -0,0 +1,67 @@
<include src="partials/layout.html">
<template name="head">
<title
th:text="|${singlePage.spec.title} - ${site.title}|"
th:if="${singlePage != null}"
>
微信读书
</title>
<style>
.weread-mount {
margin: 2rem auto;
min-height: 12rem;
}
.weread-loading {
text-align: center;
color: var(--color-text-muted);
padding: 3rem 0;
}
.weread-error {
text-align: center;
color: var(--color-primary);
padding: 3rem 0;
}
</style>
</template>
<div class="container">
<section class="weread-page">
<h1 th:text="${singlePage.spec.title}" th:if="${singlePage != null}">微信读书</h1>
<p
class="weread-intro"
th:if="${singlePage.spec.description != null}"
th:utext="${singlePage.spec.description}"
></p>
<div class="weread-mount" id="wr-mount">
<p class="weread-loading">正在加载书架…</p>
</div>
</section>
</div>
<script th:inline="none">
(function () {
var mount = document.getElementById("wr-mount");
var BASE = "/halo-weread-plugin/shelf-html";
fetch(BASE, { headers: { Accept: "text/html" } })
.then(function (r) {
if (!r.ok) throw new Error("shelf " + r.status);
return r.text();
})
.then(function (html) {
mount.innerHTML = html;
// 重新执行被注入的脚本(innerHTML 不会自动执行脚本)
mount.querySelectorAll("script").forEach(function (s) {
var el = document.createElement("script");
el.textContent = s.textContent;
document.body.appendChild(el);
el.remove();
});
})
.catch(function () {
mount.innerHTML =
'<p class="weread-error">书架加载失败,请确认已安装并启用 halo-weread-plugin 插件。</p>';
});
})();
</script>
</include>
+35 -13
View File
@@ -1,21 +1,43 @@
apiVersion: theme.halo.run/v1alpha1 apiVersion: theme.halo.run/v1alpha1
kind: Theme kind: Theme
metadata: metadata:
name: theme-vite-starter name: whitespace
spec: spec:
displayName: Vite Starter displayName: Whitespace 留白
author: author:
name: Halo name: 暖心向阳335
website: https://github.com/halo-dev website: https://git.nxxy335.top/nxxy335
description: A development starter theme with Vite Ecosystem for Halo description: 一款极简单栏博客主题,移植自 Typecho 主题 whitespace 留白,支持亮暗双主题、搜索与丰富的归档能力,并适配 moments/photos/links/weread 等插件。
logo: https://www.halo.run/logo logo: ""
homepage: https://github.com/halo-dev/theme-vite-starter#readme homepage: https://git.nxxy335.top/nxxy335/halo-theme-whitespace
repo: https://github.com/halo-dev/theme-vite-starter repo: https://git.nxxy335.top/nxxy335/halo-theme-whitespace.git
issues: https://github.com/halo-dev/theme-vite-starter/issues issues: https://git.nxxy335.top/nxxy335/halo-theme-whitespace/issues
settingName: "theme-vite-starter-setting" settingName: "whitespace-setting"
configMapName: "theme-vite-starter-configMap" configMapName: "whitespace-configMap"
version: 1.0.0 version: 0.1.0
requires: ">=2.0.0" requires: ">=2.26.0"
license: license:
- name: "GPL-3.0" - name: "GPL-3.0"
url: "https://github.com/halo-dev/theme-vite-starter/blob/main/LICENSE" url: "https://github.com/halo-dev/theme-vite-starter/blob/main/LICENSE"
customTemplates:
page:
- name: 关于页面
description: 关于页面布局,展示头像、个人简介与社交链接
screenshot:
file: page_about.html
- name: 微信读书页面
description: 适配 halo-weread-plugin,展示微信读书书架与读书笔记
screenshot:
file: weread.html
- name: 留言板页面
description: 以评论区作为留言板的单页模板
screenshot:
file: page_guestbook.html
- name: 友链申请页面
description: 适配 plugin-links 的访客友链申请表单
screenshot:
file: links-apply.html
- name: 链接订阅页面
description: 适配 plugin-links 的 RSS 订阅动态聚合页
screenshot:
file: links-subscribe.html