文件
fenleixinxi/uniapp/tests/validate.mjs
linda 376a7ed793 feat(uniapp): 小程序端(用户端 uni-app)源码入库到 uniapp/ 目录
需求:小程序端代码要同时同步到服务器与 git 远程仓库,此前前端仅存在于本地目录(无 remote,从未推送),
服务器与 gitcode 上只有后端。现并入本仓库,复用现有「push 即部署」链路,一次推送双端落地。

- 新增 uniapp/:小程序端源码全量快照(150 文件 / 1.2M)
  pages(13 页面) · components(fc-navbar/fc-tabbar/fc-post-card/fc-empty) ·
  common(api·utils·mock·assets·store·config) · static(74 图标 + 3 轮播插画 + LOGO) ·
  docs(01-04 交付文档) · tests(零依赖全链路用例) · tools(图标/主题/轮播配图生成脚本)
- 明确排除:AGENTS.md(含后台密码、服务器 root 密码、小程序 AppSecret,切勿入库)、
  unpackage/(构建产物,由 uniapp/.gitignore 忽略)、node_modules、.DS_Store、*.log
- .gitignore 新增 /uniapp/AGENTS.md 兜底(与根 /AGENTS.md、/fluniapp/ 同一约定)
- README.md 增补「本项目结构 + 部署链路」章节,说明 uniapp/ 为源码快照、
  小程序真正上线仍需微信开发者工具 cli upload

说明:本次为源码快照,未携带前端本地仓库历史——其历史提交中含 AGENTS.md 凭据,
若做 subtree 合并会把密码永久写入本仓库历史,故采用快照方式。
2026-10-17 14:19:03 +08:00

203 行
7.1 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

/**
* 静态校验脚本(结构体检)
* 校验内容:
* 1. pages.json 中声明的页面文件是否存在
* 2. 每个 .vue 是否包含成对的 <template>/<script>/<style>
* 3. <style> 块内大括号是否配平(SCSS 语法粗略校验)
* 4. .vue 文件里 import 的本地模块是否存在
* 运行: node tests/validate.mjs
*/
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import url from 'node:url'
import { execFileSync } from 'node:child_process'
const __dirname = path.dirname(url.fileURLToPath(import.meta.url))
const ROOT = path.resolve(__dirname, '..')
let errors = 0
let warns = 0
const ok = (m) => console.log(` ✅ ${m}`)
const bad = (m) => {
errors++
console.log(` ❌ ${m}`)
}
const warn = (m) => {
warns++
console.log(` ⚠️ ${m}`)
}
function readJsonLoose(p) {
const raw = fs.readFileSync(p, 'utf8')
return JSON.parse(raw.replace(/\/\/.*$/gm, ''))
}
function walk(dir, ext, out = []) {
for (const name of fs.readdirSync(dir)) {
if (name === 'node_modules' || name === '.git' || name === 'unpackage') continue
const full = path.join(dir, name)
const st = fs.statSync(full)
if (st.isDirectory()) walk(full, ext, out)
else if (name.endsWith(ext)) out.push(full)
}
return out
}
console.log('\n=========== 项目结构校验 ===========')
// 1. pages.json 页面存在性
console.log('\n[1] pages.json 页面文件存在性')
const pagesJson = readJsonLoose(path.join(ROOT, 'pages.json'))
for (const p of pagesJson.pages) {
const file = path.join(ROOT, p.path + '.vue')
if (fs.existsSync(file)) ok(`${p.path}.vue`)
else bad(`缺失页面文件: ${p.path}.vue`)
}
// 2 & 3. vue 结构与大括号配平
console.log('\n[2] .vue 结构(template/script/style)与大括号配平')
const vues = walk(ROOT, '.vue')
for (const f of vues) {
const rel = path.relative(ROOT, f)
const src = fs.readFileSync(f, 'utf8')
const tOpen = (src.match(/<template>/g) || []).length
const tClose = (src.match(/<\/template>/g) || []).length
const sOpen = (src.match(/<script>/g) || []).length
const sClose = (src.match(/<\/script>/g) || []).length
const yOpen = (src.match(/<style[^>]*>/g) || []).length
const yClose = (src.match(/<\/style>/g) || []).length
if (tOpen !== tClose || sOpen !== sClose || yOpen !== yClose) {
bad(`${rel} 标签不配对 template(${tOpen}/${tClose}) script(${sOpen}/${sClose}) style(${yOpen}/${yClose})`)
continue
}
if (sOpen !== 1 || yOpen !== 1 || (tOpen !== 1 && path.basename(f) !== 'App.vue')) {
warn(`${rel} 标签数量异常(可能为多根节点)`)
}
// style 块大括号配平
const styleMatch = src.match(/<style[^>]*>([\s\S]*)<\/style>/)
if (styleMatch) {
const css = styleMatch[1]
let depth = 0
let minDepth = 0
for (const ch of css) {
if (ch === '{') depth++
else if (ch === '}') {
depth--
if (depth < minDepth) minDepth = depth
}
}
if (depth !== 0 || minDepth < 0) bad(`${rel} <style> 大括号不配平(depth=${depth}, min=${minDepth})`)
else ok(`${rel}`)
} else {
ok(`${rel}(无 style)`)
}
}
// 4. import 相对/别名路径存在性
console.log('\n[3] import 依赖存在性(@/ 与 相对路径)')
const jsFiles = [...walk(ROOT, '.js'), ...walk(ROOT, '.mjs'), ...vues]
for (const f of jsFiles) {
if (f.includes(path.join('tests', 'validate.mjs'))) continue
const src = fs.readFileSync(f, 'utf8')
const rel = path.relative(ROOT, f)
const re = /from\s+['"]([^'"]+)['"]/g
let m
while ((m = re.exec(src))) {
const spec = m[1]
if (!spec.startsWith('.') && !spec.startsWith('@/')) continue // 第三方包跳过
let target = spec.startsWith('@/')
? path.join(ROOT, spec.slice(2))
: path.resolve(path.dirname(f), spec)
const candidates = [target, target + '.js', target + '.mjs', target + '.vue', path.join(target, 'index.js')]
if (!candidates.some((c) => fs.existsSync(c))) bad(`${rel} → 找不到依赖 "${spec}"`)
}
}
ok('依赖检查完成')
// 5. <script> JS 语法校验(node --check)
console.log('\n[4] <script> JS 语法校验')
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fc-check-'))
for (const f of vues) {
const rel = path.relative(ROOT, f)
const m = fs.readFileSync(f, 'utf8').match(/<script>([\s\S]*?)<\/script>/)
if (!m) continue
const tmp = path.join(tmpDir, path.basename(f).replace(/\.vue$/, '') + '.mjs')
fs.writeFileSync(tmp, m[1], 'utf8')
try {
execFileSync(process.execPath, ['--check', tmp], { stdio: 'pipe' })
ok(`${rel} <script>`)
} catch (e) {
const msg = String(e.stderr || e.message).split('\n').find((l) => l.includes('Error')) || 'SyntaxError'
bad(`${rel} <script> 语法错误 → ${msg.trim()}`)
}
}
fs.rmSync(tmpDir, { recursive: true, force: true })
// 6. 模板标签配平(模拟 WXML 结构校验)
console.log('\n[5] <template> 标签配平(WXML 结构)')
const VOID_TAGS = new Set(['input', 'br', 'img', 'slot'])
for (const f of vues) {
const rel = path.relative(ROOT, f)
const src = fs.readFileSync(f, 'utf8')
const tm = src.match(/<template>([\s\S]*)<\/template>/)
if (!tm) continue
const body = tm[1]
const stack = []
const re = /<(\/?)([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>/g
let m
let err = null
while ((m = re.exec(body))) {
const closing = m[1] === '/'
const tag = m[2]
const selfClose = m[4] === '/'
if (selfClose || VOID_TAGS.has(tag)) continue
if (!closing) {
stack.push(tag)
} else {
const top = stack.pop()
if (top !== tag) {
err = `标签不匹配:</${tag}> 对应 <${top || '空'}>`
break
}
}
}
if (err) bad(`${rel} → ${err}`)
else if (stack.length) bad(`${rel} → 未闭合标签:<${stack.join('>, <')}>`)
else ok(`${rel}`)
}
// 6. 禁止把「URL / 路径类字段」用 <text> 裸输出
// 踩坑记录:pages/post/create.vue 曾用 <text>{{ c.icon }}</text> 渲染分类图标,
// 而接口返回的 icon 是图片地址(/static/icons/cat-job.png),
// 结果把路径当文字打印出来(8 条路径重叠)。此类字段必须用 <image :src>。
// 判定口径:只报「裸字段输出」;
// 若表达式中含字符串字面量(如 '当前:' + x),说明是刻意带标签展示,不报。
console.log('\n[6] URL/路径字段误用 <text> 裸输出')
const TEXT_INTERP_RE = /<text\b[^>]*>\s*\{\{([^}]*)\}\}\s*<\/text>/g
const URL_FIELD_WORD = /\.(?:icon|iconKey|cover|logo|avatar|image|poster|posterUrl)\b/
let urlFieldHits = 0
for (const f of vues) {
const rel = path.relative(ROOT, f)
const src = fs.readFileSync(f, 'utf8')
const tm = src.match(/<template>([\s\S]*)<\/template>/)
if (!tm) continue
let m
TEXT_INTERP_RE.lastIndex = 0
while ((m = TEXT_INTERP_RE.exec(tm[1]))) {
const expr = m[1].trim()
if (!URL_FIELD_WORD.test(expr)) continue
if (/['"]/.test(expr)) continue
urlFieldHits++
bad(`${rel} → 把 URL 字段当文本输出:${m[0].replace(/\s+/g, ' ').slice(0, 80)}`)
}
}
if (!urlFieldHits) ok('未发现把 URL/路径字段用 <text> 裸输出的地方')
console.log('\n------------------------------------------')
console.log(`结构校验结束:错误 ${errors},警告 ${warns}`)
console.log('------------------------------------------\n')
if (errors) process.exitCode = 1