// SOMA DSL 声明产出:把蓝图编译结果写成唯一页面声明。 // // ── 为什么抓手在编译器而不是提示词 ────────────────────────────────────── // 生成链的缺省模式是 blueprint:模型只出**组织意图 JSON**(哪些区块、什么组件、 // 什么布局),统一声明由 BlueprintCompiler 生成,所以模型不需要手写底层语法。 // // 这个分工本来就是体系的设计("AI 只做组织,SOMA 负责渲染"), // 组件实例自成顶层块("组件是声明的第一公民")。编译器中间结构里组件 props // 暂挂在 show 的 `{ props: … }` 下,而统一声明的 page.ui 拒收组件锚点。 // 所以本发射器必须真拆:带 props 的锚点提成顶层块,其余留在 page.ui。 // // 拆块不是格式美化,是语言语义要求;不拆的产物根本装载不进去。 import yaml from 'js-yaml'; export interface SomaDeclarationEmitInput { /** 页面 Domain 类名 */ hostClass: string; /** 编译器中间 show 映射,拆分后写入 page.ui 与组件块 */ show: Record; /** * 生成页 initProps,写入 page 块。 * * 生成链没有 Domain 可写,字面文案与演示数据必须内联在这里。 */ initProps?: Record; /** 编译器中间事件行:`锚点.事件: Pipeline 表达式`,每行一条 */ eventLines: readonly string[]; /** data 对象映射 */ dataObjects?: Record; } /** * SOMA DSL 的开关是**成对**的:首个有效行 `{{`、末行 `}}`。 */ const DECLARATION_OPEN = '{{'; const DECLARATION_CLOSE = '}}'; /** * 把一行中间事件声明拆成 `键 → 表达式`。 * * 事件行形如 `btn_save.click: UI_REFRESH | page.save`。 * 冒号后面可能还有冒号(Pipeline 里的修饰符 `permission:order_export`), * 所以只在**第一个**冒号处切——用 split(':') 会把修饰符切碎, * 那种错误的症状是事件注册上去但修饰符丢了,页面照常渲染、权限却没生效。 */ function splitEventLine(line: string): [string, string] | null { const trimmed = line.trim(); if (trimmed === '' || trimmed.startsWith('#')) return null; const at = trimmed.indexOf(':'); if (at <= 0) return null; const key = trimmed.slice(0, at).trim(); const expr = trimmed.slice(at + 1).trim(); if (key === '' || expr === '') return null; return [key, expr]; } /** 取出组件实例的 props;不是实例则返回 null */ function componentPropsOf(value: unknown): Record | null { if (value === null || typeof value !== 'object' || Array.isArray(value)) return null; const props = (value as { props?: unknown }).props; if (props === null || typeof props !== 'object' || Array.isArray(props)) return null; return props as Record; } /** * 产出统一 SOMA DSL 声明。 * * 返回完整声明文本(含开关行);事件并进块内,条件类走 ui 段的 `.class` 后缀。 */ export function emitSomaDeclaration(input: SomaDeclarationEmitInput): string { const on: Record = {}; for (const line of input.eventLines) { const parsed = splitEventLine(line); if (parsed) [, on[parsed[0]]] = [parsed[0], parsed[1]]; } // 拆块:带 props 的锚点是组件实例,必须提成顶层块 const pageUi: Record = {}; const instanceBlocks: Record> = {}; for (const [anchor, value] of Object.entries(input.show)) { const props = componentPropsOf(value); if (props) { instanceBlocks[anchor] = { ui: props }; // 实例上的非 props 声明(when/class 等)留在块内同级 const rest = { ...(value as Record) }; delete rest.props; for (const [key, extra] of Object.entries(rest)) instanceBlocks[anchor][key] = extra; } else { pageUi[anchor] = value; } } // 实例自己的 emit 路由从页面级事件里摘出来,并进它自己的块。 // 分流成两个新对象而不是边遍历边 delete——就地删改是这类转换里 // 最容易出错的写法(漏删一条就同时出现在两处,页面级与块级各注册一次) const pageEvents: Record = {}; for (const [key, expr] of Object.entries(on)) { const at = key.indexOf('.'); const anchor = at > 0 ? key.slice(0, at) : ''; const emitName = at > 0 ? key.slice(at + 1) : ''; const block = anchor ? instanceBlocks[anchor] : undefined; if (!block) { pageEvents[key] = expr; continue; } const event = (block.event as { on?: Record } | undefined) ?? {}; event.on = { ...(event.on ?? {}), [emitName]: expr }; block.event = event; } const page: Record = { domain: input.hostClass }; if (input.initProps && Object.keys(input.initProps).length > 0) { page.initProps = input.initProps; } page.ui = pageUi; if (Object.keys(pageEvents).length > 0) page.event = { on: pageEvents }; if (input.dataObjects && Object.keys(input.dataObjects).length > 0) { page.data = input.dataObjects; } const body = yaml.dump({ page, ...instanceBlocks }, { lineWidth: 120, noRefs: true, quotingType: '"', }); return `${DECLARATION_OPEN}\n${body}${body.endsWith('\n') ? '' : '\n'}${DECLARATION_CLOSE}\n`; }