feat: 打通 HBuilderX CLI 构建 + 修复文章闭环静态盲区

构建:
- 新增 tools/build.js:junction HBuilderX 工具链,CLI 构建 h5/mp-weixin
- vue 指向补丁版 @dcloudio/uni-h5-vue(官方 npm vue 不导出 isInSSRComponentSetup)
- 设 HX_APP_ROOT 避免退化成 H5 空壳产物;产物完整性校验

校验工具:
- 新增 check-cloud-methods.js:acorn 解析云对象方法,比对 94 处调用点
- 新增 check-android-contract.js:Kotlin 侧云对象契约校验
- audit-project.js 修 downloadFile 误报(注释未剥离);tools/ 排除出扫描
- package.json 声明此前隐式依赖的 acorn

功能:
- 补 uni-cms-articles.getPublishedArticles(安卓端依赖但此前不存在)
- 修 u-parse <audio> 引用已移除组件导致 H5 构建失败
This commit is contained in:
2026-09-12 01:02:45 +08:00
parent 4f5893f87a
commit 3117146281
26 changed files with 2043 additions and 337 deletions
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env node
'use strict'
/**
* HBuilderX CLI 构建包装器。
*
* 背景:本项目是 HBuilderX 工程,Vue3 编译所需的 @dcloudio/* 工具链只随
* HBuilderX 分发,不在项目 package.json 里。直接在项目目录跑 `npm install`
* 会装入 Vue2 时代的旧包,且每次 install 都会清掉手工建的联接。
*
* 因此本脚本在构建前自动完成三件事(幂等,可反复跑):
* 1. 把 HBuilderX 自带的工具链目录联接到项目 node_modules/
* 2. 设置 HX_APP_ROOT / UNI_INPUT_DIR / UNI_OUTPUT_DIR,让 uni CLI
* 走 HBuilderX 内置模块解析路径(缺 HX_APP_ROOT 时会退化成 H5 空壳产物)
* 3. 用 HBuilderX 自带的 node 执行 `uni build -p <platform>`
*
* 不用打开 HBuilderX GUI,也不需要联网。
*
* 用法:
* node tools/build.js -p h5
* node tools/build.js -p mp-weixin
* node tools/build.js -p h5 --report # 额外打印产物体积明细
* node tools/build.js --link-only # 只建联接,不构建
* node tools/build.js --doctor # 只做环境体检
*
* 环境变量:
* HBUILDERX_HOME HBuilderX 安装目录,默认 C:/Program Files/HBuilderX/HBuilderX
*/
const fs = require('fs')
const path = require('path')
const { spawnSync } = require('child_process')
const ROOT = path.resolve(__dirname, '..')
const HBX = process.env.HBUILDERX_HOME || 'C:/Program Files/HBuilderX/HBuilderX'
const HBX_TC = path.join(HBX, 'plugins', 'uniapp-cli-vite', 'node_modules')
const HBX_SASS = path.join(HBX, 'plugins', 'compile-dart-sass', 'node_modules')
const UNI_JS = path.join(HBX_TC, '@dcloudio', 'vite-plugin-uni', 'bin', 'uni.js')
const NM = path.join(ROOT, 'node_modules')
// HBuilderX 自带的 node。系统 node 太新时 vite 的 config 缓存格式不兼容
// (failed to load config / Invalid or incompatible cached data),
// 固定用 HBuilderX 的 node 最稳。
const HBX_NODE_CANDIDATES = [
path.join(HBX, 'plugins', 'node', 'node.exe'),
path.join(HBX, 'plugins', 'node18', 'node.exe')
]
const PLATFORM_OUTPUT = {
h5: 'unpackage/dist/build/web',
'mp-weixin': 'unpackage/dist/build/mp-weixin',
'mp-alipay': 'unpackage/dist/build/mp-alipay',
app: 'unpackage/dist/build/app-plus'
}
// ── 联接管理 ────────────────────────────────────────────────
function isLink(target) {
try {
return fs.lstatSync(target).isSymbolicLink() || fs.lstatSync(target).isDirectory()
} catch {
return false
}
}
/**
* 建立 junction。Node 没有跨平台的 mklink API,Windows 上退到 cmd。
* 目录联接不需要管理员权限(符号链接才需要)。
*/
function junction(linkPath, targetPath) {
const r = spawnSync('cmd', ['/c', 'mklink', '/J', linkPath, targetPath], {
encoding: 'utf8',
windowsHide: true
})
return r.status === 0
}
function linkAll() {
if (!fs.existsSync(HBX_TC)) {
console.error(`✗ 找不到 HBuilderX 工具链:${HBX_TC}`)
console.error(' 请设置 HBUILDERX_HOME 指向 HBuilderX 安装目录。')
return { ok: false, created: 0 }
}
if (!fs.existsSync(UNI_JS)) {
console.error(`✗ 找不到 uni CLI:${UNI_JS}`)
return { ok: false, created: 0 }
}
fs.mkdirSync(NM, { recursive: true })
let created = 0
// 1) scoped 包:@dcloudio/*、@vue/* 等
const scopes = []
for (const entry of fs.readdirSync(HBX_TC, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name.startsWith('@')) scopes.push(entry.name)
}
for (const scope of scopes) {
const scopeDir = path.join(HBX_TC, scope)
fs.mkdirSync(path.join(NM, scope), { recursive: true })
for (const pkg of fs.readdirSync(scopeDir)) {
const src = path.join(scopeDir, pkg)
if (!fs.statSync(src).isDirectory()) continue
const dest = path.join(NM, scope, pkg)
if (fs.existsSync(dest)) continue
if (junction(dest, src)) created++
}
}
// 2) vue 必须指向 uni-app 打过补丁的运行时。
// 官方 npm vue 3.x 不导出 isInSSRComponentSetup,而 @dcloudio/uni-app
// 从 'vue' 导入它 —— 用原版 vue 构建会直接失败在 rollup 解析阶段。
const patchedVue = path.join(HBX_TC, '@dcloudio', 'uni-h5-vue')
const vueDest = path.join(NM, 'vue')
if (fs.existsSync(patchedVue) && !fs.existsSync(vueDest)) {
if (junction(vueDest, patchedVue)) created++
}
// 3) 顶层工具包(vite / sass / rollup / esbuild ...)。
// sass 在另一个插件目录里。
for (const base of [HBX_TC, HBX_SASS]) {
if (!fs.existsSync(base)) continue
for (const pkg of fs.readdirSync(base)) {
if (pkg.startsWith('@') || pkg.startsWith('.')) continue
const src = path.join(base, pkg)
let stat
try { stat = fs.statSync(src) } catch { continue }
if (!stat.isDirectory()) continue
// 有些包用符号链接指向同目录其它包,跳过避免自指
const dest = path.join(NM, pkg)
if (fs.existsSync(dest)) continue
if (junction(dest, src)) created++
}
}
return { ok: true, created }
}
// ── 构建 ────────────────────────────────────────────────────
function dirSize(dir) {
let total = 0
const stack = [dir]
while (stack.length) {
const d = stack.pop()
let entries
try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch { continue }
for (const e of entries) {
const p = path.join(d, e.name)
if (e.isDirectory()) stack.push(p)
else {
try { total += fs.statSync(p).size } catch {}
}
}
}
return total
}
function countFiles(dir, ext) {
let n = 0
const stack = [dir]
while (stack.length) {
const d = stack.pop()
let entries
try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch { continue }
for (const e of entries) {
if (e.isDirectory()) stack.push(path.join(d, e.name))
else if (e.name.endsWith(ext)) n++
}
}
return n
}
function report(outputDir) {
if (!fs.existsSync(outputDir)) {
console.log(' (无产物目录)')
return
}
const fmt = (n) => (n / 1024).toFixed(1) + ' KB'
console.log(` 产物目录:${path.relative(ROOT, outputDir).replace(/\\/g, '/')}`)
const entries = fs.readdirSync(outputDir, { withFileTypes: true })
.map((e) => {
const p = path.join(outputDir, e.name)
return { name: e.name + (e.isDirectory() ? '/' : ''), size: e.isDirectory() ? dirSize(p) : fs.statSync(p).size }
})
.sort((a, b) => b.size - a.size)
for (const e of entries.slice(0, 10)) {
console.log(` ${e.name.padEnd(24)} ${fmt(e.size)}`)
}
const total = entries.reduce((s, e) => s + e.size, 0)
console.log(` ${'合计'.padEnd(22)} ${fmt(total)}`)
}
function main() {
const argv = process.argv.slice(2)
const linkOnly = argv.includes('--link-only')
const doctorOnly = argv.includes('--doctor')
const wantReport = argv.includes('--report')
const pIdx = argv.indexOf('-p')
const platform = pIdx >= 0 ? argv[pIdx + 1] : 'h5'
console.log('')
console.log('═══════════════════════════════════════════════')
console.log(' 军歌嘹亮 · HBuilderX CLI 构建')
console.log('═══════════════════════════════════════════════')
console.log('')
console.log(` HBuilderX ${HBX}`)
console.log(` 平台 ${platform}`)
// 环境体检:任一缺失都会导致产物异常或构建失败
const hbxNode = HBX_NODE_CANDIDATES.find((p) => fs.existsSync(p))
const issues = []
if (!fs.existsSync(HBX_TC)) issues.push(`缺失工具链目录:${HBX_TC}`)
if (!fs.existsSync(UNI_JS)) issues.push(`缺失 uni CLI:${UNI_JS}`)
if (!hbxNode) issues.push('未找到 HBuilderX 自带 node(plugins/node/node.exe 或 node18)')
const patchedVue = path.join(HBX_TC, '@dcloudio', 'uni-h5-vue')
if (!fs.existsSync(patchedVue)) {
issues.push('未找到 @dcloudio/uni-h5-vue(uni-app 打过补丁的 Vue 运行时)')
}
console.log(` 构建 node ${hbxNode || '(未找到)'}`)
if (issues.length) {
console.log('')
for (const i of issues) console.log(` ✗ ${i}`)
console.log('')
console.log(' 请确认 HBUILDERX_HOME 指向 HBuilderX 安装目录。')
process.exit(1)
}
if (doctorOnly) {
// 顺带验证补丁版 vue 是否真的导出 isInSSRComponentSetup
const vueEs = path.join(patchedVue, 'dist', 'vue.runtime.esm.js')
let patched = false
try {
patched = fs.readFileSync(vueEs, 'utf8').includes('isInSSRComponentSetup')
} catch {}
console.log(` Vue 运行时 ${patched ? '补丁版 OK(含 isInSSRComponentSetup)' : '异常:未检出 isInSSRComponentSetup'}`)
if (!patched) process.exit(1)
console.log('')
console.log('✓ 环境体检通过(--doctor,未执行构建)')
process.exit(0)
}
const { ok, created } = linkAll()
if (!ok) process.exit(1)
console.log(` 工具链联接 新建 ${created} 个(已存在的跳过)`)
console.log('')
if (linkOnly) {
console.log('✓ 联接完成(--link-only,未执行构建)')
process.exit(0)
}
const outputDir = path.join(ROOT, PLATFORM_OUTPUT[platform] || path.join('unpackage/dist/build', platform))
fs.mkdirSync(outputDir, { recursive: true })
const env = {
...process.env,
HBUILDERX_HOME: HBX,
// 缺 HX_APP_ROOT 时 uni-cli-shared 不会启用 HBuilderX 模块解析路径,
// 构建会"成功"但产出 H5 空壳(没有 app.json / 业务分包)。
HX_APP_ROOT: HBX,
UNI_HBUILDERX_PLUGINS: path.join(HBX, 'plugins'),
UNI_INPUT_DIR: ROOT,
UNI_OUTPUT_DIR: outputDir
}
delete env.UNI_PLATFORM // 由 CLI 的 -p 参数决定,预设会干扰平台判定
console.log('编译中…')
const r = spawnSync(hbxNode, [UNI_JS, 'build', '-p', platform], {
cwd: ROOT,
env,
stdio: 'inherit'
})
console.log('')
if (r.status !== 0) {
console.log('✗ 构建失败')
process.exit(r.status || 1)
}
// 产物校验:构建退出码为 0 不代表产物可用。
// 缺 HX_APP_ROOT 时 H5 会输出 index.html 空壳,mp 会缺 app.json。
const mustHave = platform.startsWith('mp-')
? ['app.json', 'app.js', 'app.wxss']
: ['index.html']
const missing = mustHave.filter((f) => !fs.existsSync(path.join(outputDir, f)))
const jsCount = countFiles(outputDir, '.js')
if (missing.length) {
console.log(`✗ 构建报成功但产物不完整,缺少:${missing.join(', ')}`)
console.log(' 多半是 HX_APP_ROOT 未生效(工具链退化为 H5 空壳输出)。')
process.exit(1)
}
if (jsCount === 0) {
console.log('✗ 产物中没有 JS 文件,构建未真正执行。')
process.exit(1)
}
console.log(`✓ 构建完成(${jsCount} 个 JS 文件)`)
if (wantReport) report(outputDir)
console.log('')
}
main()