需求:小程序端代码要同时同步到服务器与 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 合并会把密码永久写入本仓库历史,故采用快照方式。
203 行
7.1 KiB
JavaScript
203 行
7.1 KiB
JavaScript
/**
|
||
* 静态校验脚本(结构体检)
|
||
* 校验内容:
|
||
* 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
|