/** * 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 / 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()