@tofrankie/prettier-plugin-wxml 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/dist/index.cjs +362 -0
- package/dist/index.d.cts +70 -0
- package/dist/index.d.mts +70 -0
- package/dist/index.mjs +333 -0
- package/package.json +80 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Frankie
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# @tofrankie/prettier-plugin-wxml
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@tofrankie/prettier-plugin-wxml) [](https://nodejs.org) [](https://github.com/tofrankie/prettier-plugin-wxml/blob/main/LICENSE) [](https://www.npmjs.com/package/@tofrankie/prettier-plugin-wxml)
|
|
4
|
+
|
|
5
|
+
> [!WARNING]
|
|
6
|
+
> **尚未稳定**,在 **1.0.0** 之前仍可能发布**不兼容的破坏性变更**,升级前请查看 [CHANGELOG](CHANGELOG.md)。
|
|
7
|
+
|
|
8
|
+
面向微信小程序 **WXML** 的 **Prettier 3.x** 插件:仅做一件事,在**不改动标签结构、缩进与换行策略**的前提下,对文本节点与属性值中的 **`{{ ... }}` 插值语法**格式化处理:
|
|
9
|
+
|
|
10
|
+
- 花括号内部表达式保留一个空格:`{{username}}` → `{{ username }}`
|
|
11
|
+
- 若 WXML 插值语法使用语句(如 `if` / `return`)而不是表达式,则会保持原样(**不处理**)。
|
|
12
|
+
- 若 WXML 插值语法内使用不合法的表达式(如 `{{foo+}}`),则会保持原样(**不处理**)。
|
|
13
|
+
|
|
14
|
+
## 安装
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm add -D prettier @tofrankie/prettier-plugin-wxml
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## 配置
|
|
21
|
+
|
|
22
|
+
在 Prettier 配置中注册插件,并指定 `parser` 为 `wxml`(或依赖文件扩展名 `.wxml` 自动选用该 parser):
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"plugins": ["@tofrankie/prettier-plugin-wxml"],
|
|
27
|
+
"overrides": [
|
|
28
|
+
{
|
|
29
|
+
"files": "*.wxml",
|
|
30
|
+
"options": {
|
|
31
|
+
"parser": "wxml"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
通用选项(如 `singleQuote`、`printWidth`、`tabWidth` 等)会**一并传入**内层对表达式的 `format()`,但内层固定 `semi: false` 且**不会**加载本插件,以避免循环解析。
|
|
39
|
+
|
|
40
|
+
## 插件选项
|
|
41
|
+
|
|
42
|
+
| 选项 | 类型 | 默认 | 说明 |
|
|
43
|
+
| ------------------ | -------------------- | ---------- | ------------------------------------------------------------------------------------------------------- |
|
|
44
|
+
| `wxmlThrowOnError` | `boolean` | `false` | 为 `true` 时,WXML 解析失败或某一插值无法安全格式化时**抛出**原始错误,便于排查。 |
|
|
45
|
+
| `wxmlReportLevel` | `'silent' \| 'warn'` | `'silent'` | 为 `warn` 时,在容错回退(整文件跳过或部分插值未改)时向 `console.warn` 输出一行提示(含 `filepath`)。 |
|
|
46
|
+
|
|
47
|
+
## 行为与限制
|
|
48
|
+
|
|
49
|
+
### 整文件 HTML 解析(`angular-html-parser` 的 `parseHtml`)
|
|
50
|
+
|
|
51
|
+
插件依赖 `angular-html-parser` 将源码当作类 HTML 解析。若与微信 WXML 存在细微差异,或源码无法被解析器接受,可能产生**解析错误**。默认**整文件原样返回**(不做任何插值改写);若 `wxmlThrowOnError: true` 则抛出。
|
|
52
|
+
|
|
53
|
+
### 扫描范围
|
|
54
|
+
|
|
55
|
+
仅在 AST 给出的**文本节点**与**属性值**对应区间内提取 `{{ }}`。**注释节点整段跳过**(注释里的 `{{ }}` 不处理)。
|
|
56
|
+
|
|
57
|
+
### 插值边界扫描(字符串与模板字符串)
|
|
58
|
+
|
|
59
|
+
在每一段可扫描的文本或属性值内部,用状态机配对 `{{` 与 `}}`:在 **单引号 / 双引号** 字符串字面量内出现的 `}}` **不会**被当作闭合。
|
|
60
|
+
**模板字符串**(反引号 `` ` ``)与嵌套 `{}` **未**按 JS 模板字符串语法处理;若插值内使用反引号表达式,可能出现 `}}` 配对错误,属于已知限制,该插值可能无法正确格式化或区间异常。
|
|
61
|
+
|
|
62
|
+
### 插值语言
|
|
63
|
+
|
|
64
|
+
仅将内容视为 **JavaScript 表达式** 格式化;**不包含** TypeScript(无 `typescript` / `babel-ts` 承诺)。
|
|
65
|
+
|
|
66
|
+
### 典型表达式
|
|
67
|
+
|
|
68
|
+
业务中常见:标识符、数字、字符串、数组字面量(如 `wx:for="{{[1, 2, 3]}}"`)、对象字面量等。更复杂的写法在能解析时同样会格式化;**若解析或格式化失败,则该插值原样保留**(默认)。
|
|
69
|
+
|
|
70
|
+
### `template` / `data` 的「类对象」简写
|
|
71
|
+
|
|
72
|
+
小程序 `data="{{foo, bar}}"` 等运行时语义与 **ECMAScript 中同一字符串作为独立表达式** 的解析结果**可能不一致**。本插件仍对**抽出的字符串**按 JS 去 `format`;若无法解析或存在语义偏差,依赖容错——**该插值原样保留**(首版不承诺还原为与微信「完全等价」的对象字面量)。
|
|
73
|
+
|
|
74
|
+
### 属性值与源码偏移
|
|
75
|
+
|
|
76
|
+
实现依赖解析器给出的节点在源码中的起止位置;若某版本解析器对属性值区间与微信不一致,可能影响插值定位(属实现细节)。
|
|
77
|
+
|
|
78
|
+
### `}}` 与 `}}` 后、引号前的空格
|
|
79
|
+
|
|
80
|
+
若属性写成 `wx:for="{{[1,2,3]}} "`(`}}` 与闭合引号 `"` 之间有空格),该空格**属于属性值字符串的一部分**,可能影响运行时语义。
|
|
81
|
+
**插件行为**:只替换一对 `{{`…`}}` 所覆盖的区间内的表达式;**不会**删除或移动 `}}` 与引号之间的空格。
|
|
82
|
+
|
|
83
|
+
### 绝对偏移与逆序替换
|
|
84
|
+
|
|
85
|
+
整份 `.wxml` 是一个字符串,从 0 编号。每个插值记录 `{{` 与 `}}` 之后**在**整串中的**绝对偏移**;**printer** 按各插值的 `start` **从大到小**逆序替换,避免先改前面导致后面下标偏移。
|
|
86
|
+
|
|
87
|
+
## 容错与错误
|
|
88
|
+
|
|
89
|
+
- 默认 **`wxmlThrowOnError: false`**:`parseHtml` 失败则整文件不变;某一插值表达式失败则**仅该插值**不变,其余继续。
|
|
90
|
+
- **`wxmlThrowOnError: true`**:上述错误直接抛出。
|
|
91
|
+
- **`wxmlReportLevel: 'warn'`**:在容错回退时向 `console.warn` 输出一行提示,例如:
|
|
92
|
+
- `[prettier-plugin-wxml] 已跳过 <filepath>:WXML 解析失败:<message>`
|
|
93
|
+
- `[prettier-plugin-wxml] 部分失败 <filepath>:共 <N> 处插值未能格式化`
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
Object.defineProperties(exports, {
|
|
2
|
+
__esModule: { value: true },
|
|
3
|
+
[Symbol.toStringTag]: { value: "Module" }
|
|
4
|
+
});
|
|
5
|
+
//#region \0rolldown/runtime.js
|
|
6
|
+
var __create = Object.create;
|
|
7
|
+
var __defProp = Object.defineProperty;
|
|
8
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
9
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
10
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
11
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
14
|
+
key = keys[i];
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
16
|
+
get: ((k) => from[k]).bind(null, key),
|
|
17
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return to;
|
|
21
|
+
};
|
|
22
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
23
|
+
value: mod,
|
|
24
|
+
enumerable: true
|
|
25
|
+
}) : target, mod));
|
|
26
|
+
//#endregion
|
|
27
|
+
let angular_html_parser = require("angular-html-parser");
|
|
28
|
+
let _babel_parser = require("@babel/parser");
|
|
29
|
+
let prettier = require("prettier");
|
|
30
|
+
prettier = __toESM(prettier);
|
|
31
|
+
//#region src/interpolation.ts
|
|
32
|
+
const MUSTACHE_SCAN_STATE = {
|
|
33
|
+
SCAN: "scan",
|
|
34
|
+
SINGLE_QUOTE: "singleQuote",
|
|
35
|
+
DOUBLE_QUOTE: "doubleQuote"
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* 在一段节点内容内提取 `{{`…`}}` 区间(相对 content 的偏移)。
|
|
39
|
+
* 使用状态机,避免字符串字面量内的 `}}` 被误当作闭合。
|
|
40
|
+
* @param content
|
|
41
|
+
*/
|
|
42
|
+
function extractMustacheRegions(content) {
|
|
43
|
+
const regions = [];
|
|
44
|
+
let i = 0;
|
|
45
|
+
while (i < content.length - 1) if (content[i] === "{" && content[i + 1] === "{") {
|
|
46
|
+
const end = findMustacheEnd(content, i);
|
|
47
|
+
if (end === null) {
|
|
48
|
+
i += 1;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
regions.push({
|
|
52
|
+
start: i,
|
|
53
|
+
end
|
|
54
|
+
});
|
|
55
|
+
i = end;
|
|
56
|
+
} else i += 1;
|
|
57
|
+
return regions;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 从 `openIdx` 处的 `{{` 起,找配对的 `}}` 结束下标(不含则返回 null)。
|
|
61
|
+
* @param content 节点内整段文本
|
|
62
|
+
* @param openIdx 指向 `{{` 的第一个 `{`
|
|
63
|
+
*/
|
|
64
|
+
function findMustacheEnd(content, openIdx) {
|
|
65
|
+
if (content[openIdx] !== "{" || content[openIdx + 1] !== "{") return null;
|
|
66
|
+
let pos = openIdx + 2;
|
|
67
|
+
let state = MUSTACHE_SCAN_STATE.SCAN;
|
|
68
|
+
while (pos < content.length) {
|
|
69
|
+
const c = content[pos];
|
|
70
|
+
if (state === MUSTACHE_SCAN_STATE.SCAN) {
|
|
71
|
+
if (c === "'") {
|
|
72
|
+
state = MUSTACHE_SCAN_STATE.SINGLE_QUOTE;
|
|
73
|
+
pos += 1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (c === "\"") {
|
|
77
|
+
state = MUSTACHE_SCAN_STATE.DOUBLE_QUOTE;
|
|
78
|
+
pos += 1;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (c === "}" && content[pos + 1] === "}") return pos + 2;
|
|
82
|
+
pos += 1;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (state === MUSTACHE_SCAN_STATE.SINGLE_QUOTE) {
|
|
86
|
+
if (c === "\\") {
|
|
87
|
+
pos += 2;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (c === "'") {
|
|
91
|
+
state = MUSTACHE_SCAN_STATE.SCAN;
|
|
92
|
+
pos += 1;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
pos += 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (state === MUSTACHE_SCAN_STATE.DOUBLE_QUOTE) {
|
|
99
|
+
if (c === "\\") {
|
|
100
|
+
pos += 2;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (c === "\"") {
|
|
104
|
+
state = MUSTACHE_SCAN_STATE.SCAN;
|
|
105
|
+
pos += 1;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
pos += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region src/collect-mustache-regions.ts
|
|
116
|
+
const HTML_FATAL_ERROR_LEVEL = 1;
|
|
117
|
+
function hasFatalHtmlParseErrors(result) {
|
|
118
|
+
return result.errors.some((e) => e.level === HTML_FATAL_ERROR_LEVEL);
|
|
119
|
+
}
|
|
120
|
+
function collectMustacheRegions(source) {
|
|
121
|
+
const result = (0, angular_html_parser.parseHtml)(source);
|
|
122
|
+
if (hasFatalHtmlParseErrors(result)) throw new Error(result.errors.map((e) => e.msg).join("; "));
|
|
123
|
+
const mustacheRegions = [];
|
|
124
|
+
(0, angular_html_parser.visitAll)(new MustacheRegionCollector(source, mustacheRegions), result.rootNodes);
|
|
125
|
+
return mustacheRegions;
|
|
126
|
+
}
|
|
127
|
+
var MustacheRegionCollector = class extends angular_html_parser.RecursiveVisitor {
|
|
128
|
+
constructor(source, mustacheRegions) {
|
|
129
|
+
super();
|
|
130
|
+
this.source = source;
|
|
131
|
+
this.mustacheRegions = mustacheRegions;
|
|
132
|
+
}
|
|
133
|
+
visitText(ast, context) {
|
|
134
|
+
this.pushSpan(ast.sourceSpan);
|
|
135
|
+
super.visitText(ast, context);
|
|
136
|
+
}
|
|
137
|
+
visitCdata(ast, context) {
|
|
138
|
+
this.pushSpan(ast.sourceSpan);
|
|
139
|
+
super.visitCdata(ast, context);
|
|
140
|
+
}
|
|
141
|
+
visitAttribute(ast, context) {
|
|
142
|
+
if (ast.valueSpan && ast.value.includes("{{")) this.pushSpan(ast.valueSpan);
|
|
143
|
+
super.visitAttribute(ast, context);
|
|
144
|
+
}
|
|
145
|
+
pushSpan(span) {
|
|
146
|
+
const start = span.start.offset;
|
|
147
|
+
const end = span.end.offset;
|
|
148
|
+
const slice = this.source.slice(start, end);
|
|
149
|
+
if (!slice.includes("{{")) return;
|
|
150
|
+
for (const r of extractMustacheRegions(slice)) this.mustacheRegions.push({
|
|
151
|
+
start: start + r.start,
|
|
152
|
+
end: start + r.end
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
//#endregion
|
|
157
|
+
//#region src/format-expression.ts
|
|
158
|
+
const RE_SEMICOLON_END = /;\s*$/;
|
|
159
|
+
const EXPORT_DEFAULT_PREFIX = "export default ";
|
|
160
|
+
/**
|
|
161
|
+
* 与 {@link tryFormatWrappedObjectLiteral} 中剥除 `export default` 与尾部分号的方式一致。
|
|
162
|
+
* @param output
|
|
163
|
+
*/
|
|
164
|
+
function stripFormattedExportDefaultLine(output) {
|
|
165
|
+
const trimmedOut = output.trimEnd();
|
|
166
|
+
if (!trimmedOut.startsWith(EXPORT_DEFAULT_PREFIX)) throw new Error("Unexpected Prettier output for expression");
|
|
167
|
+
let body = trimmedOut.slice(15).trimEnd();
|
|
168
|
+
body = body.replace(RE_SEMICOLON_END, "").trimEnd();
|
|
169
|
+
return body;
|
|
170
|
+
}
|
|
171
|
+
function buildInnerFormatOptions(options) {
|
|
172
|
+
return {
|
|
173
|
+
parser: "babel",
|
|
174
|
+
semi: false,
|
|
175
|
+
singleQuote: options.singleQuote,
|
|
176
|
+
printWidth: options.printWidth,
|
|
177
|
+
tabWidth: options.tabWidth,
|
|
178
|
+
useTabs: options.useTabs,
|
|
179
|
+
bracketSpacing: options.bracketSpacing,
|
|
180
|
+
arrowParens: options.arrowParens,
|
|
181
|
+
endOfLine: options.endOfLine,
|
|
182
|
+
plugins: []
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* 鉴于 WXML 对对象的支持情况,https://developers.weixin.qq.com/miniprogram/dev/reference/wxml/data.html#对象
|
|
187
|
+
* 若裸字符串不是合法表达式,则用 `{}` 包一层再试:可解析为对象字面量时,
|
|
188
|
+
* 按对象走 Prettier,再把外层 `{}` 剥掉写回插值(WXML 常见 `a:1,b:2` 即如此)。
|
|
189
|
+
* @param trimmed
|
|
190
|
+
* @param options
|
|
191
|
+
* @param throwOnError
|
|
192
|
+
*/
|
|
193
|
+
async function tryFormatWrappedObjectLiteral(trimmed, options, throwOnError) {
|
|
194
|
+
const wrapped = `{${trimmed}}`;
|
|
195
|
+
try {
|
|
196
|
+
(0, _babel_parser.parseExpression)(wrapped, { sourceType: "module" });
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const src = `export default ${wrapped};`;
|
|
202
|
+
const body = stripFormattedExportDefaultLine(await prettier.format(src, buildInnerFormatOptions(options)));
|
|
203
|
+
if (!body.startsWith("{") || !body.endsWith("}")) throw new Error("Unexpected Prettier output for object literal");
|
|
204
|
+
return body.slice(1, -1).trim();
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (throwOnError) throw err;
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* 将插值内层按 JS 表达式校验并交给 Prettier babel 格式化。
|
|
212
|
+
* 非表达式(语句)或语法错误时返回 null(除非 throwOnError)。
|
|
213
|
+
* @param inner
|
|
214
|
+
* @param options
|
|
215
|
+
* @param throwOnError
|
|
216
|
+
*/
|
|
217
|
+
async function formatInterpolationInner(inner, options, throwOnError) {
|
|
218
|
+
const trimmed = inner.trim();
|
|
219
|
+
if (!trimmed) {
|
|
220
|
+
if (throwOnError) throw new Error("Empty WXML interpolation expression");
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
(0, _babel_parser.parseExpression)(trimmed, { sourceType: "module" });
|
|
225
|
+
} catch (err) {
|
|
226
|
+
const fromObj = await tryFormatWrappedObjectLiteral(trimmed, options, throwOnError);
|
|
227
|
+
if (fromObj !== null) return fromObj;
|
|
228
|
+
if (throwOnError) throw err;
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const wrapped = `export default ${trimmed};`;
|
|
233
|
+
return stripFormattedExportDefaultLine(await prettier.format(wrapped, buildInnerFormatOptions(options)));
|
|
234
|
+
} catch (err) {
|
|
235
|
+
if (throwOnError) throw err;
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
//#endregion
|
|
240
|
+
//#region src/plugin.ts
|
|
241
|
+
const AST_FORMAT = "wxml-ast";
|
|
242
|
+
const WXML_REPORT_LEVEL = {
|
|
243
|
+
SILENT: "silent",
|
|
244
|
+
WARN: "warn"
|
|
245
|
+
};
|
|
246
|
+
function getThrowOnError(options) {
|
|
247
|
+
return Boolean(options.wxmlThrowOnError);
|
|
248
|
+
}
|
|
249
|
+
function getReportLevel(options) {
|
|
250
|
+
return options.wxmlReportLevel === WXML_REPORT_LEVEL.WARN ? WXML_REPORT_LEVEL.WARN : WXML_REPORT_LEVEL.SILENT;
|
|
251
|
+
}
|
|
252
|
+
function warnPartial(filepath, count) {
|
|
253
|
+
const fp = filepath ?? "<stdin>";
|
|
254
|
+
console.warn(`[prettier-plugin-wxml] partial ${fp}: expression-format-failed x${count}`);
|
|
255
|
+
}
|
|
256
|
+
function warnSkipped(filepath, reason) {
|
|
257
|
+
const fp = filepath ?? "<stdin>";
|
|
258
|
+
console.warn(`[prettier-plugin-wxml] skipped ${fp}: wxml-parse-failed: ${reason}`);
|
|
259
|
+
}
|
|
260
|
+
async function buildAst(text, options) {
|
|
261
|
+
const pluginOptions = options;
|
|
262
|
+
const throwOnError = getThrowOnError(pluginOptions);
|
|
263
|
+
const reportLevel = getReportLevel(pluginOptions);
|
|
264
|
+
const filepath = pluginOptions.filepath;
|
|
265
|
+
let mustacheRegions;
|
|
266
|
+
try {
|
|
267
|
+
mustacheRegions = collectMustacheRegions(text);
|
|
268
|
+
} catch (err) {
|
|
269
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
270
|
+
if (throwOnError) throw err;
|
|
271
|
+
if (reportLevel === WXML_REPORT_LEVEL.WARN) warnSkipped(filepath, msg);
|
|
272
|
+
return {
|
|
273
|
+
type: "wxml-root",
|
|
274
|
+
source: text,
|
|
275
|
+
interpolations: []
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
mustacheRegions.sort((a, b) => a.start - b.start);
|
|
279
|
+
const interpolations = [];
|
|
280
|
+
let formatFailCount = 0;
|
|
281
|
+
for (const { start, end } of mustacheRegions) {
|
|
282
|
+
const raw = text.slice(start, end);
|
|
283
|
+
const inner = text.slice(start + 2, end - 2);
|
|
284
|
+
let formatted;
|
|
285
|
+
try {
|
|
286
|
+
formatted = await formatInterpolationInner(inner, options, throwOnError);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
if (throwOnError) throw err;
|
|
289
|
+
formatted = null;
|
|
290
|
+
}
|
|
291
|
+
if (formatted === null && inner.trim() !== "") formatFailCount += 1;
|
|
292
|
+
interpolations.push({
|
|
293
|
+
start,
|
|
294
|
+
end,
|
|
295
|
+
raw,
|
|
296
|
+
formatted
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
if (formatFailCount > 0 && reportLevel === WXML_REPORT_LEVEL.WARN && !throwOnError) warnPartial(filepath, formatFailCount);
|
|
300
|
+
return {
|
|
301
|
+
type: "wxml-root",
|
|
302
|
+
source: text,
|
|
303
|
+
interpolations
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const wxmlParser = {
|
|
307
|
+
astFormat: AST_FORMAT,
|
|
308
|
+
parse: (text, options) => buildAst(text, options),
|
|
309
|
+
locStart: () => 0,
|
|
310
|
+
locEnd: (node) => node.source.length
|
|
311
|
+
};
|
|
312
|
+
const wxmlPrinter = { print(path) {
|
|
313
|
+
const node = path.getValue();
|
|
314
|
+
if (node.type !== "wxml-root") return "";
|
|
315
|
+
const { source, interpolations } = node;
|
|
316
|
+
const sorted = [...interpolations].sort((a, b) => b.start - a.start);
|
|
317
|
+
let out = source;
|
|
318
|
+
for (const item of sorted) {
|
|
319
|
+
if (item.formatted === null) continue;
|
|
320
|
+
const replacement = `{{ ${item.formatted} }}`;
|
|
321
|
+
out = out.slice(0, item.start) + replacement + out.slice(item.end);
|
|
322
|
+
}
|
|
323
|
+
return out;
|
|
324
|
+
} };
|
|
325
|
+
const languages = [{
|
|
326
|
+
name: "WXML",
|
|
327
|
+
parsers: ["wxml"],
|
|
328
|
+
extensions: [".wxml"],
|
|
329
|
+
vscodeLanguageIds: ["wxml"]
|
|
330
|
+
}];
|
|
331
|
+
const options = {
|
|
332
|
+
wxmlThrowOnError: {
|
|
333
|
+
type: "boolean",
|
|
334
|
+
default: false,
|
|
335
|
+
category: "WXML",
|
|
336
|
+
description: "When true, throw on WXML parse failure or when an interpolation cannot be formatted. Default: false (graceful fallback)."
|
|
337
|
+
},
|
|
338
|
+
wxmlReportLevel: {
|
|
339
|
+
type: "choice",
|
|
340
|
+
category: "WXML",
|
|
341
|
+
default: WXML_REPORT_LEVEL.SILENT,
|
|
342
|
+
description: "When not silent, emit console warnings when the file is skipped or partially formatted.",
|
|
343
|
+
choices: [{
|
|
344
|
+
value: WXML_REPORT_LEVEL.SILENT,
|
|
345
|
+
description: "No extra logging."
|
|
346
|
+
}, {
|
|
347
|
+
value: WXML_REPORT_LEVEL.WARN,
|
|
348
|
+
description: "Log warnings when falling back to raw source."
|
|
349
|
+
}]
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
const defaultExport = {
|
|
353
|
+
name: "@tofrankie/prettier-plugin-wxml",
|
|
354
|
+
languages,
|
|
355
|
+
parsers: { wxml: wxmlParser },
|
|
356
|
+
printers: { [AST_FORMAT]: wxmlPrinter },
|
|
357
|
+
options
|
|
358
|
+
};
|
|
359
|
+
//#endregion
|
|
360
|
+
exports.default = defaultExport;
|
|
361
|
+
exports.defaultExport = defaultExport;
|
|
362
|
+
exports.options = options;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Parser, Printer, SupportLanguage } from "prettier";
|
|
2
|
+
|
|
3
|
+
//#region src/types.d.ts
|
|
4
|
+
interface WxmlInterpolation {
|
|
5
|
+
start: number;
|
|
6
|
+
end: number;
|
|
7
|
+
raw: string;
|
|
8
|
+
formatted: string | null;
|
|
9
|
+
}
|
|
10
|
+
interface WxmlRootAst {
|
|
11
|
+
type: 'wxml-root';
|
|
12
|
+
source: string;
|
|
13
|
+
interpolations: WxmlInterpolation[];
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region src/plugin.d.ts
|
|
17
|
+
declare const options: {
|
|
18
|
+
wxmlThrowOnError: {
|
|
19
|
+
type: "boolean";
|
|
20
|
+
default: boolean;
|
|
21
|
+
category: string;
|
|
22
|
+
description: string;
|
|
23
|
+
};
|
|
24
|
+
wxmlReportLevel: {
|
|
25
|
+
type: "choice";
|
|
26
|
+
category: string;
|
|
27
|
+
default: "silent";
|
|
28
|
+
description: string;
|
|
29
|
+
choices: ({
|
|
30
|
+
value: "silent";
|
|
31
|
+
description: string;
|
|
32
|
+
} | {
|
|
33
|
+
value: "warn";
|
|
34
|
+
description: string;
|
|
35
|
+
})[];
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
declare const defaultExport: {
|
|
39
|
+
name: string;
|
|
40
|
+
languages: SupportLanguage[];
|
|
41
|
+
parsers: {
|
|
42
|
+
wxml: Parser<WxmlRootAst>;
|
|
43
|
+
};
|
|
44
|
+
printers: {
|
|
45
|
+
"wxml-ast": Printer<WxmlRootAst>;
|
|
46
|
+
};
|
|
47
|
+
options: {
|
|
48
|
+
wxmlThrowOnError: {
|
|
49
|
+
type: "boolean";
|
|
50
|
+
default: boolean;
|
|
51
|
+
category: string;
|
|
52
|
+
description: string;
|
|
53
|
+
};
|
|
54
|
+
wxmlReportLevel: {
|
|
55
|
+
type: "choice";
|
|
56
|
+
category: string;
|
|
57
|
+
default: "silent";
|
|
58
|
+
description: string;
|
|
59
|
+
choices: ({
|
|
60
|
+
value: "silent";
|
|
61
|
+
description: string;
|
|
62
|
+
} | {
|
|
63
|
+
value: "warn";
|
|
64
|
+
description: string;
|
|
65
|
+
})[];
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
//#endregion
|
|
70
|
+
export { type WxmlRootAst, defaultExport as default, defaultExport, options };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Parser, Printer, SupportLanguage } from "prettier";
|
|
2
|
+
|
|
3
|
+
//#region src/types.d.ts
|
|
4
|
+
interface WxmlInterpolation {
|
|
5
|
+
start: number;
|
|
6
|
+
end: number;
|
|
7
|
+
raw: string;
|
|
8
|
+
formatted: string | null;
|
|
9
|
+
}
|
|
10
|
+
interface WxmlRootAst {
|
|
11
|
+
type: 'wxml-root';
|
|
12
|
+
source: string;
|
|
13
|
+
interpolations: WxmlInterpolation[];
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region src/plugin.d.ts
|
|
17
|
+
declare const options: {
|
|
18
|
+
wxmlThrowOnError: {
|
|
19
|
+
type: "boolean";
|
|
20
|
+
default: boolean;
|
|
21
|
+
category: string;
|
|
22
|
+
description: string;
|
|
23
|
+
};
|
|
24
|
+
wxmlReportLevel: {
|
|
25
|
+
type: "choice";
|
|
26
|
+
category: string;
|
|
27
|
+
default: "silent";
|
|
28
|
+
description: string;
|
|
29
|
+
choices: ({
|
|
30
|
+
value: "silent";
|
|
31
|
+
description: string;
|
|
32
|
+
} | {
|
|
33
|
+
value: "warn";
|
|
34
|
+
description: string;
|
|
35
|
+
})[];
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
declare const defaultExport: {
|
|
39
|
+
name: string;
|
|
40
|
+
languages: SupportLanguage[];
|
|
41
|
+
parsers: {
|
|
42
|
+
wxml: Parser<WxmlRootAst>;
|
|
43
|
+
};
|
|
44
|
+
printers: {
|
|
45
|
+
"wxml-ast": Printer<WxmlRootAst>;
|
|
46
|
+
};
|
|
47
|
+
options: {
|
|
48
|
+
wxmlThrowOnError: {
|
|
49
|
+
type: "boolean";
|
|
50
|
+
default: boolean;
|
|
51
|
+
category: string;
|
|
52
|
+
description: string;
|
|
53
|
+
};
|
|
54
|
+
wxmlReportLevel: {
|
|
55
|
+
type: "choice";
|
|
56
|
+
category: string;
|
|
57
|
+
default: "silent";
|
|
58
|
+
description: string;
|
|
59
|
+
choices: ({
|
|
60
|
+
value: "silent";
|
|
61
|
+
description: string;
|
|
62
|
+
} | {
|
|
63
|
+
value: "warn";
|
|
64
|
+
description: string;
|
|
65
|
+
})[];
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
//#endregion
|
|
70
|
+
export { type WxmlRootAst, defaultExport as default, defaultExport, options };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { RecursiveVisitor, parseHtml, visitAll } from "angular-html-parser";
|
|
2
|
+
import { parseExpression } from "@babel/parser";
|
|
3
|
+
import * as prettier from "prettier";
|
|
4
|
+
//#region src/interpolation.ts
|
|
5
|
+
const MUSTACHE_SCAN_STATE = {
|
|
6
|
+
SCAN: "scan",
|
|
7
|
+
SINGLE_QUOTE: "singleQuote",
|
|
8
|
+
DOUBLE_QUOTE: "doubleQuote"
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* 在一段节点内容内提取 `{{`…`}}` 区间(相对 content 的偏移)。
|
|
12
|
+
* 使用状态机,避免字符串字面量内的 `}}` 被误当作闭合。
|
|
13
|
+
* @param content
|
|
14
|
+
*/
|
|
15
|
+
function extractMustacheRegions(content) {
|
|
16
|
+
const regions = [];
|
|
17
|
+
let i = 0;
|
|
18
|
+
while (i < content.length - 1) if (content[i] === "{" && content[i + 1] === "{") {
|
|
19
|
+
const end = findMustacheEnd(content, i);
|
|
20
|
+
if (end === null) {
|
|
21
|
+
i += 1;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
regions.push({
|
|
25
|
+
start: i,
|
|
26
|
+
end
|
|
27
|
+
});
|
|
28
|
+
i = end;
|
|
29
|
+
} else i += 1;
|
|
30
|
+
return regions;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 从 `openIdx` 处的 `{{` 起,找配对的 `}}` 结束下标(不含则返回 null)。
|
|
34
|
+
* @param content 节点内整段文本
|
|
35
|
+
* @param openIdx 指向 `{{` 的第一个 `{`
|
|
36
|
+
*/
|
|
37
|
+
function findMustacheEnd(content, openIdx) {
|
|
38
|
+
if (content[openIdx] !== "{" || content[openIdx + 1] !== "{") return null;
|
|
39
|
+
let pos = openIdx + 2;
|
|
40
|
+
let state = MUSTACHE_SCAN_STATE.SCAN;
|
|
41
|
+
while (pos < content.length) {
|
|
42
|
+
const c = content[pos];
|
|
43
|
+
if (state === MUSTACHE_SCAN_STATE.SCAN) {
|
|
44
|
+
if (c === "'") {
|
|
45
|
+
state = MUSTACHE_SCAN_STATE.SINGLE_QUOTE;
|
|
46
|
+
pos += 1;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (c === "\"") {
|
|
50
|
+
state = MUSTACHE_SCAN_STATE.DOUBLE_QUOTE;
|
|
51
|
+
pos += 1;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (c === "}" && content[pos + 1] === "}") return pos + 2;
|
|
55
|
+
pos += 1;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (state === MUSTACHE_SCAN_STATE.SINGLE_QUOTE) {
|
|
59
|
+
if (c === "\\") {
|
|
60
|
+
pos += 2;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (c === "'") {
|
|
64
|
+
state = MUSTACHE_SCAN_STATE.SCAN;
|
|
65
|
+
pos += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
pos += 1;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (state === MUSTACHE_SCAN_STATE.DOUBLE_QUOTE) {
|
|
72
|
+
if (c === "\\") {
|
|
73
|
+
pos += 2;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (c === "\"") {
|
|
77
|
+
state = MUSTACHE_SCAN_STATE.SCAN;
|
|
78
|
+
pos += 1;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
pos += 1;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region src/collect-mustache-regions.ts
|
|
89
|
+
const HTML_FATAL_ERROR_LEVEL = 1;
|
|
90
|
+
function hasFatalHtmlParseErrors(result) {
|
|
91
|
+
return result.errors.some((e) => e.level === HTML_FATAL_ERROR_LEVEL);
|
|
92
|
+
}
|
|
93
|
+
function collectMustacheRegions(source) {
|
|
94
|
+
const result = parseHtml(source);
|
|
95
|
+
if (hasFatalHtmlParseErrors(result)) throw new Error(result.errors.map((e) => e.msg).join("; "));
|
|
96
|
+
const mustacheRegions = [];
|
|
97
|
+
visitAll(new MustacheRegionCollector(source, mustacheRegions), result.rootNodes);
|
|
98
|
+
return mustacheRegions;
|
|
99
|
+
}
|
|
100
|
+
var MustacheRegionCollector = class extends RecursiveVisitor {
|
|
101
|
+
constructor(source, mustacheRegions) {
|
|
102
|
+
super();
|
|
103
|
+
this.source = source;
|
|
104
|
+
this.mustacheRegions = mustacheRegions;
|
|
105
|
+
}
|
|
106
|
+
visitText(ast, context) {
|
|
107
|
+
this.pushSpan(ast.sourceSpan);
|
|
108
|
+
super.visitText(ast, context);
|
|
109
|
+
}
|
|
110
|
+
visitCdata(ast, context) {
|
|
111
|
+
this.pushSpan(ast.sourceSpan);
|
|
112
|
+
super.visitCdata(ast, context);
|
|
113
|
+
}
|
|
114
|
+
visitAttribute(ast, context) {
|
|
115
|
+
if (ast.valueSpan && ast.value.includes("{{")) this.pushSpan(ast.valueSpan);
|
|
116
|
+
super.visitAttribute(ast, context);
|
|
117
|
+
}
|
|
118
|
+
pushSpan(span) {
|
|
119
|
+
const start = span.start.offset;
|
|
120
|
+
const end = span.end.offset;
|
|
121
|
+
const slice = this.source.slice(start, end);
|
|
122
|
+
if (!slice.includes("{{")) return;
|
|
123
|
+
for (const r of extractMustacheRegions(slice)) this.mustacheRegions.push({
|
|
124
|
+
start: start + r.start,
|
|
125
|
+
end: start + r.end
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/format-expression.ts
|
|
131
|
+
const RE_SEMICOLON_END = /;\s*$/;
|
|
132
|
+
const EXPORT_DEFAULT_PREFIX = "export default ";
|
|
133
|
+
/**
|
|
134
|
+
* 与 {@link tryFormatWrappedObjectLiteral} 中剥除 `export default` 与尾部分号的方式一致。
|
|
135
|
+
* @param output
|
|
136
|
+
*/
|
|
137
|
+
function stripFormattedExportDefaultLine(output) {
|
|
138
|
+
const trimmedOut = output.trimEnd();
|
|
139
|
+
if (!trimmedOut.startsWith(EXPORT_DEFAULT_PREFIX)) throw new Error("Unexpected Prettier output for expression");
|
|
140
|
+
let body = trimmedOut.slice(15).trimEnd();
|
|
141
|
+
body = body.replace(RE_SEMICOLON_END, "").trimEnd();
|
|
142
|
+
return body;
|
|
143
|
+
}
|
|
144
|
+
function buildInnerFormatOptions(options) {
|
|
145
|
+
return {
|
|
146
|
+
parser: "babel",
|
|
147
|
+
semi: false,
|
|
148
|
+
singleQuote: options.singleQuote,
|
|
149
|
+
printWidth: options.printWidth,
|
|
150
|
+
tabWidth: options.tabWidth,
|
|
151
|
+
useTabs: options.useTabs,
|
|
152
|
+
bracketSpacing: options.bracketSpacing,
|
|
153
|
+
arrowParens: options.arrowParens,
|
|
154
|
+
endOfLine: options.endOfLine,
|
|
155
|
+
plugins: []
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* 鉴于 WXML 对对象的支持情况,https://developers.weixin.qq.com/miniprogram/dev/reference/wxml/data.html#对象
|
|
160
|
+
* 若裸字符串不是合法表达式,则用 `{}` 包一层再试:可解析为对象字面量时,
|
|
161
|
+
* 按对象走 Prettier,再把外层 `{}` 剥掉写回插值(WXML 常见 `a:1,b:2` 即如此)。
|
|
162
|
+
* @param trimmed
|
|
163
|
+
* @param options
|
|
164
|
+
* @param throwOnError
|
|
165
|
+
*/
|
|
166
|
+
async function tryFormatWrappedObjectLiteral(trimmed, options, throwOnError) {
|
|
167
|
+
const wrapped = `{${trimmed}}`;
|
|
168
|
+
try {
|
|
169
|
+
parseExpression(wrapped, { sourceType: "module" });
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const src = `export default ${wrapped};`;
|
|
175
|
+
const body = stripFormattedExportDefaultLine(await prettier.format(src, buildInnerFormatOptions(options)));
|
|
176
|
+
if (!body.startsWith("{") || !body.endsWith("}")) throw new Error("Unexpected Prettier output for object literal");
|
|
177
|
+
return body.slice(1, -1).trim();
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (throwOnError) throw err;
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* 将插值内层按 JS 表达式校验并交给 Prettier babel 格式化。
|
|
185
|
+
* 非表达式(语句)或语法错误时返回 null(除非 throwOnError)。
|
|
186
|
+
* @param inner
|
|
187
|
+
* @param options
|
|
188
|
+
* @param throwOnError
|
|
189
|
+
*/
|
|
190
|
+
async function formatInterpolationInner(inner, options, throwOnError) {
|
|
191
|
+
const trimmed = inner.trim();
|
|
192
|
+
if (!trimmed) {
|
|
193
|
+
if (throwOnError) throw new Error("Empty WXML interpolation expression");
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
parseExpression(trimmed, { sourceType: "module" });
|
|
198
|
+
} catch (err) {
|
|
199
|
+
const fromObj = await tryFormatWrappedObjectLiteral(trimmed, options, throwOnError);
|
|
200
|
+
if (fromObj !== null) return fromObj;
|
|
201
|
+
if (throwOnError) throw err;
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const wrapped = `export default ${trimmed};`;
|
|
206
|
+
return stripFormattedExportDefaultLine(await prettier.format(wrapped, buildInnerFormatOptions(options)));
|
|
207
|
+
} catch (err) {
|
|
208
|
+
if (throwOnError) throw err;
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
//#endregion
|
|
213
|
+
//#region src/plugin.ts
|
|
214
|
+
const AST_FORMAT = "wxml-ast";
|
|
215
|
+
const WXML_REPORT_LEVEL = {
|
|
216
|
+
SILENT: "silent",
|
|
217
|
+
WARN: "warn"
|
|
218
|
+
};
|
|
219
|
+
function getThrowOnError(options) {
|
|
220
|
+
return Boolean(options.wxmlThrowOnError);
|
|
221
|
+
}
|
|
222
|
+
function getReportLevel(options) {
|
|
223
|
+
return options.wxmlReportLevel === WXML_REPORT_LEVEL.WARN ? WXML_REPORT_LEVEL.WARN : WXML_REPORT_LEVEL.SILENT;
|
|
224
|
+
}
|
|
225
|
+
function warnPartial(filepath, count) {
|
|
226
|
+
const fp = filepath ?? "<stdin>";
|
|
227
|
+
console.warn(`[prettier-plugin-wxml] partial ${fp}: expression-format-failed x${count}`);
|
|
228
|
+
}
|
|
229
|
+
function warnSkipped(filepath, reason) {
|
|
230
|
+
const fp = filepath ?? "<stdin>";
|
|
231
|
+
console.warn(`[prettier-plugin-wxml] skipped ${fp}: wxml-parse-failed: ${reason}`);
|
|
232
|
+
}
|
|
233
|
+
async function buildAst(text, options) {
|
|
234
|
+
const pluginOptions = options;
|
|
235
|
+
const throwOnError = getThrowOnError(pluginOptions);
|
|
236
|
+
const reportLevel = getReportLevel(pluginOptions);
|
|
237
|
+
const filepath = pluginOptions.filepath;
|
|
238
|
+
let mustacheRegions;
|
|
239
|
+
try {
|
|
240
|
+
mustacheRegions = collectMustacheRegions(text);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
243
|
+
if (throwOnError) throw err;
|
|
244
|
+
if (reportLevel === WXML_REPORT_LEVEL.WARN) warnSkipped(filepath, msg);
|
|
245
|
+
return {
|
|
246
|
+
type: "wxml-root",
|
|
247
|
+
source: text,
|
|
248
|
+
interpolations: []
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
mustacheRegions.sort((a, b) => a.start - b.start);
|
|
252
|
+
const interpolations = [];
|
|
253
|
+
let formatFailCount = 0;
|
|
254
|
+
for (const { start, end } of mustacheRegions) {
|
|
255
|
+
const raw = text.slice(start, end);
|
|
256
|
+
const inner = text.slice(start + 2, end - 2);
|
|
257
|
+
let formatted;
|
|
258
|
+
try {
|
|
259
|
+
formatted = await formatInterpolationInner(inner, options, throwOnError);
|
|
260
|
+
} catch (err) {
|
|
261
|
+
if (throwOnError) throw err;
|
|
262
|
+
formatted = null;
|
|
263
|
+
}
|
|
264
|
+
if (formatted === null && inner.trim() !== "") formatFailCount += 1;
|
|
265
|
+
interpolations.push({
|
|
266
|
+
start,
|
|
267
|
+
end,
|
|
268
|
+
raw,
|
|
269
|
+
formatted
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
if (formatFailCount > 0 && reportLevel === WXML_REPORT_LEVEL.WARN && !throwOnError) warnPartial(filepath, formatFailCount);
|
|
273
|
+
return {
|
|
274
|
+
type: "wxml-root",
|
|
275
|
+
source: text,
|
|
276
|
+
interpolations
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
const wxmlParser = {
|
|
280
|
+
astFormat: AST_FORMAT,
|
|
281
|
+
parse: (text, options) => buildAst(text, options),
|
|
282
|
+
locStart: () => 0,
|
|
283
|
+
locEnd: (node) => node.source.length
|
|
284
|
+
};
|
|
285
|
+
const wxmlPrinter = { print(path) {
|
|
286
|
+
const node = path.getValue();
|
|
287
|
+
if (node.type !== "wxml-root") return "";
|
|
288
|
+
const { source, interpolations } = node;
|
|
289
|
+
const sorted = [...interpolations].sort((a, b) => b.start - a.start);
|
|
290
|
+
let out = source;
|
|
291
|
+
for (const item of sorted) {
|
|
292
|
+
if (item.formatted === null) continue;
|
|
293
|
+
const replacement = `{{ ${item.formatted} }}`;
|
|
294
|
+
out = out.slice(0, item.start) + replacement + out.slice(item.end);
|
|
295
|
+
}
|
|
296
|
+
return out;
|
|
297
|
+
} };
|
|
298
|
+
const languages = [{
|
|
299
|
+
name: "WXML",
|
|
300
|
+
parsers: ["wxml"],
|
|
301
|
+
extensions: [".wxml"],
|
|
302
|
+
vscodeLanguageIds: ["wxml"]
|
|
303
|
+
}];
|
|
304
|
+
const options = {
|
|
305
|
+
wxmlThrowOnError: {
|
|
306
|
+
type: "boolean",
|
|
307
|
+
default: false,
|
|
308
|
+
category: "WXML",
|
|
309
|
+
description: "When true, throw on WXML parse failure or when an interpolation cannot be formatted. Default: false (graceful fallback)."
|
|
310
|
+
},
|
|
311
|
+
wxmlReportLevel: {
|
|
312
|
+
type: "choice",
|
|
313
|
+
category: "WXML",
|
|
314
|
+
default: WXML_REPORT_LEVEL.SILENT,
|
|
315
|
+
description: "When not silent, emit console warnings when the file is skipped or partially formatted.",
|
|
316
|
+
choices: [{
|
|
317
|
+
value: WXML_REPORT_LEVEL.SILENT,
|
|
318
|
+
description: "No extra logging."
|
|
319
|
+
}, {
|
|
320
|
+
value: WXML_REPORT_LEVEL.WARN,
|
|
321
|
+
description: "Log warnings when falling back to raw source."
|
|
322
|
+
}]
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
const defaultExport = {
|
|
326
|
+
name: "@tofrankie/prettier-plugin-wxml",
|
|
327
|
+
languages,
|
|
328
|
+
parsers: { wxml: wxmlParser },
|
|
329
|
+
printers: { [AST_FORMAT]: wxmlPrinter },
|
|
330
|
+
options
|
|
331
|
+
};
|
|
332
|
+
//#endregion
|
|
333
|
+
export { defaultExport as default, defaultExport, options };
|
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tofrankie/prettier-plugin-wxml",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"description": "格式化微信小程序 WXML 的 Prettier 插件",
|
|
6
|
+
"author": "Frankie <1426203851@qq.com> (https://github.com/tofrankie)",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"homepage": "https://github.com/tofrankie/prettier-plugin-wxml#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/tofrankie/prettier-plugin-wxml.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/tofrankie/prettier-plugin-wxml/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"prettier",
|
|
16
|
+
"plugin",
|
|
17
|
+
"wxml",
|
|
18
|
+
"wechat",
|
|
19
|
+
"miniprogram",
|
|
20
|
+
"tofrankie"
|
|
21
|
+
],
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"import": {
|
|
25
|
+
"types": "./dist/index.d.mts",
|
|
26
|
+
"default": "./dist/index.mjs"
|
|
27
|
+
},
|
|
28
|
+
"require": {
|
|
29
|
+
"types": "./dist/index.d.cts",
|
|
30
|
+
"default": "./dist/index.cjs"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"main": "./dist/index.cjs",
|
|
35
|
+
"module": "./dist/index.mjs",
|
|
36
|
+
"types": "./dist/index.d.mts",
|
|
37
|
+
"files": [
|
|
38
|
+
"CHANGELOG.md",
|
|
39
|
+
"dist"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"prettier": ">=3.0.0"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@babel/parser": "^7.29.2",
|
|
52
|
+
"angular-html-parser": "^10.4.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@tofrankie/action": "^0.0.4",
|
|
56
|
+
"@tofrankie/eslint": "^0.0.15",
|
|
57
|
+
"@tofrankie/prettier": "^0.0.5",
|
|
58
|
+
"@types/node": "^25.5.0",
|
|
59
|
+
"eslint": "^9.39.4",
|
|
60
|
+
"husky": "^9.1.7",
|
|
61
|
+
"lint-staged": "^16.4.0",
|
|
62
|
+
"npm-run-all2": "^8.0.4",
|
|
63
|
+
"prettier": "^3.8.1",
|
|
64
|
+
"publint": "^0.3.18",
|
|
65
|
+
"tsdown": "^0.21.4",
|
|
66
|
+
"typescript": "^5.9.3",
|
|
67
|
+
"vitest": "^4.1.0"
|
|
68
|
+
},
|
|
69
|
+
"scripts": {
|
|
70
|
+
"build": "tsdown",
|
|
71
|
+
"lint": "run-s lint:eslint lint:prettier tsc publint",
|
|
72
|
+
"lint:eslint": "eslint . --cache --fix",
|
|
73
|
+
"lint:prettier": "prettier . --cache --write --log-level silent",
|
|
74
|
+
"publint": "publint",
|
|
75
|
+
"tsc": "tsc",
|
|
76
|
+
"test": "vitest run",
|
|
77
|
+
"test:watch": "vitest",
|
|
78
|
+
"github:release": "tfr"
|
|
79
|
+
}
|
|
80
|
+
}
|