唯杰地图 logo唯杰地图
VJMAP
EN

compile.mts

vjvp/scripts/vptsx/compile.mts 5.6 KB146 行
原文
/**
 * 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)
}