feat: 初始化 Halo 暗色模式插件
- Halo Plugin 后端(Java/Gradle),含 DarkModePlugin 主类和测试 - Vue 3 + TypeScript 前端 UI,包含主题切换组件和设置页面 - 暗色模式 CSS 变量和覆盖样式(布局/编辑器/表单/滚动条等) - 设计文档和调查文档 - Halo 插件/主题开发 Agent Skills Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
# Plugin API Changelog
|
||||
|
||||
Read the official changelog before using version-sensitive plugin APIs,
|
||||
upgrading Halo dependencies, or raising `spec.requires`.
|
||||
|
||||
Official docs:
|
||||
|
||||
- Plugin API changelog: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-changelog.md
|
||||
- Form schema: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/form-schema.md
|
||||
|
||||
High-impact changes:
|
||||
|
||||
| Halo version | Change | Skill reference |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
|
||||
| 2.25.0 | `select` options support `icon` and `description`; remote selects support `requestOption.iconField` and `descriptionField` | [ui-forms.md](ui-forms.md) |
|
||||
| 2.25.0 | Plugin UI can register custom FormKit inputs through `definePlugin({ formkit: { inputs } })` | [ui-entry.md](ui-entry.md), [ui-forms.md](ui-forms.md) |
|
||||
| 2.25.0 | `secret` FormKit input supports `descriptionPreset` | [ui-forms.md](ui-forms.md) |
|
||||
| 2.23.0 | Spring Boot 4 upgrade can break plugins using Spring APIs; upgrade the Halo platform dependency and `spec.requires` together | [devtools.md](devtools.md), [plugin-manifest.md](plugin-manifest.md) |
|
||||
| 2.23.0 | `iconify` supports optional `sizing` config | [ui-forms.md](ui-forms.md) |
|
||||
| 2.22.8 | `toggle` FormKit input added | [ui-forms.md](ui-forms.md) |
|
||||
| 2.22.5 | SpringDoc update can break OpenAPI documentation generation; upgrade the Halo platform dependency and `spec.requires` together | [server-api.md](server-api.md), [devtools.md](devtools.md) |
|
||||
| 2.22.2 | `switch` FormKit input added | [ui-forms.md](ui-forms.md) |
|
||||
| 2.22.0 | Custom model index/query APIs changed: use `IndexSpecs.single/multi`, `Queries`, and newer `ExtensionClient` query helpers | [server-extension.md](server-extension.md) |
|
||||
| 2.22.0 | `@halo-dev/console-shared` was renamed to `@halo-dev/ui-shared` | [ui-shared.md](ui-shared.md) |
|
||||
| 2.22.0 | Attachment selector extension results need `mediaType` on `AttachmentLike` | [ui-extension-points.md](ui-extension-points.md) |
|
||||
|
||||
When a feature requires a newer Halo runtime, update dependency versions and
|
||||
`plugin.yaml` `spec.requires` together. Do not raise `spec.requires` for an
|
||||
optional UI enhancement unless the plugin cannot run without it.
|
||||
@@ -0,0 +1,171 @@
|
||||
# DevTools (run.halo.plugin.devtools)
|
||||
|
||||
A Gradle plugin for streamlined plugin development. Requires Docker.
|
||||
|
||||
> - [halo-gradle-plugin repo](https://github.com/halo-sigs/halo-gradle-plugin)
|
||||
> - [halo-gradle-plugin releases](https://github.com/halo-sigs/halo-gradle-plugin/releases)
|
||||
|
||||
## Quick Commands
|
||||
|
||||
| Command | Purpose |
|
||||
| --------------------------------- | --------------------------------------------------- |
|
||||
| `./gradlew haloServer` | Start Halo in Docker with plugin loaded in dev mode |
|
||||
| `./gradlew reload` | Reload plugin changes without restarting Halo |
|
||||
| `./gradlew watch` | Auto-reload on file changes |
|
||||
| `./gradlew build` | Build plugin JAR (includes frontend build) |
|
||||
| `./gradlew generateApiClient` | Generate TypeScript API client from OpenAPI |
|
||||
| `./gradlew generateRoleTemplates` | Generate role template YAML from OpenAPI |
|
||||
|
||||
## build.gradle Setup
|
||||
|
||||
```groovy
|
||||
plugins {
|
||||
id 'java'
|
||||
id "io.freefair.lombok" version "8.13"
|
||||
id "run.halo.plugin.devtools" version "0.6.0"
|
||||
}
|
||||
|
||||
group = 'com.example.myplugin'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation platform('run.halo.tools.platform:plugin:2.22.0')
|
||||
compileOnly 'run.halo.app:api'
|
||||
|
||||
testImplementation 'run.halo.app:api'
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.encoding = "UTF-8"
|
||||
options.release = 21
|
||||
}
|
||||
|
||||
// UI build integration (if UI is in a separate subproject)
|
||||
tasks.register('processUiResources', Copy) {
|
||||
from project(':ui').layout.buildDirectory.dir('dist')
|
||||
into layout.buildDirectory.dir('resources/main/console')
|
||||
dependsOn project(':ui').tasks.named('assemble')
|
||||
shouldRunAfter tasks.named('processResources')
|
||||
}
|
||||
|
||||
tasks.named('classes') {
|
||||
dependsOn tasks.named('processUiResources')
|
||||
}
|
||||
|
||||
halo {
|
||||
version = '2.22'
|
||||
superAdminUsername = 'admin'
|
||||
superAdminPassword = 'admin'
|
||||
port = 8090
|
||||
// debug = true
|
||||
// debugPort = 5005
|
||||
// suspend = true
|
||||
externalUrl = 'http://localhost:8090'
|
||||
}
|
||||
```
|
||||
|
||||
## halo {} Block Options
|
||||
|
||||
| Option | Description | Default |
|
||||
| -------------------- | ------------------------------------------ | ----------------------------------------- |
|
||||
| `version` | Halo Docker image version | `'2.9.1'` |
|
||||
| `superAdminUsername` | Auto-created admin username | `'admin'` |
|
||||
| `superAdminPassword` | Auto-created admin password | `'admin'` |
|
||||
| `port` | Halo server port | `8090` |
|
||||
| `externalUrl` | External access URL | `'http://localhost:8090'` |
|
||||
| `debug` | Enable JDWP debug | `false` |
|
||||
| `debugPort` | JDWP port | auto-assigned |
|
||||
| `suspend` | Suspend on startup until debugger connects | `false` |
|
||||
| `docker.url` | Docker daemon URL | `unix:///var/run/docker.sock` (Mac/Linux) |
|
||||
| `docker.apiVersion` | Docker API version | `'1.42'` |
|
||||
|
||||
## Watch Configuration
|
||||
|
||||
```groovy
|
||||
haloPlugin {
|
||||
watchDomains {
|
||||
consoleSource {
|
||||
files files('ui/src/')
|
||||
// exclude '**/node_modules/**'
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Generate API Client
|
||||
|
||||
Requires OpenAPI grouping configuration in `build.gradle`:
|
||||
|
||||
```groovy
|
||||
haloPlugin {
|
||||
openApi {
|
||||
groupingRules {
|
||||
extensionApis {
|
||||
displayName = 'Extension API for MyPlugin'
|
||||
pathsToMatch = ['/apis/my-plugin.halo.run/v1alpha1/**']
|
||||
}
|
||||
}
|
||||
groupedApiMappings = [
|
||||
'/v3/api-docs/extensionApis': 'extensionApis.json'
|
||||
]
|
||||
generator {
|
||||
outputDir = file("${projectDir}/ui/src/api/generated")
|
||||
additionalProperties = [
|
||||
useES6: true,
|
||||
useSingleRequestParameter: true,
|
||||
withSeparateModelsAndApi: true,
|
||||
apiPackage: "api",
|
||||
modelPackage: "models"
|
||||
]
|
||||
typeMappings = [
|
||||
set: "Array"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Usage in TypeScript:
|
||||
|
||||
```ts
|
||||
import { axiosInstance } from "@halo-dev/api-client";
|
||||
import { MyResourceV1alpha1Api } from "./api/generated";
|
||||
|
||||
const api = new MyResourceV1alpha1Api(undefined, "", axiosInstance);
|
||||
const { data } = await api.listMyResources({});
|
||||
```
|
||||
|
||||
## Generate Role Templates
|
||||
|
||||
After configuring OpenApi grouping, run:
|
||||
|
||||
```bash
|
||||
./gradlew generateRoleTemplates
|
||||
```
|
||||
|
||||
Generates `roleTemplates.yaml` in the `workplace/` directory. Review and customize before adding to `src/main/resources/extensions/`.
|
||||
|
||||
## Custom Halo Config
|
||||
|
||||
Place `workplace/config/application.yaml` to override Halo defaults:
|
||||
|
||||
```yaml
|
||||
logging:
|
||||
level:
|
||||
run.halo.app: DEBUG
|
||||
```
|
||||
@@ -0,0 +1,98 @@
|
||||
# Plugin Interaction
|
||||
|
||||
Use this reference when a plugin depends on another plugin, exposes Java types
|
||||
for other plugins, shares events, or defines/consumes extension points.
|
||||
|
||||
Official docs:
|
||||
|
||||
- Dependencies: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/interaction/dependency.md
|
||||
- Shared events: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/interaction/shared-events.md
|
||||
- Making a plugin extensible: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/interaction/making-plugin-extensible.md
|
||||
- ExtensionGetter: https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/server/extension-getter.md
|
||||
|
||||
## Dependencies
|
||||
|
||||
Declare runtime plugin dependencies in `plugin.yaml` under
|
||||
`spec.pluginDependencies`.
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
pluginDependencies:
|
||||
required-plugin: ">=1.0.0 & <2.0.0"
|
||||
optional-plugin?: "1.*"
|
||||
```
|
||||
|
||||
- Dependency keys are plugin `metadata.name` values.
|
||||
- Optional dependencies use a `?` suffix and require Halo 2.20.11+.
|
||||
- Prefer explicit versions or ranges. Avoid broad `*` constraints for production
|
||||
plugins.
|
||||
|
||||
## API Modules
|
||||
|
||||
When other plugins need to compile against your public Java types, put those
|
||||
types in a separate API module and publish it. Keep implementation code in the
|
||||
plugin module.
|
||||
|
||||
Consumer plugins should normally depend on the provider API module with
|
||||
`compileOnly`, not package the provider classes into their plugin jar.
|
||||
|
||||
```groovy
|
||||
dependencies {
|
||||
compileOnly "run.halo.example:plugin-a-api:1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
## Shared Events
|
||||
|
||||
Use Spring events for plugin-to-plugin notifications. Mark custom event classes
|
||||
with `@SharedEvent` when dependent plugins should be able to listen to them.
|
||||
|
||||
```java
|
||||
import org.springframework.context.ApplicationEvent;
|
||||
import run.halo.app.plugin.SharedEvent;
|
||||
|
||||
@SharedEvent
|
||||
public class CustomSharedEvent extends ApplicationEvent {
|
||||
public CustomSharedEvent(Object source) {
|
||||
super(source);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Listen with `@EventListener` or `ApplicationListener`. Built-in shared events
|
||||
include post publish/update/delete/visibility changes, user login/logout, and
|
||||
third-party login disconnection events. Check the official docs for the current
|
||||
event class names before importing.
|
||||
|
||||
## Extension Points
|
||||
|
||||
To make a plugin extensible:
|
||||
|
||||
1. Define an interface that extends `org.pf4j.ExtensionPoint`.
|
||||
2. Declare an `ExtensionPointDefinition` resource under
|
||||
`src/main/resources/extensions/`.
|
||||
3. Publish the interface in an API module so extension plugins can compile
|
||||
against it.
|
||||
4. Resolve enabled implementations with `ExtensionGetter`.
|
||||
|
||||
```yaml
|
||||
apiVersion: plugin.halo.run/v1alpha1
|
||||
kind: ExtensionPointDefinition
|
||||
metadata:
|
||||
name: my-plugin-reactive-notifier
|
||||
spec:
|
||||
className: run.halo.example.ReactiveNotifier
|
||||
displayName: "Reactive Notifier"
|
||||
description: "Extends notification delivery"
|
||||
type: MULTI_INSTANCE
|
||||
```
|
||||
|
||||
Use a plugin-prefixed `metadata.name` to avoid collisions. Use
|
||||
`SINGLE_INSTANCE` only when exactly one enabled implementation makes sense;
|
||||
otherwise use `MULTI_INSTANCE`.
|
||||
|
||||
```java
|
||||
extensionGetter.getEnabledExtensions(ReactiveNotifier.class)
|
||||
.flatMap(notifier -> notifier.notify(context))
|
||||
.then();
|
||||
```
|
||||
@@ -0,0 +1,102 @@
|
||||
# Plugin Manifest (plugin.yaml)
|
||||
|
||||
Located at `src/main/resources/plugin.yaml`. Required.
|
||||
|
||||
> - [create-halo-plugin repo](https://github.com/halo-dev/create-halo-plugin)
|
||||
|
||||
## Minimal Example
|
||||
|
||||
```yaml
|
||||
apiVersion: plugin.halo.run/v1alpha1
|
||||
kind: Plugin
|
||||
metadata:
|
||||
name: hello-world
|
||||
spec:
|
||||
enabled: true
|
||||
requires: ">=2.22.0"
|
||||
author:
|
||||
name: Halo
|
||||
website: https://www.halo.run
|
||||
logo: logo.svg
|
||||
displayName: "Hello World"
|
||||
description: "A minimal Halo plugin"
|
||||
license:
|
||||
- name: "GPL-3.0"
|
||||
url: "https://github.com/example/plugin/blob/main/LICENSE"
|
||||
```
|
||||
|
||||
## Field Reference
|
||||
|
||||
| Field | Description | Required |
|
||||
| ------------------------------------- | --------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `apiVersion` / `kind` | Fixed: `plugin.halo.run/v1alpha1` / `Plugin` | Yes |
|
||||
| `metadata.name` | Unique plugin ID. Max 253 chars, lowercase letters/numbers/hyphens only, must start/end with alphanumeric | Yes |
|
||||
| `spec.enabled` | Auto-enable on install. For production, prefer `false` for security | Yes |
|
||||
| `spec.requires` | Supported Halo version range. SemVer ranges: `>=2.22.0`, `^2.22.0`, `2.22.*`, etc. | Yes |
|
||||
| `spec.version` | Plugin version (e.g., `1.0.0`) | Recommended |
|
||||
| `spec.author.name` / `author.website` | Author info | Recommended |
|
||||
| `spec.logo` | Logo file (relative to `src/main/resources/`) or URL | Recommended |
|
||||
| `spec.displayName` | Human-readable name | Yes |
|
||||
| `spec.description` | Short description | Yes |
|
||||
| `spec.homepage` | Plugin homepage/docs URL | Recommended |
|
||||
| `spec.repo` | Source repository URL | Recommended |
|
||||
| `spec.issues` | Issue tracker URL | Recommended |
|
||||
| `spec.license` | License name + URL array | Recommended |
|
||||
| `spec.settingName` | Setting resource name for plugin config form. Suffix with `-settings` | Optional |
|
||||
| `spec.configMapName` | ConfigMap name for persisting config. Suffix with `-configmap`. Required if `settingName` is set | Conditional |
|
||||
| `spec.pluginDependencies` | Map of `pluginName: versionRange`. Optional deps suffix name with `?` (Halo 2.20.11+) | Optional |
|
||||
|
||||
## Plugin Dependencies
|
||||
|
||||
For dependency design, optional dependencies, and API modules, see
|
||||
[plugin-interaction.md](plugin-interaction.md).
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
pluginDependencies:
|
||||
# Required dependency
|
||||
some-plugin: ">=1.0.0 & <2.0.0"
|
||||
# Optional dependency (Halo 2.20.11+)
|
||||
optional-plugin?: "1.*"
|
||||
```
|
||||
|
||||
## Settings & ConfigMap
|
||||
|
||||
To provide a user-configurable form in Console:
|
||||
|
||||
1. Set `spec.settingName: my-plugin-settings` and `spec.configMapName: my-plugin-configmap` in `plugin.yaml`
|
||||
2. Create `src/main/resources/extensions/settings.yaml`:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1alpha1
|
||||
kind: Setting
|
||||
metadata:
|
||||
name: my-plugin-settings # must match spec.settingName
|
||||
spec:
|
||||
forms:
|
||||
- group: basic
|
||||
label: Basic Settings
|
||||
formSchema:
|
||||
- $formkit: text
|
||||
name: apiKey
|
||||
label: API Key
|
||||
- $formkit: switch
|
||||
name: enabled
|
||||
label: Enable Feature
|
||||
```
|
||||
|
||||
> If `settingName` is set but the corresponding `Setting` resource does not exist, the plugin will fail to start.
|
||||
>
|
||||
> For full form input options and Vue component usage, see [ui-forms.md](ui-forms.md).
|
||||
>
|
||||
> After setting this up, read the config at runtime using [`ReactiveSettingFetcher` or `SettingFetcher`](server-shared-beans.md#reactivesettingfetcher--settingfetcher).
|
||||
|
||||
## App Store Annotations
|
||||
|
||||
For distribution via Halo App Store:
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
annotations:
|
||||
store.halo.run/app-id: "app-XXXXX"
|
||||
```
|
||||
@@ -0,0 +1,46 @@
|
||||
# Plugin Project Structure
|
||||
|
||||
## Typical Directory Layout
|
||||
|
||||
```
|
||||
my-halo-plugin/
|
||||
├── ui/ # Frontend source (Vue + TypeScript)
|
||||
│ ├── src/
|
||||
│ │ ├── assets/
|
||||
│ │ ├── views/ # Page components
|
||||
│ │ ├── components/ # Reusable components
|
||||
│ │ └── index.ts # Plugin entry (definePlugin)
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ ├── vite.config.ts | rsbuild.config.ts
|
||||
│ └── ...
|
||||
├── src/main/
|
||||
│ ├── java/
|
||||
│ │ └── com/example/myplugin/
|
||||
│ │ └── MyPlugin.java # Entry class extending BasePlugin
|
||||
│ └── resources/
|
||||
│ ├── plugin.yaml # Plugin manifest (required)
|
||||
│ ├── console/ # Built frontend output (main.js + style.css)
|
||||
│ ├── extensions/ # YAML extension declarations
|
||||
│ ├── templates/ # Thymeleaf templates (optional, for theme integration)
|
||||
│ └── static/ # Static assets served at /plugins/{name}/assets (optional)
|
||||
├── build.gradle # Gradle build config
|
||||
├── gradle.properties
|
||||
├── settings.gradle
|
||||
├── gradlew
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Backend (`src/main/java/`)
|
||||
|
||||
- **Entry class**: One class extending `run.halo.app.plugin.BasePlugin`, annotated with `@Component`. It is the only lifecycle entry point.
|
||||
- **Spring features supported**: Core IoC, WebFlux reactive stack, Testing. Standard annotations: `@Component`, `@Service`, `@Repository`, `@Configuration`, `@Controller`, etc.
|
||||
- **Resources**: `src/main/resources/plugin.yaml` is mandatory. `src/main/resources/extensions/` holds YAML declarations for custom extensions, role templates, settings, etc.
|
||||
|
||||
## Frontend (`ui/` or `console/`)
|
||||
|
||||
- **Entry file**: `ui/src/index.ts` (or `console/src/index.ts`) exports a default object created by `definePlugin()`.
|
||||
- **Build output**: compiled to `src/main/resources/console/main.js` + `style.css`. Halo merges all plugin JS/CSS into global bundles.
|
||||
- **Build tools**: Vite or Rsbuild via `@halo-dev/ui-plugin-bundler-kit`.
|
||||
|
||||
> From Halo 2.11+, UC (User Center) shares the same plugin mechanism. `resources/console` may be renamed to `resources/ui` in future but both are compatible.
|
||||
@@ -0,0 +1,218 @@
|
||||
# Custom APIs
|
||||
|
||||
Halo plugins can define custom APIs in addition to auto-generated CRUD APIs.
|
||||
|
||||
> Source references (Halo main branch):
|
||||
>
|
||||
> - [CustomEndpoint](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/core/extension/endpoint/CustomEndpoint.java)
|
||||
> - [ApiVersion](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/plugin/ApiVersion.java)
|
||||
> - [SpringdocRouteBuilder](https://github.com/halo-dev/halo/blob/main/application/src/main/java/run/halo/app/infra/utils/SpringdocRouteBuilder.java)
|
||||
> - [SortableRequest](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/extension/router/SortableRequest.java)
|
||||
> - [PostEndpoint (SpringdocRouteBuilder example)](https://github.com/halo-dev/halo/blob/main/application/src/main/java/run/halo/app/core/endpoint/console/PostEndpoint.java)
|
||||
|
||||
## API Group Conventions
|
||||
|
||||
| Scope | URL Prefix | Example |
|
||||
| ---------------- | ----------------------------------------- | ------------------------------------------------------- |
|
||||
| Console | `/apis/console.api.{group}/{version}/...` | `/apis/console.api.my-plugin.halo.run/v1alpha1/persons` |
|
||||
| UC (User Center) | `/apis/uc.api.{group}/{version}/...` | `/apis/uc.api.my-plugin.halo.run/v1alpha1/persons` |
|
||||
| Public (theme) | `/apis/api.{group}/{version}/...` | `/apis/api.my-plugin.halo.run/v1alpha1/persons` |
|
||||
|
||||
> `{group}` is the `group` value from the `@GVK` annotation.
|
||||
|
||||
## Method 1: CustomEndpoint (Recommended)
|
||||
|
||||
Implements `run.halo.app.core.extension.endpoint.CustomEndpoint`:
|
||||
|
||||
```java
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||
import run.halo.app.extension.GroupVersion;
|
||||
|
||||
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
|
||||
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
|
||||
import static org.springframework.http.MediaType.APPLICATION_JSON;
|
||||
|
||||
@Component
|
||||
public class PersonEndpoint implements CustomEndpoint {
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
return route()
|
||||
.GET("/persons/{name}", accept(APPLICATION_JSON), this::getPerson)
|
||||
.POST("/persons", this::createPerson)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> getPerson(ServerRequest request) {
|
||||
String name = request.pathVariable("name");
|
||||
return ServerResponse.ok().bodyValue("Hello, " + name);
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> createPerson(ServerRequest request) {
|
||||
// ...
|
||||
return ServerResponse.ok().build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public GroupVersion groupVersion() {
|
||||
return new GroupVersion("console.api.my-plugin.halo.run", "v1alpha1");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `endpoint()` paths are automatically prefixed with `/apis/{group}/{version}/`.
|
||||
|
||||
## Method 2: MVC-style @Controller
|
||||
|
||||
```java
|
||||
import run.halo.app.plugin.ApiVersion;
|
||||
|
||||
@ApiVersion("my-plugin.halo.run/v1alpha1")
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/persons")
|
||||
public class PersonController {
|
||||
private final PersonService personService;
|
||||
|
||||
@GetMapping("/{name}")
|
||||
public Mono<Person> getPerson(@PathVariable("name") String name) {
|
||||
return personService.getPerson(name);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ **@ApiVersion is required** — controllers without it will not be registered.
|
||||
|
||||
## OpenAPI Documentation
|
||||
|
||||
Use `SpringdocRouteBuilder` to document Functional Endpoints:
|
||||
|
||||
```java
|
||||
import run.halo.app.infra.utils.SpringdocRouteBuilder;
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
final var tag = "PersonV1alpha1Console";
|
||||
return SpringdocRouteBuilder.route()
|
||||
.GET("/persons", this::listPersons,
|
||||
builder -> builder
|
||||
.operationId("ListPersons")
|
||||
.description("List all persons")
|
||||
.tag(tag)
|
||||
.response(responseBuilder()
|
||||
.implementation(ListResult.generateGenericClass(Person.class))
|
||||
)
|
||||
)
|
||||
.build();
|
||||
}
|
||||
```
|
||||
|
||||
Tag naming convention: `{Kind}{Version}{Scope}` e.g., `PersonV1alpha1Console`.
|
||||
|
||||
## Query Parameters
|
||||
|
||||
Extend `run.halo.app.extension.router.SortableRequest` for list queries:
|
||||
|
||||
```java
|
||||
import static run.halo.app.extension.index.query.Queries.equal;
|
||||
import static run.halo.app.extension.index.query.Queries.contains;
|
||||
import static run.halo.app.extension.index.query.Queries.or;
|
||||
|
||||
public class PersonQuery extends SortableRequest {
|
||||
public PersonQuery(ServerWebExchange exchange) {
|
||||
super(exchange);
|
||||
}
|
||||
|
||||
public String getKeyword() {
|
||||
return queryParams.getFirst("keyword");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ListOptions toListOptions() {
|
||||
var keyword = getKeyword();
|
||||
if (StringUtils.hasText(keyword)) {
|
||||
return ListOptions.builder(super.toListOptions())
|
||||
.fieldQuery(or(
|
||||
equal("metadata.name", keyword),
|
||||
contains("spec.name", keyword)
|
||||
))
|
||||
.build();
|
||||
}
|
||||
return super.toListOptions();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```java
|
||||
public Mono<ListResult<Person>> list(ServerRequest request) {
|
||||
var query = new PersonQuery(request.exchange());
|
||||
return client.listBy(Person.class, query.toListOptions(), query.toPageRequest());
|
||||
}
|
||||
```
|
||||
|
||||
## Request Validation (Bean Validation)
|
||||
|
||||
```java
|
||||
public class PersonParam {
|
||||
@NotNull
|
||||
@Size(max = 64)
|
||||
private String name;
|
||||
|
||||
@Min(0)
|
||||
private int age;
|
||||
}
|
||||
```
|
||||
|
||||
Enable validator:
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
public class PluginConfig {
|
||||
@Bean
|
||||
public LocalValidatorFactoryBean validator() {
|
||||
return new LocalValidatorFactoryBean();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Inject and use:
|
||||
|
||||
```java
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PersonEndpoint implements CustomEndpoint {
|
||||
private final Validator validator;
|
||||
|
||||
private Mono<ServerResponse> createPerson(ServerRequest request) {
|
||||
return request.bodyToMono(PersonParam.class)
|
||||
.doOnNext(this::validate)
|
||||
.flatMap(person -> /* ... */);
|
||||
}
|
||||
|
||||
private void validate(PersonParam param) {
|
||||
var result = new BeanPropertyBindingResult(param, "person");
|
||||
validator.validate(param, result);
|
||||
if (result.hasErrors()) {
|
||||
throw new RequestBodyValidationException(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Swagger Groups
|
||||
|
||||
API docs at `/swagger-ui.html` are grouped as:
|
||||
|
||||
| Group | Content |
|
||||
| ------------------------ | ------------------------ |
|
||||
| Aggregated API V1alpha1 | All APIs combined |
|
||||
| Extension API V1alpha1 | Auto-generated CRUD APIs |
|
||||
| Console API V1alpha1 | Console custom APIs |
|
||||
| User-center API V1alpha1 | UC custom APIs |
|
||||
| Public API V1alpha1 | Public/theme custom APIs |
|
||||
@@ -0,0 +1,405 @@
|
||||
# Custom Extension (Data Model)
|
||||
|
||||
Halo uses a Kubernetes CRD-like system called **Extension** for custom data storage.
|
||||
|
||||
> Source references (Halo main branch):
|
||||
>
|
||||
> - [AbstractExtension](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/extension/AbstractExtension.java)
|
||||
> - [GVK](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/extension/GVK.java)
|
||||
> - [SchemeManager](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/extension/SchemeManager.java)
|
||||
> - [IndexSpecs](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/extension/index/IndexSpecs.java)
|
||||
> - [ReactiveExtensionClient](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/extension/ReactiveExtensionClient.java)
|
||||
> - [GroupVersion](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/extension/GroupVersion.java)
|
||||
> - [GroupVersionKind](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/extension/GroupVersionKind.java)
|
||||
> - [Queries](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/extension/index/query/Queries.java)
|
||||
> - [FieldSelector](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/extension/router/selector/FieldSelector.java)
|
||||
> - [ExtensionUtil](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/extension/ExtensionUtil.java)
|
||||
> - [MetadataUtil](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/extension/MetadataUtil.java)
|
||||
> - [ExtensionOperator](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/extension/ExtensionOperator.java)
|
||||
|
||||
## Docs Routing
|
||||
|
||||
This file captures the common model pattern. Verify exact APIs, query helpers,
|
||||
and reconciler contracts in the official docs before depending on a recent
|
||||
method or version-specific behavior.
|
||||
|
||||
| Need | Official docs |
|
||||
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Extension model, indexes, query params | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/server/extension.md |
|
||||
| ExtensionClient / ReactiveExtensionClient | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/server/extension-client.md |
|
||||
| Object management basics | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/basics/server/object-management.md |
|
||||
| Reconciler controllers | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/server/reconciler.md |
|
||||
| Plugin API changelog for version gates | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-changelog.md |
|
||||
|
||||
When working from a local docs checkout, use the same paths under
|
||||
`docs/developer-guide/...`; versioned docs live under
|
||||
`versioned_docs/version-2.25/...`.
|
||||
|
||||
## Quick Index
|
||||
|
||||
- Basic model shape: [Creating an Extension](#creating-an-extension)
|
||||
- Registration: [Registering in Lifecycle](#registering-in-lifecycle)
|
||||
- Generated endpoints: [Auto-Generated CRUD APIs](#auto-generated-crud-apis)
|
||||
- Indexes and queries: [Indexes](#indexes)
|
||||
- Query APIs: [Querying Extensions](#querying-extensions)
|
||||
|
||||
## Creating an Extension
|
||||
|
||||
Three steps:
|
||||
|
||||
1. Create a class extending `run.halo.app.extension.AbstractExtension`
|
||||
2. Annotate with `@GVK(group, version, kind, plural, singular)`
|
||||
3. Register in plugin `start()` via `SchemeManager`
|
||||
|
||||
## Example
|
||||
|
||||
```java
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@GVK(
|
||||
group = "my-plugin.halo.run",
|
||||
version = "v1alpha1",
|
||||
kind = "Person",
|
||||
plural = "persons",
|
||||
singular = "person"
|
||||
)
|
||||
public class Person extends AbstractExtension {
|
||||
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Spec spec;
|
||||
|
||||
@Data
|
||||
@Schema(name = "PersonSpec")
|
||||
public static class Spec {
|
||||
@Schema(description = "Name", maxLength = 100)
|
||||
private String name;
|
||||
|
||||
@Schema(description = "Slug", maxLength = 100)
|
||||
private String slug;
|
||||
|
||||
@Schema(description = "Age", maximum = "150", minimum = "0")
|
||||
private Integer age;
|
||||
|
||||
@Schema(description = "Tags")
|
||||
private List<String> tags;
|
||||
|
||||
@Schema(description = "Priority")
|
||||
private Integer priority;
|
||||
|
||||
@Schema(description = "Pinned")
|
||||
private Boolean pinned;
|
||||
|
||||
@Schema(description = "Publish time")
|
||||
private Instant publishTime;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Registering in Lifecycle
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class MyPlugin extends BasePlugin {
|
||||
@Autowired
|
||||
private SchemeManager schemeManager;
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
schemeManager.register(Person.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
schemeManager.unregister(Scheme.buildFromType(Person.class));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## GVK Annotation Fields
|
||||
|
||||
| Field | Description |
|
||||
| ---------- | ----------------------------------------------------- |
|
||||
| `group` | Domain-style group, e.g., `my-plugin.halo.run` |
|
||||
| `version` | API version, e.g., `v1alpha1` |
|
||||
| `kind` | Resource type name (PascalCase) |
|
||||
| `plural` | REST plural path segment, lowercase (e.g., `persons`) |
|
||||
| `singular` | Singular name, lowercase (e.g., `person`) |
|
||||
|
||||
## Auto-Generated CRUD APIs
|
||||
|
||||
After registration, Halo automatically exposes:
|
||||
|
||||
```
|
||||
GET /apis/{group}/{version}/{plural} # List
|
||||
GET /apis/{group}/{version}/{plural}/{name} # Get by name
|
||||
POST /apis/{group}/{version}/{plural} # Create
|
||||
PUT /apis/{group}/{version}/{plural}/{name} # Update
|
||||
DELETE /apis/{group}/{version}/{plural}/{name} # Delete
|
||||
```
|
||||
|
||||
List endpoint supports:
|
||||
|
||||
| Param | Description |
|
||||
| --------------- | --------------------------------------------------------------------- |
|
||||
| `page` | Page number (1-based) |
|
||||
| `size` | Page size |
|
||||
| `sort` | `field,asc\|desc`. Must be an indexed field |
|
||||
| `labelSelector` | Label filtering: `key=value`, `key!=value`, `!key`, `key` |
|
||||
| `fieldSelector` | Indexed field filtering: `field=value`, `field!=value`, `field=(a,b)` |
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
GET /apis/my-plugin.halo.run/v1alpha1/persons?page=1&size=10&sort=metadata.name,desc&fieldSelector=spec.age=18
|
||||
```
|
||||
|
||||
## Declaring Extension Objects (YAML)
|
||||
|
||||
Place YAML files in `src/main/resources/extensions/`. They are created/updated on plugin startup.
|
||||
|
||||
```yaml
|
||||
apiVersion: my-plugin.halo.run/v1alpha1
|
||||
kind: Person
|
||||
metadata:
|
||||
name: default-person
|
||||
spec:
|
||||
name: halo
|
||||
slug: halo
|
||||
age: 18
|
||||
```
|
||||
|
||||
> ⚠️ Resources here are overwritten on every plugin start. Do NOT place user-modifiable config here.
|
||||
|
||||
## Validation with @Schema
|
||||
|
||||
```java
|
||||
@Schema(description = "Email", format = "email")
|
||||
private String email;
|
||||
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, minLength = 1, maxLength = 50)
|
||||
private String title;
|
||||
```
|
||||
|
||||
Validation is applied on create/update automatically.
|
||||
|
||||
## Indexes
|
||||
|
||||
Indexes improve query performance for `fieldSelector` and `sort`.
|
||||
|
||||
```java
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
import run.halo.app.extension.index.IndexSpecs;
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
schemeManager.register(Person.class, indexSpecs -> {
|
||||
// Single-value index, can return null unless nullable(false) is configured
|
||||
indexSpecs.add(IndexSpecs.<Person, String>single("spec.name", String.class)
|
||||
.indexFunc(person -> person.getSpec().getName()));
|
||||
|
||||
// Multi-value index, returns a set of values
|
||||
indexSpecs.add(IndexSpecs.<Person, String>multi("spec.tags", String.class)
|
||||
.indexFunc(person -> {
|
||||
var tags = person.getSpec().getTags();
|
||||
return tags == null ? Set.of() : Set.copyOf(tags);
|
||||
}));
|
||||
|
||||
// Index keys are not limited to String. Use Comparable types.
|
||||
indexSpecs.add(IndexSpecs.<Person, Boolean>single("spec.pinned", Boolean.class)
|
||||
.indexFunc(person -> person.getSpec().getPinned()));
|
||||
indexSpecs.add(IndexSpecs.<Person, Integer>single("spec.priority", Integer.class)
|
||||
.indexFunc(person -> person.getSpec().getPriority()));
|
||||
indexSpecs.add(IndexSpecs.<Person, Instant>single("spec.publishTime", Instant.class)
|
||||
.indexFunc(person -> person.getSpec().getPublishTime()));
|
||||
|
||||
// Optional builder flags from Halo 2.22+: unique and nullable.
|
||||
indexSpecs.add(IndexSpecs.<Person, String>single("spec.slug", String.class)
|
||||
.unique(true)
|
||||
.nullable(false)
|
||||
.indexFunc(person -> person.getSpec().getSlug()));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
An index spec declares an index item. Prefer building it with
|
||||
`IndexSpecs.single(name, keyType)` or `IndexSpecs.multi(name, keyType)`.
|
||||
Key details:
|
||||
|
||||
| Property | Description |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `name` | Unique index name for this extension type, usually a field path |
|
||||
| `keyType` | Index key type. Must implement `Comparable`, e.g. `String`, `Boolean`, `Integer`, `Long`, `Instant` |
|
||||
| `indexFunc` | Function that extracts the indexed value from the extension |
|
||||
| `unique` | Optional. Enforces unique index values when set to `true` |
|
||||
| `nullable` | Optional. Allows null index values by default; set `false` for required keys |
|
||||
|
||||
Since Halo 2.22.0, `IndexAttributeFactory.simpleAttribute()`,
|
||||
`IndexAttributeFactory.multiValueAttribute()`, and direct `new IndexSpec()`
|
||||
construction are deprecated. Use `IndexSpecs.single()` and
|
||||
`IndexSpecs.multi()` instead.
|
||||
|
||||
Built-in indexes (do not re-declare):
|
||||
|
||||
- `metadata.name` (unique)
|
||||
- `metadata.labels`
|
||||
- `metadata.creationTimestamp`
|
||||
- `metadata.deletionTimestamp`
|
||||
|
||||
## Metadata Structure
|
||||
|
||||
Every extension has `metadata`:
|
||||
|
||||
| Field | Description |
|
||||
| ---------------------------- | ---------------------------------------------------------- |
|
||||
| `metadata.name` | Unique ID, max 253 chars, lowercase alphanumeric + hyphens |
|
||||
| `metadata.creationTimestamp` | Auto-set on create, immutable |
|
||||
| `metadata.version` | Optimistic locking version. Mismatch on update = conflict |
|
||||
| `metadata.deletionTimestamp` | Set when marked for deletion (before actual removal) |
|
||||
| `metadata.finalizers` | Cleanup hooks. Extension not deleted until empty |
|
||||
| `metadata.labels` | String key-value map. Auto-indexed. Use for querying |
|
||||
| `metadata.annotations` | String key-value map. NOT indexed. Use for extra metadata |
|
||||
|
||||
## GroupVersion & GroupVersionKind
|
||||
|
||||
Programmatically construct API identifiers:
|
||||
|
||||
```java
|
||||
// From strings
|
||||
var gv = new GroupVersion("my-plugin.halo.run", "v1alpha1");
|
||||
var gvk = GroupVersionKind.fromAPIVersionAndKind("my-plugin.halo.run/v1alpha1", "Person");
|
||||
|
||||
// From a @GVK-annotated class
|
||||
var gvk = GroupVersionKind.fromExtension(Person.class);
|
||||
|
||||
// Parse from API version string
|
||||
var gv = GroupVersion.parseAPIVersion("my-plugin.halo.run/v1alpha1");
|
||||
```
|
||||
|
||||
## Querying Extensions
|
||||
|
||||
Prefer the newer `ReactiveExtensionClient` query methods:
|
||||
|
||||
```java
|
||||
Flux<Person> people = client.listAll(Person.class, options, sort);
|
||||
Mono<ListResult<Person>> page = client.listBy(Person.class, options, pageable);
|
||||
```
|
||||
|
||||
Use these methods instead of the deprecated `list(Class, Predicate, Comparator, ...)`
|
||||
overloads. Common query methods include:
|
||||
|
||||
| Method | Description |
|
||||
| -------------- | --------------------------- |
|
||||
| `listBy` | Page through matching data |
|
||||
| `listNamesBy` | Page through matching names |
|
||||
| `listAll` | Return all matching data |
|
||||
| `listAllNames` | Return all matching names |
|
||||
| `listTopNames` | Return top matching names |
|
||||
| `countBy` | Count matching data |
|
||||
|
||||
`ListOptions` carries label and field conditions:
|
||||
|
||||
```java
|
||||
import static run.halo.app.extension.index.query.Queries.equal;
|
||||
|
||||
ListOptions options = ListOptions.builder()
|
||||
.labelSelector()
|
||||
.eq("env", "production")
|
||||
.end()
|
||||
.fieldQuery(equal("spec.pinned", true))
|
||||
.build();
|
||||
```
|
||||
|
||||
Call `end()` after `labelSelector()` to return to the `ListOptions` builder.
|
||||
Use `andQuery` and `orQuery` when combining multiple field selector conditions
|
||||
inside the builder.
|
||||
|
||||
Sorting and pagination are passed separately:
|
||||
|
||||
```java
|
||||
import org.springframework.data.domain.Sort;
|
||||
import run.halo.app.extension.PageRequestImpl;
|
||||
|
||||
var sort = Sort.by(Sort.Order.asc("metadata.name"));
|
||||
var pageable = PageRequestImpl.of(1, 10, sort);
|
||||
|
||||
client.listBy(Person.class, options, pageable);
|
||||
client.listAll(Person.class, options, sort);
|
||||
```
|
||||
|
||||
Fields used in `fieldQuery` or `Sort` must be indexed, otherwise Halo rejects
|
||||
the query as unsupported. Query values should match the index `keyType`; Halo
|
||||
uses conversion where possible, but incompatible values fail at query time.
|
||||
|
||||
## Query DSL (Field Selectors)
|
||||
|
||||
Build typed queries for `ListOptions` field filtering:
|
||||
|
||||
```java
|
||||
import static run.halo.app.extension.index.query.Queries.*;
|
||||
|
||||
ListOptions.builder()
|
||||
.fieldQuery(and(
|
||||
equal("spec.pinned", true),
|
||||
contains("spec.name", keyword),
|
||||
greaterThan("spec.priority", 10)
|
||||
))
|
||||
.build();
|
||||
```
|
||||
|
||||
`QueryFactory` is deprecated since Halo 2.22.0. Use `Queries` to build query
|
||||
conditions. Negation can be built with either `Queries.not(condition)` or
|
||||
`condition.not()`.
|
||||
|
||||
Available operators include: `empty`, `all`, `equal`, `notEqual`,
|
||||
`greaterThan(field, value)`, `greaterThan(field, value, inclusive)`,
|
||||
`lessThan(field, value)`, `lessThan(field, value, inclusive)`, `between`,
|
||||
`in`, `isNull`, `contains`, `startsWith`, `endsWith`, `and`, `or`, `not`,
|
||||
`labelExists`, `labelEqual`, `labelIn`.
|
||||
|
||||
Use negation for operators that no longer have direct helper methods:
|
||||
|
||||
```java
|
||||
var isNotNull = isNull("metadata.deletionTimestamp").not();
|
||||
var greaterThanOrEqual = greaterThan("spec.priority", 10, true);
|
||||
var lessThanOrEqual = lessThan("spec.priority", 20, true);
|
||||
var labelNotEqual = labelEqual("env", "production").not();
|
||||
```
|
||||
|
||||
The HTTP `fieldSelector` parameter only supports the selector-style subset
|
||||
(`=`, `!=`, and `in`, for example `fieldSelector=spec.slug=(halo,halo2)`).
|
||||
For richer field and label conditions in Java code, use `ListOptions` with
|
||||
`Queries`.
|
||||
|
||||
## Extension Utilities
|
||||
|
||||
```java
|
||||
// Check deletion state
|
||||
boolean deleted = ExtensionUtil.isDeleted(extension);
|
||||
Predicate<ExtensionOperator> notDeleted = ExtensionOperator.isNotDeleted();
|
||||
|
||||
// Finalizer management
|
||||
ExtensionUtil.addFinalizers(metadata, Set.of("my-plugin/finalizer"));
|
||||
ExtensionUtil.removeFinalizers(metadata, Set.of("my-plugin/finalizer"));
|
||||
|
||||
// Default sort
|
||||
Sort sort = ExtensionUtil.defaultSort(); // creationTimestamp desc, name asc
|
||||
|
||||
// Safe metadata access
|
||||
Map<String, String> labels = MetadataUtil.nullSafeLabels(extension);
|
||||
Map<String, String> annotations = MetadataUtil.nullSafeAnnotations(extension);
|
||||
```
|
||||
|
||||
## Naming Rules
|
||||
|
||||
- **metadata.name**: ≤253 chars, `[a-z0-9]([-a-z0-9]*[a-z0-9])?`
|
||||
- **labels keys**: Optional prefix (DNS subdomain) + name (DNS label, ≤63 chars). Reserved: no-prefix keys and `halo.run/*`
|
||||
- **annotations keys**: Same rules as labels, but not indexed
|
||||
@@ -0,0 +1,152 @@
|
||||
# Plugin Lifecycle
|
||||
|
||||
The plugin entry class extends `run.halo.app.plugin.BasePlugin` and must be annotated with `@Component`.
|
||||
|
||||
> Source references (Halo main branch):
|
||||
>
|
||||
> - [BasePlugin](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/plugin/BasePlugin.java)
|
||||
> - [PluginContext](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/plugin/PluginContext.java)
|
||||
|
||||
## Entry Class Template
|
||||
|
||||
```java
|
||||
package com.example.myplugin;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.plugin.BasePlugin;
|
||||
import run.halo.app.plugin.PluginContext;
|
||||
|
||||
@Component
|
||||
public class MyPlugin extends BasePlugin {
|
||||
|
||||
public MyPlugin(PluginContext pluginContext) {
|
||||
super(pluginContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
// Called after classes are loaded and before the plugin is marked active
|
||||
// Register schemes, initialize caches, start watchers, etc.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
// Called when the plugin is stopped (disabled)
|
||||
// Unregister schemes, dispose watchers, clean up resources
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete() {
|
||||
// Called when the plugin is uninstalled
|
||||
// Final cleanup, delete external data if needed
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Lifecycle Behavior
|
||||
|
||||
| Method | When Called | Typical Actions |
|
||||
| ---------- | ---------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `start()` | After plugin classes loaded, before activation | `schemeManager.register()`, init caches, register watchers |
|
||||
| `stop()` | When plugin is disabled | `schemeManager.unregister()`, dispose watchers, clear rate limiters |
|
||||
| `delete()` | When plugin is uninstalled | Delete external resources, cleanup |
|
||||
|
||||
## Important Rules
|
||||
|
||||
1. **Only ONE class** may extend `BasePlugin` and be annotated with `@Component`. Multiple candidates cause startup failure.
|
||||
2. **Must have `@Component`** (or other Spring stereotype). Without it, lifecycle methods are never invoked.
|
||||
3. **Use constructor injection** for dependencies (preferred over `@Autowired` fields).
|
||||
|
||||
## Scheme Registration / Cleanup
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class MyPlugin extends BasePlugin {
|
||||
private final SchemeManager schemeManager;
|
||||
|
||||
public MyPlugin(PluginContext ctx, SchemeManager schemeManager) {
|
||||
super(ctx);
|
||||
this.schemeManager = schemeManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
schemeManager.register(MyExtension.class, indexSpecs -> {
|
||||
indexSpecs.add(IndexSpecs.<MyExtension, String>single("spec.slug", String.class)
|
||||
.indexFunc(ext -> ext.getSpec().getSlug()));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
schemeManager.unregister(Scheme.buildFromType(MyExtension.class));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Watchers
|
||||
|
||||
Watch extension changes for cache invalidation or reactive workflows:
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class MyPlugin extends BasePlugin {
|
||||
private final ReactiveExtensionClient client;
|
||||
private Watcher watcher;
|
||||
|
||||
// ... constructor
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
watcher = new Watcher() {
|
||||
private volatile boolean disposed = false;
|
||||
|
||||
@Override
|
||||
public void onAdd(Extension extension) {
|
||||
if (extension instanceof MyExtension) {
|
||||
// handle add
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUpdate(Extension oldObj, Extension newObj) {
|
||||
if (newObj instanceof MyExtension) {
|
||||
// handle update
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDelete(Extension extension) {
|
||||
if (extension instanceof MyExtension) {
|
||||
// handle delete
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() { disposed = true; }
|
||||
|
||||
@Override
|
||||
public boolean isDisposed() { return disposed; }
|
||||
};
|
||||
client.watch(watcher);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
if (watcher != null) watcher.dispose();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## RateLimiter Cleanup
|
||||
|
||||
If creating rate limiters via `RateLimiterRegistry`, always clean up in `stop()`:
|
||||
|
||||
```java
|
||||
private final Set<String> limiterNames = ConcurrentHashMap.newKeySet();
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
limiterNames.forEach(rateLimiterRegistry::remove);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,144 @@
|
||||
# Reconciler (Controller Pattern)
|
||||
|
||||
A Kubernetes-style controller that watches Extension resources and continuously reconciles their desired state. More commonly used than `Watcher` in production plugins.
|
||||
|
||||
> Source: [Reconciler](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/extension/controller/Reconciler.java) | [ControllerBuilder](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/extension/controller/ControllerBuilder.java) | [Controller](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/extension/controller/Controller.java)
|
||||
|
||||
## Basic Pattern
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class MyReconciler implements Reconciler<Reconciler.Request> {
|
||||
|
||||
private final ExtensionClient client;
|
||||
|
||||
public MyReconciler(ExtensionClient client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result reconcile(Request request) {
|
||||
// Fetch the extension being reconciled
|
||||
var myExt = client.fetch(MyExtension.class, request.name());
|
||||
if (myExt.isEmpty()) {
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
|
||||
// Perform reconciliation logic
|
||||
var ext = myExt.get();
|
||||
// ... update annotations, labels, related resources, etc.
|
||||
|
||||
// Update the extension if modified
|
||||
client.update(ext);
|
||||
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Controller setupWith(ControllerBuilder builder) {
|
||||
return builder
|
||||
.extension(new MyExtension())
|
||||
.syncAllOnStart(false)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## ControllerBuilder Options
|
||||
|
||||
| Method | Description |
|
||||
| ---------------------------------- | ----------------------------------------------------------- |
|
||||
| `.extension(new MyExtension())` | The extension type to watch (required) |
|
||||
| `.syncAllOnStart(true)` | Reconcile all existing instances on startup (default: true) |
|
||||
| `.syncAllListOptions(listOptions)` | Filter which existing instances to sync on start |
|
||||
| `.minDelay(Duration)` | Minimum retry delay (default: 5ms) |
|
||||
| `.maxDelay(Duration)` | Maximum retry delay (default: 1000s) |
|
||||
| `.workerCount(int)` | Number of concurrent workers (default: 1) |
|
||||
| `.onAddMatcher(matcher)` | Filter which add events to process |
|
||||
| `.onUpdateMatcher(matcher)` | Filter which update events to process |
|
||||
| `.onDeleteMatcher(matcher)` | Filter which delete events to process |
|
||||
|
||||
## Result Types
|
||||
|
||||
```java
|
||||
// Success, do not retry
|
||||
return Result.doNotRetry();
|
||||
|
||||
// Requeue after a delay (for async operations or retry)
|
||||
return Result.requeue(Duration.ofSeconds(30));
|
||||
```
|
||||
|
||||
## Lifecycle Integration
|
||||
|
||||
Reconcilers are auto-discovered by Spring. If you need to start/stop manually (e.g., conditional on plugin config):
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class MyReconciler implements Reconciler<Request>, SmartLifecycle {
|
||||
private Controller controller;
|
||||
private boolean running = false;
|
||||
|
||||
@Override
|
||||
public Controller setupWith(ControllerBuilder builder) {
|
||||
this.controller = builder.extension(new MyExtension()).build();
|
||||
return controller;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
if (controller != null && !running) {
|
||||
controller.start();
|
||||
running = true;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
if (controller != null && running) {
|
||||
controller.dispose(); // or controller.stop()
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() { return running; }
|
||||
}
|
||||
```
|
||||
|
||||
## Finalizers
|
||||
|
||||
Use finalizers for cleanup before an extension is deleted:
|
||||
|
||||
```java
|
||||
@Override
|
||||
public Result reconcile(Request request) {
|
||||
var ext = client.fetch(MyExtension.class, request.name()).orElse(null);
|
||||
if (ext == null) return Result.doNotRetry();
|
||||
|
||||
// Check if being deleted
|
||||
if (ext.getMetadata().getDeletionTimestamp() != null) {
|
||||
// Perform cleanup
|
||||
doCleanup(ext);
|
||||
// Remove finalizer to allow deletion
|
||||
ExtensionUtil.removeFinalizers(ext.getMetadata(), Set.of("my-plugin/finalizer"));
|
||||
client.update(ext);
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
|
||||
// Add finalizer if not present
|
||||
ExtensionUtil.addFinalizers(ext.getMetadata(), Set.of("my-plugin/finalizer"));
|
||||
client.update(ext);
|
||||
|
||||
// Normal reconciliation
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
```
|
||||
|
||||
## When to Use Reconciler vs Watcher
|
||||
|
||||
| | Reconciler | Watcher |
|
||||
| --------------- | ---------------------------------------------------- | ----------------------- |
|
||||
| **Use case** | Continuous state reconciliation, finalizers, retries | One-time event handling |
|
||||
| **Persistence** | Queued, survives restarts | In-memory only |
|
||||
| **Concurrency** | Configurable worker count | Single threaded |
|
||||
| **Retry** | Built-in exponential backoff | No retry |
|
||||
@@ -0,0 +1,123 @@
|
||||
# Search Integration
|
||||
|
||||
Integrate custom extensions with Halo's built-in search engine.
|
||||
|
||||
> Source: [HaloDocument](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/search/HaloDocument.java) | [HaloDocumentsProvider](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/search/HaloDocumentsProvider.java) | [SearchEngine](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/search/SearchEngine.java)
|
||||
|
||||
## HaloDocumentsProvider
|
||||
|
||||
Implement this extension point to make your custom Extension searchable.
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class MyDocumentProvider implements HaloDocumentsProvider {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
public MyDocumentProvider(ReactiveExtensionClient client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<HaloDocument> fetchAll() {
|
||||
return client.listAll(MyExtension.class, new ListOptions(), Sort.unsorted())
|
||||
.filter(ext -> ext.getMetadata().getDeletionTimestamp() == null)
|
||||
.map(this::convertToDocument);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getType() {
|
||||
return "myextension.mygroup.halo.run";
|
||||
}
|
||||
|
||||
private HaloDocument convertToDocument(MyExtension ext) {
|
||||
var doc = new HaloDocument();
|
||||
doc.setId(ext.getMetadata().getName());
|
||||
doc.setMetadataName(ext.getMetadata().getName());
|
||||
doc.setTitle(ext.getSpec().getTitle());
|
||||
doc.setDescription(ext.getSpec().getDescription());
|
||||
doc.setContent(stripHtml(ext.getSpec().getContent()));
|
||||
doc.setOwnerName(ext.getSpec().getOwner());
|
||||
doc.setCreationTimestamp(ext.getMetadata().getCreationTimestamp());
|
||||
doc.setUpdateTimestamp(ext.getMetadata().getCreationTimestamp());
|
||||
doc.setPermalink("/my-extensions/" + ext.getSpec().getSlug());
|
||||
doc.setType(getType());
|
||||
doc.setPublished(true);
|
||||
doc.setRecycled(false);
|
||||
doc.setExposed(true);
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Search Events
|
||||
|
||||
Publish events to add/remove documents from the search index:
|
||||
|
||||
```java
|
||||
// Add or update documents
|
||||
applicationEventPublisher.publishEvent(
|
||||
new HaloDocumentAddRequestEvent(this, List.of(haloDoc)));
|
||||
|
||||
// Delete documents by ID
|
||||
applicationEventPublisher.publishEvent(
|
||||
new HaloDocumentDeleteRequestEvent(this, List.of("doc-id-1", "doc-id-2")));
|
||||
|
||||
// Rebuild entire index (e.g., on plugin config change)
|
||||
applicationEventPublisher.publishEvent(
|
||||
new HaloDocumentRebuildRequestEvent(this));
|
||||
```
|
||||
|
||||
## SearchEngine (Replacing Default)
|
||||
|
||||
To replace Halo's default search engine (e.g., with Meilisearch):
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class MeilisearchEngine implements SearchEngine {
|
||||
|
||||
@Override
|
||||
public boolean available() {
|
||||
return meilisearchClient != null && meilisearchClient.isHealthy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addOrUpdate(Iterable<HaloDocument> docs) {
|
||||
// Batch index documents
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteDocument(Iterable<String> ids) {
|
||||
// Batch delete by ID
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteAll() {
|
||||
// Clear index
|
||||
}
|
||||
|
||||
@Override
|
||||
public SearchResult search(SearchOption option) {
|
||||
// Execute search query
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## HaloDocument Fields
|
||||
|
||||
| Field | Description | Required |
|
||||
| --------------------------------------- | ---------------------------- | -------- |
|
||||
| `id` | Global unique document ID | Yes |
|
||||
| `metadataName` | Extension metadata name | Yes |
|
||||
| `title` | Document title | Yes |
|
||||
| `content` | Plain text content (no HTML) | Yes |
|
||||
| `description` | Short description | No |
|
||||
| `permalink` | URL path | Yes |
|
||||
| `type` | Document type identifier | Yes |
|
||||
| `ownerName` | Owner metadata name | Yes |
|
||||
| `categories` | Category metadata names | No |
|
||||
| `tags` | Tag metadata names | No |
|
||||
| `published` | Whether published | Yes |
|
||||
| `recycled` | Whether in recycle bin | Yes |
|
||||
| `exposed` | Whether publicly visible | Yes |
|
||||
| `creationTimestamp` / `updateTimestamp` | Timestamps | Yes |
|
||||
@@ -0,0 +1,179 @@
|
||||
# RBAC & Role Templates
|
||||
|
||||
> ⚠️ **Not all plugins need to create RoleTemplate resources.** All plugin APIs are restricted to super-admin by default. If your plugin is intended for super-admin use only, there is no need to create or configure RoleTemplates at all.
|
||||
>
|
||||
> Only create RoleTemplates in the following scenarios:
|
||||
>
|
||||
> - When certain APIs need to be accessible to other roles (e.g., editor, contributor)
|
||||
> - When certain APIs need to be publicly accessible to unauthenticated users (anonymous)
|
||||
> - When the plugin UI requires role-based permission controls
|
||||
|
||||
All plugin APIs (auto-generated CRUD + custom) are restricted to super-admin by default. To allow other users access, define **role templates**.
|
||||
|
||||
> Source references (Halo main branch):
|
||||
>
|
||||
> - [Role template anonymous](https://github.com/halo-dev/halo/blob/main/application/src/main/resources/extensions/role-template-anonymous.yaml)
|
||||
> - [Role template authenticated](https://github.com/halo-dev/halo/blob/main/application/src/main/resources/extensions/role-template-authenticated.yaml)
|
||||
> - [Plugin photos role templates (production example)](https://github.com/halo-sigs/plugin-photos/blob/main/src/main/resources/extensions/roleTemplate.yaml)
|
||||
|
||||
## Role Template File
|
||||
|
||||
Place YAML files in `src/main/resources/extensions/`.
|
||||
|
||||
```yaml
|
||||
apiVersion: v1alpha1
|
||||
kind: Role
|
||||
metadata:
|
||||
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: "View Persons"
|
||||
rbac.authorization.halo.run/ui-permissions: |
|
||||
["plugin:my-plugin:person:view"]
|
||||
rules:
|
||||
- apiGroups: ["my-plugin.halo.run"]
|
||||
resources: ["my-plugin/persons"]
|
||||
verbs: ["get", "list"]
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: my-plugin-role-manage-persons
|
||||
labels:
|
||||
halo.run/role-template: "true"
|
||||
annotations:
|
||||
rbac.authorization.halo.run/dependencies: |
|
||||
["my-plugin-role-view-persons"]
|
||||
rbac.authorization.halo.run/module: "Persons Management"
|
||||
rbac.authorization.halo.run/display-name: "Manage Persons"
|
||||
rbac.authorization.halo.run/ui-permissions: |
|
||||
["plugin:my-plugin:person:manage"]
|
||||
rules:
|
||||
- apiGroups: ["my-plugin.halo.run"]
|
||||
resources: ["my-plugin/persons"]
|
||||
verbs: ["*"]
|
||||
```
|
||||
|
||||
## Key Rules
|
||||
|
||||
| Element | Description |
|
||||
| -------------------------------------------------------- | -------------------------------------------------- |
|
||||
| `metadata.name` | Must use plugin name as prefix to avoid collisions |
|
||||
| `labels.halo.run/role-template: "true"` | Required to mark as template |
|
||||
| `annotations.rbac.authorization.halo.run/dependencies` | Other roles this role requires |
|
||||
| `annotations.rbac.authorization.halo.run/module` | UI grouping name |
|
||||
| `annotations.rbac.authorization.halo.run/display-name` | Human-readable name |
|
||||
| `annotations.rbac.authorization.halo.run/ui-permissions` | Frontend permission strings |
|
||||
|
||||
## Resource Rules
|
||||
|
||||
For APIs matching `/apis/<group>/<version>/<resource>[/<name>/<subresource>]`:
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
- apiGroups: ["my-plugin.halo.run"]
|
||||
resources: ["my-plugin/persons"]
|
||||
resourceNames: ["zhangsan"] # optional, for single resource
|
||||
verbs: ["get", "list"]
|
||||
```
|
||||
|
||||
## Non-Resource Rules
|
||||
|
||||
For APIs not matching resource patterns (e.g., `/healthz`):
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
- nonResourceURLs: ["/healthz", "/healthz/*"]
|
||||
verbs: ["get"]
|
||||
```
|
||||
|
||||
## Verbs
|
||||
|
||||
| Verb | HTTP Method | Description |
|
||||
| ------------------ | --------------- | --------------------------------------- |
|
||||
| `create` | POST | Create new resource |
|
||||
| `get` | GET | Get single resource (with name in path) |
|
||||
| `list` | GET | List resources (without name in path) |
|
||||
| `watch` | GET (WebSocket) | Watch resource changes |
|
||||
| `update` | PUT | Full update |
|
||||
| `patch` | PATCH | Partial update |
|
||||
| `delete` | DELETE | Delete single resource |
|
||||
| `deletecollection` | DELETE | Delete collection |
|
||||
|
||||
## Aggregation
|
||||
|
||||
Merge plugin permissions into existing Halo roles:
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
labels:
|
||||
halo.run/role-template: "true"
|
||||
halo.run/hidden: "true" # hide from UI
|
||||
rbac.authorization.halo.run/aggregate-to-anonymous: "true"
|
||||
rules:
|
||||
- apiGroups: ["api.my-plugin.halo.run"]
|
||||
resources: ["public-data"]
|
||||
verbs: ["get", "list"]
|
||||
```
|
||||
|
||||
Available aggregation targets: `anonymous`, `authenticated`, `editor`, etc.
|
||||
|
||||
## Special Roles
|
||||
|
||||
| Role | Description |
|
||||
| --------------- | -------------------------------------------------------- |
|
||||
| `anonymous` | Unauthenticated visitors |
|
||||
| `authenticated` | All logged-in users (minimum permissions) |
|
||||
| `super-role` | Full system access |
|
||||
| `guest` | No explicit permissions (only anonymous + authenticated) |
|
||||
|
||||
## Web Filters
|
||||
|
||||
Add custom WebFlux filters for request interception.
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class MyFilter implements AdditionalWebFilter {
|
||||
@Override
|
||||
public int getOrder() { return Ordered.LOWEST_PRECEDENCE; }
|
||||
|
||||
@Override
|
||||
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
|
||||
// Runs before security chain
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`AfterSecurityWebFilter` runs after the security chain (for caching, analytics, etc.).
|
||||
|
||||
## ReactiveSecurityContextHolder
|
||||
|
||||
Access the current authentication in reactive code:
|
||||
|
||||
```java
|
||||
ReactiveSecurityContextHolder.getContext()
|
||||
.map(SecurityContext::getAuthentication)
|
||||
.map(Authentication::getName)
|
||||
.defaultIfEmpty(AnonymousUserConst.PRINCIPAL);
|
||||
```
|
||||
|
||||
## UI Permissions
|
||||
|
||||
Used in frontend route guards:
|
||||
|
||||
```ts
|
||||
meta: {
|
||||
permissions: ["plugin:my-plugin:person:view"];
|
||||
}
|
||||
```
|
||||
|
||||
Check at runtime:
|
||||
|
||||
```ts
|
||||
import { utils } from "@halo-dev/ui-shared";
|
||||
|
||||
utils.permission.has(["plugin:my-plugin:person:view"]); // true/false
|
||||
```
|
||||
@@ -0,0 +1,339 @@
|
||||
# Shared Beans (Dependency Injection)
|
||||
|
||||
Halo exposes several core beans that any plugin can inject via constructor injection.
|
||||
|
||||
> Source references (Halo main branch):
|
||||
>
|
||||
> - [ReactiveExtensionClient](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/extension/ReactiveExtensionClient.java)
|
||||
> - [ExtensionClient](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/extension/ExtensionClient.java)
|
||||
> - [SchemeManager](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/extension/SchemeManager.java)
|
||||
> - [ExtensionGetter](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/plugin/extensionpoint/ExtensionGetter.java)
|
||||
> - [UserService](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/core/user/service/UserService.java)
|
||||
> - [RoleService](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/core/user/service/RoleService.java)
|
||||
> - [AttachmentService](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/core/extension/service/AttachmentService.java)
|
||||
> - [PostContentService](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/content/PostContentService.java)
|
||||
> - [ExternalLinkProcessor](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/infra/ExternalLinkProcessor.java)
|
||||
> - [ExternalUrlSupplier](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/infra/ExternalUrlSupplier.java)
|
||||
> - [NotificationReasonEmitter](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/notification/NotificationReasonEmitter.java)
|
||||
> - [NotificationCenter](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/notification/NotificationCenter.java)
|
||||
> - [CryptoService](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/security/authentication/CryptoService.java)
|
||||
> - [RateLimiterRegistry](https://github.com/resilience4j/resilience4j/blob/master/resilience4j-ratelimiter/src/main/java/io/github/resilience4j/ratelimiter/RateLimiterRegistry.java)
|
||||
> - [ReactiveSettingFetcher](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/plugin/ReactiveSettingFetcher.java)
|
||||
> - [SettingFetcher](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/plugin/SettingFetcher.java)
|
||||
> - [PluginConfigUpdatedEvent](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/plugin/PluginConfigUpdatedEvent.java)
|
||||
> - [JsonUtils](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/infra/utils/JsonUtils.java)
|
||||
> - [ExtensionUtil](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/extension/ExtensionUtil.java)
|
||||
> - [MetadataUtil](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/extension/MetadataUtil.java)
|
||||
> - [PageRequestImpl](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/extension/PageRequestImpl.java)
|
||||
> - [SortResolver](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/core/extension/endpoint/SortResolver.java)
|
||||
> - [AnonymousUserConst](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/infra/AnonymousUserConst.java)
|
||||
|
||||
## Docs Routing
|
||||
|
||||
This file lists frequently used beans, not every injectable type in Halo. For
|
||||
method signatures and newer APIs, verify against the official docs or source
|
||||
before coding.
|
||||
|
||||
| Need | Official docs |
|
||||
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| ExtensionClient / ReactiveExtensionClient | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/server/extension-client.md |
|
||||
| ExtensionGetter and extension points | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/server/extension-getter.md |
|
||||
| SettingFetcher / ReactiveSettingFetcher | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/server/setting-fetcher.md |
|
||||
| Notifications | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/server/notification.md |
|
||||
| Reverse proxy helpers | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/server/reverseproxy.md |
|
||||
| WebSocket helpers | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/server/websocket.md |
|
||||
| Login handler enhancement | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/server/login-handler-enhancer.md |
|
||||
| Template and finder helpers for themes | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/server/template-for-theme.md |
|
||||
|
||||
When working from a local docs checkout, use the same paths under
|
||||
`docs/developer-guide/...`; versioned docs live under
|
||||
`versioned_docs/version-2.25/...`.
|
||||
|
||||
## Quick Index
|
||||
|
||||
- Custom model CRUD: [ReactiveExtensionClient](#reactiveextensionclient)
|
||||
- Blocking model CRUD: [ExtensionClient](#extensionclient)
|
||||
- Extension point lookup: [ExtensionGetter](#extensiongetter)
|
||||
- Plugin settings: [SettingFetcher / ReactiveSettingFetcher](#settingfetcher--reactivesettingfetcher)
|
||||
- Notifications: [Notifications](#notifications)
|
||||
- JSON helpers: [JsonUtils](#jsonutils)
|
||||
|
||||
## ReactiveExtensionClient
|
||||
|
||||
Reactive CRUD for custom extensions.
|
||||
|
||||
```java
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
// List with options
|
||||
client.listBy(Person.class, query.toListOptions(), query.toPageRequest());
|
||||
|
||||
// Get by name
|
||||
client.fetch(Person.class, "person-name");
|
||||
|
||||
// Create
|
||||
client.create(person);
|
||||
|
||||
// Update
|
||||
client.update(person);
|
||||
|
||||
// Delete
|
||||
client.delete(person);
|
||||
```
|
||||
|
||||
## ExtensionClient
|
||||
|
||||
Blocking version of `ReactiveExtensionClient`. Use only in non-NIO threads (e.g., background tasks).
|
||||
|
||||
## SchemeManager
|
||||
|
||||
Register/unregister custom extension types.
|
||||
|
||||
```java
|
||||
schemeManager.register(Person.class);
|
||||
schemeManager.register(Person.class, indexSpecs -> { /* ... */ });
|
||||
schemeManager.unregister(Scheme.buildFromType(Person.class));
|
||||
```
|
||||
|
||||
## ExtensionGetter
|
||||
|
||||
Retrieve implementations of an extension point.
|
||||
|
||||
```java
|
||||
private final ExtensionGetter extensionGetter;
|
||||
|
||||
// Get all implementations
|
||||
extensionGetter.getExtensions(AttachmentHandler.class);
|
||||
```
|
||||
|
||||
## UserService
|
||||
|
||||
User operations: get info, update password, create users.
|
||||
|
||||
## ReactiveUserDetailsService
|
||||
|
||||
```java
|
||||
Mono<UserDetails> findByUsername(String username);
|
||||
```
|
||||
|
||||
## RoleService
|
||||
|
||||
Role operations: query roles, bindings, dependencies.
|
||||
|
||||
## AttachmentService
|
||||
|
||||
Upload, delete, get access URLs for attachments.
|
||||
|
||||
## PostContentService
|
||||
|
||||
Get post content with version management.
|
||||
|
||||
```java
|
||||
postContentService.getHeadContent(postName); // latest draft
|
||||
postContentService.getReleaseContent(postName); // published version
|
||||
postContentService.getSpecifiedContent(snapshotName);
|
||||
postContentService.listSnapshots(postName);
|
||||
```
|
||||
|
||||
## ExternalLinkProcessor
|
||||
|
||||
Convert relative URLs to absolute using configured `externalUrl`.
|
||||
|
||||
```java
|
||||
externalLinkProcessor.processLink("/post/1");
|
||||
// -> "https://example.com/post/1"
|
||||
```
|
||||
|
||||
## ExternalUrlSupplier
|
||||
|
||||
Get the configured external URL.
|
||||
|
||||
## NotificationReasonEmitter / NotificationCenter
|
||||
|
||||
Send notifications and manage subscriptions.
|
||||
|
||||
## ServerSecurityContextRepository
|
||||
|
||||
Access authentication context. Required if your filter runs before Spring Security's `ReactorContextWebFilter`.
|
||||
|
||||
## CryptoService
|
||||
|
||||
Decrypt login passwords or reuse the public key.
|
||||
|
||||
```java
|
||||
cryptoService.readPublicKey();
|
||||
cryptoService.decrypt(encryptedPassword);
|
||||
```
|
||||
|
||||
## RateLimiterRegistry
|
||||
|
||||
Create rate limiters (remember to clean up in `stop()`).
|
||||
|
||||
```java
|
||||
var rateLimiter = rateLimiterRegistry.rateLimiter(key,
|
||||
new RateLimiterConfig.Builder()
|
||||
.limitForPeriod(1)
|
||||
.limitRefreshPeriod(Duration.ofSeconds(60))
|
||||
.build());
|
||||
```
|
||||
|
||||
## SystemInfoGetter (Halo 2.20.11+)
|
||||
|
||||
```java
|
||||
Mono<SystemInfo> info = systemInfoGetter.get();
|
||||
// Contains: title, subtitle, logo, favicon, url, version, seo, locale, timeZone, activatedThemeName
|
||||
```
|
||||
|
||||
## ReactiveSettingFetcher / SettingFetcher
|
||||
|
||||
Fetch plugin configuration defined in `plugin.yaml` (`settingName` / `configMapName`).
|
||||
The fetcher caches values internally and auto-refreshes when configuration changes.
|
||||
|
||||
**Reactive (WebFlux):**
|
||||
|
||||
```java
|
||||
private final ReactiveSettingFetcher settingFetcher;
|
||||
|
||||
// Fetch a typed config object by group name
|
||||
settingFetcher.fetch("seo", SeoSetting.class)
|
||||
.doOnNext(seo -> { ... });
|
||||
|
||||
// Get raw JsonNode by group
|
||||
settingFetcher.getSettingValue("seo");
|
||||
|
||||
// Get all groups as a map
|
||||
settingFetcher.getSettingValues();
|
||||
```
|
||||
|
||||
**Blocking (background tasks / non-reactive code):**
|
||||
|
||||
```java
|
||||
private final SettingFetcher settingFetcher;
|
||||
|
||||
Optional<SeoSetting> seo = settingFetcher.fetch("seo", SeoSetting.class);
|
||||
JsonNode raw = settingFetcher.getSettingValue("seo");
|
||||
Map<String, JsonNode> all = settingFetcher.getSettingValues();
|
||||
```
|
||||
|
||||
**Listen for config changes:**
|
||||
|
||||
```java
|
||||
@EventListener
|
||||
public void onConfigUpdated(PluginConfigUpdatedEvent event) {
|
||||
if (event.getNewConfig().containsKey("seo")) {
|
||||
// re-apply configuration
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Prerequisite**: In `plugin.yaml`, declare `settingName: my-settings` and `configMapName: my-configmap`, and create a corresponding `settings.yaml` extension resource defining the form schema and groups.
|
||||
|
||||
## JsonUtils
|
||||
|
||||
JSON serialization/deserialization utilities.
|
||||
|
||||
> Note: `@Deprecated(forRemoval = true, since = "2.23.0")` — prefer `tools.jackson.databind.json.JsonMapper` in newer Halo versions.
|
||||
|
||||
```java
|
||||
// Object to JSON string
|
||||
String json = JsonUtils.objectToJson(myObject);
|
||||
|
||||
// JSON string to object
|
||||
MyObject obj = JsonUtils.jsonToObject(json, MyObject.class);
|
||||
|
||||
// JSON string with generics
|
||||
Set<String> tags = JsonUtils.jsonToObject(json, new TypeReference<>() {});
|
||||
|
||||
// Map to object
|
||||
MyObject obj = JsonUtils.mapToObject(map, MyObject.class);
|
||||
|
||||
// Deep copy
|
||||
MyObject copy = JsonUtils.deepCopy(original);
|
||||
|
||||
// Access the underlying ObjectMapper
|
||||
ObjectMapper mapper = JsonUtils.mapper();
|
||||
mapper.convertValue(data, new TypeReference<>() {});
|
||||
```
|
||||
|
||||
## ExtensionUtil / MetadataUtil
|
||||
|
||||
Utility methods for Extension lifecycle and metadata.
|
||||
|
||||
```java
|
||||
// Check deletion state
|
||||
boolean deleted = ExtensionUtil.isDeleted(extension);
|
||||
|
||||
// Query for non-deleted resources (for ListOptions)
|
||||
ListOptions.builder().fieldQuery(ExtensionUtil.notDeleting()).build();
|
||||
|
||||
// Finalizer management
|
||||
ExtensionUtil.addFinalizers(metadata, Set.of("my-plugin/finalizer"));
|
||||
ExtensionUtil.removeFinalizers(metadata, Set.of("my-plugin/finalizer"));
|
||||
|
||||
// Default sort: creationTimestamp desc, name asc
|
||||
Sort sort = ExtensionUtil.defaultSort();
|
||||
|
||||
// Safe metadata access (auto-initializes null collections)
|
||||
Map<String, String> labels = MetadataUtil.nullSafeLabels(extension);
|
||||
Map<String, String> annotations = MetadataUtil.nullSafeAnnotations(extension);
|
||||
```
|
||||
|
||||
## PageRequestImpl / PageRequest
|
||||
|
||||
Pagination for list queries. Page numbers are **1-based**.
|
||||
|
||||
```java
|
||||
// Basic pagination
|
||||
PageRequestImpl.of(page, size); // page >= 1, size <= 1000
|
||||
PageRequestImpl.of(page, size, Sort.by("name"));
|
||||
PageRequestImpl.ofSize(size); // page 1, given size
|
||||
|
||||
// With sorting
|
||||
PageRequestImpl.of(1, 20, Sort.by(
|
||||
Sort.Order.desc("metadata.creationTimestamp"),
|
||||
Sort.Order.asc("metadata.name")
|
||||
));
|
||||
```
|
||||
|
||||
## SortResolver / SortableRequest
|
||||
|
||||
Resolve sorting from HTTP query parameters.
|
||||
|
||||
```java
|
||||
// In a CustomEndpoint handler
|
||||
Sort sort = SortResolver.defaultInstance.resolve(exchange);
|
||||
|
||||
// Or extend SortableRequest for standardized list queries
|
||||
public class MyQuery extends SortableRequest {
|
||||
public MyQuery(ServerWebExchange exchange) { super(exchange); }
|
||||
|
||||
public ListOptions toListOptions() {
|
||||
return labelAndFieldSelectorToListOptions(getLabelSelector(), getFieldSelector());
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
MyQuery query = new MyQuery(request.exchange());
|
||||
client.listBy(MyExtension.class, query.toListOptions(), query.toPageRequest());
|
||||
```
|
||||
|
||||
Query parameter format: `?sort=field,asc` or `?sort=field,desc`. Multiple sorts: `?sort=field1,asc&sort=field2,desc`.
|
||||
|
||||
## AnonymousUserConst
|
||||
|
||||
Check for anonymous users:
|
||||
|
||||
```java
|
||||
if (AnonymousUserConst.isAnonymousUser(authentication.getName())) {
|
||||
// Handle anonymous user
|
||||
}
|
||||
// Constants: AnonymousUserConst.PRINCIPAL ("anonymousUser"), AnonymousUserConst.Role ("anonymous")
|
||||
```
|
||||
|
||||
## BackupRootGetter / PluginsRootGetter
|
||||
|
||||
`Supplier<Path>` for backup directory and plugin directory.
|
||||
|
||||
## LoginHandlerEnhancer
|
||||
|
||||
Hook into login success/failure for custom logic.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Content Handlers (Post / SinglePage)
|
||||
|
||||
Intercept and modify rendered post/page HTML before it reaches the theme.
|
||||
|
||||
> Source: [ReactivePostContentHandler](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/theme/ReactivePostContentHandler.java) | [ReactiveSinglePageContentHandler](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/theme/ReactiveSinglePageContentHandler.java)
|
||||
|
||||
## ReactivePostContentHandler
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class MyPostContentHandler implements ReactivePostContentHandler {
|
||||
|
||||
@Override
|
||||
public Mono<PostContentContext> handle(PostContentContext postContent) {
|
||||
var content = postContent.getContent();
|
||||
|
||||
// Modify HTML content
|
||||
var modified = content.replace("<h2>", "<h2 id=\"heading-\">");
|
||||
|
||||
return Mono.just(postContent.toBuilder()
|
||||
.content(modified)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## ReactiveSinglePageContentHandler
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class MyPageContentHandler implements ReactiveSinglePageContentHandler {
|
||||
|
||||
@Override
|
||||
public Mono<SinglePageContentContext> handle(SinglePageContentContext pageContent) {
|
||||
// Same pattern as PostContentHandler
|
||||
return Mono.just(pageContent);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Context Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| --------------------- | --------------------- | ---------------------------------- |
|
||||
| `post` / `singlePage` | `Post` / `SinglePage` | The extension object |
|
||||
| `content` | `String` | Rendered HTML content (modifiable) |
|
||||
| `raw` | `String` | Raw source content |
|
||||
| `rawType` | `String` | Content format (e.g., `markdown`) |
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
- Inject diagram rendering (Mermaid, text-diagram)
|
||||
- Add anchor links to headings
|
||||
- Wrap code blocks with copy buttons
|
||||
- Process custom shortcodes
|
||||
- Add lazy loading to images
|
||||
- Watermark injection
|
||||
@@ -0,0 +1,68 @@
|
||||
# TemplateHeadProcessor
|
||||
|
||||
Inject scripts, styles, or meta tags into the theme's `<head>` section.
|
||||
|
||||
> Source: [TemplateHeadProcessor](https://github.com/halo-dev/halo/blob/main/api/src/main/java/run/halo/app/theme/dialect/TemplateHeadProcessor.java)
|
||||
|
||||
## Usage
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class MyHeadProcessor implements TemplateHeadProcessor {
|
||||
|
||||
private final ReactiveSettingFetcher settingFetcher;
|
||||
|
||||
public MyHeadProcessor(ReactiveSettingFetcher settingFetcher) {
|
||||
this.settingFetcher = settingFetcher;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> process(ITemplateContext context, IModel model,
|
||||
IElementModelStructureHandler structureHandler) {
|
||||
return settingFetcher.fetch("basic", MyConfig.class)
|
||||
.flatMap(config -> {
|
||||
if (!config.isEnabled()) {
|
||||
return Mono.empty();
|
||||
}
|
||||
// Add script tag to <head>
|
||||
var script = createScriptTag(config.getTrackingId());
|
||||
model.add(script);
|
||||
return Mono.empty();
|
||||
})
|
||||
.then();
|
||||
}
|
||||
|
||||
private IModel createScriptTag(String trackingId) {
|
||||
// Build Thymeleaf model with script element
|
||||
var modelFactory = new ModelFactory();
|
||||
var script = modelFactory.createOpenElementTag("script");
|
||||
// ... configure attributes
|
||||
var model = modelFactory.createModel();
|
||||
model.add(script);
|
||||
return model;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Ordering
|
||||
|
||||
Use `@Order` to control execution order. Higher values execute first, allowing later processors to override earlier ones.
|
||||
|
||||
```java
|
||||
@Component
|
||||
@Order(100) // Higher = earlier
|
||||
public class HighPriorityHeadProcessor implements TemplateHeadProcessor { ... }
|
||||
|
||||
@Component
|
||||
@Order(200) // Lower = later, can override above
|
||||
public class LowPriorityHeadProcessor implements TemplateHeadProcessor { ... }
|
||||
```
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
- Analytics tracking (Google Analytics, Plausible)
|
||||
- Code highlighting (highlight.js, Prism, Shiki)
|
||||
- Math rendering (KaTeX, MathJax)
|
||||
- SEO meta tags (OpenGraph, Twitter Cards)
|
||||
- Custom CSS/JS injection
|
||||
- Comment widgets
|
||||
@@ -0,0 +1,199 @@
|
||||
# Theme Integration
|
||||
|
||||
Plugins can provide data and pages to the theme frontend via Finder APIs, Thymeleaf templates, and reverse proxies.
|
||||
|
||||
> - [Finder for theme (official docs)](https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/server/finder-for-theme.md)
|
||||
> - [Template for theme (official docs)](https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/server/template-for-theme.md)
|
||||
> - [Plugin photos Finder example](https://github.com/halo-sigs/plugin-photos/blob/main/src/main/java/run/halo/photos/finders/PhotoFinder.java)
|
||||
|
||||
## Finder API
|
||||
|
||||
A **Finder** is a Java class annotated with `@Component` that exposes methods callable from Thymeleaf templates.
|
||||
|
||||
### Creating a Finder
|
||||
|
||||
```java
|
||||
package com.example.myplugin.finders;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.theme.finders.Finder;
|
||||
|
||||
@Component
|
||||
@Finder("myPlugin") // Template variable name: ${myPlugin}
|
||||
public class MyPluginFinder {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
public MyPluginFinder(ReactiveExtensionClient client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
public Mono<ListResult<Person>> listPersons(int page, int size) {
|
||||
return client.listBy(Person.class,
|
||||
ListOptions.builder().build(),
|
||||
PageRequestImpl.of(page, size));
|
||||
}
|
||||
|
||||
public Mono<Person> getPerson(String name) {
|
||||
return client.fetch(Person.class, name);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using in Templates
|
||||
|
||||
```html
|
||||
<!-- List persons -->
|
||||
<ul>
|
||||
<li th:each="person : ${myPlugin.listPersons(1, 10).items}" th:text="${person.spec.name}"></li>
|
||||
</ul>
|
||||
|
||||
<!-- Get single person -->
|
||||
<div th:with="person = ${myPlugin.getPerson('john')}">
|
||||
<h1 th:text="${person.spec.name}"></h1>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Thymeleaf Templates
|
||||
|
||||
Place templates in `src/main/resources/templates/`:
|
||||
|
||||
```
|
||||
src/main/resources/
|
||||
templates/
|
||||
my-page.html # Accessible as a route
|
||||
modules/
|
||||
my-widget.html # Partial templates
|
||||
```
|
||||
|
||||
Use in a template:
|
||||
|
||||
```html
|
||||
<div th:replace="~{modules/my-widget :: my-widget}"></div>
|
||||
```
|
||||
|
||||
## Reverse Proxy
|
||||
|
||||
Serve plugin static resources or proxy external APIs through Halo:
|
||||
|
||||
Create `src/main/resources/extensions/reverseProxy.yaml`:
|
||||
|
||||
```yaml
|
||||
apiVersion: plugin.halo.run/v1alpha1
|
||||
kind: ReverseProxy
|
||||
metadata:
|
||||
name: my-plugin-reverse-proxy
|
||||
rules:
|
||||
- path: /assets
|
||||
file:
|
||||
directory: static/dist # relative to src/main/resources/
|
||||
- path: /api/proxy
|
||||
url:
|
||||
url: http://localhost:8080
|
||||
```
|
||||
|
||||
Access at: `/plugins/{plugin-name}/assets/...`
|
||||
|
||||
## Static Resources
|
||||
|
||||
Place static files in `src/main/resources/static/`:
|
||||
|
||||
```
|
||||
src/main/resources/
|
||||
static/
|
||||
dist/
|
||||
main.css
|
||||
main.js
|
||||
```
|
||||
|
||||
Access at: `/plugins/{plugin-name}/assets/dist/main.css`
|
||||
|
||||
## Template Variables
|
||||
|
||||
Plugins can contribute global template variables via a `TemplateModel` bean:
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class MyTemplateModel implements TemplateModel {
|
||||
|
||||
@Override
|
||||
public String getVariableName() {
|
||||
return "myPluginData";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Object> getValue() {
|
||||
return Mono.just(Map.of("version", "1.0.0"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then in any template:
|
||||
|
||||
```html
|
||||
<div th:text="${myPluginData.version}"></div>
|
||||
```
|
||||
|
||||
## CommentSubject
|
||||
|
||||
Enable Halo's comment system on your custom Extension:
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class MyCommentSubject implements CommentSubject<MyExtension> {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
public MyCommentSubject(ReactiveExtensionClient client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<MyExtension> get(String name) {
|
||||
return client.fetch(MyExtension.class, name)
|
||||
.switchIfEmpty(Mono.error(() -> new NotFoundException("Not found")));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<SubjectDisplay> getSubjectDisplay(String name) {
|
||||
return get(name).map(ext -> new SubjectDisplay(
|
||||
ext.getSpec().getTitle(),
|
||||
"/my-extensions/" + ext.getSpec().getSlug(),
|
||||
"My Extension"
|
||||
));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(Ref ref) {
|
||||
return GroupVersionKind.fromExtension(MyExtension.class).equals(ref.getGroupVersionKind());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Also add a role template aggregating `comment` permissions to `anonymous`:
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
labels:
|
||||
halo.run/role-template: "true"
|
||||
halo.run/hidden: "true"
|
||||
rbac.authorization.halo.run/aggregate-to-anonymous: "true"
|
||||
rules:
|
||||
- apiGroups: ["my-plugin.halo.run"]
|
||||
resources: ["my-extensions/comments"]
|
||||
verbs: ["create", "list"]
|
||||
```
|
||||
|
||||
## URL Conventions for Public APIs
|
||||
|
||||
When building APIs consumed by the theme:
|
||||
|
||||
```
|
||||
/apis/api.{group}/{version}/{resource}
|
||||
```
|
||||
|
||||
Example: `/apis/api.my-plugin.halo.run/v1alpha1/persons`
|
||||
|
||||
These should have role templates aggregated to `anonymous` for public access.
|
||||
@@ -0,0 +1,200 @@
|
||||
# API Requests in Plugin UI
|
||||
|
||||
Halo provides `@halo-dev/api-client` for making API calls from plugin Vue/TypeScript code.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pnpm install @halo-dev/api-client axios
|
||||
```
|
||||
|
||||
> `@halo-dev/ui-plugin-bundler-kit@2.17.0+` already excludes `@halo-dev/api-client` and `axios` from the bundle — the final build will use Halo's own copies. If using these versions, set `spec.requires: ">=2.17.0"` in `plugin.yaml`.
|
||||
|
||||
## Built-in API Clients
|
||||
|
||||
`@halo-dev/api-client` exports pre-configured clients for Halo's built-in APIs. They handle base URL, auth, error handling (login expiry, permission denied), etc.
|
||||
|
||||
```ts
|
||||
import {
|
||||
coreApiClient, // CRUD for all Extensions
|
||||
consoleApiClient, // Console APIs
|
||||
ucApiClient, // User Center APIs
|
||||
publicApiClient, // Public APIs
|
||||
axiosInstance, // Raw axios instance
|
||||
} from "@halo-dev/api-client";
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Halo adds global response interceptors to the shared Axios instance used by `@halo-dev/api-client`. Request failures from `coreApiClient`, `consoleApiClient`, `ucApiClient`, `publicApiClient`, generated clients constructed with `axiosInstance`, or direct `axiosInstance` calls already show Halo-managed error toasts.
|
||||
|
||||
- Do not add local `try/catch`, `catch`, or `useMutation.onError` handlers that call `Toast.error` / `Toast.warning` for those Axios request failures. Doing so can show duplicate toasts.
|
||||
- Keep local toasts for non-Axios errors such as client-side validation, parsing failures, missing local prerequisites, or domain-specific messages created before a request is sent.
|
||||
- If a handler must run cleanup or custom logic after a failed request, guard Axios errors with `isAxiosError` and do not toast them locally.
|
||||
|
||||
```ts
|
||||
import { Toast } from "@halo-dev/components";
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
function toastNonAxiosError(error: unknown) {
|
||||
if (isAxiosError(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Toast.error(error instanceof Error ? error.message : "Operation failed");
|
||||
}
|
||||
```
|
||||
|
||||
### coreApiClient (Extension CRUD)
|
||||
|
||||
```ts
|
||||
// List posts
|
||||
const { data } = await coreApiClient.content.post.listPost({
|
||||
page: 1,
|
||||
size: 10,
|
||||
sort: ["spec.publishTime,desc"],
|
||||
});
|
||||
|
||||
// Get a config map
|
||||
const { data: configMap } = await coreApiClient.extension.configMap.getv1alpha1ConfigMap({
|
||||
name: "my-plugin-configmap",
|
||||
});
|
||||
```
|
||||
|
||||
### consoleApiClient / ucApiClient / publicApiClient
|
||||
|
||||
```ts
|
||||
// Console: list attachments
|
||||
const { data } = await consoleApiClient.attachment.listAttachments({
|
||||
page: 1,
|
||||
size: 20,
|
||||
});
|
||||
|
||||
// UC: get current user notifications
|
||||
const { data } = await ucApiClient.notification.listNotifications();
|
||||
|
||||
// Public: search
|
||||
const { data } = await publicApiClient.post.searchPost({ keyword: "halo" });
|
||||
```
|
||||
|
||||
## Calling Plugin Custom APIs
|
||||
|
||||
For APIs defined by your plugin (CustomEndpoint, @Controller, etc.), use the raw `axiosInstance`:
|
||||
|
||||
```ts
|
||||
import { axiosInstance } from "@halo-dev/api-client";
|
||||
|
||||
// GET custom endpoint
|
||||
const { data } = await axiosInstance.get("/apis/console.api.my-plugin.halo.run/v1alpha1/items");
|
||||
|
||||
// POST with body
|
||||
await axiosInstance.post("/apis/console.api.my-plugin.halo.run/v1alpha1/items", {
|
||||
name: "new-item",
|
||||
});
|
||||
|
||||
// Custom query params
|
||||
const { data } = await axiosInstance.get("/apis/api.my-plugin.halo.run/v1alpha1/public/items", {
|
||||
params: { page: 1, size: 10 },
|
||||
});
|
||||
```
|
||||
|
||||
## Generated API Client (Recommended for Plugin APIs)
|
||||
|
||||
For plugin-defined APIs, use the DevTools `generateApiClient` Gradle task to generate a typed TypeScript client from your OpenAPI spec.
|
||||
|
||||
### 1. Configure OpenAPI grouping in `build.gradle`
|
||||
|
||||
```groovy
|
||||
haloPlugin {
|
||||
openApi {
|
||||
groupingRules {
|
||||
extensionApis {
|
||||
displayName = 'Extension API for MyPlugin'
|
||||
pathsToMatch = ['/apis/my-plugin.halo.run/v1alpha1/**']
|
||||
}
|
||||
}
|
||||
groupedApiMappings = [
|
||||
'/v3/api-docs/extensionApis': 'extensionApis.json'
|
||||
]
|
||||
generator {
|
||||
outputDir = file("${projectDir}/ui/src/api/generated")
|
||||
additionalProperties = [
|
||||
useES6: true,
|
||||
useSingleRequestParameter: true,
|
||||
withSeparateModelsAndApi: true,
|
||||
apiPackage: "api",
|
||||
modelPackage: "models"
|
||||
]
|
||||
typeMappings = [
|
||||
set: "Array"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Generate the client
|
||||
|
||||
```bash
|
||||
./gradlew generateApiClient
|
||||
```
|
||||
|
||||
### 3. Use the generated client with `axiosInstance`
|
||||
|
||||
```ts
|
||||
import { axiosInstance } from "@halo-dev/api-client";
|
||||
import { MyResourceV1alpha1Api } from "./api/generated";
|
||||
|
||||
const api = new MyResourceV1alpha1Api(undefined, "", axiosInstance);
|
||||
|
||||
// List with typed parameters
|
||||
const { data } = await api.listMyResources({ page: 1, size: 10 });
|
||||
|
||||
// Create with typed body
|
||||
await api.createMyResource({ myResource: { ... } });
|
||||
```
|
||||
|
||||
> The generated client needs `axiosInstance` as its third constructor argument so it uses Halo's pre-configured axios (with auth, base URL, error handling).
|
||||
|
||||
## Data Fetching with `@tanstack/vue-query`
|
||||
|
||||
For managing server state (caching, refetching, mutations) in plugin Vue components, use `@tanstack/vue-query`.
|
||||
|
||||
> **Version warning:** Halo plugins currently use **v4** of `@tanstack/vue-query` (e.g. `^4.44.0`). Do **not** install v5 — the API is incompatible.
|
||||
|
||||
```bash
|
||||
pnpm install @tanstack/vue-query@^4.44.0
|
||||
```
|
||||
|
||||
Halo's plugin runtime already provides `VueQueryPlugin` setup — plugin code can use `useQuery` / `useMutation` directly without additional configuration.
|
||||
|
||||
### Usage Example
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { consoleApiClient } from "@halo-dev/api-client";
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["attachments"],
|
||||
queryFn: async () => {
|
||||
const { data } = await consoleApiClient.attachment.listAttachments({
|
||||
page: 1,
|
||||
size: 20,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
> Use `useQuery` for read operations and `useMutation` + `queryClient.invalidateQueries()` for create/update/delete operations.
|
||||
|
||||
## When to Use Which
|
||||
|
||||
| Approach | Use for | Example |
|
||||
| ------------------------------------- | ---------------------------- | ----------------------------------- |
|
||||
| `coreApiClient` / `consoleApiClient` | Halo built-in APIs | List posts, fetch users |
|
||||
| `axiosInstance` directly | Ad-hoc plugin API calls | Simple GET/POST to custom endpoints |
|
||||
| `generateApiClient` + `axiosInstance` | Plugin APIs with type safety | Full CRUD on your custom Extension |
|
||||
| `@tanstack/vue-query` + API clients | Server state in Vue UI | Cached lists, mutations, loading UI |
|
||||
@@ -0,0 +1,173 @@
|
||||
# UI Build (@halo-dev/ui-plugin-bundler-kit)
|
||||
|
||||
Halo plugins use `@halo-dev/ui-plugin-bundler-kit` to build Vue/TypeScript frontend code into `main.js` + `style.css`.
|
||||
|
||||
> - [NPM: @halo-dev/ui-plugin-bundler-kit](https://www.npmjs.com/package/@halo-dev/ui-plugin-bundler-kit)
|
||||
> - [NPM: @halo-dev/ui-shared](https://www.npmjs.com/package/@halo-dev/ui-shared)
|
||||
> - [NPM: @halo-dev/api-client](https://www.npmjs.com/package/@halo-dev/api-client)
|
||||
|
||||
## Build Tool Options
|
||||
|
||||
| Feature | Vite | Rsbuild (recommended for complex plugins) |
|
||||
| ----------------- | --------- | ----------------------------------------- |
|
||||
| Code splitting | Limited | Excellent |
|
||||
| Build performance | Good | Excellent |
|
||||
| Vue ecosystem | Excellent | Good |
|
||||
| Dev experience | Excellent | Excellent |
|
||||
| Dynamic imports | Limited | Excellent |
|
||||
|
||||
## Vite Setup
|
||||
|
||||
```bash
|
||||
pnpm install @halo-dev/ui-plugin-bundler-kit@2.22.0 vite -D
|
||||
```
|
||||
|
||||
```ts
|
||||
// vite.config.ts
|
||||
import { viteConfig } from "@halo-dev/ui-plugin-bundler-kit";
|
||||
|
||||
export default viteConfig();
|
||||
```
|
||||
|
||||
```json
|
||||
// package.json
|
||||
{
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch --mode=development",
|
||||
"build": "vite build"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With Customizations
|
||||
|
||||
```ts
|
||||
import { viteConfig } from "@halo-dev/ui-plugin-bundler-kit";
|
||||
import path from "path";
|
||||
|
||||
export default viteConfig({
|
||||
vite: {
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
// Additional Vite plugins (Vue plugin is pre-configured)
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Rsbuild Setup
|
||||
|
||||
```bash
|
||||
pnpm install @halo-dev/ui-plugin-bundler-kit@2.22.0 @rsbuild/core -D
|
||||
```
|
||||
|
||||
```ts
|
||||
// rsbuild.config.ts
|
||||
import { rsbuildConfig } from "@halo-dev/ui-plugin-bundler-kit";
|
||||
|
||||
export default rsbuildConfig();
|
||||
```
|
||||
|
||||
```json
|
||||
// package.json
|
||||
{
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "rsbuild build --env-mode development --watch",
|
||||
"build": "rsbuild build"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With Customizations
|
||||
|
||||
```ts
|
||||
import { rsbuildConfig } from "@halo-dev/ui-plugin-bundler-kit";
|
||||
|
||||
export default rsbuildConfig({
|
||||
rsbuild: {
|
||||
source: {
|
||||
alias: {
|
||||
"@": "./src",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
// Additional Rsbuild plugins
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Output Directories
|
||||
|
||||
| Environment | Output Path |
|
||||
| ----------- | ----------------------------------------------------------------------------------------- |
|
||||
| Development | `build/resources/main/console/` |
|
||||
| Production | `ui/build/dist/` (temporary, Gradle copies to `src/main/resources/console/` during build) |
|
||||
|
||||
## Dynamic Imports (Rsbuild)
|
||||
|
||||
```ts
|
||||
import { definePlugin } from "@halo-dev/ui-shared";
|
||||
import { defineAsyncComponent } from "vue";
|
||||
import { VLoading } from "@halo-dev/components";
|
||||
|
||||
export default definePlugin({
|
||||
routes: [
|
||||
{
|
||||
parentName: "Root",
|
||||
route: {
|
||||
path: "heavy-page",
|
||||
name: "HeavyPage",
|
||||
component: defineAsyncComponent({
|
||||
loader: () => import("./views/HeavyPage.vue"),
|
||||
loadingComponent: VLoading,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Gradle Integration
|
||||
|
||||
In the root `build.gradle`:
|
||||
|
||||
```groovy
|
||||
tasks.register('processUiResources', Copy) {
|
||||
from project(':ui').layout.buildDirectory.dir('dist')
|
||||
into layout.buildDirectory.dir('resources/main/console')
|
||||
dependsOn project(':ui').tasks.named('assemble')
|
||||
shouldRunAfter tasks.named('processResources')
|
||||
}
|
||||
|
||||
tasks.named('classes') {
|
||||
dependsOn tasks.named('processUiResources')
|
||||
}
|
||||
```
|
||||
|
||||
In `ui/build.gradle`:
|
||||
|
||||
```groovy
|
||||
plugins {
|
||||
id 'base'
|
||||
id "com.github.node-gradle.node" version "7.1.0"
|
||||
}
|
||||
|
||||
tasks.register('buildFrontend', PnpmTask) {
|
||||
group = 'build'
|
||||
args = ['build']
|
||||
dependsOn tasks.named('pnpmInstall')
|
||||
inputs.dir(layout.projectDirectory.dir('src'))
|
||||
outputs.dir(layout.buildDirectory.dir('dist'))
|
||||
}
|
||||
|
||||
tasks.named('assemble') {
|
||||
dependsOn tasks.named('buildFrontend')
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,252 @@
|
||||
# UI Components
|
||||
|
||||
Halo provides two layers of UI primitives for plugin frontends:
|
||||
|
||||
1. **Base component library** (`@halo-dev/components`) — install and import explicitly
|
||||
2. **Business components & directives** — globally registered, use directly without import
|
||||
|
||||
> Full base component docs: https://halo-ui-components.halo-run.workers.dev
|
||||
>
|
||||
> For forms, see [ui-forms.md](ui-forms.md) — Halo uses FormKit (globally registered) with many custom inputs.
|
||||
|
||||
## Official Docs Routing
|
||||
|
||||
Component APIs change across Halo versions. Treat the lists below as common
|
||||
shortcuts, not an exhaustive API reference.
|
||||
|
||||
| Need | Official docs |
|
||||
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Base components from `@halo-dev/components` | https://halo-ui-components.halo-run.workers.dev |
|
||||
| Business component index | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/ui/components/index.md |
|
||||
| `AttachmentSelectorModal` | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/ui/components/attachment-selector-modal.md |
|
||||
| `AttachmentFileTypeIcon` | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/ui/components/attachment-file-type-icon.md |
|
||||
| `AnnotationsForm` | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/ui/components/annotations-form.md |
|
||||
| `FilterDropdown` / `FilterCleanButton` | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/ui/components/filter-dropdown.md |
|
||||
| `HasPermission` | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/ui/components/has-permission.md |
|
||||
| `PluginDetailModal` | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/ui/components/plugin-detail-modal.md |
|
||||
| `SearchInput` | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/ui/components/search-input.md |
|
||||
| `UppyUpload` | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/ui/components/uppy-upload.md |
|
||||
| `VCodemirror` | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/ui/components/v-codemirror.md |
|
||||
| `v-permission` | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/ui/components/v-permission.md |
|
||||
| `v-tooltip` | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/ui/components/v-tooltip.md |
|
||||
|
||||
## Base Component Library
|
||||
|
||||
Install:
|
||||
|
||||
```bash
|
||||
pnpm install @halo-dev/components
|
||||
```
|
||||
|
||||
Import and use:
|
||||
|
||||
```vue
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { VButton, VModal, VCard } from "@halo-dev/components";
|
||||
const visible = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VButton type="secondary" @click="visible = true">Open</VButton>
|
||||
<VModal v-if="visible" @close="visible = false" title="Title">
|
||||
<VCard>Content</VCard>
|
||||
</VModal>
|
||||
</template>
|
||||
```
|
||||
|
||||
Common exported components from `@halo-dev/components`:
|
||||
|
||||
| Component | Purpose |
|
||||
| ----------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `VAlert` | Alert banner |
|
||||
| `VAvatar` / `VAvatarGroup` | User avatar(s) |
|
||||
| `VButton` | Button with variants: `default`, `primary`, `secondary`, `danger`, `ghost` |
|
||||
| `VCard` | Card container |
|
||||
| `VDescription` / `VDescriptionItem` | Key-value description list |
|
||||
| `VDialog` + `Dialog` (manager) | Dialog with imperative API |
|
||||
| `VDropdownDivider` / `VDropdownItem` | Dropdown menu items |
|
||||
| `VEmpty` | Empty state placeholder |
|
||||
| `VEntity` / `VEntityContainer` / `VEntityField` | Entity list item layout |
|
||||
| `VLoading` | Loading spinner/overlay |
|
||||
| `VMenu` / `VMenuItem` / `VMenuLabel` | Menu navigation |
|
||||
| `VModal` | Modal with `v-model:visible` |
|
||||
| `VPagination` | Pagination control |
|
||||
| `VPageHeader` | Page header with back button/title/actions |
|
||||
| `VSpace` | Flex spacing layout |
|
||||
| `VStatusDot` | Status indicator dot |
|
||||
| `VSwitch` | Toggle switch |
|
||||
| `VTabbar` / `VTabs` / `VTabItem` | Tab navigation |
|
||||
| `VTag` | Colored tag/badge |
|
||||
| `Toast` (manager) | Toast notification imperative API |
|
||||
| `VTooltipComponent` / `vTooltip` | Tooltip component/directive |
|
||||
|
||||
## Business Components (Globally Registered)
|
||||
|
||||
These are available without import in any plugin Vue component.
|
||||
|
||||
### VCodemirror
|
||||
|
||||
Code editor.
|
||||
|
||||
```vue
|
||||
<VCodemirror v-model="value" height="300px" language="yaml" />
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------ | -------- | -------- | --------------------------------------------- |
|
||||
| `modelValue` | `string` | `""` | Binding value |
|
||||
| `height` | `string` | `"auto"` | Editor height |
|
||||
| `language` | `string` | `"yaml"` | Language: `yaml`, `html`, `js`, `css`, `json` |
|
||||
| `extensions` | `array` | `[]` | Codemirror extensions |
|
||||
|
||||
### AttachmentSelectorModal
|
||||
|
||||
Attachment picker modal (Console only).
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const visible = ref(false);
|
||||
function onSelect(attachments) {
|
||||
console.log(attachments); // AttachmentLike[]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VButton @click="visible = true">Select</VButton>
|
||||
<AttachmentSelectorModal
|
||||
v-if="visible"
|
||||
@close="visible = false"
|
||||
:accepts="['image/*']"
|
||||
:min="1"
|
||||
:max="5"
|
||||
@select="onSelect"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------- | ---------- | --------- | ----------------------- |
|
||||
| `visible` | `boolean` | `false` | Controlled visibility |
|
||||
| `accepts` | `string[]` | `["*/*"]` | Accepted MIME types |
|
||||
| `min` / `max` | `number` | — | Min/max selection count |
|
||||
|
||||
### UppyUpload
|
||||
|
||||
File upload component.
|
||||
|
||||
```vue
|
||||
<UppyUpload
|
||||
endpoint="/apis/api.console.halo.run/v1alpha1/attachments/upload"
|
||||
:meta="{ policyName, groupName }"
|
||||
@uploaded="onUploaded"
|
||||
@error="onError"
|
||||
/>
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------- | ------------------------- | -------- | ------------------------------- |
|
||||
| `endpoint` | `string` | required | Upload API endpoint |
|
||||
| `meta` | `Record<string, unknown>` | — | Extra metadata sent with upload |
|
||||
| `autoProceed` | `boolean` | `false` | Auto-upload on select |
|
||||
| `method` | `string` | `"post"` | HTTP method |
|
||||
|
||||
### SearchInput
|
||||
|
||||
Search input that only triggers on Enter (not while typing).
|
||||
|
||||
```vue
|
||||
<SearchInput v-model="keyword" placeholder="Search..." />
|
||||
```
|
||||
|
||||
### AttachmentFileTypeIcon
|
||||
|
||||
File-type icon for attachment/file lists.
|
||||
|
||||
```vue
|
||||
<AttachmentFileTypeIcon fileName="example.png" :display-ext="true" />
|
||||
```
|
||||
|
||||
### AnnotationsForm
|
||||
|
||||
Renders the Annotations form for a given Extension group/kind.
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const formRef = ref();
|
||||
|
||||
async function handleSubmit() {
|
||||
formRef.value?.handleSubmit();
|
||||
await nextTick();
|
||||
const { customAnnotations, annotations, customFormInvalid, specFormInvalid } =
|
||||
formRef.value || {};
|
||||
if (customFormInvalid || specFormInvalid) return;
|
||||
const merged = { ...annotations, ...customAnnotations };
|
||||
// ...submit merged
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AnnotationsForm ref="formRef" :value="currentAnnotations" kind="Post" group="content.halo.run" />
|
||||
<VButton @click="handleSubmit">Save</VButton>
|
||||
</template>
|
||||
```
|
||||
|
||||
### FilterDropdown / FilterCleanButton
|
||||
|
||||
Generic filter dropdown and clear button for list pages.
|
||||
|
||||
```vue
|
||||
<FilterDropdown
|
||||
v-model="sortValue"
|
||||
label="Sort"
|
||||
:items="[
|
||||
{ label: 'Newest', value: 'creationTimestamp,desc' },
|
||||
{ label: 'Oldest', value: 'creationTimestamp,asc' },
|
||||
]"
|
||||
/>
|
||||
|
||||
<FilterCleanButton @click="resetFilters" />
|
||||
```
|
||||
|
||||
### PluginDetailModal
|
||||
|
||||
Open a plugin's detail/settings modal inline.
|
||||
|
||||
```vue
|
||||
<PluginDetailModal v-if="visible" name="my-plugin" @close="visible = false" />
|
||||
```
|
||||
|
||||
| Prop | Type | Description |
|
||||
| ------ | -------- | -------------------- |
|
||||
| `name` | `string` | Plugin metadata.name |
|
||||
|
||||
### HasPermission
|
||||
|
||||
Render content only when the user has the required permissions.
|
||||
|
||||
```vue
|
||||
<HasPermission :permissions="['system:posts:manage']">
|
||||
<VButton type="danger">Delete</VButton>
|
||||
</HasPermission>
|
||||
```
|
||||
|
||||
## Directives (Globally Registered)
|
||||
|
||||
### v-permission
|
||||
|
||||
Conditionally render based on permissions.
|
||||
|
||||
```vue
|
||||
<VButton type="danger" v-permission="['system:posts:manage']">Delete</VButton>
|
||||
```
|
||||
|
||||
Equivalent component: `<HasPermission :permissions="['system:posts:manage']">...</HasPermission>`
|
||||
|
||||
### v-tooltip
|
||||
|
||||
Add tooltip to any element.
|
||||
|
||||
```vue
|
||||
<IconDeleteBin v-tooltip="'Delete this item'" />
|
||||
```
|
||||
@@ -0,0 +1,176 @@
|
||||
# UI Entry & Routes
|
||||
|
||||
The frontend entry file exports a plugin definition using `definePlugin` from `@halo-dev/ui-shared`.
|
||||
|
||||
## Entry File (`ui/src/index.ts`)
|
||||
|
||||
```ts
|
||||
import { definePlugin } from "@halo-dev/ui-shared";
|
||||
import HomeView from "./views/HomeView.vue";
|
||||
import { IconComputer } from "@halo-dev/components";
|
||||
import { markRaw } from "vue";
|
||||
|
||||
export default definePlugin({
|
||||
components: {
|
||||
// Global component registration
|
||||
// "MyComponent": MyComponent
|
||||
},
|
||||
routes: [
|
||||
// Console routes
|
||||
{
|
||||
parentName: "Root",
|
||||
route: {
|
||||
path: "/my-plugin",
|
||||
name: "MyPluginHome",
|
||||
component: HomeView,
|
||||
meta: {
|
||||
title: "My Plugin",
|
||||
permissions: [],
|
||||
menu: {
|
||||
name: "My Plugin",
|
||||
group: "tool", // dashboard | content | interface | system | tool
|
||||
icon: markRaw(IconComputer),
|
||||
priority: 40,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
ucRoutes: [
|
||||
// UC (User Center) routes
|
||||
{
|
||||
parentName: "Root",
|
||||
route: {
|
||||
path: "/uc-my-plugin",
|
||||
name: "MyPluginUCHome",
|
||||
component: HomeView,
|
||||
meta: {
|
||||
permissions: [],
|
||||
menu: {
|
||||
name: "My Plugin",
|
||||
priority: 40,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
extensionPoints: {
|
||||
// UI extension point implementations
|
||||
},
|
||||
formkit: {
|
||||
inputs: {
|
||||
// Custom FormKit inputs (Halo 2.25+)
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Route Definition
|
||||
|
||||
### With parentName (RouteRecordAppend)
|
||||
|
||||
```ts
|
||||
{
|
||||
parentName: "Root",
|
||||
route: { /* RouteRecordRaw */ }
|
||||
}
|
||||
```
|
||||
|
||||
### Without parentName (RouteRecordRaw)
|
||||
|
||||
```ts
|
||||
{
|
||||
path: "/standalone",
|
||||
name: "StandalonePage",
|
||||
component: MyView
|
||||
}
|
||||
```
|
||||
|
||||
## Console Parent Routes
|
||||
|
||||
| parentName | Section |
|
||||
| ----------------- | --------------------- |
|
||||
| `Root` | Top level |
|
||||
| `AttachmentsRoot` | Attachment management |
|
||||
| `CommentsRoot` | Comments |
|
||||
| `SinglePagesRoot` | Single pages |
|
||||
| `PostsRoot` | Posts |
|
||||
| `MenusRoot` | Menus |
|
||||
| `ThemeRoot` | Themes |
|
||||
| `OverviewRoot` | Overview |
|
||||
| `BackupRoot` | Backups |
|
||||
| `PluginsRoot` | Plugins |
|
||||
| `SettingsRoot` | Settings |
|
||||
| `UsersRoot` | Users |
|
||||
| `ToolsRoot` | Tools |
|
||||
|
||||
## UC Parent Routes
|
||||
|
||||
| parentName | Section |
|
||||
| ------------------- | ------------- |
|
||||
| `PostsRoot` | Posts |
|
||||
| `NotificationsRoot` | Notifications |
|
||||
|
||||
## RouteMeta
|
||||
|
||||
```ts
|
||||
interface RouteMeta {
|
||||
title?: string; // Browser tab title
|
||||
searchable?: boolean; // Include in Console global search
|
||||
permissions?: string[]; // Required UI permissions
|
||||
menu?: {
|
||||
name: string; // Menu display name
|
||||
group?: CoreMenuGroupId; // Built-in group or custom group name
|
||||
icon?: Component; // Vue icon component (use markRaw)
|
||||
priority: number; // Sort order (lower = higher)
|
||||
mobile?: boolean; // Show on mobile
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Using markRaw for Icons
|
||||
|
||||
Always wrap icon components with `markRaw`:
|
||||
|
||||
```ts
|
||||
import { IconComputer } from "@halo-dev/components";
|
||||
import { markRaw } from "vue";
|
||||
|
||||
icon: markRaw(IconComputer);
|
||||
```
|
||||
|
||||
## Permissions in Routes
|
||||
|
||||
```ts
|
||||
meta: {
|
||||
permissions: ["plugin:my-plugin:person:manage"];
|
||||
}
|
||||
```
|
||||
|
||||
The route is hidden if the user lacks any of the listed permissions.
|
||||
|
||||
## Custom FormKit Inputs
|
||||
|
||||
Halo 2.25+ lets plugin UI entries register custom FormKit inputs for use in
|
||||
plugin-provided FormKit Schema.
|
||||
|
||||
```ts
|
||||
import { createInput } from "@formkit/vue";
|
||||
import { definePlugin } from "@halo-dev/ui-shared";
|
||||
import { defineAsyncComponent } from "vue";
|
||||
|
||||
export default definePlugin({
|
||||
formkit: {
|
||||
inputs: {
|
||||
myPluginInput: createInput(
|
||||
defineAsyncComponent(() => import("./components/MyPluginInput.vue")),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Use a plugin-prefixed input name to avoid collisions with Halo built-ins or
|
||||
earlier-loaded plugins. If the input definition uses `@formkit/vue`, require
|
||||
`@halo-dev/ui-plugin-bundler-kit@2.25.0+` and set `plugin.yaml` `spec.requires`
|
||||
to `>=2.25.0`.
|
||||
@@ -0,0 +1,116 @@
|
||||
# UI Extension Points
|
||||
|
||||
Plugins can extend existing Console/UC UI via `extensionPoints` in `definePlugin()`.
|
||||
|
||||
## ExtensionPoint Keys
|
||||
|
||||
### Editor
|
||||
|
||||
```ts
|
||||
"editor:create": () => EditorProvider[] | Promise<EditorProvider[]>;
|
||||
"default:editor:extension:create": () => AnyExtension[] | Promise<AnyExtension[]>;
|
||||
```
|
||||
|
||||
### Dashboard
|
||||
|
||||
```ts
|
||||
"console:dashboard:widgets:create": () => DashboardWidgetDefinition[] | Promise<DashboardWidgetDefinition[]>;
|
||||
"console:dashboard:widgets:internal:quick-action:item:create": () => DashboardWidgetQuickActionItem[] | Promise<DashboardWidgetQuickActionItem[]>;
|
||||
```
|
||||
|
||||
### Attachment Selector
|
||||
|
||||
```ts
|
||||
"attachment:selector:create": () => AttachmentSelectProvider[] | Promise<AttachmentSelectProvider[]>;
|
||||
```
|
||||
|
||||
### Comment Subject Ref
|
||||
|
||||
```ts
|
||||
"comment:subject-ref:create": () => CommentSubjectRefProvider[];
|
||||
```
|
||||
|
||||
### List Item Operations
|
||||
|
||||
Add action buttons to list items:
|
||||
|
||||
```ts
|
||||
"post:list-item:operation:create": (post: Ref<ListedPost>) => OperationItem<ListedPost>[];
|
||||
"single-page:list-item:operation:create": (singlePage: Ref<ListedSinglePage>) => OperationItem<ListedSinglePage>[];
|
||||
"comment:list-item:operation:create": (comment: Ref<ListedComment>) => OperationItem<ListedComment>[];
|
||||
"reply:list-item:operation:create": (reply: Ref<ListedReply>) => OperationItem<ListedReply>[];
|
||||
"plugin:list-item:operation:create": (plugin: Ref<Plugin>) => OperationItem<Plugin>[];
|
||||
"backup:list-item:operation:create": (backup: Ref<Backup>) => OperationItem<Backup>[];
|
||||
"attachment:list-item:operation:create": (attachment: Ref<Attachment>) => OperationItem<Attachment>[];
|
||||
"theme:list-item:operation:create": (theme: Ref<Theme>) => OperationItem<Theme>[];
|
||||
```
|
||||
|
||||
### List Item Fields
|
||||
|
||||
Add columns to list tables:
|
||||
|
||||
```ts
|
||||
"plugin:list-item:field:create": (plugin: Ref<Plugin>) => EntityFieldItem[];
|
||||
"post:list-item:field:create": (post: Ref<ListedPost>) => EntityFieldItem[];
|
||||
"single-page:list-item:field:create": (singlePage: Ref<ListedSinglePage>) => EntityFieldItem[];
|
||||
```
|
||||
|
||||
### Tabs
|
||||
|
||||
```ts
|
||||
"plugin:self:tabs:create": () => PluginTab[] | Promise<PluginTab[]>;
|
||||
"backup:tabs:create": () => BackupTab[] | Promise<BackupTab[]>;
|
||||
"plugin:installation:tabs:create": () => PluginInstallationTab[] | Promise<PluginInstallationTab[]>;
|
||||
"theme:list:tabs:create": () => ThemeListTab[] | Promise<ThemeListTab[]>;
|
||||
"user:detail:tabs:create": () => UserTab[] | Promise<UserTab[]>;
|
||||
"uc:user:profile:tabs:create": () => UserProfileTab[] | Promise<UserProfileTab[]>;
|
||||
```
|
||||
|
||||
## Example: Adding a Post List Operation
|
||||
|
||||
```ts
|
||||
import { definePlugin } from "@halo-dev/ui-shared";
|
||||
import { VButton } from "@halo-dev/components";
|
||||
import { h } from "vue";
|
||||
|
||||
export default definePlugin({
|
||||
extensionPoints: {
|
||||
"post:list-item:operation:create": (post) => [
|
||||
{
|
||||
priority: 10,
|
||||
component: h(
|
||||
VButton,
|
||||
{
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
console.log("Action on post:", post.value.metadata.name);
|
||||
},
|
||||
},
|
||||
() => "My Action",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Example: Registering a Dashboard Widget
|
||||
|
||||
```ts
|
||||
import { definePlugin } from "@halo-dev/ui-shared";
|
||||
import MyWidget from "./components/MyWidget.vue";
|
||||
|
||||
export default definePlugin({
|
||||
extensionPoints: {
|
||||
"console:dashboard:widgets:create": () => [
|
||||
{
|
||||
id: "my-plugin-widget",
|
||||
name: "My Widget",
|
||||
component: MyWidget,
|
||||
priority: 10,
|
||||
permissions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,480 @@
|
||||
# UI Forms (FormKit)
|
||||
|
||||
Halo uses [FormKit](https://formkit.com/) as its form solution. FormKit is **globally registered** in both Console and UC (User Center) — you do **not** need to install or import FormKit in plugin UI code. Use `<FormKit>` components directly, or define forms via Schema in `Setting` resources.
|
||||
|
||||
> **Critical**: Do NOT build custom form components from scratch (e.g. raw `<input>` elements) in plugin pages. Always use FormKit inputs so your UI stays consistent with the rest of Halo.
|
||||
|
||||
## Docs Routing
|
||||
|
||||
FormKit integration changes across Halo versions. Treat this file as a plugin
|
||||
working guide, then verify exact input options in the official docs when using a
|
||||
recent or version-sensitive field.
|
||||
|
||||
| Need | Official docs |
|
||||
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Core form schema and built-in inputs | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/form-schema.md |
|
||||
| Plugin custom FormKit inputs | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/ui/formkit.md |
|
||||
| Plugin UI entry shape (`formkit.inputs`) | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/basics/ui/entry.md |
|
||||
| Plugin API changelog for version gates | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-changelog.md |
|
||||
| Business form components and directives | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/plugin/api-reference/ui/components/index.md |
|
||||
| Annotation forms for extension metadata | https://raw.githubusercontent.com/halo-dev/docs/refs/heads/main/docs/developer-guide/annotations-form.md |
|
||||
|
||||
When working from a local docs checkout, use the same paths under
|
||||
`docs/developer-guide/...`; versioned docs live under
|
||||
`versioned_docs/version-2.25/...`.
|
||||
|
||||
## Quick Index
|
||||
|
||||
- Plugin setting forms: [Setting Schema](#1-setting-schema-plugin-config)
|
||||
- Direct plugin page forms: [Vue Component](#2-vue-component-direct-formkit)
|
||||
- Built-in and Halo custom inputs: [Available Inputs](#available-inputs)
|
||||
- Version-sensitive fields: [Version Notes](#version-notes)
|
||||
- Validation and schema patterns: [Validation](#validation)
|
||||
- Submission patterns:
|
||||
[Programmatic Form Submission](#programmatic-form-submission)
|
||||
|
||||
## Two Ways to Use Forms
|
||||
|
||||
### 1. Setting Schema (Plugin Config)
|
||||
|
||||
For plugin settings that users configure in the plugin detail page, define the form in a `Setting` resource using FormKit Schema syntax (written in YAML):
|
||||
|
||||
```yaml
|
||||
# src/main/resources/extensions/settings.yaml
|
||||
apiVersion: v1alpha1
|
||||
kind: Setting
|
||||
metadata:
|
||||
name: my-plugin-settings # must match spec.settingName in plugin.yaml
|
||||
spec:
|
||||
forms:
|
||||
- group: basic
|
||||
label: Basic Settings
|
||||
formSchema:
|
||||
- $formkit: text
|
||||
name: apiKey
|
||||
label: API Key
|
||||
value: ""
|
||||
validation: required
|
||||
- $formkit: switch
|
||||
name: enabled
|
||||
label: Enable Feature
|
||||
value: true
|
||||
```
|
||||
|
||||
Then reference it in `plugin.yaml`:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
settingName: my-plugin-settings
|
||||
configMapName: my-plugin-configmap
|
||||
```
|
||||
|
||||
See [plugin-manifest.md](plugin-manifest.md#settings--configmap) for full setup.
|
||||
|
||||
### 2. Vue Component (Direct `<FormKit>`)
|
||||
|
||||
For forms inside plugin pages (e.g. a custom admin page), use `<FormKit>` components directly:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<FormKit id="my-form" type="form" :actions="false" @submit="handleSubmit">
|
||||
<FormKit type="text" name="title" label="Title" validation="required" />
|
||||
<FormKit type="textarea" name="description" label="Description" :auto-height="true" />
|
||||
<FormKit type="switch" name="published" label="Published" :value="true" />
|
||||
<VButton type="primary" @click="$formkit.submit('my-form')"> Save </VButton>
|
||||
</FormKit>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Toast } from "@halo-dev/components";
|
||||
|
||||
function handleSubmit(values: Record<string, unknown>) {
|
||||
console.log(values);
|
||||
Toast.success("Saved");
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
No `import { FormKit } from "@formkit/vue"` is needed — FormKit is globally registered.
|
||||
|
||||
## Available Inputs
|
||||
|
||||
### FormKit Built-ins (Official)
|
||||
|
||||
All standard FormKit inputs work out of the box:
|
||||
|
||||
| Input | Type | Description |
|
||||
| ------------------------- | ---------------------- | ------------------------------------------ |
|
||||
| `text` | `string` | Single-line text |
|
||||
| `textarea` | `string` | Multi-line text (with `auto-height` addon) |
|
||||
| `email` | `string` | Email with validation |
|
||||
| `number` | `number` | Numeric input |
|
||||
| `password` | `string` | Password (Halo disables autocomplete) |
|
||||
| `date` / `datetime-local` | `string` | Date pickers |
|
||||
| `checkbox` | `boolean` / `string[]` | Single or multi checkbox |
|
||||
| `radio` | `string` | Radio group |
|
||||
| `range` | `number` | Slider |
|
||||
| `file` | `FileList` | File input |
|
||||
| `group` | `object` | Nested object container |
|
||||
|
||||
### Halo Custom Inputs
|
||||
|
||||
Halo registers additional inputs for common CMS use cases. Use them exactly like built-ins:
|
||||
|
||||
#### `select` — Enhanced Select
|
||||
|
||||
Custom select with static or remote data source, multi-select, sorting, and search.
|
||||
|
||||
```yaml
|
||||
- $formkit: select
|
||||
name: country
|
||||
label: Country
|
||||
searchable: true
|
||||
clearable: true
|
||||
options:
|
||||
- label: China
|
||||
value: cn
|
||||
icon: /assets/flags/cn.svg
|
||||
description: Chinese cuisine with rich regional styles
|
||||
- label: USA
|
||||
value: us
|
||||
```
|
||||
|
||||
Remote data source:
|
||||
|
||||
```yaml
|
||||
- $formkit: select
|
||||
name: post
|
||||
label: Post
|
||||
clearable: true
|
||||
action: /apis/api.console.halo.run/v1alpha1/posts
|
||||
requestOption:
|
||||
method: GET
|
||||
labelField: post.spec.title
|
||||
valueField: post.metadata.name
|
||||
iconField: post.spec.cover
|
||||
descriptionField: post.status.excerpt
|
||||
```
|
||||
|
||||
Key props: `options`, `action`, `requestOption`, `multiple`, `searchable`, `clearable`, `sortable`, `maxCount`.
|
||||
|
||||
Halo 2.25+ supports `icon` and `description` in static options, plus
|
||||
`requestOption.iconField` and `requestOption.descriptionField` for remote
|
||||
options.
|
||||
|
||||
#### `switch` — Toggle Switch
|
||||
|
||||
```yaml
|
||||
- $formkit: switch
|
||||
name: enabled
|
||||
label: Enable
|
||||
value: false
|
||||
onValue: "active"
|
||||
offValue: "inactive"
|
||||
```
|
||||
|
||||
#### `attachment` / `attachmentInput` — Attachment Picker
|
||||
|
||||
`attachment` (Halo 2.22+): supports preview, direct upload, and library selection.
|
||||
|
||||
```yaml
|
||||
- $formkit: attachment
|
||||
name: logo
|
||||
label: Logo
|
||||
accepts:
|
||||
- "image/png"
|
||||
- "image/jpeg"
|
||||
width: "200px"
|
||||
aspectRatio: "1/1"
|
||||
```
|
||||
|
||||
`attachmentInput`: simpler input that opens the attachment library modal.
|
||||
|
||||
```yaml
|
||||
- $formkit: attachmentInput
|
||||
name: cover
|
||||
label: Cover
|
||||
accepts: ["image/*"]
|
||||
min: 1
|
||||
max: 1
|
||||
```
|
||||
|
||||
#### `code` — Code Editor
|
||||
|
||||
Integrated with CodeMirror. Supports `yaml`, `html`, `javascript`, `css`, `json`.
|
||||
|
||||
```yaml
|
||||
- $formkit: code
|
||||
name: custom_css
|
||||
label: Custom CSS
|
||||
language: css
|
||||
height: "300px"
|
||||
```
|
||||
|
||||
#### `iconify` — Icon Selector
|
||||
|
||||
Based on [Iconify](https://iconify.design/).
|
||||
|
||||
```yaml
|
||||
- $formkit: iconify
|
||||
name: social_icon
|
||||
label: Social Icon
|
||||
format: svg # svg | dataurl | url | name
|
||||
```
|
||||
|
||||
With sizing options:
|
||||
|
||||
```yaml
|
||||
- $formkit: iconify
|
||||
name: icon
|
||||
label: Icon
|
||||
format: svg
|
||||
sizing:
|
||||
enabled: true
|
||||
default: "24"
|
||||
presets: ["16", "24", "32", "48"]
|
||||
```
|
||||
|
||||
#### `toggle` — Visual Toggle
|
||||
|
||||
For image, color, or text option toggling.
|
||||
|
||||
```yaml
|
||||
- $formkit: toggle
|
||||
name: theme
|
||||
label: Theme
|
||||
render-type: color
|
||||
options:
|
||||
- label: Dark
|
||||
value: dark
|
||||
render: "#1a1a1a"
|
||||
- label: Light
|
||||
value: light
|
||||
render: "#ffffff"
|
||||
```
|
||||
|
||||
#### `array` — Object Array (Recommended over `repeater`)
|
||||
|
||||
For defining arrays of objects with add/remove/reorder.
|
||||
|
||||
```yaml
|
||||
- $formkit: array
|
||||
name: socials
|
||||
label: Social Accounts
|
||||
value: []
|
||||
max: 5
|
||||
min: 1
|
||||
itemLabels:
|
||||
- type: image
|
||||
label: $value.logo
|
||||
- type: text
|
||||
label: $value.name
|
||||
children:
|
||||
- $formkit: attachment
|
||||
name: logo
|
||||
label: Icon
|
||||
- $formkit: text
|
||||
name: name
|
||||
label: Name
|
||||
validation: required
|
||||
- $formkit: text
|
||||
name: url
|
||||
label: URL
|
||||
validation: required|url
|
||||
```
|
||||
|
||||
> Use `itemLabels` to show preview content on collapsed array items. `$value` refers to the current item.
|
||||
|
||||
#### `list` — Primitive Array
|
||||
|
||||
For arrays of primitives (strings, numbers, booleans).
|
||||
|
||||
```yaml
|
||||
- $formkit: list
|
||||
name: tags
|
||||
label: Tags
|
||||
itemType: string
|
||||
min: 1
|
||||
max: 10
|
||||
addLabel: Add Tag
|
||||
children:
|
||||
- $formkit: text
|
||||
index: "$index"
|
||||
validation: required
|
||||
```
|
||||
|
||||
#### `verificationForm` — Remote Validation
|
||||
|
||||
Wraps a group of fields and validates them against a remote endpoint.
|
||||
|
||||
```yaml
|
||||
- $formkit: verificationForm
|
||||
action: /apis/console.api.halo.run/v1alpha1/verify/verify-password
|
||||
label: Verify Account
|
||||
children:
|
||||
- $formkit: text
|
||||
name: username
|
||||
label: Username
|
||||
validation: required
|
||||
- $formkit: password
|
||||
name: password
|
||||
label: Password
|
||||
validation: required
|
||||
```
|
||||
|
||||
> Unlike other inputs, `verificationForm` does NOT wrap values in its own key. The saved values stay flat: `{ "username": "...", "password": "..." }`.
|
||||
|
||||
#### CMS Entity Selectors
|
||||
|
||||
Halo provides dedicated selectors for core CMS entities. All return the resource's `metadata.name`.
|
||||
|
||||
| Input | Description | Multi-select |
|
||||
| ------------------------ | -------------------------- | ------------ |
|
||||
| `menuSelect` | Navigation menu selector | Yes |
|
||||
| `menuCheckbox` | Menu checkbox group | Yes (array) |
|
||||
| `menuRadio` | Menu radio selection | No |
|
||||
| `postSelect` | Post selector | No |
|
||||
| `singlePageSelect` | Single page selector | No |
|
||||
| `categorySelect` | Category selector | No |
|
||||
| `categoryCheckbox` | Category checkbox | Yes (array) |
|
||||
| `tagSelect` | Tag selector | No |
|
||||
| `tagCheckbox` | Tag checkbox | Yes (array) |
|
||||
| `userSelect` | User selector | Yes |
|
||||
| `roleSelect` | Role selector | Yes |
|
||||
| `attachmentGroupSelect` | Attachment group selector | Yes |
|
||||
| `attachmentPolicySelect` | Attachment policy selector | Yes |
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
- $formkit: postSelect
|
||||
name: featuredPost
|
||||
label: Featured Post
|
||||
value: ""
|
||||
|
||||
- $formkit: categoryCheckbox
|
||||
name: categories
|
||||
label: Categories
|
||||
value: []
|
||||
```
|
||||
|
||||
#### `secret` — Secret Resource Selector
|
||||
|
||||
For selecting a Halo Secret resource (stores sensitive data like API keys).
|
||||
|
||||
```yaml
|
||||
- $formkit: secret
|
||||
name: apiSecret
|
||||
label: API Secret
|
||||
descriptionPreset: "Token for My Plugin"
|
||||
requiredKeys:
|
||||
- key: apiKey
|
||||
help: API Key
|
||||
- key: secretKey
|
||||
help: Secret Key
|
||||
```
|
||||
|
||||
#### `color` — Color Picker
|
||||
|
||||
```yaml
|
||||
- $formkit: color
|
||||
name: themeColor
|
||||
label: Theme Color
|
||||
value: "#1890ff"
|
||||
```
|
||||
|
||||
## Custom Inputs from Plugin UI
|
||||
|
||||
Halo 2.25+ lets a plugin register custom FormKit inputs from `ui/src/index.ts`
|
||||
through `definePlugin({ formkit: { inputs } })`. See [ui-entry.md](ui-entry.md#custom-formkit-inputs).
|
||||
|
||||
Use these only when built-in FormKit and Halo inputs cannot express the
|
||||
interaction. Prefix names with the plugin identifier to avoid collisions, for
|
||||
example `myPluginTokenPicker`.
|
||||
|
||||
## Version Notes
|
||||
|
||||
- Halo 2.25+ supports `select` option `icon` / `description`, remote
|
||||
`iconField` / `descriptionField`, and plugin-registered custom FormKit inputs.
|
||||
- When using 2.25-only FormKit features, raise `spec.requires` in `plugin.yaml`
|
||||
and keep related UI packages on a compatible version.
|
||||
|
||||
## Programmatic Form Submission
|
||||
|
||||
In Vue components, trigger form submission programmatically:
|
||||
|
||||
```vue
|
||||
<VButton type="primary" @click="$formkit.submit('my-form-id')">
|
||||
Submit
|
||||
</VButton>
|
||||
```
|
||||
|
||||
Or using `@formkit/core`:
|
||||
|
||||
```ts
|
||||
import { submitForm } from "@formkit/core";
|
||||
|
||||
submitForm("my-form-id");
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
FormKit supports built-in validation rules. Use them in Schema or Vue components:
|
||||
|
||||
```yaml
|
||||
- $formkit: text
|
||||
name: email
|
||||
label: Email
|
||||
validation: required|email
|
||||
```
|
||||
|
||||
```vue
|
||||
<FormKit
|
||||
type="text"
|
||||
name="slug"
|
||||
label="Slug"
|
||||
:validation="[['required'], ['matches', /^[a-z0-9-]+$/]]"
|
||||
/>
|
||||
```
|
||||
|
||||
Common rules: `required`, `email`, `url`, `number`, `min`, `max`, `matches`, `confirm`.
|
||||
|
||||
## Conditional Rendering
|
||||
|
||||
Use `if` in Schema to conditionally show fields:
|
||||
|
||||
```yaml
|
||||
- $formkit: select
|
||||
name: type
|
||||
label: Type
|
||||
options:
|
||||
- label: Internal
|
||||
value: internal
|
||||
- label: External
|
||||
value: external
|
||||
|
||||
- $formkit: text
|
||||
name: url
|
||||
label: URL
|
||||
if: "$value.type === 'external'"
|
||||
validation: required|url
|
||||
```
|
||||
|
||||
> In `if` expressions, `$value` refers to the current form values object.
|
||||
|
||||
## Schema vs Vue Component: When to Use Which
|
||||
|
||||
| Scenario | Approach |
|
||||
| ------------------------------------ | ---------------------------------------------- |
|
||||
| Plugin settings (config page) | `Setting` resource with Schema |
|
||||
| Custom admin page with dynamic logic | Vue `<FormKit>` components |
|
||||
| Simple CRUD form in a modal | Vue `<FormKit>` components |
|
||||
| Reusable form across plugins | Vue `<FormKit>` components in a shared package |
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Do NOT install FormKit in your plugin** — it's already globally registered. Installing it again can cause conflicts.
|
||||
- **Do NOT use FormKit Pro inputs** — they are not included in Halo.
|
||||
- Schema is JSON format natively, but Halo uses YAML for `Setting` resources. Write Schema in YAML syntax.
|
||||
- When using `array` or `list`, always provide `value: []` as default to avoid undefined issues.
|
||||
- For `attachment` with `multiple: true`, the value is a `string[]` of attachment URLs/names.
|
||||
@@ -0,0 +1,103 @@
|
||||
# @halo-dev/ui-shared Utilities
|
||||
|
||||
Available from Halo 2.22. Requires plugin `spec.requires: ">=2.22.0"`.
|
||||
|
||||
## Stores (Pinia)
|
||||
|
||||
Install Pinia: `pnpm install pinia`
|
||||
|
||||
### currentUser
|
||||
|
||||
```ts
|
||||
import { stores } from "@halo-dev/ui-shared";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const userStore = stores.currentUser();
|
||||
await userStore.fetchCurrentUser();
|
||||
|
||||
console.log(userStore.currentUser?.user.metadata.name);
|
||||
console.log(userStore.isAnonymous);
|
||||
|
||||
// Reactive refs
|
||||
const { currentUser, isAnonymous } = storeToRefs(stores.currentUser());
|
||||
```
|
||||
|
||||
| Property | Type | Description |
|
||||
| ------------- | --------------------------- | ---------------------------- |
|
||||
| `currentUser` | `DetailedUser \| undefined` | Current user info |
|
||||
| `isAnonymous` | `boolean` | Whether visitor is anonymous |
|
||||
|
||||
### globalInfo
|
||||
|
||||
```ts
|
||||
const globalInfoStore = stores.globalInfo();
|
||||
await globalInfoStore.fetchGlobalInfo();
|
||||
|
||||
console.log(globalInfoStore.globalInfo?.externalUrl);
|
||||
console.log(globalInfoStore.globalInfo?.siteTitle);
|
||||
```
|
||||
|
||||
| Property | Type | Description |
|
||||
| ------------ | ------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `globalInfo` | `GlobalInfo \| undefined` | Site config: externalUrl, siteTitle, timeZone, locale, allowComments, allowRegistration, favicon, etc. |
|
||||
|
||||
## Utils
|
||||
|
||||
### date (dayjs-based)
|
||||
|
||||
Dayjs is already bundled in `@halo-dev/ui-shared` and exposed via `utils.date`. **Always use this for any date handling in plugin UI code — do NOT install `dayjs`, `date-fns`, or any other date library, and do NOT write your own date formatting functions.**
|
||||
|
||||
```ts
|
||||
import { utils } from "@halo-dev/ui-shared";
|
||||
|
||||
utils.date.format(new Date()); // "2025-11-05 14:30"
|
||||
utils.date.format("2025-10-22", "YYYY/MM/DD"); // "2025/10/22"
|
||||
utils.date.toISOString(new Date()); // ISO string
|
||||
utils.date.toDatetimeLocal(new Date()); // "2025-10-22T14:30"
|
||||
utils.date.timeAgo("2025-10-23"); // "1 天后"
|
||||
utils.date.dayjs(); // raw dayjs instance for advanced usage
|
||||
```
|
||||
|
||||
### permission
|
||||
|
||||
```ts
|
||||
utils.permission.has(["core:posts:manage"]); // any match
|
||||
utils.permission.has(["core:posts:manage", "core:posts:delete"], false); // all match
|
||||
utils.permission.getUserPermissions(); // string[]
|
||||
```
|
||||
|
||||
### attachment
|
||||
|
||||
```ts
|
||||
// Generate thumbnail URL
|
||||
utils.attachment.getThumbnailUrl("/uploads/image.jpg", "M"); // "?width=800"
|
||||
// Sizes: "XL" (1600), "L" (1200), "M" (800), "S" (400)
|
||||
|
||||
// Extract URL from various attachment formats
|
||||
utils.attachment.getUrl(attachmentObject);
|
||||
|
||||
// Convert to simplified format
|
||||
utils.attachment.convertToSimple(attachmentObject);
|
||||
// -> { url: "...", alt?: "...", mediaType?: "..." }
|
||||
```
|
||||
|
||||
### id
|
||||
|
||||
```ts
|
||||
utils.id.uuid(); // UUID v7 (time-sortable)
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
```ts
|
||||
import { events } from "@halo-dev/ui-shared";
|
||||
|
||||
// Listen for plugin config updates
|
||||
events.on("core:plugin:configMap:updated", (data) => {
|
||||
console.log(`Plugin ${data.pluginName} config updated, group: ${data.group}`);
|
||||
});
|
||||
```
|
||||
|
||||
| Event | Payload | Description |
|
||||
| ------------------------------- | --------------------------------------- | ---------------------------- |
|
||||
| `core:plugin:configMap:updated` | `{ pluginName: string, group: string }` | Plugin configuration changed |
|
||||
@@ -0,0 +1,168 @@
|
||||
# UI Tooling
|
||||
|
||||
Recommended third-party tools for plugin frontend development that work well alongside Halo's UI framework.
|
||||
|
||||
---
|
||||
|
||||
## Icons: unplugin-icons + Iconify
|
||||
|
||||
[unplugin-icons](https://github.com/unplugin/unplugin-icons) provides on-demand icon imports from [Iconify](https://iconify.design/) with full IDE support (auto-completion, hover preview).
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
pnpm add -D unplugin-icons @iconify/json
|
||||
```
|
||||
|
||||
> For smaller bundles, install only the icon sets you need: `pnpm add -D @iconify-json/ri @iconify-json/mdi`
|
||||
|
||||
### Vite Configuration
|
||||
|
||||
```ts
|
||||
// ui/vite.config.ts
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import Icons from "unplugin-icons/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
Icons({
|
||||
compiler: "vue3",
|
||||
autoInstall: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Rsbuild Configuration
|
||||
|
||||
```ts
|
||||
// ui/rsbuild.config.mjs
|
||||
import { defineConfig } from "@rsbuild/core";
|
||||
import { pluginVue } from "@rsbuild/plugin-vue";
|
||||
import Icons from "unplugin-icons/rspack";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [pluginVue()],
|
||||
tools: {
|
||||
rspack: {
|
||||
plugins: [Icons({ compiler: "vue3" })],
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
> Always set `compiler: 'vue3'` so icons are rendered as Vue components.
|
||||
|
||||
### Usage in Vue
|
||||
|
||||
Import icons directly from the `~icons/` virtual module:
|
||||
|
||||
```ts
|
||||
// ui/src/index.ts
|
||||
import RiImage2Line from '~icons/ri/image-2-line';
|
||||
import MdiHome from '~icons/mdi/home';
|
||||
|
||||
// Use as a regular Vue component
|
||||
// Common pattern: pass to markRaw() for menu icons
|
||||
{
|
||||
menu: {
|
||||
icon: markRaw(RiImage2Line),
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import RiImage2Line from "~icons/ri/image-2-line";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RiImage2Line class="w-4 h-4" />
|
||||
</template>
|
||||
```
|
||||
|
||||
> Browse icons at [icones.js.org](https://icones.js.org/) or [iconify.design](https://icon-sets.iconify.design/).
|
||||
|
||||
---
|
||||
|
||||
## Atomic CSS: UnoCSS
|
||||
|
||||
[UnoCSS](https://unocss.dev/) is an instant atomic CSS engine that provides Tailwind-compatible utilities with zero runtime overhead.
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
pnpm add -D unocss @unocss/webpack
|
||||
```
|
||||
|
||||
### Vite Configuration
|
||||
|
||||
```ts
|
||||
// ui/vite.config.ts
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import UnoCSS from "unocss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), UnoCSS()],
|
||||
});
|
||||
```
|
||||
|
||||
Add the CSS import in your entry file:
|
||||
|
||||
```ts
|
||||
// ui/src/index.ts
|
||||
import "uno.css";
|
||||
```
|
||||
|
||||
### Rsbuild Configuration
|
||||
|
||||
```ts
|
||||
// ui/rsbuild.config.mjs
|
||||
import { defineConfig } from "@rsbuild/core";
|
||||
import { pluginVue } from "@rsbuild/plugin-vue";
|
||||
import { UnoCSSRspackPlugin } from "@unocss/webpack/rspack";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [pluginVue()],
|
||||
tools: {
|
||||
rspack: {
|
||||
plugins: [UnoCSSRspackPlugin()],
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Add the CSS import:
|
||||
|
||||
```ts
|
||||
// ui/src/index.ts
|
||||
import "uno.css";
|
||||
```
|
||||
|
||||
### Configuration File
|
||||
|
||||
Create `ui/uno.config.ts`:
|
||||
|
||||
```ts
|
||||
import { defineConfig, presetWind3, transformerCompileClass } from "unocss";
|
||||
|
||||
export default defineConfig({
|
||||
presets: [presetWind3()],
|
||||
transformers: [transformerCompileClass()],
|
||||
});
|
||||
```
|
||||
|
||||
### Usage in Vue
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded">
|
||||
<span class="text-sm font-medium text-gray-700">Hello</span>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
> Use UnoCSS for layout, spacing, and any custom component styling when `@halo-dev/components` does not provide the needed primitive; use `@halo-dev/components` for standard business UI.
|
||||
Reference in New Issue
Block a user