唯杰地图 logo唯杰地图
VJMAP
EN

gen-types.mts

vjvp/scripts/vptsx/gen-types.mts 10.2 KB278 行
原文
/**
 * 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)`)