30b190363e
## Bug 上一批把 9 个记录分支从弹层整体搬到 addrecord 页时,连带删掉了带 wx:if 的 第一支(weight),整条链从 wx:elif 开头。小程序直接报 Bad attr `wx:elif` with message: `wx:if not found, then something must be wrong` 整个组件编译不过 —— 不是少个样式,是弹层完全打不开。把现在的链首 (就医前摘要)改成 wx:if。 ## 检查器补这一条 这种错「删对了 9 支、漏改 1 个属性」就会发生,而且是编译期爆炸, 检查器原来完全看不见。 判断必须靠真正的标签栈。中间走错两次,都值得写下来: **① 按缩进深度分链 —— 不行。** 这个项目里 scroll-view 和它的子节点缩进 是一样深的,「同层」判断会把父节点当兄弟节点。而且更糟的是它不报错: 前一条链的 wx:if 被当成这一条的链首,注入错误也查不出来,是个假绿。 **② 给 void 标签列白名单 —— 不行。** wxml 里 <image></image> 是显式闭合的, 把它当 void 就变成「开标签不入栈、闭标签却弹栈」,一路把父级弹光,8 个误报。 只认 /> 就够。 **③ 自闭合不能用单独的捕获组。** 属性那段是贪婪的,会把 /> 里的 / 一起吃掉, (\/?) 永远匹配空。多行写的 <input ... /> 因此被当成非自闭合入了栈, 把整份栈层级带偏,7 个误报。改成从 attrs 尾部 /\/\s*$/ 判断。 ## 自测 把链首改回 wx:elif → 精确报第 96 行那一处,其余 24 个 wxml 零误报; 还原 → 恢复绿。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
170 lines
8.4 KiB
JavaScript
170 lines
8.4 KiB
JavaScript
// 用法:node scripts/check.js(在 pets-fe 目录下跑,或直接给全路径)
|
||
//
|
||
// 静态自查:class 有没有人定义、而且是不是「在能生效的地方」定义、pt-icon 的 name
|
||
// 是否存在、绑的事件方法是否存在、组件是否已注册。
|
||
//
|
||
// 防的是「漏改一处 → 元素完全裸奔」这种不报错、不在 diff 里、只在特定页面/弹层才暴露
|
||
// 的问题。这个项目已经踩过两次同一个坑:.post-card 留在 community.wxss 却被用户主页
|
||
// 用(帖子没样式)、.profile-row 留在 settings.wxss 却被「我的」用(入口整个散架)。
|
||
// 所以 class 检查是按作用域来的,不是全局并一起看——那样恰好检查不出这类问题。
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const FE = path.resolve(__dirname, '..');
|
||
const walk = (d, out = []) => {
|
||
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
||
const p = path.join(d, e.name);
|
||
if (e.isDirectory()) { if (!['node_modules', '.git'].includes(e.name)) walk(p, out); }
|
||
else out.push(p);
|
||
}
|
||
return out;
|
||
};
|
||
const files = walk(FE);
|
||
const read = (p) => fs.readFileSync(p, 'utf8');
|
||
const rel = (p) => path.relative(FE, p);
|
||
|
||
// ── 一个 wxss 里定义的 class,跟着 @import 一起收 ────────────────────────────
|
||
function classesOf(wxss, seen = new Set()) {
|
||
const out = new Set();
|
||
if (!fs.existsSync(wxss) || seen.has(wxss)) return out;
|
||
seen.add(wxss);
|
||
const src = read(wxss);
|
||
for (const m of src.matchAll(/\.([a-zA-Z][\w-]*)/g)) out.add(m[1]);
|
||
for (const m of src.matchAll(/@import\s+["']([^"']+)["']/g)) {
|
||
for (const c of classesOf(path.resolve(path.dirname(wxss), m[1]), seen)) out.add(c);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ── 作用域 ─────────────────────────────────────────────────────────────────
|
||
// app.wxss 是全局的;页面 wxss 只对自己那一页生效;组件 wxss 只对组件自己的节点生效。
|
||
// 页面之间互不可见——这正是前面两次事故的成因。
|
||
const GLOBAL = classesOf(path.join(FE, 'app.wxss'));
|
||
|
||
// 谁定义了这个 class,报错时好指路
|
||
const whereDefined = new Map();
|
||
for (const f of files.filter((f) => f.endsWith('.wxss'))) {
|
||
for (const c of classesOf(f)) {
|
||
if (!whereDefined.has(c)) whereDefined.set(c, []);
|
||
whereDefined.get(c).push(rel(f));
|
||
}
|
||
}
|
||
|
||
const iconNames = new Set();
|
||
for (const m of read(path.join(FE, 'styles/iconfont.wxss')).matchAll(/\.pt-i-([\w-]+)\{/g)) iconNames.add(m[1]);
|
||
|
||
const problems = [];
|
||
for (const f of files.filter((f) => f.endsWith('.wxml'))) {
|
||
const src = read(f);
|
||
const r = rel(f);
|
||
const isComponent = /^(components|custom-tab-bar)\//.test(r);
|
||
|
||
// 这个 wxml 能看到的 class:自己那个同名 wxss,加上全局(组件要开了 addGlobalClass 才有)
|
||
const own = classesOf(f.replace(/\.wxml$/, '.wxss'));
|
||
const js = f.replace(/\.wxml$/, '.js');
|
||
const seesGlobal = !isComponent ||
|
||
(fs.existsSync(js) && read(js).includes('addGlobalClass'));
|
||
const scope = seesGlobal ? new Set([...own, ...GLOBAL]) : own;
|
||
|
||
for (const m of src.matchAll(/class="([^"]*)"/g)) {
|
||
// 先把 {{...}} 整段挖掉(里面是表达式不是 class),再看剩下的静态部分;
|
||
// 表达式里的三元字面量 'active' 单独收集
|
||
const dyn = [...m[1].matchAll(/\{\{([^}]*)\}\}/g)].map((x) => x[1]).join(' ');
|
||
const statics = m[1].replace(/\{\{[^}]*\}\}/g, ' ');
|
||
const names = statics.split(/\s+/).filter(Boolean);
|
||
// 只认三元表达式里的字面量(? 'x' : 'y');wxs 函数的字符串入参不是 class
|
||
for (const q of dyn.matchAll(/[?:]\s*'([\w-]+)'/g)) names.push(q[1]);
|
||
// 纯布局壳,没样式是故意的
|
||
const LAYOUT_ONLY = new Set(['greeting', 'tab-text', 'aiPlan']);
|
||
for (const raw of names) {
|
||
if (LAYOUT_ONLY.has(raw)) continue;
|
||
// class="row-{{item.role}}" 挖掉表达式后剩个 row-,是拼接前缀不是类名
|
||
if (raw.endsWith('-')) continue;
|
||
if (scope.has(raw)) continue;
|
||
const at = whereDefined.get(raw);
|
||
if (at && at.length) {
|
||
// 最坑的一种:class 有定义,但定义在别的页面/组件里,这一页拿不到,元素裸奔
|
||
problems.push(`${r}: class "${raw}" 只定义在 ${at.join(' / ')} —— 页面级 wxss 跨不了页,这里是裸的。两页共用就提到 app.wxss`);
|
||
} else {
|
||
problems.push(`${r}: class "${raw}" 没有任何 wxss 定义`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// wx:if / wx:elif / wx:else 链的第一支必须是 wx:if。
|
||
// 删掉链首那一支(比如把 9 个记录分支整体搬走)会让整条链从 wx:elif 开头,
|
||
// 小程序报「wx:if not found」直接编译不过——整个组件一起挂,不是少个样式。
|
||
//
|
||
// 判断必须靠真正的标签栈,不能靠缩进:这个项目里 scroll-view 和它的子节点
|
||
// 缩进是一样深的,按缩进判「同层」会把父节点当成兄弟节点,什么都查不出来。
|
||
{
|
||
const noComment = src.replace(/<!--[\s\S]*?-->/g, '');
|
||
const stack = [{ last: null }]; // 每层记「上一个兄弟节点带的条件指令」
|
||
const re = /<(\/?)([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g;
|
||
let m;
|
||
while ((m = re.exec(noComment))) {
|
||
const [, closing, tag, rawAttrs] = m;
|
||
// 自闭合要从 attrs 尾部判断:属性那段是贪婪的,会把 /> 里的 / 一起吃掉,
|
||
// 单独用一个 (\/?) 捕获组永远是空的。多行写的 <input ... /> 就是这么
|
||
// 被当成非自闭合、入了栈,把整份栈的层级全带偏,满屏误报。
|
||
const selfClose = /\/\s*$/.test(rawAttrs);
|
||
const attrs = rawAttrs.replace(/\/\s*$/, '');
|
||
const top = stack[stack.length - 1];
|
||
if (closing) {
|
||
if (stack.length > 1) stack.pop();
|
||
continue;
|
||
}
|
||
const cond = /\bwx:if\b/.test(attrs) ? 'if'
|
||
: /\bwx:elif\b/.test(attrs) ? 'elif'
|
||
: /\bwx:else\b/.test(attrs) ? 'else' : '';
|
||
if (cond === 'elif' || cond === 'else') {
|
||
if (top.last !== 'if' && top.last !== 'elif') {
|
||
const line = noComment.slice(0, m.index).split('\n').length;
|
||
problems.push(`${r}: 第 ${line} 行附近 <${tag} wx:${cond}> 前面没有 wx:if` +
|
||
` —— 链首被删了,这个文件编译不过`);
|
||
}
|
||
}
|
||
// 没带条件的兄弟节点会把链断开,所以无条件覆盖
|
||
top.last = cond || null;
|
||
// 不搞「void 标签」白名单:wxml 里 <image></image> 是显式闭合的,
|
||
// 把它当 void 就会「开标签不入栈、闭标签却弹栈」,一路把父级弹光,
|
||
// 于是满屏误报。只认 /> 自闭合,其余一律入栈。
|
||
if (!selfClose) stack.push({ last: null });
|
||
}
|
||
}
|
||
|
||
// 事件绑定的方法必须真的存在——绑了个不存在的方法,点了没反应且不报错
|
||
if (fs.existsSync(js)) {
|
||
const code = read(js);
|
||
const handlers = new Set();
|
||
for (const m of src.matchAll(/\b(?:bind|catch)(?::)?[a-z]+="([A-Za-z_$][\w$]*)"/g)) handlers.add(m[1]);
|
||
for (const h of handlers) {
|
||
if (!new RegExp(`(^|[\\s,{])${h}\\s*[(:]`, 'm').test(code)) {
|
||
problems.push(`${r}: 绑了 ${h},但 ${path.basename(js)} 里没有这个方法 —— 点了没反应`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// pt-icon 的静态 name
|
||
for (const m of src.matchAll(/<pt-icon[^>]*name="([^"{]*)"/g)) {
|
||
if (m[1] && !iconNames.has(m[1])) problems.push(`${r}: pt-icon name="${m[1]}" 图标不存在`);
|
||
}
|
||
// 用到的自定义组件是否在同名 .json 里注册
|
||
const json = f.replace(/\.wxml$/, '.json');
|
||
const reg = fs.existsSync(json) ? Object.keys(JSON.parse(read(json)).usingComponents || {}) : [];
|
||
const known = ['nav-bar', 'pet-switch', 'bottom-sheet', 'fab', 'pt-icon', 'seg-tabs', 'stat-ring', 'profile-head'];
|
||
for (const tag of known) {
|
||
if (new RegExp('<' + tag + '[\\s>]').test(src) && !reg.includes(tag)) {
|
||
problems.push(`${r}: 用了 <${tag}> 但 ${path.basename(json)} 没有注册`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (problems.length) {
|
||
console.log('发现 ' + problems.length + ' 处问题:');
|
||
problems.forEach((p) => console.log(' ✗ ' + p));
|
||
process.exit(1);
|
||
}
|
||
console.log('✓ class 作用域 / 图标名 / 事件方法 / 组件注册 全部对得上(检查了 ' +
|
||
files.filter((f) => f.endsWith('.wxml')).length + ' 个 wxml,' + iconNames.size + ' 个图标)');
|