// 页面蓝图编译器:AI 只出组织意图,程序确定性编译成原生 HTML + canonical SOMA DSL。 // // 页面外观是本包自己的封闭 Tailwind 词汇;soma-core 只负责声明解析与运行时挂载, // soma-ui(若 Host 安装)只可作为 Host 注入的目录资料,绝不会进入生成页的渲染链。 import { emitSomaDeclaration } from './SomaDeclarationEmitter'; import { TAILWIND_PAGE_CLASSES, TAILWIND_PAGE_VOCABULARY, type TailwindLayoutAlign, type TailwindLayoutColumn, type TailwindLayoutGap, type TailwindLayoutItemMin, type TailwindLayoutPattern, type TailwindLayoutRatio, type TailwindPageSkeleton, } from './TailwindPageVocabulary'; import { isPublishedPageSlug } from 'soma-core'; import { GENERATED_PAGE_MARKER, type PageCatalogEntry, type PageDeclarations, } from './PageDeclarationValidator'; /** 文字区块不依赖任何组件运行时。 */ export const TEXT_BLOCK = 'text'; export type BlueprintSkeleton = TailwindPageSkeleton; export type BlueprintLayoutPattern = TailwindLayoutPattern; export type BlueprintLayoutGap = TailwindLayoutGap; export type BlueprintLayoutAlign = TailwindLayoutAlign; export type BlueprintLayoutItemMin = TailwindLayoutItemMin; export type BlueprintLayoutRatio = TailwindLayoutRatio; export interface BlueprintLayoutSpec { pattern?: BlueprintLayoutPattern; gap?: BlueprintLayoutGap; itemMin?: BlueprintLayoutItemMin; columns?: TailwindLayoutColumn; align?: BlueprintLayoutAlign; ratio?: BlueprintLayoutRatio; } export interface BlueprintBlock { /** Host 注入目录中的语义条目,或 `text`。不会被当作运行时组件实例。 */ component: string; /** 区块小标题(可选) */ title?: string; /** 展示数据;每个值都会以安全文本绑定落入原生 Tailwind 卡片。 */ props?: Record; /** text 区块的段落 */ paragraphs?: string[]; /** * 区块自身不承载布局:pattern / gap / itemMin / columns / align / ratio 只写在 grid 分组上, * 由分组统一派给每个子区块。区块上出现 `layout` 子对象、或把这六个字段平铺在区块上, * 都明确拒绝——与 feedback 同理,不能看似接受、实际不生效。 */ layout?: BlueprintLayoutSpec; /** * 旧组件链的 emit 反馈。直接 Tailwind 页面没有 soma-ui 组件 emit,text 区块更没有交互, * 任何区块出现即明确拒绝,不能看似接受、实际没有交互。 */ feedback?: { emit: string; text: string }; } /** grid 分组:布局参数(BlueprintLayoutSpec 的六个字段)平铺在分组自身上。 */ export interface BlueprintGroup extends BlueprintLayoutSpec { group: 'grid'; title?: string; blocks: BlueprintBlock[]; } export interface BlueprintDataSource { object: string; api: string; into?: string; window?: BlueprintDataWindow; } export interface BlueprintDataWindow { size?: number; sort?: Array<{ field: string; order: 'asc' | 'desc' }>; } export interface SomaPageBlueprint { title: string; slug?: string; subtitle?: string; skeleton?: BlueprintSkeleton; maxWidth?: keyof typeof TAILWIND_PAGE_VOCABULARY.maxWidths; data?: BlueprintDataSource[]; blocks: Array; } /** 取数引用前缀:props 值写成 `$data.order.rows` 即绑到实时数据而不是内联字面量。 */ export const DATA_REF_PREFIX = '$data.'; /** * grid 分组的布局参数:schema 里只此一份。区块自身不收 `layout`(见 BlueprintBlock.layout), * 所以这组参数只挂在分组上。 */ const BLUEPRINT_GROUP_LAYOUT_PROPERTIES = { columns: { type: 'number', enum: [...TAILWIND_PAGE_VOCABULARY.columns], description: '栅格列数' }, pattern: { type: 'string', enum: [...TAILWIND_PAGE_VOCABULARY.patterns], description: '布局模式' }, gap: { type: 'string', enum: [...TAILWIND_PAGE_VOCABULARY.gaps], description: '间距档' }, itemMin: { type: 'string', enum: [...TAILWIND_PAGE_VOCABULARY.itemMins], description: '最小内容密度档' }, align: { type: 'string', enum: [...TAILWIND_PAGE_VOCABULARY.aligns], description: '交叉轴对齐' }, ratio: { type: 'string', enum: [...TAILWIND_PAGE_VOCABULARY.ratios], description: '比例档' }, }; /** 分组布局字段名:schema 的区块分支与编译器的区块检查共用这一份,不各写一张清单。 */ const BLUEPRINT_GROUP_LAYOUT_KEYS = Object.keys(BLUEPRINT_GROUP_LAYOUT_PROPERTIES) as Array; /** * revise 底稿的协议迁移:剥掉旧协议放行、现行编译器拒收的键,返回副本,不改入参。 * * 旧 schema 让区块与分组共用一张属性表:区块上平铺六个布局字段、区块或分组带 `layout` 子对象 * 都能过 schema,旧编译器一律静默忽略,text 区块的 feedback 也照样放行。这样的蓝图作为 * result.blueprint 存下,下一次 revise 原样传回,现行编译器会逐条拒收——模型照抄未改区块也过不了闸, * 空输出兜底更编不出来。这些键旧编译器从不消费,剥掉不改变页面。组件区块上的 feedback * 旧编译器就已拒绝,那样的底稿当初编不出来,不在迁移范围内。 * * 每剥一个键记一条修补,由调用方随 meta.repairs 上报。 */ export function migrateLegacyBlueprintBlocks( blueprint: SomaPageBlueprint, ): { blueprint: SomaPageBlueprint; repairs: string[] } { const repairs: string[] = []; const strip = (item: T, keys: readonly string[], label: string): T => { const record = item as unknown as Record; const present = keys.filter((key) => record[key] !== undefined); if (present.length === 0) return item; const next = { ...record }; for (const key of present) { delete next[key]; repairs.push(`协议迁移:${label} 的 ${key} 已移除`); } return next as unknown as T; }; // 非对象条目原样放过:形状错误留给编译器按原有报错回喂,迁移不替它判 const migrateLeaf = (block: BlueprintBlock): BlueprintBlock => { if (!block || typeof block !== 'object') return block; const keys = [...BLUEPRINT_GROUP_LAYOUT_KEYS, 'layout', ...(block.component === TEXT_BLOCK ? ['feedback'] : [])]; return strip(block, keys, `区块 "${block.component}"`); }; const migrateItem = (item: BlueprintBlock | BlueprintGroup): BlueprintBlock | BlueprintGroup => { if (!item || typeof item !== 'object') return item; if (!isGroup(item)) return migrateLeaf(item); const group = strip(item, ['layout'], `分组 "${item.title ?? 'grid'}"`); return Array.isArray(group.blocks) ? { ...group, blocks: group.blocks.map(migrateLeaf) } : group; }; return { blueprint: Array.isArray(blueprint.blocks) ? { ...blueprint, blocks: blueprint.blocks.map(migrateItem) } : { ...blueprint }, repairs, }; } const BLUEPRINT_COMPONENT_BLOCK_SCHEMA = { type: 'object' as const, additionalProperties: false, required: ['component'], properties: { component: { type: 'string', description: `Host 可信目录中的语义条目,或 "${TEXT_BLOCK}" 文字区块` }, title: { type: 'string', description: '区块小标题' }, props: { type: 'object', description: '以原生 Tailwind 卡片展示的业务数据' }, paragraphs: { type: 'array', items: { type: 'string' }, description: 'text 区块的段落文字' }, }, }; /** AI 只输出组织意图;HTML / YAML / Tailwind class 只由编译器产生。 */ export const BLUEPRINT_SCHEMA = { type: 'object' as const, additionalProperties: false, required: ['title', 'blocks'], properties: { title: { type: 'string', description: '页面主标题' }, slug: { type: 'string', pattern: '^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$', description: '独立发布路径名:语义化英文小写 slug', }, subtitle: { type: 'string', description: '副标题(可选)' }, skeleton: { type: 'string', enum: [...TAILWIND_PAGE_VOCABULARY.skeletons], description: '页面视觉骨架' }, maxWidth: { type: 'string', enum: Object.keys(TAILWIND_PAGE_VOCABULARY.maxWidths), description: '内容限宽档' }, data: { type: 'array', description: '可选取数声明;区块 props 可用 $data.<对象>.<字段> 引用', items: { type: 'object', additionalProperties: false, required: ['object', 'api'], properties: { object: { type: 'string', description: '页面内唯一的数据对象名' }, api: { type: 'string', description: '本次授权接口路径' }, into: { type: 'string', description: '聚合到对象子键(可选)' }, window: { type: 'object', additionalProperties: false, properties: { size: { type: 'number', description: '窗口块大小(10~500)' }, sort: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['field', 'order'], properties: { field: { type: 'string' }, order: { type: 'string', enum: ['asc', 'desc'] }, }, }, }, }, }, }, }, }, blocks: { type: 'array', minItems: 1, description: '页面区块序列:语义数据卡或 Tailwind 栅格分组;布局字段只写在 grid 分组上', items: { type: 'object', additionalProperties: false, oneOf: [ { required: ['component'], // 分组专属键(group / blocks 与六个布局字段)落在区块上即不合契约,而不是被静默忽略 not: { anyOf: ['group', 'blocks', ...BLUEPRINT_GROUP_LAYOUT_KEYS].map((key) => ({ required: [key] })), }, }, { required: ['group', 'blocks'], not: { required: ['component'] }, }, ], properties: { ...BLUEPRINT_COMPONENT_BLOCK_SCHEMA.properties, group: { type: 'string', enum: ['grid'], description: '栅格分组' }, ...BLUEPRINT_GROUP_LAYOUT_PROPERTIES, blocks: { type: 'array', minItems: 1, description: '分组内的语义数据卡', items: BLUEPRINT_COMPONENT_BLOCK_SCHEMA, }, }, }, }, }, }; export class BlueprintCompileError extends Error {} /** * 编译器自己落在页面上的界面文案(眉题、空集合占位、区块导语)使用的语言。 * 业务文案(标题、props、段落)来自蓝图本身,不在此列。 */ export const BLUEPRINT_LOCALES = ['zh-CN', 'en-US'] as const; export type BlueprintLocale = (typeof BLUEPRINT_LOCALES)[number]; /** 产品其余文案都是中文,编译器文案缺省跟随中文。 */ const DEFAULT_BLUEPRINT_LOCALE: BlueprintLocale = 'zh-CN'; interface BlueprintChromeCopy { pageEyebrow: string; shellEyebrow: string; emptyList: string; emptyRecord: string; listItem: (ordinal: number) => string; /** 区块卡片标题下的导语:每张卡都一样的中性词,不随目录条目的几何族或来源变化。 */ blockLead: string; } /** * 编译器文案的封闭词表,按 locale 在编译期取值。 * * 这些字终端用户都看得到:只说页面是什么,不写样式方案、运行时、几何族、目录来源之类的实现细节。 * * 不走 `page._locale.*` 绑定是有意的:preview / visualize / revise 产物零 Domain、没有 locale * 资产,`_locale` 在那里根本不存在(见 PageGenerator 的零 Domain 铁律),绑上去只会渲出空白。 */ const BLUEPRINT_CHROME_COPY: Record = { 'zh-CN': { pageEyebrow: 'AI 生成页面', shellEyebrow: 'AI 生成工作台', emptyList: '无条目', emptyRecord: '无字段', listItem: (ordinal) => `第 ${ordinal} 项`, blockLead: '业务信息', }, 'en-US': { pageEyebrow: 'AI generated page', shellEyebrow: 'Generated workspace', emptyList: 'No items', emptyRecord: 'No fields', listItem: (ordinal) => `Item ${ordinal}`, blockLead: 'Business details', }, }; function resolveChromeCopy(locale: unknown): BlueprintChromeCopy { const resolved = locale ?? DEFAULT_BLUEPRINT_LOCALE; if (typeof resolved !== 'string' || !(BLUEPRINT_LOCALES as readonly string[]).includes(resolved)) { throw new BlueprintCompileError(`locale "${String(resolved)}" 不在编译器文案词表内(可用:${BLUEPRINT_LOCALES.join(', ')})`); } return BLUEPRINT_CHROME_COPY[resolved as BlueprintLocale]; } /** 语义目录只用于提示与数据校验;有必填 slot 的条目不能安全退化成原生卡片。 */ export function isBlueprintBlockCompatible(entry: PageCatalogEntry): boolean { return !(entry.slots ?? []).some((slot) => slot.required); } interface CatalogShape { names: Set; entries: Map; contractNames: Set; } function normalizeCatalog(catalog: readonly string[] | readonly PageCatalogEntry[]): CatalogShape { const entries = new Map(); const contractNames = new Set(); for (const item of catalog) { if (typeof item === 'string') { entries.set(item, { name: item, props: [], emits: [], slots: [] }); } else { contractNames.add(item.name); entries.set(item.name, item); } } return { names: new Set(entries.keys()), entries, contractNames }; } function isGroup(block: BlueprintBlock | BlueprintGroup): block is BlueprintGroup { return (block as BlueprintGroup).group === 'grid'; } function assertEnum(value: unknown, allowed: readonly T[], label: string): T | undefined { if (value === undefined) return undefined; if (typeof value !== 'string' || !allowed.includes(value as T)) { throw new BlueprintCompileError(`${label} "${String(value)}" 不在封闭 Tailwind 词汇内(可用:${allowed.join(', ')})`); } return value as T; } function assertColumns(value: unknown): TailwindLayoutColumn | undefined { if (value === undefined) return undefined; if (typeof value !== 'number' || !Number.isInteger(value) || !TAILWIND_PAGE_VOCABULARY.columns.includes(value as TailwindLayoutColumn)) { throw new BlueprintCompileError(`columns "${String(value)}" 不在封闭 Tailwind 词汇内(可用:${TAILWIND_PAGE_VOCABULARY.columns.join(', ')})`); } return value as TailwindLayoutColumn; } interface ResolvedLayout { pattern: TailwindLayoutPattern; gap: TailwindLayoutGap; itemMin: TailwindLayoutItemMin; align: TailwindLayoutAlign; ratio: TailwindLayoutRatio; columns?: TailwindLayoutColumn; } function validateLayout(spec: BlueprintLayoutSpec | undefined, label: string): ResolvedLayout { return { pattern: assertEnum(spec?.pattern, TAILWIND_PAGE_VOCABULARY.patterns, `${label}.pattern`) ?? 'stack', gap: assertEnum(spec?.gap, TAILWIND_PAGE_VOCABULARY.gaps, `${label}.gap`) ?? 'md', itemMin: assertEnum(spec?.itemMin, TAILWIND_PAGE_VOCABULARY.itemMins, `${label}.itemMin`) ?? 'md', align: assertEnum(spec?.align, TAILWIND_PAGE_VOCABULARY.aligns, `${label}.align`) ?? 'stretch', ratio: assertEnum(spec?.ratio, TAILWIND_PAGE_VOCABULARY.ratios, `${label}.ratio`) ?? 'auto', columns: assertColumns(spec?.columns), }; } /** 锚点里取自组件名/字段名的那一段最多保留几个字符;唯一性由后缀序号保证。 */ const ANCHOR_HINT_MAX_LENGTH = 24; /** 组件名/字段名 → 锚点片段:只留 `[a-z0-9_]`;清空后(如纯中文字段名)用调用方给的兜底词。 */ function anchorSlug(hint: string, fallback: string): string { return hint .toLowerCase() .replace(/[^a-z0-9]+/g, '_') .replace(/^_|_$/g, '') .slice(0, ANCHOR_HINT_MAX_LENGTH) || fallback; } function nextAnchor(ctx: CompileContext, hint: string): string { ctx.seq += 1; return `${anchorSlug(hint, 'block')}_${ctx.seq}`; } interface CompileContext { catalog: CatalogShape; show: Record; initProps: Record; seq: number; displaySeq: number; dataObjects: Set; copy: BlueprintChromeCopy; } function escapeHtml(text: string): string { return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function isScalar(value: unknown): value is string | number | boolean | null { return value === null || ['string', 'number', 'boolean'].includes(typeof value); } function nextDisplayAnchor(ctx: CompileContext, parent: string, hint: string): string { ctx.displaySeq += 1; return `${parent}_${anchorSlug(hint, 'value')}_${ctx.displaySeq}`; } function bindDisplayValue(ctx: CompileContext, anchor: string, value: unknown): string { if (typeof value === 'string' && value.startsWith(DATA_REF_PREFIX)) { const path = value.slice(DATA_REF_PREFIX.length).trim(); const object = path.split('.')[0]; if (!path || !object) { throw new BlueprintCompileError(`取数引用格式应为 ${DATA_REF_PREFIX}<对象>.<字段>:${value}`); } if (!ctx.dataObjects.has(object)) { throw new BlueprintCompileError(`取数引用 ${value} 指向未声明的数据对象 "${object}"——先在蓝图 data 段声明它`); } ctx.show[anchor] = `page.data.${path}`; return `page.data.${path}`; } if (!isScalar(value)) { throw new BlueprintCompileError( `展示锚点 "${anchor}" 收到未展开的复杂值;对象和数组必须先拆成受控的字段行`, ); } const path = `page.${anchor}`; ctx.initProps[path] = value; ctx.show[anchor] = path; return path; } function arrayItemLabel(value: unknown, index: number, copy: BlueprintChromeCopy): string { if (value !== null && typeof value === 'object' && !Array.isArray(value)) { const record = value as Record; for (const key of ['label', 'name', 'title', 'key', 'id']) { const candidate = record[key]; if (isScalar(candidate) && candidate !== null && String(candidate).trim() !== '') { return String(candidate); } } } return copy.listItem(index + 1); } /** * 生成页不是 soma-ui 组件实例,复杂 prop 也不能退化为一坨 JSON 文本。静态对象和 * 数组在编译期已知形状,因此递归变成语义化的 dl/ul;只有叶子标量拥有实际绑定锚点。 */ function compileStructuredValue(ctx: CompileContext, anchor: string, value: unknown): string { if (isScalar(value) || (typeof value === 'string' && value.startsWith(DATA_REF_PREFIX))) { bindDisplayValue(ctx, anchor, value); return `--`; } if (Array.isArray(value)) { if (value.length === 0) { return `${escapeHtml(ctx.copy.emptyList)}`; } const items = value.map((item, index) => { const itemAnchor = nextDisplayAnchor(ctx, anchor, `item_${index + 1}`); return [ `
  • `, `${escapeHtml(arrayItemLabel(item, index, ctx.copy))}`, `
    ${compileStructuredValue(ctx, itemAnchor, item)}
    `, '
  • ', ].join(''); }).join(''); return `
      ${items}
    `; } if (value !== null && typeof value === 'object') { const entries = Object.entries(value as Record); if (entries.length === 0) { return `${escapeHtml(ctx.copy.emptyRecord)}`; } const rows = entries.map(([name, child]) => { const childAnchor = nextDisplayAnchor(ctx, anchor, name); return [ `
    `, `
    ${escapeHtml(name)}
    `, `
    ${compileStructuredValue(ctx, childAnchor, child)}
    `, '
    ', ].join(''); }).join(''); return `
    ${rows}
    `; } throw new BlueprintCompileError(`展示锚点 "${anchor}" 的值类型无法安全呈现`); } function assertBlockContract(block: BlueprintBlock, ctx: CompileContext): PageCatalogEntry | undefined { if (block.component === TEXT_BLOCK) return undefined; if (ctx.catalog.names.size === 0) return undefined; const entry = ctx.catalog.entries.get(block.component); if (!entry) { throw new BlueprintCompileError(`语义条目 "${block.component}" 不在 Host 注入的可信目录中`); } if (!ctx.catalog.contractNames.has(block.component)) return entry; const props = block.props ?? {}; const allowed = new Set(entry.props.map((prop) => prop.name)); for (const name of Object.keys(props)) { if (!allowed.has(name)) { throw new BlueprintCompileError(`语义条目 "${block.component}" 的 props.${name} 不在可信目录契约中`); } } for (const prop of entry.props) { if (prop.required && !Object.hasOwn(props, prop.name)) { throw new BlueprintCompileError(`语义条目 "${block.component}" 缺少必填 prop "${prop.name}"`); } } if ((entry.slots ?? []).some((slot) => slot.required)) { throw new BlueprintCompileError(`语义条目 "${block.component}" 有必填 slot,不能安全映射为原生 Tailwind 卡片`); } return entry; } function joinClasses(...values: Array): string { return values.filter((value): value is string => Boolean(value && value.trim())).join(' '); } function compileText(block: BlueprintBlock, ctx: CompileContext, extraClasses?: string): string { const paragraphs = (block.paragraphs ?? []).filter((paragraph) => typeof paragraph === 'string' && paragraph.trim()); if (paragraphs.length === 0) { throw new BlueprintCompileError(`text 区块 "${block.title ?? ''}" 没有 paragraphs 内容`); } const anchor = nextAnchor(ctx, TEXT_BLOCK); const content = paragraphs.map((paragraph, index) => { const paragraphAnchor = `${anchor}_p${index + 1}`; bindDisplayValue(ctx, paragraphAnchor, paragraph); return `

    --

    `; }).join(''); const heading = block.title ? `

    ${escapeHtml(block.title)}

    ` : ''; return [ `
    `, heading, `
    ${content}
    `, '
    ', ].join(''); } /** * 区块与分组都不收 `layout` 子对象。布局参数只以平铺字段写在 grid 分组上; * 静默忽略会让模型以为布局生效了,所以明确拒绝并回喂正确写法。 */ function assertNoNestedLayout(item: BlueprintBlock | BlueprintGroup, label: string): void { if ((item as { layout?: unknown }).layout === undefined) return; throw new BlueprintCompileError( `${label} 使用了 layout 子对象;布局参数只以平铺字段写在 grid 分组上:` + '{ "group": "grid", "pattern": …, "gap": …, "itemMin": …, "columns": …, "align": …, "ratio": …, "blocks": […] }', ); } /** * 六个布局字段是 grid 分组的平铺字段,区块(含 text、含分组子块)自身不收。 * 区块上的 columns 最容易是误放的组件 prop(表格列定义),所以报错同时指出两个去处。 */ function assertNoGroupLayoutKeys(block: BlueprintBlock, label: string): void { const record = block as unknown as Record; const misplaced = BLUEPRINT_GROUP_LAYOUT_KEYS.filter((key) => record[key] !== undefined); if (misplaced.length === 0) return; throw new BlueprintCompileError( `${label} 写了分组布局字段 ${misplaced.join(' / ')};这些字段只平铺写在包住区块的 grid 分组上:` + '{ "group": "grid", "pattern": …, "gap": …, "itemMin": …, "columns": …, "align": …, "ratio": …, "blocks": [ …区块… ] };' + '若是组件自己的同名 prop(如表格的 columns),写进 props', ); } /** feedback 在分流到 text 之前判:text 区块只有 title 与 paragraphs,同样没有交互可反馈。 */ function assertNoFeedback(block: BlueprintBlock): void { if (block.feedback === undefined) return; if (block.component === TEXT_BLOCK) { throw new BlueprintCompileError( `text 区块 "${block.title ?? ''}" 使用了 feedback;Tailwind 直出页不挂 soma-ui 组件 emit,` + 'text 区块只收 title 与 paragraphs,反馈文字直接写进 paragraphs', ); } throw new BlueprintCompileError( `组件 "${block.component}" 使用了 feedback;Tailwind 直出页不挂 soma-ui 组件 emit,` + '请把反馈内容作为 text 区块输出,或由 Host 另行提供受信任交互能力', ); } function compileBlock(block: BlueprintBlock, ctx: CompileContext, extraClasses?: string): string { const label = `区块 "${block.component}"`; assertNoNestedLayout(block, label); assertNoGroupLayoutKeys(block, label); assertNoFeedback(block); if (block.component === TEXT_BLOCK) return compileText(block, ctx, extraClasses); const entry = assertBlockContract(block, ctx); const anchor = nextAnchor(ctx, block.component); const props = block.props ?? {}; const rows = Object.entries(props).map(([name, value]) => { const valueAnchor = nextDisplayAnchor(ctx, anchor, name); return [ `
    `, `
    ${escapeHtml(name)}
    `, `
    ${compileStructuredValue(ctx, valueAnchor, value)}
    `, '
    ', ].join(''); }).join(''); const title = block.title ?? entry?.name ?? block.component; return [ `
    `, `

    ${escapeHtml(title)}

    `, `

    ${escapeHtml(ctx.copy.blockLead)}

    `, rows ? `
    ${rows}
    ` : '', '
    ', ].join(''); } /** * 分组没写 columns 时按区块数推列:取词汇里不超过区块数的最大列数(至少取最小档)。 * 列档只从词汇读,不在这里写死上限——否则词汇增删一档,这里就会推出一个没有静态 class 的列数。 */ function defaultGroupColumns(blockCount: number): TailwindLayoutColumn { const fitting = TAILWIND_PAGE_VOCABULARY.columns.filter((columns) => columns <= blockCount); return fitting.length > 0 ? fitting[fitting.length - 1] : TAILWIND_PAGE_VOCABULARY.columns[0]; } function compileGroup(group: BlueprintGroup, ctx: CompileContext): string { assertNoNestedLayout(group, `分组 "${group.title ?? 'grid'}"`); const layout = validateLayout({ pattern: group.pattern ?? 'auto-grid', gap: group.gap, itemMin: group.itemMin, align: group.align, ratio: group.ratio, columns: group.columns, }, 'group'); const groupAnchor = nextAnchor(ctx, 'group'); // 先确定最终列数,sidebar 首项才能选择不会超出该栅格的静态 span class。 const columns = layout.columns ?? defaultGroupColumns(group.blocks.length); const childHtml = group.blocks.map((block, index) => { const childClasses = joinClasses( TAILWIND_PAGE_CLASSES.itemMins[layout.itemMin], TAILWIND_PAGE_CLASSES.ratios[layout.ratio], layout.pattern === 'reel' ? TAILWIND_PAGE_CLASSES.reelItem : undefined, layout.pattern === 'sidebar' && index === 0 ? TAILWIND_PAGE_CLASSES.sidebarMain[columns] : undefined, ); return compileBlock(block, ctx, childClasses); }).join(''); const isGridPattern = ['auto-grid', 'switcher', 'sidebar', 'cover', 'frame'].includes(layout.pattern); const classes = joinClasses( TAILWIND_PAGE_CLASSES.group[layout.pattern], TAILWIND_PAGE_CLASSES.gaps[layout.gap], TAILWIND_PAGE_CLASSES.aligns[layout.align], isGridPattern ? TAILWIND_PAGE_CLASSES.gridColumns[columns] : undefined, ); const heading = group.title ? `

    ${escapeHtml(group.title)}

    ` : ''; return [ `
    `, heading, `
    ${childHtml}
    `, '
    ', ].join(''); } export interface CompileBlueprintOptions { allowedApiPaths?: readonly string[]; /** 编译器文案(眉题、空集合占位、区块导语)的语言;缺省 zh-CN。业务文案不受影响。 */ locale?: BlueprintLocale; } /** * 蓝图 → 原生 Tailwind 页面 + 唯一 SOMA DSL。 * * `catalog` 是 Host 注入的可信语义资料,不参与运行时组件加载;因此本函数不要求 * 任意 soma-ui 运行时组件,也不会把任何 `data-soma-component` 写入页面。 */ export function compileBlueprint( blueprint: SomaPageBlueprint, catalog: readonly string[] | readonly PageCatalogEntry[] = [], hostClass = 'GeneratedPage', options: CompileBlueprintOptions = {}, ): PageDeclarations & { notes?: string } { if (!blueprint || typeof blueprint !== 'object') throw new BlueprintCompileError('蓝图为空'); if (!Array.isArray(blueprint.blocks) || blueprint.blocks.length === 0) throw new BlueprintCompileError('蓝图缺少 blocks'); if (blueprint.slug !== undefined && !isPublishedPageSlug(blueprint.slug)) { throw new BlueprintCompileError('蓝图 slug 必须是非保留的英文小写 URL 片段(1~64 位)'); } const shape = normalizeCatalog(catalog); const skeleton = assertEnum(blueprint.skeleton ?? 'page', TAILWIND_PAGE_VOCABULARY.skeletons, 'skeleton')!; const maxWidth = assertEnum( blueprint.maxWidth ?? 'default', Object.keys(TAILWIND_PAGE_VOCABULARY.maxWidths) as Array, 'maxWidth', )!; const copy = resolveChromeCopy(options.locale); const dataSources = compileDataSources(blueprint.data, options.allowedApiPaths); const ctx: CompileContext = { catalog: shape, show: {}, initProps: {}, seq: 0, displaySeq: 0, dataObjects: new Set(dataSources.map((source) => source.object)), copy, }; const titleAnchor = 'page_title'; bindDisplayValue(ctx, titleAnchor, blueprint.title); const subtitle = blueprint.subtitle ? (() => { bindDisplayValue(ctx, 'page_subtitle', blueprint.subtitle); return `

    --

    `; })() : ''; const body = blueprint.blocks.map((block) => isGroup(block) ? compileGroup(block, ctx) : compileBlock(block, ctx)).join(''); const rootClasses = TAILWIND_PAGE_CLASSES.root[skeleton]; const titleClass = skeleton === 'cover' ? TAILWIND_PAGE_CLASSES.coverTitle : TAILWIND_PAGE_CLASSES.title; // shell 顶栏只有一枚眉标:它就是这一栏的标签,旁边不再并排说明文字 const shellRail = skeleton === 'shell' ? `
    ${escapeHtml(copy.shellEyebrow)}
    ` : ''; const html = [ `
    `, `
    `, shellRail, `
    `, `

    ${escapeHtml(copy.pageEyebrow)}

    `, `

    --

    `, subtitle, '
    ', `
    ${body}
    `, '
    ', '
    ', ].join(''); const dataObjects = dataSources.length > 0 ? Object.fromEntries(dataSources.map((source) => [ source.object, { from: [{ api: source.api, ...(source.into ? { into: source.into } : {}) }], ...(source.window ? { window: { mode: 'server', block: { size: source.window.size ?? WINDOW_SIZE_DEFAULT }, ...(source.window.sort && source.window.sort.length > 0 ? { sort: source.window.sort } : {}), }, } : {}), }, ])) : undefined; const uiYaml = emitSomaDeclaration({ hostClass, show: ctx.show, initProps: ctx.initProps, // Tailwind 直出页不挂任何组件 emit(feedback 在区块编译入口已拒绝),页面也没有自己的交互, // 所以编译器从不产出事件行。 eventLines: [], ...(dataObjects ? { dataObjects } : {}), }); return { html, uiYaml, eventYaml: '', notes: `页面结构:${ctx.seq} 个 Tailwind 区块;骨架 ${skeleton};` + `${dataSources.length > 0 ? `绑定 ${dataSources.length} 个数据对象` : '无取数(数据内联)'};` + '不依赖 soma-ui 运行时。', }; } const DATA_OBJECT_PATTERN = /^[a-z][a-z0-9_]*$/; const WINDOW_SIZE_MIN = 10; const WINDOW_SIZE_MAX = 500; /** 蓝图 window 没给 size 时的块大小;与取数提示词示例里的 "size": 50 一致。 */ const WINDOW_SIZE_DEFAULT = 50; const DATA_FIELD_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; function compileDataSources( sources: BlueprintDataSource[] | undefined, allowedApiPaths?: readonly string[], ): BlueprintDataSource[] { if (sources === undefined) return []; if (!Array.isArray(sources)) throw new BlueprintCompileError('蓝图 data 必须是数组'); const allowed = allowedApiPaths ? new Set(allowedApiPaths) : null; const seen = new Set(); return sources.map((source, index) => { if (!source || typeof source !== 'object') { throw new BlueprintCompileError(`蓝图 data[${index}] 必须是 { object, api, into? } 对象`); } const object = typeof source.object === 'string' ? source.object.trim() : ''; const api = typeof source.api === 'string' ? source.api.trim() : ''; if (!DATA_OBJECT_PATTERN.test(object)) { throw new BlueprintCompileError(`蓝图 data[${index}].object 必须匹配 [a-z][a-z0-9_]*:${String(source.object)}`); } if (seen.has(object)) throw new BlueprintCompileError(`蓝图 data 出现重名数据对象:${object}`); seen.add(object); if (!api.startsWith('/')) { throw new BlueprintCompileError(`蓝图 data[${index}].api 必须是以 / 开头的接口路径:${String(source.api)}`); } if (allowed && !allowed.has(api)) { throw new BlueprintCompileError(`蓝图 data[${index}].api "${api}" 不在可用数据接口清单内——只能逐字使用清单里给出的路径`); } const window = normalizeDataWindow(source.window, `data[${index}].window`); return { object, api, ...(source.into ? { into: String(source.into) } : {}), ...(window ? { window } : {}), }; }); } function normalizeDataWindow(raw: unknown, path: string): BlueprintDataWindow | undefined { if (raw === undefined || raw === null) return undefined; if (typeof raw !== 'object' || Array.isArray(raw)) { throw new BlueprintCompileError(`蓝图 ${path} 必须是 { size?, sort? } 对象`); } const source = raw as Record; const out: BlueprintDataWindow = {}; if (source.size !== undefined) { const size = Number(source.size); if (!Number.isInteger(size) || size < WINDOW_SIZE_MIN || size > WINDOW_SIZE_MAX) { throw new BlueprintCompileError(`蓝图 ${path}.size 必须是 ${WINDOW_SIZE_MIN}~${WINDOW_SIZE_MAX} 的整数:${String(source.size)}`); } out.size = size; } if (source.sort !== undefined) { if (!Array.isArray(source.sort)) throw new BlueprintCompileError(`蓝图 ${path}.sort 必须是数组`); const seen = new Set(); out.sort = source.sort.map((item, index) => { if (!item || typeof item !== 'object' || Array.isArray(item)) { throw new BlueprintCompileError(`蓝图 ${path}.sort[${index}] 必须是 { field, order } 对象`); } const field = typeof (item as { field?: unknown }).field === 'string' ? String((item as { field: string }).field).trim() : ''; const order = (item as { order?: unknown }).order; if (!DATA_FIELD_PATTERN.test(field)) { throw new BlueprintCompileError(`蓝图 ${path}.sort[${index}].field 非法:${String((item as { field?: unknown }).field)}`); } if (seen.has(field)) throw new BlueprintCompileError(`蓝图 ${path}.sort 重复字段:${field}`); seen.add(field); if (order !== 'asc' && order !== 'desc') { throw new BlueprintCompileError(`蓝图 ${path}.sort[${index}].order 必须是 asc 或 desc`); } return { field, order }; }); } return out; }