// 组件橱窗生成器(REQ-012):组件目录 → 一批可发布的页面声明。 // // 边界(三条,都是有意的): // · 不经过模型——蓝图由程序按目录逐条拼装,同一份目录永远得到同一批页面; // · 不建第二套运行壳——产出就是标准四件套,交给 Registry 发布,走页面路径访问; // · 只在测试环境——生产环境默认拒绝生成(见 assertShowcaseAllowed)。 // // 谁来展示谁:Host 把自己合成的包喂进来,橱窗就展示那些包。soma-web 喂 soma-ui, // 业务 Host 喂自己的包(也可以连 soma-ui 一起)。同一份代码,两个橱窗—— // 展示能力属于框架,不属于某一个 Host。 import { compileBlueprint, BlueprintCompileError } from '../ai-page/BlueprintCompiler'; import type { PageDeclarations, PageCatalogEntry } from '../ai-page/PageDeclarationValidator'; import { buildShowcaseBlueprint, selectShowcaseComponents, showcaseSlug, SPEC_PANEL_COMPONENT, type ShowcaseCatalogEntry, type ShowcaseComponent, } from './ShowcaseBlueprint'; /** 一页橱窗 */ export interface ShowcasePage extends PageDeclarations { /** Registry 页面身份,发布后经 / 访问 */ slug: string; pack: string; title: string; /** 本页收录的组件名 */ components: string[]; /** Host 类名(四件套 html 的 data-soma-host) */ hostClass: string; } export interface GenerateShowcaseResult { pages: ShowcasePage[]; /** 落选组件与原因:补齐 example 后它们会自动出现在橱窗里 */ skipped: Array<{ name: string; reason: string }>; } export interface GenerateShowcaseOptions { /** * 一页最多放多少组件(缺省 40)。 * * 不是审美问题:整库几百个组件挤在一页会让首屏解析与视觉矩阵截图都变得不可用, * 而一个组件一页会在 Registry 里留下几百条路径。 */ maxPerPage?: number; /** 只生成这些包的橱窗;不给则全部 */ packs?: readonly string[]; /** 允许单组件预览说明板自身;批量橱窗仍默认把它作为说明板而跳过 */ includeSpecPanel?: boolean; /** 单组件预览不再嵌套说明板 */ withSpecPanel?: boolean; /** * 生产环境强制生成(缺省 false)。 * * 橱窗是给开发者看的,不是产品的一部分:生产环境连页面路径都不该存在。 * 真要在生产环境生成(比如内网的组件门户),显式写出来。 */ force?: boolean; /** 环境判定注入口,便于测试;缺省读 process.env.NODE_ENV */ nodeEnv?: string; } export class ShowcaseGenerationError extends Error { constructor(message: string) { super(`[橱窗] ${message}`); this.name = 'ShowcaseGenerationError'; } } /** * 生产环境闸门。 * * 双保险的另一半在 Host 侧(测试环境配置里才打开橱窗开关);这里兜住 * 「配置写错了、或者有人直接调了生成器」的情况——橱窗多一条路径不会报错, * 而生产环境多一条能翻遍全部组件与样张的路径是实打实的信息暴露面。 */ export function assertShowcaseAllowed(options: GenerateShowcaseOptions = {}): void { const env = options.nodeEnv ?? (typeof process === 'undefined' ? undefined : process.env?.NODE_ENV); if (env === 'production' && options.force !== true) { throw new ShowcaseGenerationError( '生产环境默认不生成组件橱窗——它是给开发者看的页面。' + '确需生成请显式传 force: true,并自行确认这批路径可以对外可见', ); } } function hostClassOf(slug: string): string { const camel = slug .split('-') .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(''); return `${camel}Page`; } function chunk(list: readonly T[], size: number): T[][] { const out: T[][] = []; for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size)); return out.length > 0 ? out : [[]]; } /** * 生成全部橱窗页。 * * 入参是**合成后的目录条目**,不是散装组件表:橱窗必须与运行时注册的是同一批组件, * 中间隔一次手工拼装就迟早漂移——这与 generatePackCatalog 的理由是同一条。 */ export function generateShowcasePages( entries: readonly ShowcaseCatalogEntry[], options: GenerateShowcaseOptions = {}, ): GenerateShowcaseResult { assertShowcaseAllowed(options); const maxPerPage = Math.max(1, options.maxPerPage ?? 40); const { picked, skipped } = selectShowcaseComponents(entries, { includeSpecPanel: options.includeSpecPanel, }); // 说明板必须在目录里,否则橱窗只能光放组件本体——那就退化成一堆没有出处的方块 const catalog = entries as readonly PageCatalogEntry[]; const withSpecPanel = options.withSpecPanel ?? entries.some((entry) => entry.name === SPEC_PANEL_COMPONENT); const byPack = new Map(); for (const component of picked) { const pack = component.pack ?? 'unknown'; if (options.packs && !options.packs.includes(pack)) continue; const list = byPack.get(pack) ?? []; list.push(component); byPack.set(pack, list); } const pages: ShowcasePage[] = []; for (const [pack, components] of [...byPack.entries()].sort(([a], [b]) => a.localeCompare(b))) { const groups = chunk(components, maxPerPage); groups.forEach((group, index) => { const page = index + 1; const slug = showcaseSlug(pack, page); const hostClass = hostClassOf(slug); const blueprint = buildShowcaseBlueprint({ pack, components: group, page, pageCount: groups.length, withSpecPanel, }); let declarations: PageDeclarations; try { declarations = compileBlueprint(blueprint, catalog, hostClass); } catch (error) { if (error instanceof BlueprintCompileError) { throw new ShowcaseGenerationError( `包 ${pack} 第 ${page} 页编译失败:${error.message}。` + '通常是某个组件的样张与契约对不上——先跑 verifyComponentPack 看 [样张] 与 [字段] 两类问题', ); } throw error; } pages.push({ ...declarations, slug, pack, title: blueprint.title, hostClass, components: group.map((component) => component.name), }); }); } return { pages, skipped }; }