/**
* validate-project-json —— 校验一份手写/生成的工程 JSON 是否"可安全导入"。
* 纯 Node(只读 JSON,无工作区依赖),可直接用 node 运行。
*
* 用法(在 vjvp/ 所在目录执行):
* node vjvp/scripts/validate-project-json.mjs <工程.json> [更多.json...]
* 不带参数时校验技能自带的 examples/*.json。
*
* 依据 reference/widget-schema.generated.json(组件类型、字段绑定 option、min/max)做:
* 组件 type 合法性、必填 position/size、字段绑定三元组形态与 uid 可解析、绑定数量约束、
* connections↔connectionData 一致性、rows 键=字段 uid、map-2d 含 map-body、dataRules 引用可解析、
* 动态数据项 dynamic-prop-entries/dynamic-prop-fields 的 name/键必须是 JSON.stringify(选项路径数组)、
* 条件样式 conditionalStyles 的 paths/cases 形态与 ruleId 引用可解析。
* ERROR 会导致退出码 1;WARN 仅提示。
*/
import { readFileSync, readdirSync } 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 examplesDir = resolve(here, '../examples')
const schema = JSON.parse(readFileSync(schemaPath, 'utf8'))
/** type -> Map(bindingOptionName -> {min,max}) ;并收集所有合法 type */
const bindingDefs = new Map()
const widgetTypes = new Set(Object.keys(schema.widgets ?? {}))
const pieceTypes = new Set(Object.keys(schema.pieces ?? {}))
function parseMinMax(typeStr) {
const min = /min=(\d+)/.exec(typeStr ?? '')
const max = /max=(\d+)/.exec(typeStr ?? '')
return { min: min ? Number(min[1]) : undefined, max: max ? Number(max[1]) : undefined }
}
for (const bucket of [schema.widgets, schema.pieces]) {
for (const [type, def] of Object.entries(bucket ?? {})) {
const m = new Map()
for (const opt of def.options ?? []) {
if (opt.binding) m.set(opt.name, parseMinMax(opt.type))
}
bindingDefs.set(type, m)
}
}
/** type -> Set(全部选项名),用于未知选项名提示 */
const optionNames = new Map()
for (const bucket of [schema.widgets, schema.pieces]) {
for (const [type, def] of Object.entries(bucket ?? {})) {
optionNames.set(type, new Set((def.options ?? []).map((o) => o.name).filter(Boolean)))
}
}
/** type -> Map(选项名 -> 选项定义),用于值类型校验 */
const optionDefs = new Map()
for (const bucket of [schema.widgets, schema.pieces]) {
for (const [type, def] of Object.entries(bucket ?? {})) {
optionDefs.set(type, new Map((def.options ?? []).filter((o) => o && o.name).map((o) => [o.name, o])))
}
}
/**
* 按 schema 的 type 串校验单个选项值。返回 null(通过)或 { level:'E'|'W', text }。
* 只校验形态明确的类型:boolean / number / slider / select(有候选) / vector<N> / string / color;field、hidden、window 等交给专门逻辑或放行。
*/
function checkOptionValue(def, v) {
const t = String(def.type || '')
const base = t.replace(/[<(].*$/, '')
const num = (name) => { const m = new RegExp(`${name}=(-?\\d+(?:\\.\\d+)?)`).exec(t); return m ? Number(m[1]) : undefined }
switch (base) {
case 'boolean':
return typeof v === 'boolean' ? null : { level: 'E', text: `应为 true/false,实际是 ${JSON.stringify(v)}` }
case 'number':
case 'slider': {
if (typeof v !== 'number' || Number.isNaN(v)) return { level: 'E', text: `应为数字(${def.alias || ''} ${t}),实际是 ${JSON.stringify(v)}${typeof v === 'boolean' ? '——布尔值会被当成 0/1' : ''}` }
const min = num('min'), max = num('max')
if (min !== undefined && v < min) return { level: 'W', text: `${v} 小于下限 ${min}(${t}),运行时会被夹紧` }
if (max !== undefined && v > max) return { level: 'W', text: `${v} 超过上限 ${max}(${t}),运行时会被夹紧` }
return null
}
case 'select': {
const choices = Array.isArray(def.selectChoices) ? def.selectChoices.map((c) => (c && typeof c === 'object' ? c.value : c)) : null
if (choices && choices.length && !choices.includes(v)) return { level: 'E', text: `取值 ${JSON.stringify(v)} 不在候选内:${choices.map((c) => JSON.stringify(c)).join(' / ')}` }
return null
}
case 'vector': {
const dims = (t.match(/<([^>]+)>/) || [, ''])[1].split(',').filter(Boolean).length
if (!Array.isArray(v)) return { level: 'E', text: `应为 ${dims || 'N'} 元数组(${t}),实际是 ${JSON.stringify(v)}` }
if (dims && v.length !== dims) return { level: 'W', text: `应为 ${dims} 元数组(${t}),实际长度 ${v.length}` }
if (!v.every((x) => typeof x === 'number' || typeof x === 'string')) return { level: 'W', text: `数组元素应为数字(${t})` }
return null
}
case 'string':
return typeof v === 'string' ? null : { level: 'W', text: `应为字符串,实际是 ${JSON.stringify(v)}` }
case 'color':
return typeof v === 'string' ? null : { level: 'E', text: `颜色应为字符串(#hex / rgba() / 渐变串),实际是 ${JSON.stringify(v)}` }
default:
return null
}
}
/** 能放 widgets 子组件的容器类型(页面/窗口节点另行处理;data-repeater 用顶层 unitTemplate、window-instance/scada-2d 用派生子级,均不算。basic-carousel 不 initContainer、不渲染子组件,故不在列) */
const CONTAINER_TYPES = new Set([
'group-panel', 'group-tabs', 'group-popup',
'layout-row', 'layout-column', 'layout-grid', 'layout-flow-row', 'layout-fixed-panel', 'layout-dock', 'layout-sidebar', 'layout-accordion', 'layout-section',
])
/** 写在 options 里但不属于组件选项字典的通用键(交互 / 多状态 / 条件样式等运行时另行读取) */
const NODE_LEVEL_OPTION_KEYS = new Set(['interactions', 'states', 'conditionalStyles', 'dynamic-prop-entries', 'dynamic-prop-fields', 'prop-source-entries', 'prop-source-fields'])
function nearest(name, candidates, max = 3) {
const scored = []
for (const c of candidates) {
const d = levenshtein(name, c)
if (d <= Math.max(2, Math.floor(name.length / 3))) scored.push([d, c])
}
return scored.sort((a, b) => a[0] - b[0]).slice(0, max).map(([, c]) => c)
}
function levenshtein(a, b) {
const m = a.length, n = b.length
if (!m) return n
if (!n) return m
let prev = Array.from({ length: n + 1 }, (_, j) => j)
for (let i = 1; i <= m; i++) {
const cur = [i]
for (let j = 1; j <= n; j++) cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
prev = cur
}
return prev[n]
}
function looksLikeTriple(v) {
return Array.isArray(v) && v.length === 3 && v.every((x) => typeof x === 'string')
}
function asTriples(v) {
// 接受 [[c,t,f],...] 或单个 [c,t,f]
if (looksLikeTriple(v)) return [v]
if (Array.isArray(v) && v.every(looksLikeTriple)) return v
return null
}
/**
* 严格形态:仅认「三元组数组」[[c,t,f],...](至少一条,且每条都是 3 个字符串)。
* 用于「未知选项名」的启发式绑定探测——避免把 operate-text-list 等恰好 3 个字符串的
* 普通字符串列表误判成字段三元组。字段绑定在本工程一律以嵌套数组存储,故此处足够。
*/
function asTriplesStrict(v) {
if (Array.isArray(v) && v.length > 0 && v.every(looksLikeTriple)) return v
return null
}
function validate(body, label) {
const errors = []
const warns = []
const E = (m) => errors.push(m)
const W = (m) => warns.push(m)
// —— 数据索引 ——
const connections = body.connections ?? []
const connectionData = body.connectionData ?? {}
const connByUid = new Map()
for (const c of connections) connByUid.set(c.uid, c)
const dcUids = new Set((body.dataRules ?? []).map((d) => d.uid))
function fieldResolvable(conn, table, field) {
const c = connByUid.get(conn)
let ok = false
if (c) {
const t = (c.tables ?? []).find((t) => t.uid === table)
if (t && (t.fields ?? []).some((f) => f.uid === field)) ok = true
}
const dts = connectionData[conn] ?? []
const dt = dts.find((t) => t.tableId === table)
if (dt && (dt.fields ?? []).some((f) => f.uid === field)) ok = true
return ok
}
// —— project / screen ——
const project = body.project
if (!project) E('缺少 project')
const screen = project?.screen
if (!screen) E('缺少 project.screen(主页面必填)')
if (screen && screen.type !== 'screen') E(`project.screen.type 应为 "screen",实际 "${screen.type}"`)
if (screen && !Array.isArray(screen.widgets)) E('project.screen.widgets 应为数组')
const seenUids = new Set()
function walkNode(node, ctx, parentFluid = false, registerUid = true, uidScope = seenUids) {
if (!node || typeof node !== 'object') return
const { type, uid, options = {} } = node
if (!uid) E(`${ctx}: 节点缺少 uid (type=${type})`)
else if (registerUid) {
// 组合组件母版子树用「模板 uid」,渲染时派生为 实例uid::母版子uid,故不计入全局 uid 唯一性(registerUid=false)
if (uidScope.has(uid)) E(`${ctx}: uid 重复 "${uid}"`)
else uidScope.add(uid)
}
const isPiece = pieceTypes.has(type)
const isWidget = widgetTypes.has(type)
if (!isPiece && !isWidget) {
E(`${ctx}: 未知 type "${type}"(应是已注册组件/图层)`)
}
// 组件(非 piece、非容器子级根)建议有 position/size。
// 例外:父容器为「流式(fluid)」时,子项定位/尺寸由 flex 流 + fluid-*-* 选项决定,position/size 不参与布局,故不强求。
if (isWidget && !parentFluid) {
if (!Array.isArray(options.position)) W(`${ctx} (${type} ${uid}): 建议显式 options.position,否则叠在 [0,0]`)
if (!Array.isArray(options.size)) W(`${ctx} (${type} ${uid}): 建议显式 options.size`)
}
// 只有容器类组件能放 widgets;把图表塞进 basic-graphical 等非容器里不会渲染(只剩三块纯色面板)
if (isWidget && Array.isArray(node.widgets) && node.widgets.length > 0 && !CONTAINER_TYPES.has(type)) {
E(`${ctx} (${type} ${uid}): 非容器组件不能嵌套 ${node.widgets.length} 个子组件(不会渲染)。面板请改用 group-panel(background / border),或把 basic-graphical 作为同层背景放在前面、子组件放在后面`)
}
// 未知选项名提示(TSX 产出链靠 compile.mts 拦臆造的 kebab 名;直接写 JSON 时在这里兜底,只给 warn 并列出相近候选)
const knownNames = optionNames.get(type)
if (knownNames) {
for (const k of Object.keys(options)) {
if (knownNames.has(k) || NODE_LEVEL_OPTION_KEYS.has(k)) continue
const near = nearest(k, knownNames)
W(`${ctx} (${type}): 未知选项名 "${k}"${near.length ? `(相近:${near.join(' / ')})` : ''}——运行时会被忽略,请对照 widget-schema.generated.json`)
}
}
// 选项值类型 / 取值范围 / select 候选(如 row-num 写成 true 被当成 1 行、bar-height 超上限被夹紧)
const defs = optionDefs.get(type)
if (defs) {
for (const [k, v] of Object.entries(options)) {
const def = defs.get(k)
if (!def || v === null || v === undefined) continue
const msg = checkOptionValue(def, v)
if (msg) (msg.level === 'E' ? E : W)(`${ctx} (${type} ${uid}): 选项 "${k}" ${msg.text}`)
}
}
// 字段绑定校验:已知绑定项 + 任何"看起来像三元组"的值
const knownBind = bindingDefs.get(type) ?? new Map()
for (const [k, v] of Object.entries(options)) {
const isKnownBind = knownBind.has(k)
// 已知绑定项:接受裸三元组或嵌套;未知项:仅当为「三元组数组」[[c,t,f],...] 才视作绑定,
// 以免把 operate-text-list 等「恰好 3 个字符串」的普通字符串列表误判为字段三元组。
const triples = isKnownBind ? asTriples(v) : asTriplesStrict(v)
if (!triples && isKnownBind && v != null && (Array.isArray(v) ? v.length : true)) {
// 绑定项存在但形态不对(空数组允许=未绑定)
if (!(Array.isArray(v) && v.length === 0)) E(`${ctx} (${type}): 绑定项 "${k}" 形态应为 [[connUid,tableUid,fieldUid],...]`)
continue
}
if (!triples) continue
for (const tr of triples) {
const [conn, table, field] = tr
if (conn === '__vp_private__') continue // 私有数据
if (!fieldResolvable(conn, table, field))
E(`${ctx} (${type}): 绑定 "${k}" 的三元组 [${conn},${table},${field}] 无法在 connections/connectionData 中解析`)
}
if (isKnownBind) {
const { min, max } = knownBind.get(k)
if (min != null && triples.length < min) E(`${ctx} (${type}): "${k}" 至少需 ${min} 个字段,实际 ${triples.length}`)
if (max != null && triples.length > max) E(`${ctx} (${type}): "${k}" 最多 ${max} 个字段,实际 ${triples.length}`)
}
}
// 动态数据项:dynamic-prop-entries[].name 与 dynamic-prop-fields 的键都必须是 JSON.stringify(选项路径数组)
// 运行时 watchRelatedFields 会 JSON.parse(entry.name) 得到目标路径;写成裸 "opacity" 会抛 JSON 解析错误。
const relEntries = options['dynamic-prop-entries']
const relFields = options['dynamic-prop-fields']
const entryNames = new Set()
const isJsonPathArray = (s) => {
if (typeof s !== 'string') return false
try {
const p = JSON.parse(s)
return Array.isArray(p) && p.length > 0 && p.every((x) => typeof x === 'string')
} catch {
return false
}
}
if (relEntries !== undefined) {
if (!Array.isArray(relEntries)) E(`${ctx} (${type}): dynamic-prop-entries 应为数组`)
else
for (const [i, e] of relEntries.entries()) {
const nm = e && typeof e === 'object' ? e.name : undefined
if (typeof nm !== 'string') {
E(`${ctx} (${type}): dynamic-prop-entries[${i}] 缺少字符串 name`)
continue
}
entryNames.add(nm)
if (!isJsonPathArray(nm))
E(`${ctx} (${type}): dynamic-prop-entries[${i}].name 必须是「选项路径数组」的 JSON 串,如 "[\\"opacity\\"]"(当前 ${JSON.stringify(nm)})`)
}
}
if (relFields !== undefined) {
if (typeof relFields !== 'object' || Array.isArray(relFields)) E(`${ctx} (${type}): dynamic-prop-fields 应为对象(键=dynamic-prop-entries 的 name)`)
else
for (const [key, val] of Object.entries(relFields)) {
if (!isJsonPathArray(key))
E(`${ctx} (${type}): dynamic-prop-fields 键必须是「选项路径数组」的 JSON 串,如 "[\\"opacity\\"]"(当前 ${JSON.stringify(key)})`)
if (relEntries !== undefined && !entryNames.has(key))
E(`${ctx} (${type}): dynamic-prop-fields 键 ${JSON.stringify(key)} 在 dynamic-prop-entries 中无对应 name(不会被监听)`)
const tr = asTriples(val && typeof val === 'object' ? val['dynamic-prop-field'] : undefined)
if (!tr) E(`${ctx} (${type}): dynamic-prop-fields[${JSON.stringify(key)}].dynamic-prop-field 形态应为 [[connUid,tableUid,fieldUid]]`)
else
for (const t of tr) {
const [c, tb, f] = t
if (c !== '__vp_private__' && !fieldResolvable(c, tb, f))
E(`${ctx} (${type}): dynamic-prop-field 三元组 [${c},${tb},${f}] 无法解析`)
}
}
}
for (const nm of entryNames) if (!(relFields && nm in relFields)) W(`${ctx} (${type}): dynamic-prop-entries 项 ${JSON.stringify(nm)} 未在 dynamic-prop-fields 绑定字段`)
// 条件样式 conditionalStyles(node 级;paths 直接用作选项路径,不做 JSON 编码)
if (node.conditionalStyles !== undefined) {
if (!Array.isArray(node.conditionalStyles)) E(`${ctx} (${type}): conditionalStyles 应为数组`)
else
for (const [i, cs] of node.conditionalStyles.entries()) {
if (!cs || !Array.isArray(cs.paths) || !cs.paths.length || !cs.paths.every((s) => typeof s === 'string'))
E(`${ctx} (${type}): conditionalStyles[${i}].paths 应为非空字符串数组(选项路径)`)
for (const [j, e] of (cs?.cases ?? []).entries()) {
if (!e || typeof e !== 'object') {
E(`${ctx} (${type}): conditionalStyles[${i}].cases[${j}] 应为对象`)
continue
}
if (!('value' in e)) E(`${ctx} (${type}): conditionalStyles[${i}].cases[${j}] 缺少 value`)
if (!e.fallback && !e.inlineRules && e.ruleId == null)
E(`${ctx} (${type}): conditionalStyles[${i}].cases[${j}] 需 ruleId / inlineRules / fallback 之一`)
if (e.ruleId != null && !dcUids.has(e.ruleId))
W(`${ctx} (${type}): conditionalStyles[${i}] 引用的数据规则 ${JSON.stringify(e.ruleId)} 不在 dataRules 中`)
}
}
}
// map-2d 必含 map-body
if (type === 'map-2d') {
const pieces = node.pieces ?? []
if (!pieces.length) E(`${ctx}: map-2d 必须有 pieces`)
else if (!pieces.some((p) => p.type === 'map-body')) E(`${ctx}: map-2d 的 pieces 必须含一个 map-body`)
}
// component-instance:实例引用母版(masterId);自带母版快照 master 用于离线/导出渲染。
// 校验 masterId/props/overrides 形态,并递归校验内嵌母版子树(uid 为模板 uid,渲染派生为 实例::子,故 registerUid=false)。
if (type === 'component-instance') {
if (typeof node.masterId !== 'string' || !node.masterId) E(`${ctx}: component-instance 缺少 masterId(字符串)`)
if (node.props !== undefined && (typeof node.props !== 'object' || Array.isArray(node.props))) E(`${ctx}: component-instance.props 应为对象`)
if (node.overrides !== undefined && (typeof node.overrides !== 'object' || Array.isArray(node.overrides))) E(`${ctx}: component-instance.overrides 应为对象`)
const master = node.master
if (master && typeof master === 'object') {
if (!master.node || master.node.type !== 'group-panel') E(`${ctx}: component-instance.master.node 应为 group-panel 子树`)
else walkNode(master.node, `${ctx} > master`, false, false)
if (master.propsSchema !== undefined && !Array.isArray(master.propsSchema)) E(`${ctx}: master.propsSchema 应为数组`)
} else {
W(`${ctx}: component-instance 无内嵌 master 快照(运行时需从组件库按 masterId 解析)`)
}
}
// 容器自身是否流式:决定其子项是否需要 position/size
const selfFluid = options['container-layout'] === 'fluid'
for (const child of node.widgets ?? []) walkNode(child, `${ctx} > ${type}.widgets`, selfFluid, registerUid, uidScope)
// pieces 的 uid 命名空间是「所属 builder 组件内部」(triggerTarget/数据源引用都在组件内解析):
// 每个组件各开一个作用域,多个地图/组态/三维组件可各自使用固定 uid(map-body / scene3d-scene / scene3d-camera)。
const pieceScope = new Set()
for (const child of node.pieces ?? []) walkNode(child, `${ctx} > ${type}.pieces`, selfFluid, registerUid, pieceScope)
// data-repeater 的单元模板(持久化于 unitTemplate,非 widgets):递归校验模板及其子组件绑定
if (node.unitTemplate) {
if (node.unitTemplate.type !== 'group-panel') E(`${ctx} (${type}): unitTemplate.type 应为 "group-panel",实际 "${node.unitTemplate.type}"`)
walkNode(node.unitTemplate, `${ctx} > ${type}.unitTemplate`, false, registerUid, uidScope)
}
}
const screenFluid = screen?.options?.['container-layout'] === 'fluid'
for (const w of screen?.widgets ?? []) walkNode(w, 'screen.widgets', screenFluid)
for (const extra of project?.screens ?? []) {
const extraFluid = extra?.options?.['container-layout'] === 'fluid'
for (const w of extra.widgets ?? []) walkNode(w, 'screens[].widgets', extraFluid)
}
// —— connections ↔ connectionData ——
// 精简交换格式:connectionData[].tableName/fields 可缺省(由 connections 对应表推导);
// 若显式写了 fields,允许与 connections 不同(有意覆盖,如改过 revisedType),仅当字段集合不同时给 warn。
for (const c of connections) {
const dts = connectionData[c.uid]
for (const t of c.tables ?? []) {
const dt = (dts ?? []).find((d) => d.tableId === t.uid)
if (!dt) {
if (['json', 'csv', 'excel', 'static'].includes(c.connectorType ?? c.type))
W(`连接 ${c.uid} 表 ${t.uid}: 静态/文件类缺少 connectionData 行数据(组件将空数据)`)
continue
}
const connFieldIds = (t.fields ?? []).map((f) => f.uid)
const hasOwnFields = Array.isArray(dt.fields)
// 工程经 REST API 原样入库(不经过编辑器导入的补齐),运行时按 connectionData.fields 取字段元数据,
// 缺失会让绑定该表的图表整块空白(TypeError: reading 'find')。vp-cli import 会自动补齐,这里提醒。
if (!hasOwnFields || !dt.fields.length) W(`连接 ${c.uid} 表 ${t.uid}: connectionData 缺少 fields(精简形态)——直接 API 导入会导致图表空白;请补全 fields 后导入`)
// 有效字段:显式 connectionData.fields 优先,否则继承 connections(精简形态)
const effIds = hasOwnFields ? dt.fields.map((f) => f.uid) : connFieldIds
if (hasOwnFields) {
const a = new Set(connFieldIds)
const b = new Set(effIds)
const differ = a.size !== b.size || [...a].some((f) => !b.has(f))
if (differ) W(`连接 ${c.uid} 表 ${t.uid}: connectionData.fields 字段集合与 connections 不同(按需重复定义,请确认是有意覆盖)`)
}
for (const [i, row] of (dt.rows ?? []).entries()) {
for (const f of effIds) if (!(f in row)) W(`连接 ${c.uid} 表 ${t.uid} 第 ${i} 行: 缺少字段键 "${f}"`)
}
}
}
// —— dataRules 引用 ——
for (const dc of body.dataRules ?? []) {
for (const group of dc.rules ?? []) {
for (const r of group ?? []) {
if (!r.field) continue
if (['row', 'reverseRow', 'count'].includes(r.field)) continue
if (r.connection === '__vp_project_params__') continue
if (r.connection && r.table && !fieldResolvable(r.connection, r.table, r.field))
W(`dataRule ${dc.uid}: 规则引用 [${r.connection},${r.table},${r.field}] 无法解析`)
}
}
}
return { errors, warns }
}
export { validate }
function isMain() {
const self = fileURLToPath(import.meta.url)
const invoked = process.argv[1] ? resolve(process.argv[1]) : ''
return self === invoked
}
function main() {
const args = process.argv.slice(2)
const files = args.length
? args.map((a) => resolve(process.cwd(), a))
: readdirSync(examplesDir)
.filter((f) => f.endsWith('.json'))
.map((f) => resolve(examplesDir, f))
let totalErrors = 0
for (const file of files) {
let body
try {
body = JSON.parse(readFileSync(file, 'utf8'))
} catch (e) {
console.log(`\n✗ ${file}\n JSON 解析失败: ${e.message}`)
totalErrors++
continue
}
const { errors, warns } = validate(body, file)
const tag = errors.length ? '✗' : '✓'
console.log(`\n${tag} ${file}`)
for (const e of errors) console.log(` ERROR: ${e}`)
for (const w of warns) console.log(` warn : ${w}`)
if (!errors.length && !warns.length) console.log(' OK')
totalErrors += errors.length
}
console.log(`\n${totalErrors ? '存在 ' + totalErrors + ' 个 ERROR' : '全部通过(无 ERROR)'}`)
process.exit(totalErrors ? 1 : 0)
}
if (isMain()) main()