feat: 初始化 Halo 暗色模式插件

- Halo Plugin 后端(Java/Gradle),含 DarkModePlugin 主类和测试
- Vue 3 + TypeScript 前端 UI,包含主题切换组件和设置页面
- 暗色模式 CSS 变量和覆盖样式(布局/编辑器/表单/滚动条等)
- 设计文档和调查文档
- Halo 插件/主题开发 Agent Skills

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-06 21:03:00 +08:00
commit edbf78f236
115 changed files with 14745 additions and 0 deletions
@@ -0,0 +1,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.