├── .gitignore ├── LICENSE ├── README.md ├── babel.config.js ├── build.config.ts ├── dist ├── chunks │ └── parser.mjs ├── cli.d.ts ├── cli.mjs ├── index.d.ts └── index.mjs ├── doc └── assets │ ├── 00_follow_the_guide.png │ └── 01_results.png ├── jest.config.ts ├── package-lock.json ├── package.json ├── src ├── cli.ts ├── index.ts ├── parser.ts ├── patch │ └── index.ts ├── rules │ ├── box-sizing │ │ └── index.ts │ ├── darkmode │ │ └── index.ts │ ├── disable-scroll │ │ └── index.ts │ ├── display-flex │ │ └── index.ts │ ├── display-inline │ │ └── index.ts │ ├── interface.ts │ ├── mark-wx-for │ │ └── index.ts │ ├── navigator │ │ └── index.ts │ ├── no-calc │ │ └── index.ts │ ├── no-css-animation │ │ └── index.ts │ ├── no-inline-text │ │ └── index.ts │ ├── no-native-nav │ │ └── index.ts │ ├── no-pseudo │ │ └── index.ts │ ├── no-svg-style-tag │ │ └── index.ts │ ├── position-fixed │ │ └── index.ts │ ├── renderer-skyline │ │ └── index.ts │ ├── scroll-view │ │ └── index.ts │ ├── templates │ │ ├── json.ts │ │ └── no-inline.ts │ ├── text-overflow-ellipse │ │ └── index.ts │ ├── unsupported-component │ │ └── index.ts │ └── weui-extendedlib │ │ └── index.ts ├── serializer │ ├── css.ts │ ├── html.ts │ ├── interface.ts │ └── json.ts ├── utils │ ├── collect-template.ts │ ├── collect-wxss.ts │ ├── css-ast.ts │ ├── dom-ast.ts │ ├── patch.ts │ ├── print-code.ts │ ├── readline.ts │ └── resolve.ts └── walker │ ├── css.ts │ ├── html.ts │ ├── interface.ts │ └── json.ts ├── third-party-licenses.txt └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | pnpm-lock.yaml -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2022 maniacata 2 | 3 | Permission is hereby granted, free of 4 | charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice 12 | (including the next paragraph) shall be included in all copies or substantial 13 | portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Skylint:Skyline 小程序迁移工具 2 | 3 | 为帮助开发者迁移原有的 WebView 小程序到 Skyline,我们提供迁移工具 Skylint。Skylint 基于抽象语法树和一系列预设规则,分析小程序源代码中可能存在的兼容性和性能问题。 4 | 5 | ## 安装 6 | 7 | 使用任意 npm 包管理工具全局安装 Skylint : 8 | 9 | ```shell 10 | $ npm i -g skylint@latest 11 | ``` 12 | 13 | ## 使用 14 | 15 | **建议使用 Node 16 及以上版本。** 16 | 17 | 在需要迁移的工程根目录(即包含 `app.json` 的目录)执行: 18 | 19 | ```shell 20 | $ skylint 21 | ``` 22 | 23 | 随后跟随交互式指引配置 `app.json` 和要迁移的页面,Skylint 会逐个分析所选页面及其引用的模板和样式文件。 24 | 25 | ![00_follow_the_guide](./doc/assets/00_follow_the_guide.png) 26 | 27 | Skylint 会依照预设的一系列规则分析源代码,提示可能存在的兼容性和性能问题,并给出修改建议。对于部分规则,Skylint 提供可选的自动修复功能。 28 | 29 | ![01_follow_the_guide](./doc/assets/01_results.png) 30 | 31 | Skylint 所检查出的问题分为四个等级,分别是: 32 | 33 | * [3] **Error**:明确有问题,需要修复,显示为红色; 34 | 35 | * [2] **Warn**:大概率有问题,视具体情况而定,显示为黄色; 36 | 37 | * [1] **Info**:可能有问题,也可能不影响适配,或不影响使用,显示为蓝色; 38 | 39 | * [0] **Verbose**:不改也能正常运行,但强烈建议修改,显示为青色。 40 | 41 | 对于日志量较大的情况,Skylint 支持一组命令行参数,允许按日志等级、文件名和规则名过滤输出。例如: 42 | 43 | ```bash 44 | # 仅显示 Warn 和 Error,并排除以 weui.wxss 为结尾的文件 45 | $ skylint --log-level 2 --exclude "weui\.wxss$" 46 | 47 | # 忽略 no-pseudo-class 和 no-pseudo-element 两条规则,注意需要完整规则名 48 | $ skylint --ignore "no-pseudo-class, no-pseudo-element" 49 | 50 | # 更多用法可以查看帮助 51 | $ skylint -h 52 | ``` 53 | 54 | ## 预设规则 55 | 56 | 下面列出了目前 Skylint 的预设规则,更多规则会陆续添加。 57 | 58 | | 规则名 | 说明 | 日志等级 | 59 | | --------------------- | ------------------------------------------- | -------- | 60 | | disable-scroll | 不支持页面全局滚动 | Error | 61 | | flex-direction | flex 布局下未显示指定 flex-direction | Error | 62 | | form | 暂不支持 form 组件 | Error | 63 | | inline-text | 多段文本内联只能使用 text 组件包裹 | Error | 64 | | movable-view | 不支持 movable-view 组件 | Error | 65 | | no-native-nav | 不支持原生导航栏 | Error | 66 | | no-svg-style-tag | svg 不支持 style 标签 | Error | 67 | | scroll-view-type | scroll-view 未显示指定 type 类型 | Error | 68 | | useExtendedLib | 暂不支持 useExtendedLib 扩展库 | Error | 69 | | navigator | navigator 组件只能嵌套文本 | Warn | 70 | | scroll-view-not-found | 当前页面未使用 scroll-view 组件 | Warn | 71 | | text-overflow-ellipse | text-overflow: ellipse 只在 text 组件下生效 | Warn | 72 | | scroll-view-x-y | scroll-view 暂不支持水平垂直方向同时滚动 | Info | 73 | | mark-wx-for | 未打开样式共享标记 | Verbose | 74 | | scroll-view-optimize | 未能充分利用 scroll-view 按需渲染的机制 | Verbose | 75 | | video | 暂只支持基础播放功能 | Verbose | 76 | 77 | ## 反馈 78 | 79 | 在使用中遇到问题,或对 Skylint 有功能改进的建议,欢迎在本仓库发表 issue。 -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [["@babel/preset-env", { targets: { node: "current" } }], "@babel/preset-typescript"], 3 | }; 4 | -------------------------------------------------------------------------------- /build.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { defineBuildConfig } from "unbuild"; 3 | 4 | export default defineBuildConfig({ 5 | entries: ["./src/index", "./src/cli"], 6 | rootDir: "./", 7 | rollup: { 8 | alias: { 9 | entries: { 10 | src: path.resolve("./src"), 11 | }, 12 | }, 13 | }, 14 | outDir: "dist", 15 | declaration: true, 16 | }); 17 | -------------------------------------------------------------------------------- /dist/chunks/parser.mjs: -------------------------------------------------------------------------------- 1 | import { walk as walk$3, parse as parse$1 } from 'css-tree'; 2 | import { parseDocument } from 'htmlparser2'; 3 | import parseJSON from 'json-to-ast'; 4 | import MagicString from 'magic-string'; 5 | import { hasChildren } from 'domhandler'; 6 | 7 | var PatchStatus = /* @__PURE__ */ ((PatchStatus2) => { 8 | PatchStatus2[PatchStatus2["Pending"] = 0] = "Pending"; 9 | PatchStatus2[PatchStatus2["Applied"] = 1] = "Applied"; 10 | PatchStatus2[PatchStatus2["Failed"] = 2] = "Failed"; 11 | return PatchStatus2; 12 | })(PatchStatus || {}); 13 | const sortPatchesByLoc = (patches) => patches.sort((a, b) => { 14 | return a.loc.start - b.loc.start; 15 | }); 16 | const applyPatchesOnString = (rawString, patches) => { 17 | const str = new MagicString(rawString); 18 | const sortedPatches = sortPatchesByLoc(patches); 19 | const nonOverLappedPatches = sortedPatches; 20 | const len = nonOverLappedPatches.length; 21 | for (let i = 0; i < len; i++) { 22 | const { loc, patchedStr } = sortedPatches[i]; 23 | const range = loc.end - loc.start; 24 | if (range === 0) { 25 | str.appendRight(loc.start, patchedStr); 26 | } else if (range > 0) { 27 | str.overwrite(loc.start, loc.end, patchedStr); 28 | } 29 | } 30 | return str; 31 | }; 32 | 33 | var RuleLevel = /* @__PURE__ */ ((RuleLevel2) => { 34 | RuleLevel2[RuleLevel2["Verbose"] = 0] = "Verbose"; 35 | RuleLevel2[RuleLevel2["Info"] = 1] = "Info"; 36 | RuleLevel2[RuleLevel2["Warn"] = 2] = "Warn"; 37 | RuleLevel2[RuleLevel2["Error"] = 3] = "Error"; 38 | return RuleLevel2; 39 | })(RuleLevel || {}); 40 | var RuleType = /* @__PURE__ */ ((RuleType2) => { 41 | RuleType2[RuleType2["Unknown"] = 0] = "Unknown"; 42 | RuleType2[RuleType2["WXML"] = 1] = "WXML"; 43 | RuleType2[RuleType2["WXSS"] = 2] = "WXSS"; 44 | RuleType2[RuleType2["Node"] = 3] = "Node"; 45 | RuleType2[RuleType2["JSON"] = 4] = "JSON"; 46 | return RuleType2; 47 | })(RuleType || {}); 48 | const defineRule = (info, init) => (env) => { 49 | const { name, type } = info; 50 | let hooks = {}; 51 | let results = []; 52 | let patches = []; 53 | let astPatches = []; 54 | const lifetimes = (newHooks) => { 55 | hooks = { ...hooks, ...newHooks }; 56 | }; 57 | const addResult = (...newResults) => { 58 | results.push(...newResults); 59 | }; 60 | const addResultWithPatch = (result, patch) => { 61 | const { name: name2 } = result; 62 | results.push(result); 63 | patches.push({ 64 | ...patch, 65 | name: name2, 66 | status: PatchStatus.Pending 67 | }); 68 | }; 69 | const addASTPatch = (...newPatches) => astPatches.push(...newPatches); 70 | const addPatch = (...newPatches) => patches.push( 71 | ...newPatches.map((patch) => ({ 72 | ...patch, 73 | status: PatchStatus.Pending 74 | })) 75 | ); 76 | const getRelatedWXMLFilename = () => env.path.replace(/(?!\.)(wxml|json|wxss)$/, "wxml"); 77 | const getRelatedWXMLAst = () => { 78 | if (!env.astMap) 79 | return null; 80 | return env.astMap.get(getRelatedWXMLFilename()) ?? null; 81 | }; 82 | init({ 83 | lifetimes, 84 | addASTPatch, 85 | addPatch, 86 | addResult, 87 | addResultWithPatch, 88 | getRelatedWXMLFilename, 89 | getRelatedWXMLAst, 90 | env 91 | }); 92 | return { 93 | name, 94 | type, 95 | get results() { 96 | return results; 97 | }, 98 | get astPatches() { 99 | return astPatches; 100 | }, 101 | get patches() { 102 | return patches; 103 | }, 104 | clear() { 105 | }, 106 | ...hooks 107 | }; 108 | }; 109 | const createResultItem = (params) => { 110 | return { 111 | level: 2 /* Warn */, 112 | ...params 113 | }; 114 | }; 115 | 116 | const elementType = { 117 | Root: "root", 118 | Text: "text", 119 | Directive: "directive", 120 | Comment: "comment", 121 | Script: "script", 122 | Style: "style", 123 | Tag: "tag", 124 | CDATA: "cdata", 125 | Doctype: "doctype" 126 | }; 127 | const isType$2 = (node, type) => { 128 | return node.type === elementType[type]; 129 | }; 130 | const walk$2 = (node, callback, ctx = { parent: node }) => { 131 | if (!ctx) 132 | ctx = { parent: node }; 133 | if (callback(node, ctx) === false) { 134 | return false; 135 | } else { 136 | if (hasChildren(node)) { 137 | for (const childNode of node.childNodes ?? []) { 138 | const ret = walk$2(childNode, callback, { ...ctx, parent: node }); 139 | if (ret === false) 140 | return ret; 141 | } 142 | } 143 | return true; 144 | } 145 | }; 146 | 147 | const isType$1 = (node, type) => { 148 | return node.type === type; 149 | }; 150 | const walk$1 = (node, callback) => { 151 | return walk$3(node, function(node2) { 152 | const self = this; 153 | callback(node2, self); 154 | }); 155 | }; 156 | 157 | const isType = (node, type) => { 158 | return node.type === type; 159 | }; 160 | const walk = (node, callback, ctx = { parent: node }) => { 161 | if (!ctx) 162 | ctx = { parent: node }; 163 | if (callback(node, ctx) === false) { 164 | return false; 165 | } else { 166 | const newCtx = { parent: node }; 167 | if ("children" in node) { 168 | for (const childNode of node.children ?? []) { 169 | if (walk(childNode, callback, { ...newCtx }) === false) 170 | return false; 171 | } 172 | } else if (isType(node, "Property")) { 173 | if (walk(node.key, callback, { ...newCtx }) === false) 174 | return false; 175 | if (walk(node.value, callback, { ...newCtx }) === false) 176 | return false; 177 | } 178 | return true; 179 | } 180 | }; 181 | 182 | const classifyRules = (rules) => { 183 | const wxmlRules = []; 184 | const wxssRules = []; 185 | const nodeRules = []; 186 | const jsonRules = []; 187 | const anyRules = []; 188 | for (const rule of rules) { 189 | anyRules.push(rule); 190 | switch (rule.type) { 191 | case RuleType.WXML: 192 | wxmlRules.push(rule); 193 | continue; 194 | case RuleType.WXSS: 195 | wxssRules.push(rule); 196 | continue; 197 | case RuleType.Node: 198 | nodeRules.push(rule); 199 | continue; 200 | case RuleType.JSON: 201 | jsonRules.push(rule); 202 | continue; 203 | } 204 | } 205 | return { wxmlRules, wxssRules, nodeRules, jsonRules, anyRules }; 206 | }; 207 | const runLifetimeHooks = (rules, ast, walker) => { 208 | rules.forEach((rule) => rule.before?.()); 209 | walker( 210 | ast, 211 | (...args) => { 212 | rules.forEach((rule) => { 213 | rule.onVisit?.(...args); 214 | }); 215 | }, 216 | null 217 | ); 218 | rules.forEach((rule) => rule.after?.()); 219 | }; 220 | const extractResultFromRules = (rules) => { 221 | return rules.flatMap((rule) => { 222 | const { name, level, results, patches } = rule; 223 | if (!results.length) 224 | return []; 225 | return { name, level, results, patches }; 226 | }); 227 | }; 228 | const parse = (options) => { 229 | const { wxml, wxss, json, Rules = [], env } = options; 230 | const rules = Rules.map((Rule2) => Rule2(env)); 231 | const { wxmlRules, wxssRules, nodeRules, jsonRules, anyRules } = classifyRules(rules); 232 | let { astJSON, astWXML, astWXSS } = options; 233 | if (wxml && !astWXML) 234 | astWXML = parseDocument(wxml, { xmlMode: true, withStartIndices: true, withEndIndices: true }); 235 | if (astWXML) { 236 | env.astMap?.set(env.path, astWXML); 237 | runLifetimeHooks(wxmlRules, astWXML, walk$2); 238 | } 239 | if (wxss && !astWXSS) 240 | astWXSS = parse$1(wxss, { positions: true }); 241 | if (astWXSS) { 242 | env.astMap?.set(env.path, astWXSS); 243 | runLifetimeHooks(wxssRules, astWXSS, walk$1); 244 | } 245 | if (json && !astJSON) 246 | astJSON = parseJSON(json); 247 | if (astJSON) { 248 | env.astMap?.set(env.path, astJSON); 249 | runLifetimeHooks(jsonRules, astJSON, walk); 250 | } 251 | return { astWXML, astWXSS, astJSON, ruleResults: extractResultFromRules(anyRules) }; 252 | }; 253 | 254 | export { RuleLevel as R, RuleType as a, isType$1 as b, createResultItem as c, defineRule as d, isType as e, applyPatchesOnString as f, isType$2 as i, parse as p }; 255 | -------------------------------------------------------------------------------- /dist/cli.d.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | declare const main: () => Promise; 3 | 4 | export { main as default }; 5 | -------------------------------------------------------------------------------- /dist/cli.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { Command } from 'commander'; 3 | import { argv, cwd, stdout } from 'process'; 4 | import { globby } from 'globby'; 5 | import { readFile, writeFile } from 'fs/promises'; 6 | import chalk from 'chalk'; 7 | import { d as defineRule, i as isType, c as createResultItem, R as RuleLevel, a as RuleType, b as isType$1, e as isType$2, p as parse, f as applyPatchesOnString } from './chunks/parser.mjs'; 8 | import { isText, Text, isComment, Comment, isTag, Element, isCDATA, CDATA, isDocument, Document, isDirective, ProcessingInstruction, hasChildren } from 'domhandler'; 9 | import * as CssTree from 'css-tree'; 10 | import { selectAll } from 'css-select'; 11 | import { DomUtils } from 'htmlparser2'; 12 | import { format } from 'util'; 13 | import inquirer from 'inquirer'; 14 | import path, { join, resolve, dirname, relative } from 'path'; 15 | import { existsSync, readFileSync, lstatSync } from 'fs'; 16 | import { codeFrameColumns } from '@babel/code-frame'; 17 | import lineColumn from 'line-column'; 18 | import 'json-to-ast'; 19 | import 'magic-string'; 20 | 21 | const name = "skylint"; 22 | const version = "1.0.2"; 23 | const description = "Skyline 小程序迁移工具. Migration assistant for Skyline miniapp."; 24 | const main$1 = "dist/index.mjs"; 25 | const type = "module"; 26 | const scripts = { 27 | build: "unbuild", 28 | start: "node dist/cli.mjs", 29 | test: "NODE_OPTIONS='--experimental-vm-modules' jest" 30 | }; 31 | const bin = { 32 | skylint: "./dist/cli.mjs" 33 | }; 34 | const keywords = [ 35 | "skyline", 36 | "linter", 37 | "migration" 38 | ]; 39 | const author = "maniacata"; 40 | const license = "MIT"; 41 | const files = [ 42 | "./dist", 43 | "./LICENSE", 44 | "./third-party-licenses.txt" 45 | ]; 46 | const dependencies = { 47 | "@babel/code-frame": "^7.18.6", 48 | "@babel/preset-typescript": "^7.18.6", 49 | acorn: "^8.7.1", 50 | chalk: "^5.0.1", 51 | commander: "^9.4.0", 52 | "css-select": "^5.1.0", 53 | "css-tree": "^2.1.0", 54 | "dom-serializer": "^2.0.0", 55 | domhandler: "^5.0.3", 56 | domutils: "^3.0.1", 57 | globby: "^13.1.2", 58 | htmlparser2: "^8.0.1", 59 | inquirer: "^9.0.1", 60 | jest: "^28.1.3", 61 | "json-to-ast": "^2.1.0", 62 | "line-column": "^1.0.2", 63 | "magic-string": "^0.26.2", 64 | parse5: "^7.0.0", 65 | "ts-node": "^10.9.1", 66 | typescript: "^4.7.4", 67 | unbuild: "^0.7.4" 68 | }; 69 | const devDependencies = { 70 | "@babel/core": "^7.18.9", 71 | "@babel/preset-env": "^7.18.9", 72 | "@types/babel__code-frame": "^7.0.3", 73 | "@types/inquirer": "^8.2.1", 74 | "@types/json-to-ast": "^2.1.2", 75 | "@types/css-tree": "^1.0.7", 76 | "@types/line-column": "^1.0.0", 77 | "@types/node": "^18.0.6", 78 | "babel-jest": "^28.1.3" 79 | }; 80 | const pkg = { 81 | name: name, 82 | version: version, 83 | description: description, 84 | main: main$1, 85 | type: type, 86 | scripts: scripts, 87 | bin: bin, 88 | keywords: keywords, 89 | author: author, 90 | license: license, 91 | files: files, 92 | dependencies: dependencies, 93 | devDependencies: devDependencies 94 | }; 95 | 96 | const nodeFilenameMap = /* @__PURE__ */ new WeakMap(); 97 | const attachFilenameToNode = (node, filename) => { 98 | nodeFilenameMap.set(node, filename); 99 | }; 100 | const getLocationByNode = (node) => { 101 | const { startIndex: start, endIndex: end } = node; 102 | const path = nodeFilenameMap.get(node) ?? null; 103 | return { start, end, path }; 104 | }; 105 | function cloneNode(node, recursive = false, transform) { 106 | let result; 107 | if (isText(node)) { 108 | result = new Text(node.data); 109 | } else if (isComment(node)) { 110 | result = new Comment(node.data); 111 | } else if (isTag(node)) { 112 | const children = recursive ? cloneChildren(node.children, transform) : []; 113 | const clone = new Element(node.name, { ...node.attribs }, children); 114 | children.forEach((child) => child.parent = clone); 115 | if (node.namespace != null) { 116 | clone.namespace = node.namespace; 117 | } 118 | if (node["x-attribsNamespace"]) { 119 | clone["x-attribsNamespace"] = { ...node["x-attribsNamespace"] }; 120 | } 121 | if (node["x-attribsPrefix"]) { 122 | clone["x-attribsPrefix"] = { ...node["x-attribsPrefix"] }; 123 | } 124 | result = clone; 125 | } else if (isCDATA(node)) { 126 | const children = recursive ? cloneChildren(node.children) : []; 127 | const clone = new CDATA(children); 128 | children.forEach((child) => child.parent = clone); 129 | result = clone; 130 | } else if (isDocument(node)) { 131 | const children = recursive ? cloneChildren(node.children) : []; 132 | const clone = new Document(children); 133 | children.forEach((child) => child.parent = clone); 134 | if (node["x-mode"]) { 135 | clone["x-mode"] = node["x-mode"]; 136 | } 137 | result = clone; 138 | } else if (isDirective(node)) { 139 | const instruction = new ProcessingInstruction(node.name, node.data); 140 | if (node["x-name"] != null) { 141 | instruction["x-name"] = node["x-name"]; 142 | instruction["x-publicId"] = node["x-publicId"]; 143 | instruction["x-systemId"] = node["x-systemId"]; 144 | } 145 | result = instruction; 146 | } else { 147 | throw new Error(`Not implemented yet: ${node.type}`); 148 | } 149 | result.startIndex = node.startIndex; 150 | result.endIndex = node.endIndex; 151 | if (node.sourceCodeLocation != null) { 152 | result.sourceCodeLocation = node.sourceCodeLocation; 153 | } 154 | transform?.(result); 155 | return result; 156 | } 157 | function cloneChildren(childs, transform) { 158 | const children = childs.map((child) => cloneNode(child, true, transform)); 159 | for (let i = 1; i < children.length; i++) { 160 | children[i].prev = children[i - 1]; 161 | children[i - 1].next = children[i]; 162 | } 163 | return children; 164 | } 165 | const replaceChildWithChildren = (child, children, option = {}) => { 166 | const parent = child.parentNode; 167 | if (!parent) 168 | return false; 169 | const { transform, keepLocation = true, attachFilename = false } = option; 170 | parent.children = parent.children.flatMap((childNode) => { 171 | if (childNode === child) { 172 | const newChildren = cloneChildren(children, (newChild) => { 173 | if (keepLocation) { 174 | if (attachFilename) 175 | attachFilenameToNode(newChild, attachFilename); 176 | } else { 177 | newChild.startIndex = newChild.endIndex = null; 178 | } 179 | transform?.(newChild); 180 | }); 181 | newChildren.forEach((child2) => child2.parent = parent); 182 | const firstChild = newChildren[0]; 183 | const lastChild = newChildren[newChildren.length - 1]; 184 | if (firstChild) { 185 | if (child.prev) 186 | child.prev.next = firstChild; 187 | firstChild.prev = child.prev; 188 | } 189 | if (lastChild) { 190 | if (child.next) 191 | child.next.prev = lastChild; 192 | lastChild.next = child.next; 193 | } 194 | return newChildren; 195 | } 196 | return childNode; 197 | }); 198 | return true; 199 | }; 200 | 201 | const matchTagName = (tag, matcher) => { 202 | if (!matcher) 203 | return false; 204 | if (Array.isArray(matcher)) { 205 | return matcher.some((m) => tag.match(m)); 206 | } else { 207 | return tag.match(matcher); 208 | } 209 | }; 210 | const generateNoInlineTagChildrenCheck = (info, { result, tagName, parentTagNameShouldBe }) => defineRule(info, (ctx) => { 211 | ctx.lifetimes({ 212 | onVisit: (node) => { 213 | if (!isType(node, "Tag") || tagName && node.name !== tagName) 214 | return; 215 | if (!hasChildren(node)) 216 | return; 217 | let isPrevText = false; 218 | for (const child of node.childNodes) { 219 | const isText = isType(child, "Text") && child.data.trim() !== "" || isType(child, "Tag") && child.name === "text"; 220 | if (isText && isPrevText && !matchTagName(node.name, parentTagNameShouldBe)) { 221 | const { start, end, path } = getLocationByNode(node); 222 | ctx.addResult({ 223 | ...result, 224 | loc: { 225 | startIndex: start, 226 | endIndex: end, 227 | path 228 | } 229 | }); 230 | break; 231 | } 232 | isPrevText = isText; 233 | } 234 | } 235 | }); 236 | }); 237 | 238 | const result$9 = createResultItem({ 239 | name: "navigator", 240 | description: "navigator \u7EC4\u4EF6\u53EA\u80FD\u5D4C\u5957\u6587\u672C", 241 | advice: "\u540C text \u7EC4\u4EF6\uFF0C\u53EA\u652F\u6301\u5185\u8054\u6587\u672C\uFF0C\u82E5\u9700\u8981\u5B9E\u73B0\u5757\u7EA7\u5143\u7D20\uFF0C\u53EF\u6539\u4E3A button \u5B9E\u73B0", 242 | level: RuleLevel.Warn 243 | }); 244 | const RuleNagivator = generateNoInlineTagChildrenCheck( 245 | { name: "navigator", type: RuleType.WXML }, 246 | { result: result$9, tagName: "navigator" } 247 | ); 248 | 249 | const result$8 = createResultItem({ 250 | name: "no-inline-text", 251 | description: "\u591A\u6BB5\u6587\u672C\u5185\u8054\u53EA\u80FD\u4F7F\u7528 text/span \u7EC4\u4EF6\u5305\u88F9", 252 | advice: "\u76EE\u524D\u4E0D\u652F\u6301 inline \uFF0C\u9700\u901A\u8FC7 text \u7EC4\u4EF6\u5B9E\u73B0\uFF0C\u5982 foo bar \u8981\u6539\u4E3A foo bar ", 253 | level: RuleLevel.Error, 254 | withCodeFrame: true 255 | }); 256 | const RuleNoInlineText = generateNoInlineTagChildrenCheck( 257 | { name: "no-inline-text", type: RuleType.WXML }, 258 | { result: result$8, parentTagNameShouldBe: ["text", "span"] } 259 | ); 260 | 261 | const result$7 = createResultItem({ 262 | name: "no-svg-style-tag", 263 | description: "\u4E0D\u652F\u6301 svg \u7EC4\u4EF6\u5185\u4F7F\u7528 style \u6807\u7B7E", 264 | advice: "\u5728 wxss \u6587\u4EF6\u5185\u4E66\u5199\u6837\u5F0F\u89C4\u5219", 265 | level: RuleLevel.Error, 266 | withCodeFrame: true 267 | }); 268 | const RuleNoSvgStyleTag = defineRule({ name: "no-svg-style-tag", type: RuleType.WXML }, (ctx) => { 269 | const dfs = (node, ruleCtx) => { 270 | if (isType(node, "Tag") && node.name === "style" || isType(node, "Style")) { 271 | ruleCtx.addResult({ 272 | ...result$7, 273 | loc: { 274 | startIndex: node.startIndex, 275 | endIndex: node.endIndex, 276 | path: ctx.env.path 277 | } 278 | }); 279 | } 280 | if (hasChildren(node)) { 281 | for (const childNode of node.childNodes) { 282 | dfs(childNode, ruleCtx); 283 | } 284 | } 285 | }; 286 | ctx.lifetimes({ 287 | onVisit: (node, walkCtx) => { 288 | if (isType(node, "Tag") && node.name === "svg" && hasChildren(node)) { 289 | node.childNodes.forEach((childNode) => dfs(childNode, ctx)); 290 | } 291 | } 292 | }); 293 | }); 294 | 295 | const results = { 296 | "movable-view": createResultItem({ 297 | name: "movable-view", 298 | description: "\u4E0D\u652F\u6301 movable-view \u7EC4\u4EF6", 299 | advice: "\u4E0D\u518D\u652F\u6301 movable-view \u7EC4\u4EF6\uFF0C\u901A\u8FC7 skyline \u7684\u65B0\u7279\u6027\uFF0Cworklet \u52A8\u753B + \u624B\u52BF\u7CFB\u7EDF\u5B9E\u73B0", 300 | level: RuleLevel.Error 301 | }), 302 | video: createResultItem({ 303 | name: "video", 304 | description: "\u6682\u53EA\u652F\u6301\u57FA\u7840\u64AD\u653E\u529F\u80FD", 305 | advice: "\u5B8C\u6574\u529F\u80FD skyline \u540E\u7EED\u7248\u672C\u4F1A\u652F\u6301", 306 | level: RuleLevel.Verbose 307 | }), 308 | form: createResultItem({ 309 | name: "form", 310 | description: "\u4E0D\u652F\u6301 form \u7EC4\u4EF6", 311 | advice: "\u5B8C\u6574\u529F\u80FD skyline \u540E\u7EED\u7248\u672C\u4F1A\u652F\u6301", 312 | level: RuleLevel.Verbose 313 | }) 314 | }; 315 | const RuleUnsupportedComponent = defineRule( 316 | { name: "unsupported-component", type: RuleType.WXML }, 317 | (ctx) => { 318 | ctx.lifetimes({ 319 | onVisit: (node) => { 320 | if (!isType(node, "Tag")) 321 | return; 322 | if (Reflect.has(results, node.name)) { 323 | ctx.addResult({ 324 | ...results[node.name], 325 | loc: { 326 | startIndex: node.startIndex, 327 | endIndex: node.endIndex, 328 | path: ctx.env.path 329 | } 330 | }); 331 | } 332 | } 333 | }); 334 | } 335 | ); 336 | 337 | const result$6 = createResultItem({ 338 | name: "display-flex", 339 | description: "flex \u5E03\u5C40\u4E0B\u672A\u663E\u5F0F\u6307\u5B9A flex-direction", 340 | advice: "\u76EE\u524D flex-direction \u9ED8\u8BA4\u4E3A column\uFF0C\u800C\u5728 web \u4E0B\u9ED8\u8BA4\u503C\u4E3A row \u6545\u4E00\u822C\u4E0D\u4F1A\u663E\u5F0F\u6307\u5B9A\uFF0C\u56E0\u6B64\u8FD9\u91CC\u9700\u8981\u663E\u5F0F\u6307\u5B9A\u4E3A row", 341 | fixable: true, 342 | level: RuleLevel.Warn 343 | }); 344 | const RuleDisplayFlex = defineRule({ name: "display-flex", type: RuleType.WXSS }, (ctx) => { 345 | ctx.lifetimes({ 346 | onVisit: (node) => { 347 | if (isType$1(node, "Block")) { 348 | let loc; 349 | let hasFlexDirection = false; 350 | node.children.forEach((child) => { 351 | if (!isType$1(child, "Declaration")) 352 | return; 353 | if (child.property === "display" && isType$1(child.value, "Value") && child.value.children.some((val) => isType$1(val, "Identifier") && val.name === "flex")) { 354 | loc = child.loc; 355 | } 356 | if (child.property === "flex-direction") 357 | hasFlexDirection = true; 358 | }); 359 | if (loc && !hasFlexDirection) { 360 | ctx.addResultWithPatch( 361 | { 362 | ...result$6, 363 | loc: { 364 | startLn: loc.start.line, 365 | endLn: loc.end.line, 366 | startCol: loc.start.column, 367 | endCol: loc.end.column, 368 | path: ctx.env.path 369 | } 370 | }, 371 | { 372 | loc: { 373 | start: loc.start.offset, 374 | end: loc.start.offset, 375 | path: ctx.env.path 376 | }, 377 | patchedStr: "flex-direction: row; " 378 | } 379 | ); 380 | } 381 | } 382 | } 383 | }); 384 | }); 385 | 386 | const RuleDisplayInline = defineRule({ name: "display-inline", type: RuleType.WXSS }, (ctx) => { 387 | ctx.lifetimes({ 388 | onVisit: (node) => { 389 | if (isType$1(node, "Declaration") && node.property === "display" && isType$1(node.value, "Value")) { 390 | const val = node.value.children.toArray().find((val2) => isType$1(val2, "Identifier") && ["inline", "inline-block"].includes(val2.name)); 391 | if (!val || !isType$1(val, "Identifier")) 392 | return; 393 | const loc = node.loc; 394 | ctx.addResult({ 395 | name: `display-${val.name}`, 396 | description: `\u4E0D\u652F\u6301 display: ${val.name}`, 397 | advice: "\u82E5\u662F\u5E03\u5C40\u9700\u8981\uFF0C\u53EF\u6539\u4E3A flex \u5E03\u5C40\u5B9E\u73B0\uFF1B\u82E5\u662F\u5B9E\u73B0\u5185\u8054\u6587\u672C\uFF0C\u53EF\u4F7F\u7528 text \u7EC4\u4EF6", 398 | level: RuleLevel.Info, 399 | loc: { 400 | startLn: loc.start.line, 401 | endLn: loc.end.line, 402 | startCol: loc.start.column, 403 | endCol: loc.end.column, 404 | path: ctx.env.path 405 | } 406 | }); 407 | } 408 | } 409 | }); 410 | }); 411 | 412 | const result$5 = createResultItem({ 413 | name: "mark-wx-for", 414 | description: `\u672A\u6253\u5F00\u6837\u5F0F\u5171\u4EAB\u6807\u8BB0`, 415 | advice: `\u6BCF\u4E00\u4E2A\u5217\u8868\u9879\u7684\u6837\u5F0F\u662F\u57FA\u672C\u76F8\u540C\u7684\uFF0C\u56E0\u6B64 skyline \u5B9E\u73B0\u4E86\u6837\u5F0F\u5171\u4EAB\u673A\u5236\uFF0C\u53EF\u964D\u4F4E\u6837\u5F0F\u5339\u914D\u7684\u8017\u65F6\uFF0C\u53EA\u9700\u8981\u5217\u8868\u9879\u52A0\u4E2A list-item\uFF0C\u5982 `, 416 | level: RuleLevel.Verbose 417 | }); 418 | const RuleMarkWxFor = defineRule({ name: "mark-wx-for", type: RuleType.WXML }, (ctx) => { 419 | ctx.lifetimes({ 420 | before: () => { 421 | }, 422 | onVisit: (node) => { 423 | if (!isType(node, "Tag") || !Reflect.has(node.attribs, "wx:for")) 424 | return; 425 | if (node.name === "block" && hasChildren(node)) { 426 | for (const childNode of node.childNodes) { 427 | if (isType(childNode, "Tag") && !Reflect.has(childNode.attribs, "list-item")) { 428 | ctx.addResult({ 429 | ...result$5, 430 | loc: { 431 | startIndex: childNode.startIndex, 432 | endIndex: childNode.endIndex, 433 | path: ctx.env.path 434 | } 435 | }); 436 | } 437 | } 438 | } else if (!Reflect.has(node.attribs, "list-item")) { 439 | ctx.addResult({ 440 | ...result$5, 441 | loc: { 442 | startIndex: node.startIndex, 443 | endIndex: node.endIndex, 444 | path: ctx.env.path 445 | } 446 | }); 447 | } 448 | } 449 | }); 450 | }); 451 | 452 | const result$4 = createResultItem({ 453 | name: "position-fixed", 454 | description: "position: fixed \u672A\u6307\u5B9A\u5143\u7D20\u504F\u79FB", 455 | advice: "position-fixed \u9700\u8981\u6307\u5B9A top/bottom left/right\u3002", 456 | level: RuleLevel.Error 457 | }); 458 | const verboseResult = createResultItem({ 459 | name: "position fixed", 460 | description: "skyline \u6682\u4E0D\u652F\u6301 stacking context", 461 | advice: "\u8BF7\u81EA\u884C\u786E\u8BA4 fixed \u8282\u70B9\u7684 z-index \u5C42\u7EA7\u662F\u5426\u7B26\u5408\u9884\u671F", 462 | level: RuleLevel.Info 463 | }); 464 | const RulePositionFixed = defineRule( 465 | { name: "position-fixed", type: RuleType.WXSS }, 466 | (ctx) => { 467 | ctx.lifetimes({ 468 | onVisit: (node, visitCtx) => { 469 | if (isType$1(node, "Declaration") && node.property === "position" && isType$1(node.value, "Value") && node.value.children.some( 470 | (val) => isType$1(val, "Identifier") && val.name === "fixed" 471 | )) { 472 | const loc = node.loc; 473 | ctx.addResult({ 474 | ...verboseResult, 475 | loc: { 476 | startLn: loc.start.line, 477 | endLn: loc.end.line, 478 | startCol: loc.start.column, 479 | endCol: loc.end.column, 480 | path: ctx.env.path 481 | } 482 | }); 483 | const hasLOrR = CssTree.find(visitCtx.block, (node2, item, list) => { 484 | return isType$1(node2, "Declaration") && (node2.property === "left" || node2.property === "right"); 485 | }); 486 | const hasTOrB = CssTree.find(visitCtx.block, (node2, item, list) => { 487 | return isType$1(node2, "Declaration") && (node2.property === "top" || node2.property === "bottom"); 488 | }); 489 | if (!hasLOrR || !hasTOrB) { 490 | ctx.addResult({ 491 | ...result$4, 492 | loc: { 493 | startLn: loc.start.line, 494 | endLn: loc.end.line, 495 | startCol: loc.start.column, 496 | endCol: loc.end.column, 497 | path: ctx.env.path 498 | } 499 | }); 500 | } 501 | } 502 | } 503 | }); 504 | } 505 | ); 506 | 507 | const result$3 = createResultItem({ 508 | name: "text-overflow-ellipse", 509 | description: "text-overflow: ellipse \u53EA\u5728 text \u7EC4\u4EF6\u4E0B\u751F\u6548", 510 | advice: "\u6587\u672C\u7701\u7565\u9700\u8981\u901A\u8FC7 text \u7EC4\u4EF6 + text-overflow: ellipse + overflow: hidden \u4E09\u8005\u5B9E\u73B0", 511 | level: RuleLevel.Warn 512 | }); 513 | const RuleTextOverflowEllipse = defineRule({ name: "text-overflow-ellipse", type: RuleType.WXSS }, (ctx) => { 514 | ctx.lifetimes({ 515 | onVisit: (node, walkCtx) => { 516 | if (isType$1(node, "Declaration") && node.property === "text-overflow") { 517 | const loc = node.loc; 518 | ctx.addResult({ 519 | ...result$3, 520 | loc: { 521 | startLn: loc.start.line, 522 | endLn: loc.end.line, 523 | startCol: loc.start.column, 524 | endCol: loc.end.column, 525 | path: ctx.env.path 526 | } 527 | }); 528 | } 529 | } 530 | }); 531 | }); 532 | 533 | const generateBasicJsonConfigCheck = (info, { result, key, value, autoPatch = true, allowUndefined }) => defineRule(info, (ctx) => { 534 | let firstNode = null; 535 | let propNode = null; 536 | let state = 0 /* Undefined */; 537 | ctx.lifetimes({ 538 | onVisit: (node) => { 539 | if (!firstNode && isType$2(node, "Object")) 540 | firstNode = node; 541 | if (!isType$2(node, "Property")) 542 | return; 543 | if (node.key.value !== key) 544 | return; 545 | propNode = node; 546 | if (isType$2(node.value, "Literal")) { 547 | state = (Array.isArray(value) ? value.includes(node.value.value) : node.value.value === value) ? 2 /* Equal */ : 1 /* Unequal */; 548 | } 549 | }, 550 | after: () => { 551 | if (allowUndefined && state === 0 /* Undefined */ || 2 /* Equal */) 552 | return; 553 | const node = propNode; 554 | if (node) { 555 | const res = { 556 | ...result, 557 | loc: { 558 | startLn: node.loc.start.line, 559 | endLn: node.loc.end.line, 560 | startCol: node.loc.start.column, 561 | endCol: node.loc.end.column, 562 | path: ctx.env.path 563 | } 564 | }; 565 | const patch = { 566 | patchedStr: JSON.stringify(value), 567 | loc: { 568 | start: node.value.loc.start.offset, 569 | end: node.value.loc.end.offset, 570 | path: ctx.env.path 571 | } 572 | }; 573 | autoPatch ? ctx.addResultWithPatch(res, patch) : ctx.addResult(res); 574 | } else if (firstNode) { 575 | const res = { ...result }; 576 | const patch = { 577 | patchedStr: `"${key}": ${JSON.stringify(value)},`, 578 | loc: { 579 | start: firstNode.loc.start.offset + 1, 580 | end: firstNode.loc.start.offset + 1, 581 | path: ctx.env.path 582 | } 583 | }; 584 | autoPatch ? ctx.addResultWithPatch(res, patch) : ctx.addResult(res); 585 | } 586 | } 587 | }); 588 | }); 589 | 590 | const result$2 = createResultItem({ 591 | name: "no-native-nav", 592 | description: "\u4E0D\u652F\u6301\u7684\u539F\u751F\u5BFC\u822A\u680F", 593 | advice: `\u9700\u5C06\u9875\u9762\u914D\u7F6E\u4E2D\u7684 navigationStyle \u7F6E\u4E3A custom\uFF0C\u5E76\u81EA\u884C\u5B9E\u73B0\u81EA\u5B9A\u4E49\u5BFC\u822A\u680F\uFF0C\u8BE6\u89C1\u6587\u6863\uFF08\u94FE\u63A5\u5F85\u5B9A\uFF09`, 594 | patchHint: `\u5C06\u914D\u7F6E\u9879 navigationStyle \u7F6E\u4E3A "custom"`, 595 | fixable: true, 596 | level: RuleLevel.Error 597 | }); 598 | const RuleNoNativeNav = generateBasicJsonConfigCheck( 599 | { name: "no-native-nav", type: RuleType.JSON }, 600 | { result: result$2, key: "navigationStyle", value: "custom" } 601 | ); 602 | 603 | const result$1 = createResultItem({ 604 | name: "disable-scroll", 605 | description: "\u4E0D\u652F\u6301\u9875\u9762\u5168\u5C40\u6EDA\u52A8", 606 | advice: `\u9700\u5C06\u9875\u9762\u914D\u7F6E\u4E2D\u7684 disableScroll \u7F6E\u4E3A true\uFF0C\u5E76\u5728\u9700\u8981\u6EDA\u52A8\u7684\u533A\u57DF\u4F7F\u7528 scroll-view \u5B9E\u73B0\uFF0C\u8BE6\u89C1\u6587\u6863\uFF08\u94FE\u63A5\u5F85\u5B9A\uFF09`, 607 | patchHint: `\u5C06\u914D\u7F6E\u9879 disableScroll \u7F6E\u4E3A true`, 608 | fixable: true, 609 | level: RuleLevel.Error 610 | }); 611 | const RuleDisableScroll = generateBasicJsonConfigCheck( 612 | { name: "disable-scroll", type: RuleType.JSON }, 613 | { result: result$1, key: "disableScroll", value: true } 614 | ); 615 | 616 | const result = createResultItem({ 617 | name: "renderer-skyline", 618 | description: "\u672A\u5F00\u542F skyline \u6E32\u67D3", 619 | advice: `\u5C06\u914D\u7F6E\u9879 renderer \u7F6E\u4E3A "skyline"`, 620 | fixable: true, 621 | level: RuleLevel.Error 622 | }); 623 | const RuleRendererSkyline = generateBasicJsonConfigCheck( 624 | { name: "renderer-skyline", type: RuleType.JSON }, 625 | { result, key: "renderer", value: "skyline" } 626 | ); 627 | 628 | const formatSelector = (selector) => { 629 | let str = ""; 630 | selector.children.forEach((node) => { 631 | if (isType$1(node, "IdSelector")) { 632 | str += `#${node.name}`; 633 | } else if (isType$1(node, "TypeSelector")) { 634 | str += node.name; 635 | } else if (isType$1(node, "ClassSelector")) { 636 | str += `.${node.name}`; 637 | } else if (isType$1(node, "Combinator")) { 638 | str += node.name; 639 | } else if (isType$1(node, "AttributeSelector")) { 640 | let tmp = node.name.name; 641 | if (node.matcher) 642 | tmp += node.matcher; 643 | if (node.value) 644 | tmp += isType$1(node.value, "String") ? `"${node.value.value}"` : node.value.name; 645 | if (node.flags) 646 | tmp += ` ${node.flags}`; 647 | str += `[${tmp}]`; 648 | } else if (isType$1(node, "PseudoClassSelector")) ; else if (isType$1(node, "PseudoElementSelector")) ; 649 | }); 650 | return str; 651 | }; 652 | const formatSelectorList = (selectorList) => { 653 | return selectorList.children.toArray().flatMap((selector) => { 654 | if (!isType$1(selector, "Selector")) 655 | return []; 656 | return formatSelector(selector); 657 | }).join(", "); 658 | }; 659 | 660 | const resultScrollViewNotFound = createResultItem({ 661 | name: "scroll-view-not-found", 662 | description: "\u5F53\u524D\u9875\u9762\u672A\u4F7F\u7528 scroll-view \u7EC4\u4EF6", 663 | advice: "skyline \u4E0D\u652F\u6301\u9875\u9762\u5168\u5C40\u6EDA\u52A8\uFF0C\u82E5\u9875\u9762\u8D85\u8FC7\u4E00\u5C4F\uFF0C\u9700\u8981\u4F7F\u7528 scroll-view \u7EC4\u4EF6\u5B9E\u73B0\u6EDA\u52A8", 664 | level: RuleLevel.Warn 665 | }); 666 | const resultScrollViewImproperType = createResultItem({ 667 | name: "scroll-view-type", 668 | description: `scroll-view \u672A\u663E\u5F0F\u6307\u5B9A type \u7C7B\u578B`, 669 | advice: `\u5F53\u524D scroll-view \u53EA\u652F\u6301 type=list \u4E14\u9700\u663E\u5F0F\u6307\u5B9A\uFF0C\u8BE6\u89C1\u6587\u6863\uFF08\u94FE\u63A5\u5F85\u5B9A\uFF09`, 670 | fixable: true, 671 | level: RuleLevel.Error 672 | }); 673 | const resultScrollViewOptimize = createResultItem({ 674 | name: "scroll-view-optimize", 675 | description: `\u672A\u80FD\u5145\u5206\u5229\u7528 scroll-view \u6309\u9700\u6E32\u67D3\u7684\u673A\u5236`, 676 | advice: `scroll-view \u4F1A\u6839\u636E\u76F4\u63A5\u5B50\u8282\u70B9\u662F\u5426\u5728\u5C4F\u6765\u6309\u9700\u6E32\u67D3\uFF0C\u82E5\u53EA\u6709\u4E00\u4E2A\u76F4\u63A5\u5B50\u8282\u70B9\u5219\u6027\u80FD\u4F1A\u9000\u5316\uFF0C\u5982 `, 677 | level: RuleLevel.Verbose 678 | }); 679 | const resultScrollViewXY = createResultItem({ 680 | name: "scroll-view-x-y", 681 | description: `scroll-view \u6682\u4E0D\u652F\u6301\u6C34\u5E73\u5782\u76F4\u65B9\u5411\u540C\u65F6\u6EDA\u52A8`, 682 | advice: `skyline \u540E\u7EED\u7248\u672C\u4F1A\u652F\u6301`, 683 | level: RuleLevel.Info 684 | }); 685 | const resultScrollMargin = createResultItem({ 686 | name: "scroll-view-margin", 687 | description: `scroll-view \u7EC4\u4EF6\u7684\u76F4\u63A5\u5B50\u8282\u70B9 margin \u65E0\u6548`, 688 | advice: `\u9700\u8981\u7ED9\u8BBE\u7F6E\u4E86 margin \u7684\u76F4\u63A5\u5B50\u8282\u70B9\u5957\u591A\u4E00\u5C42 view\u3002skyline \u540E\u7EED\u7248\u672C\u8003\u8651\u4ECE\u5E03\u5C40\u7B97\u6CD5\u4E0A\u652F\u6301`, 689 | level: RuleLevel.Info 690 | }); 691 | const RuleScroolViewWXML = defineRule( 692 | { name: "scroll-view-wxml", type: RuleType.WXML }, 693 | (ctx) => { 694 | let scrollViewCount = 0; 695 | ctx.lifetimes({ 696 | before: () => { 697 | scrollViewCount = 0; 698 | }, 699 | onVisit: (node) => { 700 | if (!isType(node, "Tag") || node.name !== "scroll-view") 701 | return; 702 | scrollViewCount++; 703 | let hasTypeList = DomUtils.getAttributeValue(node, "type") === "list"; 704 | if (!hasTypeList) { 705 | const { start, end, path } = getLocationByNode(node); 706 | ctx.addResultWithPatch( 707 | { 708 | ...resultScrollViewImproperType, 709 | loc: { 710 | startIndex: start, 711 | endIndex: end, 712 | path 713 | } 714 | }, 715 | { 716 | patchedStr: ` { 738 | if (isType(child, "Tag")) 739 | return true; 740 | if (isType(child, "Text") && child.data.trim() !== "") 741 | return true; 742 | return false; 743 | }); 744 | if (trimedChildren.length === 1 && isType(trimedChildren[0], "Tag") && !Reflect.has(trimedChildren[0].attribs, "wx:for")) { 745 | const { start, end, path } = getLocationByNode(node); 746 | ctx.addResult({ 747 | ...resultScrollViewOptimize, 748 | loc: { 749 | startIndex: start, 750 | endIndex: end, 751 | path 752 | } 753 | }); 754 | } 755 | } 756 | }, 757 | after: () => { 758 | if (scrollViewCount === 0) 759 | ctx.addResult(resultScrollViewNotFound); 760 | } 761 | }); 762 | } 763 | ); 764 | defineRule( 765 | { name: "scroll-view-wxss", type: RuleType.WXSS }, 766 | (ctx) => { 767 | ctx.lifetimes({ 768 | onVisit: (node, walkCtx) => { 769 | if (!isType$1(node, "Declaration") || !node.property.startsWith("margin")) 770 | return; 771 | const wxmlFilename = ctx.getRelatedWXMLFilename(); 772 | const ast = ctx.getRelatedWXMLAst(); 773 | const prelude = walkCtx.rule?.prelude; 774 | if (!ast || !prelude) 775 | return; 776 | const selector = isType$1(prelude, "Raw") ? prelude.value : formatSelectorList(prelude); 777 | const children = selectAll(selector, ast); 778 | for (const child of children) { 779 | if (child.parent && isType(child.parent, "Tag") && child.parent.name === "scroll-view") { 780 | const { start, end, path } = getLocationByNode(child); 781 | ctx.addResult({ 782 | ...resultScrollMargin, 783 | loc: { 784 | startIndex: child.startIndex, 785 | endIndex: child.endIndex, 786 | path: path ?? wxmlFilename ?? null 787 | } 788 | }); 789 | } 790 | } 791 | } 792 | }); 793 | } 794 | ); 795 | const RuleScrollView = [RuleScroolViewWXML]; 796 | 797 | const serialize = (node) => JSON.stringify(node, null, 2); 798 | 799 | const resolvePath = (currentPath, rootPath, filePath) => { 800 | let path = ""; 801 | if (filePath.startsWith("/")) { 802 | path = join(rootPath, filePath); 803 | } else { 804 | path = resolve(dirname(currentPath), filePath); 805 | } 806 | return path; 807 | }; 808 | 809 | const collectImportedWXSS = async (wxssPaths, rootPath, shouldExclude) => { 810 | const originPaths = wxssPaths.slice(); 811 | const wxssSet = new Set(wxssPaths); 812 | const rule = defineRule({ name: "collect-imported-wxss", type: RuleType.WXSS }, (ctx) => { 813 | ctx.lifetimes({ 814 | onVisit: (node) => { 815 | if (!ctx.env) 816 | return; 817 | if (!isType$1(node, "Atrule") || node.name !== "import" || !node.prelude || !isType$1(node.prelude, "AtrulePrelude")) { 818 | return; 819 | } 820 | const { path: currentPath, wxssPaths: wxssPaths2, wxssSet: wxssSet2, rootPath: rootPath2 } = ctx.env; 821 | node.prelude.children.forEach((child) => { 822 | let path = null; 823 | if (isType$1(child, "String")) { 824 | path = child.value; 825 | } else if (isType$1(child, "Url") && isType$1(child.value, "String")) { 826 | path = child.value.value; 827 | } 828 | if (!path?.endsWith(".wxss")) 829 | return; 830 | path = resolvePath(currentPath, rootPath2, path); 831 | if (!existsSync(path) || wxssSet2.has(path) || shouldExclude?.(path)) 832 | return; 833 | wxssSet2.add(path); 834 | wxssPaths2.push(path); 835 | }); 836 | } 837 | }); 838 | }); 839 | for (const wxssPath of wxssPaths) { 840 | const wxss = (await readFile(wxssPath)).toString(); 841 | const env = { rootPath, path: wxssPath, wxssSet, wxssPaths }; 842 | parse({ wxss, Rules: [rule], env }); 843 | } 844 | for (const wxssPath of originPaths) { 845 | wxssSet.delete(wxssPath); 846 | } 847 | return wxssSet; 848 | }; 849 | 850 | const MAX_CODE_FRAME_LENGTH = 1024; 851 | const formatSourceCodeLocation = (rawStr, loc, options = {}) => { 852 | const { withCodeFrame = false, alternativeFilename } = options; 853 | let location; 854 | if ("startCol" in loc) { 855 | location = loc; 856 | } else { 857 | const finder = lineColumn(rawStr); 858 | const { line: startLn = -1, col: startCol = -1 } = finder.fromIndex(loc.startIndex) ?? {}; 859 | const { line: endLn = -1, col: endCol = -1 } = finder.fromIndex(loc.endIndex) ?? {}; 860 | location = { startLn, startCol, endLn, endCol, path: loc.path }; 861 | } 862 | const filenameWithLnCol = format("%s:%d:%d", loc.path ?? alternativeFilename, location.startLn, location.startCol); 863 | if (!withCodeFrame) 864 | return filenameWithLnCol; 865 | const codeFrame = codeFrameColumns( 866 | rawStr, 867 | { 868 | start: { 869 | line: location.startLn, 870 | column: location.startCol 871 | }, 872 | end: { 873 | line: location.endLn, 874 | column: location.endCol 875 | } 876 | }, 877 | { 878 | linesAbove: 1, 879 | linesBelow: 1, 880 | forceColor: true 881 | } 882 | ); 883 | if (codeFrame.length > MAX_CODE_FRAME_LENGTH) 884 | return filenameWithLnCol; 885 | return [filenameWithLnCol, codeFrame].join("\n"); 886 | }; 887 | 888 | const getUniqueKey = (path, tmplName) => `${tmplName}`; 889 | const Rule = defineRule( 890 | { name: "collect-template", type: RuleType.WXML }, 891 | (ctx) => { 892 | ctx.lifetimes({ 893 | onVisit: (node, walkerContext) => { 894 | if (!isType(node, "Tag")) 895 | return; 896 | if (node.name === "template") { 897 | const { is, name } = node.attribs; 898 | if (is) { 899 | const key = getUniqueKey(ctx.env.path, is); 900 | const { fragment, fromFile } = ctx.env.tmplFragments.get(key) ?? {}; 901 | if (!fragment) 902 | return; 903 | replaceChildWithChildren(node, fragment.childNodes, { 904 | attachFilename: fromFile ?? ctx.env.path 905 | }); 906 | } else if (name) { 907 | const key = getUniqueKey(ctx.env.path, name); 908 | if (ctx.env.tmplFragments.has(key)) 909 | return; 910 | ctx.env.tmplFragments.set(key, { 911 | fragment: node, 912 | fromFile: ctx.env.path 913 | }); 914 | replaceChildWithChildren(node, []); 915 | } 916 | } else if (node.name === "include") { 917 | const { src } = node.attribs; 918 | if (!src) 919 | return; 920 | const srcPath = resolvePath(ctx.env.path, ctx.env.rootPath, src); 921 | let { fragment: srcAST, fromFile } = ctx.env.includeFragments.get(srcPath) ?? {}; 922 | if (!srcAST) 923 | [srcAST] = collectTemplate([srcPath], ctx.env); 924 | replaceChildWithChildren(node, srcAST.childNodes, { 925 | attachFilename: srcPath 926 | }); 927 | } else if (node.name === "import") { 928 | const { src } = node.attribs; 929 | const srcPath = resolvePath(ctx.env.path, ctx.env.rootPath, src); 930 | let { fragment: srcAST, fromFile } = ctx.env.importFragments.get(srcPath) ?? {}; 931 | if (!srcAST) 932 | [srcAST] = collectTemplate([srcPath], ctx.env); 933 | replaceChildWithChildren(node, []); 934 | } 935 | } 936 | }); 937 | } 938 | ); 939 | const collectTemplate = (wxmlPaths, env) => { 940 | [...wxmlPaths]; 941 | const newEnv = env ?? { 942 | rootPath: "", 943 | path: "", 944 | wxmlPaths, 945 | importFragments: /* @__PURE__ */ new Map(), 946 | includeFragments: /* @__PURE__ */ new Map(), 947 | tmplFragments: /* @__PURE__ */ new Map() 948 | }; 949 | return wxmlPaths.map((path) => { 950 | const wxml = readFileSync(path).toString(); 951 | let { astWXML } = parse({ wxml, Rules: [Rule], env: { ...newEnv, path } }); 952 | return astWXML; 953 | }); 954 | }; 955 | 956 | const Rules = [ 957 | RuleNagivator, 958 | RuleNoInlineText, 959 | RuleNoSvgStyleTag, 960 | RuleUnsupportedComponent, 961 | RuleDisplayFlex, 962 | RuleDisplayInline, 963 | RuleMarkWxFor, 964 | RulePositionFixed, 965 | RuleTextOverflowEllipse, 966 | RuleNoNativeNav, 967 | RuleDisableScroll, 968 | RuleRendererSkyline, 969 | RuleScrollView 970 | ].flat(); 971 | const logColor = { 972 | [RuleLevel.Verbose]: chalk.cyan, 973 | [RuleLevel.Info]: chalk.blue, 974 | [RuleLevel.Warn]: chalk.yellow, 975 | [RuleLevel.Error]: chalk.red 976 | }; 977 | const splitString = (input) => { 978 | if (Array.isArray(input)) 979 | return input; 980 | return input.split(",").map((item) => item.trim()); 981 | }; 982 | const cli = new Command(); 983 | cli.name(pkg.name); 984 | cli.version(pkg.version); 985 | cli.option( 986 | "-p, --path [string]", 987 | "\u5DE5\u7A0B\u7684\u6839\u76EE\u5F55", 988 | (input) => path.resolve(input), 989 | "" 990 | ); 991 | cli.option( 992 | "-l, --log-level [number]", 993 | "\u4F9D\u65E5\u5FD7\u7B49\u7EA7\u8FC7\u6EE4\uFF0C\u4ECE 0 \u5230 3", 994 | parseInt, 995 | 0 996 | ); 997 | cli.option( 998 | "-i, --ignore [string]", 999 | "\u8981\u5FFD\u7565\u7684\u89C4\u5219\u540D\uFF0C\u7528\u534A\u89D2\u9017\u53F7\u5206\u9694", 1000 | splitString, 1001 | [] 1002 | ); 1003 | cli.option( 1004 | "-e, --exclude [string]", 1005 | "\u8981\u6392\u9664\u7684\u8DEF\u5F84\u540D\u7684\u6B63\u5219\u8868\u8FBE\u5F0F\uFF0C\u7528\u534A\u89D2\u9017\u53F7\u5206\u9694", 1006 | splitString, 1007 | [] 1008 | ); 1009 | cli.parse(argv); 1010 | const options = cli.opts(); 1011 | const main = async () => { 1012 | let appJsonPath = ""; 1013 | let appJsonObject = null; 1014 | let pageJsonObjects = []; 1015 | const disabledRules = new Set(options.ignore); 1016 | const excludedFiles = options.exclude.map((str) => new RegExp(str)); 1017 | const isPathExcluded = (path2) => excludedFiles.some((regex) => regex.test(path2)); 1018 | const getAppJsonFromPath = async (path2) => { 1019 | try { 1020 | appJsonPath = resolve(path2, "app.json"); 1021 | const appJsonFile = await readFile(appJsonPath); 1022 | appJsonObject = JSON.parse(appJsonFile.toString()); 1023 | } catch (e) { 1024 | throw "\u65E0\u6548 app.json\uFF0C\u8BF7\u68C0\u67E5\u8DEF\u5F84\u548C\u8BED\u6CD5\u662F\u5426\u6B63\u786E"; 1025 | } 1026 | }; 1027 | if (options.path) { 1028 | await getAppJsonFromPath(options.path); 1029 | } 1030 | const pages = []; 1031 | const validatePath = async (input) => { 1032 | await getAppJsonFromPath(input); 1033 | const subPackages = appJsonObject["subpackages"] ?? appJsonObject["subPackages"] ?? []; 1034 | pages.push(...appJsonObject["pages"] ?? []); 1035 | for (const subPackage of subPackages) { 1036 | const { root, pages: subPackagePages } = subPackage; 1037 | pages.push(...subPackagePages.map((page) => join(root, page))); 1038 | } 1039 | for (const page of pages) { 1040 | const pageJsonPath = resolve(input, page + ".json"); 1041 | try { 1042 | const pageJsonFile = await readFile(pageJsonPath); 1043 | const pageJsonObject = JSON.parse(pageJsonFile.toString()); 1044 | pageJsonObjects[page] = pageJsonObject; 1045 | } catch (err) { 1046 | throw `\u9875\u9762 ${page} \u7684\u914D\u7F6E\u6587\u4EF6\u4E0D\u5B58\u5728`; 1047 | } 1048 | } 1049 | }; 1050 | if (options.path) { 1051 | await validatePath(options.path); 1052 | } else { 1053 | await inquirer.prompt({ 1054 | type: "input", 1055 | name: "path", 1056 | message: "\u8BF7\u8F93\u5165\u5DE5\u7A0B\u7684\u6839\u76EE\u5F55:", 1057 | default: cwd(), 1058 | when: !options.path, 1059 | validate: async (input) => { 1060 | await validatePath(input); 1061 | return true; 1062 | }, 1063 | filter: (input) => resolve(input) 1064 | }).then((answer) => { 1065 | if (answer.path) { 1066 | options.path = answer.path; 1067 | } 1068 | }); 1069 | } 1070 | let globalSkyline = appJsonObject["renderer"] === "skyline"; 1071 | const answers = await inquirer.prompt([ 1072 | { 1073 | type: "confirm", 1074 | name: "appJsonEnableDynamicInjection", 1075 | message: `skyline \u4F9D\u8D56\u6309\u9700\u6CE8\u5165\u7279\u6027\uFF0C\u7ACB\u5373\u5F00\u542F\uFF1F 1076 | \u{1F4A1} \u6309\u9700\u6CE8\u5165\u7279\u6027\u8BE6\u89C1\u6587\u6863 https://developers.weixin.qq.com/miniprogram/dev/framework/ability/lazyload.html`, 1077 | default: false, 1078 | when: (hash) => { 1079 | const flag = appJsonObject["lazyCodeLoading"] !== "requiredComponents"; 1080 | if (!flag) 1081 | stdout.write(chalk.green("\u2705 skyline \u4F9D\u8D56\u6309\u9700\u6CE8\u5165\u7279\u6027\uFF0C\u5DF2\u5F00\u542F\n")); 1082 | return flag; 1083 | } 1084 | }, 1085 | { 1086 | type: "confirm", 1087 | name: "globalSkyline", 1088 | message: `\u662F\u5426\u5168\u5C40\u5F00\u542F skyline? 1089 | \u{1F4A1} \u5168\u5C40\u5F00\u542F skyline \u610F\u5473\u7740\u6574\u4E2A\u5C0F\u7A0B\u5E8F\u9700\u8981\u9002\u914D skyline\uFF0C\u5EFA\u8BAE\u5B58\u91CF\u5DE5\u7A0B\u9010\u4E2A\u9875\u9762\u5F00\u542F\uFF0C\u5168\u65B0\u5DE5\u7A0B\u53EF\u5168\u5C40\u5F00\u542F`, 1090 | default: false, 1091 | when: (hash) => { 1092 | const flag = !globalSkyline; 1093 | if (!flag) 1094 | stdout.write(chalk.green("\u2705 \u5DF2\u5168\u5C40\u5F00\u542F skyline\n")); 1095 | return flag; 1096 | } 1097 | }, 1098 | { 1099 | type: "input", 1100 | name: "skylinePages", 1101 | message: "\u8BF7\u8F93\u5165\u9700\u8981\u8FC1\u79FB\u7684\u9875\u9762\uFF08\u7528\u534A\u89D2\u9017\u53F7\u5206\u9694\uFF09", 1102 | filter: (input) => { 1103 | if (Array.isArray(input)) 1104 | return input; 1105 | return input.split(",").map((page) => page.trim()); 1106 | }, 1107 | validate: (pages2) => { 1108 | for (const page of pages2) { 1109 | if (!pageJsonObjects[page]) 1110 | return `\u9875\u9762 ${page} \u4E0D\u5B58\u5728`; 1111 | } 1112 | return true; 1113 | }, 1114 | default: () => Object.entries(pageJsonObjects).filter(([k, v]) => v["renderer"] === "skyline").map(([k]) => k), 1115 | when: () => appJsonObject["renderer"] !== "skyline" 1116 | } 1117 | ]); 1118 | if (!existsSync(options.path)) 1119 | return; 1120 | if (!appJsonObject) 1121 | return; 1122 | if (answers.globalSkyline) 1123 | globalSkyline = answers.globalSkyline; 1124 | if (answers.appJsonEnableDynamicInjection) { 1125 | appJsonObject["lazyCodeLoading"] = "requiredComponents"; 1126 | } 1127 | if (globalSkyline) { 1128 | appJsonObject["renderer"] = "skyline"; 1129 | answers.skylinePages = Object.keys(pageJsonObjects); 1130 | } 1131 | writeFile(appJsonPath, serialize(appJsonObject)); 1132 | if (appJsonObject.useExtendedLib) { 1133 | stdout.write( 1134 | format( 1135 | chalk.bold("\n============ %s %s ============\n"), 1136 | "App", 1137 | chalk.blue("app.json") 1138 | ) 1139 | ); 1140 | stdout.write( 1141 | format( 1142 | logColor[RuleLevel.Error]("@%s %s"), 1143 | "useExtendedLib", 1144 | "app.json \u6682\u4E0D\u652F\u6301 useExtendedLib" 1145 | ) 1146 | ); 1147 | stdout.write( 1148 | format("\n\u{1F4A1} %s\n", chalk.gray("\u5B8C\u6574\u529F\u80FD skyline \u540E\u7EED\u7248\u672C\u4F1A\u652F\u6301")) 1149 | ); 1150 | stdout.write(format(" %s\n", appJsonPath)); 1151 | } 1152 | const scan = async () => { 1153 | const checkList = []; 1154 | const fileMap = /* @__PURE__ */ new Map(); 1155 | for (const page of answers.skylinePages) { 1156 | const path2 = resolve(options.path, page); 1157 | if (isPathExcluded(path2)) 1158 | continue; 1159 | checkList.push(path2); 1160 | fileMap.set(path2, "page"); 1161 | } 1162 | const dfs = async (base, obj, isDir = false) => { 1163 | let pathDirname = base; 1164 | if (!isDir) { 1165 | if (base.startsWith(options.path)) { 1166 | pathDirname = dirname(base); 1167 | } else { 1168 | pathDirname = dirname(join("./", base)); 1169 | } 1170 | } 1171 | const compList = Object.values(obj?.["usingComponents"] ?? {}); 1172 | for (const comp of compList) { 1173 | let path2 = comp.startsWith("/") ? join(options.path, comp) : resolve(pathDirname, comp); 1174 | try { 1175 | const stat = lstatSync(path2); 1176 | if (stat.isDirectory()) 1177 | path2 = resolve(path2, "index"); 1178 | } catch (e) { 1179 | } 1180 | if (fileMap.has(path2) || isPathExcluded(path2) || !existsSync(`${path2}.json`)) 1181 | continue; 1182 | checkList.push(path2); 1183 | fileMap.set(path2, "comp"); 1184 | const json = JSON.parse((await readFile(`${path2}.json`)).toString()); 1185 | await dfs(path2, json); 1186 | } 1187 | }; 1188 | await dfs(options.path, appJsonObject, true); 1189 | for (const page of answers.skylinePages) { 1190 | const pagePath = resolve(options.path, page); 1191 | pageJsonObjects[page] && await dfs(pagePath, pageJsonObjects[page]); 1192 | } 1193 | const wxssFiles = []; 1194 | for (const pageOrComp of checkList) { 1195 | wxssFiles.push(...await globby([`${pageOrComp}.wxss`])); 1196 | } 1197 | const importedWXSS = await collectImportedWXSS( 1198 | wxssFiles, 1199 | options.path, 1200 | isPathExcluded 1201 | ); 1202 | const stringPatches = []; 1203 | let fileCount = 0; 1204 | let resultCount = 0; 1205 | const runOnFile = async (filename, env = {}) => { 1206 | let wxss = ""; 1207 | let wxml = ""; 1208 | let json = ""; 1209 | let astWXML; 1210 | let astWXSS; 1211 | let astJSON; 1212 | fileCount++; 1213 | if (!existsSync(filename)) 1214 | return []; 1215 | const raw = (await readFile(filename)).toString(); 1216 | if (filename.endsWith("wxss")) { 1217 | wxss = raw; 1218 | } else if (filename.endsWith("wxml")) { 1219 | wxml = raw; 1220 | astWXML = collectTemplate([filename])[0]; 1221 | } else if (filename.endsWith("json")) { 1222 | json = raw; 1223 | } 1224 | let parsed = parse({ 1225 | wxml, 1226 | wxss, 1227 | json, 1228 | astWXML, 1229 | astWXSS, 1230 | astJSON, 1231 | Rules, 1232 | env: { ...env, path: filename } 1233 | }); 1234 | const resultItems = []; 1235 | for (const { patches, results } of parsed.ruleResults) { 1236 | for (const item of results) { 1237 | if (disabledRules.has(item.name)) 1238 | continue; 1239 | resultItems.push({ 1240 | filename, 1241 | ...item 1242 | }); 1243 | } 1244 | stringPatches.push( 1245 | ...patches.filter((patch) => !disabledRules.has(patch.name)) 1246 | ); 1247 | } 1248 | return resultItems; 1249 | }; 1250 | const sortResults = (resultItems) => resultItems.sort((a, b) => { 1251 | return a.level !== b.level ? b.level - a.level : a.name.localeCompare(b.name); 1252 | }); 1253 | const printResults = (resultItems) => { 1254 | resultCount += resultItems.length; 1255 | let lastName = null; 1256 | for (const result of resultItems) { 1257 | if (options.logLevel > result.level) 1258 | continue; 1259 | const { 1260 | loc, 1261 | advice, 1262 | description, 1263 | name, 1264 | level, 1265 | fixable, 1266 | filename, 1267 | withCodeFrame 1268 | } = result; 1269 | const color = logColor[level]; 1270 | let filePath = ""; 1271 | const rawStr = readFileSync(loc?.path ?? result.filename).toString(); 1272 | if (!loc) { 1273 | filePath = filename; 1274 | } else { 1275 | filePath = formatSourceCodeLocation(rawStr, loc, { 1276 | withCodeFrame, 1277 | alternativeFilename: filename 1278 | }); 1279 | } 1280 | if (lastName !== name) { 1281 | stdout.write("\n"); 1282 | stdout.write(format(color("@%s %s"), name, description)); 1283 | fixable && stdout.write(chalk.green(" [\u53EF\u81EA\u52A8\u5B8C\u6210]")); 1284 | advice && stdout.write(format("\n\u{1F4A1} %s\n", chalk.gray(advice))); 1285 | } 1286 | stdout.write(format(" %s\n", filePath)); 1287 | lastName = name; 1288 | } 1289 | }; 1290 | for (const pageOrComp of checkList) { 1291 | const type = fileMap.get(pageOrComp); 1292 | const files = ["json", "wxml", "wxss"].map((ext) => [pageOrComp, ext].join(".")).filter((file) => existsSync(file)); 1293 | const astMap = /* @__PURE__ */ new Map(); 1294 | let results = []; 1295 | for (const filename of files) { 1296 | const result = await runOnFile(filename, { astMap }); 1297 | results.push(...result); 1298 | } 1299 | if (results.length) { 1300 | stdout.write( 1301 | format( 1302 | chalk.bold("\n============ %s %s ============\n"), 1303 | type?.toUpperCase(), 1304 | chalk.blue(relative(options.path, pageOrComp)) 1305 | ) 1306 | ); 1307 | printResults(sortResults(results)); 1308 | } 1309 | } 1310 | { 1311 | const jobs = [...importedWXSS].map((filename) => runOnFile(filename)); 1312 | const results = (await Promise.all(jobs)).flat(); 1313 | if (results.length) { 1314 | stdout.write( 1315 | format(chalk.bold("\n============ %s ============\n"), "Imported") 1316 | ); 1317 | printResults(sortResults(results)); 1318 | } 1319 | } 1320 | stdout.write("\n"); 1321 | const fixMessage = format( 1322 | "%d \u4E2A\u6587\u4EF6\u4E2D\u5171\u6709 %d \u5904\u95EE\u9898\uFF0C\u5176\u4E2D %d \u5904\u53EF\u4EE5\u81EA\u52A8\u4FEE\u590D\uFF0C\u662F\u5426\u8FDB\u884C\uFF1F\n", 1323 | fileCount, 1324 | resultCount, 1325 | stringPatches.length 1326 | ); 1327 | const fixAnswer = await inquirer.prompt([ 1328 | { 1329 | type: "confirm", 1330 | name: "applyFix", 1331 | message: fixMessage, 1332 | default: false, 1333 | when: stringPatches.length > 0 1334 | } 1335 | ]); 1336 | if (fixAnswer.applyFix) { 1337 | const filePatchMap = /* @__PURE__ */ new Map(); 1338 | for (const patch of stringPatches) { 1339 | const { path: path2 } = patch.loc; 1340 | if (!filePatchMap.has(path2)) { 1341 | if (!existsSync(path2)) 1342 | continue; 1343 | filePatchMap.set(path2, { 1344 | content: (await readFile(path2)).toString(), 1345 | patches: [] 1346 | }); 1347 | } 1348 | filePatchMap.get(path2)?.patches.push(patch); 1349 | } 1350 | for (const [path2, { patches, content }] of filePatchMap) { 1351 | const patchedString = applyPatchesOnString(content, patches); 1352 | await writeFile(path2, patchedString.toString()); 1353 | } 1354 | stdout.write(chalk.green("\u2705 \u4FEE\u590D\u5B8C\u6210")); 1355 | } 1356 | const { again } = await inquirer.prompt([ 1357 | { 1358 | type: "confirm", 1359 | name: "again", 1360 | message: "\u662F\u5426\u91CD\u65B0\u626B\u63CF\uFF1F", 1361 | default: false 1362 | } 1363 | ]); 1364 | if (again) 1365 | await scan(); 1366 | }; 1367 | await scan(); 1368 | }; 1369 | main().catch((err) => { 1370 | console.error(chalk.blue("\u274C"), err.message, err.stack); 1371 | }); 1372 | 1373 | export { main as default }; 1374 | -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | import * as domhandler from 'domhandler'; 2 | import { Document, Text, ProcessingInstruction, Comment, Element, CDATA, Node as Node$1 } from 'domhandler'; 3 | import { CssNode, WalkContext } from 'css-tree'; 4 | import parseJSON, { ArrayNode, IdentifierNode, LiteralNode, ObjectNode, PropertyNode, ValueNode } from 'json-to-ast'; 5 | 6 | interface PatchLocation { 7 | start: number; 8 | end: number; 9 | path: string; 10 | } 11 | declare const enum PatchStatus { 12 | Pending = 0, 13 | Applied = 1, 14 | Failed = 2 15 | } 16 | interface Patch { 17 | name: string; 18 | loc: PatchLocation; 19 | patchedStr: string; 20 | status: PatchStatus; 21 | } 22 | 23 | type WalkerReturnType = boolean | void; 24 | interface DefaultWalkerContext { 25 | parent: T; 26 | } 27 | type Walker = K extends Object ? (node: T, callback: (node: T, ctx: K) => WalkerReturnType, ctx: K) => WalkerReturnType : (node: T, callback: (node: T) => WalkerReturnType) => WalkerReturnType; 28 | 29 | interface NodeTypeMap { 30 | Root: Document; 31 | Text: Text; 32 | Directive: ProcessingInstruction; 33 | Comment: Comment; 34 | Script: Element; 35 | Style: Element; 36 | Tag: Element; 37 | CDATA: CDATA; 38 | Doctype: ProcessingInstruction; 39 | } 40 | type HTMLWalker = Walker>; 41 | 42 | type CSSWalker = Walker; 43 | 44 | type Node = ArrayNode | IdentifierNode | LiteralNode | ObjectNode | PropertyNode; 45 | type JSONWalker = Walker>; 46 | 47 | declare enum RuleLevel { 48 | Verbose = 0, 49 | Info = 1, 50 | Warn = 2, 51 | Error = 3 52 | } 53 | interface BasicLocation { 54 | path: string | null; 55 | } 56 | interface LocationLnColBased extends BasicLocation { 57 | startLn: number; 58 | endLn: number; 59 | startCol: number; 60 | endCol: number; 61 | } 62 | interface LocationIndexBased extends BasicLocation { 63 | startIndex: number; 64 | endIndex: number; 65 | } 66 | type SourceCodeLocation = LocationIndexBased | LocationLnColBased; 67 | interface RuleResultItem { 68 | name: string; 69 | description?: string; 70 | advice?: string; 71 | patchHint?: string; 72 | loc?: SourceCodeLocation; 73 | level: RuleLevel; 74 | fixable?: boolean; 75 | withCodeFrame?: boolean; 76 | } 77 | declare const enum RuleType { 78 | Unknown = 0, 79 | WXML = 1, 80 | WXSS = 2, 81 | Node = 3, 82 | JSON = 4 83 | } 84 | interface HookType { 85 | [RuleType.WXML]: Parameters[1]; 86 | [RuleType.WXSS]: Parameters[1]; 87 | [RuleType.JSON]: Parameters[1]; 88 | [RuleType.Node]: any; 89 | [RuleType.Unknown]: never; 90 | } 91 | interface Hooks { 92 | before?: () => void; 93 | onVisit?: HookType[T]; 94 | after?: () => void; 95 | } 96 | interface RuleBasicInfo { 97 | name: string; 98 | type: T; 99 | level: RuleLevel; 100 | } 101 | interface Rule extends Hooks, RuleBasicInfo { 102 | get results(): RuleResultItem[]; 103 | get patches(): Patch[]; 104 | get astPatches(): Function[]; 105 | clear(): void; 106 | } 107 | interface RuleContext { 108 | lifetimes(hooks: Hooks): void; 109 | addResult(...results: RuleResultItem[]): void; 110 | addResultWithPatch(result: RuleResultItem, patch: QuickPatch): void; 111 | addPatch(...patches: Omit[]): void; 112 | addASTPatch(...patches: Function[]): void; 113 | getRelatedWXMLFilename(): string | undefined; 114 | getRelatedWXMLAst(): Document | null; 115 | env: K; 116 | } 117 | type QuickPatch = Pick; 118 | type RuleBasicInfoWithOptionalLevel = Pick, "name" | "type">; 119 | declare const defineRule: (info: RuleBasicInfoWithOptionalLevel, init: (ctx: RuleContext) => void) => (env: Env) => Rule; 120 | 121 | interface BasicParseEnv { 122 | path: string; 123 | astMap?: Map; 124 | } 125 | interface IParseOptions { 126 | wxml?: string; 127 | wxss?: string; 128 | json?: string; 129 | Rules?: ((env: T) => Rule)[]; 130 | env: T; 131 | astWXML?: NodeTypeMap["Root"]; 132 | astWXSS?: CssNode; 133 | astJSON?: ValueNode; 134 | } 135 | declare const parse: (options: IParseOptions) => { 136 | astWXML: domhandler.Document | undefined; 137 | astWXSS: CssNode | undefined; 138 | astJSON: parseJSON.ValueNode | undefined; 139 | ruleResults: { 140 | name: string; 141 | level: RuleLevel; 142 | results: RuleResultItem[]; 143 | patches: Patch[]; 144 | }[]; 145 | }; 146 | 147 | export { defineRule, parse }; 148 | -------------------------------------------------------------------------------- /dist/index.mjs: -------------------------------------------------------------------------------- 1 | export { d as defineRule, p as parse } from './chunks/parser.mjs'; 2 | import 'css-tree'; 3 | import 'htmlparser2'; 4 | import 'json-to-ast'; 5 | import 'magic-string'; 6 | import 'domhandler'; 7 | -------------------------------------------------------------------------------- /doc/assets/00_follow_the_guide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wechat-miniprogram/skylint/c2f28943ebe2f33a98427d7e18b7086595dcd7be/doc/assets/00_follow_the_guide.png -------------------------------------------------------------------------------- /doc/assets/01_results.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wechat-miniprogram/skylint/c2f28943ebe2f33a98427d7e18b7086595dcd7be/doc/assets/01_results.png -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | export default { 7 | // All imported modules in your tests should be mocked automatically 8 | // automock: false, 9 | 10 | // Stop running tests after `n` failures 11 | // bail: 0, 12 | 13 | // The directory where Jest should store its cached dependency information 14 | // cacheDirectory: "/private/var/folders/g_/s1051mtx7lb84wkwnr_l7vzc0000gn/T/jest_dx", 15 | 16 | // Automatically clear mock calls, instances, contexts and results before every test 17 | // clearMocks: false, 18 | 19 | // Indicates whether the coverage information should be collected while executing the test 20 | // collectCoverage: false, 21 | 22 | // An array of glob patterns indicating a set of files for which coverage information should be collected 23 | // collectCoverageFrom: undefined, 24 | 25 | // The directory where Jest should output its coverage files 26 | // coverageDirectory: undefined, 27 | 28 | // An array of regexp pattern strings used to skip coverage collection 29 | // coveragePathIgnorePatterns: [ 30 | // "/node_modules/" 31 | // ], 32 | 33 | // Indicates which provider should be used to instrument code for coverage 34 | coverageProvider: "v8", 35 | 36 | // A list of reporter names that Jest uses when writing coverage reports 37 | // coverageReporters: [ 38 | // "json", 39 | // "text", 40 | // "lcov", 41 | // "clover" 42 | // ], 43 | 44 | // An object that configures minimum threshold enforcement for coverage results 45 | // coverageThreshold: undefined, 46 | 47 | // A path to a custom dependency extractor 48 | // dependencyExtractor: undefined, 49 | 50 | // Make calling deprecated APIs throw helpful error messages 51 | // errorOnDeprecated: false, 52 | 53 | // The default configuration for fake timers 54 | // fakeTimers: { 55 | // "enableGlobally": false 56 | // }, 57 | 58 | // Force coverage collection from ignored files using an array of glob patterns 59 | // forceCoverageMatch: [], 60 | 61 | // A path to a module which exports an async function that is triggered once before all test suites 62 | // globalSetup: undefined, 63 | 64 | // A path to a module which exports an async function that is triggered once after all test suites 65 | // globalTeardown: undefined, 66 | 67 | // A set of global variables that need to be available in all test environments 68 | // globals: {}, 69 | 70 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 71 | // maxWorkers: "50%", 72 | 73 | // An array of directory names to be searched recursively up from the requiring module's location 74 | // moduleDirectories: [ 75 | // "node_modules" 76 | // ], 77 | 78 | // An array of file extensions your modules use 79 | // moduleFileExtensions: [ 80 | // "js", 81 | // "mjs", 82 | // "cjs", 83 | // "jsx", 84 | // "ts", 85 | // "tsx", 86 | // "json", 87 | // "node" 88 | // ], 89 | 90 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 91 | // moduleNameMapper: {}, 92 | 93 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 94 | // modulePathIgnorePatterns: [], 95 | 96 | // Activates notifications for test results 97 | // notify: false, 98 | 99 | // An enum that specifies notification mode. Requires { notify: true } 100 | // notifyMode: "failure-change", 101 | 102 | // A preset that is used as a base for Jest's configuration 103 | // preset: undefined, 104 | 105 | // Run tests from one or more projects 106 | // projects: undefined, 107 | 108 | // Use this configuration option to add custom reporters to Jest 109 | // reporters: undefined, 110 | 111 | // Automatically reset mock state before every test 112 | // resetMocks: false, 113 | 114 | // Reset the module registry before running each individual test 115 | // resetModules: false, 116 | 117 | // A path to a custom resolver 118 | // resolver: undefined, 119 | 120 | // Automatically restore mock state and implementation before every test 121 | // restoreMocks: false, 122 | 123 | // The root directory that Jest should scan for tests and modules within 124 | // rootDir: undefined, 125 | 126 | // A list of paths to directories that Jest should use to search for files in 127 | // roots: [ 128 | // "" 129 | // ], 130 | 131 | // Allows you to use a custom runner instead of Jest's default test runner 132 | // runner: "jest-runner", 133 | 134 | // The paths to modules that run some code to configure or set up the testing environment before each test 135 | // setupFiles: [], 136 | 137 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 138 | // setupFilesAfterEnv: [], 139 | 140 | // The number of seconds after which a test is considered as slow and reported as such in the results. 141 | // slowTestThreshold: 5, 142 | 143 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 144 | // snapshotSerializers: [], 145 | 146 | // The test environment that will be used for testing 147 | // testEnvironment: "jest-environment-node", 148 | 149 | // Options that will be passed to the testEnvironment 150 | // testEnvironmentOptions: {}, 151 | 152 | // Adds a location field to test results 153 | // testLocationInResults: false, 154 | 155 | // The glob patterns Jest uses to detect test files 156 | // testMatch: [ 157 | // "**/__tests__/**/*.[jt]s?(x)", 158 | // "**/?(*.)+(spec|test).[tj]s?(x)" 159 | // ], 160 | 161 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 162 | // testPathIgnorePatterns: [ 163 | // "/node_modules/" 164 | // ], 165 | 166 | // The regexp pattern or array of patterns that Jest uses to detect test files 167 | // testRegex: [], 168 | 169 | // This option allows the use of a custom results processor 170 | // testResultsProcessor: undefined, 171 | 172 | // This option allows use of a custom test runner 173 | // testRunner: "jest-circus/runner", 174 | 175 | // A map from regular expressions to paths to transformers 176 | // transform: undefined, 177 | 178 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 179 | // transformIgnorePatterns: [ 180 | // "/node_modules/", 181 | // "\\.pnp\\.[^\\/]+$" 182 | // ], 183 | 184 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 185 | // unmockedModulePathPatterns: undefined, 186 | 187 | // Indicates whether each individual test should be reported during the run 188 | // verbose: undefined, 189 | 190 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 191 | // watchPathIgnorePatterns: [], 192 | 193 | // Whether to use watchman for file crawling 194 | // watchman: true, 195 | }; 196 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "skylint", 3 | "version": "1.0.2", 4 | "description": "Skyline 小程序迁移工具. Migration assistant for Skyline miniapp.", 5 | "main": "dist/index.mjs", 6 | "type": "module", 7 | "scripts": { 8 | "build": "unbuild", 9 | "start": "node dist/cli.mjs", 10 | "test": "NODE_OPTIONS='--experimental-vm-modules' jest" 11 | }, 12 | "bin": { 13 | "skylint": "./dist/cli.mjs" 14 | }, 15 | "keywords": [ 16 | "skyline", 17 | "linter", 18 | "migration" 19 | ], 20 | "author": "maniacata", 21 | "license": "MIT", 22 | "files": [ 23 | "./dist", 24 | "./LICENSE", 25 | "./third-party-licenses.txt" 26 | ], 27 | "dependencies": { 28 | "@babel/code-frame": "^7.18.6", 29 | "@babel/preset-typescript": "^7.18.6", 30 | "acorn": "^8.7.1", 31 | "chalk": "^5.0.1", 32 | "commander": "^9.4.0", 33 | "css-select": "^5.1.0", 34 | "css-tree": "^2.1.0", 35 | "dom-serializer": "^2.0.0", 36 | "domhandler": "^5.0.3", 37 | "domutils": "^3.0.1", 38 | "globby": "^13.1.2", 39 | "htmlparser2": "^8.0.1", 40 | "inquirer": "^9.0.1", 41 | "jest": "^28.1.3", 42 | "json-to-ast": "^2.1.0", 43 | "line-column": "^1.0.2", 44 | "magic-string": "^0.26.2", 45 | "parse5": "^7.0.0", 46 | "ts-node": "^10.9.1", 47 | "typescript": "^4.7.4", 48 | "unbuild": "^0.7.4" 49 | }, 50 | "devDependencies": { 51 | "@babel/core": "^7.18.9", 52 | "@babel/preset-env": "^7.18.9", 53 | "@types/babel__code-frame": "^7.0.3", 54 | "@types/inquirer": "^8.2.1", 55 | "@types/json-to-ast": "^2.1.2", 56 | "@types/css-tree": "^1.0.7", 57 | "@types/line-column": "^1.0.0", 58 | "@types/node": "^18.0.6", 59 | "babel-jest": "^28.1.3" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { Command } from "commander"; 3 | import { cwd, argv, stdout } from "process"; 4 | import { globby } from "globby"; 5 | import { readFile, writeFile } from "fs/promises"; 6 | import chalk from "chalk"; 7 | import pkg from "../package.json"; 8 | import { BasicParseEnv, parse } from "./parser"; 9 | 10 | // WXML rules 11 | import RuleNagivator from "./rules/navigator"; 12 | import RuleNoInlineText from "./rules/no-inline-text"; 13 | import RuleNoSvgStyleTag from "./rules/no-svg-style-tag"; 14 | import RuleUnsupportedComponent from "./rules/unsupported-component"; 15 | // WXSS rules 16 | import RuleDisplayFlex from "./rules/display-flex"; 17 | import RuleDisplayInline from "./rules/display-inline"; 18 | import RuleMarkWxFor from "./rules/mark-wx-for"; 19 | import RulePositionFixed from "./rules/position-fixed"; 20 | import RuleTextOverflowEllipse from "./rules/text-overflow-ellipse"; 21 | // JSON rules 22 | import RuleNoNativeNav from "./rules/no-native-nav"; 23 | import RuleDisableScroll from "./rules/disable-scroll"; 24 | import RuleRendererSkyline from "./rules/renderer-skyline"; 25 | // Mixed rules 26 | import RuleScrollView from "./rules/scroll-view"; 27 | 28 | import { RuleLevel, RuleResultItem } from "./rules/interface"; 29 | import { format } from "util"; 30 | // import { serialize as serializeHTML } from "./serializer/html"; 31 | // import { serialize as serializeCSS } from "./serializer/css"; 32 | import { serialize as serializeJSON } from "./serializer/json"; 33 | 34 | import inquirer from "inquirer"; 35 | import path, { resolve, dirname, relative, join } from "path"; 36 | import { Patch, applyPatchesOnString } from "./patch"; 37 | import { existsSync, readFileSync, lstatSync } from "fs"; 38 | import { collectImportedWXSS } from "./utils/collect-wxss"; 39 | import { formatSourceCodeLocation } from "./utils/print-code"; 40 | import { NodeTypeMap } from "./walker/html"; 41 | import { Node as CssNode } from "./walker/css"; 42 | import { Node as JsonNode, ValueNode } from "./walker/json"; 43 | import { collectTemplate } from "./utils/collect-template"; 44 | 45 | const Rules = [ 46 | // WXML rules 47 | RuleNagivator, 48 | RuleNoInlineText, 49 | RuleNoSvgStyleTag, 50 | RuleUnsupportedComponent, 51 | // WXSS rules 52 | RuleDisplayFlex, 53 | RuleDisplayInline, 54 | RuleMarkWxFor, 55 | RulePositionFixed, 56 | RuleTextOverflowEllipse, 57 | // JSON rules 58 | RuleNoNativeNav, 59 | RuleDisableScroll, 60 | RuleRendererSkyline, 61 | // Mixed rules 62 | RuleScrollView, 63 | ].flat(); 64 | 65 | const logColor = { 66 | [RuleLevel.Verbose]: chalk.cyan, 67 | [RuleLevel.Info]: chalk.blue, 68 | [RuleLevel.Warn]: chalk.yellow, 69 | [RuleLevel.Error]: chalk.red, 70 | }; 71 | 72 | interface ICliOptions { 73 | path?: string; 74 | logLevel: number; 75 | ignore: string[]; 76 | exclude: string[]; 77 | } 78 | 79 | const splitString = (input: string | string[]) => { 80 | if (Array.isArray(input)) return input; 81 | return input.split(",").map((item) => item.trim()); 82 | }; 83 | 84 | const cli = new Command(); 85 | cli.name(pkg.name); 86 | cli.version(pkg.version); 87 | 88 | cli.option( 89 | "-p, --path [string]", 90 | "工程的根目录", 91 | (input) => path.resolve(input), 92 | "" 93 | ); 94 | cli.option( 95 | "-l, --log-level [number]", 96 | "依日志等级过滤,从 0 到 3", 97 | parseInt, 98 | 0 99 | ); 100 | cli.option( 101 | "-i, --ignore [string]", 102 | "要忽略的规则名,用半角逗号分隔", 103 | splitString, 104 | [] as string[] 105 | ); 106 | cli.option( 107 | "-e, --exclude [string]", 108 | "要排除的路径名的正则表达式,用半角逗号分隔", 109 | splitString, 110 | [] as string[] 111 | ); 112 | 113 | cli.parse(argv); 114 | 115 | const options = cli.opts(); 116 | 117 | interface PromptAnswer { 118 | autoAppJson: boolean; 119 | appJsonEnableDynamicInjection: boolean; 120 | globalSkyline: boolean; 121 | usePageSelector: boolean; 122 | skylinePages: string[]; 123 | } 124 | 125 | interface ExtendedRuleResultItem extends RuleResultItem { 126 | filename: string; 127 | } 128 | 129 | const main = async () => { 130 | let appJsonPath: string = ""; 131 | let appJsonObject: any = null; 132 | let pageJsonObjects: Record = []; 133 | 134 | const disabledRules = new Set(options.ignore); 135 | const excludedFiles = options.exclude.map((str) => new RegExp(str)); 136 | const isPathExcluded = (path: string) => 137 | excludedFiles.some((regex) => regex.test(path)); 138 | 139 | const getAppJsonFromPath = async (path: string) => { 140 | try { 141 | appJsonPath = resolve(path, "app.json"); 142 | const appJsonFile = await readFile(appJsonPath); 143 | appJsonObject = JSON.parse(appJsonFile.toString()); 144 | } catch (e) { 145 | throw "无效 app.json,请检查路径和语法是否正确"; 146 | } 147 | }; 148 | 149 | if (options.path) { 150 | await getAppJsonFromPath(options.path); 151 | } 152 | 153 | const pages: string[] = []; 154 | 155 | const validatePath = async (input: string) => { 156 | await getAppJsonFromPath(input); 157 | const subPackages = 158 | appJsonObject["subpackages"] ?? appJsonObject["subPackages"] ?? []; 159 | pages.push(...(appJsonObject["pages"] ?? [])); 160 | for (const subPackage of subPackages) { 161 | const { root, pages: subPackagePages } = subPackage; 162 | pages.push(...subPackagePages.map((page: string) => join(root, page))); 163 | } 164 | 165 | for (const page of pages) { 166 | const pageJsonPath = resolve(input, page + ".json"); 167 | try { 168 | const pageJsonFile = await readFile(pageJsonPath); 169 | const pageJsonObject = JSON.parse(pageJsonFile.toString()); 170 | pageJsonObjects[page] = pageJsonObject; 171 | } catch (err) { 172 | throw `页面 ${page} 的配置文件不存在`; 173 | } 174 | } 175 | }; 176 | 177 | if (options.path) { 178 | await validatePath(options.path); 179 | } else { 180 | await inquirer 181 | .prompt>({ 182 | type: "input", 183 | name: "path", 184 | message: "请输入工程的根目录:", 185 | default: cwd(), 186 | when: !options.path, 187 | validate: async (input) => { 188 | await validatePath(input); 189 | return true; 190 | }, 191 | filter: (input) => resolve(input), 192 | }) 193 | .then((answer) => { 194 | if (answer.path) { 195 | options.path = answer.path; 196 | } 197 | }); 198 | } 199 | 200 | let globalSkyline = appJsonObject["renderer"] === "skyline"; 201 | 202 | const answers = await inquirer.prompt([ 203 | { 204 | type: "confirm", 205 | name: "appJsonEnableDynamicInjection", 206 | message: `skyline 依赖按需注入特性,立即开启? 207 | 💡 按需注入特性详见文档 https://developers.weixin.qq.com/miniprogram/dev/framework/ability/lazyload.html`, 208 | default: false, 209 | when: (hash) => { 210 | const flag = appJsonObject["lazyCodeLoading"] !== "requiredComponents"; 211 | if (!flag) 212 | stdout.write(chalk.green("✅ skyline 依赖按需注入特性,已开启\n")); 213 | return flag; 214 | }, 215 | }, 216 | { 217 | type: "confirm", 218 | name: "globalSkyline", 219 | message: `是否全局开启 skyline? 220 | 💡 全局开启 skyline 意味着整个小程序需要适配 skyline,建议存量工程逐个页面开启,全新工程可全局开启`, 221 | default: false, 222 | when: (hash) => { 223 | const flag = !globalSkyline; 224 | if (!flag) stdout.write(chalk.green("✅ 已全局开启 skyline\n")); 225 | return flag; 226 | }, 227 | }, 228 | { 229 | type: "input", 230 | name: "skylinePages", 231 | message: "请输入需要迁移的页面(用半角逗号分隔)", 232 | filter: (input: string | string[]) => { 233 | if (Array.isArray(input)) return input; 234 | return input.split(",").map((page) => page.trim()); 235 | }, 236 | validate: (pages: string[]) => { 237 | for (const page of pages) { 238 | if (!pageJsonObjects[page]) return `页面 ${page} 不存在`; 239 | } 240 | return true; 241 | }, 242 | 243 | default: () => 244 | Object.entries(pageJsonObjects) 245 | .filter(([k, v]) => v["renderer"] === "skyline") 246 | .map(([k]) => k), 247 | when: () => appJsonObject["renderer"] !== "skyline", 248 | }, 249 | ]); 250 | 251 | if (!existsSync(options.path!)) return; 252 | 253 | if (!appJsonObject) return; 254 | 255 | if (answers.globalSkyline) globalSkyline = answers.globalSkyline; 256 | 257 | if (answers.appJsonEnableDynamicInjection) { 258 | appJsonObject["lazyCodeLoading"] = "requiredComponents"; 259 | } 260 | 261 | if (globalSkyline) { 262 | appJsonObject["renderer"] = "skyline"; 263 | answers.skylinePages = Object.keys(pageJsonObjects); 264 | } 265 | 266 | writeFile(appJsonPath, serializeJSON(appJsonObject)); 267 | 268 | if (appJsonObject.useExtendedLib) { 269 | stdout.write( 270 | format( 271 | chalk.bold("\n============ %s %s ============\n"), 272 | "App", 273 | chalk.blue("app.json") 274 | ) 275 | ); 276 | stdout.write( 277 | format( 278 | logColor[RuleLevel.Error]("@%s %s"), 279 | "useExtendedLib", 280 | "app.json 暂不支持 useExtendedLib" 281 | ) 282 | ); 283 | stdout.write( 284 | format("\n💡 %s\n", chalk.gray("完整功能 skyline 后续版本会支持")) 285 | ); 286 | stdout.write(format(" %s\n", appJsonPath)); 287 | } 288 | 289 | const scan = async () => { 290 | const checkList: string[] = []; 291 | 292 | type FileType = "page" | "comp" | "imported"; 293 | 294 | const fileMap = new Map(); 295 | 296 | // collect pages 297 | // const pages: string[] = answers.skylinePages.map((page) => resolve(options.path!, page)); 298 | for (const page of answers.skylinePages) { 299 | const path = resolve(options.path!, page); 300 | if (isPathExcluded(path)) continue; 301 | checkList.push(path); 302 | fileMap.set(path, "page"); 303 | } 304 | // collect used components 305 | // const usedComponents: string[] = []; 306 | const dfs = async (base: string, obj: any, isDir = false) => { 307 | let pathDirname = base; 308 | if (!isDir) { 309 | if (base.startsWith(options.path!)) { 310 | pathDirname = dirname(base); 311 | } else { 312 | pathDirname = dirname(join("./", base)); 313 | } 314 | } 315 | 316 | const compList: string[] = Object.values(obj?.["usingComponents"] ?? {}); 317 | for (const comp of compList) { 318 | let path = comp.startsWith("/") 319 | ? join(options.path!, comp) 320 | : resolve(pathDirname, comp); 321 | try { 322 | const stat = lstatSync(path); 323 | if (stat.isDirectory()) path = resolve(path, "index"); 324 | } catch (e) {} 325 | if ( 326 | fileMap.has(path) || 327 | isPathExcluded(path) || 328 | !existsSync(`${path}.json`) 329 | ) 330 | continue; 331 | checkList.push(path); 332 | fileMap.set(path, "comp"); 333 | const json = JSON.parse((await readFile(`${path}.json`)).toString()); 334 | await dfs(path, json); 335 | } 336 | }; 337 | await dfs(options.path!, appJsonObject, true); 338 | for (const page of answers.skylinePages) { 339 | const pagePath = resolve(options.path!, page); 340 | pageJsonObjects[page] && (await dfs(pagePath, pageJsonObjects[page])); 341 | } 342 | 343 | // collect imported wxss 344 | const wxssFiles: string[] = []; 345 | for (const pageOrComp of checkList) { 346 | // wxssFiles.push(`${pageOrComp}.wxss`); 347 | wxssFiles.push(...(await globby([`${pageOrComp}.wxss`]))); 348 | } 349 | const importedWXSS = await collectImportedWXSS( 350 | wxssFiles, 351 | options.path!, 352 | isPathExcluded 353 | ); 354 | 355 | // collet patches 356 | // const stringPatchesMap = new Map(); 357 | const stringPatches: Patch[] = []; 358 | 359 | let fileCount = 0; 360 | let resultCount = 0; 361 | 362 | const runOnFile = async ( 363 | filename: string, 364 | env: Partial = {} 365 | ) => { 366 | let wxss = ""; 367 | let wxml = ""; 368 | let json = ""; 369 | let astWXML: NodeTypeMap["Root"] | undefined; 370 | let astWXSS: CssNode | undefined; 371 | let astJSON: ValueNode | undefined; 372 | fileCount++; 373 | if (!existsSync(filename)) return []; 374 | const raw = (await readFile(filename)).toString(); 375 | if (filename.endsWith("wxss")) { 376 | wxss = raw; 377 | } else if (filename.endsWith("wxml")) { 378 | wxml = raw; 379 | astWXML = collectTemplate([filename])[0]; 380 | } else if (filename.endsWith("json")) { 381 | json = raw; 382 | } 383 | let parsed = parse({ 384 | wxml, 385 | wxss, 386 | json, 387 | astWXML, 388 | astWXSS, 389 | astJSON, 390 | Rules, 391 | env: { ...env, path: filename }, 392 | }); 393 | const resultItems: ExtendedRuleResultItem[] = []; 394 | for (const { patches, results } of parsed.ruleResults) { 395 | for (const item of results) { 396 | if (disabledRules.has(item.name)) continue; 397 | resultItems.push({ 398 | filename, 399 | ...item, 400 | }); 401 | } 402 | stringPatches.push( 403 | ...patches.filter((patch) => !disabledRules.has(patch.name)) 404 | ); 405 | } 406 | return resultItems; 407 | }; 408 | 409 | const sortResults = (resultItems: ExtendedRuleResultItem[]) => 410 | resultItems.sort((a, b) => { 411 | return a.level !== b.level 412 | ? b.level - a.level 413 | : a.name.localeCompare(b.name); 414 | }); 415 | 416 | const printResults = (resultItems: ExtendedRuleResultItem[]) => { 417 | resultCount += resultItems.length; 418 | let lastName: string | null = null; 419 | for (const result of resultItems) { 420 | if (options.logLevel > result.level) continue; 421 | const { 422 | loc, 423 | advice, 424 | description, 425 | name, 426 | level, 427 | fixable, 428 | filename, 429 | withCodeFrame, 430 | } = result; 431 | const color = logColor[level]; 432 | 433 | let filePath = ""; 434 | const rawStr = readFileSync(loc?.path ?? result.filename).toString(); 435 | if (!loc) { 436 | filePath = filename; 437 | } else { 438 | filePath = formatSourceCodeLocation(rawStr, loc, { 439 | withCodeFrame, 440 | alternativeFilename: filename, 441 | }); 442 | } 443 | if (lastName !== name) { 444 | stdout.write("\n"); 445 | stdout.write(format(color("@%s %s"), name, description)); 446 | fixable && stdout.write(chalk.green(" [可自动完成]")); 447 | advice && stdout.write(format("\n💡 %s\n", chalk.gray(advice))); 448 | } 449 | stdout.write(format(" %s\n", filePath)); 450 | lastName = name; 451 | } 452 | }; 453 | for (const pageOrComp of checkList) { 454 | const type = fileMap.get(pageOrComp); 455 | const files = ["json", "wxml", "wxss"] 456 | .map((ext) => [pageOrComp, ext].join(".")) 457 | .filter((file) => existsSync(file)); 458 | const astMap = new Map(); 459 | let results: ExtendedRuleResultItem[] = []; 460 | for (const filename of files) { 461 | const result = await runOnFile(filename, { astMap }); 462 | results.push(...result); 463 | } 464 | 465 | if (results.length) { 466 | stdout.write( 467 | format( 468 | chalk.bold("\n============ %s %s ============\n"), 469 | type?.toUpperCase(), 470 | chalk.blue(relative(options.path!, pageOrComp)) 471 | ) 472 | ); 473 | printResults(sortResults(results)); 474 | } 475 | } 476 | 477 | { 478 | const jobs = [...importedWXSS].map((filename) => runOnFile(filename)); 479 | const results = (await Promise.all(jobs)).flat(); 480 | if (results.length) { 481 | stdout.write( 482 | format(chalk.bold("\n============ %s ============\n"), "Imported") 483 | ); 484 | printResults(sortResults(results)); 485 | } 486 | } 487 | 488 | stdout.write("\n"); 489 | const fixMessage = format( 490 | "%d 个文件中共有 %d 处问题,其中 %d 处可以自动修复,是否进行?\n", 491 | fileCount, 492 | resultCount, 493 | stringPatches.length 494 | ); 495 | 496 | type FixAnswer = Record<"applyFix", boolean>; 497 | 498 | const fixAnswer = await inquirer.prompt([ 499 | { 500 | type: "confirm", 501 | name: "applyFix", 502 | message: fixMessage, 503 | default: false, 504 | when: stringPatches.length > 0, 505 | }, 506 | ]); 507 | 508 | if (fixAnswer.applyFix) { 509 | const filePatchMap = new Map< 510 | string, 511 | { content: string; patches: Patch[] } 512 | >(); 513 | for (const patch of stringPatches) { 514 | const { path } = patch.loc; 515 | if (!filePatchMap.has(path)) { 516 | if (!existsSync(path)) continue; 517 | filePatchMap.set(path, { 518 | content: (await readFile(path)).toString(), 519 | patches: [], 520 | }); 521 | } 522 | filePatchMap.get(path)?.patches.push(patch); 523 | } 524 | for (const [path, { patches, content }] of filePatchMap) { 525 | const patchedString = applyPatchesOnString(content, patches); 526 | await writeFile(path, patchedString.toString()); 527 | } 528 | stdout.write(chalk.green("✅ 修复完成")); 529 | } 530 | 531 | type AgainAnswer = Record<"again", boolean>; 532 | 533 | const { again } = await inquirer.prompt([ 534 | { 535 | type: "confirm", 536 | name: "again", 537 | message: "是否重新扫描?", 538 | default: false, 539 | }, 540 | ]); 541 | 542 | if (again) await scan(); 543 | }; 544 | await scan(); 545 | }; 546 | 547 | main().catch((err: Error) => { 548 | console.error(chalk.blue("❌"), err.message, err.stack); 549 | }); 550 | 551 | export default main; 552 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { parse } from "./parser"; 2 | export { defineRule } from "./rules/interface"; 3 | -------------------------------------------------------------------------------- /src/parser.ts: -------------------------------------------------------------------------------- 1 | import { parse as parseCSS, CssNode } from "css-tree"; 2 | import { parseDocument as parseXML } from "htmlparser2"; 3 | import parseJSON, { type ValueNode } from "json-to-ast"; 4 | import { Rule, RuleType } from "./rules/interface"; 5 | import { NodeTypeMap, walk as walkHTML } from "./walker/html"; 6 | import { walk as walkCSS } from "./walker/css"; 7 | import { walk as walkJSON } from "./walker/json"; 8 | import { Walker } from "./walker/interface"; 9 | import { naivePrint } from "./utils/dom-ast"; 10 | 11 | export interface BasicParseEnv { 12 | path: string; 13 | astMap?: Map; 14 | } 15 | 16 | interface IParseOptions { 17 | wxml?: string; 18 | wxss?: string; 19 | json?: string; 20 | Rules?: ((env: T) => Rule)[]; 21 | env: T; 22 | astWXML?: NodeTypeMap["Root"]; 23 | astWXSS?: CssNode; 24 | astJSON?: ValueNode; 25 | } 26 | 27 | const classifyRules = (rules: Rule[]) => { 28 | const wxmlRules: Rule[] = []; 29 | const wxssRules: Rule[] = []; 30 | const nodeRules: Rule[] = []; 31 | const jsonRules: Rule[] = []; 32 | const anyRules: Rule[] = []; 33 | 34 | for (const rule of rules) { 35 | anyRules.push(rule); 36 | switch (rule.type) { 37 | case RuleType.WXML: 38 | wxmlRules.push(rule); 39 | continue; 40 | case RuleType.WXSS: 41 | wxssRules.push(rule); 42 | continue; 43 | case RuleType.Node: 44 | nodeRules.push(rule); 45 | continue; 46 | case RuleType.JSON: 47 | jsonRules.push(rule); 48 | continue; 49 | default: 50 | break; 51 | } 52 | } 53 | return { wxmlRules, wxssRules, nodeRules, jsonRules, anyRules }; 54 | }; 55 | 56 | const runLifetimeHooks = (rules: Rule[], ast: any, walker: Walker) => { 57 | rules.forEach((rule) => rule.before?.()); 58 | walker( 59 | ast, 60 | (...args: any[]) => { 61 | rules.forEach((rule) => { 62 | rule.onVisit?.(...args); 63 | }); 64 | }, 65 | null as any 66 | ); 67 | rules.forEach((rule) => rule.after?.()); 68 | }; 69 | 70 | const extractResultFromRules = (rules: Rule[]) => { 71 | return rules.flatMap((rule) => { 72 | const { name, level, results, patches } = rule; 73 | if (!results.length) return []; 74 | return { name, level, results, patches }; 75 | }); 76 | }; 77 | 78 | export const parse = (options: IParseOptions) => { 79 | const { wxml, wxss, json, Rules = [], env } = options; 80 | const rules = Rules.map((Rule) => Rule(env)); // inject env into rules 81 | const { wxmlRules, wxssRules, nodeRules, jsonRules, anyRules } = classifyRules(rules); 82 | let { astJSON, astWXML, astWXSS } = options; 83 | 84 | if (wxml && !astWXML) astWXML = parseXML(wxml, { xmlMode: true, withStartIndices: true, withEndIndices: true }); 85 | if (astWXML) { 86 | env.astMap?.set(env.path, astWXML); 87 | runLifetimeHooks(wxmlRules, astWXML, walkHTML); 88 | } 89 | 90 | if (wxss && !astWXSS) astWXSS = parseCSS(wxss, { positions: true }); 91 | if (astWXSS) { 92 | env.astMap?.set(env.path, astWXSS); 93 | runLifetimeHooks(wxssRules, astWXSS, walkCSS); 94 | } 95 | 96 | if (json && !astJSON) astJSON = parseJSON(json); 97 | if (astJSON) { 98 | env.astMap?.set(env.path, astJSON); 99 | runLifetimeHooks(jsonRules, astJSON, walkJSON); 100 | } 101 | 102 | return { astWXML, astWXSS, astJSON, ruleResults: extractResultFromRules(anyRules) }; 103 | }; 104 | -------------------------------------------------------------------------------- /src/patch/index.ts: -------------------------------------------------------------------------------- 1 | import MagicString from "magic-string"; 2 | 3 | export interface PatchLocation { 4 | start: number; 5 | end: number; 6 | path: string; 7 | } 8 | 9 | export const enum PatchStatus { 10 | Pending, 11 | Applied, 12 | Failed, 13 | } 14 | 15 | export const enum PatchType { 16 | Replace, 17 | Append, 18 | } 19 | 20 | export interface Patch { 21 | // type: PatchType; 22 | name: string; 23 | loc: PatchLocation; 24 | patchedStr: string; 25 | status: PatchStatus; 26 | } 27 | 28 | const sortPatchesByLoc = (patches: Patch[]) => 29 | patches.sort((a, b) => { 30 | return a.loc.start - b.loc.start; 31 | }); 32 | 33 | const findOverlaps = (sortedPatches: Patch[]) => { 34 | const patches = sortedPatches; 35 | const endLocs = patches.map((r) => r.loc.end).sort((a, b) => a - b); 36 | let i = 0; 37 | let j = 0; 38 | let n = patches.length; 39 | let active = 0; 40 | const groups = []; 41 | let curGroup = []; 42 | while (true) { 43 | if (i < n && patches[i].loc.start <= endLocs[j]) { 44 | curGroup.push(patches[i++]); 45 | active++; 46 | } else if (j < n) { 47 | j++; 48 | if (--active === 0) { 49 | groups.push(curGroup); 50 | curGroup = []; 51 | } 52 | } else { 53 | break; 54 | } 55 | } 56 | return groups; 57 | }; 58 | 59 | export const applyPatchesOnString = (rawString: string, patches: Patch[]) => { 60 | const str = new MagicString(rawString); 61 | const sortedPatches = sortPatchesByLoc(patches); 62 | // const groups = findOverlaps(sortedPatches); 63 | // const overlappedGroups: Patch[][] = []; 64 | const nonOverLappedPatches: Patch[] = sortedPatches; 65 | // groups.forEach((group) => { 66 | // if (group.length > 1) { 67 | // overlappedGroups.push(group); 68 | // } else { 69 | // nonOverLappedPatches.push(...group); 70 | // } 71 | // }); 72 | const len = nonOverLappedPatches.length; 73 | for (let i = 0; i < len; i++) { 74 | const { loc, patchedStr } = sortedPatches[i]; 75 | const range = loc.end - loc.start; 76 | if (range === 0) { 77 | str.appendRight(loc.start, patchedStr); 78 | } else if (range > 0) { 79 | str.overwrite(loc.start, loc.end, patchedStr); 80 | } 81 | // // we don't maintain offset delta, it's magic-string's job 82 | // const delta = patchedStr.length - range; 83 | // for (let j = i + 1; j < len; j++) { 84 | // const patch = nonOverLappedPatches[j]; 85 | // patch.loc.start += delta; 86 | // patch.loc.end += delta; 87 | // } 88 | } 89 | return str; 90 | }; 91 | -------------------------------------------------------------------------------- /src/rules/box-sizing/index.ts: -------------------------------------------------------------------------------- 1 | import { defineRule, RuleType, createResultItem, RuleLevel } from "../interface"; 2 | import { isType } from "src/walker/css"; 3 | 4 | const result = createResultItem({ 5 | name: "box-sizing", 6 | description: "存在不支持的 box-sizing 值,skyline 只支持 border-box", 7 | advice: "改为 border-box", 8 | fixable: true, 9 | level: RuleLevel.Warn, 10 | }); 11 | 12 | export default defineRule({ name: "box-sizing", type: RuleType.WXSS }, (ctx) => { 13 | ctx.lifetimes({ 14 | onVisit: (node) => { 15 | if ( 16 | isType(node, "Declaration") && 17 | node.property === "box-sizing" && 18 | isType(node.value, "Value") && 19 | node.value.children.some((val) => isType(val, "Identifier") && val.name === "content-box") 20 | ) { 21 | const loc = node.loc!; 22 | ctx.addResultWithPatch( 23 | { 24 | ...result, 25 | loc: { 26 | startLn: loc.start.line, 27 | endLn: loc.end.line, 28 | startCol: loc.start.column, 29 | endCol: loc.end.column, 30 | path: ctx.env.path, 31 | }, 32 | }, 33 | { 34 | loc: { 35 | start: loc.start.offset, 36 | end: loc.end.offset, 37 | path: ctx.env.path, 38 | }, 39 | patchedStr: "box-sizing: border-box", 40 | } 41 | ); 42 | } 43 | }, 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/rules/darkmode/index.ts: -------------------------------------------------------------------------------- 1 | import { defineRule, RuleType, createResultItem, RuleLevel } from "../interface"; 2 | import { isType } from "src/walker/css"; 3 | 4 | const result = createResultItem({ 5 | name: "darkmode", 6 | description: "暂不支持 darkmode", 7 | advice: `目前只能通过 wx.onThemeChange 接口监听系统 darkmode 切换,自行通过切换 class 的方式实现。skyline 后续版本会支持`, 8 | level: RuleLevel.Info, 9 | }); 10 | 11 | export default defineRule({ name: "darkmode", type: RuleType.WXSS }, (ctx) => { 12 | ctx.lifetimes({ 13 | onVisit: (node) => { 14 | if (isType(node, "MediaFeature") && node.name === "prefers-color-scheme") { 15 | const loc = node.loc!; 16 | ctx.addResult({ 17 | ...result, 18 | loc: { 19 | startLn: loc.start.line, 20 | endLn: loc.end.line, 21 | startCol: loc.start.column, 22 | endCol: loc.end.column, 23 | path: ctx.env.path, 24 | }, 25 | }); 26 | } 27 | }, 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/rules/disable-scroll/index.ts: -------------------------------------------------------------------------------- 1 | import { RuleType, createResultItem, RuleLevel } from "../interface"; 2 | import { generateBasicJsonConfigCheck } from "../templates/json"; 3 | 4 | const result = createResultItem({ 5 | name: "disable-scroll", 6 | description: "不支持页面全局滚动", 7 | advice: `需将页面配置中的 disableScroll 置为 true,并在需要滚动的区域使用 scroll-view 实现,详见文档(链接待定)`, 8 | patchHint: `将配置项 disableScroll 置为 true`, 9 | fixable: true, 10 | level: RuleLevel.Error, 11 | }); 12 | 13 | export default generateBasicJsonConfigCheck( 14 | { name: "disable-scroll", type: RuleType.JSON }, 15 | { result, key: "disableScroll", value: true } 16 | ); 17 | -------------------------------------------------------------------------------- /src/rules/display-flex/index.ts: -------------------------------------------------------------------------------- 1 | import { defineRule, RuleType, createResultItem, RuleLevel } from "../interface"; 2 | import { isType } from "src/walker/css"; 3 | 4 | const result = createResultItem({ 5 | name: "display-flex", 6 | description: "flex 布局下未显式指定 flex-direction", 7 | advice: "目前 flex-direction 默认为 column,而在 web 下默认值为 row 故一般不会显式指定,因此这里需要显式指定为 row", 8 | fixable: true, 9 | level: RuleLevel.Warn, 10 | }); 11 | 12 | export default defineRule({ name: "display-flex", type: RuleType.WXSS }, (ctx) => { 13 | ctx.lifetimes({ 14 | onVisit: (node) => { 15 | if (isType(node, "Block")) { 16 | let loc: typeof node.loc; 17 | let hasFlexDirection = false; 18 | node.children.forEach((child) => { 19 | if (!isType(child, "Declaration")) return; 20 | if ( 21 | child.property === "display" && 22 | isType(child.value, "Value") && 23 | child.value.children.some((val) => isType(val, "Identifier") && val.name === "flex") 24 | ) { 25 | loc = child.loc; 26 | } 27 | if (child.property === "flex-direction") hasFlexDirection = true; 28 | }); 29 | if (loc && !hasFlexDirection) { 30 | ctx.addResultWithPatch( 31 | { 32 | ...result, 33 | loc: { 34 | startLn: loc.start.line, 35 | endLn: loc.end.line, 36 | startCol: loc.start.column, 37 | endCol: loc.end.column, 38 | path: ctx.env.path, 39 | }, 40 | }, 41 | { 42 | loc: { 43 | start: loc.start.offset, 44 | end: loc.start.offset, 45 | path: ctx.env.path, 46 | }, 47 | patchedStr: "flex-direction: row; ", 48 | } 49 | ); 50 | } 51 | } 52 | }, 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/rules/display-inline/index.ts: -------------------------------------------------------------------------------- 1 | import { defineRule, RuleType, createResultItem, RuleLevel } from "../interface"; 2 | import { isType } from "src/walker/css"; 3 | 4 | export default defineRule({ name: "display-inline", type: RuleType.WXSS }, (ctx) => { 5 | ctx.lifetimes({ 6 | onVisit: (node) => { 7 | if (isType(node, "Declaration") && node.property === "display" && isType(node.value, "Value")) { 8 | const val = node.value.children 9 | .toArray() 10 | .find((val) => isType(val, "Identifier") && ["inline", "inline-block"].includes(val.name)); 11 | if (!val || !isType(val, "Identifier")) return; 12 | const loc = node.loc!; 13 | ctx.addResult({ 14 | name: `display-${val.name}`, 15 | description: `不支持 display: ${val.name}`, 16 | advice: "若是布局需要,可改为 flex 布局实现;若是实现内联文本,可使用 text 组件", 17 | level: RuleLevel.Info, 18 | loc: { 19 | startLn: loc.start.line, 20 | endLn: loc.end.line, 21 | startCol: loc.start.column, 22 | endCol: loc.end.column, 23 | path: ctx.env.path, 24 | }, 25 | }); 26 | } 27 | }, 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/rules/interface.ts: -------------------------------------------------------------------------------- 1 | import { HTMLWalker, type Node as WXMLNode } from "src/walker/html"; 2 | import { CSSWalker, type Node as WXSSNode } from "src/walker/css"; 3 | import { JSONWalker, type Node as JSONNode } from "src/walker/json"; 4 | import { Patch, PatchStatus } from "../patch"; 5 | import { BasicParseEnv } from "src/parser"; 6 | import { Document } from "domhandler"; 7 | 8 | export enum RuleLevel { 9 | Verbose = 0, 10 | Info = 1, 11 | Warn = 2, 12 | Error = 3, 13 | } 14 | 15 | interface BasicLocation { 16 | path: string | null; 17 | } 18 | 19 | export interface LocationLnColBased extends BasicLocation { 20 | startLn: number; 21 | endLn: number; 22 | startCol: number; 23 | endCol: number; 24 | } 25 | 26 | export interface LocationIndexBased extends BasicLocation { 27 | startIndex: number; 28 | endIndex: number; 29 | } 30 | 31 | export type SourceCodeLocation = LocationIndexBased | LocationLnColBased; 32 | 33 | export interface RuleResultItem { 34 | name: string; 35 | description?: string; 36 | advice?: string; 37 | patchHint?: string; 38 | loc?: SourceCodeLocation; 39 | level: RuleLevel; 40 | fixable?: boolean; 41 | withCodeFrame?: boolean; 42 | } 43 | 44 | export const enum RuleType { 45 | Unknown, 46 | WXML, 47 | WXSS, 48 | Node, 49 | JSON, 50 | } 51 | 52 | interface HookType { 53 | [RuleType.WXML]: Parameters[1]; 54 | [RuleType.WXSS]: Parameters[1]; 55 | [RuleType.JSON]: Parameters[1]; 56 | [RuleType.Node]: any; 57 | [RuleType.Unknown]: never; 58 | } 59 | 60 | interface Hooks { 61 | before?: () => void; 62 | onVisit?: HookType[T]; 63 | after?: () => void; 64 | } 65 | 66 | interface RuleBasicInfo { 67 | name: string; 68 | type: T; 69 | level: RuleLevel; 70 | } 71 | 72 | export interface Rule extends Hooks, RuleBasicInfo { 73 | get results(): RuleResultItem[]; 74 | get patches(): Patch[]; 75 | get astPatches(): Function[]; 76 | clear(): void; 77 | } 78 | 79 | export interface RuleContext { 80 | lifetimes(hooks: Hooks): void; 81 | addResult(...results: RuleResultItem[]): void; 82 | addResultWithPatch(result: RuleResultItem, patch: QuickPatch): void; 83 | addPatch(...patches: Omit[]): void; 84 | addASTPatch(...patches: Function[]): void; 85 | getRelatedWXMLFilename(): string | undefined; 86 | getRelatedWXMLAst(): Document | null; 87 | env: K; 88 | } 89 | 90 | type QuickPatch = Pick; 91 | 92 | export type RuleBasicInfoWithOptionalLevel = Pick, "name" | "type">; 93 | 94 | export const defineRule = 95 | ( 96 | info: RuleBasicInfoWithOptionalLevel, 97 | init: (ctx: RuleContext) => void 98 | ) => 99 | (env: Env) => { 100 | const { name, type } = info; 101 | let hooks: Hooks = {}; 102 | let results: RuleResultItem[] = []; 103 | let patches: Patch[] = []; 104 | let astPatches: Function[] = []; 105 | const lifetimes = (newHooks: Hooks) => { 106 | hooks = { ...hooks, ...newHooks }; 107 | }; 108 | const addResult = (...newResults: RuleResultItem[]) => { 109 | results.push(...newResults); 110 | }; 111 | const addResultWithPatch = (result: RuleResultItem, patch: Patch) => { 112 | const { name } = result; 113 | results.push(result); 114 | patches.push({ 115 | ...patch, 116 | name, 117 | status: PatchStatus.Pending, 118 | }); 119 | }; 120 | const addASTPatch = (...newPatches: Function[]) => astPatches.push(...newPatches); 121 | const addPatch = (...newPatches: Omit[]) => 122 | patches.push( 123 | ...newPatches.map((patch) => ({ 124 | ...patch, 125 | status: PatchStatus.Pending, 126 | })) 127 | ); 128 | const getRelatedWXMLFilename = () => env.path.replace(/(?!\.)(wxml|json|wxss)$/, "wxml"); 129 | 130 | const getRelatedWXMLAst = () => { 131 | if (!env.astMap) return null; 132 | return env.astMap.get(getRelatedWXMLFilename()) ?? null; 133 | }; 134 | 135 | init({ 136 | lifetimes, 137 | addASTPatch, 138 | addPatch, 139 | addResult, 140 | addResultWithPatch, 141 | getRelatedWXMLFilename, 142 | getRelatedWXMLAst, 143 | env, 144 | }); 145 | return { 146 | name, 147 | type, 148 | get results() { 149 | return results; 150 | }, 151 | get astPatches() { 152 | return astPatches; 153 | }, 154 | get patches() { 155 | return patches; 156 | }, 157 | clear() { 158 | results; 159 | }, 160 | ...hooks, 161 | } as Rule; 162 | }; 163 | 164 | export const createResultItem = ( 165 | params: Omit & Partial> 166 | ): RuleResultItem => { 167 | return { 168 | level: RuleLevel.Warn, 169 | ...params, 170 | }; 171 | }; 172 | -------------------------------------------------------------------------------- /src/rules/mark-wx-for/index.ts: -------------------------------------------------------------------------------- 1 | import { hasChildren } from "domhandler"; 2 | import { DomUtils, isType } from "src/walker/html"; 3 | import { defineRule, RuleType, createResultItem, RuleLevel } from "../interface"; 4 | 5 | const result = createResultItem({ 6 | name: "mark-wx-for", 7 | description: `未打开样式共享标记`, 8 | advice: `每一个列表项的样式是基本相同的,因此 skyline 实现了样式共享机制,可降低样式匹配的耗时,只需要列表项加个 list-item,如 `, 9 | level: RuleLevel.Verbose, 10 | }); 11 | 12 | export default defineRule({ name: "mark-wx-for", type: RuleType.WXML }, (ctx) => { 13 | ctx.lifetimes({ 14 | before: () => {}, 15 | onVisit: (node) => { 16 | if (!isType(node, "Tag") || !Reflect.has(node.attribs, "wx:for")) return; 17 | if (node.name === "block" && hasChildren(node)) { 18 | for (const childNode of node.childNodes) { 19 | if (isType(childNode, "Tag") && !Reflect.has(childNode.attribs, "list-item")) { 20 | ctx.addResult({ 21 | ...result, 22 | loc: { 23 | startIndex: childNode.startIndex!, 24 | endIndex: childNode.endIndex!, 25 | path: ctx.env.path, 26 | }, 27 | }); 28 | } 29 | } 30 | } else if (!Reflect.has(node.attribs, "list-item")) { 31 | ctx.addResult({ 32 | ...result, 33 | loc: { 34 | startIndex: node.startIndex!, 35 | endIndex: node.endIndex!, 36 | path: ctx.env.path, 37 | }, 38 | }); 39 | } 40 | }, 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/rules/navigator/index.ts: -------------------------------------------------------------------------------- 1 | import { RuleType, createResultItem, RuleLevel } from "../interface"; 2 | import { generateNoInlineTagChildrenCheck } from "../templates/no-inline"; 3 | 4 | const result = createResultItem({ 5 | name: "navigator", 6 | description: "navigator 组件只能嵌套文本", 7 | advice: "同 text 组件,只支持内联文本,若需要实现块级元素,可改为 button 实现", 8 | level: RuleLevel.Warn, 9 | }); 10 | 11 | export default generateNoInlineTagChildrenCheck( 12 | { name: "navigator", type: RuleType.WXML }, 13 | { result, tagName: "navigator" } 14 | ); 15 | -------------------------------------------------------------------------------- /src/rules/no-calc/index.ts: -------------------------------------------------------------------------------- 1 | import { defineRule, RuleType, createResultItem, RuleLevel } from "../interface"; 2 | import { isType } from "src/walker/css"; 3 | 4 | const result = createResultItem({ 5 | name: "no-calc", 6 | description: "不支持 calc 表达式", 7 | advice: `需要改为静态值,可考虑使用兼容写法,即 height: 100px; height: calc(50px+3rem); 使得 skyline 下使用静态值,webview 下使用 calc 函数`, 8 | level: RuleLevel.Error, 9 | withCodeFrame: true, 10 | }); 11 | 12 | export default defineRule({ name: "no-calc", type: RuleType.WXSS }, (ctx) => { 13 | ctx.lifetimes({ 14 | onVisit: (node) => { 15 | if (isType(node, "Function") && node.name === "calc") { 16 | const loc = node.loc!; 17 | ctx.addResult({ 18 | ...result, 19 | loc: { 20 | startLn: loc.start.line, 21 | endLn: loc.end.line, 22 | startCol: loc.start.column, 23 | endCol: loc.end.column, 24 | path: ctx.env.path, 25 | }, 26 | }); 27 | } 28 | }, 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/rules/no-css-animation/index.ts: -------------------------------------------------------------------------------- 1 | import { defineRule, RuleType, createResultItem, RuleLevel } from "../interface"; 2 | import { isType } from "src/walker/css"; 3 | 4 | const result = createResultItem({ 5 | name: "no-css-animation", 6 | description: "不支持 css animation", 7 | advice: "可通过 skyline 的新特性,worklet 动画实现", 8 | level: RuleLevel.Warn, 9 | }); 10 | 11 | export default defineRule({ name: "no-css-animation", type: RuleType.WXSS }, (ctx) => { 12 | ctx.lifetimes({ 13 | onVisit: (node) => { 14 | if (isType(node, "Declaration") && node.property.startsWith("animation")) { 15 | const loc = node.loc!; 16 | ctx.addResult({ 17 | ...result, 18 | loc: { 19 | startLn: loc.start.line, 20 | endLn: loc.end.line, 21 | startCol: loc.start.column, 22 | endCol: loc.end.column, 23 | path: ctx.env.path, 24 | }, 25 | }); 26 | } 27 | }, 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/rules/no-inline-text/index.ts: -------------------------------------------------------------------------------- 1 | import { RuleType, createResultItem, RuleLevel } from "../interface"; 2 | import { generateNoInlineTagChildrenCheck } from "../templates/no-inline"; 3 | 4 | const result = createResultItem({ 5 | name: "no-inline-text", 6 | description: "多段文本内联只能使用 text/span 组件包裹", 7 | advice: 8 | "目前不支持 inline ,需通过 text 组件实现,如 foo bar 要改为 foo bar ", 9 | level: RuleLevel.Error, 10 | withCodeFrame: true, 11 | }); 12 | 13 | export default generateNoInlineTagChildrenCheck( 14 | { name: "no-inline-text", type: RuleType.WXML }, 15 | { result, parentTagNameShouldBe: ["text", "span"] } 16 | ); 17 | -------------------------------------------------------------------------------- /src/rules/no-native-nav/index.ts: -------------------------------------------------------------------------------- 1 | import { RuleType, createResultItem, RuleLevel } from "../interface"; 2 | import { generateBasicJsonConfigCheck } from "../templates/json"; 3 | 4 | const result = createResultItem({ 5 | name: "no-native-nav", 6 | description: "不支持的原生导航栏", 7 | advice: `需将页面配置中的 navigationStyle 置为 custom,并自行实现自定义导航栏,详见文档(链接待定)`, 8 | patchHint: `将配置项 navigationStyle 置为 "custom"`, 9 | fixable: true, 10 | level: RuleLevel.Error, 11 | }); 12 | 13 | export default generateBasicJsonConfigCheck( 14 | { name: "no-native-nav", type: RuleType.JSON }, 15 | { result, key: "navigationStyle", value: "custom" } 16 | ); 17 | -------------------------------------------------------------------------------- /src/rules/no-pseudo/index.ts: -------------------------------------------------------------------------------- 1 | import { defineRule, RuleType, createResultItem, RuleLevel } from "../interface"; 2 | import { isType } from "src/walker/css"; 3 | 4 | const supportedPseudoClass = new Set(["first-child", "last-child"]); 5 | const resultNoPseudoClass = (name: string) => 6 | createResultItem({ 7 | name: "no-pseudo-class", 8 | description: `不支持的伪类 :${name}`, 9 | advice: "需要改为通过 JS 或 wxml 模板语法的添加额外的 class 实现。", 10 | level: RuleLevel.Error, 11 | withCodeFrame: true, 12 | }); 13 | 14 | const supportedPseudoElement = new Set(["before", "after"]); 15 | const resultNoPseudoElement = (name: string) => 16 | createResultItem({ 17 | name: "no-pseudo-element", 18 | description: `不支持的伪元素 ::${name}`, 19 | advice: `需要改为添加真实的 wxml 节点实现,若是实现 "1px" 1 物理像素的效果,可直接使用小数点,如 0.5px`, 20 | level: RuleLevel.Error, 21 | withCodeFrame: true, 22 | }); 23 | 24 | export default defineRule({ name: "no-pseudo", type: RuleType.WXSS }, (ctx) => { 25 | ctx.lifetimes({ 26 | onVisit: (node) => { 27 | if (isType(node, "PseudoClassSelector") && !supportedPseudoClass.has(node.name)) { 28 | const loc = node.loc!; 29 | ctx.addResult({ 30 | ...resultNoPseudoClass(node.name), 31 | loc: { 32 | startLn: loc.start.line, 33 | endLn: loc.end.line, 34 | startCol: loc.start.column, 35 | endCol: loc.end.column, 36 | path: ctx.env.path, 37 | }, 38 | }); 39 | } else if (isType(node, "PseudoElementSelector") && !supportedPseudoElement.has(node.name)) { 40 | const loc = node.loc!; 41 | ctx.addResult({ 42 | ...resultNoPseudoElement(node.name), 43 | loc: { 44 | startLn: loc.start.line, 45 | endLn: loc.end.line, 46 | startCol: loc.start.column, 47 | endCol: loc.end.column, 48 | path: ctx.env.path, 49 | }, 50 | }); 51 | } 52 | }, 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/rules/no-svg-style-tag/index.ts: -------------------------------------------------------------------------------- 1 | import { defineRule, RuleType, createResultItem, RuleLevel } from "../interface"; 2 | import { isType, Node } from "src/walker/html"; 3 | import { hasChildren } from "domhandler"; 4 | 5 | const result = createResultItem({ 6 | name: "no-svg-style-tag", 7 | description: "不支持 svg 组件内使用 style 标签", 8 | advice: "在 wxss 文件内书写样式规则", 9 | level: RuleLevel.Error, 10 | withCodeFrame: true, 11 | }); 12 | 13 | export default defineRule({ name: "no-svg-style-tag", type: RuleType.WXML }, (ctx) => { 14 | const dfs = (node: Node, ruleCtx: typeof ctx) => { 15 | if ((isType(node, "Tag") && node.name === "style") || isType(node, "Style")) { 16 | ruleCtx.addResult({ 17 | ...result, 18 | loc: { 19 | startIndex: node.startIndex!, 20 | endIndex: node.endIndex!, 21 | path: ctx.env.path, 22 | }, 23 | }); 24 | } 25 | if (hasChildren(node)) { 26 | for (const childNode of node.childNodes) { 27 | dfs(childNode, ruleCtx); 28 | } 29 | } 30 | }; 31 | ctx.lifetimes({ 32 | onVisit: (node, walkCtx) => { 33 | if (isType(node, "Tag") && node.name === "svg" && hasChildren(node)) { 34 | node.childNodes.forEach((childNode) => dfs(childNode, ctx)); 35 | } 36 | }, 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/rules/position-fixed/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineRule, 3 | RuleType, 4 | createResultItem, 5 | RuleLevel, 6 | } from "../interface"; 7 | import { isType } from "src/walker/css"; 8 | import * as CssTree from "css-tree"; 9 | 10 | const result = createResultItem({ 11 | name: "position-fixed", 12 | description: "position: fixed 未指定元素偏移", 13 | advice: "position-fixed 需要指定 top/bottom left/right。", 14 | level: RuleLevel.Error, 15 | }); 16 | 17 | const verboseResult = createResultItem({ 18 | name: "position fixed", 19 | description: "skyline 暂不支持 stacking context", 20 | advice: "请自行确认 fixed 节点的 z-index 层级是否符合预期", 21 | level: RuleLevel.Info, 22 | }); 23 | 24 | export default defineRule( 25 | { name: "position-fixed", type: RuleType.WXSS }, 26 | (ctx) => { 27 | ctx.lifetimes({ 28 | onVisit: (node, visitCtx) => { 29 | if ( 30 | isType(node, "Declaration") && 31 | node.property === "position" && 32 | isType(node.value, "Value") && 33 | node.value.children.some( 34 | (val) => isType(val, "Identifier") && val.name === "fixed" 35 | ) 36 | ) { 37 | const loc = node.loc!; 38 | ctx.addResult({ 39 | ...verboseResult, 40 | loc: { 41 | startLn: loc.start.line, 42 | endLn: loc.end.line, 43 | startCol: loc.start.column, 44 | endCol: loc.end.column, 45 | path: ctx.env.path, 46 | }, 47 | }); 48 | 49 | const hasLOrR = CssTree.find(visitCtx.block!, (node, item, list) => { 50 | return ( 51 | isType(node, "Declaration") && 52 | (node.property === "left" || node.property === "right") 53 | ); 54 | }); 55 | const hasTOrB = CssTree.find(visitCtx.block!, (node, item, list) => { 56 | return ( 57 | isType(node, "Declaration") && 58 | (node.property === "top" || node.property === "bottom") 59 | ); 60 | }); 61 | if (!hasLOrR || !hasTOrB) { 62 | ctx.addResult({ 63 | ...result, 64 | loc: { 65 | startLn: loc.start.line, 66 | endLn: loc.end.line, 67 | startCol: loc.start.column, 68 | endCol: loc.end.column, 69 | path: ctx.env.path, 70 | }, 71 | }); 72 | } 73 | } 74 | }, 75 | }); 76 | } 77 | ); 78 | -------------------------------------------------------------------------------- /src/rules/renderer-skyline/index.ts: -------------------------------------------------------------------------------- 1 | import { RuleType, RuleLevel, createResultItem } from "../interface"; 2 | import { generateBasicJsonConfigCheck } from "../templates/json"; 3 | 4 | const result = createResultItem({ 5 | name: "renderer-skyline", 6 | description: "未开启 skyline 渲染", 7 | advice: `将配置项 renderer 置为 "skyline"`, 8 | fixable: true, 9 | level: RuleLevel.Error, 10 | // patchHint: `将配置项 disableScroll 置为 true`, 11 | }); 12 | 13 | export default generateBasicJsonConfigCheck( 14 | { name: "renderer-skyline", type: RuleType.JSON }, 15 | { result, key: "renderer", value: "skyline" } 16 | ); 17 | -------------------------------------------------------------------------------- /src/rules/scroll-view/index.ts: -------------------------------------------------------------------------------- 1 | import { hasChildren } from "domhandler"; 2 | import { selectAll } from "css-select"; 3 | import { getLocationByNode } from "src/utils/dom-ast"; 4 | import { formatSelectorList } from "src/utils/css-ast"; 5 | import { DomUtils, isType } from "src/walker/html"; 6 | import { isType as isTypeCSS } from "src/walker/css"; 7 | import { 8 | defineRule, 9 | RuleType, 10 | createResultItem, 11 | RuleLevel, 12 | } from "../interface"; 13 | 14 | const resultScrollViewNotFound = createResultItem({ 15 | name: "scroll-view-not-found", 16 | description: "当前页面未使用 scroll-view 组件", 17 | advice: 18 | "skyline 不支持页面全局滚动,若页面超过一屏,需要使用 scroll-view 组件实现滚动", 19 | level: RuleLevel.Warn, 20 | }); 21 | const resultScrollViewImproperType = createResultItem({ 22 | name: "scroll-view-type", 23 | description: `scroll-view 未显式指定 type 类型`, 24 | advice: `当前 scroll-view 只支持 type=list 且需显式指定,详见文档(链接待定)`, 25 | fixable: true, 26 | level: RuleLevel.Error, 27 | }); 28 | const resultScrollViewOptimize = createResultItem({ 29 | name: "scroll-view-optimize", 30 | description: `未能充分利用 scroll-view 按需渲染的机制`, 31 | advice: `scroll-view 会根据直接子节点是否在屏来按需渲染,若只有一个直接子节点则性能会退化,如 `, 32 | level: RuleLevel.Verbose, 33 | }); 34 | const resultScrollViewXY = createResultItem({ 35 | name: "scroll-view-x-y", 36 | description: `scroll-view 暂不支持水平垂直方向同时滚动`, 37 | advice: `skyline 后续版本会支持`, 38 | level: RuleLevel.Info, 39 | }); 40 | const resultScrollMargin = createResultItem({ 41 | name: "scroll-view-margin", 42 | description: `scroll-view 组件的直接子节点 margin 无效`, 43 | advice: `需要给设置了 margin 的直接子节点套多一层 view。skyline 后续版本考虑从布局算法上支持`, 44 | level: RuleLevel.Info, 45 | }); 46 | 47 | const RuleScroolViewWXML = defineRule( 48 | { name: "scroll-view-wxml", type: RuleType.WXML }, 49 | (ctx) => { 50 | let scrollViewCount = 0; 51 | ctx.lifetimes({ 52 | before: () => { 53 | scrollViewCount = 0; 54 | }, 55 | onVisit: (node) => { 56 | if (!isType(node, "Tag") || node.name !== "scroll-view") return; 57 | scrollViewCount++; 58 | let hasTypeList = DomUtils.getAttributeValue(node, "type") === "list"; 59 | if (!hasTypeList) { 60 | const { start, end, path } = getLocationByNode(node); 61 | ctx.addResultWithPatch( 62 | { 63 | ...resultScrollViewImproperType, 64 | loc: { 65 | startIndex: start!, 66 | endIndex: end!, 67 | path, 68 | }, 69 | }, 70 | { 71 | patchedStr: ` { 96 | if (isType(child, "Tag")) return true; 97 | if (isType(child, "Text") && child.data.trim() !== "") return true; 98 | return false; 99 | }); 100 | 101 | if ( 102 | trimedChildren.length === 1 && 103 | isType(trimedChildren[0], "Tag") && 104 | !Reflect.has(trimedChildren[0].attribs, "wx:for") 105 | ) { 106 | const { start, end, path } = getLocationByNode(node); 107 | ctx.addResult({ 108 | ...resultScrollViewOptimize, 109 | loc: { 110 | startIndex: start!, 111 | endIndex: end!, 112 | path, 113 | }, 114 | }); 115 | } 116 | } 117 | }, 118 | after: () => { 119 | if (scrollViewCount === 0) ctx.addResult(resultScrollViewNotFound); 120 | }, 121 | }); 122 | } 123 | ); 124 | 125 | const RuleScroolViewWXSS = defineRule( 126 | { name: "scroll-view-wxss", type: RuleType.WXSS }, 127 | (ctx) => { 128 | ctx.lifetimes({ 129 | onVisit: (node, walkCtx) => { 130 | if ( 131 | !isTypeCSS(node, "Declaration") || 132 | !node.property.startsWith("margin") 133 | ) 134 | return; 135 | const wxmlFilename = ctx.getRelatedWXMLFilename(); 136 | const ast = ctx.getRelatedWXMLAst(); 137 | const prelude = walkCtx.rule?.prelude; 138 | if (!ast || !prelude) return; 139 | const selector = isTypeCSS(prelude, "Raw") 140 | ? prelude.value 141 | : formatSelectorList(prelude); 142 | const children = selectAll(selector, ast); 143 | for (const child of children) { 144 | if ( 145 | child.parent && 146 | isType(child.parent, "Tag") && 147 | child.parent.name === "scroll-view" 148 | ) { 149 | const { start, end, path } = getLocationByNode(child); 150 | ctx.addResult({ 151 | ...resultScrollMargin, 152 | loc: { 153 | startIndex: child.startIndex!, 154 | endIndex: child.endIndex!, 155 | path: path ?? wxmlFilename ?? null, 156 | }, 157 | }); 158 | } 159 | } 160 | }, 161 | }); 162 | } 163 | ); 164 | 165 | export default [RuleScroolViewWXML]; 166 | -------------------------------------------------------------------------------- /src/rules/templates/json.ts: -------------------------------------------------------------------------------- 1 | import { isType } from "src/walker/json"; 2 | import { ObjectNode, PropertyNode } from "json-to-ast"; 3 | import { defineRule, RuleType, RuleResultItem, RuleBasicInfoWithOptionalLevel } from "../interface"; 4 | 5 | type JSONLiterial = string | number | boolean | null; 6 | 7 | interface BasicJsonConfigCheckOptions { 8 | result: RuleResultItem; 9 | key: string; 10 | value: JSONLiterial | JSONLiterial[]; 11 | /** @default true */ 12 | autoPatch?: boolean; 13 | /** @default false */ 14 | allowUndefined?: boolean; 15 | } 16 | 17 | const enum State { 18 | Undefined, 19 | Unequal, 20 | Equal, 21 | } 22 | 23 | export const generateBasicJsonConfigCheck = ( 24 | info: RuleBasicInfoWithOptionalLevel, 25 | { result, key, value, autoPatch = true, allowUndefined }: BasicJsonConfigCheckOptions 26 | ) => 27 | defineRule(info, (ctx) => { 28 | let firstNode: ObjectNode | null = null; 29 | let propNode: PropertyNode | null = null; 30 | let state: State = State.Undefined; 31 | ctx.lifetimes({ 32 | onVisit: (node) => { 33 | if (!firstNode && isType(node, "Object")) firstNode = node; 34 | if (!isType(node, "Property")) return; 35 | if (node.key.value !== key) return; 36 | propNode = node; 37 | if (isType(node.value, "Literal")) { 38 | state = (Array.isArray(value) ? value.includes(node.value.value) : node.value.value === value) 39 | ? State.Equal 40 | : State.Unequal; 41 | } 42 | }, 43 | after: () => { 44 | if ((allowUndefined && state === State.Undefined) || State.Equal) return; 45 | const node = propNode; 46 | if (node) { 47 | const res = { 48 | ...result, 49 | loc: { 50 | startLn: node.loc!.start.line, 51 | endLn: node.loc!.end.line, 52 | startCol: node.loc!.start.column, 53 | endCol: node.loc!.end.column, 54 | path: ctx.env.path, 55 | }, 56 | }; 57 | const patch = { 58 | patchedStr: JSON.stringify(value), 59 | loc: { 60 | start: node.value.loc!.start.offset, 61 | end: node.value.loc!.end.offset, 62 | path: ctx.env.path, 63 | }, 64 | }; 65 | autoPatch ? ctx.addResultWithPatch(res, patch) : ctx.addResult(res); 66 | } else if (firstNode) { 67 | const res = { ...result }; 68 | const patch = { 69 | patchedStr: `"${key}": ${JSON.stringify(value)},`, 70 | loc: { 71 | start: firstNode.loc!.start.offset + 1, 72 | end: firstNode.loc!.start.offset + 1, 73 | path: ctx.env.path, 74 | }, 75 | }; 76 | autoPatch ? ctx.addResultWithPatch(res, patch) : ctx.addResult(res); 77 | } 78 | }, 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/rules/templates/no-inline.ts: -------------------------------------------------------------------------------- 1 | import { hasChildren } from "domhandler"; 2 | import { getLocationByNode } from "src/utils/dom-ast"; 3 | import { isType } from "src/walker/html"; 4 | import { defineRule, RuleType, RuleResultItem, RuleBasicInfoWithOptionalLevel } from "../interface"; 5 | 6 | type Matcher = string | RegExp; 7 | interface BasicNoInlineCheckOptions { 8 | result: RuleResultItem; 9 | tagName?: string; 10 | parentTagNameShouldBe?: Matcher | Matcher[]; 11 | } 12 | const matchTagName = (tag: string, matcher?: Matcher | Matcher[]) => { 13 | if (!matcher) return false; 14 | if (Array.isArray(matcher)) { 15 | return matcher.some((m) => tag.match(m)); 16 | } else { 17 | return tag.match(matcher); 18 | } 19 | }; 20 | 21 | export const generateNoInlineTagChildrenCheck = ( 22 | info: RuleBasicInfoWithOptionalLevel, 23 | { result, tagName, parentTagNameShouldBe }: BasicNoInlineCheckOptions 24 | ) => 25 | defineRule(info, (ctx) => { 26 | ctx.lifetimes({ 27 | onVisit: (node) => { 28 | if (!isType(node, "Tag") || (tagName && node.name !== tagName)) return; 29 | if (!hasChildren(node)) return; 30 | let isPrevText = false; 31 | for (const child of node.childNodes) { 32 | const isText = 33 | (isType(child, "Text") && child.data.trim() !== "") || (isType(child, "Tag") && child.name === "text"); 34 | if (isText && isPrevText && !matchTagName(node.name, parentTagNameShouldBe)) { 35 | const { start, end, path } = getLocationByNode(node); 36 | ctx.addResult({ 37 | ...result, 38 | loc: { 39 | startIndex: start!, 40 | endIndex: end!, 41 | path, 42 | }, 43 | }); 44 | break; 45 | } 46 | isPrevText = isText; 47 | } 48 | }, 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/rules/text-overflow-ellipse/index.ts: -------------------------------------------------------------------------------- 1 | import { defineRule, RuleType, createResultItem, RuleLevel } from "../interface"; 2 | import { isType } from "src/walker/css"; 3 | 4 | const result = createResultItem({ 5 | name: "text-overflow-ellipse", 6 | description: "text-overflow: ellipse 只在 text 组件下生效", 7 | advice: "文本省略需要通过 text 组件 + text-overflow: ellipse + overflow: hidden 三者实现", 8 | level: RuleLevel.Warn, 9 | }); 10 | 11 | export default defineRule({ name: "text-overflow-ellipse", type: RuleType.WXSS }, (ctx) => { 12 | ctx.lifetimes({ 13 | onVisit: (node, walkCtx) => { 14 | if (isType(node, "Declaration") && node.property === "text-overflow") { 15 | const loc = node.loc!; 16 | ctx.addResult({ 17 | ...result, 18 | loc: { 19 | startLn: loc.start.line, 20 | endLn: loc.end.line, 21 | startCol: loc.start.column, 22 | endCol: loc.end.column, 23 | path: ctx.env.path, 24 | }, 25 | }); 26 | } 27 | }, 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/rules/unsupported-component/index.ts: -------------------------------------------------------------------------------- 1 | import { isType } from "src/walker/html"; 2 | import { 3 | defineRule, 4 | RuleType, 5 | createResultItem, 6 | RuleLevel, 7 | } from "../interface"; 8 | 9 | const results = { 10 | "movable-view": createResultItem({ 11 | name: "movable-view", 12 | description: "不支持 movable-view 组件", 13 | advice: 14 | "不再支持 movable-view 组件,通过 skyline 的新特性,worklet 动画 + 手势系统实现", 15 | level: RuleLevel.Error, 16 | }), 17 | video: createResultItem({ 18 | name: "video", 19 | description: "暂只支持基础播放功能", 20 | advice: "完整功能 skyline 后续版本会支持", 21 | level: RuleLevel.Verbose, 22 | }), 23 | form: createResultItem({ 24 | name: "form", 25 | description: "不支持 form 组件", 26 | advice: "完整功能 skyline 后续版本会支持", 27 | level: RuleLevel.Verbose, 28 | }), 29 | }; 30 | 31 | export default defineRule( 32 | { name: "unsupported-component", type: RuleType.WXML }, 33 | (ctx) => { 34 | ctx.lifetimes({ 35 | onVisit: (node) => { 36 | if (!isType(node, "Tag")) return; 37 | if (Reflect.has(results, node.name)) { 38 | ctx.addResult({ 39 | ...results[node.name as keyof typeof results], 40 | loc: { 41 | startIndex: node.startIndex!, 42 | endIndex: node.endIndex!, 43 | path: ctx.env.path, 44 | }, 45 | }); 46 | } 47 | }, 48 | }); 49 | } 50 | ); 51 | -------------------------------------------------------------------------------- /src/rules/weui-extendedlib/index.ts: -------------------------------------------------------------------------------- 1 | import { isType } from "src/walker/json"; 2 | import { isType as isTypeCSS } from "src/walker/css"; 3 | import { defineRule, RuleType, RuleResultItem, createResultItem, RuleLevel } from "../interface"; 4 | import { BasicParseEnv } from "src/parser"; 5 | 6 | const enum State { 7 | Unknown, 8 | WXSS, 9 | JSON, 10 | } 11 | 12 | const stateMap = new Map(); 13 | 14 | const result = createResultItem({ 15 | name: "weui-extendedlib", 16 | description: "暂不支持 weui 扩展库", 17 | advice: "目前 skyline 页面需要通过引入 weui npm 包的方式使用,后续版本会支持,webview 页面则不影响", 18 | level: RuleLevel.Error, 19 | }); 20 | 21 | const before = (env?: BasicParseEnv) => { 22 | if (!env || !env.path) return; 23 | const state = stateMap.get(env.path); 24 | if (!state) stateMap.set(env.path, State.Unknown); 25 | }; 26 | 27 | const RuleWeuiExtendedlibWXSS = defineRule( 28 | { 29 | name: "weui-extendedlib", 30 | type: RuleType.WXSS, 31 | }, 32 | (ctx) => { 33 | let weuiImported = false; 34 | ctx.lifetimes({ 35 | before: () => before(ctx.env), 36 | onVisit: (node) => { 37 | if (weuiImported || !ctx.env || !ctx.env.path) return; 38 | if ( 39 | !isTypeCSS(node, "Atrule") || 40 | node.name !== "import" || 41 | !node.prelude || 42 | !isTypeCSS(node.prelude, "AtrulePrelude") 43 | ) { 44 | return; 45 | } 46 | const imported = node.prelude.children.some((child) => { 47 | let path: string | null = null; 48 | if (isTypeCSS(child, "String")) { 49 | path = child.value; 50 | } else if (isTypeCSS(child, "Url") && isTypeCSS(child.value, "String")) { 51 | path = child.value.value; 52 | } 53 | if (path?.endsWith("weui-miniprogram/weui-wxss/dist/style/weui.wxss")) return true; 54 | return false; 55 | }); 56 | if (imported) weuiImported = imported; 57 | }, 58 | after: () => { 59 | if (!ctx.env || !ctx.env.path) return; 60 | const state = stateMap.get(ctx.env.path); 61 | if (state === undefined) return; 62 | if (state === State.JSON) { 63 | stateMap.delete(ctx.env.path); 64 | !weuiImported && 65 | ctx.addResult({ 66 | ...result, 67 | }); 68 | } else { 69 | stateMap.set(ctx.env.path, State.WXSS); 70 | } 71 | }, 72 | }); 73 | } 74 | ); 75 | 76 | const RuleWeuiExtendedlibJSON = defineRule( 77 | { 78 | name: "weui-extendedlib", 79 | type: RuleType.JSON, 80 | }, 81 | (ctx) => { 82 | let weuiUsed = false; 83 | ctx.lifetimes({ 84 | before: () => before(ctx.env), 85 | onVisit: (node) => { 86 | if (weuiUsed || !isType(node, "Property")) return; 87 | if (node.key.value !== "usingComponents" || !isType(node.value, "Object")) return; 88 | for (const child of node.value.children) { 89 | if (!isType(child, "Property") || !isType(child.value, "Literal")) continue; 90 | if (typeof child.value.value === "string" && child.value.value.match(/weui-miniprogram\//)) { 91 | weuiUsed = true; 92 | break; 93 | } 94 | } 95 | }, 96 | after() { 97 | if (!ctx.env || !ctx.env.path) return; 98 | const state = stateMap.get(ctx.env.path); 99 | if (state === undefined) return; 100 | if (state === State.WXSS) { 101 | stateMap.delete(ctx.env.path); 102 | !weuiUsed && 103 | ctx.addResult({ 104 | ...result, 105 | }); 106 | } else { 107 | stateMap.set(ctx.env.path, State.JSON); 108 | } 109 | }, 110 | }); 111 | } 112 | ); 113 | 114 | export default [RuleWeuiExtendedlibWXSS, RuleWeuiExtendedlibJSON]; 115 | -------------------------------------------------------------------------------- /src/serializer/css.ts: -------------------------------------------------------------------------------- 1 | import { generate, CssNode } from "css-tree"; 2 | import { Serializer } from "./interface"; 3 | 4 | export const serialize: Serializer = (node: CssNode) => generate(node); 5 | -------------------------------------------------------------------------------- /src/serializer/html.ts: -------------------------------------------------------------------------------- 1 | import rawSerilize from "dom-serializer"; 2 | import { ParentNode } from "domhandler"; 3 | import { Serializer } from "./interface"; 4 | 5 | export const serialize: Serializer = (ast) => rawSerilize(ast); 6 | -------------------------------------------------------------------------------- /src/serializer/interface.ts: -------------------------------------------------------------------------------- 1 | export type Serializer = (ast: T) => string; 2 | -------------------------------------------------------------------------------- /src/serializer/json.ts: -------------------------------------------------------------------------------- 1 | import { Serializer } from "./interface"; 2 | 3 | export const serialize: Serializer> = (node) => JSON.stringify(node, null, 2); 4 | -------------------------------------------------------------------------------- /src/utils/collect-template.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs"; 2 | import { BasicParseEnv, parse } from "src/parser"; 3 | import { defineRule, RuleType } from "src/rules/interface"; 4 | import { isType } from "src/walker/html"; 5 | import { ParentNode } from "domhandler"; 6 | import { resolvePath } from "./resolve"; 7 | import { replaceChildWithChildren } from "./dom-ast"; 8 | 9 | interface FragmentInfo { 10 | fragment: ParentNode; 11 | fromFile: string; 12 | } 13 | 14 | interface CollectTemplateEnv extends BasicParseEnv { 15 | rootPath: string; 16 | wxmlPaths: string[]; 17 | tmplFragments: Map; 18 | importFragments: Map; 19 | includeFragments: Map; 20 | } 21 | 22 | // TODO avoid name conflict 23 | const getUniqueKey = (path: string, tmplName: string) => `${tmplName}`; 24 | 25 | // TODO scope of import and include 26 | const Rule = defineRule( 27 | { name: "collect-template", type: RuleType.WXML }, 28 | (ctx) => { 29 | ctx.lifetimes({ 30 | onVisit: (node, walkerContext) => { 31 | if (!isType(node, "Tag")) return; 32 | if (node.name === "template") { 33 | //