// SOMA 页面声明校验器:AI 生成统一声明后的质量闸门。 // // 校验维度: // - 注入面安全扫描(禁用标签 / 内联事件属性 / 伪协议,与发布管线同一把尺子); // - canonical _ui.yaml 结构合法(bind/show、内嵌事件与四动词 Pipeline); // - ui 引用的锚点在 html 中存在(组件内部锚点不许被页面引用——AI 常见错误); // - 组件实例:组件名在目录内、props ⊆ 契约、required props 齐全、必填插槽有内容; // - emit 订阅:实例锚点上的非 DOM 事件必须匹配契约 emit; // - 普通锚点订阅:事件名必须在 DOM 事件名单里(普通锚点没有 emit 可订阅)。 // // 说明:html 检查基于正则扫描(服务端无 DOM),是保守近似——宁可放过布局细节, // 绝不放过契约违约。发现的问题以中文清单返回,供生成器自修复重试时回喂模型。 import yaml from 'js-yaml'; import { compilePageDeclaration } from 'soma-core/server/page-declaration'; import { scanDeclarationSafety } from 'soma-core/server/declaration-safety'; import { TAILWIND_PAGE_CLASSES, TAILWIND_PAGE_VOCABULARY } from './TailwindPageVocabulary'; /** 与 soma-core C11 对齐的目录侧几何族值域;运行时值由目录条目携带。 */ export type SomaGeometryFamily = 'control' | 'surface' | 'message' | 'data' | 'overlay' | 'layout' | 'display'; export interface PageCatalogProp { name: string; required: boolean; type?: string; /** * 目录是 Host 运行时契约的投影;这些说明字段不参与 Tailwind 页面生成, * 但橱窗和审查页须能如实展示原契约,不能在 soma-ai 边界被截断。 */ values?: Array; mode?: 'oneway' | 'model' | string; default?: unknown; desc?: string; } export interface PageCatalogEmit { name: string; /** 原始目录的 emit payload 字段;Host 可在自己的只读 API 中再投影为字段名。 */ payload?: Array<{ name: string; type?: string; desc?: string }>; desc?: string; } export interface PageCatalogSlot { name: string; required: boolean; desc?: string; } export interface PageCatalogEntry { name: string; /** 组件主几何族;旧目录条目缺省时保留声明链兼容,真实 catalog 均已补齐。 */ geometry?: SomaGeometryFamily; layer?: string; props: PageCatalogProp[]; emits: PageCatalogEmit[]; slots: PageCatalogSlot[]; } export interface PageDeclarations { html: string; uiYaml: string; eventYaml: string; styleYaml?: string; /** 退役侧车字段只为 API 边界给出明确拒绝;合法声明必须为空。 */ dataYaml?: string; } /** * BlueprintCompiler 产物根节点的来源标记(属性名),只有取值 `"true"` 才算数。 * * 全仓只此一份:编译器写它,结构闸门与声明 lint 用同一个 `hasGeneratedPageMarker` 认它, * 两处判据不会再各写一条正则、各自漂移。 */ export const GENERATED_PAGE_MARKER = 'data-soma-ai-tailwind-page'; interface HtmlComponentHost { id?: string; name: string; } /** HTML 属性支持双引号、单引号与无引号;所有声明扫描必须共用这一口径。 */ function readHtmlAttribute(tag: string, name: string): string | undefined { const match = tag.match(new RegExp( `\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>]+))`, 'i', )); return match ? (match[1] ?? match[2] ?? match[3]) : undefined; } function collectComponentHosts(html: string): HtmlComponentHost[] { const result: HtmlComponentHost[] = []; for (const match of html.matchAll(/<[a-z][^>]*>/gi)) { const name = readHtmlAttribute(match[0], 'data-soma-component'); if (!name) continue; const id = readHtmlAttribute(match[0], 'data-soma-id'); result.push({ ...(id ? { id } : {}), name, }); } return result; } /** * 按组件实例归属收集宿主提供的插槽。 * * 不能用全文 includes:页面里任意一个 Card.content 会让另一个缺 content 的 StatePanel * 被误判为合格。这里用轻量标签栈把 data-soma-slot 归给最近的组件祖先;不承担完整 HTML * 解析,只守住组件投影所需的父子边界。 */ function collectProvidedSlots(html: string): Map> { const provided = new Map>(); const stack: Array<{ tag: string; componentId?: string }> = []; const voidTags = new Set([ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr', ]); for (const match of html.matchAll(/|<\/?([a-z][\w:-]*)\b[^>]*>/gi)) { const token = match[0]; if (token.startsWith('