gen-types.mts
/**
* 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<string, SchemaComponent>
pieces?: Record<string, SchemaComponent>
}
/** 选项类型串 → 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<string>()
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<string, string[]>()
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<string, string> = {
'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<K extends keyof VpPieceOptionsMap> {
uid: string
type: K
name?: string
enabled?: boolean
locked?: boolean
/** 嵌套子图元(如 map-2d 的图层/数据源挂在 map-body 下) */
pieces?: VpKnownPiece[]
/** 多状态/条件样式等节点级持久化字段(与 widget 节点同名语义) */
state?: string
states?: unknown
stateOverrides?: Record<string, unknown>
conditionalStyles?: unknown
templateSubtypeLabel?: string
templateAlias?: string
options?: VpPieceOptionsMap[K]
}
/** 全部已注册 piece 的判别联合(写错 type / 选项键 / 枚举值 → 编译报错) */
export type VpKnownPiece = { [K in keyof VpPieceOptionsMap]: VpPieceNode<K> }[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)`)