/**
* compile —— 执行一份 *.vp.tsx(default export = defineProject(...) 信封),
* 做「未知选项名」检查(tsc 对带连字符的 JSX 属性豁免多余属性检查,臆造
* kebab 属性名只能在这里兜住),写出工程 JSON 并用技能自带校验器终审。
*
* 用法(工作区根目录,技能目录为 vjvp/):
* pnpm exec tsx vjvp/scripts/vptsx/compile.mts <input.vp.tsx> [-o out.json] [--no-validate]
*
* 完整产出链(类型检查 → 执行+选项名检查 → 校验):
* pnpm exec tsc -p vjvp/scripts/vptsx/tsconfig.json
* pnpm exec tsx vjvp/scripts/vptsx/compile.mts <input.vp.tsx>
*/
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 <input.vp.tsx> [-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<string, SchemaDef>
pieces?: Record<string, SchemaDef>
}
const optionNames = new Map<string, Set<string>>()
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>): 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<string, unknown>
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<string, unknown>
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)
}