// WXML 结构扫描器(lib):供 CLI 与 UI 契约门禁共用,避免两份实现漂移。 // // 关键设计:**不能**用「一条懒惰正则」判断标签配对。 // 属性值里可以合法出现 `>`:`subtitle="{{unreadTotal > 0 ? unreadTotal + ' 条未读' : '全部已读'}}"` // 若用 `<(\/?)([a-zA-Z][\w-]*)([^>]*?)(\/?)>`,`[^>]*?` 会在**引号内的 `>`** 处提前收尾, // 于是 `` 的自闭合斜杠被读掉 → 报出「未闭合/错配」的**假阳性**。 // 2026-09-19 首次实现即踩此坑(把 messages.wxml 误判为坏文件),故改为按引号状态机逐字符扫描。 // // 本模块只做**结构**判断:标签名与嵌套关系。它不解析属性语义、不校验组件是否存在。 import { readFileSync, readdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; export const SKIP_DIRS = new Set(['node_modules', '.git', 'miniprogram_npm', 'dist', 'build']); /** 递归收集目录下的 .wxml(跳过 SKIP_DIRS) */ export function walkWxml(dir, out = []) { let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; } for (const e of entries) { const p = join(dir, e.name); if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) walkWxml(p, out); } else if (e.name.endsWith('.wxml')) { out.push(p); } } return out; } /** * 按引号状态机扫描出全部标签。 * @returns {{tag:string, close:boolean, selfClose:boolean, index:number, line:number}[]} */ export function scanTags(src) { const tags = []; let i = 0; let line = 1; while (i < src.length) { const ch = src[i]; if (ch === '\n') { line++; i++; continue; } // 注释 整体跳过(其中的 < > 不参与配对) if (src.startsWith('', i + 4); const stop = end === -1 ? src.length : end + 3; for (let k = i; k < stop; k++) if (src[k] === '\n') line++; i = stop; continue; } if (ch !== '<') { i++; continue; } const startLine = line; const startIdx = i; i++; // 吃掉 '<' let close = false; if (src[i] === '/') { close = true; i++; } // 非标签起始(裸文本里的 "<")→ 当作普通字符跳过 if (!/[a-zA-Z]/.test(src[i] || '')) continue; let name = ''; while (i < src.length && /[\w:.-]/.test(src[i])) name += src[i++]; // 属性区:引号内的一切(含 '>')都不算标签结束 let quote = null; let selfClose = false; while (i < src.length) { const c = src[i]; if (c === '\n') line++; if (quote) { if (c === quote) quote = null; i++; continue; } if (c === '"' || c === "'") { quote = c; i++; continue; } if (c === '/' && src[i + 1] === '>') { selfClose = true; i += 2; break; } if (c === '>') { i++; break; } i++; } tags.push({ tag: name, close, selfClose, index: startIdx, line: startLine }); } return tags; } /** * 校验 WXML 结构。 * @returns {string[]} 错误描述(空数组 = 结构合法) */ export function checkWxml(src) { const stack = []; const errors = []; for (const t of scanTags(src)) { if (t.selfClose) continue; if (t.close) { const top = stack.pop(); if (!top) { errors.push(`第 ${t.line} 行:多余的闭合标签 (此时没有任何未闭合的标签)`); break; } if (top.tag !== t.tag) { errors.push( `第 ${t.line} 行:闭合标签 与栈顶 <${top.tag}>(第 ${top.line} 行)不匹配`, ); } } else { stack.push(t); } } if (stack.length) { errors.push( `文件结束时仍有 ${stack.length} 个标签未闭合:` + stack.map((s) => `<${s.tag}>@第${s.line}行`).join('、'), ); } return errors; } export function isFile(p) { try { return statSync(p).isFile(); } catch { return false; } } export { readFileSync };