feat: vibe-registry M1 — 万物皆注册框架骨架

- core: registerLayout/Widget/Action(泛型)/Schema + 校验闸门
- definePage: 页面只管业务,结构来自注册表
- presets: admin / app-shell 预设布局(vr add 物化)
- cli: vr list/add/update 防覆盖
- examples/qa-orchestrator: 实战编排验证(workspace:* 依赖)
This commit is contained in:
hellow-da-sb
2026-09-03 00:48:10 +08:00
commit 8c93bde5f6
30 changed files with 1289 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
export { createRegistry, registerLayout, registerWidget, registerAction, registerSchema, registry } from './registry.ts'
export type {
RegistryApi,
RegistryEntry,
RegistryKind,
LayoutRegistryEntry,
WidgetRegistryEntry,
ActionRegistryEntry,
SchemaRegistryEntry,
LayoutPreview,
} from './registry.js'
export { definePage } from './page.ts'
export type { PageDeclaration, PageBlockDecl, PageContext, SetupData } from './page.js'
+56
View File
@@ -0,0 +1,56 @@
/**
* definePage —— 页面声明层
*
* 理念(设计文档 §3.1):
* - 页面只管业务:setup 只做数据/状态,结构全部来自注册表
* - 布局/组件用 id 引用,不写任何布局 JSX
* - 生成产物受控:引用的 id 必须是已注册项(validate 把关)
*/
import { type RegistryApi, registry } from './registry.ts'
/** 页面中一个块的声明:引用已注册 widget,并给 props/绑定 */
export interface PageBlockDecl {
/** widget 注册 id,如 'data-table'、'page-header' */
readonly widget: string
/** 静态 props(可选) */
readonly props?: Record<string, unknown>
/** 业务绑定:setup 返回的数据里取哪些字段喂给 widget(键:widget prop 名,值:setup 结果字段) */
readonly bind?: Record<string, string>
}
/** 页面声明:布局 id + 块列表 + 业务 */
export interface PageDeclaration {
/** 布局注册 id,如 'admin' —— 用户只需说"用 admin 布局" */
readonly layout: string
readonly blocks: readonly PageBlockDecl[]
/** 业务层:返回的数据可供 blocks.bind 使用;只做业务,不碰 UI 结构 */
readonly setup?: (ctx: PageContext) => Promise<Record<string, unknown>> | Record<string, unknown>
/** 行为回调:业务动作(bivariant,允许实现方标注收窄的 payload 类型) */
readonly actions?: Record<string, { bivarianceHack(payload: never, ctx: PageContext): unknown }['bivarianceHack']>
/** 页标题等元信息(可选) */
readonly title?: string
}
export interface PageContext {
readonly query: Record<string, string | undefined>
readonly params: Record<string, string | undefined>
}
/** 把声明物化为"已校验的页面对象";未知引用直接抛错(校验闸门) */
export function definePage(decl: PageDeclaration, reg: RegistryApi = registry): PageDeclaration {
const refs = [
{ kind: 'layout' as const, id: decl.layout },
...decl.blocks.map((b) => ({ kind: 'widget' as const, id: b.widget })),
]
const missing = reg.validateRefs(refs)
if (missing.length > 0) {
throw new Error(
`[vibe-registry] definePage 引用了未注册项:${missing.join(', ')} ` +
`(万物皆注册:请先注册,或用已有注册项)`,
)
}
return decl
}
/** 业务 setup 辅助类型:让页面文件只声明"取哪些数据" */
export type SetupData<P extends PageDeclaration> = Awaited<ReturnType<NonNullable<P['setup']>>>
+146
View File
@@ -0,0 +1,146 @@
/**
* vibe-registry core —— 万物皆注册的类型基石
*
* 设计要点(v0.5):
* - 注册项类型封闭 + id 即引用键
* - 所有注册项可带 demo 预览数据(预览即真实)
* - 纯 TS、零依赖、不接 UI(渲染内核后续接)
*/
export type RegistryKind = 'layout' | 'widget' | 'action' | 'schema' | 'theme'
/** 布局骨架:页面级结构。渲染内核据 slots 填充 blocks。 */
export interface LayoutRegistryEntry {
readonly kind: 'layout'
/** 简易 id,如 'admin' —— 用户/agent 只需说这个 id */
readonly id: string
/** 布局所含槽位顺序,渲染时 blocks 依 slot 填入 */
readonly slots: readonly string[]
/** 描述,供画廊/agent 展示 */
readonly description: string
/** demo 数据(预览即真实,与实装共用渲染内核) */
readonly preview?: LayoutPreview
}
export interface LayoutPreview {
/** 标题文本,画廊首页展示 */
readonly title: string
/** 缩略示意:每个 slot 放什么 demo block(id 或描述) */
readonly demoBlocks?: ReadonlyArray<{
readonly slot: string
readonly widget: string
readonly props?: Record<string, unknown>
}>
}
/** 业务组件注册项(映射到已有 UI 库,不重造原子组件) */
export interface WidgetRegistryEntry {
readonly kind: 'widget'
readonly id: string
/** 渲染组件。内核薄,仅做映射层 */
readonly component?: unknown
/** 关联字段/列 schema id(可选) */
readonly schemaId?: string
readonly description?: string
}
/** 行为注册项:可复用的事件语义(泛型:payload/结果类型随注册保留,不丢业务语义) */
export interface ActionRegistryEntry<P = unknown, R = unknown> {
readonly kind: 'action'
readonly id: string
readonly handler: (payload: P, ctx: unknown) => R | Promise<R>
readonly description?: string
}
/** 数据/字段注册项:表单字段、表格列 */
export interface SchemaRegistryEntry {
readonly kind: 'schema'
readonly id: string
readonly fields?: readonly unknown[]
readonly description?: string
}
export type RegistryEntry =
| LayoutRegistryEntry
| WidgetRegistryEntry
| ActionRegistryEntry
| SchemaRegistryEntry
/** 注册表集合:kind → id → entry(单一事实来源) */
export type RegistryStore = {
readonly layouts: ReadonlyMap<string, LayoutRegistryEntry>
readonly widgets: ReadonlyMap<string, WidgetRegistryEntry>
readonly actions: ReadonlyMap<string, ActionRegistryEntry>
readonly schemas: ReadonlyMap<string, SchemaRegistryEntry>
}
/* ── 注册与查询 ─────────────────────────────────────────────── */
export type RegisterFn<K extends RegistryKind, E extends { readonly kind: K; readonly id: string }> =
(entry: E) => E
export interface RegistryApi {
readonly store: RegistryStore
registerLayout: RegisterFn<'layout', LayoutRegistryEntry>
registerWidget: RegisterFn<'widget', WidgetRegistryEntry>
registerAction: <P, R>(entry: ActionRegistryEntry<P, R>) => ActionRegistryEntry<P, R>
registerSchema: RegisterFn<'schema', SchemaRegistryEntry>
/** 校验:给定引用集合,返回未注册的 id 清单(空 = 通过) */
validateRefs: (refs: ReadonlyArray<{ readonly kind: RegistryKind; readonly id: string }>) => string[]
/** 画廊用的清单:全部已注册项(dev 用) */
listAll: () => RegistryEntry[]
}
/** 创建注册表(单例由 createRegistry 返回) */
export function createRegistry(): RegistryApi {
const layouts = new Map<string, LayoutRegistryEntry>()
const widgets = new Map<string, WidgetRegistryEntry>()
const actions = new Map<string, ActionRegistryEntry>()
const schemas = new Map<string, SchemaRegistryEntry>()
function register<K extends RegistryKind, E extends { readonly kind: K; readonly id: string }>(
map: Map<string, RegistryEntry>,
entry: E,
): E {
const existed = map.get(entry.id)
if (existed) {
throw new Error(`[vibe-registry] 注册冲突:${entry.kind} '${entry.id}' 已存在(万物皆注册,id 唯一)`)
}
map.set(entry.id, entry as RegistryEntry)
return entry
}
return {
store: { layouts, widgets, actions, schemas },
registerLayout: (e) => register(layouts, e),
registerWidget: (e) => register(widgets, e),
registerAction: <P, R>(entry: ActionRegistryEntry<P, R>) => {
if (actions.has(entry.id)) {
throw new Error(`[vibe-registry] 注册冲突:action '${entry.id}' 已存在(万物皆注册,id 唯一)`)
}
actions.set(entry.id, entry as ActionRegistryEntry)
return entry
},
registerSchema: (e) => register(schemas, e),
validateRefs(refs) {
const missing: string[] = []
for (const r of refs) {
const map = r.kind === 'layout' ? layouts : r.kind === 'widget' ? widgets : r.kind === 'action' ? actions : schemas
if (!map.has(r.id)) missing.push(`${r.kind}:${r.id}`)
}
return missing
},
listAll() {
return [...layouts.values(), ...widgets.values(), ...actions.values(), ...schemas.values()]
},
}
}
/* ── 全局注册表(单例,项目内 import 即用) ────────────────────── */
const globalRegistry = createRegistry()
export const registerLayout: RegistryApi['registerLayout'] = (e) => globalRegistry.registerLayout(e)
export const registerWidget: RegistryApi['registerWidget'] = (e) => globalRegistry.registerWidget(e)
export const registerAction: RegistryApi['registerAction'] = (e) => globalRegistry.registerAction(e)
export const registerSchema: RegistryApi['registerSchema'] = (e) => globalRegistry.registerSchema(e)
export const registry: RegistryApi = globalRegistry