/** * compile —— 执行一份 *.vp.tsx(default export = defineProject(...) 信封), * 做「未知选项名」检查(tsc 对带连字符的 JSX 属性豁免多余属性检查,臆造 * kebab 属性名只能在这里兜住),写出工程 JSON 并用技能自带校验器终审。 * * 用法(工作区根目录,技能目录为 vjvp/): * pnpm exec tsx vjvp/scripts/vptsx/compile.mts [-o out.json] [--no-validate] * * 完整产出链(类型检查 → 执行+选项名检查 → 校验): * pnpm exec tsc -p vjvp/scripts/vptsx/tsconfig.json * pnpm exec tsx vjvp/scripts/vptsx/compile.mts */ import { spawnSync } from 'node:child_process' import { readFileSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' const here = dirname(fileURLToPath(import.meta.url)) const validator = resolve(here, '../validate-project-json.mjs') const schemaPath = resolve(here, '../../references/widget-schema.generated.json') const args = process.argv.slice(2) let input = '' let output = '' let validate = true for (let i = 0; i < args.length; i++) { const a = args[i]! if (a === '-o' || a === '--out') output = args[++i] ?? '' else if (a === '--no-validate') validate = false else if (!input) input = a } if (!input) { console.error('usage: tsx compile.mts [-o out.json] [--no-validate]') process.exit(2) } const abs = resolve(process.cwd(), input) const mod = (await import(pathToFileURL(abs).href)) as { default?: { project?: { screen?: unknown } } } const envelope = mod.default if (!envelope || typeof envelope !== 'object' || !envelope.project?.screen) { console.error(`[vptsx] ${input} 必须 default export defineProject(...) 的返回值(含 project.screen)`) process.exit(1) } // --- 未知选项名检查(schema 驱动) ------------------------------------------- // tsc 只对无连字符的属性做多余属性检查(kebab 名被当 data-* 豁免),因此 // `background-colour` 这类臆造名要在这里对照 widget schema 拦下并给出相近候选。 interface SchemaDef { options?: Array<{ name?: string }> } const schema = JSON.parse(readFileSync(schemaPath, 'utf8')) as { widgets?: Record pieces?: Record } 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) as string[])) } } /** options 里合法但不在 defineOptions 蓝图里的键 */ const EXTRA_OPTION_KEYS = new Set(['interactions']) function levenshtein(a: string, b: string): number { const m = a.length const n = b.length if (!m || !n) return Math.max(m, n) 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 suggest(name: string, candidates: Set): string { const scored = [...candidates] .map((c) => ({ c, d: levenshtein(name, c) })) .filter((x) => x.d <= 3 || x.c.includes(name) || name.includes(x.c)) .sort((x, y) => x.d - y.d) .slice(0, 3) .map((x) => x.c) return scored.length ? `;相近选项: ${scored.join(', ')}` : '' } const optionErrors: string[] = [] interface AnyNode { uid?: string type?: string options?: Record widgets?: AnyNode[] unitTemplate?: AnyNode pieces?: AnyNode[] } function checkNode(node: AnyNode, path: string): void { if (!node || typeof node !== 'object') return const type = String(node.type ?? '') const where = `${path}/${type}#${node.uid ?? '?'}` if (type && type !== 'screen' && type !== 'window' && type !== '#fragment') { const known = optionNames.get(type) if (!known) { optionErrors.push(`${where}: 未知组件类型 ${JSON.stringify(type)}`) } else { for (const key of Object.keys(node.options ?? {})) { if (known.has(key) || EXTRA_OPTION_KEYS.has(key)) continue optionErrors.push(`${where}: 未知选项 ${JSON.stringify(key)}${suggest(key, known)}`) } } } for (const w of node.widgets ?? []) checkNode(w, where) if (node.unitTemplate) checkNode(node.unitTemplate, `${where}/unitTemplate`) for (const p of node.pieces ?? []) checkNode(p, `${where}/pieces`) } const proj = envelope.project as Record const roots: AnyNode[] = [ proj.screen as AnyNode, ...((proj.screens as AnyNode[]) ?? []), ...((proj.windows as AnyNode[]) ?? []), proj.foregroundScreen as AnyNode, proj.backgroundScreen as AnyNode, ].filter(Boolean) for (const r of roots) checkNode(r, '') if (optionErrors.length) { console.error(`[vptsx] 选项名检查失败(${optionErrors.length} 处):`) for (const e of optionErrors) console.error(' ERROR ' + e) console.error('[vptsx] 选项名必须来自 widget-schema.generated.json(get_widget_metadata / page_get_schemas 同源)') process.exit(1) } const out = output ? resolve(process.cwd(), output) : abs.replace(/\.vp\.tsx$/i, '.vpproj.json').replace(/\.tsx$/i, '.vpproj.json') writeFileSync(out, JSON.stringify(envelope, null, 2) + '\n', 'utf8') console.log(`[vptsx] wrote ${out}`) if (validate) { const r = spawnSync(process.execPath, [validator, out], { stdio: 'inherit' }) process.exit(r.status ?? 1) }