/** * gen-types —— 从 widget-schema.generated.json 生成 VP-TSX 的组件 props 类型。 * * 产物 types.generated.ts: * · 每个 widget type 一个 props 成员(保留 kebab-case 选项名,JSX 属性天然支持 * `text-font={...}`),并声明全局 JSX.IntrinsicElements; * · 每个 builder 子图元(piece)一个 options 成员(VpPieceOptionsMap),拼成按 type * 判别的联合 VpKnownPiece / 按宿主过滤的 VpHostPieceMap,挂到 builder 组件的 * pieces prop 上。piece 的 options 是普通对象字面量(无 JSX kebab 豁免), * 臆造选项键/写错枚举值/塞错宿主的图元都直接编译报错。 * `tsc` 由此成为带行号的 schema 校验器。 * * 用法(在 vjvp/ 所在目录执行): * pnpm exec tsx vjvp/scripts/vptsx/gen-types.mts * * 输出确定性:widget/piece 按 type 排序、选项保持 schema 原序,同一输入必产同一字节。 */ import { readFileSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const here = dirname(fileURLToPath(import.meta.url)) const schemaPath = resolve(here, '../../references/widget-schema.generated.json') const outPath = resolve(here, 'types.generated.ts') interface SchemaOption { name?: string group?: string type?: string default?: unknown alias?: string binding?: boolean selectChoices?: Array<{ label?: string; value?: unknown } | string | number> cluster?: string } interface SchemaComponent { meta?: { name?: string; category?: string; defaultSize?: [number, number]; host?: string; singleton?: boolean } options?: SchemaOption[] } const schema = JSON.parse(readFileSync(schemaPath, 'utf8')) as { widgets?: Record pieces?: Record } /** 选项类型串 → TS 类型(保守映射:未知控件回落 unknown,不误伤合法值) */ function tsType(opt: SchemaOption): string { const raw = String(opt.type ?? '') const generics = /<([^>]*)>/.exec(raw)?.[1] const control = raw .replace(/<[^>]*>/, '') .replace(/\(.*\)$/, '') .trim() switch (control) { case 'boolean': return 'boolean' case 'number': case 'slider': return 'number' case 'string': case 'textarea': case 'file': case 'color': case 'snapshot': return 'string' case 'select': { // selectChoices 可能是 "(dynamic)"(运行时求值的动态枚举)——回落宽类型 const raw = Array.isArray(opt.selectChoices) ? opt.selectChoices : [] const vals = raw .map((c) => (c && typeof c === 'object' ? (c as { value?: unknown }).value : c)) .filter((v) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') if (!vals.length) return 'string | number' return [...new Set(vals.map((v) => JSON.stringify(v)))].join(' | ') } case 'vector': { const n = generics ? generics.split(',').length : 0 return n > 0 ? `[${new Array(n).fill('number').join(', ')}]` : 'number[]' } case 'font': return 'VpFont' case 'field': return 'VpFieldValue' default: return 'unknown' } } /** JSDoc 单行注释:中文名 · 分组 · 类型串 · 默认值(截断,剥掉会破坏注释的星杠) */ function docComment(opt: SchemaOption): string { const parts: string[] = [] if (opt.alias) parts.push(String(opt.alias)) if (opt.group) parts.push(String(opt.group)) if (opt.type) parts.push(String(opt.type)) if (opt.default !== undefined) { let d = '' try { d = JSON.stringify(opt.default) ?? '' } catch { d = String(opt.default) } if (d.length > 60) d = d.slice(0, 60) + '…' if (d) parts.push(`default: ${d}`) } const text = parts.join(' · ').replace(/\*\//g, '*\u200B/') return text ? ` /** ${text} */\n` : '' } /** * pieceMode:piece 的 options 是普通对象字面量,多余属性检查全量生效,因此 * 合法但不在蓝图叶子里的键(interactions、簇头)必须显式放行为 unknown, * 与 compile.mts 的 EXTRA_OPTION_KEYS / optionNames 口径一致,避免误报。 */ function renderProps(def: SchemaComponent, pieceMode = false): string { const seen = new Set() let body = '' if (pieceMode) { body += ` /** 图元事件交互(builder 内触发器→动作) */\n` body += ` "interactions"?: unknown\n` seen.add('interactions') } for (const opt of def.options ?? []) { const name = opt.name if (!name || seen.has(name)) continue seen.add(name) if (opt.cluster !== undefined) { // 簇(可重复组)路径含动态索引段,不展开子键 if (pieceMode) { const label = [opt.alias, opt.group, '簇(可重复组,键含动态索引,宽松放行)'].filter(Boolean).join(' · ') body += ` /** ${label.replace(/\*\//g, '*\u200B/')} */\n` body += ` ${JSON.stringify(name)}?: unknown\n` } continue } body += docComment(opt) body += ` ${JSON.stringify(name)}?: ${tsType(opt)}\n` } return body } const widgets = schema.widgets ?? {} const types = Object.keys(widgets).sort() const pieces = schema.pieces ?? {} const pieceTypes = Object.keys(pieces).sort() /** builder 宿主 type → 其子图元 type 列表(据 piece meta.host) */ const hostPieces = new Map() for (const t of pieceTypes) { const host = pieces[t]?.meta?.host if (!host) continue const list = hostPieces.get(host) ?? [] list.push(t) hostPieces.set(host, list) } /** builder 宿主约定提示(写进 pieces prop 的 JSDoc) */ const HOST_HINTS: Record = { 'map-2d': '第一个必须是 map-body(图层/数据源挂在其嵌套 pieces 下)', 'scene-3d': '前两个应为固定 uid 的 scene3d-scene / scene3d-camera', 'scada-2d': '图元 scada-node 与连线 scada-link', } let out = `// 由 gen-types.mts 从 widget-schema.generated.json 自动生成 — 请勿手改。 // 重新生成:pnpm exec tsx vjvp/scripts/vptsx/gen-types.mts /* eslint-disable */ import type { VpCommonProps, VpFieldValue, VpFont, VpNode, VpScreenProps } from './runtime' export interface VpWidgetPropsMap { ` for (const type of types) { const def = widgets[type]! const label = [def.meta?.name, def.meta?.category].filter(Boolean).join(' · ') if (label) out += ` /** ${label.replace(/\*\//g, '*\u200B/')} */\n` out += ` ${JSON.stringify(type)}: VpCommonProps & {\n` if (hostPieces.has(type)) { const hint = HOST_HINTS[type] ? `。${HOST_HINTS[type]}` : '' out += ` /** 子图元(判别联合:按 type 收窄后校验选项键/枚举值,臆造键=编译错)${hint} */\n` out += ` pieces?: VpHostPieceMap[${JSON.stringify(type)}][]\n` } out += renderProps(def) .split('\n') .map((l) => (l ? ` ${l}` : l)) .join('\n') out += ` }\n` } out += `} // --- builder 子图元(pieces) ------------------------------------------------- /** 各 piece 的 options 键值表(widget-schema pieces bucket;未知控件/簇宽松为 unknown) */ export interface VpPieceOptionsMap { ` for (const type of pieceTypes) { const def = pieces[type]! const label = [def.meta?.name, def.meta?.host ? `host: ${def.meta.host}` : '', def.meta?.singleton ? '单例' : ''] .filter(Boolean) .join(' · ') if (label) out += ` /** ${label.replace(/\*\//g, '*\u200B/')} */\n` out += ` ${JSON.stringify(type)}: {\n` out += renderProps(def, true) .split('\n') .map((l) => (l ? ` ${l}` : l)) .join('\n') out += ` }\n` } out += `} /** * 子图元节点(判别联合成员)。uid 必填:撤销恢复 / 交互 triggerTarget / 值绑定寻址 * 都依赖稳定 uid。options 键名与枚举值来自各 piece 类 defineOptions 蓝图。 */ export interface VpPieceNode { uid: string type: K name?: string enabled?: boolean locked?: boolean /** 嵌套子图元(如 map-2d 的图层/数据源挂在 map-body 下) */ pieces?: VpKnownPiece[] /** 多状态/条件样式等节点级持久化字段(与 widget 节点同名语义) */ state?: string states?: unknown stateOverrides?: Record conditionalStyles?: unknown templateSubtypeLabel?: string templateAlias?: string options?: VpPieceOptionsMap[K] } /** 全部已注册 piece 的判别联合(写错 type / 选项键 / 枚举值 → 编译报错) */ export type VpKnownPiece = { [K in keyof VpPieceOptionsMap]: VpPieceNode }[keyof VpPieceOptionsMap] /** builder 宿主 → 合法子图元联合(把 map-* 图元塞进 scene-3d → 编译报错) */ export interface VpHostPieceMap { ` for (const host of [...hostPieces.keys()].sort()) { const members = hostPieces.get(host)!.map((t) => ` | VpPieceNode<${JSON.stringify(t)}>`) out += ` ${JSON.stringify(host)}:\n${members.join('\n')}\n` } out += `} ` // 宿主别名(VpScene3dPiece 等):给模块顶层的 piece 辅助函数/数组做返回值标注用—— // 无标注时对象字面量脱离上下文类型,type 拓宽成 string 便不再参与判别联合校验。 for (const host of [...hostPieces.keys()].sort()) { const alias = 'Vp' + host.split(/[^a-zA-Z0-9]+/).map((s) => (s ? s[0]!.toUpperCase() + s.slice(1) : '')).join('') + 'Piece' out += `\n/** ${host} 的子图元联合(构造 pieces 的辅助函数请标注此返回类型,保住字面量判别) */\n` out += `export type ${alias} = VpHostPieceMap[${JSON.stringify(host)}]\n` } out += ` declare global { namespace JSX { type Element = VpNode interface ElementChildrenAttribute { children: {} } interface IntrinsicElements extends VpWidgetPropsMap { /** 页面(主页面/多页);子元素为组件 */ screen: VpScreenProps /** 工程级窗口(弹窗/抽屉),放 project.windows */ window: VpScreenProps } } } export {} ` writeFileSync(outPath, out, 'utf8') console.log(`[vptsx] ${types.length} widgets + ${pieceTypes.length} pieces -> ${outPath} (${(out.length / 1024).toFixed(0)} KB)`)