Halo 主题 UI 插件打包:ui-plugin-bundler-kit 的 provider 机制与 Vite/Rsbuild 主题构建配置实战
@halo-dev/ui-plugin-bundler-kit 是 Halo 官方提供的前端构建配置工具包,为插件(Plugin)与主题(Theme)编写的 Console / User Center UI 插件统一生成构建产物。本文围绕 Halo 为“主题内嵌 UI 插件”这一能力引入的 provider: "plugin" | "theme" 选择模型,讲解主题构建的资源契约、manifest 默认路径、模块名与资源公共路径推导规则,并给出基于 Vite 与 Rsbuild 的可直接落地的配置示例。读完本文,你将掌握如何把一个 ui-plugin 前端工程挂载到某个主题名下、构建出符合 Halo 主题运行时要求、并与插件构建完全同构的 UI 资源。
为什么需要 provider:主题同样要“提供” Console / UC 界面
Halo 主题不仅能决定前台页面外观,还能通过一套与插件 UI 相同的 PluginModule 前端模块契约,为 Console 与 User Center 提供“由主题携带”的 UI 插件资源。其运行时的资源存放与暴露模型与插件不同(见该能力的设计文档):
- 主题包内的产物存放在主题包资源树下:
{themeRoot}/{themeName}/ui-plugin/dist/**
- Halo 运行时通过如下 URL 暴露这些静态资源:
/themes/{themeName}/ui-plugin/assets/**
然而在早期版本中,@halo-dev/ui-plugin-bundler-kit 只面向插件工程:它默认读取插件 manifest、生成插件产物文件名并配置插件资源的 public path。主题作者要么手工改写 Vite/Rsbuild 配置,要么直接放弃。因此 Halo 在 ui-plugin-bundler-provider 这一能力中要求:在统一的现代配置 API 上同时支持插件与主题两类 provider,让主题也能用同一套打包助手直接产出符合主题运行时契约的产物。对应的能力规格当前收录在 openspec/specs/ui-plugin-bundler-provider/spec.md,而实际实现位于 ui/packages/ui-plugin-bundler-kit。
provider 模型:一个入口、两种提供方、默认保持插件行为
规格中最核心的取舍是:不新增 themeViteConfig / themeRsbuildConfig 之类的独立导出,而是在现代 Vite 与 Rsbuild 配置助手 viteConfig / rsbuildConfig 上增加一个可选的 provider 字段:
type Provider = "plugin" | "theme";
三条被明确约束的行为规则如下:
- 默认 provider 仍是
"plugin":调用方省略provider时,助手必须使用插件 provider 的默认值,保证存量工程零改动。 - 主题显式选用
provider: "theme"后,助手切换为主题默认值。 - 遗留 helper 保持不变:被废弃的
HaloUIPluginBundlerKit不提供任何主题行为。
这一语义在源码中可以直接印证。Vite 与 Rsbuild 助手都通过 getProvider() 读取配置并回退到插件:
// ui/packages/ui-plugin-bundler-kit/src/vite.ts
type Provider = "plugin" | "theme";
function getProvider(config?: ViteUserConfig): Provider {
return config?.provider || "plugin";
}
Rsbuild 端 rsbuild.ts 的实现与此完全对称,而 manifest 路径也随 provider 一起解析:
function getManifestPath(provider: Provider, config?: ViteUserConfig) {
if (config?.manifestPath) {
return config.manifestPath;
}
return provider === "theme"
? DEFAULT_THEME_MANIFEST_PATH // ../theme.yaml
: DEFAULT_PLUGIN_MANIFEST_PATH; // ../src/main/resources/plugin.yaml
}
为什么不做自动探测
设计文档明确放弃了“根据目录里存在 theme.yaml 还是 plugin.yaml 自动推断 provider”的方案:自动探测会让构建行为随目录布局漂移,并可能意外影响存量插件工程。显式传参保证行为可预期,这也是 provider 字段语义简单、便于测试的原因。
遗留 helper 为什么不支持主题
被废弃的 legacy.ts 中的 HaloUIPluginBundlerKit 仍承担插件 IIFE 产物在旧生产链路下的兼容职责。若为它新增主题分支,一方面会扩大兼容矩阵,另一方面也会诱导新主题工程去采用已废弃的 API,因此规格约定它“不支持主题 provider”。
plugin provider 兼容性:存量行为原样保留
为插件场景服务的行为必须逐项保持不变,这是 provider 改造的底线:
- 默认 manifest 路径:未传
manifestPath时读取../src/main/resources/plugin.yaml。该常量定义于 constants/halo-plugin.ts:
const DEFAULT_PLUGIN_MANIFEST_PATH = "../src/main/resources/plugin.yaml";
const DEFAULT_THEME_MANIFEST_PATH = "../theme.yaml";
- 构建位置兼容:插件产物输出目录与“UI bundle 位置”有关,Halo 2.25.0 起插件 UI 资源迁入
ui目录(此前为console)。工具函数 utils/halo-plugin.ts 中的getHaloPluginBundleLocation()会依据 manifestspec.requires可推导出的最低 Halo 版本决定落到ui还是console,从而决定开发期输出目录../build/resources/main/{ui|console}。 - 输出产物约定:插件 provider 保留既有的 IIFE 库输出、全局模块名、外部依赖(
EXTERNALS)与全局变量映射(GLOBALS),入口固定为main.js、样式为style.css。
theme provider 的 manifest:从 ../theme.yaml 推导模块名与版本要求
当 provider 为 theme 时,manifest 默认路径切换为 ../theme.yaml,且仍可通过顶层 manifestPath 覆盖以适配非标准布局。
为保证不引入对整套 API-Client 生成模型的依赖,打包工具在内部定义了一个极简的 HaloThemeManifest 类型,只取 bundler 所需的最小字段——元数据名称与可选版本要求:
// ui/packages/ui-plugin-bundler-kit/src/utils/halo-plugin.ts
const THEME_MODULE_NAME_PREFIX = "theme:";
export interface HaloThemeManifest {
metadata: { name: string };
spec?: { requires?: string };
}
export function getHaloThemeModuleName(manifest: HaloThemeManifest) {
return `${THEME_MODULE_NAME_PREFIX}${getManifestName(manifest)}`;
}
也就是说,当 theme.yaml 的 metadata.name 为 earth 时,打包器会把前端模块(全局/模块名)配置为 theme:earth,资源公共路径配置为 /themes/earth/ui-plugin/assets/:
export function getHaloThemeAssetPublicPath(manifest: HaloThemeManifest) {
return `/themes/${getManifestName(manifest)}/ui-plugin/assets/`;
}
完整校验仍由 Halo 运行时负责——打包工具只“尽力校验最少字段”,一旦缺字段,配置生成阶段即会失败。
theme provider 的构建输出默认值(Vite / Rsbuild)
主题 provider 的默认值(来源见 vite.ts 与 rsbuild.ts 中各自的 getThemeProviderDefaults()):
| 维度 | Vite | Rsbuild |
|---|---|---|
| 输出根目录 | dist(dev/prod 一致) |
dist(dev/prod 一致) |
| 模块/全局名 | theme:{metadata.name} |
theme:{metadata.name} |
| 资源公共路径(IIFE 场景) | base: /themes/{name}/ui-plugin/assets/ |
output.publicPath: /themes/{name}/ui-plugin/assets/ |
| 主产物文件名 | main.js + style.css |
main.js + style.css |
源码中主题默认输出目录与模块名:
function getThemeProviderDefaults(manifestPath: string) {
const manifest = getHaloThemeManifest(manifestPath);
return {
moduleName: getHaloThemeModuleName(manifest),
outDir: { prod: DEFAULT_THEME_OUT_DIR, dev: DEFAULT_THEME_OUT_DIR },
legacyBase: getHaloThemeAssetPublicPath(manifest),
requires: getManifestRequires(manifest),
};
}
Vite 端在 IIFE 格式下通过 lib.fileName: () => "main.js"、cssFileName: "style" 固定产物文件名(见 vite.ts),Rsbuild 端则通过 output.filename 在 main chunk 上分别产出 main.js 与 style.css。两者都内置唯一的 Vue 编译插件(@vitejs/plugin-vue / @rsbuild/plugin-vue),无需调用方再注册一份。
复用插件 UI 的共享外部依赖
主题 UI 与插件 UI 运行在相同的 Console/UC 环境、遵循同一个 PluginModule 契约,因此主题 provider 复用插件 UI 的 external 与 globals 默认映射,避免出现重复的 Vue/运行时拷贝,并使产物体积与插件 UI 保持一致。
用户配置覆盖默认值
调用方传入的原生 vite / rsbuild 配置始终在 provider 默认值之后按构建器原生合并语义合入(Vite 用 mergeConfig,Rsbuild 用 mergeRsbuildConfig),例如 vite.ts:
return defineConfig((env) => {
const presetsConfig = presetsConfigFn(env);
const userConfig =
typeof config?.vite === "function" ? config.vite(env) : config?.vite || {};
return mergeConfig(presetsConfig, userConfig);
});
规格同时强调:一旦用户覆盖改变了最终输出契约(入口、格式、public path、externals、文件名、资源或 manifest 一致性),产物正确性由调用方负责,工具包也不会声称该产物仍保有默认预设契约。
推荐的工程布局与最小化配置
主题 UI 插件工程应放在主题的 ui-plugin/ 目录下(布局说明见 包 README):
theme-root/
├── theme.yaml # metadata.name 将决定模块名与资源 URL
└── ui-plugin/
├── package.json
├── src/index.ts # 默认入口,default export PluginModule
└── vite.config.ts # 或 rsbuild.config.ts
安装依赖:
pnpm add @halo-dev/ui-plugin-bundler-kit
# Vite 用户
pnpm add vite @vitejs/plugin-vue
# Rsbuild 用户
pnpm add @rsbuild/core @rsbuild/plugin-vue
Vite 配置
// ui-plugin/vite.config.ts
import { viteConfig } from "@halo-dev/ui-plugin-bundler-kit/vite";
export default viteConfig({
provider: "theme",
vite: {
// 自定义 Vite 配置;Vue 插件已内置,不要再注册 @vitejs/plugin-vue
},
});
Rsbuild 配置
// ui-plugin/rsbuild.config.ts
import { rsbuildConfig } from "@halo-dev/ui-plugin-bundler-kit/rsbuild";
export default rsbuildConfig({
provider: "theme",
rsbuild: {
// 自定义 Rsbuild 配置;Vue 插件已内置,不要再注册 @rsbuild/plugin-vue
},
});
主题 provider 会读取上级的 ../theme.yaml,输出到 dist,以 theme:{metadata.name} 注册模块,并让静态资源命中 /themes/{metadata.name}/ui-plugin/assets/。Halo 只从主题包的 ui-plugin/dist/** 读取 UI 资源,因此成品主题中应保留 ui-plugin/dist 完整目录。
推荐脚本
{
"scripts": {
"dev": "vite dev --mode=development --watch",
"build": "vite build"
}
}
Rsbuild 场景对应 rsbuild dev --env-mode=development --watch 与 rsbuild build。
配置项速查
Vite 与 Rsbuild 助手接受同一组顶层配置(字段定义见 vite.ts 与 rsbuild.ts):
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
provider |
"plugin" | "theme" |
"plugin" |
UI 插件提供方类型 |
manifestPath |
string |
插件:../src/main/resources/plugin.yaml;主题:../theme.yaml |
manifest 路径,覆盖默认布局 |
format |
"auto" | "iife" | "esm" |
"auto" |
产物格式选择(见下节) |
targetHaloVersion |
string |
无 | 显式 ESM 且无法从 spec.requires 推导目标时必填 |
vue |
Vue 插件选项 | 无 | 透传给内置的 Vue 编译插件 |
vite / rsbuild |
原生构建配置或函数 | {} |
在 provider 默认值之后合并 |
从源码看格式选择:auto / iife / esm 与 2.26.0 阈值
本仓库当前版本的打包器在“Provider format selection”之上进一步演进出了 format 三态选择(当前规格见 openspec/specs/ui-plugin-bundler-provider/spec.md)。auto(默认)会结合 manifest 的 spec.requires 自动决策:
spec.requires是形如MAJOR.MINOR.PATCH或>=MAJOR.MINOR.PATCH的简单稳定版本,且最低版本不低于 Halo 2.26.0 时,输出 ESM;- 缺失、非法、通配或复合范围导致无法推导目标时,回退输出 IIFE 并打印解释性警告;
- 目标低于 2.26.0 时同样选择兼容的 IIFE。
阈值常量与解析逻辑位于 utils/halo-plugin.ts:
const UI_BUNDLE_MIN_HALO_VERSION = "2.25.0";
const ESM_PROVIDER_MIN_HALO_VERSION = "2.26.0";
需要刻意强推 ESM 而版本范围无法推导目标时,可显式给出目标版本:
export default viteConfig({
provider: "theme",
format: "esm",
targetHaloVersion: "2.26.0",
vite: {},
});
若显式选择 ESM 但 spec.requires 允许低于 2.26.0 的 Halo 安装该产物,打包器会输出强兼容性警告,但不会改写 manifest 或悄悄回退 IIFE。ESM 构建成功后会额外生成保留文件名 ui-plugin.json(记录 format、实际带内容哈希的入口与可选的启动样式表),该文件名被 bundler kit 保留,不应手工创建或复制。
覆盖默认 public path 的风险边界
设计文档特别提醒:主题资源公共路径默认值 /themes/{name}/ui-plugin/assets/ 是可被用户配置覆盖的,但错误地覆盖它会破坏动态 chunk 与已发射资源的相对寻址。如果你确实需要自定义(例如非标准目录布局),应同步修改主题侧的资源映射,并自行承担缓存与路由一致性责任。此外,工具内置的 Vue 编译插件只此一个:需要通过 vue 顶层字段下发编译器选项(如自定义元素 isCustomElement),若再往 plugins 数组塞一份 @vitejs/plugin-vue 或 @rsbuild/plugin-vue,SFC 转换会被执行两次。
测试与文档佐证
该能力的单元测试覆盖在 ui/packages/ui-plugin-bundler-kit/src/tests:
- provider.spec.ts:验证
plugin/theme两套 provider 生成的 Vite/Rsbuild 配置形状; - halo-plugin.spec.ts:覆盖 manifest 工具、bundle location 选择、自定义 manifest 路径、函数式用户配置合并等;
- legacy.spec.ts:守住遗留
HaloUIPluginBundlerKit的既有输出默认值。
结合仓库可进一步确认的调用链是:viteConfig(provider: "theme") → getManifestPath() 返回 ../theme.yaml → getThemeProviderDefaults() 解析 HaloThemeManifest → 推导出模块名 theme:{name}、输出 dist、base /themes/{name}/ui-plugin/assets/ → mergeConfig() 合入用户 vite 配置。整条链路在 ui/packages/ui-plugin-bundler-kit/src/vite.ts 与 rsbuild.ts 中均有可直接阅读的实现对应。
总而言之:给主题接入 UI 插件资源,只需让前端工程位于 theme-root/ui-plugin/、为 viteConfig/rsbuildConfig 显式声明 provider: "theme",再按约定保留 dist 产物目录——其余 manifest 解析、模块命名、资源公共路径与共享依赖外置都由 @halo-dev/ui-plugin-bundler-kit 一站完成。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00