docs: update documentation for Halo 2.14 (#334)

为 Halo 2.14.0 更新文档。

/kind documentation

```release-note
None 
```
wan92hen-patch-1
Ryan Wang 8 months ago committed by GitHub
parent f4fdff1317
commit 6e97f3724b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -33,7 +33,7 @@ git checkout ${branch_name}
## 构建 Fat Jar
构建之前需要修改 `gradle.properties` 中的 `version` 属性(推荐遵循 [SemVer 规范](https://semver.org/)),例如:`version=2.13.0`
构建之前需要修改 `gradle.properties` 中的 `version` 属性(推荐遵循 [SemVer 规范](https://semver.org/)),例如:`version=2.14.0`
```bash
cd path/to/halo

@ -16,7 +16,7 @@ description: 开发环境运行的指南
3. 主题Halo 主项目内已包含默认主题)
:::info 说明
从 Halo 2.13 开始Halo 项目的 `ui` 目录同时包含了 Console管理控制台和 UC个人中心以下统称为 UI。
从 Halo 2.11 开始Halo 项目的 `ui` 目录同时包含了 Console管理控制台和 UC个人中心以下统称为 UI。
当前 Halo 主项目并不会将 UI 的构建资源托管到 Git 版本控制,所以在开发环境是需要同时运行 UI 项目的。当然,在我们的最终发布版本的时候会在 CI 中自动构建 UI 到 Halo 主项目。
:::

@ -20,18 +20,18 @@ import DockerArgs from "./slots/docker-args.md"
## 创建容器组
可用的 Halo 2.13 的 Docker 镜像:
可用的 Halo 2.14 的 Docker 镜像:
- [halohub/halo](https://hub.docker.com/r/halohub/halo)
- [ghcr.io/halo-dev/halo](https://github.com/halo-dev/halo/pkgs/container/halo)
:::info 注意
目前 Halo 2 并未更新 Docker 的 latest 标签镜像,主要因为 Halo 2 不兼容 1.x 版本,防止使用者误操作。我们推荐使用固定版本的标签,比如 `halohub/halo:2.13` 或者 `halohub/halo:2.13.0`。
目前 Halo 2 并未更新 Docker 的 latest 标签镜像,主要因为 Halo 2 不兼容 1.x 版本,防止使用者误操作。我们推荐使用固定版本的标签,比如 `halohub/halo:2.14` 或者 `halohub/halo:2.14.0`。
- `halohub/halo:2.13`:表示最新的 2.13.x 版本,即每次发布 patch 版本都会同时更新 `halohub/halo:2.13` 镜像。
- `halohub/halo:2.13.0`:表示一个具体的版本。
- `halohub/halo:2.14`:表示最新的 2.14.x 版本,即每次发布 patch 版本都会同时更新 `halohub/halo:2.14` 镜像。
- `halohub/halo:2.14.0`:表示一个具体的版本。
后续文档以 `halohub/halo:2.13` 为例。
后续文档以 `halohub/halo:2.14` 为例。
:::
1. 在系统任意位置创建一个文件夹,此文档以 `~/halo` 为例。
@ -59,7 +59,7 @@ import DockerArgs from "./slots/docker-args.md"
services:
halo:
image: halohub/halo:2.13
image: halohub/halo:2.14
restart: on-failure:3
depends_on:
halodb:
@ -117,7 +117,7 @@ import DockerArgs from "./slots/docker-args.md"
services:
halo:
image: halohub/halo:2.13
image: halohub/halo:2.14
restart: on-failure:3
depends_on:
halodb:
@ -185,7 +185,7 @@ import DockerArgs from "./slots/docker-args.md"
services:
halo:
image: halohub/halo:2.13
image: halohub/halo:2.14
restart: on-failure:3
volumes:
- ./halo2:/root/.halo2
@ -209,7 +209,7 @@ import DockerArgs from "./slots/docker-args.md"
services:
halo:
image: halohub/halo:2.13
image: halohub/halo:2.14
restart: on-failure:3
network_mode: "host"
volumes:
@ -258,7 +258,7 @@ import DockerArgs from "./slots/docker-args.md"
```yaml {3}
services:
halo:
image: halohub/halo:2.13
image: halohub/halo:2.14
```
```bash
@ -322,7 +322,7 @@ networks:
services:
halo:
image: halohub/halo:2.13
image: halohub/halo:2.14
restart: on-failure:3
volumes:
- ./halo2:/root/.halo2

@ -25,24 +25,24 @@ import DockerArgs from "./slots/docker-args.md"
## 使用 Docker 镜像
可用的 Halo 2.13 的 Docker 镜像:
可用的 Halo 2.14 的 Docker 镜像:
- [halohub/halo](https://hub.docker.com/r/halohub/halo)
- [ghcr.io/halo-dev/halo](https://github.com/halo-dev/halo/pkgs/container/halo)
:::info 注意
目前 Halo 2 并未更新 Docker 的 latest 标签镜像,主要因为 Halo 2 不兼容 1.x 版本,防止使用者误操作。我们推荐使用固定版本的标签,比如 `halohub/halo:2.13` 或者 `halohub/halo:2.13.0`。
目前 Halo 2 并未更新 Docker 的 latest 标签镜像,主要因为 Halo 2 不兼容 1.x 版本,防止使用者误操作。我们推荐使用固定版本的标签,比如 `halohub/halo:2.14` 或者 `halohub/halo:2.14.0`。
- `halohub/halo:2.13`:表示最新的 2.13.x 版本,即每次发布 patch 版本都会同时更新 `halohub/halo:2.13` 镜像。
- `halohub/halo:2.13.0`:表示一个具体的版本。
- `halohub/halo:2.14`:表示最新的 2.14.x 版本,即每次发布 patch 版本都会同时更新 `halohub/halo:2.14` 镜像。
- `halohub/halo:2.14.0`:表示一个具体的版本。
后续文档以 `halohub/halo:2.13` 为例。
后续文档以 `halohub/halo:2.14` 为例。
:::
1. 创建容器
```bash
docker run -it -d --name halo -p 8090:8090 -v ~/.halo2:/root/.halo2 halohub/halo:2.13
docker run -it -d --name halo -p 8090:8090 -v ~/.halo2:/root/.halo2 halohub/halo:2.14
```
:::info
@ -71,7 +71,7 @@ import DockerArgs from "./slots/docker-args.md"
2. 拉取新版本镜像
```bash
docker pull halohub/halo:2.13
docker pull halohub/halo:2.14
```
3. 停止运行中的容器
@ -86,5 +86,5 @@ import DockerArgs from "./slots/docker-args.md"
修改版本号后,按照最初安装的方式,重新创建容器即可。
```bash
docker run -it -d --name halo -p 8090:8090 -v ~/.halo2:/root/.halo2 halohub/halo:2.13
docker run -it -d --name halo -p 8090:8090 -v ~/.halo2:/root/.halo2 halohub/halo:2.14
```

@ -53,7 +53,7 @@ title: 使用 JAR 文件部署
3. 下载运行包
```bash
wget https://dl.halo.run/release/halo-2.13.0.jar -O halo.jar
wget https://dl.halo.run/release/halo-2.14.0.jar -O halo.jar
```
:::info
@ -244,7 +244,7 @@ journalctl -n 20 -u halo
3. 下载新版本的 Halo 运行包,覆盖原有的运行包
```bash
wget https://dl.halo.run/release/halo-2.13.0.jar -O /home/halo/app/halo.jar
wget https://dl.halo.run/release/halo-2.14.0.jar -O /home/halo/app/halo.jar
```
4. 启动 Halo 服务
@ -310,7 +310,7 @@ networks:
services:
halo:
image: halohub/halo:2.13
image: halohub/halo:2.14
container_name: halo
restart: on-failure:3
volumes:

@ -96,7 +96,7 @@ networks:
services:
halo:
image: halohub/halo:2.13
image: halohub/halo:2.14
container_name: halo
restart: on-failure:3
volumes:

@ -50,25 +50,25 @@ Podman 采用无守护进程的包容性架构,因此可以更安全、更简
通过[前言](#前言)我们已经了解了 Podman ,其中提到 ***Podman 与 Docker 高度兼容*** ,正是因为 Podman 完全是为了替代 Docker 而诞生,所以原本的 Docker 生态中的镜像我们可以无需更改直接使用。
:::
可用的 Halo 2.13 的 Docker 镜像:
可用的 Halo 2.14 的 Docker 镜像:
- [halohub/halo](https://hub.docker.com/r/halohub/halo)
- [ghcr.io/halo-dev/halo](https://github.com/halo-dev/halo/pkgs/container/halo)
:::info 注意
目前 Halo 2 并未更新 Docker 的 latest 标签镜像,主要因为 Halo 2 不兼容 1.x 版本,防止使用者误操作。我们推荐使用固定版本的标签,比如 `halohub/halo:2.13` 或者 `halohub/halo:2.13.0`。
目前 Halo 2 并未更新 Docker 的 latest 标签镜像,主要因为 Halo 2 不兼容 1.x 版本,防止使用者误操作。我们推荐使用固定版本的标签,比如 `halohub/halo:2.14` 或者 `halohub/halo:2.14.0`。
- `halohub/halo:2.13`:表示最新的 2.13.x 版本,即每次发布 patch 版本都会同时更新 `halohub/halo:2.13` 镜像。
- `halohub/halo:2.13.0`:表示一个具体的版本。
- `halohub/halo:2.14`:表示最新的 2.14.x 版本,即每次发布 patch 版本都会同时更新 `halohub/halo:2.14` 镜像。
- `halohub/halo:2.14.0`:表示一个具体的版本。
后续文档以 `halohub/halo:2.13` 为例。
后续文档以 `halohub/halo:2.14` 为例。
:::
1. 创建容器
```bash
mkdir -p ~/.halo2
podman run -it -d --name halo -p 8090:8090 -v ~/.halo2:/root/.halo2 halohub/halo:2.13
podman run -it -d --name halo -p 8090:8090 -v ~/.halo2:/root/.halo2 halohub/halo:2.14
```
:::info
@ -97,7 +97,7 @@ Podman 采用无守护进程的包容性架构,因此可以更安全、更简
2. 拉取新版本镜像
```bash
podman pull halohub/halo:2.13
podman pull halohub/halo:2.14
```
3. 停止运行中的容器
@ -112,7 +112,7 @@ Podman 采用无守护进程的包容性架构,因此可以更安全、更简
修改版本号后,按照最初安装的方式,重新创建容器即可。
```bash
podman run -it -d --name halo -p 8090:8090 -v ~/.halo2:/root/.halo2 halohub/halo:2.13
podman run -it -d --name halo -p 8090:8090 -v ~/.halo2:/root/.halo2 halohub/halo:2.14
```
## 使用 [Podman Quadlet](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html)
@ -148,7 +148,7 @@ Podman 没有和 Docker 类似的管理进程,在低配置的主机上更友
Environment=TZ=Asia/Shanghai
Volume=/opt/podman-data/halo:/.halo
PublishPort=127.0.0.1:8090:8090
Image=ghcr.io/halo-dev/halo:2.13
Image=ghcr.io/halo-dev/halo:2.14
Exec=--halo.external-url=https://localhost:8090 --spring.sql.init.platform=postgresql --spring.r2dbc.url=r2dbc:pool:postgresql://127.0.0.1:5432/my-db --spring.r2dbc.username=my-user --spring.r2dbc.password=my-password --halo.cache.page.disabled=false
[Service]
@ -177,7 +177,7 @@ Podman Quadlet 解析:
`[Container]` 部分:
- `AutoUpdate=registry`指定了自动拉取容器。假设后续Halo镜像支持了`latest`标签,你需要`systemctl enable --now podman-auto-update.timer`以启用容器自动更新。本文示例`ghcr.io/halo-dev/halo:2.13`,将会自动更新适用与`2.13`版本的patch例如您创建容器时是`2.13.1`,在官方发布`2.13.2`版本时,容器会自动更新到`2.13.2`。
- `AutoUpdate=registry`指定了自动拉取容器。假设后续Halo镜像支持了`latest`标签,你需要`systemctl enable --now podman-auto-update.timer`以启用容器自动更新。本文示例`ghcr.io/halo-dev/halo:2.14`,将会自动更新适用与`2.14`版本的patch例如您创建容器时是`2.14.1`,在官方发布`2.14.2`版本时,容器会自动更新到`2.14.2`。
- `ContainerName=`指定了 systemd 将生成的服务名称。
- `User=60000 Group=60000 UserNS=keep-id:uid=60000,gid=60000` 限制容器以 id 60000 的用户运行提高安全性。注意这个id 60000请根据你实际想要运行的用户名来修改可通过`id user`获得你的用户的id.
- `Environment=`字段指定了容器的环境变量,其中你需要注意的是`Environment=HALO_WORK_DIR="/.halo"` `Environment=SPRING_CONFIG_LOCATION="optional:classpath:/;optional:file:/.halo/"`这两个变量中的`/.halo`路径。
@ -220,7 +220,7 @@ Podman Quadlet 解析:
ContainerName=halo
Volume=/opt/podman-data/halo:/root/.halo
PublishPort=127.0.0.1:8090:8090
Image=ghcr.io/halo-dev/halo:2.13
Image=ghcr.io/halo-dev/halo:2.14
Exec=--halo.external-url=https://localhost:8090 --spring.sql.init.platform=postgresql --spring.r2dbc.url=r2dbc:pool:postgresql://127.0.0.1:5432/my-db --spring.r2dbc.username=my-user --spring.r2dbc.password=my-password --halo.cache.page.disabled=false
[Service]

@ -34,7 +34,7 @@ slug: /
## 快速开始
```bash
docker run -it -d --name halo -p 8090:8090 -v ~/.halo2:/root/.halo2 halohub/halo:2.13
docker run -d --name halo -p 8090:8090 -v ~/.halo2:/root/.halo2 halohub/halo:2.14
```
以上仅作为体验使用,详细部署文档请查阅:<https://docs.halo.run/getting-started/install/docker-compose>

@ -93,7 +93,7 @@ server {
--name halo-1 \
-p 8090:8090 \
-v ~/.halo2:/root/.halo2 \
halohub/halo:2.13 \
halohub/halo:2.14 \
# 第二个 Halo 容器
docker run \
@ -101,7 +101,7 @@ server {
--name halo-2 \
-p 8091:8090 \
-v ~/.halo2_2:/root/.halo2 \
halohub/halo:2.13 \
halohub/halo:2.14 \
```
更多 Docker 相关的教程请参考:[使用 Docker 部署 Halo](../getting-started/install/docker.md)

@ -27,11 +27,11 @@ const config = {
routeBasePath: "/",
showLastUpdateTime: true,
showLastUpdateAuthor: true,
lastVersion: "2.13",
lastVersion: "2.14",
versions: {
current: {
label: "2.14.0-SNAPSHOT",
path: "2.14.0-SNAPSHOT",
label: "2.15.0-SNAPSHOT",
path: "2.15.0-SNAPSHOT",
},
},
},
@ -235,22 +235,23 @@ const config = {
},
],
createRedirects(existingPath) {
if (existingPath.startsWith("/2.14.0-SNAPSHOT/")) {
if (existingPath.startsWith("/2.15.0-SNAPSHOT/")) {
return [
existingPath.replace("/2.14.0-SNAPSHOT/", "/2.0.0-SNAPSHOT/"),
existingPath.replace("/2.14.0-SNAPSHOT/", "/2.1.0-SNAPSHOT/"),
existingPath.replace("/2.14.0-SNAPSHOT/", "/2.2.0-SNAPSHOT/"),
existingPath.replace("/2.14.0-SNAPSHOT/", "/2.3.0-SNAPSHOT/"),
existingPath.replace("/2.14.0-SNAPSHOT/", "/2.4.0-SNAPSHOT/"),
existingPath.replace("/2.14.0-SNAPSHOT/", "/2.5.0-SNAPSHOT/"),
existingPath.replace("/2.14.0-SNAPSHOT/", "/2.6.0-SNAPSHOT/"),
existingPath.replace("/2.14.0-SNAPSHOT/", "/2.7.0-SNAPSHOT/"),
existingPath.replace("/2.14.0-SNAPSHOT/", "/2.8.0-SNAPSHOT/"),
existingPath.replace("/2.14.0-SNAPSHOT/", "/2.9.0-SNAPSHOT/"),
existingPath.replace("/2.14.0-SNAPSHOT/", "/2.10.0-SNAPSHOT/"),
existingPath.replace("/2.14.0-SNAPSHOT/", "/2.11.0-SNAPSHOT/"),
existingPath.replace("/2.14.0-SNAPSHOT/", "/2.12.0-SNAPSHOT/"),
existingPath.replace("/2.14.0-SNAPSHOT/", "/2.13.0-SNAPSHOT/"),
existingPath.replace("/2.15.0-SNAPSHOT/", "/2.0.0-SNAPSHOT/"),
existingPath.replace("/2.15.0-SNAPSHOT/", "/2.1.0-SNAPSHOT/"),
existingPath.replace("/2.15.0-SNAPSHOT/", "/2.2.0-SNAPSHOT/"),
existingPath.replace("/2.15.0-SNAPSHOT/", "/2.3.0-SNAPSHOT/"),
existingPath.replace("/2.15.0-SNAPSHOT/", "/2.4.0-SNAPSHOT/"),
existingPath.replace("/2.15.0-SNAPSHOT/", "/2.5.0-SNAPSHOT/"),
existingPath.replace("/2.15.0-SNAPSHOT/", "/2.6.0-SNAPSHOT/"),
existingPath.replace("/2.15.0-SNAPSHOT/", "/2.7.0-SNAPSHOT/"),
existingPath.replace("/2.15.0-SNAPSHOT/", "/2.8.0-SNAPSHOT/"),
existingPath.replace("/2.15.0-SNAPSHOT/", "/2.9.0-SNAPSHOT/"),
existingPath.replace("/2.15.0-SNAPSHOT/", "/2.10.0-SNAPSHOT/"),
existingPath.replace("/2.15.0-SNAPSHOT/", "/2.11.0-SNAPSHOT/"),
existingPath.replace("/2.15.0-SNAPSHOT/", "/2.12.0-SNAPSHOT/"),
existingPath.replace("/2.15.0-SNAPSHOT/", "/2.13.0-SNAPSHOT/"),
existingPath.replace("/2.15.0-SNAPSHOT/", "/2.14.0-SNAPSHOT/"),
];
}
return undefined;

@ -101,10 +101,6 @@
"message": "浅色模式",
"description": "The name for the light color mode"
},
"theme.docs.DocCard.categoryDescription": {
"message": "{count} 个项目",
"description": "The default description for a category card in the generated index about how many items this category includes"
},
"theme.docs.breadcrumbs.navAriaLabel": {
"message": "页面路径",
"description": "The ARIA label for the breadcrumbs"
@ -121,6 +117,10 @@
"message": "下一页",
"description": "The label used to navigate to the next doc"
},
"theme.docs.DocCard.categoryDescription": {
"message": "{count} 个项目",
"description": "The default description for a category card in the generated index about how many items this category includes"
},
"theme.docs.tagDocListPageTitle.nDocsTagged": {
"message": "{count} 篇文档带有标签",
"description": "Pluralized label for \"{count} docs tagged\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)"
@ -129,9 +129,6 @@
"message": "{nDocsTagged}「{tagName}」",
"description": "The title of the page for a docs tag"
},
"theme.docs.versionBadge.label": {
"message": "版本:{versionLabel}"
},
"theme.docs.versions.unreleasedVersionLabel": {
"message": "此为 {siteTitle} {versionLabel} 版尚未发行的文档。",
"description": "The label used to tell the user that he's browsing an unreleased doc version"
@ -148,6 +145,9 @@
"message": "最新版本",
"description": "The label used for the latest version suggestion link label"
},
"theme.docs.versionBadge.label": {
"message": "版本:{versionLabel}"
},
"theme.common.editThisPage": {
"message": "编辑此页",
"description": "The link label to edit the current page"
@ -204,14 +204,14 @@
"message": "打开/收起侧边栏菜单「{label}」",
"description": "The ARIA label to toggle the collapsible sidebar category"
},
"theme.navbar.mobileLanguageDropdown.label": {
"message": "选择语言",
"description": "The label for the mobile language switcher dropdown"
},
"theme.NavBar.navAriaLabel": {
"message": "主导航",
"description": "The ARIA label for the main navigation"
},
"theme.navbar.mobileLanguageDropdown.label": {
"message": "选择语言",
"description": "The label for the mobile language switcher dropdown"
},
"theme.TOCCollapsible.toggleButtonLabel": {
"message": "本页总览",
"description": "The label used by the button on the collapsible TOC component"
@ -252,10 +252,6 @@
"message": "切换导航栏",
"description": "The ARIA label for hamburger menu button of mobile navigation"
},
"theme.navbar.mobileSidebarSecondaryMenu.backButtonLabel": {
"message": "← 回到主菜单",
"description": "The label of the back button to return to main menu, inside the mobile navbar sidebar secondary menu (notably used to display the docs sidebar)"
},
"theme.docs.sidebar.expandButtonTitle": {
"message": "展开侧边栏",
"description": "The ARIA label and title attribute for expand button of doc sidebar"
@ -264,6 +260,10 @@
"message": "展开侧边栏",
"description": "The ARIA label and title attribute for expand button of doc sidebar"
},
"theme.navbar.mobileSidebarSecondaryMenu.backButtonLabel": {
"message": "← 回到主菜单",
"description": "The label of the back button to return to main menu, inside the mobile navbar sidebar secondary menu (notably used to display the docs sidebar)"
},
"theme.SearchBar.seeAll": {
"message": "查看全部 {count} 个结果"
},

@ -1,6 +1,6 @@
{
"version.label": {
"message": "2.14.0-SNAPSHOT",
"message": "2.15.0-SNAPSHOT",
"description": "The label for version current"
},
"sidebar.tutorial.category.入门": {

@ -0,0 +1,74 @@
{
"version.label": {
"message": "2.14",
"description": "The label for version 2.14"
},
"sidebar.tutorial.category.入门": {
"message": "入门",
"description": "The label for category 入门 in sidebar tutorial"
},
"sidebar.tutorial.category.安装指南": {
"message": "安装指南",
"description": "The label for category 安装指南 in sidebar tutorial"
},
"sidebar.tutorial.category.其他指南": {
"message": "其他指南",
"description": "The label for category 其他指南 in sidebar tutorial"
},
"sidebar.tutorial.category.用户指南": {
"message": "用户指南",
"description": "The label for category 用户指南 in sidebar tutorial"
},
"sidebar.tutorial.category.参与贡献": {
"message": "参与贡献",
"description": "The label for category 参与贡献 in sidebar tutorial"
},
"sidebar.developer.category.系统开发": {
"message": "系统开发",
"description": "The label for category 系统开发 in sidebar developer"
},
"sidebar.developer.category.插件开发": {
"message": "插件开发",
"description": "The label for category 插件开发 in sidebar developer"
},
"sidebar.developer.category.基础": {
"message": "基础",
"description": "The label for category 基础 in sidebar developer"
},
"sidebar.developer.category.服务端": {
"message": "服务端",
"description": "The label for category 服务端 in sidebar developer"
},
"sidebar.developer.category.UI": {
"message": "UI",
"description": "The label for category UI in sidebar developer"
},
"sidebar.developer.category.API 参考": {
"message": "API 参考",
"description": "The label for category API 参考 in sidebar developer"
},
"sidebar.developer.category.扩展点": {
"message": "扩展点",
"description": "The label for category 扩展点 in sidebar developer"
},
"sidebar.developer.category.组件": {
"message": "组件",
"description": "The label for category 组件 in sidebar developer"
},
"sidebar.developer.category.案例和最佳实践": {
"message": "案例和最佳实践",
"description": "The label for category 案例和最佳实践 in sidebar developer"
},
"sidebar.developer.category.主题开发": {
"message": "主题开发",
"description": "The label for category 主题开发 in sidebar developer"
},
"sidebar.developer.category.模板变量": {
"message": "模板变量",
"description": "The label for category 模板变量 in sidebar developer"
},
"sidebar.developer.category.Finder API": {
"message": "Finder API",
"description": "The label for category Finder API in sidebar developer"
}
}

@ -0,0 +1,16 @@
---
title: 关于文档
description: 关于本文档站点的一些说明
---
:::note
此文档使用 [Docusaurus](https://docusaurus.io/) 搭建,感谢 [Docusaurus](https://github.com/facebook/docusaurus) 社区所做的贡献。
:::
## 参与贡献
:::tip
如果你发现文档中有不正确或者需要添加的内容,非常欢迎参与到文档编辑当中。
:::
当前文档的仓库地址为 [halo-dev/docs](https://github.com/halo-dev/docs) ,所以你可以 fork 此仓库,修改之后提交 `Pull request` 等待我们合并即可。

@ -0,0 +1,28 @@
---
title: 问题反馈
description: 问题反馈渠道及指南
---
:::info
如果您在使用过程中遇到了什么问题,您可以通过下面的方式反馈,但请尽量按照要求提出反馈。
:::
## GitHub Issues
链接:<https://github.com/halo-dev/halo/issues>
如果你在使用过程中,遇到了一些 bug 或者需要添加某些新特性,请尽量在 GitHub Issues 进行反馈,这非常有助于我们跟踪解决此问题,您也可以很方便的接收到处理状态。
建议步骤:
1. 在 [Issues 列表](https://github.com/halo-dev/halo/issues) 搜索相关问题,看看是否有其他人已经提到了此问题。
2. 如果当前还没有人遇到您类似的问题,那么请点击右上角的 `New issue` 按钮创建新的 issue。
3. 选择正确的反馈类型。
4. 请尽可能详细的按照模板填写内容。
5. 点击 `Submit new issue` 提交 issue。
## Halo 官方社区
链接:<https://bbs.halo.run>
此平台主要目的用于与其他 Halo 用户进行交流。但如果您对 GitHub 不是很熟悉或者没有账号,您也可以在此平台进行反馈。

@ -0,0 +1,110 @@
---
title: 代码贡献
description: 代码贡献指南
---
欢迎关注并有想法参与 Halo 的开发,以下是关于如何参与到 Halo 项目的指南,仅供参考。
## 发现 Issue
所有的代码尽可能都有依据Issue不是凭空产生。
### 寻找一个 Good First Issue
> 这个步骤非常适合首次贡献者。
在 [halo-dev](https://github.com/halo-dev) 和 [halo-sigs](https://github.com/halo-sigs) 组织下,有非常多的仓库。每个仓库下都有可能包含一些“首次贡献者”友好的 Issue主要是为了给贡献者提供一个友好的体验。 该类 Issue
一般会用 `good-first-issue` 标签标记。标签 `good-first-issue` 表示该 Issue 不需要对 Halo 有深入的理解也能够参与。
请点击:[good-first-issue](https://github.com/issues?q=org%3Ahalo-dev+is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22+no%3Aassignee+)
查看关于 Halo 的 Good First Issue。
### 认领 Issue
若对任何一个 Issue 感兴趣,请尝试在 Issue 进行回复,讨论解决 Issue 的思路。确定后可直接通过 `/assign` 或者 `/assign @GitHub 用户名` 认领这个
Issue。这样可避免两位贡献者在同一个问题上花时间。
## 代码贡献步骤
1. Fork 此仓库
点击 Halo 仓库主页右上角的 `Fork` 按钮即可。
2. Clone 仓库到本地
```bash
git clone https://github.com/{YOUR_USERNAME}/halo --recursive
# 或者 git clone git@github.com:{YOUR_USERNAME}/halo.git --recursive
```
3. 添加主仓库
添加主仓库方便未来同步主仓库最新的 commits 以及创建新的分支。
```bash
git remote add upstream https://github.com/halo-dev/halo.git
# 或者 git remote add upstream git@github.com:halo-dev/halo.git
git fetch upstream main
```
4. 创建新的开发分支
我们需要从主仓库的主分支创建一个新的开发分支。
```bash
git checkout upstream/main
git checkout -b {BRANCH_NAME}
```
5. 提交代码
```bash
git add .
git commit -s -m "Fix a bug king"
git push origin {BRANCH_NAME}
```
6. 合并主分支
在提交 Pull Request 之前,尽量保证当前分支和主分支的代码尽可能同步,这时需要我们手动操作。示例:
```bash
git fetch upstream/main
git merge upstream/main
git push origin {BRANCH_NAME}
```
## Pull Request
进入此阶段说明已经完成了代码的编写,测试和自测,并且准备好接受 Code Review。
### 创建 Pull Request
回到自己的仓库页面,选择 `New pull request` 按钮,创建 `Pull request` 到原仓库的 `main` 分支。
然后等待我们 Review 即可,如有 `Change Request`,再本地修改之后再次提交即可。
提交 Pull Request 的注意事项:
- 提交 Pull Request 请充分自测。
- 每个 Pull Request 尽量只解决一个 Issue特殊情况除外。
- 应尽可能多的添加单元测试,其他测试(集成测试和 E2E 测试)可看情况添加。
- 不论需要解决的 Issue 发生在哪个版本,提交 Pull Request 的时候,请将主仓库的主分支设置为 `main`。例如:即使某个 Bug 于 Halo 2.0.x 被发现,但是提交 Pull Request 仍只针对
`main` 分支,等待 Pull Request 合并之后,我们会通过 `/cherrypick release-2.0` 或者 `/cherry-pick release-2.1` 指令将此 Pull Request
的修改应用到 `release-2.0``release-2.1` 分支上。
### 更新 commits
Code Review 阶段可能需要 Pull Request 作者重新修改代码,请直接在当前分支 commit 并 push 即可,无需关闭并重新提交 Pull Request。示例
```bash
git add .
git commit -s -m "Refactor some code according code review"
git push origin bug/king
```
同时,若已经进入 Code Review 阶段,请不要强制推送 commits 到当前分支。否则 Reviewers 需要从头开始 Code Review。
### 开发规范
请参考 [https://docs.halo.run/developer-guide/core/code-style](https://docs.halo.run/developer-guide/core/code-style)
,请确保所有代码格式化之后再提交。

@ -0,0 +1,26 @@
---
title: 赞助我们
description: 如果 Halo 对你有帮助,不妨赞助我们
---
## 为什么需要赞助
我们花费了大量的精力来维护这个软件并且也提供了不少资金来维护服务器域名等。因此我们需要赞助来节省部分开发成本。你的赞助不仅仅会被我们用来支付一些开发成本比如服务器OSS域名等还会让我们有更多的信心和精力投入到这个开源项目的开发中。从而让项目保持更加健康的成长以及迭代。
## 赞助形式
:::info
你可以通过任何形式对我们赞助,不限于资金。
:::
### 资金赞助
- 爱发电:<https://afdian.net/a/halo-dev>
### 通过我们的推广链接购买服务器
如果你当前还没有购买服务器,可以考虑通过以下链接购买,这会为我们带来一部分收益。
- 阿里云:<https://www.aliyun.com/daily-act/ecs/activity_selection?userCode=j57gyupo>
- 阿里云新人专享:<https://www.aliyun.com/minisite/goods?userCode=j57gyupo>
- 腾讯云:<https://curl.qcloud.com/9Ogon25Y>

@ -0,0 +1,89 @@
---
title: 元数据表单定义
---
在 Halo 2.0,所有的模型都包含了 `metadata.annotations` 字段,用于存储元数据信息。元数据信息可以用于存储一些自定义的信息,可以等同于扩展字段。此文档主要介绍如何在 Halo 中为具体的模型定义元数据编辑表单,至于如何在插件或者主题模板中使用,请看插件或者主题的文档。
定义元数据编辑表单同样使用 `FormKit Schema`,但和主题或插件的定义方式稍有不同,其中输入组件类型可参考 [表单定义](./form-schema.md)。
:::info 提示
因为 `metadata.annotations` 是一个键值都为字符串类型的对象所以表单项的值必须为字符串类型。这就意味着FormKit 的 `number`、`group`、`repeater` 等类型的输入组件都不能使用。
:::
## AnnotationSetting 资源定义方式
```yaml title="annotation-setting.yaml"
apiVersion: v1alpha1
kind: AnnotationSetting
metadata:
name: my-annotation-setting
spec:
targetRef:
group: content.halo.run
kind: Post
formSchema:
- $formkit: "text"
name: "download"
label: "下载地址"
- $formkit: "text"
name: "version"
label: "版本"
```
以上定义为文章模型添加了两个元数据字段,分别为 `download``version`,分别对应了下载地址和版本号,最终效果:
![Annotation Setting Preview](/img/annotation-setting/annotation-setting-preview.png)
字段说明:
1. `metadata.name`:唯一标识,命名规范可参考 [metadata name](./plugin/api-reference/server/extension.md#metadata-name),为了尽可能避免冲突,建议自定义前缀以及追加随机字符串,如:`theme-earth-post-wanfs5`。
2. `spec.targetRef`:模型的关联,即为哪个模型添加元数据表单,目前支持的模型可查看下方的列表。
3. `spec.formSchema`:表单的定义,使用 FormKit Schema 来定义。虽然我们使用的 YAML但与 FormKit Schema 完全一致。
targetRef 支持列表:
| 对应模型 | group | kind |
| ---------- | ---------------- | ---------- |
| 文章 | content.halo.run | Post |
| 自定义页面 | content.halo.run | SinglePage |
| 文章分类 | content.halo.run | Category |
| 文章标签 | content.halo.run | Tag |
| 菜单项 | `""` | MenuItem |
| 用户 | `""` | User |
## 为多个模型定义表单
考虑到某些情况可能会同时为多个模型添加元数据表单,推荐在一个 `yaml` 文件中使用 `---` 来分割多个资源定义,如下:
```yaml title="annotation-setting.yaml"
apiVersion: v1alpha1
kind: AnnotationSetting
metadata:
name: my-annotation-setting
spec:
targetRef:
group: content.halo.run
kind: Post
formSchema:
- $formkit: "text"
name: "download"
label: "下载地址"
- $formkit: "text"
name: "version"
label: "版本"
---
apiVersion: v1alpha1
kind: AnnotationSetting
metadata:
name: my-annotation-setting
spec:
targetRef:
group: ""
kind: MenuItem
formSchema:
- $formkit: "text"
name: "icon"
label: "图标"
```

@ -0,0 +1,84 @@
---
title: 构建
description: 构建为可执行 JAR 和 Docker 镜像的文档
---
:::info
在此之前,我们推荐你先阅读[《准备工作》](./prepare),检查本地环境是否满足要求。
:::
一般情况下,为了保证版本一致性和可维护性,我们并不推荐自行构建和二次开发。
## 克隆项目
如果你已经 Fork 了相关仓库,请将以下命令中的 `halo-dev` 替换为你的 GitHub 用户名。
```bash
git clone https://github.com/halo-dev/halo
# 或者使用 ssh 的方式 clone推荐
# git clone git@github.com:halo-dev/halo.git
# 或者使用 GitHub CLI 克隆(推荐)
# gh repo clone halo-dev/halo
# 或者使用 GitHub CLI Fork推荐
# gh repo fork halo-dev/halo
cd halo
# 切换到特定的分支或标签,请替换 ${branch_name}
git checkout ${branch_name}
```
## 构建 Fat Jar
构建之前需要修改 `gradle.properties` 中的 `version` 属性(推荐遵循 [SemVer 规范](https://semver.org/)),例如:`version=2.14.0`
```bash
cd path/to/halo
```
下载预设插件(可选):
```bash
# Windows
./gradlew.bat downloadPluginPresets
# macOS / Linux
./gradlew downloadPluginPresets
```
构建:
```bash
# Windows
./gradlew.bat clean build -x check
# macOS / Linux
./gradlew clean build -x check
```
构建完成之后,在 Halo 项目下产生的 `application/build/libs/halo-${version}.jar` 即为构建完成的文件。
最终部署文档可参考:[使用 JAR 文件部署](../../getting-started/install/jar-file.md)。
## 构建 Docker 镜像
在此之前,请确认已经构建好了 Fat Jar。
```bash
cd path/to/halo
```
```bash
# 请替换 ${tag_name}
docker build -t halo-dev/halo:${tag_name} .
```
```bash
# 插件构建完成的版本
docker images | grep halo
```
最终部署文档可参考:[使用 Docker Compose 部署](../../getting-started/install/docker-compose.md)。

@ -0,0 +1,30 @@
---
title: 代码风格
description: 代码风格的相关配置说明
---
Halo 添加了 checkstyle 插件,来保证每位提交者代码的风格保持一致,减少无效代码的修改。本篇文章主要讲解如何在 IDEA 中添加 CheckStyle 插件,并引入项目所提供的 checkstyle.xml 配置。
## 安装 CheckStyle-IDEA
- 进入 IDEA 插件市场。
- 搜索 CheckStyle-IDEA点击安装即可。
## 配置 CheckStyle
- 进入 CheckStyle 配置File | Settings | Tools | Checkstyle
- 选择 Checkstyle 版本9.3(以文件 `application/build.gradle` 中指定的版本为准)。
- 在配置文件中点击添加按钮,配置描述可随便填写(推荐 Halo Checks选择 ./config/checkstyle/checkstyle.xml点击下一步和完成。
- 勾选刚刚创建的配置文件。
## 配置 Editor
- 进入编辑器配置File | Settings | Editor | Code Style
- 导入 checkstyle.xm 配置:
![image.png](https://halo.run/upload/2020/2/image-0c7a018e73f74634a534fa3ba8806628.png)
- 选择 `./config/checkstyle/checkstyle.xml` 配置文件,点击确定即可。
至此,有关代码风格检查工具和格式化配置已经完成。

@ -0,0 +1,25 @@
---
title: 准备工作
description: 开发环境的准备工作
---
## 环境要求
- [OpenJDK 17 LTS](https://github.com/openjdk/jdk)
- [Node.js 18 LTS](https://nodejs.org)
- [pnpm 8](https://pnpm.io/)
- [IntelliJ IDEA](https://www.jetbrains.com/idea/)
- [Git](https://git-scm.com/)
- [Docker](https://www.docker.com/)(可选)
## 名词解释
### 工作目录
指 Halo 所依赖的工作目录,在 Halo 运行的时候会在系统当前用户目录下产生一个 halo-next 的文件夹,绝对路径为 ~/halo-next。里面通常包含下列目录或文件
1. `db`:存放 H2 Database 的物理文件,如果你使用其他数据库,那么不会存在这个目录。
2. `themes`:里面包含用户所安装的主题。
2. `plugins`:里面包含用户所安装的插件。
5. `attachments`:附件目录。
4. `logs`:运行日志目录。

@ -0,0 +1,131 @@
---
title: 开发环境运行
description: 开发环境运行的指南
---
:::info
在此之前,我们推荐你先阅读[《准备工作》](./prepare),检查本地环境是否满足要求。
:::
## 项目结构说明
目前如果需要完整的运行 Halo总共需要三个部分
1. Halo 主项目([halo-dev/halo](https://github.com/halo-dev/halo)
2. UI包括 Console 控制台和 UC 个人中心(托管在 Halo 主项目)
3. 主题Halo 主项目内已包含默认主题)
:::info 说明
从 Halo 2.11 开始Halo 项目的 `ui` 目录同时包含了 Console管理控制台和 UC个人中心以下统称为 UI。
当前 Halo 主项目并不会将 UI 的构建资源托管到 Git 版本控制,所以在开发环境是需要同时运行 UI 项目的。当然,在我们的最终发布版本的时候会在 CI 中自动构建 UI 到 Halo 主项目。
:::
## 克隆项目
如果你已经 Fork 了相关仓库,请将以下命令中的 `halo-dev` 替换为你的 GitHub 用户名。
```bash
git clone https://github.com/halo-dev/halo
# 或者使用 ssh 的方式 clone推荐
# git clone git@github.com:halo-dev/halo.git
# 或者使用 GitHub CLI 克隆(推荐)
# gh repo clone halo-dev/halo
# 或者使用 GitHub CLI Fork推荐
# gh repo fork halo-dev/halo
```
## 运行 UI 服务
```bash
cd path/to/halo
```
Linux / macOS 平台:
```bash
./gradlew :ui:dev
```
Windows 平台:
```bash
./gradlew.bat :ui:dev
```
最终控制台打印了如下信息即代表运行正常:
```bash
VITE v4.2.3 ready in 638 ms
# Console 控制台服务
➜ Local: http://localhost:3000/console/
# UC 个人中心服务
➜ Local: http://localhost:4000/uc/
```
:::info 提示
请不要直接使用 UI 的运行端口3000 / 4000访问会因为跨域问题导致无法正常登录建议按照后续的步骤以 dev 的配置文件运行 Halo在 dev 的配置文件中,我们默认代理了 UI 页面的访问地址,所以后续访问 UI 页面使用 `http://localhost:8090/console``http://localhost:8090/uc` 访问即可,代理的相关配置:
```yaml
halo:
console:
proxy:
endpoint: http://localhost:3000/
enabled: true
uc:
proxy:
endpoint: http://localhost:4000/
enabled: true
```
:::
## 运行 Halo
1. 在 IntelliJ IDEA 中打开 Halo 项目,等待 Gradle 初始化和依赖下载完成。
2. 下载预设插件(可选)
```bash
# Windows
./gradlew.bat downloadPluginPresets
# macOS / Linux
./gradlew downloadPluginPresets
```
3. 修改 IntelliJ IDEA 的运行配置
- Windows
将 Active Profiles 改为 `dev,win`,如图所示:
![IntelliJ IDEA Profiles](/img/developer-run/IntelliJ-IDEA-Profiles-Win.png)
- macOS / Linux
将 Active Profiles 改为 `dev`,如图所示:
![IntelliJ IDEA Profiles](/img/developer-run/IntelliJ-IDEA-Profiles-macOS.png)
4. 点击 IntelliJ IDEA 的运行按钮,等待项目启动完成。
5. 或者使用 Gradle 运行
```bash
# macOS / Linux
./gradlew bootRun --args="--spring.profiles.active=dev"
# Windows
gradlew.bat bootRun --args="--spring.profiles.active=dev,win"
```
6. 最终提供以下访问地址:
1. 网站首页:<http://localhost:8090>
2. Console 控制台:<http://localhost:8090/console>
3. UC 个人中心:<http://localhost:8090/uc>

@ -0,0 +1,36 @@
---
title: 系统结构
description: Halo 项目的构成
---
[Halo](https://github.com/halo-dev/halo) 博客系统分为以下四个部分:
| 项目名称 | 简介 |
| :------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------- |
| [halo](https://github.com/halo-dev/halo) | 提供整个系统的服务,采用 [Spring Boot](https://spring.io/) 开发 |
| [halo-admin](https://github.com/halo-dev/halo-admin) | 负责后台管理的渲染,采用 [Vue](https://vuejs.org/) 开发,已集成在 Halo 运行包内,无需独立部署。 |
| [halo-comment](https://github.com/halo-dev/halo-comment) | 评论插件,采用 [Vue](https://vuejs.org/) 开发,在主题中运行方式引入构建好的 `JavaScript` 文件即可 |
| [halo-theme-\*](https://github.com/halo-dev) | 主题项目集,采用 [FreeMarker](https://freemarker.apache.org/) 模板引擎编写,需要包含一些特殊的配置才能够被 halo 所使用 |
## 自定义配置
> 为什么要提前讲自定义配置呢?是因为在这里让大家了解到 `Halo` 的`配置方式`,以及`配置优先级`,不至于未来运行项目的时候不知道如何优雅地修改配置。
`Halo` 配置目录优先级如下(从上到下优先级越来越小,上层的配置将会覆盖下层):
- `Halo` 自定义配置
- file:~/.halo/
- file:~/.halo-dev/
- `Spring Boot` 默认配置
- file:./config/
- file:./
- classpath:/config/
- classpath:/
> 参考: [Application Property Files](https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-external-config.html#boot-features-external-config-application-property-files)
在开发的时候,希望大家能够在 `~/halo-dev/application.yml` 中进行添加自定义配置。当然后面也会讲到如何用`运行参数` 和 `VM options` 进行控制配置,届时可根据具体情况进行选择。
:::warning
开发的时候,我们不建议直接更改`项目源码`中的所包含的`配置文件`,包括 `application.yml`、`application-dev.yml`、`application-test.yml` 和 `application-user.yml`
:::

@ -0,0 +1,379 @@
---
title: 表单定义
---
在 Halo 2.0,在 Console 端的所有表单我们都使用了 [FormKit](https://github.com/formkit/formkit) 的方案。FormKit 不仅支持使用 Vue 组件的形式来构建表单,同时支持使用 Schema 的形式来构建。因此,我们的 [Setting](https://github.com/halo-dev/halo/blob/87ccd61ae5cd35a38324c30502d4e9c0ced41c6a/src/main/java/run/halo/app/core/extension/Setting.java#L20) 资源中的表单定义,都是使用 FormKit Schema 来定义的,最常用的场景即主题和插件的设置表单定义。当然,如果要在 Halo 2.0 的插件中使用,也可以参考 FormKit 的文档使用 Vue 组件的形式使用,但不需要在插件中引入 FormKit。
此文档将不会介绍 FormKit 的具体使用教程,因为我们已经很好的集成了 FormKit并且使用方式基本无异。此文章将介绍 Halo 2.0 中表单定义的一些规范,以及额外的一些输入组件。
FormKit 相关文档:
- Form Schema: <https://formkit.com/essentials/schema>
- FormKit Inputs: <https://formkit.com/inputs>
:::tip
目前不支持 FormKit Pro 中的输入组件,但 Halo 额外提供了部分输入组件,将在下面文档列出。
:::
## Setting 资源定义方式
```yaml title="settings.yaml"
apiVersion: v1alpha1
kind: Setting
metadata:
name: foo-setting
spec:
forms:
- group: group_1
label: 分组 1
formSchema:
- $formkit: radio
name: color_scheme
label: 默认配色
value: system
options:
- label: 跟随系统
value: system
- label: 深色
value: dark
- label: 浅色
value: light
- group: group_2
label: 分组 2
formSchema:
- $formkit: text
name: username
label: 用户名
value: ""
- $formkit: password
name: password
label: 密码
value: ""
```
:::tip
需要注意的是FormKit Schema 本身应该是 JSON 格式的,但目前我们定义一个表单所使用的是 YAML可能在参考 FormKit 写法时需要手动转换一下。
:::
字段说明:
1. `metadata.name`:设置资源的名称,建议以 `-setting` 结尾。
2. `spec.forms`:表单定义,可以定义多个表单,每个表单都有一个 `group` 字段,用于区分不同的表单。
3. `spec.forms[].label`:表单的标题。
4. `spec.forms[].formSchema`:表单的定义,使用 FormKit Schema 来定义。虽然我们使用的 YAML但与 FormKit Schema 完全一致。
## 组件类型
除了 FormKit 官方提供的常用输入组件之外Halo 还额外提供了一些输入组件,这些输入组件可以在 Form Schema 中使用。
### verificationForm
#### 描述
用于远程验证一组数据是否符合要求的组件。
#### 参数
- `action`:对目标数据进行验证的接口地址
- `label`:验证按钮文本
- `submitAttrs`:验证按钮的额外属性
#### 示例
```yaml
- $formkit: verificationForm
action: /apis/console.api.halo.run/v1alpha1/verify/verify-password
label: 账户校验
children:
- $formkit: text
label: "用户名"
name: username
validation: required
- $formkit: password
label: "密码"
name: password
validation: required
```
:::tip
尽管 `verificationForm` 本身是一个输入组件,但与其他输入组件不同的是,它仅仅用于包装待验证的数据,所以并不会破坏原始数据的格式。例如上述示例中的值在保存后为:
```json
{
"username": "admin",
"password": "admin"
}
```
而不是
```json
{
"verificationForm": {
"username": "admin",
"password": "admin"
}
}
```
:::
示例中发送至验证地址的值为如下格式:
```json
{
"username": "admin",
"password": "admin"
}
```
当验证接口返回成功响应时,则验证通过,否则验证失败。
若用户在验证失败时想显示错误信息,可以在验证接口返回错误信息,该错误信息的结构定义需遵循 [RFC 7807 - Problem Details for HTTP APIs](https://datatracker.ietf.org/doc/html/rfc7807.html) 。例如:
```json
{
"title": "无效凭据",
"status": 401,
"detail": "用户名或密码错误。"
}
```
UI 效果:
<img src="/img/formkit/formkit-verify-form.png" width="50%" />
### repeater
#### 描述
一组重复的输入组件,可以用于定义一组数据,最终得到的数据为一个对象的数组,可以方便地让使用者对其进行增加、移除、排序等操作。
#### 参数
- `min`:数组最小要求数量,默认为 `0`
- `max`:数组最大容量,默认为 `Infinity`,即无限制
- `addButton`:是否显示添加按钮
- `addLabel`:添加按钮的文本
- `upControl`:是否显示上移按钮
- `downControl`:是否显示下移按钮
- `insertControl`:是否显示插入按钮
- `removeControl`:是否显示移除按钮
#### 示例
```yaml
- $formkit: repeater
name: socials
label: 社交账号
value: []
max: 5
min: 1
children:
- $formkit: select
name: enabled
id: enabled
label: 是否启用
options:
- label: 是
value: true
- label: 否
value: false
- $formkit: text
# 在 Repeater 中进行条件判断的方式,当 enabled 为 true 时才显示
if: "$value.enabled === true",
name: name
label: 名称
value: ""
- $formkit: text
if: "$value.enabled === true",
name: url
label: 地址
value: ""
```
:::tip
使用 `repeater` 类型时,一定要设置默认值,如果不需要默认有任何元素,可以设置为 `[]`
:::
其中 `name``url` 即数组对象的属性,最终保存表单之后得到的值为以下形式:
```json
{
"socials": [
{
"name": "GitHub",
"url": "https://github.com/halo-dev"
}
]
}
```
UI 效果:
<img src="/img/formkit/formkit-repeater.png" width="50%" />
### attachment
#### 描述
附件类型的输入框,支持直接调用附件库弹框选择附件。
#### 参数
- `accepts`:文件类型,数据类型为 `string[]`
#### 示例
```yaml
- $formkit: attachment
name: logo
label: Logo
accepts:
- "image/png"
- "video/mp4"
- "audio/*"
value: ""
```
### code
#### 描述
代码编辑器的输入组件,集成了 [Codemirror](https://codemirror.net/)。
#### 参数
- `language`:代码语言,目前支持 `yaml` `html` `javascript` `css` `json`
- `height`:代码编辑器的高度。
#### 示例
```yaml
- $formkit: code
name: footer_code
label: 页脚代码注入
value: ""
language: yaml
```
### menuCheckbox
#### 描述
菜单复选框,用于选择系统内的导航菜单。其中选择的值为菜单资源 `metadata.name` 的集合。
#### 示例
```yaml
- $formkit: menuCheckbox
name: menus
label: 菜单
value: []
```
### menuRadio
#### 描述
菜单单选框,用于选择系统内的导航菜单。其中选择的值为菜单资源 `metadata.name`
#### 示例
```yaml
- $formkit: menuRadio
name: menu
label: 菜单
value: ""
```
### postSelect
#### 描述
文章选择器,用于选择系统内的文章。其中选择的值为文章资源 `metadata.name`
#### 示例
```yaml
- $formkit: postSelect
name: post
label: 文章
value: ""
```
### singlePageSelect
#### 描述
单页选择器,用于选择系统内的独立页面。其中选择的值为独立页面资源 `metadata.name`
#### 示例
```yaml
- $formkit: singlePageSelect
name: singlePage
label: 单页
value: ""
```
### categorySelect
#### 描述
文章分类选择器,用于选择系统内的文章分类。其中选择的值为文章分类资源 `metadata.name`
#### 示例
```yaml
- $formkit: categorySelect
name: category
label: 分类
value: ""
```
### categoryCheckbox
#### 描述
文章分类复选框,用于选择系统内的文章分类。其中选择的值为文章分类资源 `metadata.name` 的集合。
#### 示例
```yaml
- $formkit: categoryCheckbox
name: categories
label: 分类
value: []
```
### tagSelect
#### 描述
文章标签选择器,用于选择系统内的文章标签。其中选择的值为文章标签资源 `metadata.name`
#### 示例
```yaml
- $formkit: tagSelect
name: tag
label: 标签
value: ""
```
### tagCheckbox
#### 描述
文章标签复选框,用于选择系统内的文章标签。其中选择的值为文章标签资源 `metadata.name` 的集合。
#### 示例
```yaml
- $formkit: tagCheckbox
name: tags
label: 标签
value: []
```

@ -0,0 +1,202 @@
---
title: 与自定义模型交互
description: 了解如果通过代码的方式操作数据
---
Halo 提供了两个类用于与自定义模型对象交互 `ExtensionClient``ReactiveExtensionClient`
它们提供了对自定义模型对象的增删改查操作,`ExtensionClient` 是阻塞式的用于后台任务如控制器中操作数据,而 `ReactiveExtensionClient` 返回值都是 Mono 或 Flux 是反应式非阻塞的,它们由 [reactor](https://projectreactor.io/) 提供。
```java
public interface ReactiveExtensionClient {
// 已经过时,建议使用 listBy 或 listAll 代替
<E extends Extension> Flux<E> list(Class<E> type, Predicate<E> predicate,
Comparator<E> comparator);
// 已经过时,建议使用 listBy 或 listAll 代替
<E extends Extension> Mono<ListResult<E>> list(Class<E> type, Predicate<E> predicate,
Comparator<E> comparator, int page, int size);
<E extends Extension> Flux<E> listAll(Class<E> type, ListOptions options, Sort sort);
<E extends Extension> Mono<ListResult<E>> listBy(Class<E> type, ListOptions options,
PageRequest pageable);
/**
* Fetches Extension by its type and name.
*
* @param type is Extension type.
* @param name is Extension name.
* @param <E> is Extension type.
* @return an optional Extension.
*/
<E extends Extension> Mono<E> fetch(Class<E> type, String name);
Mono<Unstructured> fetch(GroupVersionKind gvk, String name);
<E extends Extension> Mono<E> get(Class<E> type, String name);
/**
* Creates an Extension.
*
* @param extension is fresh Extension to be created. Please make sure the Extension name does
* not exist.
* @param <E> is Extension type.
*/
<E extends Extension> Mono<E> create(E extension);
/**
* Updates an Extension.
*
* @param extension is an Extension to be updated. Please make sure the resource version is
* latest.
* @param <E> is Extension type.
*/
<E extends Extension> Mono<E> update(E extension);
/**
* Deletes an Extension.
*
* @param extension is an Extension to be deleted. Please make sure the resource version is
* latest.
* @param <E> is Extension type.
*/
<E extends Extension> Mono<E> delete(E extension);
}
```
### 示例
如果你想在插件中根据 name 参数查询获取到 Person 自定义模型的数据,则可以这样写:
```java
@Service
@RequiredArgsConstructor
public PersonService {
private final ReactiveExtensionClient client;
Mono<Person> getPerson(String name) {
return client.fetch(Person.class, name);
}
}
```
或者使用阻塞式 Client
```java
@Service
@RequiredArgsConstructor
public PersonService {
private final ExtensionClient client;
Optional<Person> getPerson(String name) {
return client.fetch(Person.class, name);
}
}
```
注意:非阻塞线程中不能调用阻塞式方法。
我们建议你更多的使用响应式的 `ReactiveExtensionClient` 去替代 `ExtensionClient`
### 查询
`ReactiveExtensionClient` 提供了两个方法用于查询数据,`listBy` 和 `listAll`
`listBy` 方法用于分页查询数据,`listAll` 方法用于查询所有数据,它们都需要一个 `ListOptions` 参数,用于传递查询条件:
```java
public class ListOptions {
private LabelSelector labelSelector;
private FieldSelector fieldSelector;
}
```
其中 `LabelSelector` 用于传递标签查询条件,`FieldSelector` 用于传递字段查询条件。
`FieldSelector` 支持比自动生成的 APIs 中更多的查询条件,可以通过 `run.halo.app.extension.index.query.QueryFactory` 来构建。
```java
FieldSelector.of(QueryFactory.and(
QueryFactory.equal("name", "test"),
QueryFactory.equal("age", 18)
))
```
支持的查询条件如下:
| 方法 | 说明 | 示例 |
| ---------------------------- | ---------------- | ----------------------------------------------------------------------------- |
| equal | 等于 | equal("name", "test"), name 是字段名test 是字段值 |
| equalOtherField | 等于其他字段 | equalOtherField("name", "otherName"), name 是字段名otherName 是另一个字段名 |
| notEqual | 不等于 | notEqual("name", "test") |
| notEqualOtherField | 不等于其他字段 | notEqualOtherField("name", "otherName") |
| greaterThan | 大于 | greaterThan("age", 18) |
| greaterThanOtherField | 大于其他字段 | greaterThanOtherField("age", "otherAge") |
| greaterThanOrEqual | 大于等于 | greaterThanOrEqual("age", 18) |
| greaterThanOrEqualOtherField | 大于等于其他字段 | greaterThanOrEqualOtherField("age", "otherAge") |
| lessThan | 小于 | lessThan("age", 18) |
| lessThanOtherField | 小于其他字段 | lessThanOtherField("age", "otherAge") |
| lessThanOrEqual | 小于等于 | lessThanOrEqual("age", 18) |
| lessThanOrEqualOtherField | 小于等于其他字段 | lessThanOrEqualOtherField("age", "otherAge") |
| in | 在范围内 | in("age", 18, 19, 20) |
| and | 且 | and(equal("name", "test"), equal("age", 18)) |
| or | 或 | or(equal("name", "test"), equal("age", 18)) |
| between | 在范围内 | between("age", 18, 20), 包含 18 和 20 |
| betweenExclusive | 在范围内 | betweenExclusive("age", 18, 20), 不包含 18 和 20 |
| betweenLowerExclusive | 在范围内 | betweenLowerExclusive("age", 18, 20), 不包含 18包含 20 |
| betweenUpperExclusive | 在范围内 | betweenUpperExclusive("age", 18, 20), 包含 18不包含 20 |
| startsWith | 以指定字符串开头 | startsWith("name", "test") |
| endsWith | 以指定字符串结尾 | endsWith("name", "test") |
| contains | 包含指定字符串 | contains("name", "test") |
| all | 指定字段的所有值 | all("age") |
`FieldSelector` 中使用的所有字段都必须添加为索引,否则会抛出异常表示不支持该字段。关于如何使用索引请参考 [自定义模型使用索引](./extension.md#using-indexes)。
可以通过 `and``or` 方法组合和嵌套查询条件:
```java
import static run.halo.app.extension.index.query.QueryFactory.and;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import static run.halo.app.extension.index.query.QueryFactory.greaterThan;
import static run.halo.app.extension.index.query.QueryFactory.or;
Query query = and(
or(equal("dept", "A"), equal("dept", "B")),
or(equal("age", "19"), equal("age", "18"))
);
FieldSelector.of(query);
```
### 排序
`listBy``listAll` 方法都支持传递 `Sort` 参数,用于传递排序条件。
```java
import org.springframework.data.domain.Sort;
Sort.by(Sort.Order.asc("metadata.name"))
```
通过 `Sort.by` 方法可以构建排序条件,`Sort.Order` 用于指定排序字段和排序方式,`asc` 表示升序,`desc` 表示降序。
排序中使用的字段必须是添加为索引的字段,否则会抛出异常表示不支持该字段。关于如何使用索引请参考 [自定义模型使用索引](./extension.md#using-indexes)。
### 分页
`listBy` 方法支持传递 `PageRequest` 参数,用于传递分页条件。
```java
import run.halo.app.extension.PageRequestImpl;
PageRequestImpl.of(1, 10);
PageRequestImpl.of(1, 10, Sort.by(Sort.Order.asc("metadata.name"));
PageRequestImpl.ofSize(10);
```
通过 `PageRequestImpl.of` 方法可以构建分页条件,具有两个参数的方法用于指定页码和每页数量,具有三个参数的方法用于指定页码、每页数量和排序条件。
`ofSize` 方法用于指定每页数量,页码默认为 1。

@ -0,0 +1,76 @@
---
title: Web 过滤器
description: 为 Web 请求提供过滤器扩展点,可用于对请求进行拦截、修改等操作。
---
在现代的 Web 应用开发中过滤器Filter是一个非常重要的概念。你可以使用 `run.halo.app.security.AdditionalWebFilter` 在服务器处理请求之前或之后执行特定的任务。
通过实现这个接口,开发者可以自定义过滤逻辑,用于处理进入和离开应用程序的 HTTP 请求和响应。
AdditionalWebFilter 能做什么?
1. 认证与授权: AdditionalWebFilter 可以用来检查用户是否登录,或者是否有权限访问某个资源。
2. 日志记录与审计: 在请求处理之前或之后记录日志,帮助了解应用程序的使用情况。
3. 请求重构: 修改请求数据,例如添加、删除或修改请求头或请求参数。
4. 响应处理: 修改响应,例如设置通用的响应头。
5. 性能监控: 记录处理请求所需的时间,用于性能分析。
6. 异常处理: 统一处理请求过程中抛出的异常。
7. ......
## 使用示例
以下是一个使用 `AdditionalWebFilter` 来拦截 `/login` 请求实现用户名密码登录的示例:
```java
@Component
public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
final ServerWebExchangeMatcher requiresMatcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/login");
@Override
@NonNull
public Mono<Void> filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) {
return this.requiresAuthenticationMatcher.matches(exchange)
.filter((matchResult) -> {
return matchResult.isMatch();
}).flatMap((matchResult) -> {
return this.authenticationConverter.convert(exchange);
}).switchIfEmpty(chain.filter(exchange)
.then(Mono.empty()))
.flatMap((token) -> {
return this.authenticate(exchange, chain, token);
}).onErrorResume(AuthenticationException.class, (ex) -> {
return this.authenticationFailureHandler.onAuthenticationFailure(new WebFilterExchange(exchange, chain), ex);
});
}
@Override
public int getOrder() {
return SecurityWebFiltersOrder.FORM_LOGIN.getOrder();
}
}
```
1. `filter` 方法中的 `chain.filter(exchange)` 表示继续执行后续的过滤器,如果不调用这个方法,请求将不会继续执行后续的过滤器或目标处理程序。
2. `getOrder` 方法用于指定过滤器的执行顺序,比如 `SecurityWebFiltersOrder.FORM_LOGIN.getOrder()` 表示在 Spring Security 的表单登录过滤器之前执行,参考:[SecurityWebFiltersOrder](https://github.com/spring-projects/spring-security/blob/main/config/src/main/java/org/springframework/security/config/web/server/SecurityWebFiltersOrder.java)。
`AdditionalWebFilter` 对应的 `ExtensionPointDefinition` 如下:
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionPointDefinition
metadata:
name: additional-webfilter
spec:
className: run.halo.app.security.AdditionalWebFilter
displayName: AdditionalWebFilter
type: MULTI_INSTANCE
description: "Contract for interception-style, chained processing of Web requests that may be used to
implement cross-cutting, application-agnostic requirements such as security, timeouts, and others."
```
即声明 `ExtensionDefinition` 自定义模型对象时对应的 `extensionPointName``additional-webfilter`
以下是一些可以参考的项目示例:
- [OAuth2 第三方登录插件](https://github.com/halo-sigs/plugin-oauth2)
- [Halo 用户名密码登陆](https://github.com/halo-dev/halo/blob/main/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordAuthenticator.java)

@ -0,0 +1,55 @@
---
title: 附件存储
description: 为附件存储方式提供的扩展点,可用于自定义附件存储方式。
---
附件存储策略扩展点支持扩展附件的上传和存储方式,如将附件存储到第三方云存储服务中。
扩展点接口如下:
```java
public interface AttachmentHandler extends ExtensionPoint {
Mono<Attachment> upload(UploadContext context);
Mono<Attachment> delete(DeleteContext context);
default Mono<URI> getSharedURL(Attachment attachment,
Policy policy,
ConfigMap configMap,
Duration ttl) {
return Mono.empty();
}
default Mono<URI> getPermalink(Attachment attachment,
Policy policy,
ConfigMap configMap) {
return Mono.empty();
}
```
- `upload` 方法用于上传附件,返回值为 `Mono<Attachment>`,其中 `Attachment` 为上传成功后的附件对象。
- `delete` 方法用于删除附件,返回值为 `Mono<Attachment>`,其中 `Attachment` 为删除后的附件对象。
- `getSharedURL` 方法用于获取附件的共享链接,返回值为 `Mono<URI>`,其中 `URI` 为附件的共享链接。
- `getPermalink` 方法用于获取附件的永久链接,返回值为 `Mono<URI>`,其中 `URI` 为附件的永久链接。
`AttachmentHandler` 对应的 `ExtensionPointDefinition` 如下:
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionPointDefinition
metadata:
name: attachment-handler
spec:
className: run.halo.app.core.extension.attachment.endpoint.AttachmentHandler
displayName: AttachmentHandler
type: MULTI_INSTANCE
description: "Provide extension points for attachment storage strategies"
```
即声明 `ExtensionDefinition` 自定义模型对象时对应的 `extensionPointName``attachment-handler`
可以参考以下项目示例:
- [S3 对象存储协议的存储插件](https://github.com/halo-dev/plugin-s3)
- [阿里云 OSS 的存储策略插件](https://github.com/halo-sigs/plugin-alioss)
- [又拍云 OSS 的存储策略](https://github.com/AirboZH/plugin-uposs)

@ -0,0 +1,81 @@
---
title: 认证安全过滤器
description: 提供 Security WebFilter 扩展点插件可实现自定义认证逻辑例如用户名密码认证JWT 认证,匿名认证。
---
此前Halo 提供了 AdditionalWebFilter 作为扩展点供插件扩展认证相关的功能。但是近期我们明确了 AdditionalWebFilter
的使用用途,故不再作为认证的扩展点。
目前Halo 提供了三种认证扩展点:表单登录认证、普通认证和匿名认证。
## 表单登录FormLogin
示例如下:
```java
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import run.halo.app.security.FormLoginSecurityWebFilter;
@Component
public class MyFormLoginSecurityWebFilter implements FormLoginSecurityWebFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
// Do your logic here
return chain.filter(exchange);
}
}
```
## 普通认证Authentication
示例如下:
```java
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import run.halo.app.security.AuthenticationSecurityWebFilter;
@Component
public class MyAuthenticationSecurityWebFilter implements AuthenticationSecurityWebFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
// Do your logic here
return chain.filter(exchange);
}
}
```
## 匿名认证Anonymous Authentication
示例如下:
```java
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import run.halo.app.security.AnonymousAuthenticationSecurityWebFilter;
@Component
public class MyAnonymousAuthenticationSecurityWebFilter
implements AnonymousAuthenticationSecurityWebFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
// Do your logic here
return chain.filter(exchange);
}
}
```
我们在实现扩展点的时候需要注意:如果当前请求不满足认证条件,请一定要调用 `chain.filter(exchange)`,给其他 filter 留下机会。
后续会根据需求实现其他认证相关的扩展点。

@ -0,0 +1,83 @@
---
title: 评论主体展示
description: 用于在管理端评论列表中展示评论的主体内容。
---
评论主体扩展点用于在管理端评论列表中展示评论的主体内容,评论自定义模型是 Halo 主应用提供的,如果你需要使用评论自定义模型,那么评论会统一
展示在管理后台的评论列表中,这时就需要通过评论主体扩展点来展示评论的主体内容便于跳转到对应的页面,如果没有实现该扩展点,那么评论列表中对应的评论的主体会显示为“未知”。
```java
public interface CommentSubject<T extends Extension> extends ExtensionPoint {
Mono<T> get(String name);
default Mono<SubjectDisplay> getSubjectDisplay(String name) {
return Mono.empty();
}
boolean supports(Ref ref);
record SubjectDisplay(String title, String url, String kindName) {
}
}
```
- `get` 方法用于获取评论主体,参数 `name` 是评论主体的自定义模型对象的名称,返回值为 `Mono<T>`,其中 `T` 为评论主体对象,它是使用评论的那个自定义模型。
- `getSubjectDisplay` 方法用于获取评论主体的展示信息,返回值为 `Mono<SubjectDisplay>`,其中 `SubjectDisplay` 为评论主体的展示信息,包含标题、链接和类型名称,用于在主题端展示评论主体的信息。
- `supports` 方法用于判断是否支持该评论主体,返回值为 `boolean`,如果支持则返回 `true`,否则返回 `false`
实现该扩展点后,评论列表会通过 `get` 方法将主体的自定义模型对象带到评论列表中,可以配置前端的扩展点来决定如何展示评论主体的信息,参考:[UI 评论来源显示](../../ui/extension-points//comment-subject-ref-create.md)
例如对于文章是支持评论的,所以文章的评论主体扩展点实现如下:
```java
public class PostCommentSubject implements CommentSubject<Post> {
private final ReactiveExtensionClient client;
private final ExternalLinkProcessor externalLinkProcessor;
@Override
public Mono<Post> get(String name) {
return client.fetch(Post.class, name);
}
@Override
public Mono<SubjectDisplay> getSubjectDisplay(String name) {
return get(name)
.map(post -> {
var url = externalLinkProcessor
.processLink(post.getStatusOrDefault().getPermalink());
return new SubjectDisplay(post.getSpec().getTitle(), url, "文章");
});
}
@Override
public boolean supports(Ref ref) {
Assert.notNull(ref, "Subject ref must not be null.");
GroupVersionKind groupVersionKind =
new GroupVersionKind(ref.getGroup(), ref.getVersion(), ref.getKind());
return GroupVersionKind.fromExtension(Post.class).equals(groupVersionKind);
}
}
```
`CommentSubject` 对应的 `ExtensionPointDefinition` 如下:
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionPointDefinition
metadata:
name: comment-subject
spec:
className: run.halo.app.content.comment.CommentSubject
displayName: CommentSubject
type: MULTI_INSTANCE
description: "Provide extension points for comment subject display"
```
即声明 `ExtensionDefinition` 自定义模型对象时对应的 `extensionPointName``comment-subject`
可以参考其他使用该扩展点的项目示例:
- [Halo 自定义页面评论主体](https://github.com/halo-dev/halo/blob/main/application/src/main/java/run/halo/app/content/comment/SinglePageCommentSubject.java)
- [瞬间的评论主体](https://github.com/halo-sigs/plugin-moments/blob/096b1b3e4a2ca44b6f858ba1181b62eeff64a139/src/main/java/run/halo/moments/MomentCommentSubject.java#L25)

@ -0,0 +1,41 @@
---
title: 评论组件
description: 用于自定义评论组件,可在主题端使用其他评论组件。
---
评论组件扩展点用于自定义主题端使用的评论组件Halo 通过插件提供了一个默认的评论组件,如果你需要使用其他的评论组件,那么可以通过实现该扩展点来自定义评论组件。
```java
public interface CommentWidget extends ExtensionPoint {
String ENABLE_COMMENT_ATTRIBUTE = CommentWidget.class.getName() + ".ENABLE";
void render(ITemplateContext context, IProcessableElementTag tag,
IElementTagStructureHandler structureHandler);
}
```
其中 `render` 方法用于在主题端模板中渲染评论组件,参数:
- `context` 为模板上下文,包含执行模板的上下文:变量、模板数据等。
- 参数 `tag``<halo:comment />` 标签它包含元素的名称及其属性
- `structureHandler` 是一个特殊的对象,它允许 `CommentWidget` 向引擎发出指令,指示模板引擎应根据处理器的执行而执行哪些操作。
`CommentWidget` 对应的 `ExtensionPointDefinition` 如下:
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionPointDefinition
metadata:
name: comment-widget
spec:
className: run.halo.app.theme.dialect.CommentWidget
displayName: CommentWidget
type: SINGLETON
description: "Provides an extension point for the comment widget on the theme-side."
```
即声明 `ExtensionDefinition` 自定义模型对象时对应的 `extensionPointName``comment-widget`
参考:[Thymeleaf IElementTagProcessor 文档](https://www.thymeleaf.org/doc/tutorials/3.1/extendingthymeleaf.html#element-tag-processors-ielementtagprocessor)。
参考:[Halo 默认评论组件的实现](https://github.com/halo-dev/plugin-comment-widget/blob/main/src/main/java/run/halo/comment/widget/DefaultCommentWidget.java)。

@ -0,0 +1,65 @@
---
title: 扩展点
description: Halo 服务端为插件提供的扩展点接口
---
术语:
- 扩展点
- 由 Halo 定义的用于添加特定功能的接口。
- 扩展点应该在服务的核心功能和它所认为的集成之间的交叉点上。
- 扩展点是对服务的扩充,但不是影响服务的核心功能:区别在于,如果没有其核心功能,服务就无法运行,而扩展点对于特定的配置可能至关重要该服务最终是可选的。
- 扩展点应该小且可组合,并且在相互配合使用时,可为 Halo 提供比其各部分总和更大的价值。
- 扩展
- 扩展点的一种具体实现。
使用 Halo 扩展点的必要步骤是:
1. 实现扩展点接口,然后标记上 `@Component` 注解。
2. 对该扩展点的实现类进行 `ExtensionDefinition` 自定义模型对象的声明。
例如: 实现一个通知器的扩展,首先 `implements` ReactiveNotifier 扩展点接口:
```java
@Component
public class EmailNotifier implements ReactiveNotifier {
@Override
public Mono<Void> notify(NotificationContext context) {
// Send notification
}
}
```
然后声明一个 `ExtensionDefinition` 自定义模型对象:
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionDefinition
metadata:
name: halo-email-notifier # 指定一个扩展定义的名称
spec:
# 扩展的全限定类名
className: run.halo.app.notification.EmailNotifier
# 所实现的扩展点定义的自定义模型对象名称
extensionPointName: reactive-notifier
# 扩展名称用于展示给用户
displayName: "EmailNotifier"
# 扩展的简要描述,用于让用户了解该扩展的作用
description: "Support sending notifications to users via email"
```
:::tip Note
单实例或多实例的扩展点需要声明对应的 `ExtensionPointDefinition` 自定义模型对象被称之为扩展点定义,用于描述该扩展点的信息,例如:扩展点的名称、描述、扩展点的类型等。
单实例或多实例扩展点的实现也必须声明一个对应的 `ExtensionDefinition` 自定义模型对象被称之为扩展定义,用于描述该扩展的信息,例如:扩展的名称、描述、对应扩展点的对象名称等。
:::
关于如何在插件中声明自定义模型对象请参考:[自定义模型](../../server/extension.md#declare-extension-object)
以下是目前已支持的扩展点列表:
```mdx-code-block
import DocCardList from '@theme/DocCardList';
<DocCardList />
```

@ -0,0 +1,60 @@
---
title: 通知器
description: 为以何种方式向用户发送通知提供的扩展点。
---
通知器扩展点是用于扩展为 Halo 通知系统提供更多通知方式的扩展点例如邮件、短信、WebHook 等。
```java
public interface ReactiveNotifier extends ExtensionPoint {
Mono<Void> notify(NotificationContext context);
}
```
`notify` 方法用于发送通知参数context 为通知上下文,包含通知的内容、接收者、通知配置等信息。
除了实现该扩展点并声明 `ExtensionDefinition` 自定义模型对象外,还需要声明一个 `NotifierDescriptor` 自定义模型对象用于描述通知器,例如:
```yaml
apiVersion: notification.halo.run/v1alpha1
kind: NotifierDescriptor
metadata:
name: default-email-notifier
spec:
displayName: '邮件通知'
description: '通过邮件将通知发送给用户'
notifierExtName: 'halo-email-notifier'
senderSettingRef:
name: 'notifier-setting-for-email'
group: 'sender'
#receiverSettingRef:
# name: ''
# group: ''
```
- `notifierExtName` 为通知器扩展的自定义模型对象名称
- `senderSettingRef` 用于声明通知器的发送者配置例如邮件通知器的发送者配置为SMTP 服务器地址、端口、用户名、密码等,如果没有可以不配置,参考:[表单定义](../../../../form-schema.md)
- `name` 为发送者配置的名称,它是一个 `Setting` 自定义模型对象的名称。
- `group` 用于引用到一个具体的配置 Schema 组,它是一个 `Setting` 自定义模型对象中描述的 `formSchema``group`,由于 `Setting` 可以声明多个配置分组但通知器的发送者配置只能有在一个组,因此需要指定一个组。
- `receiverSettingRef` 用于声明通知器的接收者配置,例如:邮件通知器的接收者配置为:接收者邮箱地址,如果没有可以不配置,`name` 和 `group` 配置同 `senderSettingRef`
当配置了 `senderSettingRef` 后,触发通知时 `notify` 方法的 `context` 参数中会包含 `senderConfig` 即为发送者配置的值,`receiverConfig` 同理。
`ReactiveNotifier` 对应的 `ExtensionPointDefinition` 如下:
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionPointDefinition
metadata:
name: reactive-notifier
spec:
className: run.halo.app.notification.ReactiveNotifier
displayName: Notifier
type: MULTI_INSTANCE
description: "Provides a way to extend the notifier to send notifications to users."
```
即声明 `ExtensionDefinition` 自定义模型对象时对应的 `extensionPointName``reactive-notifier`
使用案例可以参考:[Halo 邮件通知器](https://github.com/halo-dev/halo/blob/main/application/src/main/java/run/halo/app/notification/EmailNotifier.java)

@ -0,0 +1,43 @@
---
title: 主题端文章内容处理
description: 提供扩展主题端文章内容处理的方法,干预文章内容的渲染。
---
主题端文章内容处理扩展点用于干预文章内容的渲染,例如:在文章内容中添加广告、添加版权信息等。
```java
public interface ReactivePostContentHandler extends ExtensionPoint {
Mono<PostContentContext> handle(@NonNull PostContentContext postContent);
@Data
@Builder
class PostContentContext {
private Post post;
private String content;
private String raw;
private String rawType;
}
}
```
`handle` 方法用于处理文章内容,参数 `postContent` 为文章内容上下文,包含文章自定义模型对象、文章 html 内容、原始内容、原始内容类型等信息。
`ReactivePostContentHandler` 对应的 `ExtensionPointDefinition` 如下:
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionPointDefinition
metadata:
name: reactive-post-content-handler
spec:
className: run.halo.app.theme.ReactivePostContentHandler
displayName: ReactivePostContentHandler
type: MULTI_INSTANCE
description: "Provides a way to extend the post content to be displayed on the theme-side."
```
即声明 `ExtensionDefinition` 自定义模型对象时对应的 `extensionPointName``reactive-post-content-handler`
使用案例可以参考:[WebP Cloud 插件](https://github.com/webp-sh/halo-plugin-webp-cloud/blob/a6069dfa78931de0d5b5dfe98fdd18a0da75b09f/src/main/java/se/webp/plugin/WebpCloudPostContentHandler.java#L17)
它的作用是处理主题端文章内容中的所有图片的地址,将其替换为一个 WebP Cloud 的代理地址,从而实现文章内容中的图片都使用 WebP 格式。

@ -0,0 +1,38 @@
---
title: 主题端自定义页面内容处理
description: 提供扩展主题端自定义页面内容处理的方法,干预自定义页面内容的渲染。
---
主题端自定义页面内容处理扩展点,作用同 [主题端文章内容处理](./post-content.md) 扩展点,只是作用于自定义页面。
```java
public interface ReactiveSinglePageContentHandler extends ExtensionPoint {
Mono<SinglePageContentContext> handle(@NonNull SinglePageContentContext singlePageContent);
@Data
@Builder
class SinglePageContentContext {
private SinglePage singlePage;
private String content;
private String raw;
private String rawType;
}
}
```
`ReactiveSinglePageContentHandler` 对应的 `ExtensionPointDefinition` 如下:
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionPointDefinition
metadata:
name: reactive-singlepage-content-handler
spec:
className: run.halo.app.theme.ReactiveSinglePageContentHandler
displayName: ReactiveSinglePageContentHandler
type: MULTI_INSTANCE
description: "Provides a way to extend the single page content to be displayed on the theme-side."
```
即声明 `ExtensionDefinition` 自定义模型对象时对应的 `extensionPointName``reactive-singlepage-content-handler`

@ -0,0 +1,30 @@
---
title: 用户名密码认证管理器
description: 提供扩展用户名密码的身份验证的方法
---
用户名密码认证管理器扩展点用于替换 Halo 默认的用户名密码认证管理器实现,例如:使用第三方的身份验证服务,一个例子是 LDAP。
```java
public interface UsernamePasswordAuthenticationManager extends ExtensionPoint {
Mono<Authentication> authenticate(Authentication authentication);
}
```
`UsernamePasswordAuthenticationManager` 对应的 `ExtensionPointDefinition` 如下:
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionPointDefinition
metadata:
name: username-password-authentication-manager
spec:
className: run.halo.app.security.authentication.login.UsernamePasswordAuthenticationManager
displayName: Username password authentication manager
type: SINGLETON
description: "Provides a way to extend the username password authentication."
```
即声明 `ExtensionDefinition` 自定义模型对象时对应的 `extensionPointName``username-password-authentication-manager`
可以参考的实现示例 [TOTP 认证](https://github.com/halo-dev/halo/blob/86e688a15d05c084021b6ba5e75d56a8813c3813/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationFilter.java#L84)

@ -0,0 +1,305 @@
---
title: 自定义模型
description: 了解什么是自定义模型及如何创建
---
Halo 自定义模型主要参考自 [Kubernetes CRD](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/) 。自定义模型遵循 [OpenAPI v3](https://spec.openapis.org/oas/v3.1.0)。设计目的在于提供一种灵活可扩展的数据存储和使用方式,便于为插件提供自定义数据支持。
比如某插件需要存储自定义数据,同时也想读取和操作自定义数据。更多细节请参考 [自定义模型设计](https://github.com/halo-dev/rfcs/tree/main/extension)。
一个典型的自定义模型 `Java` 代码示例如下:
```java
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
import run.halo.app.extension.GroupKind;
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@GVK(group = "my-plugin.halo.run",
version = "v1alpha1",
kind = "Person",
plural = "persons",
singular = "person")
public class Person extends AbstractExtension {
@Schema(requireMode = Schema.RequireMode.REQUIRED)
private Spec spec;
@Schema(name="PersonSpec")
public static class Spec {
@Schema(description = "The description on name field", maxLength = 100)
private String name;
@Schema(description = "The description on age field", maximum = "150", minimum = "0")
private Integer age;
@Schema(description = "The description on gender field")
private Gender gender;
private Person otherPerson;
}
public enum Gender {
MALE, FEMALE,
}
}
```
要创建一个自定义模型需要三步:
1. 创建一个类继承 `run.halo.app.extension.AbstractExtension`
2. 使用 `GVK` 注解。
3. 在插件 `start()` 生命周期方法中注册自定义模型:
```java
@Autowired
private SchemeManager schemeManager;
@Override
public void start() {
schemeManager.register(Person.class);
}
```
:::tip 释义
- @GVK:此注解标识该类为一个自定义模型,同时必须继承 `AbstractExtension`
- kind表示自定义模型所表示的 REST 资源。
- group表示一组公开的资源通常采用域名形式Halo 项目保留使用空组和任何以 `*.halo.run` 结尾的组名供其单独使用。
选择群组名称时,我们建议选择你的群组或组织拥有的子域,例如 `widget.mycompany.com`,而这里提到的公开并不是指你的自定义资源可以被任何人访问,
而是指你的自定义模型对象可以被以 APIs 的形式访问。
- versionAPI 的版本,它与 group 组合使用为 `apiVersion=GROUP/VERSION`,例如`api.halo.run/v1alpha1`。
- singular: 资源的单数名称,这允许客户端不透明地处理复数和单数,必须全部小写,通常是将 `kind` 的值转换为小写作为 `singular` 的值。
- plural 资源的复数名称,自定义资源在 `/apis/<group>/<version>/.../<plural>` 下提供,必须为全部小写,通常是将 `kind` 的值转换为小写并转为复数形式作为 `plural` 的值。
- @Schema:属性校验注解,会在创建/修改资源前对资源进行简单校验,参考 [schema-validator](https://www.openapi4j.org/schema-validator.html)。
:::
一个自定义模型通常包括以下几个部分:
- `apiVersion`: 用于标识自定义模型的 API 版本,它由 `GVK` 注解的 `group``version` 组合而成。
- `kind`: 用于标识自定义模型的类型,它由 `GVK` 注解的 `kind` 声明。
- `metadata`: 用于标识自定义模型的元数据:
- `name`: 用于标识自定义模型的名称。
- `creationTimestamp`: 用于标识自定义模型的创建时间,无法修改,只在创建时自动生成。
- `version`: 用于标识自定义模型的数据乐观锁版本,无法修改,由更新时自动填充,如果更新时指定了 `version` 且与当前 `version` 不一致则会更新失败。
- `deletionTimestamp`: 用于标识自定义模型的删除时间,表示此自定义模型对象被声明为删除,此时仍然可以通过 API 访问到此对象,参考 [自定义模型对象生命周期](../../basics/framework.md#extension-lifecycle)
- `finalizers`: 用于标识终结器,它是一个字符串集合,用于标识自定义模型对象是否可回收,参考 [自定义模型对象生命周期](../../basics/framework.md#extension-lifecycle)
- `labels`: 用于标识自定义模型的标签,它是一个字符串键值对集合,用于标识自定义模型对象的标签,可以通过标签来查询自定义模型对象。
- `annotations`: 用于存放扩展信息,它是一个字符串键值对集合,用于存放自定义模型对象的扩展信息。
- `spec`: 用于声明自定义模型对象的期望状态,它是声明式的,用户只需要声明期望状态,实际状态由具体的控制器来维护,最终达到用户期望的状态。
- `status`: 用于描述自定义模型对象资源状态的变化,和一些实际状态。
其中 `apiVersion`、`kind`、`metadata`都包含在了 AbstractExtension 类中,所以我们只需要关注 `spec``status` 即可,参考:[Halo 架构概览之自定义模型](../../basics/framework.md#extension)
## 声明自定义模型对象 {#declare-extension-object}
有了自定义模型后可以通过在插件项目的 `src/main/resources/extensions` 目录下声明 `yaml` 文件来创建一个自定义模型对象,
此目录下的所有自定义模型 `yaml` 都会在插件启动后被创建:
```yaml
apiVersion: my-plugin.halo.run/v1alpha1
kind: Person
metadata:
name: fake-person
spec:
name: halo
age: 18
gender: male
```
在该目录下声明自定义模型对象所使用的 `yaml` 文件的文件名是任意的,只根据 `kind``apiVersion` 来确定自定义模型对象的类型。
## 命名规范 {#naming-conventions}
### metadata name {#metadata-name}
`metadata.name` 它是自定义模型对象的唯一标识名,包含不超过 253 个字符,仅包含小写字母、数字或`-`,以字母或数字开头,以字母或数字结尾。
### labels
`labels` 它是一个字符串键值对集合, Key 的基本结构为 `<prefix>/<name>`,完整的 label 键通常包括一个可选的前缀和名称,二者通过斜杠(/)分隔。
- 前缀可选通常是域名的反向表示形式用于避免键名冲突。例如halo.run/post-slug
- 名称:标识 label 的具体含义,如 post-slug。
前缀规则:
- 如果 label 用于特定于一个组织的资源,建议使用一个前缀,如 `plugin.halo.run/plugin-name`
- 前缀必须是一个有效的 DNS 子域名(参考 metadata.name且最多可包含 253 个字符。
- 保留了不带前缀的 label 键以及特定前缀(如 halo.run因此插件不可使用。
名称规则:
- 名称必须是合法的 DNS 标签,最多可包含 63 个字符。
- 必须以字母数字字符开头和结尾。
- 可以包含 `-`、`.`、`_` 和`字母数字`字符。
通用规范:
- 避免使用容易引起混淆或误解的键名。
- 尽量保持简洁明了,易于理解。
- 使用易于记忆和识别的单词或缩写。
一致性和清晰性:
- 在整个项目或组织中保持一致的命名约定。
- labels 应直观地反映其代表的信息或用途。
- 不要在 labels 中包含敏感信息,例如用户凭据或个人识别信息。
## 使用索引 {#using-indexes}
自定义模型虽然带来了很大的灵活性可扩展性,但也引入了查询问题,自定义模型对象存储在数据库中是 `byte[]` 的形式存在的,从而实现不依赖于数据库特性,你可以使用 `MySQL``PostgreSQL``H2` 等数据库来来作为存储介质但查询自定义模型对象时无法使用数据库的索引特性这就导致了查询自定义模型对象的效率问题Halo 自己实现了一套索引机制来解决这个问题。
索引是一种存储数据结构,可提供对数据集中字段的高效查找。索引将自定义模型中的字段映射到数据库行,以便在查询特定字段时不需要完整的扫描。查询数据之前,必须对需要查询的字段创建索引。索引可以包含一个或多个字段的值。索引可以包含唯一值或重复值。索引中的值按照索引中的顺序进行排序。
索引可以提高查询性能,但会占用额外的存储空间,因为它们需要存储索引字段的副本。索引的大小取决于字段的数据类型和索引的类型,因此,创建索引时应该考虑存储成本和性能收益。
你可以通过以下方式在注册自定义模型时声明索引:
```java
@Override
public void start() {
schemeManager.register(Moment.class, indexSpecs -> {
indexSpecs.add(new IndexSpec()
.setName("spec.tags")
.setIndexFunc(multiValueAttribute(Moment.class, moment -> {
var tags = moment.getSpec().getTags();
return tags == null ? Set.of() : tags;
}))
);
// more index spec
}
```
`IndexSpec` 用于声明索引项,它包含以下属性:
- name索引名称在同一个自定义模型的索引中必须唯一一般建议使用字段路径作为索引名称例如 `spec.slug`
- order对索引值的排序方式支持 `ASC``DESC`,默认为 `ASC`
- unique是否唯一索引如果为 `true` 则索引值必须唯一,如果创建自定义模型对象时检测到此索引字段有重复值则会创建失败。
- indexFunc索引函数用于获取索引值接收当前自定义模型对象返回一个索引值索引值必须是字符串任意类型如果不是字符串类型则需要自己转为字符串可以使用 `IndexAttributeFactory` 提供的静态方法来创建 `indexFunc`
- `simpleAttribute()`:用于得到一个返回单个值的索引函数,例如 `moment -> moment.getSpec().getSlug()`
- `multiValueAttribute()`:用于得到一个返回多个值的索引函数,例如 `moment -> moment.getSpec().getTags()`
当注册自定义模型时声明了索引后Halo 会在插件启动时构建索引,在构建索引期间插件出于未启动状态。
Halo 默认会为每个自定义模型建立以下几个索引,因此不需要为下列字段再次声明索引:
- `metadata.name` 创建唯一索引
- `metadata.labels`
- `metadata.creationTimestamp`
- `metadata.deletionTimestamp`
创建了索引的字段可以在查询时使用 `fieldSelector` 参数来查询,参考 [自定义模型 APIs](#extension-apis)。
## 自定义模型 APIs {#extension-apis}
定义好自定义模型并注册后,会根据 `GVK` 注解自动生成一组 `CRUD` APIs规则为
`/apis/<group>/<version>/<extension>/{extensionname}/<subextension>`
对于上述 Person 自定义模型将有以下 APIs
```shell
# 用于列出所有 Person 自定义模型对象
GET /apis/my-plugin.halo.run/v1alpha1/persons
# 用于查询指定名称更新自定义模型对象
PUT /apis/my-plugin.halo.run/v1alpha1/persons/{name}
# 用于创建自定义模型对象
POST /apis/my-plugin.halo.run/v1alpha1/persons
# 用于根据指定名称删除自定义模型对象
DELETE /apis/my-plugin.halo.run/v1alpha1/persons/{name}
```
对于这组自动生成的 `CRUD` APIs你可以通过定义[控制器](../../basics/framework.md#controller)来完成对数据修改后的业务逻辑处理来满足大部分的场景需求。
`GET /apis/my-plugin.halo.run/v1alpha1/persons` 这个 API 用于查询自定义模型对象,它支持以下参数:
- page页码从 1 开始。
- size每页数据量如果不传此参数默认为查询所有。
- sort排序字段格式为 `字段名,排序方式`,例如 `name,desc`,如果不传此参数默认为按照 `metadata.creationTimestamp` 降序排序,排序使用的字段必须是注册为索引的字段。
- labelSelector标签选择器格式为 `key=value`,例如 `labelSelector=name=halo`,如果不传此参数默认为查询所有,此标签选择器筛选的是 `metadata.labels`,支持的操作符有 `=``!=`、`!` 和 `存在检查`
- `=` 表示等于,例如 `labelSelector=name=halo` 表示查询 `metadata.labels``name` 的值等于 `halo` 的自定义模型对象。
- `!=` 表示不等于,例如 `labelSelector=name!=halo` 表示查询 `metadata.labels``name` 的值不等于 `halo`的自定义模型对象。
- `!` 表示不存在 key例如 `labelSelector=!name` 表示查询 `metadata.labels` 不存在 `name` 这个 key 的自定义模型对象。
- `存在检查` 表示查询存在 key 的对象,例如 `labelSelector=name` 表示查询 `metadata.labels` 存在 `name` 这个 key 的自定义模型对象。
- fieldSelector字段选择器格式与 `labelSelector` 类似,但需要确保对应的字段是注册为索引的字段,例如 `fieldSelector=spec.name=slug` 表示查询 `spec.slug` 的值等于 `halo` 的自定义模型对象,支持的操作符有 `=`、`!=` 和 `in`
- `=` 表示等于,例如 `fieldSelector=spec.slug=halo` 表示查询 `spec.slug` 的值等于 `halo` 的自定义模型对象。
- `!=` 表示不等于,例如 `fieldSelector=spec.slug!=halo` 表示查询 `spec.slug` 的值不等于 `halo` 的自定义模型对象。
- `in` 表示在集合中,例如 `fieldSelector=spec.slug=(halo,halo2)` 表示查询 `spec.slug` 的值在 `halo``halo2` 中的自定义模型对象。
这些查询参数是 `AND` 的关系,例如 `page=1&size=10&sort=name,desc&labelSelector=name=halo&fieldSelector=spec.slug=halo` 表示查询 `metadata.labels``name` 的值等于 `halo``spec.slug` 的值等于 `halo` 的自定义模型对象,并按照 `name` 字段降序排序,查询第 1 页,每页 10 条数据。
## 自定义 API {#custom-extension-apis}
在一些场景下,只有自动生成的 `CRUD` API 往往是不够用的,此时可以通过自定义一些 APIs 来满足功能。
你可以使用 [Spring Framework Web](https://docs.spring.io/spring-framework/reference/web/webflux/new-framework.html) 的 Adaptive 写法来暴露 APIs不同的是需要添加 `@ApiVersion` 注解,没有此注解的 `Controller` 将被忽略:
```java
@ApiVersion("fake.halo.run/v1alpha1")
@RequestMapping("/apples")
@RestController
public class AppleController {
@PostMapping("/starting")
public void starting() {
}
}
```
当插件被启动时Halo 将会为此 AppleController 生成一个 API
```text
/apis/fake.halo.run/v1alpha1/apples/starting
```
但我们**更推荐使用** [Functional Endpoints](https://docs.spring.io/spring-framework/reference/web/webflux-functional.html) 写法来提供 APIs这是一种轻量级函数式编程写法
```java
RouterFunction<ServerResponse> route = route()
.GET("/person/{id}", accept(APPLICATION_JSON), this::getPerson)
.GET("/person", accept(APPLICATION_JSON), this::listPeople)
.POST("/person", this::createPerson)
.add(otherRoute)
.build();
public Mono<ServerResponse> listPeople(ServerRequest request) {
// ...
}
public Mono<ServerResponse> createPerson(ServerRequest request) {
// ...
}
public Mono<ServerResponse> getPerson(ServerRequest request) {
// ...
}
```
HTTP 请求通过 HandlerFunction 处理:这是一个接收 ServerRequest 并返回延迟的 ServerResponse`Mono<ServerResponse>`)的函数。
请求和响应对象都有不可变的约定,它们提供了对 HTTP 请求和响应的 JDK 8 友好访问。HandlerFunction 相当于基于注解的编程模型中 @RequestMapping 方法的主体。
传入的请求通过 RouterFunction 路由到一个处理函数:这是一个接收 ServerRequest 并返回延迟的 HandlerFunction`Mono<HandlerFunction>`)的函数。
当路由函数匹配时,返回一个处理函数;否则返回一个空的 Mono。RouterFunction 相当于 `@RequestMapping` 注解,但主要区别在于路由函数不仅提供数据,还提供行为。
ServerRequest 和 ServerResponse 是不可变的接口,它们提供了对 HTTP 请求和响应的 JDK 8 友好访问。请求和响应都针对主体流提供了
[Reactive Streams](https://www.reactive-streams.org/) 的背压back pressure。请求主体用 Reactor Flux 或 Mono 表示。
响应主体可用任何响应式流发布者Publisher表示包括 Flux 和 Mono。
更多相关信息,请参见 [Reactor 3 Reference Guide](https://projectreactor.io/docs/core/release/reference/) 和 [Webflux](https://docs.spring.io/spring-framework/reference/web/webflux.html)。
### 自定义 API 的路由规则 {#route-rules-for-custom-apis}
自定义模型对象的自定义 APIs 的路由规则建议与自动生成的 CRUD APIs 的路由规则保持一致,这样可以方便用户理解和使用,因此对于自动生成的 CRUD APIs 的路由规则建议遵循以下规则:
1. 以 `/apis/<group>/<version>/<plural>[/<resourceName>/<subresource>]` 规则组成 APIs。
2. 为了与自动生成的 CRUD APIs 区分开避免冲突,我们选择通过不同的 group 来区分,自定义 APIs 的 group 建议遵循以下规则,以便保证唯一性的同时能与自定义模型产生关联:
- 在 Console 端使用的自定义 APIs 的 group 规则建议为 `console.api.<group>`,例如对于 Person 自定义模型需要额外的在 Console 端使用的自定义 APIs 那么 group 可以定义为 `console.api.my-plugin.halo.run`,则可能的用于查询 Person 列表的 API 的规则应为 `/apis/console.api.my-plugin.halo.run/v1alpha1/persons`
- 在个人中心使用的自定义 APIs 的 group 规则建议为 `uc.api.<group>`,例如 `uc.api.my-plugin.halo.run`
- 为主题端提供的公开的自定义 APIs 的 group 规则建议为 `api.<group>`,例如 `api.my-plugin.halo.run`

@ -0,0 +1,60 @@
---
title: 为主题提供数据
description: 了解如何为主题提供更多获取和使用数据的方法。
---
当你在插件中创建了自己的自定义模型时,你可能需要在主题模板中使用这些数据。或者,你提供一些额外的数据,以便主题可以使用它们,你可以通过创建一个自定义的 `finder` 来实现这一点。
## 创建一个 Finder
首先,你需要创建一个 `interface`,并在其中定义你需要提供给主题获取数据的方法,方法的返回值可以是 `Mono``Flux` 类型,例如:
```java
public interface LinkFinder {
Mono<LinkVo> get(String linkName);
Flux<LinkVo> listAll();
}
```
然后写一个实现类,实现这个 `interface`,并在类上添加 `@Finder` 注解,例如:
```java
import run.halo.app.theme.finders.Finder;
@Finder("myPluginLinkFinder")
public class LinkFinderImpl implements LinkFinder {
@Override
public Mono<LinkVo> get(String linkName) {
// ...
}
@Override
public Flux<LinkVo> listAll() {
// ...
}
}
```
`@Finder` 注解的值是你在主题中使用的名称,例如,你可以在主题中使用 `myPluginLinkFinder.get('a-link-name')` 来获取数据,`myPluginLinkFinder` 就是你在 `@Finder` 注解中定义的名称。
## Finder 命名
为了避免与其他插件的 `finder` 名称冲突,建议在 `@Finder` 注解中添加一个你插件名称的前缀作为 `finder` 名称且名称需要是驼峰式的,不能包含除了 `_` 之外的其他特殊字符。
例如,你的插件名称是 `my_plugin`,你需要为主题提供一个获取链接的 `finder`,那么你可以这样定义 `@Finder` 注解:
```java
@Finder("myPluginLinkFinder")
```
## 使用 Finder
在主题中,你可以通过 `finder` 名称和方法名及对应的参数来获取数据,例如:
```html
<div th:text="${myPluginLinkFinder.listAll()}">
</div>
```
模板语法参考:[Thymeleaf](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#standard-expression-syntax)。

@ -0,0 +1,268 @@
---
title: 编写控制器
description: 了解如何为自定义模型编写控制器
---
控制器是 Halo 的关键组件,它们负责对每个自定义模型对象进行操作,协调所需状态和当前状态,参考: [控制器概述](../../basics/framework.md#controller)。
控制器通常在具有一般事件序列的控制循环中运行:
1. 观察:每个控制器将被设计为观察一组自定义模型对象,例如文章的控制器会观察文章对象,插件的控制器会观察插件自定义模型对象等。
2. 比较:控制器将对象配置的期望状态与其当前状态进行比较,以确定是否需要更改,例如插件的 `spec.enabled``true`,而插件的当前状态是未启动,则插件控制器会处理启动插件的逻辑。
3. 操作:控制器将根据比较的结果执行相应的操作,以确保对象的实际状态与其期望状态一致,例如插件期望启动,插件控制器会处理启动插件的逻辑。
3. 重复:上述所有步骤都由控制器重复执行直到与期望状态一致。
这是一个描述控制器作用的例子:房间里的温度自动调节器。
当你设置了温度告诉了温度自动调节器你的期望状态Desired State
房间的实际温度是当前状态Current State。 通过对设备的开关控制,温度自动调节器让其当前状态接近期望状态,未到达期望状态则继续调节,直到达到期望状态。
在 Halo 中控制器的运行部分已经有一个默认实现,你只需要编写控制器的调谐的逻辑也就是 [控制器概述](../../basics/framework.md#controller) 中的所说的 Reconciler 即可。
## 编写 Reconciler
Reconciler 是控制器的核心,它是一个接口,你需要实现它的 `reconcile()` 方法,该方法接收一个 `Reconciler.Request` 对象,它包含了当前自定义模型对象的名称,你可以通过它来获取自定义模型对象的当前状态和期望状态,然后编写调谐的逻辑。
```java
@Component
public class PostReconciler implements Reconciler<Reconciler.Request> {
@Override
public Result reconcile(Request request) {
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return builder
.extension(new Post())
.build();
}
}
```
以上是一个简单的 Reconciler 实现,它实现了 `reconcile()` 方法,然后在 `setupWith()` 方法中将其通过 `ControllerBuilder` 构建为一个控制器并指定了
它要观察的自定义模型对象为`Post`,当文章自定义模型对象发生变化时,`reconcile()` 方法就会被调用,从 `Request request` 参数中你可以获得当前发生变化的文章自定义模型对象的名称,然后你就可以通过名称来查询到自定义模型对象进行调谐了。
### Reconciler 的返回值
`reconcile()` 方法的返回值是一个 `Result` 对象,它包含了调谐的结果,你可以通过它来告诉控制器是否需要重试,如果需要重试则控制器会在稍后再次调用 `reconcile()` 方法,而这个过程会一直重复,直到 `reconcile()` 方法返回成功为止这个过程被称之为调谐循环Reconciliation Loop
```java
record Result(boolean reEnqueue, Duration retryAfter) {}
```
`Result` 对象包含了两个属性reEnqueue 和 retryAfterreEnqueue 用于标识是否需要重试retryAfter 用于标识重试的时间间隔,如果 reEnqueue 为 true 则会在 retryAfter 指定的时间间隔后再次调用 `reconcile()` 方法,如果 reEnqueue 为 false 则不会再次调用 `reconcile()` 方法。
在没有特殊需要时,`retryAfter` 可以不指定,控制器会有一套默认的重试策略。
如果直接返回 `null` 则会被视为成功,效果等同于返回 `new Result(false, null)`
### Reconciler 的异常处理
`reconcile()` 方法抛出异常时,控制器会将异常记录到日志中,然后会将 `Request request` 对象重新放入队列中,等待下次调用 `reconcile()` 方法,这个过程会一直重复,直到 `reconcile()` 成功,对于默认重试策略,每次重试间隔会越来越长,直到达到最长间隔后不再增加。
## 控制器示例
本章节将通过一个简单的示例来演示如何编写控制器。
### 场景:事件管理系统
创建一个名为 ”EventTracker“ 的自定义模型,用于管理和追踪组织内的各种事件。这些事件可以是会议、研讨会、社交聚会或任何其他类型的组织活动。
“EventTracker“ 自定义模型将提供一个框架,用于记录事件的详细信息,如时间、地点、参与者和状态。
由于这里的重点是控制器,因此我们将忽略自定义模型的详细信息,只关注控制器的实现,一个可能的 “EventTracker” 数据结构如下:
```yaml
apiVersion: tracker.halo.run/v1alpha1
kind: EventTracker
metadata:
name: event-tracker-1
spec:
eventName: "Halo Meetup"
eventDate: "2024-01-20T12:00:00Z"
location: "Chengdu"
participants: ["@sig-doc", "@sig-console", "@sig-halo"]
description: "Halo Meetup in Chengdu"
status:
phase: "Planned" # Planned, Ongoing, Completed
participants: []
conditions:
- type: "Invalid"
status: "True"
reason: "InvalidEventDate"
message: "Event date is invalid"
```
业务逻辑处理:
1. 事件创建:
- 当新的 EventTracker 资源被创建时,控制器需验证所有必要字段的存在和格式正确性。
- 初始化事件状态为 Planned。
2. 事件更新:
- 检查 eventDate、location 和 participants 字段的变更。
- 如果接近事件日期,自动更新状态为 Ongoing。
3. 状态管理:
- 根据当前日期和事件日期自动管理 phase 字段。
- 当事件日期过去时,将状态更新为 Completed。
4. 数据验证和完整性:
- 确保所有输入数据的格式正确且合理。
- 如有不一致或缺失的重要信息,记录警告或错误。
5. 事件提醒和通知:
- 在事件状态改变或临近事件日期时发送通知。
6. 清理和维护:
- 对于已完成的事件,提供自动清理机制,例如在事件结束后一定时间内删除资源。
首先实现 EventTracker 控制器的协调循环主体,通过依赖注入 `ExtensionClient` 可以用于获取当前变更的对象:
```java
@Component
@RequiredArgsConstructor
public class EventTrackerReconciler implements Reconciler<Reconciler.Request> {
private final ExtensionClient client;
@Override
public Result reconcile(Request request) {
// ...
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return builder
.extension(new EventTracker())
.build();
}
}
```
然后在 `reconcile()` 方法中根据 `EventTracker` 对象的状态来执行响应的操作,确保执行逻辑是是幂等的,这意味着即使多次执行相同操作,结果也应该是一致的。
```java
public Result reconcile(Request request) {
client.fetch(EventTracker.class, request.name()).ifPresent(eventTracker -> {
// 获取到当前变更的 EventTracker 对象
// 1. 检查必要字段的存在和格式正确性
// 2. 初始化事件状态为 Planned
if (eventTracker.getStatus() == null) {
eventTracker.setStatus(new EventTracker.Status());
}
var status = eventTracker.getStatus();
if (status.getPhase() == null) {
status.setPhase(EventTracker.Phase.PLANNED);
}
var eventName = eventTracker.getSpec().getEventName();
if (StringUtils.isBlank(eventName)) {
Condition condition = Condition.builder()
.type("Invalid")
.reason("InvalidEventName")
.message("Event name is invalid")
.status(ConditionStatus.FALSE)
.lastTransitionTime(Instant.now())
.build();
status.getConditions().addAndEvictFIFO(condition);
}
client.update(eventTracker);
});
return new Result(false, null);
}
```
上述,我们通过 `client.fetch()` 方法获取到了当前变更的 `EventTracker` 对象,然后根据 `EventTracker` 对象的状态来执行响应的操作,例如初始化事件状态为 Planned检查必要字段的存在和格式正确性等但需要注意控制器的执行是异步的如果我们通过 `EventTracker` 的 API 来创建或更改了一个 `EventTracker` 对象,那么 API 会在控制器执行之前返回结果,这意味着在用户界面看到的结果可能不是最新的,并且可能会在稍后更新。
对于上述校验 `eventName` 的逻辑只是保证后续的执行是可靠的,如果有些字段是必须的,那么我们可以通过 `@Schema` 注解来标注,为了让控制器中校验字段失败的信息能够呈现到用户界面,我们通过向 `status.condtions` 中添加了一条 Condition 记录来用于记录这个事件,再用户界面可以展示这个 Condition 记录的信息以让用户知晓。
最后,我们通过 `client.update()` 方法来更新 `EventTracker` 对象,这个过程就是将实际状态回写到 `EventTracker` 对象并应用到数据库中,这样就完成了一次调谐。
`EventTracker` 对象发生变更时,控制器也会被执行,这时我们可以根据 `EventTracker` 对象的状态来执行响应的操作,例如检查和更新 `eventDate`、`location` 和 `participants` 字段的变更,如果接近事件日期,自动更新状态为 Ongoing。
```java
public Result reconcile(Request request) {
client.fetch(EventTracker.class, request.name()).ifPresent(eventTracker -> {
// ...此处省略之前的逻辑
if (isApproach(eventTracker.getSpec().getEventDate())) {
status.setPhase(EventTracker.Phase.ONGOING);
sendNotification(eventTracker, "Event is ongoing");
}
});
return new Result(false, null);
}
```
这里我们通过 `isApproach()` 方法来表示判断是否接近事件日期,如果接近则更新状态为 Ongoing并使用 `sendNotification` 来发送发送通知。
> 为了简化示例,我们省略了 `isApproach()``sendNotification` 方法的实现。
还可以根据 `spec.participants` 字段来解析参与者信息,然后将其添加到 `status.participants` 中,这样就可以在用户界面看到参与者信息了。
```java
public Result reconcile(Request request) {
client.fetch(EventTracker.class, request.name()).ifPresent(eventTracker -> {
// ...此处省略之前的逻辑
var participants = eventTracker.getSpec().getParticipants();
resolveParticipants(participants).forEach(status::addParticipant);
});
return new Result(false, null);
}
```
### 使用 Finalizers
`Finalizers` 允许控制器实现异步预删除钩子。假设您为正在实现的 API 类型的每个对象创建了一个外部资源,例如存储桶,并且您希望在从 Halo 中删除相应对象
时清理外部资源,您可以使用终结器来删除外部资源资源。
比如 `EventTracker` 对象被删除时,我们需要删除 `EventTracker` 对象记录的日志,这时我们可以通过 `Finalizers` 来实现。
首先我们需要在 `reconcile()` 的开头判断 `EventTracker` 对象的 `metadata.deletionTimestamp` 是否存在,如果存在则表示 `EventTracker` 对象被删除了,
这时我们就可以执行清理操作。
```java
public Result reconcile(Request request) {
client.fetch(EventTracker.class, request.name()).ifPresent(eventTracker -> {
if (ExtensionOperator.isDeleted(eventTracker)) { // 1. 判断是否被删除
// 2. 调用 removeFinalizers 方法移除终结器(稍后会说明)
ExtensionUtil.removeFinalizers(eventTracker.getMetadata(), Set.of(FINALIZER_NAME));
// 3. 执行清理操作
cleanUpLogsForTracker(eventTracker);
// 4. 更新 EventTracker 对象将变更应用到数据库中
client.update(eventTracker);
// 5. return 避免执行后续逻辑
return;
}
// ...此处省略之前的逻辑
});
return new Result(false, null);
}
```
1. `ExtensionOperator.isDeleted` 方法是 Halo 提供的工具方法,用于判断对象是否被删除,它会判断 `metadata.deletionTimestamp` 是否存在,如果存在则表示对象被标记删除了。
关于自定义模型对象的删除可以参考:[自定义模型对象生命周期](../../basics/framework.md#extension-lifecycle)
2. `ExtensionUtil.removeFinalizers` 方法是 Halo 提供的工具方法,用于移除对象的终结器,它接收两个参数,第一个参数是对象的元数据,第二个参数是要移除的终结器名称集合,它来自 `run.halo.app.extension.ExtensionUtil`
3. `cleanUpLogsForTracker` 方法是我们自己实现的,这里的示例是用于清理 `EventTracker` 对象记录的日志,你可以根据自己的业务需求来实现,如清理外部资源等。
经过上述步骤,我们只是写了移除终结器但是发现没有添加终结器的逻辑,添加终结器的逻辑需要在判断删除之后,`metadata.finalizers` 是一个字符串集合,用于标识对象是否可回收,如果 `metadata.finalizers` 不为空则表示对象不可回收,否则表示对象可回收,我们可以通过 `ExtensionUtil.addFinalizers` 方法来添加终结器。
最佳实践是,一个控制器最多添加一个终结器,名称为了防止冲突可以使用当前业务的 `group/终结器名称` 来命名,例如 `tracker.halo.run/finalizer`,例如在 Halo 中文章的控制器使用了一个终结器,但可能插件也会定义一个文章控制器来扩展文章的业务,那么根据最佳实践命名终结器可以避免冲突。
```java
private static final String FINALIZER_NAME = "tracker.halo.run/finalizer";
public Result reconcile(Request request) {
client.fetch(EventTracker.class, request.name()).ifPresent(eventTracker -> {
if (ExtensionOperator.isDeleted(eventTracker)) {
// ... 省略删除逻辑
}
// 添加终结器
ExtensionUtil.addFinalizers(post.getMetadata(), Set.of(FINALIZER_NAME));
// ...此处省略之前的逻辑
// 会在更新时将终结器的变更写入到数据库中
client.update(eventTracker);
});
}
```

@ -0,0 +1,36 @@
---
title: 静态资源代理
description: 了解如果使用静态资源代理来访问插件中的静态资源
---
插件中的静态资源如图片等如果想被外部访问到,需要放到 `src/main/resources` 目录下,并通过创建 `ReverseProxy` 自定义模型对象来进行静态资源代理访问。
例如 `src/main/resources` 下的 `static` 目录下有一张 `halo.jpg`:
1. 首先需要在 `src/main/resources/extensions` 下创建一个 `yaml`,文件名可以任意。
2. 声明 `ReverseProxy` 对象如下:
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: ReverseProxy
metadata:
# 为了避免与其他插件冲突,推荐带上插件名称前缀
name: my-plugin-fake-reverse-proxy
rules:
- path: /res/**
file:
directory: static
# 如果想代理 static 下所有静态资源则省略 filename 配置
filename: halo.jpg
```
插件启动后会根据 `/plugins/{plugin-name}/assets/**` 规则生成访问路径,
因此该 `ReverseProxy` 的访问路径为: `/plugins/my-plugin/assets/res/halo.jpg`
- `rules` 下可以添加多组规则。
- `path` 为路径前缀。
- `file` 表示访问文件系统,目前暂时仅支持这一种。
- `directory` 表示要代理的目标文件目录,它相对于 `src/main/resources/` 目录。
- `filename` 表示要代理的目标文件名。
`directory``filename` 都是可选的,但必须至少有一个被配置。

@ -0,0 +1,202 @@
---
title: API 权限控制
description: 了解如果对插件中的 API 定义角色模板以接入权限控制
---
插件中的 APIs 无论是自定义模型自动生成的 APIs 或者是通过 `@Controller` 自定义的 APIs 都只有超级管理员能够访问,如果想将这些 APIs 授权给其他用户访问,
则需要定义一些[角色模板](../../basics/framework.md#rbac)的资源以便可以在用户界面上将其分配给其他角色使用。
定义角色模板就是在插件的 `src/main/resources/extensions` 目录下声明角色的自定义模型资源并标记为模板的过程,文件名称可以任意,它的 kind 为 Role 但需要一个 label `halo.run/role-template: "true"` 来表示它是角色模板。
Halo 的权限控制对同一种资源一般只定义两个角色模板的自定义模型对象,一个是只读权限,另一个是管理权限,因此如果没有特殊情况需要更细粒度的控制,我们建议你也保持一致:
```yaml
apiVersion: v1
kind: Role
metadata:
# 使用 plugin name 作为前缀防止与其他插件冲突,比如这里的 my-plugin
name: my-plugin-role-view-persons
labels:
halo.run/role-template: "true"
annotations:
rbac.authorization.halo.run/module: "Persons Management"
rbac.authorization.halo.run/display-name: "Person Manage"
rbac.authorization.halo.run/ui-permissions: |
["plugin:my-plugin:person:view"]
rules:
- apiGroups: ["my-plugin.halo.run"]
resources: ["my-plugin/persons"]
verbs: ["*"]
---
apiVersion: v1
kind: Role
metadata:
name: my-plugin-role-manage-persons
labels:
halo.run/role-template: "true"
annotations:
rbac.authorization.halo.run/dependencies: |
[ "role-template-view-person" ]
rbac.authorization.halo.run/module: "Persons Management"
rbac.authorization.halo.run/display-name: "Person Manage"
rbac.authorization.halo.run/ui-permissions: |
["plugin:my-plugin:person:manage"]
rules:
- apiGroups: [ "my-plugin.halo.run" ]
resources: [ "my-plugin/persons" ]
verbs: [ "get", "list" ]
```
上述便是根据 [自定义模型](./extension.md) 章节中定义的 Person 自定义模型来配置角色模板的示例。
1. 定义了一个用于管理 Person 自定义模型对象的角色模板 `my-plugin-role-manage-persons`,它具有所有权限。
2. 定义了一个只允许查询 Person 资源的角色模板 `my-plugin-role-view-persons`
3. `metadata.name` 的命名规则参考 [metadata name 命名规范](../server/extension.md#metadata-name)。
下面让我们回顾一下这些配置:
`rules` 是个数组,它允许配置多组规则:
- `apiGroups` 对应 `GVK` 中的 `group` 所声明的值。
- `resources` 对应 API 中的 resource 部分。
- `verbs` 表示请求动词,可选值为 "create", "delete", "deletecollection", "get", "list", "patch", "update"。对应的 HTTP 请求方式如下表所示:
| HTTP verb | request verb |
| --------- | ------------------------------------------------------------ |
| POST | create |
| GET, HEAD | get (for individual resources), list (for collections, including full object content), watch (for watching an individual resource or collection of resources) |
| PUT | update |
| PATCH | patch |
| DELETE | delete (for individual resources), deletecollection (for collections) |
`metadata.labels` 中必须包含 `halo.run/role-template: "true"` 以表示它此资源要作为角色模板。
`metadata.annotations` 中:
- `rbac.authorization.halo.run/dependencies`:用于声明角色间的依赖关系,例如管理角色必须要依赖查看角色,以避免分配了管理权限却没有查看权限的情况。
- `rbac.authorization.halo.run/module`:角色模板分组名称。在此示例中,管理 Person 的模板角色将和查看 Person 的模板角色将被在 UI 层面归为一组展示。
- `rbac.authorization.halo.run/display-name`:模板角色的显示名称,用于展示为用户可读的名称信息。
- `rbac.authorization.halo.run/ui-permissions`:用于控制 UI 权限,规则为 `plugin:{your-plugin-name}:scope-name`,使用示例为在插件前端部分入口文件 `index.ts` 中用于控制菜单是否显示或者控制页面按钮是否展示:
```javascript
{
path: "",
name: "HelloWorld",
component: DefaultView,
meta: {
permissions: ["plugin:my-plugin:person:view"]
}
}
```
以上定义角色模板的方式适合资源型请求,即需要符合以下规则
- 以 `/api/<version>/<resource>[/<resourceName>/<subresource>]` 规则组成 APIs。
- 以 `/apis/<group>/<version>/<resource>[/<resourceName>/<subresource>]` 规则组成的 APIs。
注:`[]`包裹的部分表示可选,只有 Halo 自身使用的 APIs 可以没有 group所以插件的 APIs 都是以 `/apis` 开头的。
:::tip Note
资源型的 API 通常是对自定义模型对象的操作,例如 `/apis/my-plugin.halo.run/v1alpha1/persons`,如果你自定义 APIs 则建议路径层级不要超过 `<subresource>` 层级,否则会导致角色模板无法生效,例如 `/apis/my-plugin.halo.run/v1alpha1/persons/zhangsan/post`,如果 `post` 后面还有路径则会导致角色模板无法生效。
:::
如果你的 API 不符合以上资源型 API 的规则,则被定型为非资源型 API例如 `/healthz`,需要使用一下配置方式:
```yaml
rules:
# nonResourceURL 中的 '*' 是一个全局通配符
- nonResourceURLs: ["/healthz", "/healthz/*"]
verbs: [ "get", "create"]
```
## 默认角色
在 Halo 中,每个访问者都至少有一个角色,包括未登陆的用户(被称为匿名用户)它们会拥有角色为 `anonymous` 的角色,而已登陆的用户则会至少拥有一个角色名为 `authenticated` 的角色,
但这两个角色不会显示在角色列表中。
`anonymous` 角色的定义参考 [anonymous 角色](https://github.com/halo-dev/halo/blob/main/application/src/main/resources/extensions/role-template-anonymous.yaml)。
`authenticated` 角色的定义参考 [authenticated 角色](https://github.com/halo-dev/halo/blob/main/application/src/main/resources/extensions/role-template-authenticated.yaml)。
进入角色列表页面,你会看到一些内置角色,用于方便你快速的分配权限给用户,并可以基于这些角色来创建新的角色:
- 超级管理员:拥有所有权限,不可删除,不可编辑。
- 访客:拥有默认的 `anonymous``authenticated` 角色的权限。
- 投稿者:拥有“允许投稿”的权限。
- 作者:拥有“允许管理自己的文章”和”允许发布自己的文章“的权限。
- 文章管理员:拥有“允许管理所有文章”的权限。
## 角色绑定
角色绑定用于将角色中定义的权限授予一个或一组用户。它包含主体列表(用户)以及对所授予角色的引用。
角色绑定示例:
```yaml
apiVersion: v1alpha1
# 这个角色绑定允许 "guqing" 用户拥有 "post-reader" 角色的权限,你需要在 Halo 中已经定义了一个名为 "post-reader" 的角色。
kind: RoleBinding
metadata:
name: guqing-post-reader-binding
roleRef:
# "roleRef" 指定了绑定到的角色
apiGroup: ''
# 这里必须是 Role
kind: Role
# 这里的 name 必须匹配到一个已经定义的角色
name: post-reader
subjects:
- apiGroup: ''
kind: User
# 这里的 name 是用户的 username
name: guqing
```
在 Halo 中,当你给一个用户分配角色后,实际上就是创建了一个 ”RoleBinding” 对象来完成的。
## 聚合角色
你可以聚合角色来将多个角色的权限聚合到一个已有的角色中,这样你就不需要再为每个用户分配多个角色了。
聚合角色是通过在你定义的角色模板中添加 `"rbac.authorization.halo.run/aggregate-to-` 开头的 label 来实现的,例如
```yaml
apiVersion: v1alpha1
kind: "Role"
metadata:
name: role-template-view-categories
labels:
halo.run/role-template: "true"
rbac.authorization.halo.run/aggregate-to-editor: "true"
annotations:
rbac.authorization.halo.run/ui-permissions: |
[ "system:categories:view", "uc:categories:view" ]
rules:
- apiGroups: [ "content.halo.run" ]
resources: [ "categories" ]
verbs: [ "get", "list" ]
```
`rbac.authorization.halo.run/aggregate-to-editor` 表示将 `role-template-view-categories` 角色聚合到 `editor` 角色中,这样所有拥有 `editor` 角色的用户都会拥有 `role-template-view-categories` 角色的权限。
如果你想将你写的资源型 APIs 公开给所有用户访问,这时你可以通过聚合角色来将你的资源型 APIs 的角色聚合到 `anonymous` 角色中,这样所有用户都可以访问你的资源型 APIs 了。
```yaml
apiVersion: v1
kind: Role
metadata:
name: my-plugin-role-view-persons
labels:
halo.run/role-template: "true"
rbac.authorization.halo.run/aggregate-to-anonymous: "true"
annotations:
rbac.authorization.halo.run/module: "Persons Management"
rbac.authorization.halo.run/display-name: "Person Manage"
rbac.authorization.halo.run/ui-permissions: |
["plugin:my-plugin:person:view"]
rules:
- apiGroups: ["my-plugin.halo.run"]
resources: ["my-plugin/persons"]
verbs: ["*"]
```
`rbac.authorization.halo.run/aggregate-to-anonymous` 的写法就表示将 `my-plugin-role-view-persons` 角色聚合到 `anonymous` 角色中。

@ -0,0 +1,90 @@
---
title: 在插件中提供主题模板
description: 了解如何为主题扩充模板。
---
当你在插件中创建了自己的自定义模型后,你可能需要在主题端提供一个模板来展示这些数据,这一般有两种方式:
1. 插件规定模板名称,由主题选择性适配,如瞬间插件提供了 `/moments` 的路由渲染 `moment.html` 模板,主题可以选择性的提供 `moment.html` 模板来展示瞬间数据。
2. 插件提供默认模板,当主题没有提供对应的模板时,使用默认模板,主题提供了对应的模板时,使用主题提供的模板。
## 创建一个模板
首先,你需要在插件的 `resources` 目录下创建一个 `templates` 目录,然后在 `templates` 目录下提供你的模板,例如:
```text
├── templates
│ ├── moment.html
```
然后提供一个路由用于渲染这个模板,例如:
```java
import run.halo.app.theme.TemplateNameResolver;
@RequiredArgsConstructor
@Configuration(proxyBeanMethods = false)
public class MomentRouter {
private final TemplateNameResolver templateNameResolver;
@Bean
RouterFunction<ServerResponse> momentRouterFunction() {
return route(GET("/moments"), this::renderMomentPage).build();
}
Mono<ServerResponse> renderMomentPage(ServerRequest request) {
// 或许你需要准备你需要提供给模板的默认数据,非必须
var model = new HashMap<String, Object>();
model.put("moments", List.of());
return templateNameResolver.resolveTemplateNameOrDefault(request.exchange(), "moments")
.flatMap(templateName -> ServerResponse.ok().render(templateName, model));
}
}
```
使用 `TemplateNameResolver` 来解析模板名称,如果主题提供了对应的模板,那么就使用主题提供的模板,否则使用插件提供的模板,如果直接返回模板名称,那么只会使用主题提供的模板,如果主题没有提供对应的模板,那么会抛出异常。
## 模板片段
如果你的默认模板不止一个,你可能需要通过模板片段来抽取一些公共的部分,例如,你的插件提供了一个 `moment.html` 模板,你可能需要抽取一些公共的部分,例如头部、尾部等,你可以这样做:
```text
├── templates
│ ├── moment.html
│ ├── fragments
│ │ ├── layout.html
```
然后定义一个 `layout.html` 模板,例如:
```html
<!DOCTYPE html th:fragment="layoutHtml(content)">
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title th:text="${title}">Moment</title>
</head>
<body>
<div class="container">
<th:block th:replace="${content}" />
</div>
</body>
</html>
```
那么使用 `layout.html` 模板中提供的 `fragment` 时,你需要这样做:
```html
<div th:replace="~{plugin:plugin-moment:fragments/layout :: layoutHtml(content = ~{::content})}">
<th:block th:fragment="content"> Hello World </th:block>
</div>
```
`plugin:plugin-moment:fragments/layout` 即为使用 `layout.html` 模板的路径,必须以 `plugin:<your-plugin-name>:`前缀作为开头,`fragments/layout` 为模板相对于 `resources/templates` 的路径,`<your-plugin-name>` 即为你的插件名称。
**总结:**
1. 定义模板片段时与主题端定义模板片段时一样
2. 使用模板片段时,必须以 `plugin:<your-plugin-name>:` 前缀作为开头,后跟模板相对于 `resources/templates` 的路径,例如 `plugin:plugin-moment:fragments/layout``plugin-moment` 即为你的插件名称,`fragments/layout` 为模板相对于 `resources/templates` 的路径。
参考:[Thymeleaf 模板片段](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#including-template-fragments)

@ -0,0 +1,55 @@
---
title: AnnotationsForm
description: 元数据表单组件
---
此组件用于提供统一的 [Annotations 表单](../../../../annotations-form.md),可以根据 `group``kind` 属性自动渲染对应的表单项。
## 使用示例
```html
<script lang="ts" setup>
import { ref } from "vue"
const annotationsFormRef = ref()
const currentAnnotations = ref()
function handleSubmit () {
annotationsFormRef.value?.handleSubmit();
await nextTick();
const { customAnnotations, annotations, customFormInvalid, specFormInvalid } =
annotationsFormRef.value || {};
// 表单验证不通过
if (customFormInvalid || specFormInvalid) {
return;
}
// 合并自定义数据和表单提供的数据
const newAnnotations = {
...annotations,
...customAnnotations,
};
}
</script>
<template>
<AnnotationsForm
ref="annotationsFormRef"
:value="currentAnnotations"
kind="Post"
group="content.halo.run"
/>
<VButton @click="handleSubmit">提交</VButton>
</template>
```
## Props
| 属性名 | 类型 | 默认值 | 描述 |
|---------|------------------------------------|---------|-----------------------------------------|
| `group` | string | 无,必填 | 定义组件所属的分组。 |
| `kind` | string | 无,必填 | 定义组件的种类。 |
| `value` | { [key: string]: string; } \| null | null | 可选,包含键值对的对象或空值,用于存储数据。 |

@ -0,0 +1,25 @@
---
title: AttachmentFileTypeIcon
description: 附件文件类型图标组件
---
此组件用于根据文件名显示文件类型图标。
## 使用示例
```html
<script lang="ts" setup></script>
<template>
<AttachmentFileTypeIcon fileName="example.png" />
</div>
```
## Props
| 属性名 | 类型 | 默认值 | 描述 |
|--------------|---------------------|-----------|------------------------------------|
| `fileName` | string \| undefined | undefined | 文件名,可以是字符串或未定义。 |
| `displayExt` | boolean | true | 可选,是否显示文件扩展名,默认为 true。 |
| `width` | number | 10 | 可选,组件宽度,默认为 10。 |
| `height` | number | 10 | 可选,组件高度,默认为 10。 |

@ -0,0 +1,50 @@
---
title: AttachmentSelectorModal
description: 附件选择组件
---
此组件用于调出附件选择器,以供用户选择附件。
:::info 注意
此组件当前仅在 Console 中可用。
:::
## 使用示例
```html
<script lang="ts" setup>
import { ref } from "vue"
const visible = ref(false)
function onAttachmentSelect (attachments: AttachmentLike[]) {
console.log(attachments)
}
</script>
<template>
<VButton @click="visible = true">选择附件</VButton>
<AttachmentSelectorModal
v-model:visible="visible"
@select="onAttachmentSelect"
/>
</template>
```
## Props
| 属性名 | 类型 | 默认值 | 描述 |
|-----------|----------|---------------|--------------------------|
| `visible` | boolean | false | 控制组件是否可见。 |
| `accepts` | string[] | () => ["*/*"] | 可选,定义可接受的文件类型。 |
| `min` | number | undefined | 可选,定义最小选择数量。 |
| `max` | number | undefined | 可选,定义最大选择数量。 |
## Emits
| 事件名称 | 参数 | 描述 |
|----------------|---------------------------------------------------|---------------------|
| update:visible | `visible`: boolean 类型,表示可见状态。 | 当可见状态更新时触发。 |
| close | 无 | 当弹框关闭时触发。 |
| select | `attachments`: AttachmentLike[] 类型,表示附件数组。 | 当选择确定按钮时触发。 |

@ -0,0 +1,18 @@
---
title: FilterCleanButton
description: 过滤器清除按钮组件
---
## 使用示例
```html
<script lang="ts" setup>
function onClear () {
console.log("clear")
}
</script>
<template>
<FilterCleanButton @click="onClear" />
</template>
```

@ -0,0 +1,48 @@
---
title: FilterDropdown
description: 过滤器下拉组件
---
此组件为通用的下拉筛选组件,可以接收一个对象数组作为选项,并使用 `v-model` 绑定选择的值。
## 使用示例
```html
<script lang="ts" setup>
import { ref } from "vue"
const value = ref("")
const items = [
{
label: "最近创建",
value: "creationTimestamp,desc"
},
{
label: "较晚创建",
value: "creationTimestamp,asc"
}
]
</script>
<template>
<FilterDropdown
v-model="value"
label="排序"
:items="items"
/>
</template>
```
## Props
| 属性名 | 类型 | 默认值 | 描述 |
|--------------|-----------------------------------------------------------|-----------|--------------------------------------------------|
| `items` | { label: string; value?: string \| boolean \| number; }[] | 无,必填 | 包含 `label` 和可选 `value` 的对象数组。 |
| `label` | string | 无,必填 | 组件的标签文本。 |
| `modelValue` | string \| boolean \| number | undefined | 可选,用于绑定到组件的值,可以是字符串、布尔值或数字。 |
## Emits
| 事件名称 | 参数 | 描述 |
|-------------------|--------------------------------------------------------|-------------------|
| update:modelValue | `modelValue`: string \| boolean \| number \| undefined | 当模型值更新时触发。 |

@ -0,0 +1,26 @@
---
title: HasPermission
description: 权限判断组件
---
此组件用于根据权限控制元素的显示与隐藏。
## 使用方式
```html
<script lang="ts" setup>
import { VButton } from "@halo-dev/components"
</script>
<template>
<HasPermission :permissions="['system:posts:manage']">
<VButton type="danger">删除</VButton>
</HasPermission>
</template>
```
## Props
| 属性名 | 类型 | 默认值 | 描述 |
|---------------|----------|------|-----------------------|
| `permissions` | string[] | 无,必填 | 定义组件所需的权限列表。 |

@ -0,0 +1,42 @@
---
title: 组件
description: 在 Halo UI 中可使用的组件。
---
此文档将介绍所有在插件中可用的组件,以及它们的使用方法和区别。
## 基础组件库
我们为 Halo 的前端封装了一个基础组件的库,你可以在插件中使用这些组件。
安装方式:
```bash
pnpm install @halo-dev/components
```
在 Vue 组件中:
```html
<script lang="ts" setup>
import { VButton } from "@halo-dev/components";
</script>
<template>
<VButton>Hello</VButton>
</template>
```
所有可用的基础组件以及文档可查阅:<https://halo-ui-components.pages.dev>
## 业务组件和指令
除了基础组件库,我们还为 Halo 的前端封装了一些业务组件和指令,这些组件已经在全局注册,你可以直接在插件中使用这些组件和指令。
以下是所有可用的业务组件和指令:
```mdx-code-block
import DocCardList from '@theme/DocCardList';
<DocCardList />
```

@ -0,0 +1,33 @@
---
title: SearchInput
description: 搜索输入框组件
---
此组件适用于关键词搜索场景,输入数据的过程中不会触发搜索,只有在输入完成后,点击回车才会触发搜索。
## 使用方式
```html
<script lang="ts" setup>
import { ref } from "vue"
const keyword = ref("")
</script>
<template>
<SearchInput v-model="keyword" placeholder="请输入关键字" />
</template>
```
## Props
| 属性名 | 类型 | 默认值 | 描述 |
|---------------|--------|-----------|----------------------------------|
| `placeholder` | string | undefined | 可选,用于指定输入字段的占位符文本。 |
| `modelValue` | string | 无,必填 | 用于绑定输入字段的值。 |
## Emits
| 事件名称 | 参数 | 描述 |
|-------------------|-------------------------------------|-------------------|
| update:modelValue | `modelValue`: string 类型,表示模型值。 | 当模型值更新时触发。 |

@ -0,0 +1,47 @@
---
title: UppyUpload
description: 文件上传组件
---
## 使用方式
```html
<script lang="ts" setup>
const policyName = ref('my-test-policy')
const groupName = ref('my-test-group')
</script>
<template>
<UppyUpload
endpoint="/apis/api.console.halo.run/v1alpha1/attachments/upload"
:meta="{
policyName: policyName,
groupName: groupName,
}"
/>
</template>
```
## Props
| 属性名 | 类型 | 默认值 | 描述 |
|---------------------|----------------------------------------------------------------|-----------|------------------------------|
| `restrictions` | Restrictions | undefined | 可选,指定任何限制。 |
| `meta` | Record<string, unknown> | undefined | 可选,要发送的额外元数据。 |
| `autoProceed` | boolean | false | 可选,在某些操作后自动继续。 |
| `allowedMetaFields` | string[] | undefined | 可选,指定允许的元数据字段。 |
| `endpoint` | string | 无,必填 | 数据发送到的端点URL。 |
| `name` | string | file | 可选,用于上传的表单字段的名称。 |
| `note` | string | undefined | 可选,任何备注或描述。 |
| `method` | "GET" \| "POST" \| "PUT" \| "HEAD" \| "get" \| "post" \| "put" | post | 可选用于请求的HTTP方法。 |
| `disabled` | boolean | false | 可选,如果为真,则禁用组件。 |
| `width` | string | 750px | 可选,组件的宽度。 |
| `height` | string | 550px | 可选,组件的高度。 |
| `doneButtonHandler` | () => void | undefined | 可选,完成时调用的处理函数。 |
## Emits
| 事件名称 | 参数 | 描述 |
|----------|------------------------------------------------------|---------------------|
| uploaded | `response`: SuccessResponse 类型,表示上传成功的响应。 | 当文件上传成功时触发。 |
| error | `file`: 出错的文件。<br />`response`: 出错时的响应数据。 | 当文件上传出错时触发。 |

@ -0,0 +1,36 @@
---
title: VCodemirror
description: 代码编辑器组件
---
此组件封装了 Codemirror 代码编辑器,适用于一些需要编辑代码的场景。
## 使用方式
```html
<script lang="ts" setup>
import { ref } from "vue"
const value = ref("")
</script>
<template>
<VCodemirror v-model="value" height="300px" language="html" />
</template>
```
## Props
| 属性名 | 类型 | 默认值 | 描述 |
|--------------|-------------------------------------------------|----------|-------------------------------------------|
| `modelValue` | string | "" | 可选,绑定到组件的字符串值,默认为空字符串。 |
| `height` | string | auto | 可选,组件的高度,默认为 `"auto"`。 |
| `language` | keyof typeof presetLanguages \| LanguageSupport | yaml | 代码编辑器的语言支持,默认为 `"yaml"`。 |
| `extensions` | EditorStateConfig["extensions"] | () => [] | 可选,编辑器状态配置的扩展,默认为一个空数组。 |
## Emits
| 事件名称 | 参数 | 描述 |
|-------------------|----------------------------------|-------------------|
| update:modelValue | `value`: string 类型,表示模型值。 | 当模型值更新时触发。 |
| change | `value`: string 类型,表示变更的值。 | 当值发生变化时触发。 |

@ -0,0 +1,18 @@
---
title: v-permission
description: 权限指令
---
与 [HasPermission](./has-permission.md) 组件相同,此指令也是用于根据权限控制元素的显示与隐藏。
## 使用方式
```html
<script lang="ts" setup>
import { VButton } from "@halo-dev/components"
</script>
<template>
<VButton type="danger" v-permission="['system:posts:manage']">删除</VButton>
</template>
```

@ -0,0 +1,18 @@
---
title: v-tooltip
description: Tooltip 指令
---
此指令用于在任何元素上添加一个提示框。
## 使用方式
```html
<script lang="ts" setup>
import { IconDeleteBin } from "@halo-dev/components"
</script>
<template>
<IconDeleteBin v-tooltip="'删除此文档'" />
</template>
```

@ -0,0 +1,98 @@
---
title: 附件数据列表操作菜单
description: 扩展附件数据列表操作菜单 - attachment:list-item:operation:create
---
此扩展点用于扩展附件数据列表的操作菜单项。
![附件数据列表操作菜单](/img/developer-guide/plugin/api-reference/ui/extension-points/attachment-list-item-operation-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"attachment:list-item:operation:create": (
attachment: Ref<Attachment>
): OperationItem<Attachment>[] | Promise<OperationItem<Attachment>[]> => {
return [
{
priority: 10,
component: markRaw(VDropdownItem),
props: {},
action: (item?: Attachment) => {
// do something
},
label: "foo",
hidden: false,
permissions: [],
children: [],
},
];
},
},
});
```
```mdx-code-block
import OperationItem from "./interface/OperationItem.md";
<OperationItem />
```
## 示例
此示例将实现一个下载附件到本地的操作菜单项。
```ts
import { definePlugin, type OperationItem } from "@halo-dev/console-shared";
import { Toast, VDropdownItem } from "@halo-dev/components";
import { markRaw, type Ref } from "vue";
import type { Attachment } from "@halo-dev/api-client";
export default definePlugin({
extensionPoints: {
"attachment:list-item:operation:create": (
attachment: Ref<Attachment>
): OperationItem<Attachment>[] | Promise<OperationItem<Attachment>[]> => {
return [
{
priority: 10,
component: markRaw(VDropdownItem),
props: {},
action: (item?: Attachment) => {
if (!item?.status?.permalink) {
Toast.error("该附件没有下载地址");
return;
}
const a = document.createElement("a");
a.href = item.status.permalink;
a.download = item?.spec.displayName || item.metadata.name;
a.click();
},
label: "下载",
hidden: false,
permissions: [],
children: [],
},
];
},
},
});
```
## 实现案例
- <https://github.com/halo-dev/plugin-s3>
## 类型定义
### Attachment
```mdx-code-block
import Attachment from "./interface/Attachment.md";
<Attachment />
```

@ -0,0 +1,146 @@
---
title: 附件选择选项卡
description: 扩展附件选择组件的选项卡 - attachment:selector:create
---
此扩展点用于扩展附件选择组件的选项卡,目前 Halo 仅包含内置的附件库,你可以通过此扩展点添加自定义的选项卡。
![附件选择选项卡](/img/developer-guide/plugin/api-reference/ui/extension-points/attachment-selector-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"attachment:selector:create": (): AttachmentSelectProvider[]| Promise<AttachmentSelectProvider[]> => {
return [
{
id: "foo",
label: "foo",
component: markRaw(FooComponent),
},
];
},
},
});
```
```ts title="AttachmentSelectProvider"
export interface AttachmentSelectProvider {
id: string; // 选项卡 ID
label: string; // 选项卡名称
component: Component | string; // 选项卡组件
}
```
其中,`component` 可以是组件对象或组件名称,且此组件有以下实现要求:
1. 组件必须包含名称为 `selected``prop`,用于接收当前选中的附件。
```ts
const props = withDefaults(
defineProps<{
selected: AttachmentLike[];
}>(),
{
selected: () => [],
}
);
```
2. 组件必须包含名称为 `update:selected` 的 emit 事件,用于更新选中的附件。
```ts
const emit = defineEmits<{
(event: "update:selected", attachments: AttachmentLike[]): void;
}>();
```
```ts title="AttachmentLike"
export type AttachmentLike =
| Attachment
| string
| {
url: string;
type: string;
};
```
## 示例
为附件选择组件添加 Stickers 选项卡,用于从给定的贴纸列表选择附件。
```ts title="index.ts"
import {
definePlugin,
type AttachmentSelectProvider,
} from "@halo-dev/console-shared";
import { markRaw } from "vue";
import StickerSelectorProvider from "./components/StickerSelectorProvider.vue";
export default definePlugin({
components: {},
routes: [],
extensionPoints: {
"attachment:selector:create": (): AttachmentSelectProvider[] => {
return [
{
id: "stickers",
label: "Stickers",
component: markRaw(StickerSelectorProvider),
},
];
},
},
});
```
```html title="StickerSelectorProvider.vue"
<script lang="ts" setup>
const props = withDefaults(
defineProps<{
selected: AttachmentLike[];
}>(),
{
selected: () => [],
}
);
const emit = defineEmits<{
(event: "update:selected", attachments: AttachmentLike[]): void;
}>();
const stickers = [
{
url: "https://picsum.photos/200?random=1",
},
{
url: "https://picsum.photos/200?random=2",
},
{
url: "https://picsum.photos/200?random=3",
},
];
const selectedStickers = ref<Set<String>>(new Set());
const handleSelect = async (url: string) => {
if (selectedStickers.value.has(url)) {
selectedStickers.value.delete(url);
return;
}
selectedStickers.value.add(url);
emit('update:selected', Array.from(selectedStickers.value));
};
</script>
<template>
<div>
<img v-for="sticker in stickers" :src="sticker.url" @click="handleSelect(sticker.url)" />
</div>
</template>
```
## 实现案例
- <https://github.com/halo-sigs/plugin-unsplash>

@ -0,0 +1,41 @@
---
title: 备份数据列表操作菜单
description: 扩展备份数据列表操作菜单 - backup:list-item:operation:create
---
此扩展点用于扩展备份数据列表的操作菜单项。
![备份数据列表操作菜单](/img/developer-guide/plugin/api-reference/ui/extension-points/backup-list-item-operation-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"backup:list-item:operation:create": (
backup: Ref<Backup>
): OperationItem<Backup>[] | Promise<OperationItem<Backup>[]> => {
return [
{
priority: 10,
component: markRaw(VDropdownItem),
props: {},
action: (item?: Backup) => {
// do something
},
label: "foo",
hidden: false,
permissions: [],
children: [],
},
];
},
},
});
```
```mdx-code-block
import OperationItem from "./interface/OperationItem.md";
<OperationItem />
```

@ -0,0 +1,36 @@
---
title: 备份页面选项卡
description: 扩展备份页面选项卡 - backup:tabs:create
---
此扩展点可以针对备份页面扩展更多关于 UI 的功能,比如定时备份设置、备份到第三方云存储等。
![备份页面选项卡](/img/developer-guide/plugin/api-reference/ui/extension-points/backup-tabs-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"backup:tabs:create": (): BackupTab[] | Promise<BackupTab[]> => {
return [
{
id: "foo",
label: "foo",
component: markRaw(FooComponent),
permissions: [],
},
];
},
},
});
```
```ts title="BackupTab"
export interface BackupTab {
id: string; // 选项卡 ID
label: string; // 选项卡标题
component: Raw<Component>; // 选项卡面板组件
permissions?: string[]; // 选项卡权限
}
```

@ -0,0 +1,114 @@
---
title: 评论来源显示
description: 扩展评论来源显示 - comment:subject-ref:create
---
Console 的评论管理列表的评论来源默认仅支持显示来自文章和页面的评论,如果其他插件中的业务模块也使用了评论,那么就可以通过该拓展点来扩展评论来源的显示。
:::info 提示
此扩展点需要后端配合使用,请参考 [评论主体展示](../../server/extension-points/comment-subject.md)。
:::
![评论来源显示](/img/developer-guide/plugin/api-reference/ui/extension-points/comment-subject-ref-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"comment:subject-ref:create": (): CommentSubjectRefProvider[] => {
return [
{
kind: "Example",
group: "example.halo.run",
resolve: (subject: Extension): CommentSubjectRefResult => {
return {
label: "foo",
title: subject.title,
externalUrl: `/example/${subject.metadata.name}`,
route: {
name: "Example",
},
};
},
},
];
},
},
});
```
```ts title="CommentSubjectRefProvider"
export type CommentSubjectRefProvider = {
kind: string;
group: string;
resolve: (subject: Extension) => CommentSubjectRefResult;
};
```
```ts title="CommentSubjectRefResult"
export interface CommentSubjectRefResult {
label: string;
title: string;
route?: RouteLocationRaw;
externalUrl?: string;
}
```
## 示例
此示例以[瞬间插件](https://github.com/halo-sigs/plugin-moments)为例,目前瞬间插件中的评论是通过 Halo 的内置的评论功能实现的,所以需要扩展评论来源显示。
```ts
import {
definePlugin,
type CommentSubjectRefResult,
} from "@halo-dev/console-shared";
import { markRaw } from "vue";
import type { Moment } from "@/types";
import { formatDatetime } from "./utils/date";
import type { Extension } from "@halo-dev/api-client/index";
export default definePlugin({
extensionPoints: {
"comment:subject-ref:create": () => {
return [
{
kind: "Moment",
group: "moment.halo.run",
resolve: (subject: Extension): CommentSubjectRefResult => {
const moment = subject as Moment;
return {
label: "瞬间",
title: determineMomentTitle(moment),
externalUrl: `/moments/${moment.metadata.name}`,
route: {
name: "Moments",
},
};
},
},
];
},
},
});
const determineMomentTitle = (moment: Moment) => {
const pureContent = stripHtmlTags(moment.spec.content.raw);
const title = !pureContent?.trim()
? formatDatetime(new Date(moment.spec.releaseTime || ""))
: pureContent;
return title?.substring(0, 100);
};
const stripHtmlTags = (str: string) => {
// strip html tags
const stripped = str?.replace(/<\/?[^>]+(>|$)/gi, "") || "";
// strip newlines and collapse spaces
return stripped.replace(/\n/g, " ").replace(/\s+/g, " ");
};
```
## 实现案例
- <https://github.com/halo-sigs/plugin-moments>

@ -0,0 +1,479 @@
---
title: 默认编辑器
description: 扩展默认编辑器 - default:editor:extension:create
---
此扩展点用于扩展默认编辑器的功能。
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"default:editor:extension:create": (): AnyExtension[] | Promise<AnyExtension[]> => {
return [FooExtension];
},
},
});
```
:::info 提示
AnyExtension 类型来自 [Tiptap](https://github.com/ueberdosis/tiptap),这意味着 Halo 默认编辑器的扩展点返回类型与 Tiptap 的扩展完全一致Tiptap 的扩展文档可参考:<https://tiptap.dev/docs/editor/api/extensions>。此外Halo 也为默认编辑器的扩展提供了一些独有的参数,用于实现工具栏、指令等扩展。
:::
### Halo 独有扩展
阅读此文当前请确保已经熟悉了 Tiptap 的扩展文档这里将介绍如何对编辑器的功能进行扩展包括但不限于扩展工具栏、悬浮工具栏、Slash Command、拖拽功能等。
目前支持的所有扩展类型如下所示:
```ts
export interface ExtensionOptions {
// 顶部工具栏扩展
getToolbarItems?: ({
editor,
}: {
editor: Editor;
}) => ToolbarItem | ToolbarItem[];
// Slash Command 扩展
getCommandMenuItems?: () => CommandMenuItem | CommandMenuItem[];
// 悬浮菜单扩展
getBubbleMenu?: ({ editor }: { editor: Editor }) => NodeBubbleMenu;
// 工具箱扩展
getToolboxItems?: ({
editor,
}: {
editor: Editor;
}) => ToolboxItem | ToolboxItem[];
// 拖拽扩展
getDraggable?: ({ editor }: { editor: Editor }) => DraggableItem | boolean;
}
```
#### 1. 顶部工具栏扩展
编辑器顶部功能区域内容的扩展,通常用于增加用户常用操作,例如文本加粗、变更颜色等。
![顶部工具栏扩展](/img/developer-guide/plugin/api-reference/ui/extension-points/default-editor-extension-toolbar.png)
<https://github.com/halo-sigs/richtext-editor/pull/16> 中,我们实现了对顶部工具栏的扩展,如果需要添加额外的功能,只需要在具体的 Tiptap Extension 中的 `addOptions` 中定义 `getToolbarItems` 函数即可,如:
```ts
{
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return []
},
};
},
}
```
其中 `getToolbarItems` 即为对顶部工具栏的扩展。其返回类型为:
```ts
// 顶部工具栏扩展
getToolbarItems?: ({
editor,
}: {
editor: Editor;
}) => ToolbarItem | ToolbarItem[];
// 工具栏
export interface ToolbarItem {
priority: number;
component: Component;
props: {
editor: Editor;
isActive: boolean;
disabled?: boolean;
icon?: Component;
title?: string;
action?: () => void;
};
children?: ToolbarItem[];
}
```
如下为 [`Bold`](https://github.com/halo-dev/halo/blob/main/console/packages/editor/src/extensions/bold/index.ts) 扩展中对于 `getToolbarItems` 的扩展示例:
```ts
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: Editor }) {
return {
priority: 40,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: editor.isActive("bold"),
icon: markRaw(MdiFormatBold),
title: i18n.global.t("editor.common.bold"),
action: () => editor.chain().focus().toggleBold().run(),
},
};
},
};
},
```
#### 2. 工具箱扩展
编辑器工具箱区域的扩展,可用于增加编辑器附属操作,例如插入表格,插入第三方组件等功能。
![工具箱扩展](/img/developer-guide/plugin/api-reference/ui/extension-points/default-editor-extension-toolbox.png)
<https://github.com/halo-sigs/richtext-editor/pull/27> 中,我们实现了对编辑器工具箱区域的扩展,如果需要添加额外的功能,只需要在具体的 Tiptap Extension 中的 `addOptions` 中定义 `getToolboxItems` 函数即可,如:
```ts
{
addOptions() {
return {
...this.parent?.(),
getToolboxItems({ editor }: { editor: Editor }) {
return []
},
};
},
}
```
其中 `getToolboxItems` 即为对工具箱的扩展。其返回类型为:
```ts
// 工具箱扩展
getToolboxItems?: ({
editor,
}: {
editor: Editor;
}) => ToolboxItem | ToolboxItem[];
export interface ToolboxItem {
priority: number;
component: Component;
props: {
editor: Editor;
icon?: Component;
title?: string;
description?: string;
action?: () => void;
};
}
```
如下为 [`Table`](https://github.com/halo-dev/halo/blob/main/console/packages/editor/src/extensions/table/index.ts) 扩展中对于 `getToolboxItems` 工具箱的扩展示例:
```ts
addOptions() {
return {
...this.parent?.(),
getToolboxItems({ editor }: { editor: Editor }) {
return {
priority: 15,
component: markRaw(ToolboxItem),
props: {
editor,
icon: markRaw(MdiTablePlus),
title: i18n.global.t("editor.menus.table.add"),
action: () =>
editor
.chain()
.focus()
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run(),
},
};
},
}
}
```
#### 3. Slash Command 扩展
Slash Command (斜杠命令)的扩展,可用于在当前行快捷执行功能操作,例如转换当前行为标题、在当前行添加代码块等功能。
![Slash Command 扩展](/img/developer-guide/plugin/api-reference/ui/extension-points/default-editor-extension-slash-command.png)
<https://github.com/halo-sigs/richtext-editor/pull/16> 中,我们实现了对 Slash Command 指令的扩展,如果需要添加额外的功能,只需要在具体的 Tiptap Extension 中的 `addOptions` 中定义 `getCommandMenuItems` 函数即可,如:
```ts
{
addOptions() {
return {
...this.parent?.(),
getCommandMenuItems() {
return []
},
};
},
}
```
其中 `getCommandMenuItems` 即为对工具箱的扩展。其返回类型为:
```ts
// Slash Command 扩展
getCommandMenuItems?: () => CommandMenuItem | CommandMenuItem[];
export interface CommandMenuItem {
priority: number;
icon: Component;
title: string;
keywords: string[];
command: ({ editor, range }: { editor: Editor; range: Range }) => void;
}
```
如下为 [`Table`](https://github.com/halo-dev/halo/blob/main/console/packages/editor/src/extensions/table/index.ts) 扩展中对于 `getCommandMenuItems` 的扩展示例:
```ts
addOptions() {
return {
...this.parent?.(),
getCommandMenuItems() {
return {
priority: 120,
icon: markRaw(MdiTable),
title: "editor.extensions.commands_menu.table",
keywords: ["table", "biaoge"],
command: ({ editor, range }: { editor: Editor; range: Range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run();
},
};
},
}
}
```
#### 4. 悬浮菜单扩展
编辑器悬浮菜单的扩展。可用于支持目标元素组件的功能扩展及操作简化。例如 `Table` 扩展中的添加下一列、添加上一列等操作。
![悬浮菜单扩展](/img/developer-guide/plugin/api-reference/ui/extension-points/default-editor-extension-bubble-menu.png)
<https://github.com/halo-sigs/richtext-editor/pull/38> 中,我们重构了对编辑器悬浮区域的扩展,如果需要对某个块进行支持,只需要在具体的 Tiptap Extension 中的 `addOptions` 中定义 `getBubbleMenu` 函数即可,如:
```ts
{
addOptions() {
return {
...this.parent?.(),
getBubbleMenu({ editor }: { editor: Editor }) {
return []
},
};
},
}
```
其中 `getBubbleMenu` 即为对悬浮菜单的扩展。其返回类型为:
```ts
// 悬浮菜单扩展
getBubbleMenu?: ({ editor }: { editor: Editor }) => NodeBubbleMenu;
interface BubbleMenuProps {
pluginKey?: string; // 悬浮菜单插件 Key建议命名方式 xxxBubbleMenu
editor?: Editor;
shouldShow: (props: { // 悬浮菜单显示的条件
editor: Editor;
node?: HTMLElement;
view?: EditorView;
state?: EditorState;
oldState?: EditorState;
from?: number;
to?: number;
}) => boolean;
tippyOptions?: Record<string, unknown>; // 可自由定制悬浮菜单所用的 tippy 组件的选项
getRenderContainer?: (node: HTMLElement) => HTMLElement; // 悬浮菜单所基准的 DOM
defaultAnimation?: boolean; // 是否启用默认动画。默认为 true
}
// 悬浮菜单
export interface NodeBubbleMenu extends BubbleMenuProps {
component?: Component; // 不使用默认的样式,与 items 二选一
items?: BubbleItem[]; // 悬浮菜单子项,使用默认的形式进行,与 items 二选一
}
// 悬浮菜单子项
export interface BubbleItem {
priority: number; // 优先级,数字越小优先级越大,越靠前
component?: Component; // 完全自定义子项样式
props: {
isActive: ({ editor }: { editor: Editor }) => boolean; // 当前功能是否已经处于活动状态
visible?: ({ editor }: { editor: Editor }) => boolean; // 是否显示当前子项
icon?: Component; // 图标
iconStyle?: string; // 图标自定义样式
title?: string; // 标题
action?: ({ editor }: { editor: Editor }) => Component | void; // 点击子项后的操作,如果返回 Component则会将其包含在下拉框中。
};
}
```
如下为 [`Table`](https://github.com/halo-dev/halo/blob/main/console/packages/editor/src/extensions/table/index.ts) 扩展中对于 `getBubbleMenu` 悬浮菜单的部分扩展示例:
```ts
addOptions() {
return {
...this.parent?.(),
getBubbleMenu({ editor }) {
return {
pluginKey: "tableBubbleMenu",
shouldShow: ({ state }: { state: EditorState }): boolean => {
return isActive(state, Table.name);
},
getRenderContainer(node) {
let container = node;
if (container.nodeName === "#text") {
container = node.parentElement as HTMLElement;
}
while (
container &&
container.classList &&
!container.classList.contains("tableWrapper")
) {
container = container.parentElement as HTMLElement;
}
return container;
},
tippyOptions: {
offset: [26, 0],
},
items: [
{
priority: 10,
props: {
icon: markRaw(MdiTableColumnPlusBefore),
title: i18n.global.t("editor.menus.table.add_column_before"),
action: () => editor.chain().focus().addColumnBefore().run(),
},
},
]
}
}
}
}
```
#### 5. 拖拽功能扩展
拖拽功能的扩展,可用于支持当前块元素的拖拽功能。
![拖拽功能扩展](/img/developer-guide/plugin/api-reference/ui/extension-points/default-editor-extension-drag.png)
<https://github.com/halo-sigs/richtext-editor/pull/48> 中,我们实现了对所有元素的拖拽功能,如果需要让当前扩展支持拖拽,只需要在具体的 Tiptap Extension 中的 `addOptions` 中定义 `getDraggable` 函数,并让其返回 true 即可。如:
```ts
{
addOptions() {
return {
...this.parent?.(),
getDraggable() {
return true;
},
};
},
}
```
其中 `getDraggable` 即为为当前扩展增加可拖拽的功能。其返回类型为:
```ts
// 拖拽扩展
getDraggable?: ({ editor }: { editor: Editor }) => DraggableItem | boolean;
export interface DraggableItem {
getRenderContainer?: ({ // 拖拽按钮计算偏移位置的基准 DOM
dom,
view,
}: {
dom: HTMLElement;
view: EditorView;
}) => DragSelectionNode;
handleDrop?: ({ // 完成拖拽功能之后的处理。返回 true 则会阻止拖拽的发生
view,
event,
slice,
insertPos,
node,
selection,
}: {
view: EditorView;
event: DragEvent;
slice: Slice;
insertPos: number;
node: Node;
selection: Selection;
}) => boolean | void;
allowPropagationDownward?: boolean; // 是否允许拖拽事件向内部传播,
}
export interface DragSelectionNode {
$pos?: ResolvedPos;
node?: Node;
el: HTMLElement;
nodeOffset?: number;
dragDomOffset?: {
x: number;
y: number;
};
}
```
> 拖拽会从父 Node 节点开始触发,直到找到一个实现 `getDraggable` 的扩展,如果没有找到,则不会触发拖拽事件。父 Node 可以通过 `allowPropagationDownward` 来控制是否允许拖拽事件向内部传播。如果 `allowPropagationDownward` 设置为 true则会继续向内部寻找实现 `getDraggable` 的扩展,如果没有找到,则触发父 Node 的 `getDraggable` 实现,否则继续进行传播。
如下为 [`Iframe`](https://github.com/halo-dev/halo/blob/main/console/packages/editor/src/extensions/iframe/index.ts) 扩展中对于 `getDraggable` 拖拽功能的扩展示例:
```ts
addOptions() {
return {
...this.parent?.(),
getDraggable() {
return {
getRenderContainer({ dom, view }) {
let container = dom;
while (
container.parentElement &&
container.parentElement.tagName !== "P"
) {
container = container.parentElement;
}
if (container) {
container = container.firstElementChild
?.firstElementChild as HTMLElement;
}
let node;
if (container.firstElementChild) {
const pos = view.posAtDOM(container.firstElementChild, 0);
const $pos = view.state.doc.resolve(pos);
node = $pos.node();
}
return {
node: node,
el: container as HTMLElement,
};
},
};
},
}
}
```
## 实现案例
- <https://github.com/halo-sigs/plugin-hybrid-edit-block>
- <https://github.com/halo-sigs/plugin-katex>
- <https://github.com/halo-sigs/plugin-text-diagram>

@ -0,0 +1,188 @@
---
title: 编辑器集成
description: 通过实现扩展点为文章提供新的编辑器 - editor:create
---
此扩展点可以为文章提供新的独立编辑器。
![编辑器集成](/img/developer-guide/plugin/api-reference/ui/extension-points/editor-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"editor:create": (): EditorProvider[] | Promise<EditorProvider[]> => {
return [
{
name: "foo",
displayName: "foo",
logo: "/plugins/plugin-foo/assets/logo.png",
component: FooComponent,
rawType: "markdown",
},
];
},
},
});
```
```ts title="EditorProvider"
export interface EditorProvider {
name: string;
displayName: string;
logo?: string;
component: Component;
rawType: string;
}
```
其中,`component` 可以是组件对象或组件名称,且此组件有以下实现要求:
1. 组件包含以下 props
1. `title:string`:用于接收标题。
2. `raw:string`:用于接收原始内容。
3. `content:string`:用于接收渲染后的内容。
4. `uploadImage?: (file: File) => Promise<Attachment>`:用于上传图片,在编辑器内部获取到 File 之后直接调用此方法即可得到上传后的附件信息。
2. 组件包含以下 emit 事件:
1. `update:title`:用于更新标题。
2. `update:raw`:用于更新原始内容。
3. `update:content`:用于更新渲染后的内容。
4. `update`:发送更新事件。
## 示例
此示例将实现一个简单的 Markdown 编辑器。
```ts title="index.ts"
import { definePlugin } from "@halo-dev/console-shared";
import { markRaw } from "vue";
import MarkdownEditor from "./components/markdown-editor.vue";
export default definePlugin({
extensionPoints: {
"editor:create": () => {
return [
{
name: "markdown",
displayName: "Markdown 编辑器",
component: markRaw(MarkdownEditor),
rawType: "markdown",
logo: "/plugins/markdown-editor/assets/logo.png",
},
];
},
},
});
```
```html title="./components/markdown-editor.vue"
<script setup lang="ts">
import { marked } from "marked";
import { debounce } from "lodash-es";
import { ref, computed, onMounted } from "vue";
import type { Attachment } from "@halo-dev/api-client";
const props = withDefaults(
defineProps<{
raw: string;
content: string;
uploadImage?: (file: File) => Promise<Attachment>;
}>(),
{
raw: "",
content: "",
uploadImage: undefined,
}
);
const emit = defineEmits<{
(event: "update:raw", value: string): void;
(event: "update:content", value: string): void;
(event: "update", value: string): void;
}>();
const output = computed(() => marked(props.raw));
const update = debounce((e) => {
emit("update:raw", e.target.value);
emit("update:content", marked(e.target.value));
if (e.target.value !== props.raw) {
emit("update", e.target.value);
}
}, 100);
const textareaRef = ref();
onMounted(() => {
textareaRef.value.addEventListener("paste", (e) => {
if (e.clipboardData && e.clipboardData.items) {
const items = e.clipboardData.items;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf("image") !== -1) {
const file = items[i].getAsFile();
props.uploadImage?.(file).then((attachment: Attachment) => {
emit(
"update:raw",
props.raw +
`![${attachment.spec.displayName}](${attachment.status?.permalink})`
);
});
}
}
}
});
});
</script>
<template>
<div class="editor">
<textarea ref="textareaRef" class="input" :value="raw" @input="update"></textarea>
<div class="output" v-html="output"></div>
</div>
</template>
<style>
body {
margin: 0;
}
.editor {
height: 100vh;
display: flex;
}
.input,
.output {
overflow: auto;
width: 50%;
height: 100%;
box-sizing: border-box;
padding: 0 20px;
}
.input {
border: none;
border-right: 1px solid #ccc;
resize: none;
outline: none;
background-color: #f6f6f6;
font-size: 14px;
font-family: 'Monaco', courier, monospace;
padding: 20px;
}
code {
color: #f66;
}
</style>
```
> 来源:<https://vuejs.org/examples/#markdown>
## 实现案例
- <https://github.com/halo-sigs/plugin-stackedit>
- <https://github.com/halo-sigs/plugin-bytemd>
- <https://github.com/justice2001/halo-plugin-vditor>

@ -0,0 +1,14 @@
---
title: 扩展点
description: Halo UI 为插件提供的扩展点接口
---
UI 扩展点是用于扩展 Console 和 UC 的界面的接口,通过实现扩展点接口,插件可以在 Console 和 UC 中扩展功能。
以下是目前已支持的扩展点列表:
```mdx-code-block
import DocCardList from '@theme/DocCardList';
<DocCardList />
```

@ -0,0 +1,25 @@
```ts
export interface Attachment {
apiVersion: "storage.halo.run/v1alpha1"
kind: "Attachment"
metadata: {
annotations: {}
creationTimestamp: string
labels: {}
name: string // 附件的唯一标识
version: number
}
spec: {
displayName: string // 附件名称
groupName: string // 附件分组
mediaType: string // 附件类型
ownerName: string // 附件上传者
policyName: string // 附件存储策略
size: number // 附件大小
tags: Array<string>
}
status: {
permalink: string // 附件固定访问地址
}
}
```

@ -0,0 +1,119 @@
```ts
export interface ListedPost {
categories: Array<{ // 文章的分类集合
apiVersion: "content.halo.run/v1alpha1";
kind: "Category";
metadata: {
annotations: {};
creationTimestamp: string;
labels: {};
name: string; // 分类的唯一标识
version: number;
};
spec: {
children: Array<string>; // 子分类
cover: string; // 分类封面图
description: string; // 分类描述
displayName: string; // 分类名称
priority: number; // 分类优先级
slug: string; // 分类别名
template: string; // 分类渲染模板
};
status: {
permalink: string; // 分类的永久链接
postCount: number; // 分类下的文章总数
visiblePostCount: number; // 分类下可见的文章总数
};
}>;
contributors: Array<{ // 文章的贡献者集合
avatar: string; // 贡献者头像
displayName: string; // 贡献者名称
name: string; // 贡献者唯一标识
}>;
owner: { // 文章的作者信息
avatar: string; // 作者头像
displayName: string; // 作者名称
name: string; // 作者唯一标识
};
post: { // 文章信息
apiVersion: "content.halo.run/v1alpha1";
kind: "Post";
metadata: {
annotations: {};
creationTimestamp: string;
labels: {};
name: string; // 文章的唯一标识
version: number;
};
spec: {
allowComment: boolean; // 是否允许评论
baseSnapshot: string; // 内容基础快照
categories: Array<string>; // 文章所属分类
cover: string; // 文章封面图
deleted: boolean; // 是否已删除
excerpt: { // 文章摘要
autoGenerate: boolean; // 是否自动生成
raw: string; // 摘要内容
};
headSnapshot: string; // 内容最新快照
htmlMetas: Array<{}>;
owner: string; // 文章作者的唯一标识
pinned: boolean; // 是否置顶
priority: number; // 文章优先级
publish: boolean; // 是否发布
publishTime: string; // 发布时间
releaseSnapshot: string; // 已发布的内容快照
slug: string; // 文章别名
tags: Array<string>; // 文章所属标签
template: string; // 文章渲染模板
title: string; // 文章标题
visible: string; // 文章可见性
};
status: {
commentsCount: number; // 文章评论总数
conditions: Array<{
lastTransitionTime: string;
message: string;
reason: string;
status: string;
type: string;
}>;
contributors: Array<string>;
excerpt: string; // 最终的文章摘要,根据是否自动生成计算
inProgress: boolean; // 是否有未发布的内容
lastModifyTime: string; // 文章最后修改时间
permalink: string; // 文章的永久链接
phase: string;
};
};
stats: {
approvedComment: number; // 已审核的评论数
totalComment: number; // 评论总数
upvote: number; // 点赞数
visit: number; // 访问数
};
tags: Array<{ // 文章的标签集合
apiVersion: "content.halo.run/v1alpha1";
kind: "Tag";
metadata: {
annotations: {};
creationTimestamp: string;
labels: {};
name: string; // 标签的唯一标识
version: number;
};
spec: {
color: string; // 标签颜色
cover: string; // 标签封面图
displayName: string; // 标签名称
slug: string; // 标签别名
};
status: {
permalink: string; // 标签的永久链接
postCount: number; // 标签下的文章总数
visiblePostCount: number; // 标签下可见的文章总数
};
}>;
}
```

@ -0,0 +1,12 @@
```ts title="OperationItem"
export interface OperationItem<T> {
priority: number; // 排序优先级
component: Raw<Component>; // 菜单项组件
props?: Record<string, unknown>; // 菜单项组件属性
action?: (item?: T) => void; // 菜单项点击事件
label?: string; // 菜单项标题
hidden?: boolean; // 菜单项是否隐藏
permissions?: string[]; // 菜单项 UI 权限
children?: OperationItem<T>[]; // 子菜单项
}
```

@ -0,0 +1,50 @@
```ts
export interface Plugin {
apiVersion: "plugin.halo.run/v1alpha1"
kind: "Plugin"
metadata: {
annotations: {}
creationTimestamp: string // 创建时间
labels: {}
name: string // 唯一标识
version: number
}
spec: {
author: { // 作者信息
name: string
website: string
}
configMapName: string // 关联的 ConfigMap 模型,用于存储配置
description: string // 插件描述
displayName: string // 插件名称
enabled: boolean
homepage: string // 插件主页
license: Array<{ // 插件协议
name: string
url: string
}>
logo: string
pluginDependencies: {}
repo: string // 插件仓库地址
requires: string // 所依赖的 Halo 版本
settingName: string // 关联的 Setting 模型,用于渲染配置表单
version: string // 插件版本
}
status: {
conditions: Array<{
lastTransitionTime: string
message: string
reason: string
status: string
type: string
}>
entry: string
lastProbeState: string
lastStartTime: string
loadLocation: string
logo: string // 插件 Logo 地址
phase: string
stylesheet: string
}
}
```

@ -0,0 +1,63 @@
```ts
export interface Theme {
apiVersion: "theme.halo.run/v1alpha1"
kind: "Theme"
metadata: {
annotations: {}
creationTimestamp: string
labels: {}
name: string // 主题的唯一标识
version: number
}
spec: {
author: { // 主题作者信息
name: string
website: string
}
configMapName: string // 关联的 ConfigMap 模型,用于存储配置
customTemplates: { // 自定义模板信息
category: Array<{
description: string
file: string
name: string
screenshot: string
}>
page: Array<{
description: string
file: string
name: string
screenshot: string
}>
post: Array<{
description: string
file: string
name: string
screenshot: string
}>
}
description: string // 主题描述
displayName: string // 主题名称
homepage: string // 主题主页
license: Array<{ // 主题许可证信息
name: string
url: string
}>
logo: string // 主题 Logo
repo: string // 主题仓库地址
requires: string // 所依赖的 Halo 版本
settingName: string // 关联的 Setting 模型,用于渲染配置表单
version: string // 主题版本
}
status: {
conditions: Array<{
lastTransitionTime: string
message: string
reason: string
status: string
type: string
}>
location: string
phase: string
}
}
```

@ -0,0 +1,44 @@
---
title: 插件安装界面选项卡
description: 扩展插件安装界面选项卡 - plugin:installation:tabs:create
---
目前 Halo 原生支持本地上传和远程下载的方式安装插件,此扩展点用于扩展插件安装界面的选项卡,以支持更多的安装方式。
![插件安装界面选项卡](/img/developer-guide/plugin/api-reference/ui/extension-points/plugin-installation-tabs-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"plugin:installation:tabs:create": (): PluginInstallationTab[] | Promise<PluginInstallationTab[]> => {
return [
{
id: "foo",
label: "foo",
component: markRaw(FooComponent),
props: {},
permissions: [],
priority: 0,
}
];
},
},
});
```
```ts title="PluginInstallationTab"
export interface PluginInstallationTab {
id: string; // 选项卡 ID
label: string; // 选项卡标题
component: Raw<Component>; // 选项卡面板组件
props?: Record<string, unknown>; // 选项卡面板组件属性
permissions?: string[]; // 选项卡 UI 权限
priority: number; // 选项卡排序优先级
}
```
## 实现案例
- <https://github.com/halo-dev/plugin-app-store>

@ -0,0 +1,84 @@
---
title: 插件数据列表显示字段
description: 扩展插件数据列表显示字段 - plugin:list-item:field:create
---
此扩展点用于扩展插件数据列表的显示字段。
![插件数据列表显示字段](/img/developer-guide/plugin/api-reference/ui/extension-points/plugin-list-item-field-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"plugin:list-item:field:create": (plugin: Ref<Plugin>): EntityFieldItem[] | Promise<EntityFieldItem[]> => {
return [
{
priority: 0,
position: "start",
component: markRaw(FooComponent),
props: {},
permissions: [],
hidden: false,
}
];
},
},
});
```
```ts title="EntityFieldItem"
export interface EntityFieldItem {
priority: number;
position: "start" | "end";
component: Raw<Component>;
props?: Record<string, unknown>;
permissions?: string[];
hidden?: boolean;
}
```
## 示例
此示例将添加一个显示插件 requires版本要求的字段。
```ts
import { definePlugin } from "@halo-dev/console-shared";
import { markRaw, type Ref } from "vue";
import type { Plugin } from "@halo-dev/api-client";
import { VEntityField } from "@halo-dev/components";
export default definePlugin({
extensionPoints: {
"plugin:list-item:field:create": (plugin: Ref<Plugin>) => {
return [
{
priority: 0,
position: "end",
component: markRaw(VEntityField),
props: {
description: plugin.value.spec.requires,
},
permissions: [],
hidden: false,
},
];
},
},
});
```
## 实现案例
- <https://github.com/halo-dev/plugin-app-store>
## 类型定义
### Plugin
```mdx-code-block
import Plugin from "./interface/Plugin.md";
<Plugin />
```

@ -0,0 +1,55 @@
---
title: 插件数据列表操作菜单
description: 扩展插件数据列表操作菜单 - plugin:list-item:operation:create
---
此扩展点用于扩展插件数据列表的操作菜单项。
![插件数据列表操作菜单](/img/developer-guide/plugin/api-reference/ui/extension-points/plugin-list-item-operation-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"plugin:list-item:operation:create": (
plugin: Ref<Plugin>
): OperationItem<Plugin>[] | Promise<OperationItem<Plugin>[]> => {
return [
{
priority: 10,
component: markRaw(VDropdownItem),
props: {},
action: (item?: Plugin) => {
// do something
},
label: "foo",
hidden: false,
permissions: [],
children: [],
},
];
},
},
});
```
```mdx-code-block
import OperationItem from "./interface/OperationItem.md";
<OperationItem />
```
## 实现案例
- <https://github.com/halo-dev/plugin-app-store>
## 类型定义
### Plugin
```mdx-code-block
import Plugin from "./interface/Plugin.md";
<Plugin />
```

@ -0,0 +1,90 @@
---
title: 插件详情选项卡
description: 扩展当前插件的详情选项卡 - plugin:self:tabs:create
---
此扩展点用于在 Console 的插件详情页面中添加自定义选项卡,可以用于自定义插件的配置页面。
![插件详情选项卡](/img/developer-guide/plugin/api-reference/ui/extension-points/plugin-self-tabs-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"plugin:self:tabs:create": (): PluginTab[] | Promise<PluginTab[]> => {
return [
{
id: "foo",
label: "foo",
component: markRaw(FooComponent),
permissions: [],
},
];
},
},
});
```
```ts title="PluginTab"
export interface PluginTab {
id: string; // 选项卡 ID不能与设置表单的 group 重复
label: string; // 选项卡标题
component: Raw<Component>; // 选项卡面板组件
permissions?: string[]; // 选项卡权限
}
```
其中,`component` 组件可以注入inject以下属性
- `plugin`:当前插件对象,类型为 Ref<[Plugin](#plugin)>。
## 示例
此示例实现了一个自定义选项卡,用于获取插件的数据并显示名称。
```ts
import { definePlugin, PluginTab } from "@halo-dev/console-shared";
import MyComponent from "./views/my-component.vue";
import { markRaw } from "vue";
export default definePlugin({
components: {},
routes: [],
extensionPoints: {
"plugin:self:tabs:create": () : PluginTab[] => {
return [
{
id: "my-tab-panel",
label: "My Tab Panel",
component: markRaw(MyComponent),
permissions: []
},
];
},
},
});
```
```html title="./views/my-component.vue"
<script lang="ts" setup>
const plugin = inject<Ref<Plugin | undefined>>("plugin");
</script>
<template>
<h1>{{ plugin?.spec.displayName }}</h1>
</template>
```
## 实现案例
- <https://github.com/halo-dev/plugin-app-store>
## 类型定义
### Plugin
```mdx-code-block
import Plugin from "./interface/Plugin.md";
<Plugin />
```

@ -0,0 +1,80 @@
---
title: 文章数据列表显示字段
description: 扩展文章数据列表显示字段 - plugin:list-item:field:create
---
此扩展点用于扩展文章数据列表的显示字段。
![文章数据列表显示字段](/img/developer-guide/plugin/api-reference/ui/extension-points/post-list-item-field-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"post:list-item:field:create": (post: Ref<ListedPost>): EntityFieldItem[] | Promise<EntityFieldItem[]> => {
return [
{
priority: 0,
position: "start",
component: markRaw(FooComponent),
props: {},
permissions: [],
hidden: false,
}
];
},
},
});
```
```ts title="EntityFieldItem"
export interface EntityFieldItem {
priority: number;
position: "start" | "end";
component: Raw<Component>;
props?: Record<string, unknown>;
permissions?: string[];
hidden?: boolean;
}
```
## 示例
此示例将添加一个显示文章 slug别名的字段。
```ts
import { definePlugin } from "@halo-dev/console-shared";
import { markRaw, type Ref } from "vue";
import type { ListedPost } from "@halo-dev/api-client";
import { VEntityField } from "@halo-dev/components";
export default definePlugin({
extensionPoints: {
"post:list-item:field:create": (post: Ref<ListedPost>) => {
return [
{
priority: 0,
position: "end",
component: markRaw(VEntityField),
props: {
description: post.value.post.spec.slug,
},
permissions: [],
hidden: false,
},
];
},
},
});
```
## 类型定义
### ListedPost
```mdx-code-block
import ListedPost from "./interface/ListedPost.md";
<ListedPost />
```

@ -0,0 +1,92 @@
---
title: 文章数据列表操作菜单
description: 扩展文章数据列表操作菜单 - post:list-item:operation:create
---
此扩展点用于扩展文章数据列表的操作菜单项。
![文章数据列表操作菜单](/img/developer-guide/plugin/api-reference/ui/extension-points/post-list-item-operation-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"post:list-item:operation:create": (
post: Ref<ListedPost>
): OperationItem<ListedPost>[] | Promise<OperationItem<ListedPost>[]> => {
return [
{
priority: 10,
component: markRaw(VDropdownItem),
props: {},
action: (item?: ListedPost) => {
// do something
},
label: "foo",
hidden: false,
permissions: [],
children: [],
},
];
},
},
});
```
```mdx-code-block
import OperationItem from "./interface/OperationItem.md";
<OperationItem />
```
## 示例
此示例将实现一个操作菜单项,点击后会将文章内容作为文件下载到本地。
```ts
import type { ListedPost } from "@halo-dev/api-client";
import { VDropdownItem } from "@halo-dev/components";
import { definePlugin } from "@halo-dev/console-shared";
import axios from "axios";
import { markRaw } from "vue";
export default definePlugin({
extensionPoints: {
"post:list-item:operation:create": () => {
return [
{
priority: 21,
component: markRaw(VDropdownItem),
label: "下载到本地",
visible: true,
permissions: [],
action: async (post: ListedPost) => {
const { data } = await axios.get(
`/apis/api.console.halo.run/v1alpha1/posts/${post.post.metadata.name}/head-content`
);
const blob = new Blob([data.raw], {
type: "text/plain;charset=utf-8",
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${post.post.spec.title}.${data.rawType}`;
link.click();
},
},
];
},
},
});
```
## 类型定义
### ListedPost
```mdx-code-block
import ListedPost from "./interface/ListedPost.md";
<ListedPost />
```

@ -0,0 +1,91 @@
---
title: 主题数据列表操作菜单
description: 扩展主题数据列表操作菜单 - theme:list-item:operation:create
---
此扩展点用于扩展主题数据列表的操作菜单项。
![主题数据列表操作菜单](/img/developer-guide/plugin/api-reference/ui/extension-points/theme-list-item-operation-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"theme:list-item:operation:create": (
theme: Ref<Theme>
): OperationItem<Theme>[] | Promise<OperationItem<Theme>[]> => {
return [
{
priority: 10,
component: markRaw(VDropdownItem),
props: {},
action: (item?: Theme) => {
// do something
},
label: "foo",
hidden: false,
permissions: [],
children: [],
},
];
},
},
});
```
```mdx-code-block
import OperationItem from "./interface/OperationItem.md";
<OperationItem />
```
## 示例
此示例将实现一个跳转到前台预览主题的操作菜单项。
```ts
import { definePlugin, type OperationItem } from "@halo-dev/console-shared";
import { VButton } from "@halo-dev/components";
import { markRaw, type Ref } from "vue";
import type { Theme } from "@halo-dev/api-client";
export default definePlugin({
extensionPoints: {
"theme:list-item:operation:create": (
theme: Ref<Theme>
): OperationItem<Theme>[] | Promise<OperationItem<Theme>[]> => {
return [
{
priority: 10,
component: markRaw(VButton),
props: {
size: "sm",
},
action: (item?: Theme) => {
window.open(`/?preview-theme=${item?.metadata.name}`);
},
label: "前台预览",
hidden: false,
permissions: [],
children: [],
},
];
},
},
});
```
## 实现案例
- <https://github.com/halo-dev/plugin-app-store>
## 类型定义
### Theme
```mdx-code-block
import Theme from "./interface/Theme.md";
<Theme />
```

@ -0,0 +1,44 @@
---
title: 主题管理界面选项卡
description: 扩展主题管理界面选项卡 - theme:list:tabs:create
---
目前在 Halo 的主题管理中原生支持本地上传和远程下载的方式安装主题,此扩展点用于扩展主题管理界面的选项卡,以支持更多的安装方式。
![主题管理界面选项卡](/img/developer-guide/plugin/api-reference/ui/extension-points/theme-list-tabs-create.png)
## 定义方式
```ts
export default definePlugin({
extensionPoints: {
"theme:list:tabs:create": (): ThemeListTab[] | Promise<ThemeListTab[]> => {
return [
{
id: "foo",
label: "foo",
component: markRaw(FooComponent),
props: {},
permissions: [],
priority: 0,
}
];
},
},
});
```
```ts title="ThemeListTab"
export interface ThemeListTab {
id: string; // 选项卡 ID
label: string; // 选项卡标题
component: Raw<Component>; // 选项卡面板组件
props?: Record<string, unknown>; // 选项卡面板组件属性
permissions?: string[]; // 选项卡 UI 权限
priority: number; // 选项卡排序优先级
}
```
## 实现案例
- <https://github.com/halo-dev/plugin-app-store>

@ -0,0 +1,120 @@
---
title: 路由定义
description: 通过插件为 Console 控制台和 UC 个人中心添加新路由
---
Halo 为插件提供了为 Console 控制台和 UC 个人中心添加新路由的入口,可以用于为插件单独提供一个页面。
此文档将介绍如何定义路由以及侧边菜单项。
## 定义方式
Console 控制台和 UC 个人中心的路由定义基本和 Vue Router 官方的保持一致,为了区分 Console 控制台和 UC 个人中心的路由Halo 为插件提供了两个不同的路由定义入口。
- `routes`Console 控制台路由定义
- `ucRoutes`UC 个人中心路由定义
```ts
import HomeView from "./views/HomeView.vue"
import { IconComputer } from "@halo-dev/components";
export default definePlugin({
routes: [ // Console 控制台路由定义
{
parentName: "Root",
route: {
path: "/foo",
name: "Foo",
component: HomeView,
meta: {
permissions: [""],
menu: {
name: "Foo",
group: "content",
icon: markRaw(IconComputer),
priority: 40
},
},
},
},
],
ucRoutes: [ // UC 个人中心路由定义
{
parentName: "Root",
route: {
path: "/uc-foo",
name: "FooUC",
component: HomeView,
meta: {
permissions: [""],
menu: {
name: "FooUC",
group: "content",
icon: markRaw(IconComputer),
priority: 40
},
},
},
},
]
});
```
## 类型定义
```ts
{
routes?: RouteRecordRaw[] | RouteRecordAppend[];
ucRoutes?: RouteRecordRaw[] | RouteRecordAppend[];
}
```
```ts
export interface RouteRecordAppend {
parentName: RouteRecordName;
route: RouteRecordRaw;
}
```
- `parentName`:父路由名称,主要用于确认页面 Layout如果想要添加到顶级路由可以设置为 `Root`。如果不需要设置父路由,可以完全使用 `RouteRecordRaw` 定义。此外,如果同时设置了 `parentName` 以及其下路由设置了 `meta.menu`,那么此路由的菜单项将成为父菜单的子菜单项,可支持的父路由名称如下:
- Console
- `AttachmentsRoot`(附件)
- `CommentsRoot`(评论)
- `SinglePagesRoot`(页面)
- `PostsRoot`(文章)
- `MenusRoot`(菜单)
- `ThemeRoot`(主题)
- `OverviewRoot`(概览)
- `BackupRoot`(备份)
- `PluginsRoot`(插件)
- `SettingsRoot`(设置)
- `UsersRoot`(用户)
- `ToolsRoot`(工具)
- UC
- `PostsRoot`(文章)
- `NotificationsRoot`(消息)
:::info 提示
`RouteRecordRaw` 来自 Vue Router详见 [API 文档 | Vue Router](https://router.vuejs.org/zh/api/#Type-Aliases-RouteRecordRaw)
:::
此外,为了方便插件在 Console 控制台和 UC 个人中心添加菜单项等操作Halo 为 `RouteRecordRaw` 添加了 `meta` 属性,该属性为 `RouteMeta` 类型,定义如下:
```ts
interface RouteMeta {
title?: string; // 浏览器标题
searchable?: boolean; // 是否可以在 Console 的全局搜索中搜索到
permissions?: string[]; // UI 权限
menu?: { // 侧边菜单配置
name: string; // 菜单名称
group?: CoreMenuGroupId; // 内置菜单分组 ID如果不使用内置的分组也可以直接填写分组名称
icon?: Component; // 菜单图标,类型为 Vue 组件,推荐使用 https://github.com/unplugin/unplugin-icons
priority: number; // 菜单项排序,数字越小越靠前
mobile?: boolean; // 是否在移动端显示
};
}
```
```ts
export type CoreMenuGroupId = "dashboard" | "content" | "interface" | "system" | "tool";
```

@ -0,0 +1,4 @@
---
title: 附录
description: 附录
---

@ -0,0 +1,124 @@
---
title: Devtools
description: 了解 Halo 的 Devtools 插件开发工具的使用
---
Devtools 插件开发工具提供了一些 Task 用于辅助 Halo 插件的运行与调试,使用此工具的前提是需要具有 [Docker](https://docs.docker.com/get-docker/) 环境。
Devtools 还提供了一些其他的构建任务,如插件打包、插件检查等。
## 安装
Devtools 是使用 Java 开发的一个 [Gradle](https://gradle.org/) 插件,如果你使用的 [plugin-starter](https://github.com/halo-sigs/plugin-starter) 创建的插件项目,那么你无需任何操作,它已经默认集成了 Devtools 插件。
你可以在项目的 `build.gradle` 中找到它:
```groovy
plugins {
// ...
id "run.halo.plugin.devtools" version "0.0.7"
}
```
## 使用说明
当在项目中引入了 `devtools` 之后,就可以使用一些额外的构建任务:
- `haloServer`:此构建任务用于启动 Halo 服务并自动将依赖此 `devtools` 的 Halo 插件项目以开发模式加载到 Halo 服务中。
- `watch`:此构建任务用于监视 Halo 插件项目的变化并自动重新加载到 Halo 服务中
- `reloadPlugin`:此构建任务用于重载插件,如果你此时使用的是 `haloServer` 运行的插件,改动代码后可以运行此任务来重载插件代码使用新的改动被应用。
一个可能的使用场景:
正在开发 `plugin-starter` 插件,此时想测试插件的功能如看到默认提供的菜单项,你可以通过 `haloServer` 将插件运行起来:
```shell
./gradlew haloServer
```
看到如下日志时表示 Halo 服务已经启动成功:
```shell
Halo 初始化成功,访问: http://localhost:8090/console
用户名admin
密码admin
```
然后改动了某行代码需要使其生效,可以继续保持 `haloServer` 的运行,然后执行:
```shell
./gradlew reloadPlugin
```
来时新的改动应用到现有服务上。
但如果你使用的 `watch` 任务启动插件则不需要执行 `reloadPlugin` 任务,它会监听文件的改动自动重载插件。
## 配置
`build.gradle` 文件中作出配置可以更改 `devtools` 的行为:
```groovy
halo {
version = '2.9.1'
superAdminUsername = 'admin'
superAdminPassword = 'admin'
externalUrl = 'http://localhost:8090'
docker {
// windows 默认为 npipe:////./pipe/docker_engine
url = 'unix:///var/run/docker.sock'
apiVersion = '1.42'
}
port = 8090
debug = true
debugPort = 5005
}
```
`halo {}` 这个配置对象下面用于配置 Halo 服务器的一些信息,所有配置的默认值如上所示,你可以直接使用默认值而不进行任何配置。
- `version`:表示要使用的 Halo 版本,随着插件 API 的更新你可能需要更高的 Halo 版本来运行插件,可自行更改。
- `superAdminUsername` Halo 的超级管理员用户名,当你启动插件时会自动根据此配置和 `superAdminPassword` 为你初始化 Halo 的超级管理员账户。
- `superAdminPassword`Halo 的超级管理员用户密码。
- `externalUrl`Halo 的外部访问地址,一般默认即可,但如果修改了端口号映射可能需要修改。
- `docker.url`:用于配置连接 Docker 的 url 信息,在 Mac 或 Linux 系统上默认是 `unix:///var/run/docker.sock`,在 windows 上默认是 `npipe:////./pipe/docker_engine`
- `docker.apiVersion`Docker 的 API 版本,使用 `docker version` 命令可以查看到,如果你的 Docker 版本过低可能需要更改此配置,示例:
```shell
➤ docker version 11:38:06
Client:
Version: 24.0.7
API version: 1.43
```
- `port`Halo 服务的端口号,如果你的 Halo 服务端口号不想使用默认的 `8090` 或者想使用多个 Halo 服务,可以修改此配置。
- `debug`:是否开启调试模式,开启后会在启动 Halo 服务时会自动开启调试模式,此时你可以使用 IDE 连接到 Halo 服务进行调试。
- `debugPort`:调试模式下的调试端口号,默认是自动分配端口号,你可以修改此配置来固定调试端口号。
- `suspend`:是否在启动时挂起,如果开启则会在启动时挂起直到有调试器连接到 Halo 服务。
## 调试后端代码
如果你想调试后端代码,可以在 `build.gradle` 中配置
```groovy
halo {
debug = true
}
```
然后通过 IDEA 运行 `haloServer` 以便于配合 `IDEA` 进行调试。
![Use-Devtools](/img/developer-guide/plugin/use-devtools.png)
首先点击上图中 `1` 处的 `haloServer` 运行插件,然后点击 `2` 处的 `Attach debugger` 让 IDEA 连接到 Halo 服务,此时会打开一个调试窗口就可以开始打断点调试了。
可能会因为日志太快而点击不到 `Attach debugger`,那么你可以配置
```groovy
halo {
debug = true
suspend = true
}
```
这样,在点击 `haloServer` 启动插件时会挂起等待在 `Attach debugger` 处,直到你点击 `Attach debugger` 连接调试器后才会继续执行。

@ -0,0 +1,96 @@
---
title: Halo 架构概览
description: Halo 架构概览
---
Halo 是一个基于 Spring Boot 的 Java Web 应用Web 层不再使用 Servlet 技术,而是充分向异步和非阻塞的反应式编程靠拢,使用 Netty 作为 Web 服务器,使用 [Reactor](https://projectreactor.io/) 作为异步编程框架,使用 R2DBC 作为数据库访问框架,使用 WebFlux 作为 Web 层框架。
Halo 由以下几个核心模块组成:
- 安全模块:提供用户认证、授权、用户管理等功能。
- 插件模块:提供插件管理、插件加载、插件通信、扩展点等功能。
- 主题模块:提供主题管理、模板渲染、主题配置等功能。
- 内置内容管理模块:提供文章、分类、标签、评论、附件、页面、菜单、设置等功能。
## Halo 核心概念和 Extension
### 自定义模型 {#extension}
Extension 自定义模型提供了一种声明和管理数据模型的方法,它是 Halo 的核心概念之一。Halo 中的所有数据模型都是通过 Extension 来定义的,包括文章、分类、标签、评论、附件、页面、菜单、设置等,这便于插件系统可以灵活的进行数据模型的扩展,设计文档参考:[自定义模型设计](https://github.com/halo-dev/rfcs/tree/main/extension)。
每个自定义模型都有三大类属性metadata、spec、和 status。
1. metadata 用于标识自定义模型,每个自定义模型都至少有三个 metadata 属性name、creationTimestamp、version除此之外还有 labels 用于标识自定义模型的标签annotations 用于存放扩展信息deletionTimestamp 用于标识自定义模型是否被删除finalizers 用于标识自定义模型的是否可回收。
2. spec 描述用户期望达到的理想状态Desired State比如用户可以配置插件的 `spec.enable` 属性为 `true` 来启用插件或者为 `false` 来停用插件,这就是用户期望达到的理想状态,然后插件控制器会根据用户的期望状态来实现插件的启用或停用,它是声明式的,用户只需要声明期望状态,实际状态由具体的控制器来维护,最终达到用户期望的状态。
3. status 描述当前实际状态Actual State比如用户可以通过 `status.phase` 属性来查看插件启用进行到了哪一步,中间过程可能包含多个步骤,比如插件解析、加载、资源准备等,这些步骤都是由插件控制器来实现的,它是实际状态,只要插件控制器还在运行,它就会一直更新状态,最终达到用户期望的状态。
每个自定义模型注册后都会默认生成 CRUD APIs通过这些 APIs 就可以对自定义模型对象进行增删改查的操作,然后只需要编写控制器来实现自定义模型的业务逻辑即可,这就是 Halo 的异步编程模型。
### 控制器 {#controller}
在 Halo 中用户通过自定义模型定义资源的期望状态Controller 负责监视资源的实际状态当资源的实际状态和“期望状态”不一致时Controller 则对系统进行必要的更改以确保两者一致这个过程被称之为调谐Reconcile而实现调谐的逻辑被称之为 Reconciler。Reconciler 获取对象的名称并返回是否需要重试(例如发生一些错误),如果需要重试,则 Controller 会在稍后再次调用 Reconciler而这个过程会一直重复直到 Reconciler 返回成功为止这个过程被称之为调谐循环Reconciliation Loop
### 自定义模型生命周期 {#extension-lifecycle}
所有 Halo 的自定义模型对象都遵循一个共同的生命周期,可以将其视为状态机,尽管某些特定的自定义模型扩展了这一点并提供了更多状态。要编写正确的控制器,了解公共对象生命周期非常重要。
所有自定义模型对象都存在以下状态之一:
- `DOES_NOT_EXIST`Halo 不知道该对象。该状态不区分“尚未创建”和“已删除”。
- `ACTIVE`Halo 知道该对象并且该对象尚未被删除(未设置 `metadata.deletionTimestamp`。在此状态下任何更新操作PUT、PATCH、服务器端处理等都将导致相同的状态。
- `DELETING`Halo 知道该对象,该对象已被删除,但尚未完全删除。这可能是因为对象有一个或多个终结器(在 `metadata.finilizers` 中),客户端仍然可以访问该对象,并且可以看到它正在删除,因为设置了 `metadata.deleteTimestamp` 字段。当最后一个终结器被删除时,该对象将从存储中删除,并真正不存在。
下图描述了上述状态:
```text
+---- object
| updated
v |
+----------+ |
| +----+
object --------->| ACTIVE |
created | +-----------+
| +---+------+ |
| | |
| | |
+------------+---+ | |
| | object deleted |
| |<--- without finalizers |
| | object deleted
| DOES_NOT_EXIST | with finalizers
| | |
| |<--- finalizers removed |
| | | |
+----------------+ | |
| |
| |
+---+------+ |
| | |
| DELETING |<----------+
| |
+----------+
```
总结:自定义模型对象的删除并不是立即生效的,而是需要经过两个步骤,第一步是将对象的 `metadata.deletionTimestamp` 字段设置为当前时间,第二步是将对象的 `metadata.finalizers` 字段设置为空,这样对象才会真正被删除,第一步是由用户发起的,第二步是由 Halo 控制器发起的。
### Secret {#secret}
Secret 用于解决密码、token、密钥等敏感数据的配置问题而不需要把这些敏感数据暴露到自定义模型的 Spec 中,或 API 响应中。
### ConfigMap {#configmap}
ConfigMap 自定义模型用来保存 key-value pair 配置数据,这个数据可以在 Reconciler 里使用,或者被用来为插件或者主题存储配置数据。
虽然 ConfigMap 跟 Secret 类似,但是 ConfigMap 更方便的处理不含敏感信息的字符串。
### Setting {#setting}
Setting 自定义模型用于提供用户配置声明,用户可以通过 Setting 来声明一些模板需要的配置,比如主题设置、插件设置、系统设置等都可以通过 Setting 来声明,就能在 UI 层面提供配置入口,用户可以通过 UI 来配置这些设置,而不需要修改配置文件。
### 基于角色的访问控制RBAC{#rbac}
Halo 使用基于角色的访问控制Role-based Access ControlRBAC来控制用户对资源的访问权限RBAC 通过将角色分配给用户来实现访问控制,用户可以通过角色来访问资源,角色可以通过权限来访问资源。
RBAC 主要引入了角色Role和角色绑定RoleBinding的抽象概念插件可以通过定义角色来提供用户对资源的分配入口用户可以通过角色绑定来获取角色从而获取资源的访问权限。
而对于底层角色,用户分配起来比较麻烦,因此 Halo 提供了**角色模板**的概念,通过将角色标记为模板来使用一组功能相关的角色,如文章查看的角色可能必须包含标签和分类的查看才算是一组完整的功能,因此可以将文章查看的角色标记为模板,并依赖标签和分类的查看角色,这样用户就可以通过角色模板来获取一组功能相关的角色,而不需要一个一个的分配角色。

@ -0,0 +1,97 @@
---
title: 插件注册和配置
description: 了解插件定义文件 plugin.yaml 如何配置
---
一个典型的插件描述文件 plugin.yaml 如下所示:
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: Plugin
metadata:
name: hello-world
spec:
enabled: true
requires: ">=2.0.0"
author:
name: halo-dev
website: https://halo.run
logo: https://halo.run/logo
# settingName: hello-world-settings
# configMapName: hello-world-configmap
homepage: https://github.com/guqing/halo-plugin-hello-world
displayName: "插件 Hello world"
description: "插件开发的 hello world用于学习如何开发一个简单的 Halo 插件"
license:
- name: "MIT"
```
- `apiVersion``kind`:为固定写法,每个插件写法都是一样的不可变更。
- `metadata.name`:它是插件的唯一标识名,包含不超过 253 个字符,仅包含小写字母、数字或`-`,以字母或数字开头,以字母或数字结尾。
- `spec.enabled`:表示是否要在安装时自动启用插件,出于安全性考虑,仅在插件开发模式下有效,生产模式需要由用户手动启用。
- `spec.requires`:插件受支持的 Halo 版本,遵循 [Semantic Range Expressions](https://github.com/zafarkhaja/jsemver#range-expressions),以下是支持的符号及其解释的列表:
- 常规符号:`>`、`>=`、`<`、`<=`、`=`、`!=`
- 通配符范围 ( `*` | `X`| `x`)`1.*` 解释为 `>=1.0.0 && <2.0.0`
- 波形符范围 ( `~` )`~2.5`解释为 `>=1.5.0 && <1.6.0`
- 连字符范围 ( `-` )`0.0-2.0`解释为 `>=1.0.0 && <=2.0.0`
- 插入符范围 ( `^` )`^0.2.3`解释为 `>=0.2.3 && <0.3.0`
- 部分版本范围:`1` 解释为 `1.x``>=1.0.0 && <2.0.0`
- 否定运算符:`!(1.x)` 解释为 `<1.0.0 && >=2.0.0`
- 带括号的表达式:`~1.3 || (1.4.* && !=1.4.5) || ~2`
- `spec.author`:插件作者的名称和可获得支持的网站地址。
- `spec.logo`:插件 logo可以是域名或相对于项目 `src/main/resources` 目录的文件路径,如将 logo 放在 `src/main/resources/logo.png` 则配置为 `logo.png` 即可。
- `spec.settingName`:插件配置表单名称,对应一个 `Setting` 自定义模型资源文件,可为用户提供可视化的配置表单,参考:[表单定义](../../form-schema.md)。如果插件没有配置提供给用户则不需要配置此项,名称推荐为 "插件名-settings",命名规同 `metadata.name`
- `spec.configMapName`:表单定义对应的值标识名,它声明了插件的配置值将存储在哪个 ConfigMap 中,通常我们推荐命名为 "插件名-configmap",没有配置 `settingName` 则不需要配置此项,命名规同 `metadata.name`
:::tip
如果你在 plugin.yaml 中配置了 `settingName` 但确没有对应的 `Setting` 自定义模型资源文件,会导致插件无法启动,原因是 `Setting` 模型 `metadata.name` 为你配置的 `settingName` 的资源无法找到。
:::
- `spec.homepage`:通常为插件的 GitHub 仓库链接,或可联系到插件作者或插件官网或帮助中心链接等。
- `spec.displayName`:插件的显示名称,它通常是以少数几个字来概括插件的用途。
- `spec.description`:插件描述,用一段简短的说明来介绍插件的用途。
- `spec.license`:插件使用的软件协议,参考:<https://en.wikipedia.org/wiki/Software_license>
Halo 的插件可以在两种模式下运行:`development` 和 `deployment`
`deployment`(默认)模式是插件创建的标准工作流程:为每个插件创建一个新的 Gradle 项目,编码插件(声明新的扩展点和/或添加新的扩展),将插件打包成一个 JAR 文件,部署 JAR 文件到 Halo。
这些操作非常耗时,因此引入了 `development` 开发模式。
对于插件开发人员来说,`development` 运行模式的主要优点是不必打包和部署插件。在开发模式下,您可以以简单快速的流程快速开发插件。
### 配置
如果你想以 `deployment` 运行插件则参考 [传统方式运行](../hello-world.md#run-with-traditional-way) 做如下配置:
```yaml
halo:
plugin:
runtime-mode: deployment
```
`deployment` 是默认的运行模式,因此你可以不配置 `runtime-mode`
如果你想以 `development` 运行并开发插件则将 `runtime-mode` 修改为 `development`,同时配置 `fixed-plugin-path` 为插件项目绝对路径,可以配置多个。
```yaml
# macOS / Linux
plugin:
runtime-mode: development
fixed-plugin-path:
# 配置为插件绝对路径
- /path/to/halo-plugin-hello-world
# Windows
halo:
plugin:
runtime-mode: development
fixed-plugin-path:
# 配置为插件绝对路径
- C:\path\to\halo-plugin-hello-world
```
:::tip Note
1. `development` 开发模式下,既可以运行 `fixed-plugin-path` 下的插件,也可以运行通过 `Console` 管理端安装的 JAR 格式的插件。
2. 如果使用 [DevTools 运行方式](../hello-world.md#run-with-devtools) 来开发插件,则不需要配置 `runtime-mode``fixed-plugin-path`
:::

@ -0,0 +1,46 @@
---
title: 生命周期
description: 了解插件从启动到卸载的过程
---
根据[插件项目文件结构](../../basics/structure.md)所展示的 `StarterPlugin.java` 中,具有如下方法:
```java
@Override
public void start() {
System.out.println("插件启动成功!");
}
@Override
public void stop() {
System.out.println("插件停止!");
}
@Override
public void delete() {
System.out.println("插件被删除!");
}
```
它们就是插件的生命周期方法,分别对应插件的启动、停止和删除。
1. 继承 `run.halo.app.plugin.BasePlugin` 类后,你可以重写这些方法来干预插件的生命周期,例如在插件启动时初始化一些资源,在插件停止时清理掉这些资源。
2. 一个插件项目只允许有一个类继承 `BasePlugin` 类且标记为 Bean此时这个类将被作为插件的后端入口如果有多个类继承了 `BasePlugin` 会导致插件无法启动或生命周期方法无法被调用。
:::tip Note
如果一个类继承了 `BasePlugin` 类但没有标记为 Bean那么它将不会被 Halo 识别到,其中的生命周期方法也不会被调用。
:::
### 插件启动
插件被安装后,只加载了插件的 `plugin.yaml`,类及其他资源文件的加载均在启动时进行。
当插件加载完类文件并准备好启动插件后就会调用插件的 `start()` 方法,这有助于插件在启动时做一些事情,例如初始化。
### 插件停止
插件停止时,会删除在启动时创建的自定义资源,例如插件设置等通过 `yaml` 创建的自定义模型资源。
插件定义的自定义模型也需要在此时清理掉。
### 插件删除
插件被卸载时被调用。

@ -0,0 +1,57 @@
---
title: 插件中的对象管理
description: 了解如何在创建中创建对象和管理对象依赖
---
在插件中你可以使用 [Spring Framework](https://spring.io/projects/spring-framework/) 提供的常用 Bean 注解来标注一个类,然后就能使用依赖注入功能注入其他类的对象。这省去了使用工厂创建类和维护的过程,你可以像开发一个常规的 Spring 项目一样来开发插件,目前支持以下 Spring Framework 的特性:
1. [Core Technologies](https://docs.spring.io/spring-framework/reference/core.html)
2. [Web on Reactive](https://docs.spring.io/spring-framework/reference/web-reactive.html)
3. [Testing](https://docs.spring.io/spring-framework/reference/testing.html)
通过模板插件创建的项目中你会看到 `StarterPlugin` 标注了 `@Component` 注解:
```java
@Component
public class StarterPlugin extends BasePlugin {
}
```
假设项目中有一个 `FruitService`,并将其声明了为了 Bean
```java
@Service
public class FruitService {
}
```
你可以在任何同样声明为 Bean 的类中使用[依赖注入](https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#beans-dependencies)来使用它:
```java
@Component
public class Demo {
private final FruitService fruitService;
public Demo(FruitService fruitService) {
this.fruitService = fruitService;
}
// use it...
}
```
### 依赖注入 Halo 提供的 Bean
目前 Halo 只提供了少数几个 Bean 可以供插件依赖注入:
- run.halo.app.extension.ReactiveExtensionClient用于管理自定义模型对象的增删改查它是反应式的。
- run.halo.app.extension.ExtensionClient用于管理自定义模型对象的增删改查它是阻塞的只能用在非 NIO 线程中,如后台任务。
- run.halo.app.extension.SchemeManager用于管理自定义模型定义的注册和销毁。
- run.halo.app.infra.ExternalUrlSupplier用于获取用户配置的 Halo 外部访问地址。
- run.halo.app.core.extension.service.AttachmentService用于操作附件。
- run.halo.app.notification.NotificationReasonEmitter用于发送通知。
- run.halo.app.notification.NotificationCenter用于管理通知的订阅和取消订阅。
- run.halo.app.infra.ExternalLinkProcessor用于处理将一个相对地址转换为外部访问地址。
- org.springframework.security.web.server.context.ServerSecurityContextRepository用于获取操作用户的认证上下文信息如自动登录场景。
即其他不在上述列表中的类的对象都是不可依赖注入的。

@ -0,0 +1,69 @@
---
title: 插件项目结构
description: 了解插件项目的文件结构
---
新创建的插件项目典型的目录结构如下所示:
```text
├── ui
│ ├── src
│ │ ├── assets
│ │ │ └── logo.svg
│ │ ├── views
│ │ │ └── HomeView.vue
│ │ └── index.ts
│ ├── env.d.ts
│ ├── package.json
│ ├── pnpm-lock.yaml
│ ├── tsconfig.app.json
│ ├── tsconfig.config.json
│ ├── tsconfig.json
│ ├── tsconfig.vitest.json
│ └── vite.config.ts
├── gradle
├── src
│ └── main
│ ├── java
│ │ └── run
│ │ └── halo
│ │ └── starter
│ │ └── StarterPlugin.java
│ └── resources
│ ├── console
│ │ ├── main.js
│ │ └── style.css
│ └── plugin.yaml
├── LICENSE
├── README.md
├── build.gradle
├── gradle.properties
├── gradlew
├── gradlew.bat
└── settings.gradle
```
该目录包含了前端和后端两个部分,让我们依次看一下它们中的每一个。
### 后端部分
所有的后端代码都放在 `src` 目录下,它是一个常规的 `Java` 项目目录结构。
- `StarterPlugin.java` 为插件的后端入口示例文件,类名可以任意但它必须继承 `run.halo.app.plugin.BasePlugin` 类来标记它作为插件入口。
- `resources` 下的 `plugin.yaml` 为插件的资源描述文件,它是必须的,它描述了插件的基本信息,包括插件的名称、版本、作者、描述、依赖等。
- `resources/console` 下的两个文件 `main.js``style.css` 是前端插件部分打包时输出的产物。一个插件可以没有前端部分,因此 `resources/console` 同样可以不存在。
:::caution 注意
从 2.11 开始Halo 支持了 UC 个人中心,且个人中心和 Console 的插件机制共享,所以为了避免歧义,`resources/console` 在后续版本会被重命名为 `resources/ui`,但同时也会兼容 `resources/console`
:::
### 前端部分
`ui` 目录下为插件的前端部分的工程目录,包括了源码、配置文件和静态资源文件。
同样的,将所有前端项目源码放到 `src` 中。我们建议使用 `TypeScript` 作为编程语言,它可以帮助你在编译时而非运行时捕获错误。
- `src/index.ts` 作为前端部分的插件的入口文件。
- `views` 中存放视图文件。
- `styles` 中存放样式。
- `components` 中放一些公共组件。
- `assets` 用于放静态资源文件。

@ -0,0 +1,126 @@
---
title: 入口文件
description: UI 扩展部分的入口文件
---
入口文件即 Halo 核心会加载的文件,所有插件有且只有一个入口文件,构建之后会放置在插件项目的 `src/resources/console` 下,名为 `main.js`
为了方便开发者,我们已经在 [halo-dev/plugin-starter](https://github.com/halo-dev/plugin-starter) 配置好了基础项目结构,包括构建配置,后续文档也会以此为准。
## 定义入口文件
```ts title="ui/src/index.ts"
import { definePlugin } from "@halo-dev/console-shared";
export default definePlugin({
components: {},
routes: [],
ucRoutes: [],
extensionPoints: {}
});
```
## 类型定义
```ts
export function definePlugin(plugin: PluginModule): PluginModule {
return plugin;
}
```
```ts title="PluginModule"
import type { Component, Ref } from "vue";
import type { RouteRecordRaw, RouteRecordName } from "vue-router";
import type { FunctionalPage } from "../states/pages";
import type { AttachmentSelectProvider } from "../states/attachment-selector";
import type { EditorProvider, PluginTab } from "..";
import type { AnyExtension } from "@halo-dev/richtext-editor";
import type { CommentSubjectRefProvider } from "@/states/comment-subject-ref";
import type { BackupTab } from "@/states/backup";
import type { PluginInstallationTab } from "@/states/plugin-installation-tabs";
import type { EntityFieldItem } from "@/states/entity";
import type { OperationItem } from "@/states/operation";
import type { ThemeListTab } from "@/states/theme-list-tabs";
import type {
Attachment,
Backup,
ListedPost,
Plugin,
Theme,
} from "@halo-dev/api-client";
export interface RouteRecordAppend {
parentName: RouteRecordName;
route: RouteRecordRaw;
}
export interface ExtensionPoint {
// @deprecated
"page:functional:create"?: () => FunctionalPage[] | Promise<FunctionalPage[]>;
"attachment:selector:create"?: () =>
| AttachmentSelectProvider[]
| Promise<AttachmentSelectProvider[]>;
"editor:create"?: () => EditorProvider[] | Promise<EditorProvider[]>;
"plugin:self:tabs:create"?: () => PluginTab[] | Promise<PluginTab[]>;
"default:editor:extension:create"?: () =>
| AnyExtension[]
| Promise<AnyExtension[]>;
"comment:subject-ref:create"?: () => CommentSubjectRefProvider[];
"backup:tabs:create"?: () => BackupTab[] | Promise<BackupTab[]>;
"plugin:installation:tabs:create"?: () =>
| PluginInstallationTab[]
| Promise<PluginInstallationTab[]>;
"post:list-item:operation:create"?: (
post: Ref<ListedPost>
) => OperationItem<ListedPost>[] | Promise<OperationItem<ListedPost>[]>;
"plugin:list-item:operation:create"?: (
plugin: Ref<Plugin>
) => OperationItem<Plugin>[] | Promise<OperationItem<Plugin>[]>;
"backup:list-item:operation:create"?: (
backup: Ref<Backup>
) => OperationItem<Backup>[] | Promise<OperationItem<Backup>[]>;
"attachment:list-item:operation:create"?: (
attachment: Ref<Attachment>
) => OperationItem<Attachment>[] | Promise<OperationItem<Attachment>[]>;
"plugin:list-item:field:create"?: (
plugin: Ref<Plugin>
) => EntityFieldItem[] | Promise<EntityFieldItem[]>;
"post:list-item:field:create"?: (
post: Ref<ListedPost>
) => EntityFieldItem[] | Promise<EntityFieldItem[]>;
"theme:list:tabs:create"?: () => ThemeListTab[] | Promise<ThemeListTab[]>;
"theme:list-item:operation:create"?: (
theme: Ref<Theme>
) => OperationItem<Theme>[] | Promise<OperationItem<Theme>[]>;
}
export interface PluginModule {
components?: Record<string, Component>;
routes?: RouteRecordRaw[] | RouteRecordAppend[];
ucRoutes?: RouteRecordRaw[] | RouteRecordAppend[];
extensionPoints?: ExtensionPoint;
}
```
- `components`组件列表key 为组件名称value 为组件对象,在此定义之后,加载插件时会自动注册到 Vue App 全局。
- `routes`Console 控制台路由定义,详细文档可参考 [路由定义](../../api-reference/ui/route.md)
- `ucRoutes`UC 个人中心路由定义,详细文档可参考 [路由定义](../../api-reference/ui/route.md)
- `extensionPoints`:扩展点定义,详细文档可参考 [扩展点](../../api-reference/ui/extension-points)

@ -0,0 +1,12 @@
---
title: 介绍
description: 介绍插件 UI 部分的基础知识。
---
Halo 插件体系的 UI 部分可以让开发者在 Console 控制台和 UC 个人中心添加新的页面或者扩展已有的功能。
在开始之前,建议先熟悉或安装以下库和工具:
1. [Node.js 18+](https://nodejs.org)
2. [pnpm 8+](https://pnpm.io)
3. [Vue.js 3](https://vuejs.org)

@ -0,0 +1,598 @@
---
title: Todo List
description: 这个例子展示了如何开发 Todo List 插件
---
本示例用于展示如何从插件模板创建一个插件并写一个 Todo List
首先通过模板仓库创建一个插件,例如叫 `halo-plugin-todolist`
## 配置你的插件
1. 修改 `build.gradle` 中的 `group` 为你自己的,如:
```groovy
group = 'run.halo.tutorial'
```
2. 修改 `settings.gradle` 中的 `rootProject.name`
```groovy
rootProject.name = 'halo-plugin-todolist'
```
3. 修改插件的描述文件 `plugin.yaml`,它位于 `src/main/resources/plugin.yaml`。示例:
```yaml
apiVersion: plugin.halo.run/v1alpha1
kind: Plugin
metadata:
name: todolist
spec:
enabled: true
requires: ">=2.0.0"
author:
name: halo-dev
website: https://halo.run
logo: https://halo.run/logo
homepage: https://github.com/guqing/halo-plugin-hello-world
displayName: "插件 Todo List"
description: "插件开发的 hello world用于学习如何开发一个简单的 Halo 插件"
license:
- name: "MIT"
```
参考链接:
- [SemVer expression](https://github.com/zafarkhaja/jsemver#semver-expressions-api-ranges)
- [表单定义](../../form-schema.md)
此时我们已经准备好了可以开发一个 TodoList 插件的一切,下面让我们正式进入 TodoList 插件开发教程。
## 运行插件
为了看到效果,首先我们需要让插件能最简单的运行起来。
1. 在 `src/main/java` 下创建包,如 `run.halo.tutorial`,在创建一个类 `TodoListPlugin`,它继承自 `BasePlugin` 类内容如下:
```java
package run.halo.tutorial;
import org.pf4j.PluginWrapper;
import org.springframework.stereotype.Component;
import run.halo.app.plugin.BasePlugin;
@Component
public class TodoListPlugin extends BasePlugin {
public TodoListPlugin(PluginWrapper wrapper) {
super(wrapper);
}
}
```
`src/main/java` 下的文件结构如下:
```text
.
└── run
└── halo
└── tutorial
└── TodoListPlugin.java
```
2. 然后在项目目录执行命令
```shell
./gradlew build
```
3. 使用 `IntelliJ IDEA` 打开 Halo参考 [Halo 开发环境运行](../../core/run.md) 及 [插件入门](../hello-world.md) 配置插件的运行模式和路径:
```yaml
halo:
plugin:
runtime-mode: development
fixed-plugin-path:
# 配置为插件绝对路径
- /Users/guqing/halo-plugin-todolist
```
4. 启动 Halo然后访问 `http://localhost:8090/console`
在插件列表将能看到插件已经被正确启用:
![plugin-todolist-in-list-view](/img/todolist-in-list.png)
## 创建一个自定义模型
我们希望 TodoList 能够被持久化以避免重启后数据丢失,因此需要创建一个自定义模型来进行数据持久化。
首先创建一个 `class` 名为 `Todo` 并写入如下内容:
```java
package run.halo.tutorial;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
@Data
@EqualsAndHashCode(callSuper = true)
@GVK(kind = "Todo", group = "todo.plugin.halo.run",
version = "v1alpha1", singular = "todo", plural = "todos")
public class Todo extends AbstractExtension {
@Schema(requiredMode = REQUIRED)
private TodoSpec spec;
@Data
public static class TodoSpec {
@Schema(requiredMode = REQUIRED, minLength = 1)
private String title;
@Schema(defaultValue = "false")
private Boolean done;
}
}
```
然后在 `TodoListPlugin``start` 生命周期方法中注册此自定义模型到 Halo 中。
```diff
// ...
+ import run.halo.app.extension.SchemeManager;
@Component
public class TodoListPlugin extends BasePlugin {
+ private final SchemeManager schemeManager;
- public TodoListPlugin(PluginWrapper wrapper) {
+ public TodoListPlugin(PluginWrapper wrapper, SchemeManager schemeManager) {
super(wrapper);
+ this.schemeManager = schemeManager;
}
@Override
public void start() {
+ // 插件启动时注册自定义模型
+ schemeManager.register(Todo.class);
System.out.println("Hello world 插件启动了!");
}
@Override
public void stop() {
+ // 插件停用时取消注册自定义模型
+ Scheme todoScheme = schemeManager.get(Todo.class);
+ schemeManager.unregister(todoScheme);
System.out.println("Hello world 被停止!");
}
// ....
}
```
然后 build 项目,重启 Halo访问 `http://localhost:8090/swagger-ui.html`
可以找到如下 Todo APIs
![hello world plugin swagger api for toto](/img/halo-plugin-hello-world-todo-swagger-api.png)
由于所有以 `/api``/apis` 开头的 APIs 都需要认证才能访问,因此先在 Swagger UI 界面顶部点击 `Authorize` 认证,然后尝试访问
`GET /apis/todo.plugin.halo.run/v1alpha1/todos` 可以看到如下结果:
```json
{
"page": 0,
"size": 0,
"total": 0,
"items": [],
"first": true,
"last": true,
"hasNext": false,
"hasPrevious": false,
"totalPages": 1
}
```
至此我们完成了一个自定义模型的创建和使用插件生命周期方法实现了自定义模型的注册和删除,下一步我们将编写用户界面,使用这些 APIs 完成 TodoList 功能。
## 编写用户界面
### 目标
我们希望实现如下的用户界面:
- 在左侧菜单添加一个名为 `Todo List` 的菜单项,它属于一个`工具`的组。
- 内容页为一个简单的 Todo List它实现以下功能
- 添加 `Todo item`
- 将一个 `Todo item` 标记为完成,也可以取消完成状态
- 列表有三个 `Tab` 可供切换,用于过滤数据展示
![todo user interface](/img/todo-ui.png)
### 实现
使用模板仓库创建的项目中与 `src` 目录同级有一个 `console` 目录,它即为用户界面的源码目录。
在实现用户界面前我们需要先修改 `console/vite.config.ts` 中的 `pluginName``plugin.yaml` 中的 `metadata.name`,它用来标识此用户界面所属于插件名 pluginName 标识的插件,以便 Halo 加载 console 目录打包产生的文件。
修改完成后执行
```groovy
./gradlew build
```
修改前端项目不需要重启 Halo只需要 build 然后刷新页面,此时能看到多出来一个菜单项:
![hello-world-in-plugin-list](/img/plugin-hello-world.png)
而我们需要实现的目标中也需要一个菜单项,所以直接修改它即可。
打开 `console/src/index.ts` 文件,修改如下:
```diff
export default definePlugin({
- name: "PluginStarter",
+ name: "plugin-hello-world",
components: [],
routes: [
{
parentName: "Root",
route: {
- path: "/example",
+ path: "/todos", // TodoList 的路由 path
children: [
{
path: "",
- name: "Example",
+ name: "TodoList",// 菜单标识名
component: DefaultView,
meta: {
- permissions: ["plugin:apples:view"],
- title: "示例页面",
+ title: "Todo List",//菜单页的浏览器 tab 标题
searchable: true,
menu: {
- name: "示例页面",
+ name: "Todo List",// TODO 菜单显示名称
- group: "示例分组",
= group: "工具",// 所在组名
icon: markRaw(IconGrid),
priority: 0,
},
},
},
],
},
},
],
extensionPoints: {},
activated() {},
deactivated() {},
});
```
完成此步骤后 Console 左侧菜单多了一个名 `工具` 的组,其下有 `Todo List`,浏览器标签页名称也是 `Todo List`
接来下我们需要在右侧内容区域实现 [目标](#目标) 中图示的 Todo 样式,为了快速上手,我们使用 [todomvc](https://todomvc.com/examples/vue/) 提供的 Vue 标准实现。
编辑 `console/src/views/DefaultView.vue` 文件,清空它的内容,并拷贝 [examples/#todomvc](https://vuejs.org/examples/#todomvc) 的所有代码粘贴到此文件中,并执行以下步骤:
1. `cd console` 切换到 `console` 目录。
2. ` pnpm install todomvc-app-css `
3. 修改 `console/src/views/DefaultView.vue` 最底部的 `style` 标签。
```diff
- <style>
+ <style scoped>
- @import "https://unpkg.com/todomvc-app-css@2.4.1/index.css";
+ @import "todomvc-app-css/index.css";
</style>
```
4. 重新 Build 后刷新页面,便能看到目标图所示效果。
通过以上步骤就实现了一个 Todo List 的用户界面功能,但 `Todo` 数据只是被临时存放到了 `LocalStorage` 中,下一步我们将通过自定义模型生成的 APIs 来让用户界面与服务端交互。
### 与服务端数据交互
本章节我们将通过使用 `Axios` 来完成与插件后端 APIs 进行数据交互,文档参考 [axios-http](https://axios-http.com/docs)。
首先需要安装 `Axios` 在 console 目录下执行命令:
```shell
pnpm install axios
```
为了符合最佳实践,将用 TypeScript 改造之前的 todomvc 示例:
创建 types 文件 `console/src/types/index.ts`
```typescript
export interface Metadata {
name: string;
labels?: {
[key: string]: string;
} | null;
annotations?: {
[key: string]: string;
} | null;
version?: number | null;
creationTimestamp?: string | null;
deletionTimestamp?: string | null;
}
export interface TodoSpec {
title: string;
done?: boolean;
}
/**
* 与自定义模型 Todo 对应
*/
export interface Todo {
spec: TodoSpec;
apiVersion: "todo.plugin.halo.run/v1alpha1"; // apiVersion=自定义模型的 group/version
kind: "Todo"; // Todo 自定义模型中 @GVK 注解中的 kind
metadata: Metadata;
}
/**
* Todo 自定义模型生成 list API 所对应的类型
*/
export interface TodoList {
page: number;
size: number;
total: number;
items: Array<Todo>;
first: boolean;
last: boolean;
hasNext: boolean;
hasPrevious: boolean;
totalPages: number;
}
```
编辑 `console/src/views/DefaultView.vue` 文件,将所有内容替换为如下写法:
```typescript
<script setup lang="ts">
import axios from "axios";
import type { Todo, TodoList } from "../types";
import { computed, onMounted, ref } from "vue";
const http = axios.create({
baseURL: "/",
timeout: 1000,
});
interface Tab {
label: string;
}
const todos = ref<TodoList>({
page: 1,
size: 20,
total: 0,
items: [],
first: true,
last: false,
hasNext: false,
hasPrevious: false,
totalPages: 0,
});
const tabs = [
{
label: "All",
},
{
label: "Active",
},
{
label: "Completed",
},
];
const activeTab = ref("All");
/**
* 列表展示的数据
*/
const todoList = computed(() => {
if (activeTab.value === "All") {
return todos.value.items;
}
if (activeTab.value === "Active") {
return filterByDone(false);
}
if (activeTab.value === "Completed") {
return filterByDone(true);
}
return [];
});
const filterByDone = (done: boolean) => {
return todos.value.items.filter((todo) => todo.spec.done === done);
};
// 查看 http://localhost:8090/swagger-ui.html
function handleFetchTodos() {
http
.get<TodoList>("/apis/todo.plugin.halo.run/v1alpha1/todos")
.then((response) => {
todos.value = response.data;
});
}
onMounted(handleFetchTodos);
// 创建的逻辑
const title = ref("");
function handleCreate(e: Event) {
http
.post<Todo>("/apis/todo.plugin.halo.run/v1alpha1/todos", {
metadata: {
// 根据 'todo-' 前缀自动生成 todo 的名称作为唯一标识,可以理解为数据库自动生成主键 id
generateName: "todo-",
},
spec: {
title: title.value,
done: false,
},
kind: "Todo",
apiVersion: "todo.plugin.halo.run/v1alpha1",
})
.then((response) => {
title.value = "";
handleFetchTodos();
});
}
// 更新的逻辑
const selectedTodo = ref<Todo | undefined>();
const handleUpdate = () => {
http
.put<Todo>(
`/apis/todo.plugin.halo.run/v1alpha1/todos/${selectedTodo.value?.metadata.name}`,
selectedTodo.value
)
.then((response) => {
handleFetchTodos();
});
};
function handleDoneChange(todo: Todo) {
todo.spec.done = !todo.spec.done;
http
.put<Todo>(
`/apis/todo.plugin.halo.run/v1alpha1/todos/${todo.metadata.name}`,
todo
)
.then((response) => {
handleFetchTodos();
});
}
// 删除
const handleDelete = (todo: Todo) => {
http
.delete(`/apis/todo.plugin.halo.run/v1alpha1/todos/${todo.metadata.name}`)
.then((response) => {
handleFetchTodos();
});
};
</script>
<template>
<section class="todoapp">
<header class="header">
<h1>todos</h1>
<input
class="new-todo"
autofocus
v-model="title"
placeholder="What needs to be done?"
@keyup.enter="handleCreate"
/>
</header>
<section class="main" v-show="todos.items.length">
<input
id="toggle-all"
class="toggle-all"
type="checkbox"
:checked="filterByDone(false).length > 0"
/>
<label for="toggle-all">Mark all as complete</label>
<ul class="todo-list">
<li
v-for="(todo, index) in todoList"
class="todo"
:key="index"
:class="{ completed: todo.spec.done, editing: todo === selectedTodo }"
>
<div class="view">
<input
class="toggle"
type="checkbox"
:checked="todo.spec.done"
@click="handleDoneChange(todo)"
/>
<label @dblclick="selectedTodo = todo">{{ todo.spec.title }}</label>
<button class="destroy" @click="handleDelete(todo)"></button>
</div>
<input
v-if="selectedTodo"
class="edit"
type="text"
v-model="selectedTodo.spec.title"
@vnode-mounted="({ el }) => el.focus()"
@blur="handleUpdate()"
@keyup.enter="handleUpdate()"
@keyup.escape="selectedTodo = undefined"
/>
</li>
</ul>
</section>
<footer class="footer" v-show="todos.total">
<span class="todo-count">
<strong>{{ filterByDone(false).length }}</strong>
<span>
{{ filterByDone(false).length === 1 ? " item" : " items" }} left</span
>
</span>
<ul class="filters">
<li v-for="(tab, index) in tabs" :key="index">
<a
href="javascript:void(0);"
@click="activeTab = tab.label"
:class="{ selected: activeTab === tab.label }"
>
{{ tab.label }}
</a>
</li>
</ul>
<button
class="clear-completed"
@click="() => filterByDone(true).map((todo) => handleDelete(todo))"
v-show="todos.items.length > filterByDone(false).length"
>
Clear completed
</button>
</footer>
</section>
</template>
<style scoped>
@import "todomvc-app-css/index.css";
</style>
```
这在原先的基础上替换为了 `TypeScript` 写法,并去除了数据保存到 `LocalStorage` 的逻辑,这也是我们推荐的方式,可读性更强,且有 `TypeScript` 提供类型提示。
至此我们就完成了与插件后端 APIs 实现 Todo List 数据交互的部分。
### 用户界面使用静态资源
如果你想在用户界面中使用图片,你可以放到 `console/src/assets` 中,例如 `logo.png`,并将其作为 Todo 的 Logo 放到标题旁边。
需要修改 `console/src/views/DefaultView.vue` 示例如下:
```diff
+ import Logo from "../assets/logo.png";
// ...
<template>
<section class="todoapp">
<header class="header">
<h1>
+ <img :src="Logo" alt="logo" style="display: inline; width: 64px" />
todos
</h1>
//...
```
至此,我们完成了从零开始创建一个 TodoList 插件的所有步骤,希望可以帮助你对 Halo 的插件开发有一个整体的了解。

@ -0,0 +1,145 @@
---
title: 入门
description: 了解如何构建你的第一个插件并在 Halo 中使用它。
---
此文档将帮助你了解如何构建你的第一个插件并在 Halo 中安装和启用。
## 创建插件项目
1. 打开 [halo-dev/plugin-starter](https://github.com/halo-dev/plugin-starter)。
> 这是一个插件的初始模板,你可以基于它来开发自己的插件。
2. 点击 `Use this template` -> `Create a new repository`
3. 如图所示填写仓库名后点击 `Create repository from template`
![create-repository-for-hello-world-plugin](/img/create-repository-for-hello-world-plugin.png)
你现在已经基于 Halo 插件模板创建了自己的存储库。接下来,你需要将它克隆到你的计算机上。
```shell
# clone your repository
git clone https://github.com/<your-username>/halo-plugin-hello-world.git
# enter the directory
cd halo-plugin-hello-world
```
## 运行插件
现在有了一个空项目,我们需要让插件能最最小化的运行起来,这里提供两种运行方式。
### 使用 DevTools 运行(推荐){#run-with-devtools}
Halo 提供了一个用于插件开发的 DevTools它可以帮助你快速的运行和调试插件在模板插件项目中已经集成了 DevTools可查阅 [DevTools 使用说明](./basics/devtools.md)。
使用 DevTools 运行插件的前提是需要你的电脑上已经安装了 Docker 环境,这是我们推荐的用户开发时运行插件的方式,只需要执行以下命令即可。
1. 执行前端部分的依赖安装命令:
```shell
# macOS / Linux
./gradlew pnpmInstall
# Windows
./gradlew.bat pnpmInstall
```
2. 运行插件:
```shell
# macOS / Linux
./gradlew haloServer
# Windows
./gradlew.bat haloServer
```
执行此命令后,会自动创建一个 Halo 的 Docker 容器并加载当前的插件。
3. 启动成功后,可以看到如下日志输出:
```shell
Halo 初始化成功,访问: http://localhost:8090/console
用户名admin
密码admin
```
然后访问 `http://localhost:8090/console`
在插件列表将能看到插件已经被正确启动,并且在左侧菜单添加了一个 `示例分组`,其下有一个名 `示例页面` 的菜单。
![hello-world-in-plugin-list](/img/plugin-hello-world.png)
### 传统方式运行 {#run-with-traditional-way}
如果你的设备上无法安装 Docker 或你对 Docker 不熟悉,可以使用传统方式运行并开发插件。
但由于此方式需要先使用源码运行 Halo 才能启动插件,请确保已经在开发环境运行了 Halo可以参考 [Halo 开发环境运行](../core/run.md)
1. 安装前端部分的依赖
```shell
# macOS / Linux
./gradlew pnpmInstall
# Windows
./gradlew.bat pnpmInstall
```
2. 编译插件
```shell
# macOS / Linux
./gradlew build
# Windows
./gradlew.bat build
```
3. 修改 Halo 配置文件:
```shell
# 进入 Halo 项目根目录后,使用 cd 命令进入配置文件目录
cd application/src/main/resources
# 创建 application-local.yaml 文件
touch application-local.yaml
```
根据你的操作系统,将以下内容添加到 `application-local.yaml` 文件中。
```yaml
# macOS / Linux
halo:
plugin:
runtime-mode: development
fixed-plugin-path:
# 配置为插件项目目录绝对路径
- /path/to/halo-plugin-hello-world
# Windows
halo:
plugin:
runtime-mode: development
fixed-plugin-path:
# 配置为插件项目目录绝对路径
- C:\path\to\halo-plugin-hello-world
```
4. 启动 Halo
```shell
# macOS / Linux
./gradlew bootRun --args="--spring.profiles.active=dev,local"
# Windows
gradlew.bat bootRun --args="--spring.profiles.active=dev,win,local"
```
然后访问 `http://localhost:8090/console`
在插件列表将能看到插件已经被正确启动,并且在左侧菜单添加了一个 `示例分组`,其下有一个名 `示例页面` 的菜单。
![hello-world-in-plugin-list](/img/plugin-hello-world.png)

@ -0,0 +1,6 @@
---
title: 介绍
description: 插件开发的准备工作
---
Halo 采用可插拔架构,功能模块之间耦合度低、灵活性提高,支持用户按需安装、卸载插件,操作便捷。同时提供插件开发接口以确保较高扩展性和可维护性,这个系列的文档将帮助你了解如何开发 Halo 插件。

@ -0,0 +1,15 @@
---
title: 准备工作
description: 插件开发的准备工作
---
在 Halo 中,插件是使用 Java 和 JavaScript / TypeScript 编写的UI 使用 [Vuejs](https://vuejs.org) 编写。
在创建你的第一个插件之前,请确保你具备以下条件:
- 你能通过 [Docker 运行 Halo](../../getting-started/install/docker) 或在[开发环境运行 Halo](../core/run.md)。
- 你熟悉 Java Web 开发并掌握 [Spring Boot](https://spring.io/projects/spring-boot/) 框架。
- 你需要在计算机上安装最新的 LTS 版本的 Node.js如果你还没有Node.js安装你可以在这里下载 [Node.js 18 LTS](https://nodejs.org/)。
- 你熟悉 Vue 和 TypeScript。
- 你应该熟悉使用 Node.js 包管理器。
- Git 是一个版本控制系统,用于跟踪代码的更改,您需要 Git 来下载示例插件并发布插件。

@ -0,0 +1,160 @@
---
title: 发布插件
description: 了解如何与我们的社区分享你的插件
---
了解如何与我们的社区分享你的插件。
## 创建 Release
当你完成了你的插件并进行充分测试后,就可以在 GitHub 上创建新的 Release其中版本规范可以参考[版本控制](./introduction.md#版本控制)。
## 自动构建
如果你是基于 [halo-dev/plugin-starter](https://github.com/halo-dev/plugin-starter) 创建的插件项目,那么已经包含了适用于 GitHub Action 的 `workflow.yaml` 文件,里面包含了构建插件和发布插件资源到 Release 的步骤,可以根据自己的实际需要进行修改,以下是示例:
```yaml
name: Build Plugin JAR File
on:
push:
branches:
- main
paths:
- "**"
- "!**.md"
release:
types:
- created
pull_request:
branches:
- main
paths:
- "**"
- "!**.md"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
submodules: true
- name: Set up JDK 17
uses: actions/setup-java@v2
with:
distribution: 'temurin'
cache: 'gradle'
java-version: 17
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 18
- uses: pnpm/action-setup@v2.0.1
name: Install pnpm
id: pnpm-install
with:
version: 8
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
run: |
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
- uses: actions/cache@v3
name: Setup pnpm cache
with:
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/widget/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install Frontend Dependencies
run: |
./gradlew pnpmInstall
- name: Build with Gradle
run: |
# Set the version with tag name when releasing
version=${{ github.event.release.tag_name }}
version=${version#v}
sed -i "s/version=.*-SNAPSHOT$/version=$version/1" gradle.properties
./gradlew clean build -x test
- name: Archive plugin-starter jar
uses: actions/upload-artifact@v2
with:
name: plugin-starter
path: |
build/libs/*.jar
retention-days: 1
github-release:
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'release'
steps:
- name: Download plugin-starter jar
uses: actions/download-artifact@v2
with:
name: plugin-starter
path: build/libs
- name: Get Name of Artifact
id: get_artifact
run: |
ARTIFACT_PATHNAME=$(ls build/libs/*.jar | head -n 1)
ARTIFACT_NAME=$(basename ${ARTIFACT_PATHNAME})
echo "Artifact pathname: ${ARTIFACT_PATHNAME}"
echo "Artifact name: ${ARTIFACT_NAME}"
echo "ARTIFACT_PATHNAME=${ARTIFACT_PATHNAME}" >> $GITHUB_ENV
echo "ARTIFACT_NAME=${ARTIFACT_NAME}" >> $GITHUB_ENV
echo "RELEASE_ID=${{ github.event.release.id }}" >> $GITHUB_ENV
- name: Upload a Release Asset
uses: actions/github-script@v2
if: github.event_name == 'release'
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
console.log('environment', process.versions);
const fs = require('fs').promises;
const { repo: { owner, repo }, sha } = context;
console.log({ owner, repo, sha });
const releaseId = process.env.RELEASE_ID
const artifactPathName = process.env.ARTIFACT_PATHNAME
const artifactName = process.env.ARTIFACT_NAME
console.log('Releasing', releaseId, artifactPathName, artifactName)
await github.repos.uploadReleaseAsset({
owner, repo,
release_id: releaseId,
name: artifactName,
data: await fs.readFile(artifactPathName)
});
```
## 发布你的插件
用户可以在你的仓库 Release 下载使用,但为了方便让 Halo 的用户知道你的插件,可以在以下渠道发布:
1. [halo-sigs/awesome-halo](https://github.com/halo-sigs/awesome-halo):你可以向这个仓库发起一个 PR 提交的插件的信息即可。
2. [Halo 应用市场](https://www.halo.run/store/apps)Halo 官方的应用市场,但目前还不支持开发者注册和发布,如果你想发布到应用市场,可以在 PR 上说明一下,我们会暂时帮你发布。
3. [Halo 论坛](https://bbs.halo.run/t/plugins):你可以在 Halo 官方社区的插件板块发布你的插件。
## 支持
Halo 不提供对第三方应用程序的支持。作为插件的开发者你有责任帮助插件的用户解决技术问题issues
当提交插件到 [awesome-halo](https://github.com/halo-sigs/awesome-halo) 时,
你需要添加服务支持联系人Support contact。这可以是用户可以联系的电子邮件地址也可以是网站或帮助中心的链接。
## 版本控制
为了保持 Halo 生态系统的健康、可靠和安全,每次你对自己拥有的插件进行重大更新时,我们建议在遵循 [semantic versioning spec](http://semver.org/) 的基础上,
发布新版本。遵循语义版本控制规范有助于其他依赖你代码的开发人员了解给定版本的更改程度,并在必要时调整自己的代码。
我们建议你的包版本从1.0.0开始并递增,如下:
| Code status | Stage | Rule | Example version |
| ----------------------------------------- | ------------- | -------------------------------------------- | --------------- |
| First release | New product | 从 1.0.0 开始 | 1.0.0 |
| Backward compatible bug fixes | Patch release | 增加第三位数字 | 1.0.1 |
| Backward compatible new features | Minor release | 增加中间数字并将最后一位重置为零 | 1.1.0 |
| Changes that break backward compatibility | Major release | 增加第一位数字并将中间和最后一位数字重置为零 | 2.0.0 |

@ -0,0 +1,64 @@
---
title: 模型元数据
---
在 [元数据表单定义](../annotations-form.md) 我们介绍了如何为模型添加元数据表单,此文档将介绍如何在主题模板中使用元数据。
我们在模板中专门为获取 annotations 数据提供了三个方法,可以更加方便的设置默认值和判断元数据字段是否存在。
## #annotations.get(extension,key)
### 描述
根据对象和元数据的 key 获取元数据的值。
### 示例
```html {4}
<div th:with="menu = ${menuFinder.getPrimary()}">
<ul th:with="menuItems = ${menu.menuItems}">
<li th:each="menuItem : ${menuItems}">
<i th:class="${#annotations.get(menuItem, 'icon')}"></i>
<a th:href="@{${menuItem.status.href}}" th:text="${menuItem.status.displayName}"></a>
</li>
</ul>
</div>
```
## #annotations.getOrDefault(extension,key,defaultValue)
### 描述
根据对象和元数据的 key 获取元数据的值,同时支持设置默认值。
### 示例
```html {4}
<div th:with="menu = ${menuFinder.getPrimary()}">
<ul th:with="menuItems = ${menu.menuItems}">
<li th:each="menuItem : ${menuItems}">
<i th:class="${#annotations.getOrDefault(menuItem, 'icon', 'fa')}"></i>
<a th:href="@{${menuItem.status.href}}" th:text="${menuItem.status.displayName}"></a>
</li>
</ul>
</div>
```
## #annotations.contains(extension,key)
### 描述
根据对象和元数据的 key 判断元数据是否存在。
### 示例
```html {4}
<div th:with="menu = ${menuFinder.getPrimary()}">
<ul th:with="menuItems = ${menu.menuItems}">
<li th:each="menuItem : ${menuItems}">
<i th:if="${#annotations.contains(menuItem, 'icon')}" th:class="${#annotations.get(menuItem, 'icon')}"></i>
<a th:href="@{${menuItem.status.href}}" th:text="${menuItem.status.displayName}"></a>
</li>
</ul>
</div>
```

@ -0,0 +1,47 @@
---
title: 常用代码片段
description: 本文档介绍了常用的代码片段,以便于开发者快速上手。
---
## 布局模板
通常情况下,我们需要一个公共模板来定义页面的布局。
```html title="templates/layout.html"
<!DOCTYPE html>
<html lang="en" xmlns:th="https://www.thymeleaf.org" th:fragment="html (head,content)">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=2" />
<title th:text="${site.title}"></title>
<link rel="stylesheet" th:href="@{/assets/dist/style.css}" />
<script th:src="@{/assets/dist/main.iife.js}"></script>
<th:block th:if="${head != null}">
<th:block th:replace="${head}" />
</th:block>
</head>
<body>
<section>
<th:block th:replace="${content}" />
</section>
</body>
</html>
```
```html title="templates/index.html"
<!DOCTYPE html>
<html
xmlns:th="https://www.thymeleaf.org"
th:replace="~{modules/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>
</li>
</ul>
</th:block>
</html>
```

@ -0,0 +1,89 @@
---
title: 配置文件
description: 关于主题配置文件的文档。
---
目前 Halo 2.0 的主题必须在根目录包含 `theme.yaml`,用于配置主题的基本信息,如主题名称、版本、作者等。
## 格式示例
```yaml title="theme.yaml"
apiVersion: theme.halo.run/v1alpha1
kind: Theme
metadata:
name: theme-foo
spec:
displayName: 示例主题
author:
name: halo-dev
website: https://halo.run
description: 一个示例主题
logo: https://halo.run/logo
website: https://github.com/halo-sigs/theme-foo
repo: https://github.com/halo-sigs/theme-foo.git
settingName: "theme-foo-setting"
configMapName: "theme-foo-configMap"
customTemplates:
post:
- name: 文档
description: 文档类型的文章
screenshot:
file: post_documentation.html
category:
- name: 知识库
description: 知识库类型的分类
screenshot:
file: category_knowledge.html
page:
- name: 关于
description: 关于页面
screenshot:
file: page_about.html
version: 1.0.0
require: 2.0.0
```
## 字段详解
| 字段 | 描述 | 是否必填 |
| ------------------------------- | ----------------------------------------------------------------------------- | -------- |
| `metadata.name` | 主题的唯一标识 | 是 |
| `spec.displayName` | 显示名称 | 是 |
| `spec.author.name` | 作者名称 | 否 |
| `spec.author.website` | 作者网站 | 否 |
| `spec.description` | 主题描述 | 否 |
| `spec.logo` | 主题 Logo | 否 |
| `spec.website` | 主题网站 | 否 |
| `spec.repo` | 主题托管地址 | 否 |
| `spec.settingName` | 设置表单定义的名称,需要同时创建对应的 `settings.yaml` 文件 | 否 |
| `spec.configMapName` | 设置持久化配置的 ConfigMap 名称 | 否 |
| `spec.customTemplates.post` | 文章的自定义模板配置,详细文档可查阅 [模板路由](./template-route-mapping#custom-templates) | 否 |
| `spec.customTemplates.category` | 分类的自定义模板配置,详细文档可查阅 [模板路由](./template-route-mapping#custom-templates) | 否 |
| `spec.customTemplates.page` | 独立页面的自定义模板配置,详细文档可查阅 [模板路由](./template-route-mapping#custom-templates) | 否 |
| `spec.version` | 主题版本 | 是 |
| `spec.require` | 所需 Halo 的运行版本 | 是 |
## 更新配置
由于目前 `theme.yaml` 是持久化存储在数据库中的,不会在修改之后主动更新,所以我们在 Console 的主题页面添加了 `重载主题配置` 的选项。
![重载主题配置](/img/theme/reload-theme-config.png)
## 从 1.x 迁移
为了方便主题开发者从 1.x 迁移,我们提供了工具用于迁移配置文件。
工具仓库地址:<https://github.com/halo-sigs/convert-theme-config-to-next>
```bash
# 1.x 版本主题
cd path/to/theme
npx @halo-dev/convert-theme-config-to-next theme
```
执行完成之后即可看到主题目录下生成了 `theme.2.0.yaml` 文件,重命名为 `theme.yaml` 即可。
:::tip
转换完成之后需要修改 `metadata.name`、`spec.settingName` 和 `spec.configMapName`
:::

@ -0,0 +1,10 @@
---
title: Finder API
description: 本文档介绍 Finder API 的使用方法。
---
import DocCardList from '@theme/DocCardList';
目前在主题模板中获取数据可以使用对应路由提供的 [模板变量](./template-variables),但为了满足在任意位置获取数据的需求,我们提供了 Finder API。
<DocCardList />

@ -0,0 +1,187 @@
---
title: 文章分类
description: 文章分类 - CategoryFinder
---
import CategoryVo from "../vo/CategoryVo.md"
import CategoryTreeVo from "../vo/CategoryTreeVo.md"
## getByName(name)
```js
categoryFinder.getByName(name)
```
### 描述
根据 `metadata.name` 获取文章分类。
### 参数
1. `name:string` - 分类的唯一标识 `metadata.name`
### 返回值
[#CategoryVo](#categoryvo)
### 示例
```html
<div th:with="category = ${categoryFinder.getByName('category-foo')}">
<a th:href="@{${category.status.permalink}}" th:text="${category.spec.displayName}"></a>
</div>
```
## getByNames(names)
```js
categoryFinder.getByNames(names)
```
### 描述
根据一组 `metadata.name` 获取文章分类。
### 参数
1. `names:List<string>` - 分类的唯一标识 `metadata.name` 的集合。
### 返回值
List<[#CategoryVo](#categoryvo)>
### 示例
```html
<div th:with="categories = ${categoryFinder.getByNames(['category-foo', 'category-bar'])}">
<a th:each="category : ${categories}" th:href="@{${category.status.permalink}}" th:text="${category.spec.displayName}"></a>
</div>
```
## list(page,size)
```js
categoryFinder.list(page,size)
```
### 描述
根据分页参数获取分类列表。
### 参数
1. `page:int` - 分页页码,从 1 开始
2. `size:int` - 分页条数
### 返回值
[#ListResult<CategoryVo\>](#listresultcategoryvo)
### 示例
```html
<ul th:with="categories = ${categoryFinder.list(1,10)}">
<li th:each="category : ${categories.items}">
<a th:href="@{${category.status.permalink}}" th:text="${category.spec.displayName}"></a>
</li>
</ul>
```
## listAll()
```js
categoryFinder.listAll()
```
### 描述
获取所有文章分类。
### 参数
### 返回值
List<[#CategoryVo](#categoryvo)>
### 示例
```html
<ul th:with="categories = ${categoryFinder.listAll()}">
<li th:each="category : ${categories}">
<a th:href="@{${category.status.permalink}}" th:text="${category.spec.displayName}"></a>
</li>
</ul>
```
## listAsTree()
```js
categoryFinder.listAsTree()
```
### 描述
获取所有文章分类的多层级结构。
### 参数
### 返回值
List<[#CategoryTreeVo](#categorytreevo)>
### 示例
```html
<div th:with="categories = ${categoryFinder.listAsTree()}">
<ul>
<li th:replace="~{modules/category-tree :: single(categories=${categories})}" />
</ul>
</div>
```
```html title="/templates/category-tree.html"
<ul th:fragment="next (categories)">
<li th:fragment="single (categories)" th:each="category : ${categories}">
<a th:href="@{${category.status.permalink}}">
<span th:text="${category.spec.displayName}"> </span>
</a>
<th:block th:if="${not #lists.isEmpty(category.children)}">
<th:block th:replace="~{modules/category-tree :: next (categories=${category.children})}"></th:block>
</th:block>
</li>
</ul>
```
## 类型定义
### CategoryVo
<CategoryVo />
### ListResult<CategoryVo\>
```json title="ListResult<CategoryVo>"
{
"page": 0, // 当前页码
"size": 0, // 每页条数
"total": 0, // 总条数
"items": "List<#CategoryVo>", // 分类列表数据
"first": true, // 是否为第一页
"last": true, // 是否为最后一页
"hasNext": true, // 是否有下一页
"hasPrevious": true, // 是否有上一页
"totalPages": 0 // 总页数
}
```
- [#CategoryVo](#categoryvo)
### CategoryTreeVo
<CategoryTreeVo />
- [#CategoryTreeVo](#categorytreevo)

@ -0,0 +1,155 @@
---
title: 评论
description: 评论 - CommentFinder
---
import CommentVo from "../vo/CommentVo.md"
import ReplyVo from "../vo/ReplyVo.md"
## getByName(name)
```js
commentFinder.getByName(name)
```
### 描述
根据 `metadata.name` 获取评论。
### 参数
1. `name:string` - 评论的唯一标识 `metadata.name`
### 返回值
[#CommentVo](#commentvo)
### 示例
```html
<div th:with="comment = ${commentFinder.getByName('comment-foo')}">
<span th:text="${comment.spec.owner.displayName}"></span>
<div th:text="${comment.spec.content}"></div>
</div>
```
## list(ref,page,size)
```js
commentFinder.list(ref,page,size)
```
### 描述
根据评论的 `metadata.name` 和分页参数获取回复列表。
### 参数
1. `ref:#Ref` - 评论的唯一标识 `metadata.name`
2. `page:int` - 分页页码,从 1 开始
3. `size:int` - 分页条数
- [#Ref](#ref)
### 返回值
[#ListResult<CommentVo\>](#listresultcommentvo)
### 示例
```html
<ul th:with="comments = ${commentFinder.list({ group: 'content.halo.run', version: 'v1alpha1', kind: 'Post', name: 'post-foo' },1,10)}">
<li th:each="comment : ${comments.items}">
<span th:text="${comment.spec.owner.displayName}"></span>
<div th:text="${comment.spec.content}"></div>
</li>
</ul>
```
## listReply(commentName,page,size)
```js
commentFinder.listReply(commentName,page,size)
```
### 描述
根据评论的 `metadata.name` 和分页参数获取回复列表。
### 参数
1. `commentName:string` - 评论的唯一标识 `metadata.name`
1. `page:int` - 分页页码,从 1 开始
2. `size:int` - 分页条数
### 返回值
[#ListResult<ReplyVo\>](#listresultreplyvo)
### 示例
```html
<ul th:with="replies = ${commentFinder.listReply('comment-foo',1,10)}">
<li th:each="reply : ${replies.items}">
<span th:text="${reply.spec.owner.displayName}"></span>
<div th:text="${reply.spec.content}"></div>
</li>
</ul>
```
## 类型定义
### CommentVo
<CommentVo />
### ListResult<CommentVo\>
```json title="ListResult<CommentVo>"
{
"page": 0, // 当前页码
"size": 0, // 每页条数
"total": 0, // 总条数
"items": "List<#CommentVo>", // 评论列表数据
"first": true, // 是否为第一页
"last": true, // 是否为最后一页
"hasNext": true, // 是否有下一页
"hasPrevious": true, // 是否有上一页
"totalPages": 0 // 总页数
}
```
- [#CommentVo](#commentvo)
### ReplyVo
<ReplyVo />
### ListResult<ReplyVo\>
```json title="ListResult<ReplyVo>"
{
"page": 0, // 当前页码
"size": 0, // 每页条数
"total": 0, // 总条数
"items": "List<#ReplyVo>", // 回复列表数据
"first": true, // 是否为第一页
"last": true, // 是否为最后一页
"hasNext": true, // 是否有下一页
"hasPrevious": true, // 是否有上一页
"totalPages": 0 // 总页数
}
```
- [#ReplyVo](#replyvo)
### Ref
```json title="Ref"
{
"group": "string",
"kind": "string",
"version": "string",
"name": "string"
}
```

@ -0,0 +1,64 @@
---
title: 作者
description: 作者 - ContributorFinder
---
import ContributorVo from "../vo/ContributorVo.md"
## getContributor(name)
```js
contributorFinder.getContributor(name)
```
### 描述
根据 `metadata.name` 获取作者。
### 参数
1. `name:string` - 作者的唯一标识 `metadata.name`
### 返回值
[#ContributorVo](#contributorvo)
### 示例
```html
<div th:with="contributor = ${contributorFinder.getContributor('contributor-foo')}">
<h1 th:text="${contributor.displayName}"></h1>
</div>
```
## getContributors(names)
```js
contributorFinder.getContributors(names)
```
### 描述
根据一组 `metadata.name` 获取作者。
### 参数
1. `names:List<string>` - 作者的唯一标识 `metadata.name` 的集合。
### 返回值
List<[#ContributorVo](#contributorvo)>
### 示例
```html
<div th:with="contributors = ${contributorFinder.getContributors(['contributor-foo, 'contributor-bar'])}">
<span th:each="contributor : ${contributors}" th:text="${contributor.displayName}"></span>
</div>
```
## 类型定义
### ContributorVo
<ContributorVo />

@ -0,0 +1,87 @@
---
title: 导航菜单
description: 导航菜单 - MenuFinder
---
import MenuItemVo from "../vo/MenuItemVo.md"
import MenuVo from "../vo/MenuVo.md"
## getByName(name)
```js
menuFinder.getByName(name)
```
### 描述
根据 `metadata.name` 获取菜单。
### 参数
1. `name:string` - 菜单的唯一标识 `metadata.name`
### 返回值
[#MenuVo](#menuvo)
### 示例
```html
<div th:with="menu = ${menuFinder.getByName('menu-foo')}">
<ul th:with="menuItems = ${menu.menuItems}">
<li th:each="menuItem : ${menuItems}">
<a
th:href="@{${menuItem.status.href}}"
th:text="${menuItem.status.displayName}"
th:target="${menuItem.spec.target?.value}"
>
</a>
</li>
</ul>
</div>
```
## getPrimary()
```js
menuFinder.getPrimary()
```
### 描述
获取主菜单。
### 参数
### 返回值
[#MenuVo](#menuvo)
### 示例
```html
<div th:with="menu = ${menuFinder.getPrimary()}">
<ul th:with="menuItems = ${menu.menuItems}">
<li th:each="menuItem : ${menuItems}">
<a
th:href="@{${menuItem.status.href}}"
th:text="${menuItem.status.displayName}"
th:target="${menuItem.spec.target?.value}"
>
</a>
</li>
</ul>
</div>
```
## 类型定义
### MenuVo
<MenuVo />
### MenuItemVo
<MenuItemVo />

@ -0,0 +1,33 @@
---
title: 插件
description: 插件 - PluginFinder
---
## available(pluginName)
```js
pluginFinder.available(pluginName)
```
### 描述
判断一个插件是否可用,会同时判断插件是否安装和启用。
### 参数
1. `pluginName:string` - 插件的唯一标识 `metadata.name`
### 返回值
`boolean` - 插件是否可用
### 示例
```html
<!-- https://github.com/halo-sigs/plugin-search-widget -->
<li th:if="${pluginFinder.available('PluginSearchWidget')}">
<a href="javascript:SearchWidget.open()" title="搜索">
搜索
</a>
</li>
```

@ -0,0 +1,430 @@
---
title: 文章
description: 文章 - PostFinder
---
import CategoryVo from "../vo/CategoryVo.md";
import TagVo from "../vo/TagVo.md";
import PostVo from "../vo/PostVo.md";
import ContentVo from "../vo/ContentVo.md"
import ContributorVo from "../vo/ContributorVo.md"
import ListedPostVo from "../vo/ListedPostVo.md"
## getByName(postName)
```js
postFinder.getByName(postName);
```
### 描述
根据 `metadata.name` 获取文章。
### 参数
1. `postName:string` - 文章的唯一标识 `metadata.name`
### 返回值
[#PostVo](#postvo)
### 示例
```html
<div th:with="post = ${postFinder.getByName('post-foo')}">
<a th:href="@{${post.status.permalink}}" th:text="${post.spec.title}"></a>
</div>
```
## content(postName)
```js
postFinder.content(postName);
```
### 描述
根据文章的 `metadata.name` 单独获取文章内容。
### 参数
1. `postName:string` - 文章的唯一标识 `metadata.name`
### 返回值
[#ContentVo](#contentvo)
### 示例
```html
<div th:with="content = ${postFinder.content('post-foo')}">
<div th:utext="${content.content}"></div>
</div>
```
## cursor(postName)
```js
postFinder.cursor(postName);
```
### 描述
根据文章的 `metadata.name` 获取相邻的文章(上一篇 / 下一篇)。
### 参数
1. `postName:string` - 文章的唯一标识 `metadata.name`
### 返回值
[#NavigationPostVo](#navigationpostvo)
### 示例
```html title="/templates/post.html"
<div th:with="postCursor = ${postFinder.cursor(post.metadata.name)}">
<a
th:if="${postCursor.hasPrevious()}"
th:href="@{${postCursor.previous.status.permalink}}"
>
<span th:text="${postCursor.previous.spec.title}"></span>
</a>
<a
th:if="${postCursor.hasNext()}"
th:href="@{${postCursor.next.status.permalink}}"
>
<span th:text="${postCursor.next.spec.title}"></span>
</a>
</div>
```
## listAll()
```js
postFinder.listAll();
```
### 描述
获取所有文章。
### 参数
### 返回值
List<[#ListedPostVo](#listedpostvo)>
### 示例
```html
<ul th:with="posts = ${postFinder.listAll()}">
<li th:each="post : ${posts}">
<a th:href="@{${post.status.permalink}}" th:text="${post.spec.title}"></a>
</li>
</ul>
```
## list(page,size)
```js
postFinder.list(page, size);
```
### 描述
根据分页参数获取文章列表。
### 参数
1. `page:int` - 分页页码,从 1 开始
2. `size:int` - 分页条数
### 返回值
[#ListResult<ListedPostVo\>](#listresultlistedpostvo)
### 示例
```html
<ul th:with="posts = ${postFinder.list(1,10)}">
<li th:each="post : ${posts.items}">
<a th:href="@{${post.status.permalink}}" th:text="${post.spec.title}"></a>
</li>
</ul>
```
## listByCategory(page,size,categoryName)
```js
postFinder.listByCategory(page, size, categoryName);
```
### 描述
根据分类标识和分页参数获取文章列表。
### 参数
1. `page:int` - 分页页码,从 1 开始
2. `size:int` - 分页条数
3. `categoryName:string` - 文章分类唯一标识 `metadata.name`
### 返回值
[#ListResult<ListedPostVo\>](#listresultlistedpostvo)
### 示例
```html
<ul th:with="posts = ${postFinder.listByCategory(1,10,'category-foo')}">
<li th:each="post : ${posts.items}">
<a th:href="@{${post.status.permalink}}" th:text="${post.spec.title}"></a>
</li>
</ul>
```
## listByTag(page,size,tag)
```js
postFinder.listByTag(page, size, tag);
```
### 描述
根据标签标识和分页参数获取文章列表。
### 参数
1. `page:int` - 分页页码,从 1 开始
2. `size:int` - 分页条数
3. `tag:string` - 文章分类唯一标识 `metadata.name`
### 返回值
[#ListResult<ListedPostVo\>](#listresultlistedpostvo)
### 示例
```html
<ul th:with="posts = ${postFinder.listByTag(1,10,'tag-foo')}">
<li th:each="post : ${posts.items}">
<a th:href="@{${post.status.permalink}}" th:text="${post.spec.title}"></a>
</li>
</ul>
```
## archives(page,size)
```js
postFinder.archives(page, size);
```
### 描述
根据分页参数获取文章归档列表。
### 参数
1. `page:int` - 分页页码,从 1 开始
2. `size:int` - 分页条数
### 返回值
[#ListResult<PostArchiveVo\>](#listresultpostarchivevo)
### 示例
```html
<th:block th:with="archives = ${postFinder.archives(1,10)}">
<th:block th:each="archive : ${archives.items}">
<h1 th:text="${archive.year}"></h1>
<ul>
<th:block th:each="month : ${archive.months}">
<li th:each="post : ${month.posts}">
<a th:href="@{${post.status.permalink}}" th:text="${post.spec.title}">
</a>
</li>
</th:block>
</ul>
</th:block>
</th:block>
```
## archives(page,size,year)
```js
postFinder.archives(page, size, year);
```
### 描述
根据年份和分页参数获取文章归档列表。
### 参数
1. `page:int` - 分页页码,从 1 开始
2. `size:int` - 分页条数
3. `year:string` - 年份
### 返回值
[#ListResult<PostArchiveVo\>](#listresultpostarchivevo)
### 示例
```html
<th:block th:with="archives = ${postFinder.archives(1,10,'2022')}">
<th:block th:each="archive : ${archives.items}">
<h1 th:text="${archive.year}"></h1>
<ul>
<th:block th:each="month : ${archive.months}">
<li th:each="post : ${month.posts}">
<a th:href="@{${post.status.permalink}}" th:text="${post.spec.title}">
</a>
</li>
</th:block>
</ul>
</th:block>
</th:block>
```
## archives(page,size,year,month)
```js
postFinder.archives(page, size, year, month);
```
### 描述
根据年月和分页参数获取文章归档列表。
### 参数
1. `page:int` - 分页页码,从 1 开始
2. `size:int` - 分页条数
3. `year:string` - 年份
4. `month:string` - 月份
### 返回值
[#ListResult<PostArchiveVo\>](#listresultpostarchivevo)
### 示例
```html
<th:block th:with="archives = ${postFinder.archives(1,10,'2022','11')}">
<th:block th:each="archive : ${archives.items}">
<h1 th:text="${archive.year}"></h1>
<ul>
<th:block th:each="month : ${archive.months}">
<li th:each="post : ${month.posts}">
<a th:href="@{${post.status.permalink}}" th:text="${post.spec.title}">
</a>
</li>
</th:block>
</ul>
</th:block>
</th:block>
```
## 类型定义
### CategoryVo
<CategoryVo />
### TagVo
<TagVo />
### ContributorVo
<ContributorVo />
### PostVo
<PostVo />
- [#CategoryVo](#categoryvo)
- [#TagVo](#tagvo)
- [#ContributorVo](#contributorvo)
- [#ContentVo](#contentvo)
### ContentVo
<ContentVo />
### NavigationPostVo
```json title="NavigationPostVo"
{
"previous": "#PostVo", // 上一篇文章
"current": "#PostVo", // 当前文章
"next": "#PostVo" // 下一篇文章
}
```
- [#PostVo](#postvo)
### ListedPostVo
<ListedPostVo />
- [#CategoryVo](#categoryvo)
- [#TagVo](#tagvo)
- [#ContributorVo](#contributorvo)
### ListResult<ListedPostVo\>
```json title="ListResult<ListedPostVo>"
{
"page": 0, // 当前页码
"size": 0, // 每页条数
"total": 0, // 总条数
"items": "List<#ListedPostVo>", // 文章列表数据
"first": true, // 是否为第一页
"last": true, // 是否为最后一页
"hasNext": true, // 是否有下一页
"hasPrevious": true, // 是否有上一页
"totalPages": 0 // 总页数
}
```
- [#ListedPostVo](#listedpostvo)
### PostArchiveVo
```json title="PostArchiveVo"
{
"year": "string",
"months": [
{
"month": "string",
"posts": "#ListedPostVo"
}
]
}
```
- [#ListedPostVo](#listedpostvo)
### ListResult<PostArchiveVo\>
```json title="ListResult<PostArchiveVo>"
{
"page": 0, // 当前页码
"size": 0, // 每页条数
"total": 0, // 总条数
"items": "List<#PostArchiveVo>", // 文章归档数据
"first": true, // 是否为第一页
"last": true, // 是否为最后一页
"hasNext": true, // 是否有下一页
"hasPrevious": true, // 是否有上一页
"totalPages": 0 // 总页数
}
```
- [#PostArchiveVo](#postarchivevo)

@ -0,0 +1,131 @@
---
title: 独立页面
description: 独立页面 - SinglePageFinder
---
import SinglePageVo from "../vo/SinglePageVo.md"
import ListedSinglePageVo from "../vo/ListedSinglePageVo.md"
import ContributorVo from "../vo/ContributorVo.md"
import ContentVo from "../vo/ContentVo.md"
## getByName(pageName)
```js
singlePageFinder.getByName(pageName)
```
### 描述
根据 `metadata.name` 获取独立页面。
### 参数
1. `pageName:string` - 独立页面的唯一标识 `metadata.name`
### 返回值
[#SinglePageVo](#singlepagevo)
### 示例
```html
<div th:with="singlePage = ${singlePageFinder.getByName('page-foo')}">
<a th:href="@{${singlePage.status.permalink}}" th:text="${singlePage.spec.title}"></a>
</div>
```
## content(pageName)
```js
singlePageFinder.content(pageName)
```
### 描述
根据独立页面的 `metadata.name` 单独获取独立页面内容。
### 参数
1. `pageName:string` - 独立页面的唯一标识 `metadata.name`
### 返回值
[#ContentVo](#contentvo)
### 示例
```html
<div th:with="content = ${singlePageFinder.content('page-foo')}">
<div th:utext="${content.content}"></div>
</div>
```
## list(page,size)
```js
singlePageFinder.list(page,size)
```
### 描述
根据分页参数获取独立页面列表。
### 参数
1. `page:int` - 分页页码,从 1 开始
2. `size:int` - 分页条数
### 返回值
[#ListResult<ListedSinglePageVo\>](#listresultlistedsinglepagevo)
### 示例
```html
<ul th:with="singlePages = ${singlePageFinder.list(1,10)}">
<li th:each="singlePage : ${singlePages.items}">
<a th:href="@{${singlePage.status.permalink}}" th:text="${singlePage.spec.title}"></a>
</li>
</ul>
```
## 类型定义
### SinglePageVo
<SinglePageVo />
- [#ContributorVo](#contributorvo)
- [#ContentVo](#contentvo)
### ListedSinglePageVo
- [#ContributorVo](#contributorvo)
<ListedSinglePageVo />
### ListResult<ListedSinglePageVo\>
```json title="ListResult<ListedSinglePageVo>"
{
"page": 0, // 当前页码
"size": 0, // 每页条数
"total": 0, // 总条数
"items": "List<#ListedSinglePageVo>", // 自定义页面列表数据
"first": true, // 是否为第一页
"last": true, // 是否为最后一页
"hasNext": true, // 是否有下一页
"hasPrevious": true, // 是否有上一页
"totalPages": 0 // 总页数
}
```
- [#ListedSinglePageVo](#listedsinglepagevo)
### ContentVo
<ContentVo />
### ContributorVo
<ContributorVo />

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save