feat(codegen): P 层闭环 —— 模板生成页面 + 画廊生成面板
- src/codegen.ts:PageDeclaration → definePage 源码(漂亮缩进,语法合法) - src/render.ts:导出 generatePageSource/starterPageFrom;画廊受控 selected/onSelect - playground generator.tsx:选布局→填标题→生成→复制/保存到 pages/ - vite 中间件 /__vr-save:浏览器 POST 落盘 pages/(HMR 生效) - 验证:codegen 产物通过工程 tsc;保存中间件落盘成功;画廊 UI headless 通过
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* codegen —— PageDeclaration → definePage 源码(纯函数,任何端可用)
|
||||
*
|
||||
* 这是 P 层闭环的核心:用户在画廊选布局、填业务,
|
||||
* 生成一份标准 definePage 页面源码 —— 页面只声明业务,不写布局。
|
||||
* 产物可被任何 AI / 编辑器继续编辑(AI 词汇表 = 注册表)。
|
||||
*/
|
||||
import type { PageDeclaration } from './page.ts'
|
||||
|
||||
/** 生成 definePage 源码;sampleData 为 setup 的起步样例(可选) */
|
||||
export function generatePageSource(page: PageDeclaration, sampleData: Record<string, unknown> = {}): string {
|
||||
const blockLines = page.blocks.map((b) => {
|
||||
const parts: string[] = [`widget: ${JSON.stringify(b.widget)}`]
|
||||
if (b.slot) parts.push(`slot: ${JSON.stringify(b.slot)}`)
|
||||
if (b.props && Object.keys(b.props).length > 0) parts.push(`props: ${jsonValue(b.props, 8)}`)
|
||||
if (b.bind && Object.keys(b.bind).length > 0) parts.push(`bind: ${jsonValue(b.bind, 8)}`)
|
||||
return ` { ${parts.join(', ')} }`
|
||||
})
|
||||
const blocksText = blockLines.length > 0 ? blockLines.join(',\n') : ' // TODO: 添加业务块(引用已注册 widget)'
|
||||
|
||||
const sections: string[] = [` layout: ${JSON.stringify(page.layout)}`]
|
||||
if (page.title) sections.push(` title: ${JSON.stringify(page.title)}`)
|
||||
sections.push(` blocks: [\n${blocksText},\n ]`)
|
||||
|
||||
const sampleKeys = Object.keys(sampleData)
|
||||
if (sampleKeys.length > 0) {
|
||||
// 箭头函数直接返回对象字面量必须包括号,否则被解析成块语句
|
||||
sections.push(` setup: async () => (${jsonValue(sampleData, 2).trimEnd()})`)
|
||||
}
|
||||
|
||||
const actionKeys = Object.keys(page.actions ?? {})
|
||||
if (actionKeys.length > 0) {
|
||||
const lines = actionKeys.map(
|
||||
(k) =>
|
||||
` ${JSON.stringify(k)}: async (payload) => {\n` +
|
||||
` // TODO: 业务逻辑,或调用注册的 action\n` +
|
||||
` console.log(${JSON.stringify(k)}, payload)\n` +
|
||||
` }`,
|
||||
)
|
||||
sections.push(` actions: {\n${lines.join(',\n')}\n }`)
|
||||
}
|
||||
|
||||
return `import { definePage } from 'vibe-registry'
|
||||
|
||||
// 由 vibe-registry 画廊生成 —— 页面只管业务,布局/组件来自注册表
|
||||
// 之后 AI / 编辑器可直接修改此文件;改动只落在业务与配置上
|
||||
export default definePage({
|
||||
${sections.join(',\n')},
|
||||
})
|
||||
`
|
||||
}
|
||||
|
||||
/** 漂亮的 JSON 值序列化:字符串带引号、对象/数组带缩进 */
|
||||
function jsonValue(v: unknown, indentLevel: number): string {
|
||||
if (v === null || typeof v !== 'object') return quotePrimitive(v)
|
||||
const pad = ' '.repeat(indentLevel)
|
||||
const innerPad = ' '.repeat(indentLevel + 1)
|
||||
if (Array.isArray(v)) {
|
||||
if (v.length === 0) return '[]'
|
||||
const items = v.map((item) => `${innerPad}${jsonValue(item, indentLevel + 1)}`).join(',\n')
|
||||
return `[\n${items},\n${pad}]`
|
||||
}
|
||||
const entries = Object.entries(v as Record<string, unknown>)
|
||||
if (entries.length === 0) return '{}'
|
||||
const lines = entries.map(([k, val]) => `${innerPad}${JSON.stringify(k)}: ${jsonValue(val, indentLevel + 1)}`).join(',\n')
|
||||
return `{\n${lines},\n${pad}}`
|
||||
}
|
||||
|
||||
function quotePrimitive(v: unknown): string {
|
||||
if (typeof v === 'string') return JSON.stringify(v)
|
||||
if (v === undefined) return 'undefined'
|
||||
return String(v)
|
||||
}
|
||||
|
||||
/** 注册表里 layout preview 的最小形态(codegen 只需要这个) */
|
||||
interface DemoBlockLike {
|
||||
readonly slot?: string
|
||||
readonly widget: string
|
||||
readonly props?: Record<string, unknown>
|
||||
}
|
||||
interface LayoutLike {
|
||||
readonly preview?: { readonly demoBlocks?: ReadonlyArray<DemoBlockLike> }
|
||||
}
|
||||
interface RegistryLike {
|
||||
readonly store: { readonly layouts: ReadonlyMap<string, LayoutLike> }
|
||||
}
|
||||
|
||||
/** 从注册布局的 demoBlocks 生成一个"起步页面"声明(供画廊填业务后生成) */
|
||||
export function starterPageFrom(layoutId: string, registry: RegistryLike, title: string): PageDeclaration {
|
||||
const layout = registry.store.layouts.get(layoutId)
|
||||
const demo = layout?.preview?.demoBlocks ?? []
|
||||
return {
|
||||
layout: layoutId,
|
||||
title,
|
||||
blocks: demo.map((d) => ({ widget: d.widget, slot: d.slot, props: d.props })),
|
||||
}
|
||||
}
|
||||
+13
-4
@@ -9,6 +9,7 @@
|
||||
import React from 'react'
|
||||
import type { RegistryApi, LayoutRegistryEntry, LayoutRenderProps } from './registry.ts'
|
||||
import type { PageDeclaration, PageBlockDecl } from './page.ts'
|
||||
export { generatePageSource, starterPageFrom } from './codegen.ts'
|
||||
|
||||
/** widget 的 React 组件形态(宽松:props 由声明 props/bind 决定) */
|
||||
export type WidgetComponent = React.ComponentType<Record<string, unknown>> | React.ElementType
|
||||
@@ -104,6 +105,9 @@ export function DefaultLayout({ slots, blocks }: LayoutRenderProps): React.React
|
||||
export interface GalleryProps {
|
||||
readonly registry: RegistryApi
|
||||
readonly pages?: readonly PageDeclaration[]
|
||||
/** 受控选中(供外部联动,如生成面板) */
|
||||
readonly selected?: LayoutRegistryEntry | null
|
||||
readonly onSelect?: (layout: LayoutRegistryEntry) => void
|
||||
}
|
||||
|
||||
/** 由布局的 preview.demoBlocks 构造一个可渲染的 demo 页声明 */
|
||||
@@ -142,10 +146,15 @@ function Thumb({ children, onClick }: { children?: React.ReactNode; onClick?: ()
|
||||
)
|
||||
}
|
||||
|
||||
export function LayoutGallery({ registry }: GalleryProps): React.ReactElement {
|
||||
export function LayoutGallery({ registry, selected: selectedProp, onSelect }: 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<LayoutRegistryEntry | null>(null)
|
||||
const [localSelected, setLocalSelected] = React.useState<LayoutRegistryEntry | null>(null)
|
||||
const selected = selectedProp !== undefined ? selectedProp : localSelected
|
||||
const select = (layout: LayoutRegistryEntry) => {
|
||||
setLocalSelected(layout)
|
||||
onSelect?.(layout)
|
||||
}
|
||||
|
||||
const cards = layouts.map((layout) => {
|
||||
const page = demoPageFrom(layout)
|
||||
@@ -154,7 +163,7 @@ export function LayoutGallery({ registry }: GalleryProps): React.ReactElement {
|
||||
{ key: layout.id, style: { display: 'flex', flexDirection: 'column', gap: 6 } },
|
||||
React.createElement(
|
||||
Thumb,
|
||||
{ onClick: () => setSelected(layout) },
|
||||
{ onClick: () => select(layout) },
|
||||
page
|
||||
? React.createElement(PageView, { page, registry, data: {} })
|
||||
: React.createElement('div', { style: { padding: 12, color: '#9ca3af' } }, '(无 demoBlocks)'),
|
||||
@@ -171,7 +180,7 @@ export function LayoutGallery({ registry }: GalleryProps): React.ReactElement {
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
onClick: () => setSelected(layout),
|
||||
onClick: () => select(layout),
|
||||
style: { fontSize: 12, padding: '3px 10px', borderRadius: 6, border: '1px solid #d1d5db', background: '#fff', cursor: 'pointer' },
|
||||
},
|
||||
'预览',
|
||||
|
||||
Reference in New Issue
Block a user