feat: add plugin development docs (#123)

### What this PR does?
添加插件开发文档

### Which issue(s) this PR fixes
Fixes #113

/area docs

```release-note
None
```
JohnNiang-patch-1
guqing 2 years ago committed by GitHub
parent 4d59c627c5
commit f21171de21
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,104 @@
---
title: 与自定义模型交互
description: 了解如果通过代码的方式操作数据
---
Halo 提供了两个类用于与自定义模型数据交互 `ExtensionClient``ReactiveExtensionClient`
它们的本质就是操作数据库,区别在于 `ExtensionClient` 是阻塞式 API`ReactiveExtensionClient` 是响应式 API接口返回值只有两种 Mono 或 Flux它们由 [reactor](https://projectreactor.io/) 提供。
```java
public interface ReactiveExtensionClient {
/**
* Lists Extensions by Extension type, filter and sorter.
*
* @param type is the class type of Extension.
* @param predicate filters the reEnqueue.
* @param comparator sorts the reEnqueue.
* @param <E> is Extension type.
* @return all filtered and sorted Extensions.
*/
<E extends Extension> Flux<E> list(Class<E> type, Predicate<E> predicate,
Comparator<E> comparator);
/**
* Lists Extensions by Extension type, filter, sorter and page info.
*
* @param type is the class type of Extension.
* @param predicate filters the reEnqueue.
* @param comparator sorts the reEnqueue.
* @param page is page number which starts from 0.
* @param size is page size.
* @param <E> is Extension type.
* @return a list of Extensions.
*/
<E extends Extension> Mono<ListResult<E>> list(Class<E> type, Predicate<E> predicate,
Comparator<E> comparator, int page, int size);
/**
* 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
private final ReactiveExtensionClient client;
Mono<Person> getPerson(String name) {
return client.fetch(Person.class, name);
}
```
或者使用阻塞式 API:
```java
private final ExtensionClient client;
Optional<Person> getPerson(String name) {
return client.fetch(Person.class, name);
}
```
我们建议你更多的使用响应式的 `ReactiveExtensionClient` 去替代 `ExtensionClient`

@ -0,0 +1,127 @@
---
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)。设计目的在于为插件提供自定义数据支持。比如某插件需要存储自定义数据,同时也想读取和操作自定义数据。
一个典型的自定义模型 `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(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. 创建一个类继承 `AbstractExtension`
2. 使用 `GVK` 注解。
3. 在插件 `start()` 生命周期方法中注册自定义模型:
```java
@Autowired
private SchemeManager schemeManager;
@Override
public void start() {
schemeManager.register(Person.class);
}
```
有了自定义模型后可以通过在插件项目的 `src/main/resources/extensions` 目录下声明 `yaml` 文件来创建一个实例,此目录下的所有自定义模型 `yaml` 都会在插件启动时被创建:
```yaml
groupVersion: my-plugin.halo.run/v1alpha1
kind: Person
metadata:
name: fake-person
name: halo
age: 18
gender: male
```
:::tip 释义
- @GVK:此注解标识该类为一个自定义模型,同时必须继承 `AbstractExtension`
- kind表示自定义模型所表示的 REST 资源。
- group表示一组公开的资源通常采用域名形式Halo 项目保留使用空组和任何以“*.halo.run”结尾的组名供其单独使用。
选择群组名称时我们建议选择你的群组或组织拥有的子域例如“widget.mycompany.com”。
- versionAPI 的版本,它与 group 组合使用为 apiVersion=“GROUP/VERSION”例如“api.halo.run/v1alpha1”。
- singular: 资源的单数名称这允许客户端不透明地处理复数和单数必须全部小写。通常为小写的“kind”。
- plural 资源的复数名称,自定义资源在 `/apis/<group>/<version>/.../<plural>` 下提供,必须为全部小写。
- @Schema:属性校验注解,会在创建/修改资源前对资源校验,参考 [schema-validator](https://www.openapi4j.org/schema-validator.html)。
:::
### 自定义模型 API
定义好自定义模型并注册后,会根据 `GVK` 注解自动生成一组 `CRUD` API规则为
`/apis/<group>/<version>/<extension>/{extensionname}/<subextension>`
对于上述 Person 自定义模型将有以下 APIs
```text
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}
```
### 自定义 API
在一些场景下,只有自动生成的 `CRUD` API 往往是不够用的,此时可以通过自定义一些 API 来满足功能。
你可以使用 `SpringBoot` 的控制器写法来暴露 API不同的是需要添加 `@ApiVersion` 注解,没有此注解的 `Controller` 将被忽略:
```java
@ApiVersion("v1alpha1")
@RequestMapping("/apples")
@RestController
public class AppleController {
@PostMapping("/starting")
public void starting() {
}
}
```
当插件被启动时Halo 将会为此 AppleController 生成统一路径的 API。API 前缀组成规则如下:
```text
/apis/plugin.api.halo.run/{version}/plugins/{plugin-name}/**
```
示例:
```text
/apis/plugin.api.halo.run/v1alpha1/plugins/my-plugin/apples/starting
```

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

@ -0,0 +1,103 @@
---
title: API 权限控制
description: 了解如果对插件中的 API 定义角色模板以接入权限控制
---
插件中的 APIs 无论是自定义模型自动生成的 APIs 或者是通过 Controller 自定义的 APIs 都只有超级管理员能够访问,如果想将这些 APIs 授权给其他用户访问则需要定义一些 RoleTemplate 的资源以便可以在用户界面上将其分配给其他角色使用。
RoleTemplate 的 yaml 资源也需要放到 `src/main/resources/extensions` 目录下,文件名称可以任意,它的 kind 为 Role 但需要一个 label `halo.run/role-template: "true"` 来表示它是角色模板。
Halo 的权限控制对同一种资源一般只定义两个 RoleTemplate一个是只读权限另一个是管理权限因此如果没有特殊情况需要更细粒度的控制我们建议你也保持一致
```yaml
apiVersion: v1
kind: Role
metadata:
# 使用 plugin name 作为前缀防止与其他插件冲突
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 自定义模型配置角色模板的示例。
定义了一个用于管理 Person 资源的角色模板 `my-plugin-role-manage-persons`,它具有所有权限,同时定义了一个只允许查询 Person 资源的角色模板 `my-plugin-role-view-persons`
下面让我们回顾一下这些配置:
`rules` 是个数组,它允许配置多组规则:
- `apiGroups` 对应 `GVK` 中的 `group` 所声明的值。
- `resources` 对应 API 中的 resource 部分,`my-plugin` 表示插件名称。
- `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>/<subresourceName>]` 规则组成 APIs。
- 以 `/apis/<group>/<version>/<resource>[/<resourceName>/<subresource>/<subresourceName>]` 规则组成的 APIs。
注:`[]`包裹的部分表示可选
如果你的 API 不符合以上资源型 API 的规则,则被定型为非资源型 API例如 `/healthz`,需要使用一下配置方式:
```yaml
rules:
# nonResourceURL 中的 '*' 是一个全局通配符
- nonResourceURLs: ["/healthz", "/healthz/*"]
verbs: [ "get", "create"]
```

@ -0,0 +1,675 @@
---
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
```
然后在项目目录执行命令
```shell
./gradlew build
```
使用 `IntelliJ IDEA` 打开 Halo参考 [Halo 开发环境运行](../core/run.md) 及 [插件入门](../hello-world.md) 配置插件的运行模式和路径:
```yaml
halo:
plugin:
runtime-mode: development
fixed-plugin-path:
# 配置为插件绝对路径
- /Users/guqing/halo-plugin-todolist
```
启动 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 示例:
1. 创建 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>
```
这在原先的基础上替换为了 `TypeScipt` 写法,并去除了数据保存到 `LocalStorage` 的逻辑,这也是我们推荐的方式,可读性更强,且有 `TypeScript` 提供类型提示。
至此我们就完成了与插件后端 APIs 实现 Todo List 数据交互的部分。
### 使用 Icon
目前 Todo 的菜单还是默认的网格样式 Icon`console/src/index.ts` 文件中配置有一个 `icon: markRaw(IconGrid)`。以此为例说明该如何使用其他 `Icon`
1. 安装 [unplugin-icons](https://github.com/antfu/unplugin-icons)。
```shell
pnpm install -D unplugin-icons
pnpm install -D @iconify/json
pnpm install -D @vue/compiler-sfc
```
2. 编辑 `console/vite.config.ts`,在 `defineConfig``plugins` 中添加配置,修改如下。
```diff
+ import Icons from "unplugin-icons/vite";
// https://vitejs.dev/config/
export default defineConfig({
- plugins: [vue(), vueJsx()],
+ plugins: [vue(), vueJsx(), Icons({ compiler: "vue3" })],
```
3. 在 `console/tsconfig.app.json` 中加入 `unplugni-icons``types` 配置。
```diff
{
// ...
"compilerOptions": {
// ...
"paths": {
"@/*": ["./src/*"]
- }
+ },
+ "types": ["unplugin-icons/types/vue"]
}
}
```
4. 到 [icones](https://icones.js.org/) 搜索你想要使用的图标,并点击它,然后选择 `Unplugin Icons`,会复制到剪贴板。
![unplugin icons selector](/img/unplugin-icons-example.png)
5. 编辑 `console/src/index.ts``import` 区域粘贴,并 `icon` 属性。
```diff
- import { IconGrid } from "@halo-dev/components";
+ import VscodeIconsFileTypeLightTodo from "~icons/vscode-icons/file-type-light-todo";
export default definePlugin({
routes: [
{
// ...
route: {
path: "/todos",
children: [
{
// ...
meta: {
// ...
menu: {
// ...
- icon: markRaw(IconGrid),
+ icon: markRaw(VscodeIconsFileTypeLightTodo),
priority: 0,
},
},
},
],
},
},
],
// ...
});
```
### 用户界面使用静态资源
如果你想在用户界面中使用图片,你可以放到 `console/src/assets` 中,例如 `logo.png`,并将其作为 Todo 的 Logo 放到标题旁边
![todo logo example](/img/todo-logo-check-48.png)
需要修改 `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,55 @@
---
title: 入门
description: 了解如何构建你的第一个插件并在 Halo 中使用它。
---
Halo 提供了一个模板仓库用于创建插件:
1. 打开 [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 插件模板创建了自己的存储库。接下来,你需要将它 `git clone` 到你的计算机上并使用 `IntelliJ IDEA` 打开它。
## 运行插件
现在有了一个空项目,我们需要让插件能最最小化的运行起来。
这很简单,首先你需要构建插件:只需要在 `halo-plugin-hello-world` 项目目录下执行 Gradle 命令
```shell
./gradlew build
```
或者使用 `IntelliJ IDEA` 提供的 `Gradle build` 即可完成插件项目的构建。
第二步就是使用它。
使用 `IntelliJ IDEA` 打开 Halo参考 [Halo 开发环境运行](../core/run.md)。
然后在 `src/main/resources` 下创建一个 `application-local.yaml` 文件并做如下配置:
```yaml
halo:
plugin:
runtime-mode: development
fixed-plugin-path:
# 配置为插件绝对路径
- /Users/guqing/halo-plugin-hello-world
```
使用此 local profile 启动 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,27 @@
---
title: 介绍
description: 插件开发的准备工作
---
插件是由社区创建的程序或应用程序,用于扩展 Halo 的功能。插件在 Halo 中运行并执行一项或多项用户操作。它们允许用户根据自己的喜好扩展或修改 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,37 @@
---
title: 生命周期
description: 了解插件从启动到卸载的过程
---
根据[插件项目文件结构](./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("插件被删除!");
}
```
### 插件启动
插件被安装后,只加载了插件的 `plugin.yaml`,类及其他资源文件的加载均在启动时进行。
当插件加载完类文件并准备好启动插件后就会调用插件的 `start()` 方法,这有助于插件在启动时做一些事情,例如初始化。
### 插件停止
插件停止时,会删除在启动时创建的自定义资源,例如插件设置等通过 `yaml` 创建的自定义模型资源。
插件定义的自定义模型也需要在此时清理掉。
### 插件删除
插件被卸载时被调用。

@ -0,0 +1,44 @@
---
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 版本SemVer expression, e.g. ">=2.0.0"
- `spec.author`:插件作者的名称和可获得支持的网站地址。
- `spec.logo`:插件 logo可以是域名或相对于项目 src/main/resources 目录的相对文件路径。
- `spec.settingName`:插件配置表单名称,参考表单定义,不需要表单设置则可删除。
- `spec.configMapName`:表单定义对应的值标识名, 推荐命名为 "插件名-configmap",没有配置 `settingName` 则不需要配置此项。
- `spec.homepage`:通常为插件的 Github 仓库链接,或可联系到插件作者或插件官网或帮助中心链接等。
- `spec.displayName`:插件的显示名称,它通常是以少数几个字来概括插件的用途。
- `spec.description`:插件描述,用一段话来介绍插件的用途。
- `spec.license`:插件使用的软件协议,参考:<https://en.wikipedia.org/wiki/Software_license>
:::tip Note
如果你在 plugin.yaml 中配置了 `settingName` 但确没有对应的 `Setting` 自定义模型资源文件,会导致插件无法启动,原因是 `Setting` 模型 `metadata.name` 为你配置的 `settingName` 的资源无法找到。
:::

@ -0,0 +1,48 @@
---
title: 插件中的对象管理
description: 了解如何在创建中创建对象和管理对象依赖
---
在插件中你可以使用 [Spring](https://spring.io) 提供的常用 Bean 注解来标注一个类,然后就能使用依赖注入功能注入其他类的对象。这省去了使用工厂创建类和维护的过程。
通过模板插件创建的项目中你都可以看到 `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 可以供插件依赖注入:
- ReactiveExtensionClient
- ExtensionClient
- SchemeManager
- ExternalUrlSupplier
即其他不在上述列表中的类的对象都是不可依赖注入的。

@ -3,4 +3,13 @@ title: 准备工作
description: 插件开发的准备工作 description: 插件开发的准备工作
--- ---
WIP 在 Halo 中,插件是使用 Java 和 JavaScript 编写的UI 使用 [Vuejs](https://vuejs.org) 编写。
在创建你的第一个插件之前,请确保你具备以下条件:
- 你能成功[运行 Halo 2.0.0 及以上版本](../core/run.md)。
- 你应该能够熟练使用 [IntelliJ IDEA](https://www.jetbrains.com/idea/old)。
- 你需要在计算机上安装最新的 LTS 版本的 Node.js。如果你还没有Node.js安装你可以在这里下载 [Node.js 16 LTS](https://nodejs.org/)。
- 你熟悉 Vue 和 TypeScript。
- 你应该熟悉使用 PNPM 进行包管理,你可以在这里下载 [pnpm 7](https://pnpm.io/)。
- Git 是一个版本控制系统,用于跟踪代码的更改。您需要 Git 来下载示例插件并发布插件。

@ -0,0 +1,19 @@
---
title: 发布插件
description: 了解如何与我们的社区分享你的插件
---
> 了解如何与我们的社区分享您的扩展。
## 创建你的 Release
当你完成了你的插件并进行充分测试后,切换到插件目录 Build 一次,当没有发生任何错误你就可以推送到 Github 并 `Create a new release`
然后填写 `Release Tag` 和描述点击创建,项目目录下的 `.github/workflows/workflow.yaml` 文件会被 `Github Action` 触发并执行,脚本会自动根据你的 `Release Tag` 修改插件版本号然后在 `Release``Asserts` 中包含打包产物--插件的 JAR 文件。
## 分享你的插件
用户可以在你的仓库 Release 下载使用,但为了方便让社区用户看到,你可以在我们的 [awesome-halo](https://github.com/halo-sigs/awesome-halo) 仓库发起一个 Pull Request为此你需要先 Fork [awesome-halo](https://github.com/halo-sigs/awesome-halo) 并按照此仓库的要求添加一行记录是关于你的插件仓库地址和功能描述的,然后推送你的更改并通过 Github 向 [awesome-halo](https://github.com/halo-sigs/awesome-halo) 的 `main` 分支发起 Pull Request。
## 等待审核
在你发起 Pull Request 后我们将审查的你的插件并在需要时请求更改。一旦被接受Pull Request 将被合并。

@ -0,0 +1,35 @@
---
title: 插件运行模式
description: 了解插件的运行方式
---
Halo 的插件可以在两种模式下运行:`DEVELOPMENT` 和 `DEPLOYMENT`
`DEPLOYMENT`(默认)模式是插件创建的标准工作流程:为每个插件创建一个新的 Gradle 项目,编码插件(声明新的扩展点和/或添加新的扩展),将插件打包成一个 JAR 文件,部署 JAR 文件到 Halo。
这些操作非常耗时,因此引入了 `DEVELOPMENT` 运行时模式。
对于插件开发人员来说,`DEVELOPMENT` 运行时模式的主要优点是不必打包和部署插件。在开发模式下,您可以以简单快速的模式开发插件。
### 配置
如果你想以 `DEPLOYMENT` 运行插件则做如下配置:
```yaml
halo:
plugin:
runtime-mode: deployment
```
插件的 `deployment` 模式只允许通过安装 JAR 文件的方式运行插件。
而如果你想以 `DEVELOPMENT` 运行插件或开发插件则将 `runtime-mode` 修改为 `development`,同时配置 `fixed-plugin-path` 为插件项目路径,可以配置多个。
```yaml
halo:
plugin:
runtime-mode: deployment
fixed-plugin-path:
- /path/to/your/plugin/plugin-starter
```
:::tip Note
插件以开发模式运行时由于插件的加载方式与部署模式不同,如果你此时在 Console 安装插件JAR则会提示插件文件找不到而无法启动。
:::

@ -0,0 +1,76 @@
---
title: 项目结构
description: 了解插件的文件结构
---
新创建的插件项目典型的目录结构如下所示:
```text
.
├── LICENSE
├── README.md
├── gradle
│   └── .
├── lib
│   └── halo-2.0.0-SNAPSHOT-plain.jar
├── src
│   ├── main
│   │   ├── java
│   │   │   └── run
│   │   │   └── halo
│   │   │   └── starter
│   │   │   └── StarterPlugin.java
│   │   └── resources
│   │   ├── console
│   │   │   ├── main.js
│   │   │   └── style.css
│   │   └── plugin.yaml
├── gradlew
├── gradlew.bat
├── gradle.properties
├── settings.gradle
├── build.gradle
├── console
│   ├── package.json
│   ├── pnpm-lock.yaml
│   ├── src
│   │   ├── assets
│   │   │   └── logo.svg
│   │   ├── components
│   │   │   └── HelloWorld.vue
│   │   ├── index.ts
│   │   ├── styles
│   │   │   └── index.css
│   │   └── views
│   │   └── DefaultView.vue
│   ├── tsconfig.app.json
│   ├── tsconfig.config.json
│   ├── tsconfig.json
│   ├── tsconfig.vitest.json
│   └── vite.config.ts
```
该目录包含了前端和后端两个部分,让我们依次看一下它们中的每一个。
### 后端部分
所有的后端代码都放在 `src` 目录下,它是一个常规的 `Java` 项目目录结构。
- `StarterPlugin.java` 为插件的后端入口文件。
- `resources` 下的 `plugin.yaml` 为插件的资源描述文件,它是必须的。
- `resources/console` 下的两个文件 `main.js``style.css` 是前端插件部分打包时输出的产物。一个插件可以没有前端部分,因此 `resources/console` 同样可以不存在。
`lib/halo-2.0.0-SNAPSHOT-plain.jar` 它是 Halo 的类型依赖,目前使用 `JAR` 文件的方式引入依赖只是暂时的,后续将会改进它,它只作为编译时依赖使用。
### 前端部分
`console` 目录下为插件的前端部分的工程目录,包括了源码、配置文件和静态资源文件。
同样的,将所有前端项目源码放到 `src` 中。我们建议使用 `TypeScript` 作为编程语言,它可以帮助你在编译时而非运行时捕获错误。
- `src/index.ts` 作为前端部分的插件的入口文件。
- `views` 中存放视图文件。
- `styles` 中存放样式。
- `components` 中放一些公共组件。
- `assets` 用于放静态资源文件。

@ -97,7 +97,50 @@ module.exports = {
link: { link: {
type: "generated-index", type: "generated-index",
}, },
items: ["developer-guide/plugin/prepare"], items: [
"developer-guide/plugin/introduction",
"developer-guide/plugin/prepare",
"developer-guide/plugin/hello-world",
"developer-guide/plugin/publish",
{
type: "category",
label: "基础",
link: {
type: "doc",
id: "developer-guide/plugin/structure",
},
items: [
"developer-guide/plugin/structure",
"developer-guide/plugin/runtime-mode",
"developer-guide/plugin/lifecycle",
"developer-guide/plugin/manifest",
"developer-guide/plugin/object-management",
],
},
{
type: "category",
label: "示例",
link: {
type: "doc",
id: "developer-guide/plugin/examples/todolist",
},
items: ["developer-guide/plugin/examples/todolist"],
},
{
type: "category",
label: "API 参考",
link: {
type: "doc",
id: "developer-guide/plugin/api-reference/extension",
},
items: [
"developer-guide/plugin/api-reference/extension",
"developer-guide/plugin/api-reference/role-template",
"developer-guide/plugin/api-reference/extension-client",
"developer-guide/plugin/api-reference/reverseproxy",
],
},
],
}, },
{ {
type: "category", type: "category",

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

@ -0,0 +1,104 @@
---
title: 与自定义模型交互
description: 了解如果通过代码的方式操作数据
---
Halo 提供了两个类用于与自定义模型数据交互 `ExtensionClient``ReactiveExtensionClient`
它们的本质就是操作数据库,区别在于 `ExtensionClient` 是阻塞式 API`ReactiveExtensionClient` 是响应式 API接口返回值只有两种 Mono 或 Flux它们由 [reactor](https://projectreactor.io/) 提供。
```java
public interface ReactiveExtensionClient {
/**
* Lists Extensions by Extension type, filter and sorter.
*
* @param type is the class type of Extension.
* @param predicate filters the reEnqueue.
* @param comparator sorts the reEnqueue.
* @param <E> is Extension type.
* @return all filtered and sorted Extensions.
*/
<E extends Extension> Flux<E> list(Class<E> type, Predicate<E> predicate,
Comparator<E> comparator);
/**
* Lists Extensions by Extension type, filter, sorter and page info.
*
* @param type is the class type of Extension.
* @param predicate filters the reEnqueue.
* @param comparator sorts the reEnqueue.
* @param page is page number which starts from 0.
* @param size is page size.
* @param <E> is Extension type.
* @return a list of Extensions.
*/
<E extends Extension> Mono<ListResult<E>> list(Class<E> type, Predicate<E> predicate,
Comparator<E> comparator, int page, int size);
/**
* 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
private final ReactiveExtensionClient client;
Mono<Person> getPerson(String name) {
return client.fetch(Person.class, name);
}
```
或者使用阻塞式 API:
```java
private final ExtensionClient client;
Optional<Person> getPerson(String name) {
return client.fetch(Person.class, name);
}
```
我们建议你更多的使用响应式的 `ReactiveExtensionClient` 去替代 `ExtensionClient`

@ -0,0 +1,127 @@
---
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)。设计目的在于为插件提供自定义数据支持。比如某插件需要存储自定义数据,同时也想读取和操作自定义数据。
一个典型的自定义模型 `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(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. 创建一个类继承 `AbstractExtension`
2. 使用 `GVK` 注解。
3. 在插件 `start()` 生命周期方法中注册自定义模型:
```java
@Autowired
private SchemeManager schemeManager;
@Override
public void start() {
schemeManager.register(Person.class);
}
```
有了自定义模型后可以通过在插件项目的 `src/main/resources/extensions` 目录下声明 `yaml` 文件来创建一个实例,此目录下的所有自定义模型 `yaml` 都会在插件启动时被创建:
```yaml
groupVersion: my-plugin.halo.run/v1alpha1
kind: Person
metadata:
name: fake-person
name: halo
age: 18
gender: male
```
:::tip 释义
- @GVK:此注解标识该类为一个自定义模型,同时必须继承 `AbstractExtension`
- kind表示自定义模型所表示的 REST 资源。
- group表示一组公开的资源通常采用域名形式Halo 项目保留使用空组和任何以“*.halo.run”结尾的组名供其单独使用。
选择群组名称时我们建议选择你的群组或组织拥有的子域例如“widget.mycompany.com”。
- versionAPI 的版本,它与 group 组合使用为 apiVersion=“GROUP/VERSION”例如“api.halo.run/v1alpha1”。
- singular: 资源的单数名称这允许客户端不透明地处理复数和单数必须全部小写。通常为小写的“kind”。
- plural 资源的复数名称,自定义资源在 `/apis/<group>/<version>/.../<plural>` 下提供,必须为全部小写。
- @Schema:属性校验注解,会在创建/修改资源前对资源校验,参考 [schema-validator](https://www.openapi4j.org/schema-validator.html)。
:::
### 自定义模型 API
定义好自定义模型并注册后,会根据 `GVK` 注解自动生成一组 `CRUD` API规则为
`/apis/<group>/<version>/<extension>/{extensionname}/<subextension>`
对于上述 Person 自定义模型将有以下 APIs
```text
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}
```
### 自定义 API
在一些场景下,只有自动生成的 `CRUD` API 往往是不够用的,此时可以通过自定义一些 API 来满足功能。
你可以使用 `SpringBoot` 的控制器写法来暴露 API不同的是需要添加 `@ApiVersion` 注解,没有此注解的 `Controller` 将被忽略:
```java
@ApiVersion("v1alpha1")
@RequestMapping("/apples")
@RestController
public class AppleController {
@PostMapping("/starting")
public void starting() {
}
}
```
当插件被启动时Halo 将会为此 AppleController 生成统一路径的 API。API 前缀组成规则如下:
```text
/apis/plugin.api.halo.run/{version}/plugins/{plugin-name}/**
```
示例:
```text
/apis/plugin.api.halo.run/v1alpha1/plugins/my-plugin/apples/starting
```

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

@ -0,0 +1,103 @@
---
title: API 权限控制
description: 了解如果对插件中的 API 定义角色模板以接入权限控制
---
插件中的 APIs 无论是自定义模型自动生成的 APIs 或者是通过 Controller 自定义的 APIs 都只有超级管理员能够访问,如果想将这些 APIs 授权给其他用户访问则需要定义一些 RoleTemplate 的资源以便可以在用户界面上将其分配给其他角色使用。
RoleTemplate 的 yaml 资源也需要放到 `src/main/resources/extensions` 目录下,文件名称可以任意,它的 kind 为 Role 但需要一个 label `halo.run/role-template: "true"` 来表示它是角色模板。
Halo 的权限控制对同一种资源一般只定义两个 RoleTemplate一个是只读权限另一个是管理权限因此如果没有特殊情况需要更细粒度的控制我们建议你也保持一致
```yaml
apiVersion: v1
kind: Role
metadata:
# 使用 plugin name 作为前缀防止与其他插件冲突
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 自定义模型配置角色模板的示例。
定义了一个用于管理 Person 资源的角色模板 `my-plugin-role-manage-persons`,它具有所有权限,同时定义了一个只允许查询 Person 资源的角色模板 `my-plugin-role-view-persons`
下面让我们回顾一下这些配置:
`rules` 是个数组,它允许配置多组规则:
- `apiGroups` 对应 `GVK` 中的 `group` 所声明的值。
- `resources` 对应 API 中的 resource 部分,`my-plugin` 表示插件名称。
- `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>/<subresourceName>]` 规则组成 APIs。
- 以 `/apis/<group>/<version>/<resource>[/<resourceName>/<subresource>/<subresourceName>]` 规则组成的 APIs。
注:`[]`包裹的部分表示可选
如果你的 API 不符合以上资源型 API 的规则,则被定型为非资源型 API例如 `/healthz`,需要使用一下配置方式:
```yaml
rules:
# nonResourceURL 中的 '*' 是一个全局通配符
- nonResourceURLs: ["/healthz", "/healthz/*"]
verbs: [ "get", "create"]
```

@ -0,0 +1,675 @@
---
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
```
然后在项目目录执行命令
```shell
./gradlew build
```
使用 `IntelliJ IDEA` 打开 Halo参考 [Halo 开发环境运行](../core/run.md) 及 [插件入门](../hello-world.md) 配置插件的运行模式和路径:
```yaml
halo:
plugin:
runtime-mode: development
fixed-plugin-path:
# 配置为插件绝对路径
- /Users/guqing/halo-plugin-todolist
```
启动 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 示例:
1. 创建 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>
```
这在原先的基础上替换为了 `TypeScipt` 写法,并去除了数据保存到 `LocalStorage` 的逻辑,这也是我们推荐的方式,可读性更强,且有 `TypeScript` 提供类型提示。
至此我们就完成了与插件后端 APIs 实现 Todo List 数据交互的部分。
### 使用 Icon
目前 Todo 的菜单还是默认的网格样式 Icon`console/src/index.ts` 文件中配置有一个 `icon: markRaw(IconGrid)`。以此为例说明该如何使用其他 `Icon`
1. 安装 [unplugin-icons](https://github.com/antfu/unplugin-icons)。
```shell
pnpm install -D unplugin-icons
pnpm install -D @iconify/json
pnpm install -D @vue/compiler-sfc
```
2. 编辑 `console/vite.config.ts`,在 `defineConfig``plugins` 中添加配置,修改如下。
```diff
+ import Icons from "unplugin-icons/vite";
// https://vitejs.dev/config/
export default defineConfig({
- plugins: [vue(), vueJsx()],
+ plugins: [vue(), vueJsx(), Icons({ compiler: "vue3" })],
```
3. 在 `console/tsconfig.app.json` 中加入 `unplugni-icons``types` 配置。
```diff
{
// ...
"compilerOptions": {
// ...
"paths": {
"@/*": ["./src/*"]
- }
+ },
+ "types": ["unplugin-icons/types/vue"]
}
}
```
4. 到 [icones](https://icones.js.org/) 搜索你想要使用的图标,并点击它,然后选择 `Unplugin Icons`,会复制到剪贴板。
![unplugin icons selector](/img/unplugin-icons-example.png)
5. 编辑 `console/src/index.ts``import` 区域粘贴,并 `icon` 属性。
```diff
- import { IconGrid } from "@halo-dev/components";
+ import VscodeIconsFileTypeLightTodo from "~icons/vscode-icons/file-type-light-todo";
export default definePlugin({
routes: [
{
// ...
route: {
path: "/todos",
children: [
{
// ...
meta: {
// ...
menu: {
// ...
- icon: markRaw(IconGrid),
+ icon: markRaw(VscodeIconsFileTypeLightTodo),
priority: 0,
},
},
},
],
},
},
],
// ...
});
```
### 用户界面使用静态资源
如果你想在用户界面中使用图片,你可以放到 `console/src/assets` 中,例如 `logo.png`,并将其作为 Todo 的 Logo 放到标题旁边
![todo logo example](/img/todo-logo-check-48.png)
需要修改 `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,55 @@
---
title: 入门
description: 了解如何构建你的第一个插件并在 Halo 中使用它。
---
Halo 提供了一个模板仓库用于创建插件:
1. 打开 [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 插件模板创建了自己的存储库。接下来,你需要将它 `git clone` 到你的计算机上并使用 `IntelliJ IDEA` 打开它。
## 运行插件
现在有了一个空项目,我们需要让插件能最最小化的运行起来。
这很简单,首先你需要构建插件:只需要在 `halo-plugin-hello-world` 项目目录下执行 Gradle 命令
```shell
./gradlew build
```
或者使用 `IntelliJ IDEA` 提供的 `Gradle build` 即可完成插件项目的构建。
第二步就是使用它。
使用 `IntelliJ IDEA` 打开 Halo参考 [Halo 开发环境运行](../core/run.md)。
然后在 `src/main/resources` 下创建一个 `application-local.yaml` 文件并做如下配置:
```yaml
halo:
plugin:
runtime-mode: development
fixed-plugin-path:
# 配置为插件绝对路径
- /Users/guqing/halo-plugin-hello-world
```
使用此 local profile 启动 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,27 @@
---
title: 介绍
description: 插件开发的准备工作
---
插件是由社区创建的程序或应用程序,用于扩展 Halo 的功能。插件在 Halo 中运行并执行一项或多项用户操作。它们允许用户根据自己的喜好扩展或修改 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,37 @@
---
title: 生命周期
description: 了解插件从启动到卸载的过程
---
根据[插件项目文件结构](./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("插件被删除!");
}
```
### 插件启动
插件被安装后,只加载了插件的 `plugin.yaml`,类及其他资源文件的加载均在启动时进行。
当插件加载完类文件并准备好启动插件后就会调用插件的 `start()` 方法,这有助于插件在启动时做一些事情,例如初始化。
### 插件停止
插件停止时,会删除在启动时创建的自定义资源,例如插件设置等通过 `yaml` 创建的自定义模型资源。
插件定义的自定义模型也需要在此时清理掉。
### 插件删除
插件被卸载时被调用。

@ -0,0 +1,44 @@
---
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 版本SemVer expression, e.g. ">=2.0.0"
- `spec.author`:插件作者的名称和可获得支持的网站地址。
- `spec.logo`:插件 logo可以是域名或相对于项目 src/main/resources 目录的相对文件路径。
- `spec.settingName`:插件配置表单名称,参考表单定义,不需要表单设置则可删除。
- `spec.configMapName`:表单定义对应的值标识名, 推荐命名为 "插件名-configmap",没有配置 `settingName` 则不需要配置此项。
- `spec.homepage`:通常为插件的 Github 仓库链接,或可联系到插件作者或插件官网或帮助中心链接等。
- `spec.displayName`:插件的显示名称,它通常是以少数几个字来概括插件的用途。
- `spec.description`:插件描述,用一段话来介绍插件的用途。
- `spec.license`:插件使用的软件协议,参考:<https://en.wikipedia.org/wiki/Software_license>
:::tip Note
如果你在 plugin.yaml 中配置了 `settingName` 但确没有对应的 `Setting` 自定义模型资源文件,会导致插件无法启动,原因是 `Setting` 模型 `metadata.name` 为你配置的 `settingName` 的资源无法找到。
:::

@ -0,0 +1,48 @@
---
title: 插件中的对象管理
description: 了解如何在创建中创建对象和管理对象依赖
---
在插件中你可以使用 [Spring](https://spring.io) 提供的常用 Bean 注解来标注一个类,然后就能使用依赖注入功能注入其他类的对象。这省去了使用工厂创建类和维护的过程。
通过模板插件创建的项目中你都可以看到 `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 可以供插件依赖注入:
- ReactiveExtensionClient
- ExtensionClient
- SchemeManager
- ExternalUrlSupplier
即其他不在上述列表中的类的对象都是不可依赖注入的。

@ -3,4 +3,13 @@ title: 准备工作
description: 插件开发的准备工作 description: 插件开发的准备工作
--- ---
WIP 在 Halo 中,插件是使用 Java 和 JavaScript 编写的UI 使用 [Vuejs](https://vuejs.org) 编写。
在创建你的第一个插件之前,请确保你具备以下条件:
- 你能成功[运行 Halo 2.0.0 及以上版本](../core/run.md)。
- 你应该能够熟练使用 [IntelliJ IDEA](https://www.jetbrains.com/idea/old)。
- 你需要在计算机上安装最新的 LTS 版本的 Node.js。如果你还没有Node.js安装你可以在这里下载 [Node.js 16 LTS](https://nodejs.org/)。
- 你熟悉 Vue 和 TypeScript。
- 你应该熟悉使用 PNPM 进行包管理,你可以在这里下载 [pnpm 7](https://pnpm.io/)。
- Git 是一个版本控制系统,用于跟踪代码的更改。您需要 Git 来下载示例插件并发布插件。

@ -0,0 +1,19 @@
---
title: 发布插件
description: 了解如何与我们的社区分享你的插件
---
> 了解如何与我们的社区分享您的扩展。
## 创建你的 Release
当你完成了你的插件并进行充分测试后,切换到插件目录 Build 一次,当没有发生任何错误你就可以推送到 Github 并 `Create a new release`
然后填写 `Release Tag` 和描述点击创建,项目目录下的 `.github/workflows/workflow.yaml` 文件会被 `Github Action` 触发并执行,脚本会自动根据你的 `Release Tag` 修改插件版本号然后在 `Release``Asserts` 中包含打包产物--插件的 JAR 文件。
## 分享你的插件
用户可以在你的仓库 Release 下载使用,但为了方便让社区用户看到,你可以在我们的 [awesome-halo](https://github.com/halo-sigs/awesome-halo) 仓库发起一个 Pull Request为此你需要先 Fork [awesome-halo](https://github.com/halo-sigs/awesome-halo) 并按照此仓库的要求添加一行记录是关于你的插件仓库地址和功能描述的,然后推送你的更改并通过 Github 向 [awesome-halo](https://github.com/halo-sigs/awesome-halo) 的 `main` 分支发起 Pull Request。
## 等待审核
在你发起 Pull Request 后我们将审查的你的插件并在需要时请求更改。一旦被接受Pull Request 将被合并。

@ -0,0 +1,35 @@
---
title: 插件运行模式
description: 了解插件的运行方式
---
Halo 的插件可以在两种模式下运行:`DEVELOPMENT` 和 `DEPLOYMENT`
`DEPLOYMENT`(默认)模式是插件创建的标准工作流程:为每个插件创建一个新的 Gradle 项目,编码插件(声明新的扩展点和/或添加新的扩展),将插件打包成一个 JAR 文件,部署 JAR 文件到 Halo。
这些操作非常耗时,因此引入了 `DEVELOPMENT` 运行时模式。
对于插件开发人员来说,`DEVELOPMENT` 运行时模式的主要优点是不必打包和部署插件。在开发模式下,您可以以简单快速的模式开发插件。
### 配置
如果你想以 `DEPLOYMENT` 运行插件则做如下配置:
```yaml
halo:
plugin:
runtime-mode: deployment
```
插件的 `deployment` 模式只允许通过安装 JAR 文件的方式运行插件。
而如果你想以 `DEVELOPMENT` 运行插件或开发插件则将 `runtime-mode` 修改为 `development`,同时配置 `fixed-plugin-path` 为插件项目路径,可以配置多个。
```yaml
halo:
plugin:
runtime-mode: deployment
fixed-plugin-path:
- /path/to/your/plugin/plugin-starter
```
:::tip Note
插件以开发模式运行时由于插件的加载方式与部署模式不同,如果你此时在 Console 安装插件JAR则会提示插件文件找不到而无法启动。
:::

@ -0,0 +1,76 @@
---
title: 项目结构
description: 了解插件的文件结构
---
新创建的插件项目典型的目录结构如下所示:
```text
.
├── LICENSE
├── README.md
├── gradle
│   └── .
├── lib
│   └── halo-2.0.0-SNAPSHOT-plain.jar
├── src
│   ├── main
│   │   ├── java
│   │   │   └── run
│   │   │   └── halo
│   │   │   └── starter
│   │   │   └── StarterPlugin.java
│   │   └── resources
│   │   ├── console
│   │   │   ├── main.js
│   │   │   └── style.css
│   │   └── plugin.yaml
├── gradlew
├── gradlew.bat
├── gradle.properties
├── settings.gradle
├── build.gradle
├── console
│   ├── package.json
│   ├── pnpm-lock.yaml
│   ├── src
│   │   ├── assets
│   │   │   └── logo.svg
│   │   ├── components
│   │   │   └── HelloWorld.vue
│   │   ├── index.ts
│   │   ├── styles
│   │   │   └── index.css
│   │   └── views
│   │   └── DefaultView.vue
│   ├── tsconfig.app.json
│   ├── tsconfig.config.json
│   ├── tsconfig.json
│   ├── tsconfig.vitest.json
│   └── vite.config.ts
```
该目录包含了前端和后端两个部分,让我们依次看一下它们中的每一个。
### 后端部分
所有的后端代码都放在 `src` 目录下,它是一个常规的 `Java` 项目目录结构。
- `StarterPlugin.java` 为插件的后端入口文件。
- `resources` 下的 `plugin.yaml` 为插件的资源描述文件,它是必须的。
- `resources/console` 下的两个文件 `main.js``style.css` 是前端插件部分打包时输出的产物。一个插件可以没有前端部分,因此 `resources/console` 同样可以不存在。
`lib/halo-2.0.0-SNAPSHOT-plain.jar` 它是 Halo 的类型依赖,目前使用 `JAR` 文件的方式引入依赖只是暂时的,后续将会改进它,它只作为编译时依赖使用。
### 前端部分
`console` 目录下为插件的前端部分的工程目录,包括了源码、配置文件和静态资源文件。
同样的,将所有前端项目源码放到 `src` 中。我们建议使用 `TypeScript` 作为编程语言,它可以帮助你在编译时而非运行时捕获错误。
- `src/index.ts` 作为前端部分的插件的入口文件。
- `views` 中存放视图文件。
- `styles` 中存放样式。
- `components` 中放一些公共组件。
- `assets` 用于放静态资源文件。

@ -77,7 +77,50 @@
"link": { "link": {
"type": "generated-index" "type": "generated-index"
}, },
"items": ["developer-guide/plugin/prepare"] "items": [
"developer-guide/plugin/introduction",
"developer-guide/plugin/prepare",
"developer-guide/plugin/hello-world",
"developer-guide/plugin/publish",
{
"type": "category",
"label": "基础",
"link": {
"type": "doc",
"id": "developer-guide/plugin/structure"
},
"items": [
"developer-guide/plugin/structure",
"developer-guide/plugin/runtime-mode",
"developer-guide/plugin/lifecycle",
"developer-guide/plugin/manifest",
"developer-guide/plugin/object-management"
]
},
{
"type": "category",
"label": "示例",
"link": {
"type": "doc",
"id": "developer-guide/plugin/examples/todolist"
},
"items": ["developer-guide/plugin/examples/todolist"]
},
{
"type": "category",
"label": "API 参考",
"link": {
"type": "doc",
"id": "developer-guide/plugin/api-reference/extension"
},
"items": [
"developer-guide/plugin/api-reference/extension",
"developer-guide/plugin/api-reference/role-template",
"developer-guide/plugin/api-reference/extension-client",
"developer-guide/plugin/api-reference/reverseproxy"
]
}
]
}, },
{ {
"type": "category", "type": "category",

Loading…
Cancel
Save