/** * vibe-registry render —— 极薄 React 渲染内核(可选子路径:vibe-registry/render) * * 设计(v0.5): * - core(registry/page)零依赖;渲染内核单独子路径,谁用谁引 React * - 不做运行时编辑器,不重造组件:widget.component 映射已有组件 * - 预览即真实:画廊与实装共用本内核 */ import React from 'react' import type { RegistryApi, LayoutRegistryEntry, LayoutRenderProps } from './registry.ts' import type { PageDeclaration, PageBlockDecl } from './page.ts' /** widget 的 React 组件形态(宽松:props 由声明 props/bind 决定) */ export type WidgetComponent = React.ComponentType> | React.ElementType /* ── 单 widget 渲染 ─────────────────────────────────────────── */ export interface WidgetViewProps { readonly widget: string readonly registry: RegistryApi /** 静态 props */ readonly props?: Record /** setup 数据(供 bind 取字段) */ readonly data?: Record readonly bind?: Record } export function WidgetView({ widget, registry, props = {}, data = {}, bind }: WidgetViewProps): React.ReactElement { const entry = registry.store.widgets.get(widget) if (!entry) { throw new Error(`[vibe-registry/render] 未知 widget:'${widget}' (未注册)`) } const Comp = entry.component as WidgetComponent | undefined if (!Comp) { throw new Error(`[vibe-registry/render] widget '${widget}' 未挂组件(component 缺失)`) } // bind:props 名 ← setup 数据字段 const bound: Record = {} if (bind) { for (const [prop, key] of Object.entries(bind)) bound[prop] = data[key] } return React.createElement(Comp, { ...props, ...bound }) } /* ── 整页渲染:声明 + 注册表 → 组件树 ────────────────────────── */ export interface PageViewProps { readonly page: PageDeclaration readonly registry: RegistryApi /** setup 已执行完的数据 */ readonly data?: Record } export function PageView({ page, registry, data = {} }: PageViewProps): React.ReactElement { const layout = registry.store.layouts.get(page.layout) if (!layout) { throw new Error(`[vibe-registry/render] 未知布局:'${page.layout}'`) } const renderLayout = (layout.render as React.ComponentType | undefined) ?? DefaultLayout // 按 slot 分组填充 blocks const filled: Record = {} for (const b of page.blocks) { const slot = b.slot ?? 'content' ;(filled[slot] ??= []).push( React.createElement(WidgetView, { key: b.widget + JSON.stringify(b.props ?? {}), widget: b.widget, registry, props: b.props, bind: b.bind, data, }), ) } return React.createElement(renderLayout, { slots: layout.slots, blocks: filled, page }) } /** 缺省布局骨架:无 render 的布局按 slots 顺序纵向排(演示/兜底用) */ export function DefaultLayout({ slots, blocks }: LayoutRenderProps): React.ReactElement { return React.createElement( 'div', { style: { border: '1px dashed #d1d5db', borderRadius: 8, padding: 12 } }, ...slots.flatMap((slot) => { const children = blocks[slot] as React.ReactNode[] | undefined if (!children || children.length === 0) return [] return [ React.createElement( 'div', { key: slot, style: { marginBottom: 12 } }, React.createElement( 'div', { style: { fontSize: 11, color: '#9ca3af', marginBottom: 4, textTransform: 'uppercase' } }, `slot: ${slot}`, ), ...children, ), ] }), ) } /* ── 画廊:布局模板 live 预览(类 Vite 首页,点选查看) ─────────── */ export interface GalleryProps { readonly registry: RegistryApi readonly pages?: readonly PageDeclaration[] } /** 由布局的 preview.demoBlocks 构造一个可渲染的 demo 页声明 */ function demoPageFrom(layout: LayoutRegistryEntry): PageDeclaration | null { const demo = layout.preview?.demoBlocks ?? [] if (demo.length === 0) return null return { layout: layout.id, title: layout.preview?.title ?? layout.id, blocks: demo.map((d) => ({ widget: d.widget, slot: d.slot, props: d.props })), } } /** 缩略预览:把真实渲染缩放进卡片(预览即真实) */ function Thumb({ children, onClick }: { children?: React.ReactNode; onClick?: () => void }): React.ReactElement { return React.createElement( 'div', { onClick, style: { width: 420, height: 260, overflow: 'hidden', border: '1px solid #e5e7eb', borderRadius: 8, cursor: onClick ? 'pointer' : 'default', position: 'relative', background: '#fff', }, }, React.createElement( 'div', { style: { width: 1050, transform: 'scale(0.4)', transformOrigin: 'top left' } }, children, ), ) } export function LayoutGallery({ registry }: GalleryProps): React.ReactElement { const layouts = registry.listAll().filter((e): e is LayoutRegistryEntry => e.kind === 'layout') const widgets = registry.listAll().filter((e) => e.kind === 'widget') const [selected, setSelected] = React.useState(null) const cards = layouts.map((layout) => { const page = demoPageFrom(layout) return React.createElement( 'div', { key: layout.id, style: { display: 'flex', flexDirection: 'column', gap: 6 } }, React.createElement( Thumb, { onClick: () => setSelected(layout) }, page ? React.createElement(PageView, { page, registry, data: {} }) : React.createElement('div', { style: { padding: 12, color: '#9ca3af' } }, '(无 demoBlocks)'), ), React.createElement( 'div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } }, React.createElement( 'div', {}, React.createElement('div', { style: { fontSize: 13, fontWeight: 600 } }, layout.preview?.title ?? layout.id), React.createElement('div', { style: { fontSize: 11, color: '#9ca3af' } }, `id: ${layout.id} · slots: ${layout.slots.join(' → ')}`), ), React.createElement( 'button', { onClick: () => setSelected(layout), style: { fontSize: 12, padding: '3px 10px', borderRadius: 6, border: '1px solid #d1d5db', background: '#fff', cursor: 'pointer' }, }, '预览', ), ), ) }) const detail = selected ? demoPageFrom(selected) : null const detailEl = selected ? React.createElement( 'div', { style: { marginTop: 20 } }, React.createElement('h3', {}, `${selected.preview?.title ?? selected.id} · 完整预览`), detail ? React.createElement( 'div', { style: { border: '1px solid #e5e7eb', borderRadius: 8, overflow: 'hidden' } }, React.createElement(PageView, { page: detail, registry, data: {} }), ) : React.createElement('div', {}, '(此布局无 demo)'), ) : null return React.createElement( 'div', { style: { fontFamily: 'system-ui, sans-serif', maxWidth: 960, margin: '0 auto', padding: 24 } }, React.createElement('h1', { style: { fontSize: 20 } }, 'vibe-registry · 布局模板'), React.createElement('p', { style: { color: '#6b7280' } }, `已注册 ${layouts.length} 个布局模板 · ${widgets.length} 个 widget · 点击卡片预览(预览即真实)`), React.createElement('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(430px, 1fr))', gap: 16 } }, ...cards), detailEl, ) }