eslint-plugin-md-style 0.1.0 → 0.3.0
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/README.md +7 -2
- package/dist/index.d.mts +0 -1
- package/dist/index.mjs +1207 -237
- package/package.json +19 -18
package/dist/index.mjs
CHANGED
|
@@ -1,18 +1,8 @@
|
|
|
1
1
|
import markdown, { MarkdownLanguage } from "@eslint/markdown";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
return {
|
|
6
|
-
create,
|
|
7
|
-
meta: {
|
|
8
|
-
defaultOptions,
|
|
9
|
-
...meta
|
|
10
|
-
}
|
|
11
|
-
};
|
|
2
|
+
//#region src/parser/ast.ts
|
|
3
|
+
function isObject(val) {
|
|
4
|
+
return !!val && typeof val === "object";
|
|
12
5
|
}
|
|
13
|
-
|
|
14
|
-
//#endregion
|
|
15
|
-
//#region src/utils/ast.ts
|
|
16
6
|
/**
|
|
17
7
|
* Checks whether an unknown value behaves like an mdast parent node.
|
|
18
8
|
*
|
|
@@ -20,30 +10,13 @@ function createRule({ create, defaultOptions, meta }) {
|
|
|
20
10
|
* not expose mdast-specific types.
|
|
21
11
|
*/
|
|
22
12
|
function hasChildren(node) {
|
|
23
|
-
return
|
|
24
|
-
}
|
|
25
|
-
function isTableCell(node) {
|
|
26
|
-
return node.type === "tableCell";
|
|
13
|
+
return isObject(node) && "children" in node && Array.isArray(node.children);
|
|
27
14
|
}
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
"image",
|
|
31
|
-
"inlineCode",
|
|
32
|
-
"emphasis",
|
|
33
|
-
"strong"
|
|
34
|
-
]);
|
|
35
|
-
/**
|
|
36
|
-
* Checks whether a phrasing node is one of the selected inline element targets.
|
|
37
|
-
*/
|
|
38
|
-
function isInlineElement(node) {
|
|
39
|
-
return !!node && INLINE_ELEMENT_TYPES.has(node.type);
|
|
15
|
+
function isCodeNode(node) {
|
|
16
|
+
return node?.type === "code";
|
|
40
17
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
*/
|
|
44
|
-
function isNestedInlineElement(nodeContext) {
|
|
45
|
-
const { parent } = nodeContext;
|
|
46
|
-
return isInlineElement(parent);
|
|
18
|
+
function isTableCell(node) {
|
|
19
|
+
return node?.type === "tableCell";
|
|
47
20
|
}
|
|
48
21
|
/**
|
|
49
22
|
* Extracts the plain-text value of a phrasing node.
|
|
@@ -52,7 +25,7 @@ function isNestedInlineElement(nodeContext) {
|
|
|
52
25
|
function getNodeValue(node) {
|
|
53
26
|
if (!node) return;
|
|
54
27
|
if ("value" in node) return node.value;
|
|
55
|
-
if (hasChildren(node)) return node.children.map(getNodeValue).join("")
|
|
28
|
+
if (hasChildren(node)) return node.children.map(getNodeValue).join("");
|
|
56
29
|
}
|
|
57
30
|
/**
|
|
58
31
|
* Gets the start and end offsets for a node.
|
|
@@ -80,7 +53,6 @@ function getNodeContext(context, node) {
|
|
|
80
53
|
current: node
|
|
81
54
|
};
|
|
82
55
|
const currentIndex = parent.children.findIndex((child) => child === node);
|
|
83
|
-
/* v8 ignore if -- @preserve */
|
|
84
56
|
if (currentIndex === -1) return {
|
|
85
57
|
parent,
|
|
86
58
|
prev: void 0,
|
|
@@ -95,18 +67,606 @@ function getNodeContext(context, node) {
|
|
|
95
67
|
};
|
|
96
68
|
}
|
|
97
69
|
/**
|
|
98
|
-
*
|
|
70
|
+
* Returns the current node and adjacent siblings from a known children array.
|
|
71
|
+
* Useful when iterating a tokenized child list directly without an ESLint ancestor context.
|
|
72
|
+
*/
|
|
73
|
+
function getNodeContextByParent(childrenNodes, currentIndex) {
|
|
74
|
+
return {
|
|
75
|
+
prev: childrenNodes[currentIndex - 1],
|
|
76
|
+
current: childrenNodes[currentIndex],
|
|
77
|
+
next: childrenNodes[currentIndex + 1]
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Gets the first or last visible character of a string after trimming
|
|
82
|
+
* surrounding whitespace.
|
|
99
83
|
*/
|
|
100
84
|
function getAdjacentChar(str, position) {
|
|
101
85
|
if (!str) return void 0;
|
|
102
86
|
str = str.trim();
|
|
103
87
|
return position === "head" ? str[0] : str[str.length - 1];
|
|
104
88
|
}
|
|
105
|
-
|
|
89
|
+
//#endregion
|
|
90
|
+
//#region src/parser/custom-container/parse.ts
|
|
91
|
+
/**
|
|
92
|
+
* Parses top-level text into container, blank-line, and text nodes.
|
|
93
|
+
*/
|
|
94
|
+
function parseCustomContainers(value) {
|
|
95
|
+
const state = {
|
|
96
|
+
splits: splitLines(value),
|
|
97
|
+
offset: 0
|
|
98
|
+
};
|
|
99
|
+
const nodes = [];
|
|
100
|
+
while (state.splits.length) {
|
|
101
|
+
const item = state.splits[0];
|
|
102
|
+
const previous = nodes.at(-1);
|
|
103
|
+
if (isOpenTag(item)) {
|
|
104
|
+
nodes.push(parseCustomContainer(state, 1, previous));
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const consumed = consume(state);
|
|
108
|
+
if (isBlank(consumed.value)) {
|
|
109
|
+
const blankNode = parseBlankNode({
|
|
110
|
+
newline: consumed.value,
|
|
111
|
+
prevNode: previous
|
|
112
|
+
});
|
|
113
|
+
if (blankNode) nodes.push(blankNode);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
nodes.push({
|
|
117
|
+
type: "text",
|
|
118
|
+
value: consumed.value,
|
|
119
|
+
position: consumed.position
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return nodes;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Parses one container by consuming its opening tag, nested containers, content,
|
|
126
|
+
* and a closing fence whose marker is at least as wide as the opening fence.
|
|
127
|
+
*/
|
|
128
|
+
function parseCustomContainer(state, depth, parentNode) {
|
|
129
|
+
const children = [];
|
|
130
|
+
const raw = state.splits[0];
|
|
131
|
+
const openTag = parseOpenTag(raw, parentNode);
|
|
132
|
+
consume(state);
|
|
133
|
+
children.push(openTag);
|
|
134
|
+
while (state.splits.length) {
|
|
135
|
+
const item = state.splits[0];
|
|
136
|
+
const previous = children.at(-1);
|
|
137
|
+
if (parseOpenTag(item, previous)) {
|
|
138
|
+
children.push(parseCustomContainer(state, depth + 1, previous));
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const closeTag = parseCloseTag(item, previous);
|
|
142
|
+
const consumed = consume(state);
|
|
143
|
+
if (closeTag && closeTag.markerLength >= openTag.markerLength) {
|
|
144
|
+
children.push(closeTag);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
if (isBlank(consumed.value)) {
|
|
148
|
+
const blankNode = parseBlankNode({
|
|
149
|
+
newline: consumed.value,
|
|
150
|
+
prevNode: previous
|
|
151
|
+
});
|
|
152
|
+
if (blankNode) children.push(blankNode);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
children.push({
|
|
156
|
+
type: "text",
|
|
157
|
+
value: consumed.value,
|
|
158
|
+
position: consumed.position
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
type: "custom-container",
|
|
163
|
+
depth,
|
|
164
|
+
children,
|
|
165
|
+
position: {
|
|
166
|
+
start: openTag.position.start,
|
|
167
|
+
end: children.at(-1)?.position.end ?? openTag.position.end
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Consumes the next split and advances its source offset.
|
|
173
|
+
*/
|
|
174
|
+
function consume(state) {
|
|
175
|
+
const value = state.splits.shift();
|
|
176
|
+
const start = state.offset;
|
|
177
|
+
state.offset += value.length;
|
|
178
|
+
return {
|
|
179
|
+
value,
|
|
180
|
+
position: {
|
|
181
|
+
start,
|
|
182
|
+
end: state.offset
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Splits text and keeps line-break tokens so their exact source ranges survive parsing.
|
|
188
|
+
*/
|
|
189
|
+
function splitLines(value) {
|
|
190
|
+
return value.split(/(\r?\n)/).filter(Boolean);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Parses an opening custom-container tag and calculates its source ranges.
|
|
194
|
+
*/
|
|
195
|
+
function parseOpenTag(raw, prevNode) {
|
|
196
|
+
const match = raw.match(CUSTOM_CONTAINER_OPEN_MARKER_RE);
|
|
197
|
+
/* v8 ignore if -- @preserve */
|
|
198
|
+
if (!match) return null;
|
|
199
|
+
const [_, rawMarker, type, titleAndAttrs] = match;
|
|
200
|
+
const tag = rawMarker + type;
|
|
201
|
+
const marker = rawMarker.trim();
|
|
202
|
+
const offset = prevNode?.position.end || 0;
|
|
203
|
+
const valueStart = offset + tag.length - type.length;
|
|
204
|
+
const value = {
|
|
205
|
+
content: type,
|
|
206
|
+
start: valueStart,
|
|
207
|
+
end: valueStart + type.length
|
|
208
|
+
};
|
|
209
|
+
const titleAndAttrsValue = titleAndAttrs?.trim() || "";
|
|
210
|
+
const attributeMatch = titleAndAttrsValue.match(CUSTOM_CONTAINER_ATTRS_RE);
|
|
211
|
+
const attrs = attributeMatch ? {
|
|
212
|
+
content: attributeMatch[1].slice(1, -1),
|
|
213
|
+
raw: attributeMatch[1]
|
|
214
|
+
} : void 0;
|
|
215
|
+
const title = (attributeMatch ? titleAndAttrsValue.slice(0, attributeMatch.index).trim() : titleAndAttrsValue) || void 0;
|
|
216
|
+
return {
|
|
217
|
+
type: "open",
|
|
218
|
+
markerLength: marker.length,
|
|
219
|
+
raw,
|
|
220
|
+
value,
|
|
221
|
+
title,
|
|
222
|
+
...attrs ? { attribute: attrs } : {},
|
|
223
|
+
position: {
|
|
224
|
+
start: offset,
|
|
225
|
+
end: offset + raw.length
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Parses the exact closing marker,
|
|
231
|
+
* `:::` and calculates its source range.
|
|
232
|
+
*/
|
|
233
|
+
function parseCloseTag(raw, prevNode) {
|
|
234
|
+
const match = raw.match(CUSTOM_CONTAINER_CLOSE_MARKER_STRICT_RE);
|
|
235
|
+
/* v8 ignore if -- @preserve */
|
|
236
|
+
if (!match) return null;
|
|
237
|
+
const marker = match[0].match(/:{3,}/)?.[0] ?? "";
|
|
238
|
+
const start = prevNode?.position.end || 0;
|
|
239
|
+
const end = start + raw.length;
|
|
240
|
+
return {
|
|
241
|
+
type: "close",
|
|
242
|
+
markerLength: marker.length,
|
|
243
|
+
raw,
|
|
244
|
+
value: {
|
|
245
|
+
content: raw,
|
|
246
|
+
start,
|
|
247
|
+
end
|
|
248
|
+
},
|
|
249
|
+
position: {
|
|
250
|
+
start,
|
|
251
|
+
end
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Adds a blank-line node
|
|
257
|
+
* merging it with the preceding blank line when possible.
|
|
258
|
+
*/
|
|
259
|
+
function parseBlankNode(opts) {
|
|
260
|
+
const { newline = "\n", prevNode } = opts || {};
|
|
261
|
+
if (prevNode?.type === "blank") {
|
|
262
|
+
prevNode.value += newline;
|
|
263
|
+
prevNode.position.end += newline.length;
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
const start = prevNode?.position.end || 0;
|
|
267
|
+
return {
|
|
268
|
+
type: "blank",
|
|
269
|
+
value: newline,
|
|
270
|
+
position: {
|
|
271
|
+
start,
|
|
272
|
+
end: start + newline.length
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
const CUSTOM_CONTAINER_TYPE_SET = /* @__PURE__ */ new Set([
|
|
277
|
+
"info",
|
|
278
|
+
"tip",
|
|
279
|
+
"warning",
|
|
280
|
+
"danger",
|
|
281
|
+
"details",
|
|
282
|
+
"raw",
|
|
283
|
+
"code-group",
|
|
284
|
+
"v-pre",
|
|
285
|
+
"tabs"
|
|
286
|
+
]);
|
|
287
|
+
const CUSTOM_CONTAINER_MARKER_PATTERN = String.raw`(^ {0,3}:{3,}[ \t]*)`;
|
|
288
|
+
const CUSTOM_CONTAINER_TYPE_PATTERN = String.raw`([\w-]+)(?=$|[ \t]|\r?\n)`;
|
|
289
|
+
const CUSTOM_CONTAINER_TITLE_AND_ATTRS_PATTERN = String.raw`([ \t][^\r\n]*)?`;
|
|
290
|
+
const CUSTOM_CONTAINER_CLOSE_MARKER_PATTERN = String.raw` {0,3}:{3,}[ \t]*`;
|
|
291
|
+
const CUSTOM_CONTAINER_OPEN_MARKER_RE = new RegExp(`${CUSTOM_CONTAINER_MARKER_PATTERN}${CUSTOM_CONTAINER_TYPE_PATTERN}${CUSTOM_CONTAINER_TITLE_AND_ATTRS_PATTERN}`);
|
|
292
|
+
const CUSTOM_CONTAINER_CLOSE_MARKER_RE = new RegExp(`^\r?\n${CUSTOM_CONTAINER_CLOSE_MARKER_PATTERN}$`);
|
|
293
|
+
const CUSTOM_CONTAINER_CLOSE_MARKER_STRICT_RE = new RegExp(`^${CUSTOM_CONTAINER_CLOSE_MARKER_PATTERN}$`);
|
|
294
|
+
const CUSTOM_CONTAINER_ATTRS_RE = /(\{[^{}]+\})\s*$/;
|
|
295
|
+
/**
|
|
296
|
+
* Checks whether a value is one of the custom-container types supported by VitePress.
|
|
297
|
+
*/
|
|
298
|
+
function isCustomContainerType(value) {
|
|
299
|
+
return CUSTOM_CONTAINER_TYPE_SET.has(value);
|
|
300
|
+
}
|
|
301
|
+
function isOpenTag(value) {
|
|
302
|
+
return CUSTOM_CONTAINER_OPEN_MARKER_RE.test(value);
|
|
303
|
+
}
|
|
304
|
+
function isBlank(value) {
|
|
305
|
+
return value === "\n" || value === "\r\n";
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Checks whether adjacent text is a custom container marker on the next line.
|
|
309
|
+
*
|
|
310
|
+
* @see https://vitepress.dev/guide/markdown#custom-containers
|
|
311
|
+
* @example `::: info` -> true (open tag)
|
|
312
|
+
* @example `:::: tip` -> true
|
|
313
|
+
* @example `:::` -> false (close tag)
|
|
314
|
+
*/
|
|
315
|
+
function isCustomContainerMarker(str) {
|
|
316
|
+
if (!str) return false;
|
|
317
|
+
return CUSTOM_CONTAINER_CLOSE_MARKER_RE.test(str) || CUSTOM_CONTAINER_OPEN_MARKER_RE.test(str);
|
|
318
|
+
}
|
|
319
|
+
const isCustomContainerNode = (node) => !!node && node.type === "custom-container";
|
|
320
|
+
const isBlankNode = (node) => !!node && node.type === "blank";
|
|
321
|
+
const isOpenNode = (node) => !!node && node.type === "open";
|
|
322
|
+
const isCloseNode = (node) => !!node && node.type === "close";
|
|
323
|
+
/**
|
|
324
|
+
* Ignore `Custom-containerNode` in `CodeNode` & `InlineCodeNode`.
|
|
325
|
+
* Collects source offsets for fenced code blocks and inline code nodes.
|
|
326
|
+
*/
|
|
327
|
+
function getCodeNodeRanges(node) {
|
|
328
|
+
const ranges = [];
|
|
329
|
+
function visit(current) {
|
|
330
|
+
if (!isObject(current)) return;
|
|
331
|
+
if (isCodeNode(current)) {
|
|
332
|
+
const position = current.position;
|
|
333
|
+
if (!position) return;
|
|
334
|
+
const start = position.start?.offset;
|
|
335
|
+
const end = position.end?.offset;
|
|
336
|
+
if (start !== void 0 && end !== void 0) ranges.push({
|
|
337
|
+
start,
|
|
338
|
+
end
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
if (hasChildren(current)) for (const child of current.children) visit(child);
|
|
342
|
+
}
|
|
343
|
+
visit(node);
|
|
344
|
+
return ranges;
|
|
345
|
+
}
|
|
346
|
+
//#endregion
|
|
347
|
+
//#region src/utils/index.ts
|
|
348
|
+
function createRule({ create, defaultOptions, meta }) {
|
|
349
|
+
return {
|
|
350
|
+
create,
|
|
351
|
+
meta: {
|
|
352
|
+
defaultOptions,
|
|
353
|
+
...meta
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
//#endregion
|
|
358
|
+
//#region src/rules/padding-around-custom-container/analyzs.ts
|
|
359
|
+
/**
|
|
360
|
+
* Analyzes custom containers and returns de-duplicated padding issues.
|
|
361
|
+
*/
|
|
362
|
+
function getNodesIssues(nodes, opts) {
|
|
363
|
+
const { offset = 0, ignoreRanges = [], mode = "loose" } = opts || {};
|
|
364
|
+
const issues = [];
|
|
365
|
+
/**
|
|
366
|
+
* Recursively analyzes every custom-container level in a parsed document.
|
|
367
|
+
*/
|
|
368
|
+
function analyzeLevel(nodes, opts) {
|
|
369
|
+
for (let index = 0; index < nodes.length; index++) {
|
|
370
|
+
const container = nodes[index];
|
|
371
|
+
if (!isCustomContainerNode(container)) continue;
|
|
372
|
+
const boundary = getContainerBoundary(container);
|
|
373
|
+
if (!boundary) continue;
|
|
374
|
+
analyzeInnerBoundary(container.children, boundary, opts);
|
|
375
|
+
analyzeOuterBoundary(nodes, index, opts);
|
|
376
|
+
analyzeLevel(container.children, opts);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
analyzeLevel(nodes, {
|
|
380
|
+
offset,
|
|
381
|
+
issues,
|
|
382
|
+
mode
|
|
383
|
+
});
|
|
384
|
+
return dedupeIssues(issues).filter((issue) => !ignoreRanges.some((range) => issue.start < range.end && issue.end > range.start));
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Checks the blank lines immediately inside a container's opening and closing tags.
|
|
388
|
+
*/
|
|
389
|
+
function analyzeInnerBoundary(children, containerBoundary, opts) {
|
|
390
|
+
const { openIndex, closeIndex } = containerBoundary;
|
|
391
|
+
const first = children[openIndex + 1];
|
|
392
|
+
checkInnerSide(first, {
|
|
393
|
+
direction: 1,
|
|
394
|
+
children,
|
|
395
|
+
...opts
|
|
396
|
+
});
|
|
397
|
+
const last = children[closeIndex - 1];
|
|
398
|
+
if (last !== first) checkInnerSide(last, {
|
|
399
|
+
direction: -1,
|
|
400
|
+
children,
|
|
401
|
+
...opts
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Checks one side of a container's inner boundary for the selected mode.
|
|
406
|
+
*/
|
|
407
|
+
function checkInnerSide(node, opts) {
|
|
408
|
+
const { direction, children, offset } = opts;
|
|
409
|
+
if (isBlankNode(node)) {
|
|
410
|
+
const lineBreak = getLineBreak(node.value);
|
|
411
|
+
const expected = opts.mode === "loose" ? `${lineBreak}${lineBreak}` : lineBreak;
|
|
412
|
+
if (node.value !== expected) opts.issues.push({
|
|
413
|
+
start: offset + node.position.start,
|
|
414
|
+
end: offset + node.position.end,
|
|
415
|
+
replacement: expected,
|
|
416
|
+
messageId: node.value.length < expected.length ? MESSAGE_IDS$4.missing : MESSAGE_IDS$4.unexpected
|
|
417
|
+
});
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (opts.mode === "loose" && node && !isOpenNode(node) && !isCloseNode(node)) {
|
|
421
|
+
const insertion = direction === 1 ? node.position.start : node.position.end;
|
|
422
|
+
opts.issues.push({
|
|
423
|
+
start: offset + insertion,
|
|
424
|
+
end: offset + insertion,
|
|
425
|
+
replacement: getLineBreakFromChildren(children) + getLineBreakFromChildren(children),
|
|
426
|
+
messageId: MESSAGE_IDS$4.missing
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Finds the first opening tag and last closing tag for a custom container.
|
|
432
|
+
*/
|
|
433
|
+
function getContainerBoundary(container) {
|
|
434
|
+
const openIndex = container.children.findIndex(isOpenNode);
|
|
435
|
+
const closeIndex = container.children.findLastIndex(isCloseNode);
|
|
436
|
+
if (openIndex === -1 || closeIndex <= openIndex) return;
|
|
437
|
+
return {
|
|
438
|
+
openIndex,
|
|
439
|
+
closeIndex
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Checks both sides of a container for the required external blank line.
|
|
444
|
+
*/
|
|
445
|
+
function analyzeOuterBoundary(children, boundaryIndex, opts) {
|
|
446
|
+
checkOuterSide(children, {
|
|
447
|
+
direction: -1,
|
|
448
|
+
boundaryIndex,
|
|
449
|
+
...opts
|
|
450
|
+
});
|
|
451
|
+
checkOuterSide(children, {
|
|
452
|
+
direction: 1,
|
|
453
|
+
boundaryIndex,
|
|
454
|
+
...opts
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Checks one side of a container and records insertion or normalization issues.
|
|
459
|
+
*/
|
|
460
|
+
function checkOuterSide(children, opts) {
|
|
461
|
+
const { boundaryIndex, direction, offset, issues } = opts;
|
|
462
|
+
const blankIndex = boundaryIndex + direction;
|
|
463
|
+
const blank = children[blankIndex];
|
|
464
|
+
const contentIndex = isBlankNode(blank) ? blankIndex + direction : blankIndex;
|
|
465
|
+
const content = children[contentIndex];
|
|
466
|
+
if (!isExternalContent(content, {
|
|
467
|
+
index: contentIndex,
|
|
468
|
+
length: children.length
|
|
469
|
+
})) return;
|
|
470
|
+
if (!isBlankNode(blank)) {
|
|
471
|
+
const insertion = direction === -1 ? children[boundaryIndex].position.start : children[boundaryIndex].position.end;
|
|
472
|
+
issues.push({
|
|
473
|
+
start: offset + insertion,
|
|
474
|
+
end: offset + insertion,
|
|
475
|
+
replacement: getLineBreakFromChildren(children),
|
|
476
|
+
messageId: MESSAGE_IDS$4.missing
|
|
477
|
+
});
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
const lineBreak = getLineBreak(blank.value);
|
|
481
|
+
const expected = `${lineBreak}${lineBreak}`;
|
|
482
|
+
if (blank.value === expected) return;
|
|
483
|
+
issues.push({
|
|
484
|
+
start: offset + blank.position.start,
|
|
485
|
+
end: offset + blank.position.end,
|
|
486
|
+
replacement: expected,
|
|
487
|
+
messageId: blank.value.length < expected.length ? MESSAGE_IDS$4.missing : MESSAGE_IDS$4.unexpected
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Returns whether a node is content outside the container's own boundary tags.
|
|
492
|
+
*/
|
|
493
|
+
function isExternalContent(node, opts) {
|
|
494
|
+
if (!node || isBlankNode(node)) return false;
|
|
495
|
+
const { index, length } = opts;
|
|
496
|
+
if (index === 0 && isOpenNode(node)) return false;
|
|
497
|
+
if (index === length - 1 && isCloseNode(node)) return false;
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Removes issues that target the same range with the same replacement.
|
|
502
|
+
*/
|
|
503
|
+
function dedupeIssues(issues) {
|
|
504
|
+
const seen = /* @__PURE__ */ new Set();
|
|
505
|
+
return issues.filter((edit) => {
|
|
506
|
+
const key = `${edit.start}:${edit.end}:${edit.replacement}`;
|
|
507
|
+
if (seen.has(key)) return false;
|
|
508
|
+
seen.add(key);
|
|
509
|
+
return true;
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Returns the line-break sequence used by a string.
|
|
514
|
+
*/
|
|
515
|
+
function getLineBreak(value) {
|
|
516
|
+
return value.includes("\r\n") ? "\r\n" : "\n";
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Uses the first blank child to infer a container's line-break sequence.
|
|
520
|
+
*/
|
|
521
|
+
function getLineBreakFromChildren(children) {
|
|
522
|
+
const blank = children.find((child) => isBlankNode(child));
|
|
523
|
+
return isBlankNode(blank) ? getLineBreak(blank.value) : "\n";
|
|
524
|
+
}
|
|
525
|
+
//#endregion
|
|
526
|
+
//#region src/rules/padding-around-custom-container/index.ts
|
|
527
|
+
const RULE_NAME$6 = "padding-around-custom-container";
|
|
528
|
+
const MESSAGE_IDS$4 = {
|
|
529
|
+
missing: "missing",
|
|
530
|
+
unexpected: "unexpected"
|
|
531
|
+
};
|
|
532
|
+
var padding_around_custom_container_default = createRule({
|
|
533
|
+
name: RULE_NAME$6,
|
|
534
|
+
meta: {
|
|
535
|
+
type: "layout",
|
|
536
|
+
docs: { description: "Enforce padding around VitePress custom containers." },
|
|
537
|
+
messages: {
|
|
538
|
+
missing: "A custom container must be separated from surrounding content by one blank line.",
|
|
539
|
+
unexpected: "Unexpected blank lines around a custom container."
|
|
540
|
+
},
|
|
541
|
+
fixable: "whitespace",
|
|
542
|
+
schema: [{ enum: ["compact", "loose"] }]
|
|
543
|
+
},
|
|
544
|
+
defaultOptions: ["loose"],
|
|
545
|
+
create(context) {
|
|
546
|
+
return { root(node) {
|
|
547
|
+
const { position, start } = getNodePosition(node);
|
|
548
|
+
/* v8 ignore if -- @preserve */
|
|
549
|
+
if (!position) return;
|
|
550
|
+
const source = context.sourceCode.text;
|
|
551
|
+
const ignoreRanges = getCodeNodeRanges(node);
|
|
552
|
+
const nodes = parseCustomContainers(source.slice(start));
|
|
553
|
+
const [mode] = context.options;
|
|
554
|
+
const issues = getNodesIssues(nodes, {
|
|
555
|
+
offset: start,
|
|
556
|
+
ignoreRanges,
|
|
557
|
+
mode
|
|
558
|
+
});
|
|
559
|
+
for (const { start, end, messageId, replacement } of issues) context.report({
|
|
560
|
+
node,
|
|
561
|
+
messageId,
|
|
562
|
+
loc: {
|
|
563
|
+
start: context.sourceCode.getLocFromIndex(start),
|
|
564
|
+
end: context.sourceCode.getLocFromIndex(end)
|
|
565
|
+
},
|
|
566
|
+
fix: (fixer) => fixer.replaceTextRange([start, end], replacement)
|
|
567
|
+
});
|
|
568
|
+
} };
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
//#endregion
|
|
572
|
+
//#region src/rules/space-around-custom-container/analyzs.ts
|
|
573
|
+
/**
|
|
574
|
+
* Finds custom-container marker lines and normalizes their surrounding spaces.
|
|
575
|
+
*
|
|
576
|
+
* Opening markers use exactly one space between the fence and the type,
|
|
577
|
+
* while closing markers contain only the fence. Leading indentation is removed.
|
|
578
|
+
* Ranges in `ignoredRanges` (for example fenced code blocks) are skipped.
|
|
579
|
+
*/
|
|
580
|
+
function getSourceIssues(source, ignoredRanges = []) {
|
|
581
|
+
const issues = [];
|
|
582
|
+
function visit(nodes) {
|
|
583
|
+
for (const node of nodes) {
|
|
584
|
+
if (!isCustomContainerNode(node)) continue;
|
|
585
|
+
for (const child of node.children) if (isOpenNode(child) || isCloseNode(child)) analyzeMarker(child, ignoredRanges, issues);
|
|
586
|
+
else if (isCustomContainerNode(child)) visit([child]);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
visit(parseCustomContainers(source));
|
|
590
|
+
return issues;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Adds a spacing issue for one parsed opening or closing marker.
|
|
594
|
+
*/
|
|
595
|
+
function analyzeMarker(tag, ignoredRanges, issues) {
|
|
596
|
+
if (ignoredRanges.some((range) => tag.position.start < range.end && tag.position.end > range.start)) return;
|
|
597
|
+
const replacement = normalizedMarker(tag);
|
|
598
|
+
if (tag.raw === replacement) return;
|
|
599
|
+
issues.push({
|
|
600
|
+
...tag.position,
|
|
601
|
+
replacement,
|
|
602
|
+
messageId: getMarkerMessageId(tag)
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Returns the normalized source text for a parsed container marker.
|
|
607
|
+
*/
|
|
608
|
+
function normalizedMarker(tag) {
|
|
609
|
+
const fence = ":".repeat(tag.markerLength);
|
|
610
|
+
if (isCloseNode(tag)) return fence;
|
|
611
|
+
const valueStart = tag.value.start - tag.position.start;
|
|
612
|
+
return `${fence} ${tag.raw.slice(valueStart).trim()}`;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Classifies the spacing problem represented by a parsed marker.
|
|
616
|
+
*/
|
|
617
|
+
function getMarkerMessageId(tag) {
|
|
618
|
+
if (tag.raw.length !== tag.raw.trimStart().length) return MESSAGE_IDS$3.unexpectedIndentation;
|
|
619
|
+
if (isCloseNode(tag)) return MESSAGE_IDS$3.unexpectedTrailingSpace;
|
|
620
|
+
if (tag.raw.slice(tag.markerLength, tag.value.start - tag.position.start).length === 0) return MESSAGE_IDS$3.missingSeparator;
|
|
621
|
+
return MESSAGE_IDS$3.unexpectedSeparator;
|
|
622
|
+
}
|
|
623
|
+
//#endregion
|
|
624
|
+
//#region src/rules/space-around-custom-container/index.ts
|
|
625
|
+
const RULE_NAME$5 = "space-around-custom-container";
|
|
626
|
+
const MESSAGE_IDS$3 = {
|
|
627
|
+
unexpectedIndentation: "unexpectedIndentation",
|
|
628
|
+
missingSeparator: "missingSeparator",
|
|
629
|
+
unexpectedSeparator: "unexpectedSeparator",
|
|
630
|
+
unexpectedTrailingSpace: "unexpectedTrailingSpace"
|
|
631
|
+
};
|
|
632
|
+
var space_around_custom_container_default = createRule({
|
|
633
|
+
name: RULE_NAME$5,
|
|
634
|
+
meta: {
|
|
635
|
+
type: "layout",
|
|
636
|
+
docs: { description: "Enforce spacing around VitePress custom-container markers." },
|
|
637
|
+
messages: {
|
|
638
|
+
unexpectedIndentation: "Do not indent a custom-container marker.",
|
|
639
|
+
missingSeparator: "Add one space between the opening fence and its type.",
|
|
640
|
+
unexpectedSeparator: "Use exactly one space between the opening fence and its type.",
|
|
641
|
+
unexpectedTrailingSpace: "Remove trailing spaces from a custom-container closing marker."
|
|
642
|
+
},
|
|
643
|
+
fixable: "whitespace",
|
|
644
|
+
schema: []
|
|
645
|
+
},
|
|
646
|
+
defaultOptions: [],
|
|
647
|
+
create(context) {
|
|
648
|
+
return { root(node) {
|
|
649
|
+
const { position } = getNodePosition(node);
|
|
650
|
+
/* v8 ignore if -- @preserve */
|
|
651
|
+
if (!position) return;
|
|
652
|
+
const source = context.sourceCode.text;
|
|
653
|
+
const issues = getSourceIssues(source, getCodeNodeRanges(node));
|
|
654
|
+
for (const { start, end, messageId, replacement } of issues) context.report({
|
|
655
|
+
node,
|
|
656
|
+
messageId,
|
|
657
|
+
loc: {
|
|
658
|
+
start: context.sourceCode.getLocFromIndex(start),
|
|
659
|
+
end: context.sourceCode.getLocFromIndex(end)
|
|
660
|
+
},
|
|
661
|
+
fix: (fixer) => fixer.replaceTextRange([start, end], replacement)
|
|
662
|
+
});
|
|
663
|
+
} };
|
|
664
|
+
}
|
|
665
|
+
});
|
|
106
666
|
//#endregion
|
|
107
667
|
//#region src/utils/anchor.ts
|
|
108
668
|
/**
|
|
109
|
-
* Match the trailing anchor-like fragment from a heading
|
|
669
|
+
* Match the trailing anchor-like fragment from a heading or adjacent text.
|
|
110
670
|
* @example `中文标题 {#Chinese-Title}` -> `{#Chinese-Title}`
|
|
111
671
|
* @example `使用 describe #Grouping Tests` -> `#Grouping Tests`
|
|
112
672
|
*/
|
|
@@ -126,52 +686,15 @@ function getLikeAnchor(str) {
|
|
|
126
686
|
if (str === void 0) return null;
|
|
127
687
|
const match = getLikeAnchorMatch(str);
|
|
128
688
|
if (!match) return null;
|
|
129
|
-
const rawLikeAnchor = match.replace(/
|
|
689
|
+
const rawLikeAnchor = match.replace(/[{}]/g, "").replace(/^#/, "").trimStart();
|
|
130
690
|
return {
|
|
131
691
|
isLikeAnchor: rawLikeAnchor.includes(" "),
|
|
132
692
|
rawLikeAnchor
|
|
133
693
|
};
|
|
134
694
|
}
|
|
135
|
-
/**
|
|
136
|
-
* Check if the string has an anchor.
|
|
137
|
-
* @example: {#chinese-anchor}
|
|
138
|
-
*/
|
|
139
|
-
function isStrictAnchor(str) {
|
|
140
|
-
return /\s\{#[a-z0-9]+(?:-[a-z0-9]+)*\}/.test(str);
|
|
141
|
-
}
|
|
142
|
-
/**
|
|
143
|
-
* Check whether the string contains CJK Han characters.
|
|
144
|
-
*/
|
|
145
|
-
function hasChinese(str) {
|
|
146
|
-
return /[\u4E00-\u9FA5]/.test(str);
|
|
147
|
-
}
|
|
148
|
-
/**
|
|
149
|
-
* Normalize raw anchor text into the strict anchor format content.
|
|
150
|
-
* - lowercase all letters
|
|
151
|
-
* - convert spaces to `-`
|
|
152
|
-
* - remove unsupported characters
|
|
153
|
-
* - trim leading/trailing `-`
|
|
154
|
-
*/
|
|
155
|
-
function normalizeAnchor(anchor) {
|
|
156
|
-
return anchor.toLowerCase().replace(/[\s.]/g, "-").replace(/[^a-z0-9_-]/g, "").replace(/^-+|-+$/g, "");
|
|
157
|
-
}
|
|
158
|
-
/**
|
|
159
|
-
* Count wrapper characters contributed by the trailing like-anchor fragment.
|
|
160
|
-
* The value is the length difference between the raw matched fragment and the
|
|
161
|
-
* cleaned anchor text returned by `getLikeAnchor`.
|
|
162
|
-
* @example `# 中文标题 {#Chinese-Title}` -> 3
|
|
163
|
-
* @example `## 使用 \`describe\` 编组测试 #Grouping Tests with \`describe\`` -> 1
|
|
164
|
-
*/
|
|
165
|
-
function calcAnchorPositionCompensate(content) {
|
|
166
|
-
const match = getLikeAnchorMatch(content);
|
|
167
|
-
const anchor = getLikeAnchor(content);
|
|
168
|
-
if (!match || !anchor) return 0;
|
|
169
|
-
return match.length - anchor.rawLikeAnchor.length;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
695
|
//#endregion
|
|
173
696
|
//#region src/utils/punctuation.ts
|
|
174
|
-
const OPENING_PAIRED_PUNCTUATION = new Set([
|
|
697
|
+
const OPENING_PAIRED_PUNCTUATION = /* @__PURE__ */ new Set([
|
|
175
698
|
"(",
|
|
176
699
|
"[",
|
|
177
700
|
"{",
|
|
@@ -182,7 +705,7 @@ const OPENING_PAIRED_PUNCTUATION = new Set([
|
|
|
182
705
|
"“",
|
|
183
706
|
"‘"
|
|
184
707
|
]);
|
|
185
|
-
const CLOSING_PAIRED_PUNCTUATION = new Set([
|
|
708
|
+
const CLOSING_PAIRED_PUNCTUATION = /* @__PURE__ */ new Set([
|
|
186
709
|
")",
|
|
187
710
|
"]",
|
|
188
711
|
"}",
|
|
@@ -208,6 +731,16 @@ function isFullwidthPunctuation(str) {
|
|
|
208
731
|
if (!str || str.length !== 1) return false;
|
|
209
732
|
return /^[\u3001-\u303F\uFE10-\uFE1F\uFE30-\uFE4F\uFF01-\uFF0F\uFF1A-\uFF20\uFF3B-\uFF40\uFF5B-\uFF65“”‘’…]$/u.test(str);
|
|
210
733
|
}
|
|
734
|
+
const HALFWIDTH_PUNCTUATION_RE = /^\p{P}$/u;
|
|
735
|
+
/**
|
|
736
|
+
* Checks whether the character is halfwidth punctuation.
|
|
737
|
+
* @example `,` -> true
|
|
738
|
+
* @example `,` -> false
|
|
739
|
+
*/
|
|
740
|
+
function isHalfwidthPunctuation(str) {
|
|
741
|
+
if (!str || str.length !== 1) return false;
|
|
742
|
+
return str.charCodeAt(0) <= 126 && HALFWIDTH_PUNCTUATION_RE.test(str);
|
|
743
|
+
}
|
|
211
744
|
const DASH_PUNCTUATION_RE = /^[-\u2013\u2014\u2212]$/u;
|
|
212
745
|
/**
|
|
213
746
|
* Checks whether the character is hyphen-like punctuation.
|
|
@@ -240,7 +773,6 @@ function hasPunctuation(str, position = "head") {
|
|
|
240
773
|
if (position === "head") return isPunctuation(str[0]);
|
|
241
774
|
else return isPunctuation(str[str.length - 1]);
|
|
242
775
|
}
|
|
243
|
-
|
|
244
776
|
//#endregion
|
|
245
777
|
//#region src/utils/space.ts
|
|
246
778
|
/**
|
|
@@ -273,54 +805,8 @@ function getWhiteSpace(str, position = "head") {
|
|
|
273
805
|
};
|
|
274
806
|
}
|
|
275
807
|
}
|
|
276
|
-
/**
|
|
277
|
-
* Gets whitespace and punctuation information for text adjacent to a link or inline code node.
|
|
278
|
-
*/
|
|
279
|
-
function getSpaceContext(nodeContext) {
|
|
280
|
-
const { prev, next } = nodeContext;
|
|
281
|
-
const prevValue = getNodeValue(prev);
|
|
282
|
-
const nextValue = getNodeValue(next);
|
|
283
|
-
return {
|
|
284
|
-
prev: {
|
|
285
|
-
value: prevValue,
|
|
286
|
-
whiteSpace: getWhiteSpace(prevValue, "tail"),
|
|
287
|
-
hasPunctuation: hasPunctuation(prevValue, "tail"),
|
|
288
|
-
punctuationType: isFullwidthPunctuation(getAdjacentChar(prevValue, "tail")) ? "full" : "half"
|
|
289
|
-
},
|
|
290
|
-
next: {
|
|
291
|
-
value: nextValue,
|
|
292
|
-
whiteSpace: getWhiteSpace(nextValue),
|
|
293
|
-
hasPunctuation: hasPunctuation(nextValue),
|
|
294
|
-
punctuationType: isFullwidthPunctuation(getAdjacentChar(nextValue, "head")) ? "full" : "half"
|
|
295
|
-
}
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
|
|
299
808
|
//#endregion
|
|
300
|
-
//#region src/
|
|
301
|
-
const INLINE_SPACE_MESSAGE_IDS = {
|
|
302
|
-
missingSpaceBefore: "missingSpaceBefore",
|
|
303
|
-
missingSpaceAfter: "missingSpaceAfter",
|
|
304
|
-
multipleSpacesBefore: "multipleSpacesBefore",
|
|
305
|
-
multipleSpacesAfter: "multipleSpacesAfter",
|
|
306
|
-
multipleSpacesAfterPunctuation: "multipleSpacesAfterPunctuation",
|
|
307
|
-
unexpectedSpaceBefore: "unexpectedSpaceBefore",
|
|
308
|
-
unexpectedSpaceAfter: "unexpectedSpaceAfter"
|
|
309
|
-
};
|
|
310
|
-
/**
|
|
311
|
-
* Checks whether adjacent text is a custom container marker on the next line.
|
|
312
|
-
*
|
|
313
|
-
* @deprecated Temporary workaround to prevent space-between-link from reporting
|
|
314
|
-
* false positives on custom containers. Remove this and handle the case in a
|
|
315
|
-
* dedicated custom container rule when one exists.
|
|
316
|
-
* @see https://vitepress.dev/guide/markdown#custom-containers
|
|
317
|
-
* @example `\n:::` -> true
|
|
318
|
-
* @example `\n::::` -> true
|
|
319
|
-
* @example `:::` -> false
|
|
320
|
-
*/
|
|
321
|
-
function isCustomContainerMarker(str) {
|
|
322
|
-
return /^[ \t]*\n[ \t]*:{3,}[ \t]*$/u.test(str || "");
|
|
323
|
-
}
|
|
809
|
+
//#region src/rules/space-around-inline-element/analyze.ts
|
|
324
810
|
/**
|
|
325
811
|
* Validates whether a spacing run contains exactly one required space.
|
|
326
812
|
*/
|
|
@@ -334,18 +820,18 @@ function validateSingleRequiredSpace(count, missingSpaceMessageId, multipleSpace
|
|
|
334
820
|
function validateBeforePunctuation(context) {
|
|
335
821
|
const adjacentChar = getAdjacentChar(context.value, "tail");
|
|
336
822
|
if (OPENING_PAIRED_PUNCTUATION.has(adjacentChar || "") || isSlashPunctuation(adjacentChar)) {
|
|
337
|
-
if (context.whiteSpace.count > 0) return
|
|
823
|
+
if (context.whiteSpace.count > 0) return "unexpected-space-before";
|
|
338
824
|
return;
|
|
339
825
|
}
|
|
340
|
-
if (context.punctuationType === "half") return validateSingleRequiredSpace(context.whiteSpace.count,
|
|
341
|
-
if (context.whiteSpace.count > 0) return
|
|
826
|
+
if (context.punctuationType === "half") return validateSingleRequiredSpace(context.whiteSpace.count, "missing-space-before", "multiple-spaces-after-punctuation");
|
|
827
|
+
if (context.whiteSpace.count > 0) return "unexpected-space-before";
|
|
342
828
|
}
|
|
343
829
|
/**
|
|
344
830
|
* Validates the spacing between the previous node and the current inline node.
|
|
345
831
|
*/
|
|
346
832
|
function validateSpaceBeforeNode(context) {
|
|
347
833
|
if (context.hasPunctuation) return validateBeforePunctuation(context);
|
|
348
|
-
return validateSingleRequiredSpace(context.whiteSpace.count,
|
|
834
|
+
return validateSingleRequiredSpace(context.whiteSpace.count, "missing-space-before", "multiple-spaces-before");
|
|
349
835
|
}
|
|
350
836
|
/**
|
|
351
837
|
* Validates spacing after an inline node when the next character is punctuation.
|
|
@@ -353,16 +839,55 @@ function validateSpaceBeforeNode(context) {
|
|
|
353
839
|
function validateSpaceAfterPunctuation(context) {
|
|
354
840
|
const adjacentChar = getAdjacentChar(context.value, "head");
|
|
355
841
|
if (getLikeAnchor(context.value) || isCustomContainerMarker(context.value)) return;
|
|
356
|
-
if (CLOSING_PAIRED_PUNCTUATION.has(adjacentChar || "") && context.whiteSpace.count > 0) return
|
|
357
|
-
if (context.punctuationType === "half" && OPENING_PAIRED_PUNCTUATION.has(adjacentChar || "") || isDashPunctuation(adjacentChar)) return validateSingleRequiredSpace(context.whiteSpace.count,
|
|
358
|
-
if (context.whiteSpace.count > 0) return
|
|
842
|
+
if (CLOSING_PAIRED_PUNCTUATION.has(adjacentChar || "") && context.whiteSpace.count > 0) return "unexpected-space-after";
|
|
843
|
+
if (context.punctuationType === "half" && OPENING_PAIRED_PUNCTUATION.has(adjacentChar || "") || isDashPunctuation(adjacentChar)) return validateSingleRequiredSpace(context.whiteSpace.count, "missing-space-after", "multiple-spaces-after");
|
|
844
|
+
if (context.whiteSpace.count > 0) return "unexpected-space-after";
|
|
359
845
|
}
|
|
360
846
|
/**
|
|
361
847
|
* Validates the spacing between the current inline node and the next node.
|
|
362
848
|
*/
|
|
363
849
|
function validateSpaceAfterNode(context) {
|
|
364
850
|
if (context.hasPunctuation) return validateSpaceAfterPunctuation(context);
|
|
365
|
-
return validateSingleRequiredSpace(context.whiteSpace.count,
|
|
851
|
+
return validateSingleRequiredSpace(context.whiteSpace.count, "missing-space-after", "multiple-spaces-after");
|
|
852
|
+
}
|
|
853
|
+
const INLINE_ELEMENT_TYPES = /* @__PURE__ */ new Set([
|
|
854
|
+
"link",
|
|
855
|
+
"image",
|
|
856
|
+
"inlineCode",
|
|
857
|
+
"emphasis",
|
|
858
|
+
"strong"
|
|
859
|
+
]);
|
|
860
|
+
/**
|
|
861
|
+
* Checks whether a phrasing node is one of the selected inline element targets.
|
|
862
|
+
*/
|
|
863
|
+
function isInlineElement(node) {
|
|
864
|
+
return !!node && INLINE_ELEMENT_TYPES.has(node.type);
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Checks whether the current inline element is nested inside another selected inline element.
|
|
868
|
+
*/
|
|
869
|
+
function isNestedInlineElement(nodeContext) {
|
|
870
|
+
const { parent } = nodeContext;
|
|
871
|
+
return isInlineElement(parent);
|
|
872
|
+
}
|
|
873
|
+
function getSpaceContext(nodeContext) {
|
|
874
|
+
const { prev, next } = nodeContext;
|
|
875
|
+
const prevValue = getNodeValue(prev);
|
|
876
|
+
const nextValue = getNodeValue(next);
|
|
877
|
+
return {
|
|
878
|
+
prev: {
|
|
879
|
+
value: prevValue,
|
|
880
|
+
whiteSpace: getWhiteSpace(prevValue, "tail"),
|
|
881
|
+
hasPunctuation: hasPunctuation(prevValue, "tail"),
|
|
882
|
+
punctuationType: isFullwidthPunctuation(getAdjacentChar(prevValue, "tail")) ? "full" : "half"
|
|
883
|
+
},
|
|
884
|
+
next: {
|
|
885
|
+
value: nextValue,
|
|
886
|
+
whiteSpace: getWhiteSpace(nextValue),
|
|
887
|
+
hasPunctuation: hasPunctuation(nextValue),
|
|
888
|
+
punctuationType: isFullwidthPunctuation(getAdjacentChar(nextValue, "head")) ? "full" : "half"
|
|
889
|
+
}
|
|
890
|
+
};
|
|
366
891
|
}
|
|
367
892
|
/**
|
|
368
893
|
* Validates spacing around an inline element inside a table cell.
|
|
@@ -401,16 +926,71 @@ function validateSpace(nodeContext) {
|
|
|
401
926
|
if (parent && isTableCell(parent)) return validateTableCellSpace(nodeContext);
|
|
402
927
|
return validateDefaultSpace(nodeContext);
|
|
403
928
|
}
|
|
404
|
-
|
|
405
929
|
//#endregion
|
|
406
|
-
//#region src/rules/space-
|
|
407
|
-
const RULE_NAME$
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
930
|
+
//#region src/rules/space-around-inline-element/index.ts
|
|
931
|
+
const RULE_NAME$4 = "space-around-inline-element";
|
|
932
|
+
const MESSAGE_IDS$2 = {
|
|
933
|
+
missingSpaceBefore: "missingSpaceBefore",
|
|
934
|
+
missingSpaceAfter: "missingSpaceAfter",
|
|
935
|
+
multipleSpacesBefore: "multipleSpacesBefore",
|
|
936
|
+
multipleSpacesAfter: "multipleSpacesAfter",
|
|
937
|
+
multipleSpacesAfterPunctuation: "multipleSpacesAfterPunctuation",
|
|
938
|
+
unexpectedSpaceBefore: "unexpectedSpaceBefore",
|
|
939
|
+
unexpectedSpaceAfter: "unexpectedSpaceAfter"
|
|
940
|
+
};
|
|
941
|
+
const ISSUE_TO_MESSAGE_ID = {
|
|
942
|
+
"missing-space-before": MESSAGE_IDS$2.missingSpaceBefore,
|
|
943
|
+
"missing-space-after": MESSAGE_IDS$2.missingSpaceAfter,
|
|
944
|
+
"multiple-spaces-before": MESSAGE_IDS$2.multipleSpacesBefore,
|
|
945
|
+
"multiple-spaces-after": MESSAGE_IDS$2.multipleSpacesAfter,
|
|
946
|
+
"multiple-spaces-after-punctuation": MESSAGE_IDS$2.multipleSpacesAfterPunctuation,
|
|
947
|
+
"unexpected-space-before": MESSAGE_IDS$2.unexpectedSpaceBefore,
|
|
948
|
+
"unexpected-space-after": MESSAGE_IDS$2.unexpectedSpaceAfter
|
|
949
|
+
};
|
|
950
|
+
const BEFORE_INLINE_ELEMENT_MESSAGE_IDS = /* @__PURE__ */ new Set([
|
|
951
|
+
MESSAGE_IDS$2.missingSpaceBefore,
|
|
952
|
+
MESSAGE_IDS$2.multipleSpacesBefore,
|
|
953
|
+
MESSAGE_IDS$2.multipleSpacesAfterPunctuation,
|
|
954
|
+
MESSAGE_IDS$2.unexpectedSpaceBefore
|
|
413
955
|
]);
|
|
956
|
+
var space_around_inline_element_default = createRule({
|
|
957
|
+
name: RULE_NAME$4,
|
|
958
|
+
meta: {
|
|
959
|
+
type: "layout",
|
|
960
|
+
docs: { description: "Enforce spacing around Markdown inline elements." },
|
|
961
|
+
messages: {
|
|
962
|
+
missingSpaceBefore: "A space is required before the inline element.",
|
|
963
|
+
missingSpaceAfter: "A space is required after the inline element.",
|
|
964
|
+
multipleSpacesBefore: "Use exactly one space before the inline element.",
|
|
965
|
+
multipleSpacesAfter: "Use exactly one space after the inline element.",
|
|
966
|
+
multipleSpacesAfterPunctuation: "Use one space after punctuation.",
|
|
967
|
+
unexpectedSpaceBefore: "Do not add a space between punctuation and the inline element.",
|
|
968
|
+
unexpectedSpaceAfter: "Do not add a space between the inline element and punctuation."
|
|
969
|
+
},
|
|
970
|
+
fixable: "whitespace",
|
|
971
|
+
schema: []
|
|
972
|
+
},
|
|
973
|
+
defaultOptions: [],
|
|
974
|
+
create(context) {
|
|
975
|
+
return {
|
|
976
|
+
link(node) {
|
|
977
|
+
checkInlineElement(context, node);
|
|
978
|
+
},
|
|
979
|
+
image(node) {
|
|
980
|
+
checkInlineElement(context, node);
|
|
981
|
+
},
|
|
982
|
+
inlineCode(node) {
|
|
983
|
+
checkInlineElement(context, node);
|
|
984
|
+
},
|
|
985
|
+
emphasis(node) {
|
|
986
|
+
checkInlineElement(context, node);
|
|
987
|
+
},
|
|
988
|
+
strong(node) {
|
|
989
|
+
checkInlineElement(context, node);
|
|
990
|
+
}
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
});
|
|
414
994
|
/**
|
|
415
995
|
* Checks one selected inline element and reports the fix range around it.
|
|
416
996
|
*/
|
|
@@ -421,11 +1001,12 @@ function checkInlineElement(context, node) {
|
|
|
421
1001
|
const nodeContext = getNodeContext(context, node);
|
|
422
1002
|
if (isNestedInlineElement(nodeContext)) return;
|
|
423
1003
|
const spaceContext = getSpaceContext(nodeContext);
|
|
424
|
-
const
|
|
425
|
-
if (!
|
|
1004
|
+
const issue = validateSpace(nodeContext);
|
|
1005
|
+
if (!issue) return;
|
|
1006
|
+
const messageId = ISSUE_TO_MESSAGE_ID[issue];
|
|
426
1007
|
if (BEFORE_INLINE_ELEMENT_MESSAGE_IDS.has(messageId) && spaceContext.prev) {
|
|
427
1008
|
const { count } = spaceContext.prev.whiteSpace;
|
|
428
|
-
const replaceText = messageId ===
|
|
1009
|
+
const replaceText = messageId === MESSAGE_IDS$2.unexpectedSpaceBefore ? "" : " ";
|
|
429
1010
|
context.report({
|
|
430
1011
|
node,
|
|
431
1012
|
messageId,
|
|
@@ -437,7 +1018,7 @@ function checkInlineElement(context, node) {
|
|
|
437
1018
|
}
|
|
438
1019
|
if (spaceContext.next) {
|
|
439
1020
|
const { count } = spaceContext.next.whiteSpace;
|
|
440
|
-
const replaceText = messageId ===
|
|
1021
|
+
const replaceText = messageId === MESSAGE_IDS$2.unexpectedSpaceAfter ? "" : " ";
|
|
441
1022
|
context.report({
|
|
442
1023
|
node,
|
|
443
1024
|
messageId,
|
|
@@ -447,58 +1028,443 @@ function checkInlineElement(context, node) {
|
|
|
447
1028
|
});
|
|
448
1029
|
}
|
|
449
1030
|
}
|
|
450
|
-
|
|
451
|
-
|
|
1031
|
+
//#endregion
|
|
1032
|
+
//#region src/utils/text/tokenizer.ts
|
|
1033
|
+
const TEXT_TYPE = {
|
|
1034
|
+
"cjk": "cjk",
|
|
1035
|
+
"latin": "latin",
|
|
1036
|
+
"number": "number",
|
|
1037
|
+
"space": "space",
|
|
1038
|
+
"newline": "newline",
|
|
1039
|
+
"fullwidth-punctuation": "fullwidth-punctuation",
|
|
1040
|
+
"halfwidth-punctuation": "halfwidth-punctuation",
|
|
1041
|
+
"dash": "dash",
|
|
1042
|
+
"symbol": "symbol",
|
|
1043
|
+
"emoji": "emoji",
|
|
1044
|
+
"invisible": "invisible",
|
|
1045
|
+
"other": "other"
|
|
1046
|
+
};
|
|
1047
|
+
const CJK_RE = /^\p{Script=Han}$|^\p{Script=Hiragana}$|^\p{Script=Katakana}$|^\p{Script=Hangul}$/u;
|
|
1048
|
+
const LATIN_RE = /^\p{Script=Latin}$/u;
|
|
1049
|
+
const NUMBER_RE = /^\p{Number}$/u;
|
|
1050
|
+
const SYMBOL_RE = /^\p{Symbol}$/u;
|
|
1051
|
+
const EMOJI_RE = /^\p{Extended_Pictographic}$/u;
|
|
1052
|
+
const SPACE_RE = /^[\t\v\f \u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]$/u;
|
|
1053
|
+
const NEWLINE_RE = /^[\n\r\u2028\u2029]$/u;
|
|
1054
|
+
const INVISIBLE_CODE_POINTS = /* @__PURE__ */ new Set([
|
|
1055
|
+
173,
|
|
1056
|
+
847,
|
|
1057
|
+
1564,
|
|
1058
|
+
4447,
|
|
1059
|
+
4448,
|
|
1060
|
+
6068,
|
|
1061
|
+
6069,
|
|
1062
|
+
6158,
|
|
1063
|
+
65279,
|
|
1064
|
+
65440
|
|
1065
|
+
]);
|
|
1066
|
+
/**
|
|
1067
|
+
* Checks whether the character is an invisible Unicode control or filler.
|
|
1068
|
+
* These characters affect rendering, cursor movement, or text direction but
|
|
1069
|
+
* should not be treated as visible spacing, punctuation, or symbols.
|
|
1070
|
+
*/
|
|
1071
|
+
function isInvisible(char) {
|
|
1072
|
+
const codePoint = char.codePointAt(0);
|
|
1073
|
+
return codePoint != null && (INVISIBLE_CODE_POINTS.has(codePoint) || codePoint >= 8203 && codePoint <= 8207 || codePoint >= 8234 && codePoint <= 8238 || codePoint >= 8288 && codePoint <= 8303);
|
|
1074
|
+
}
|
|
1075
|
+
function isNumber(char, prev) {
|
|
1076
|
+
return NUMBER_RE.test(char) || prev?.type === TEXT_TYPE.number && (char === "." || char === "%");
|
|
1077
|
+
}
|
|
1078
|
+
function isLatinWordType(type) {
|
|
1079
|
+
if (!type) return false;
|
|
1080
|
+
return type === "latin";
|
|
1081
|
+
}
|
|
1082
|
+
const TEXT_TYPE_MATCHERS = [
|
|
1083
|
+
{
|
|
1084
|
+
type: TEXT_TYPE.newline,
|
|
1085
|
+
test: (char) => NEWLINE_RE.test(char)
|
|
1086
|
+
},
|
|
1087
|
+
{
|
|
1088
|
+
type: TEXT_TYPE.space,
|
|
1089
|
+
test: (char) => SPACE_RE.test(char)
|
|
1090
|
+
},
|
|
1091
|
+
{
|
|
1092
|
+
type: TEXT_TYPE.invisible,
|
|
1093
|
+
test: (char) => isInvisible(char)
|
|
1094
|
+
},
|
|
1095
|
+
{
|
|
1096
|
+
type: TEXT_TYPE.cjk,
|
|
1097
|
+
test: (char) => CJK_RE.test(char)
|
|
1098
|
+
},
|
|
1099
|
+
{
|
|
1100
|
+
type: TEXT_TYPE.latin,
|
|
1101
|
+
test: (char) => LATIN_RE.test(char)
|
|
1102
|
+
},
|
|
1103
|
+
{
|
|
1104
|
+
type: TEXT_TYPE.number,
|
|
1105
|
+
test: isNumber
|
|
1106
|
+
},
|
|
1107
|
+
{
|
|
1108
|
+
type: TEXT_TYPE.dash,
|
|
1109
|
+
test: (char) => isDashPunctuation(char)
|
|
1110
|
+
},
|
|
1111
|
+
{
|
|
1112
|
+
type: TEXT_TYPE["fullwidth-punctuation"],
|
|
1113
|
+
test: (char) => isFullwidthPunctuation(char)
|
|
1114
|
+
},
|
|
1115
|
+
{
|
|
1116
|
+
type: TEXT_TYPE["halfwidth-punctuation"],
|
|
1117
|
+
test: (char) => isHalfwidthPunctuation(char)
|
|
1118
|
+
},
|
|
1119
|
+
{
|
|
1120
|
+
type: TEXT_TYPE.emoji,
|
|
1121
|
+
test: (char) => EMOJI_RE.test(char)
|
|
1122
|
+
},
|
|
1123
|
+
{
|
|
1124
|
+
type: TEXT_TYPE.symbol,
|
|
1125
|
+
test: (char) => SYMBOL_RE.test(char)
|
|
1126
|
+
}
|
|
1127
|
+
];
|
|
1128
|
+
const DEFAULT_START_POINT = {
|
|
1129
|
+
line: 1,
|
|
1130
|
+
column: 1,
|
|
1131
|
+
offset: 0
|
|
1132
|
+
};
|
|
1133
|
+
function advancePoint(point, char) {
|
|
1134
|
+
const { line, column, offset = 0 } = point;
|
|
1135
|
+
if (NEWLINE_RE.test(char)) return {
|
|
1136
|
+
line: line + 1,
|
|
1137
|
+
column: 1,
|
|
1138
|
+
offset: offset + char.length
|
|
1139
|
+
};
|
|
1140
|
+
return {
|
|
1141
|
+
line,
|
|
1142
|
+
column: column + char.length,
|
|
1143
|
+
offset: offset + char.length
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Classifies a single Unicode code point for Markdown text style rules.
|
|
1148
|
+
*/
|
|
1149
|
+
function getTextType(char, prev) {
|
|
1150
|
+
for (const matcher of TEXT_TYPE_MATCHERS) if (matcher.test(char, prev)) return matcher.type;
|
|
1151
|
+
return TEXT_TYPE.other;
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Tokenizes text into consecutive typed runs while preserving source positions.
|
|
1155
|
+
*/
|
|
1156
|
+
function tokenizeText(value, start = DEFAULT_START_POINT) {
|
|
1157
|
+
const tokens = [];
|
|
1158
|
+
let point = start;
|
|
1159
|
+
let prevToken;
|
|
1160
|
+
for (const char of value) {
|
|
1161
|
+
const type = getTextType(char, prevToken);
|
|
1162
|
+
const tokenStart = point;
|
|
1163
|
+
const tokenEnd = advancePoint(point, char);
|
|
1164
|
+
if (prevToken?.type === type) {
|
|
1165
|
+
prevToken.value += char;
|
|
1166
|
+
prevToken.position.end = tokenEnd;
|
|
1167
|
+
} else {
|
|
1168
|
+
prevToken = {
|
|
1169
|
+
type,
|
|
1170
|
+
value: char,
|
|
1171
|
+
position: {
|
|
1172
|
+
start: tokenStart,
|
|
1173
|
+
end: tokenEnd
|
|
1174
|
+
}
|
|
1175
|
+
};
|
|
1176
|
+
tokens.push(prevToken);
|
|
1177
|
+
}
|
|
1178
|
+
point = tokenEnd;
|
|
1179
|
+
}
|
|
1180
|
+
return tokens;
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* Converts an mdast text node into a tokenized text AST with normalized source positions.
|
|
1184
|
+
*/
|
|
1185
|
+
function buildTextNodeAst(node) {
|
|
1186
|
+
const start = node.position?.start;
|
|
1187
|
+
const end = node.position?.end;
|
|
1188
|
+
const position = {
|
|
1189
|
+
start: {
|
|
1190
|
+
line: start?.line ?? DEFAULT_START_POINT.line,
|
|
1191
|
+
column: start?.column ?? DEFAULT_START_POINT.column,
|
|
1192
|
+
offset: start?.offset ?? DEFAULT_START_POINT.offset
|
|
1193
|
+
},
|
|
1194
|
+
end: {
|
|
1195
|
+
line: end?.line ?? DEFAULT_START_POINT.line,
|
|
1196
|
+
column: end?.column ?? DEFAULT_START_POINT.column + node.value.length,
|
|
1197
|
+
offset: end?.offset ?? DEFAULT_START_POINT.offset + node.value.length
|
|
1198
|
+
}
|
|
1199
|
+
};
|
|
1200
|
+
return {
|
|
1201
|
+
type: "text",
|
|
1202
|
+
value: node.value,
|
|
1203
|
+
position,
|
|
1204
|
+
children: tokenizeText(node.value, position.start),
|
|
1205
|
+
node
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
//#endregion
|
|
1209
|
+
//#region src/rules/shared/text-boundary-spacing.ts
|
|
1210
|
+
/**
|
|
1211
|
+
* Normalizes an existing space token around the target token type.
|
|
1212
|
+
* When multiple spaces are collapsed, the redundant side is recorded so the
|
|
1213
|
+
* caller can choose the most specific lint message.
|
|
1214
|
+
*/
|
|
1215
|
+
function processSpaceToken(ctx, result, isTargetType) {
|
|
1216
|
+
const { prev, current, next } = ctx;
|
|
1217
|
+
/* v8 ignore if -- @preserve */
|
|
1218
|
+
if (!current) return;
|
|
1219
|
+
const cjkToTarget = prev?.type === TEXT_TYPE.cjk && isTargetType(next?.type);
|
|
1220
|
+
const targetToCjk = isTargetType(prev?.type) && next?.type === TEXT_TYPE.cjk;
|
|
1221
|
+
const targets = isTargetType(prev?.type) && isTargetType(next?.type);
|
|
1222
|
+
const hasUnexpectedSpaces = current.value.length !== 1;
|
|
1223
|
+
if (hasUnexpectedSpaces) {
|
|
1224
|
+
if (cjkToTarget || targets) result.unexpectedBefore = true;
|
|
1225
|
+
if (targetToCjk || targets) result.unexpectedAfter = true;
|
|
1226
|
+
}
|
|
1227
|
+
if (cjkToTarget || targetToCjk || hasUnexpectedSpaces) result.fixed += " ";
|
|
1228
|
+
else result.fixed += current.value;
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Inserts missing spaces before or after the target token when it directly
|
|
1232
|
+
* touches adjacent CJK text.
|
|
1233
|
+
*/
|
|
1234
|
+
function processTargetToken(ctx, result) {
|
|
1235
|
+
const { prev, current, next } = ctx;
|
|
1236
|
+
/* v8 ignore if -- @preserve */
|
|
1237
|
+
if (!current) return;
|
|
1238
|
+
if (prev?.type === TEXT_TYPE.cjk) {
|
|
1239
|
+
result.fixed += " ";
|
|
1240
|
+
result.missingBefore = true;
|
|
1241
|
+
}
|
|
1242
|
+
result.fixed += current.value;
|
|
1243
|
+
if (next?.type === TEXT_TYPE.cjk) {
|
|
1244
|
+
result.fixed += " ";
|
|
1245
|
+
result.missingAfter = true;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Chooses the most specific shared boundary-space message id from the
|
|
1250
|
+
* collected missing or redundant space flags.
|
|
1251
|
+
*/
|
|
1252
|
+
function getBoundarySpaceMessageId(boundary) {
|
|
1253
|
+
if (boundary.missingBefore && boundary.missingAfter) return "missingSpacesAround";
|
|
1254
|
+
if (boundary.unexpectedBefore && boundary.unexpectedAfter) return "unexpectedSpaceAround";
|
|
1255
|
+
if (boundary.missingBefore) return "missingSpaceBefore";
|
|
1256
|
+
if (boundary.missingAfter) return "missingSpaceAfter";
|
|
1257
|
+
if (boundary.unexpectedBefore) return "unexpectedSpaceBefore";
|
|
1258
|
+
return "unexpectedSpaceAfter";
|
|
1259
|
+
}
|
|
1260
|
+
/**
|
|
1261
|
+
* Rebuilds a text node with normalized spacing between CJK text and a target
|
|
1262
|
+
* token class such as Latin words or numbers.
|
|
1263
|
+
*/
|
|
1264
|
+
function fixBoundarySpace(node, isTargetType) {
|
|
1265
|
+
const { children } = buildTextNodeAst(node);
|
|
1266
|
+
const result = {
|
|
1267
|
+
fixed: "",
|
|
1268
|
+
missingBefore: false,
|
|
1269
|
+
missingAfter: false,
|
|
1270
|
+
unexpectedBefore: false,
|
|
1271
|
+
unexpectedAfter: false
|
|
1272
|
+
};
|
|
1273
|
+
for (let i = 0; i < children.length; i += 1) {
|
|
1274
|
+
const ctx = getNodeContextByParent(children, i);
|
|
1275
|
+
/* v8 ignore if -- @preserve */
|
|
1276
|
+
if (!ctx.current) continue;
|
|
1277
|
+
if (ctx.current.type === TEXT_TYPE.space) processSpaceToken(ctx, result, isTargetType);
|
|
1278
|
+
else if (isTargetType(ctx.current.type)) processTargetToken(ctx, result);
|
|
1279
|
+
else result.fixed += ctx.current.value;
|
|
1280
|
+
}
|
|
1281
|
+
return result;
|
|
1282
|
+
}
|
|
1283
|
+
var space_around_number_default = createRule({
|
|
1284
|
+
name: "space-around-number",
|
|
452
1285
|
meta: {
|
|
453
1286
|
type: "layout",
|
|
454
|
-
docs: { description: "Enforce
|
|
1287
|
+
docs: { description: "Enforce a single space between CJK characters and numbers." },
|
|
455
1288
|
messages: {
|
|
456
|
-
missingSpaceBefore: "
|
|
457
|
-
missingSpaceAfter: "
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
unexpectedSpaceAfter: "Do not add a space between the inline element and punctuation."
|
|
1289
|
+
missingSpaceBefore: "Add a space before the number.",
|
|
1290
|
+
missingSpaceAfter: "Add a space after the number.",
|
|
1291
|
+
missingSpacesAround: "Add spaces before and after the number.",
|
|
1292
|
+
unexpectedSpaceBefore: "Remove the unexpected space before the number.",
|
|
1293
|
+
unexpectedSpaceAfter: "Remove the unexpected space after the number.",
|
|
1294
|
+
unexpectedSpaceAround: "Remove the unexpected spaces around the number."
|
|
463
1295
|
},
|
|
464
1296
|
fixable: "whitespace",
|
|
465
1297
|
schema: []
|
|
466
1298
|
},
|
|
467
1299
|
defaultOptions: [],
|
|
468
1300
|
create(context) {
|
|
469
|
-
return {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
|
|
1301
|
+
return { text(node) {
|
|
1302
|
+
const { fixed, missingBefore, missingAfter, unexpectedBefore, unexpectedAfter } = fixBoundarySpace(node, (type) => type === "number");
|
|
1303
|
+
if (fixed === node.value) return;
|
|
1304
|
+
context.report({
|
|
1305
|
+
node,
|
|
1306
|
+
messageId: getBoundarySpaceMessageId({
|
|
1307
|
+
missingBefore,
|
|
1308
|
+
missingAfter,
|
|
1309
|
+
unexpectedBefore,
|
|
1310
|
+
unexpectedAfter
|
|
1311
|
+
}),
|
|
1312
|
+
fix: (fixer) => fixer.replaceText(node, fixed)
|
|
1313
|
+
});
|
|
1314
|
+
} };
|
|
1315
|
+
}
|
|
1316
|
+
});
|
|
1317
|
+
var space_around_word_default = createRule({
|
|
1318
|
+
name: "space-around-word",
|
|
1319
|
+
meta: {
|
|
1320
|
+
type: "layout",
|
|
1321
|
+
docs: { description: "Enforce a single space between CJK characters and Latin words." },
|
|
1322
|
+
messages: {
|
|
1323
|
+
missingSpaceBefore: "Add a space before the word.",
|
|
1324
|
+
missingSpaceAfter: "Add a space after the word.",
|
|
1325
|
+
missingSpacesAround: "Add spaces before and after the word.",
|
|
1326
|
+
unexpectedSpaceBefore: "Remove the unexpected space before the word.",
|
|
1327
|
+
unexpectedSpaceAfter: "Remove the unexpected space after the word.",
|
|
1328
|
+
unexpectedSpaceAround: "Remove the unexpected spaces around the word."
|
|
1329
|
+
},
|
|
1330
|
+
fixable: "whitespace",
|
|
1331
|
+
schema: []
|
|
1332
|
+
},
|
|
1333
|
+
defaultOptions: [],
|
|
1334
|
+
create(context) {
|
|
1335
|
+
return { text(node) {
|
|
1336
|
+
const { fixed, missingBefore, missingAfter, unexpectedBefore, unexpectedAfter } = fixBoundarySpace(node, isLatinWordType);
|
|
1337
|
+
if (fixed === node.value) return;
|
|
1338
|
+
context.report({
|
|
1339
|
+
node,
|
|
1340
|
+
messageId: getBoundarySpaceMessageId({
|
|
1341
|
+
missingBefore,
|
|
1342
|
+
missingAfter,
|
|
1343
|
+
unexpectedBefore,
|
|
1344
|
+
unexpectedAfter
|
|
1345
|
+
}),
|
|
1346
|
+
fix(fixer) {
|
|
1347
|
+
return fixer.replaceText(node, fixed);
|
|
1348
|
+
}
|
|
1349
|
+
});
|
|
1350
|
+
} };
|
|
1351
|
+
}
|
|
1352
|
+
});
|
|
1353
|
+
//#endregion
|
|
1354
|
+
//#region src/rules/valid-custom-container-type/index.ts
|
|
1355
|
+
const RULE_NAME$1 = "valid-custom-container-type";
|
|
1356
|
+
const MESSAGE_IDS$1 = {
|
|
1357
|
+
invalidType: "invalidType",
|
|
1358
|
+
invalidTypeCase: "invalidTypeCase"
|
|
1359
|
+
};
|
|
1360
|
+
var valid_custom_container_type_default = createRule({
|
|
1361
|
+
name: RULE_NAME$1,
|
|
1362
|
+
meta: {
|
|
1363
|
+
type: "problem",
|
|
1364
|
+
docs: { description: "Require custom containers to use a supported type." },
|
|
1365
|
+
messages: {
|
|
1366
|
+
invalidType: "Invalid custom container type \"{{type}}\". Use info, tip, warning, danger, details, raw, code-group, v-pre, or tabs.",
|
|
1367
|
+
invalidTypeCase: "Custom container type \"{{type}}\" must be lowercase."
|
|
1368
|
+
},
|
|
1369
|
+
fixable: "code",
|
|
1370
|
+
schema: []
|
|
1371
|
+
},
|
|
1372
|
+
defaultOptions: [],
|
|
1373
|
+
create(context) {
|
|
1374
|
+
return { text(node) {
|
|
1375
|
+
const { position, start: startPoint } = getNodePosition(node);
|
|
1376
|
+
/* v8 ignore if -- @preserve */
|
|
1377
|
+
if (!position) return;
|
|
1378
|
+
for (const tag of getOpeningTags(parseCustomContainers(node.value))) {
|
|
1379
|
+
const issue = getTypeIssue(tag.value.content);
|
|
1380
|
+
if (!issue) continue;
|
|
1381
|
+
const normalizedType = "normalizedType" in issue ? issue.normalizedType : void 0;
|
|
1382
|
+
const start = startPoint + tag.value.start;
|
|
1383
|
+
const end = startPoint + tag.value.end;
|
|
1384
|
+
context.report({
|
|
1385
|
+
node,
|
|
1386
|
+
messageId: issue.messageId,
|
|
1387
|
+
data: { type: tag.value.content },
|
|
1388
|
+
loc: {
|
|
1389
|
+
start: context.sourceCode.getLocFromIndex(start),
|
|
1390
|
+
end: context.sourceCode.getLocFromIndex(end)
|
|
1391
|
+
},
|
|
1392
|
+
fix: normalizedType ? (fixer) => fixer.replaceTextRange([start, end], normalizedType) : void 0
|
|
1393
|
+
});
|
|
484
1394
|
}
|
|
485
|
-
};
|
|
1395
|
+
} };
|
|
486
1396
|
}
|
|
487
1397
|
});
|
|
488
|
-
|
|
1398
|
+
function* getOpeningTags(nodes) {
|
|
1399
|
+
for (const node of nodes) {
|
|
1400
|
+
if (node.type !== "custom-container") continue;
|
|
1401
|
+
yield* getOpeningTagsFromContainer(node);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
function* getOpeningTagsFromContainer(container) {
|
|
1405
|
+
for (const child of container.children) if (child.type === "custom-container") yield* getOpeningTagsFromContainer(child);
|
|
1406
|
+
else if (child.type === "open") yield child;
|
|
1407
|
+
}
|
|
1408
|
+
function getTypeIssue(type) {
|
|
1409
|
+
if (isCustomContainerType(type)) return null;
|
|
1410
|
+
const normalizedType = type.toLowerCase();
|
|
1411
|
+
if (isCustomContainerType(normalizedType)) return {
|
|
1412
|
+
messageId: MESSAGE_IDS$1.invalidTypeCase,
|
|
1413
|
+
normalizedType
|
|
1414
|
+
};
|
|
1415
|
+
return { messageId: MESSAGE_IDS$1.invalidType };
|
|
1416
|
+
}
|
|
1417
|
+
//#endregion
|
|
1418
|
+
//#region src/rules/valid-heading-anchor/anchor.ts
|
|
1419
|
+
/**
|
|
1420
|
+
* Check if the string has an anchor.
|
|
1421
|
+
* @example: {#chinese-anchor}
|
|
1422
|
+
*/
|
|
1423
|
+
function isStrictAnchor(str) {
|
|
1424
|
+
return /\s\{#[a-z0-9]+(?:-[a-z0-9]+)*\}/.test(str);
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Check whether the string contains CJK Han characters.
|
|
1428
|
+
*/
|
|
1429
|
+
function hasChinese(str) {
|
|
1430
|
+
return /\p{Script=Han}/u.test(str);
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Normalize raw anchor text into the strict anchor format content.
|
|
1434
|
+
* - lowercase all letters
|
|
1435
|
+
* - convert spaces to `-`
|
|
1436
|
+
* - remove unsupported characters
|
|
1437
|
+
* - trim leading/trailing `-`
|
|
1438
|
+
*/
|
|
1439
|
+
function normalizeAnchor(anchor) {
|
|
1440
|
+
return anchor.toLowerCase().replace(/[\s.]+/g, "-").replace(/[^a-z0-9_-]/g, "").replace(/^-+|-+$/g, "");
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* Count wrapper characters contributed by the trailing like-anchor fragment.
|
|
1444
|
+
* The value is the length difference between the raw matched fragment and the
|
|
1445
|
+
* cleaned anchor text returned by `getLikeAnchor`.
|
|
1446
|
+
* @example `# 中文标题 {#Chinese-Title}` -> 3
|
|
1447
|
+
* @example `## 使用 \`describe\` 编组测试 #Grouping Tests with \`describe\`` -> 1
|
|
1448
|
+
*/
|
|
1449
|
+
function calcAnchorPositionCompensate(content) {
|
|
1450
|
+
const match = getLikeAnchorMatch(content);
|
|
1451
|
+
const anchor = getLikeAnchor(content);
|
|
1452
|
+
if (!match || !anchor) return 0;
|
|
1453
|
+
return match.length - anchor.rawLikeAnchor.length;
|
|
1454
|
+
}
|
|
489
1455
|
//#endregion
|
|
490
|
-
//#region src/
|
|
1456
|
+
//#region src/parser/markdown.ts
|
|
491
1457
|
const language = new MarkdownLanguage({ mode: "gfm" });
|
|
492
1458
|
/**
|
|
493
1459
|
* Parses Markdown with the same GFM language implementation used by the
|
|
494
1460
|
* plugin tests and returns both the mdast tree and ESLint SourceCode wrapper.
|
|
495
1461
|
*/
|
|
496
|
-
function parseMarkdown(markdown
|
|
1462
|
+
function parseMarkdown(markdown) {
|
|
497
1463
|
const file = {
|
|
498
1464
|
path: "test.md",
|
|
499
1465
|
physicalPath: "test.md",
|
|
500
1466
|
bom: false,
|
|
501
|
-
body: markdown
|
|
1467
|
+
body: markdown
|
|
502
1468
|
};
|
|
503
1469
|
const parseResult = language.parse(file, { languageOptions: {
|
|
504
1470
|
...language.defaultLanguageOptions,
|
|
@@ -511,18 +1477,16 @@ function parseMarkdown(markdown$1) {
|
|
|
511
1477
|
sourceCode: language.createSourceCode(file, parseResult)
|
|
512
1478
|
};
|
|
513
1479
|
}
|
|
514
|
-
|
|
515
1480
|
//#endregion
|
|
516
|
-
//#region src/
|
|
1481
|
+
//#region src/rules/valid-heading-anchor/frontmatter.ts
|
|
517
1482
|
/**
|
|
518
1483
|
* Returns true when the Markdown document starts with YAML frontmatter.
|
|
519
1484
|
*/
|
|
520
|
-
function hasFrontmatter(markdown
|
|
521
|
-
if (prevNode?.type === "thematicBreak") markdown
|
|
522
|
-
const { ast } = parseMarkdown(markdown
|
|
1485
|
+
function hasFrontmatter(markdown, prevNode) {
|
|
1486
|
+
if (prevNode?.type === "thematicBreak") markdown = `---\n${markdown}`;
|
|
1487
|
+
const { ast } = parseMarkdown(markdown);
|
|
523
1488
|
return ast.children[0]?.type === "yaml";
|
|
524
1489
|
}
|
|
525
|
-
|
|
526
1490
|
//#endregion
|
|
527
1491
|
//#region src/rules/valid-heading-anchor/index.ts
|
|
528
1492
|
const RULE_NAME = "valid-heading-anchor";
|
|
@@ -530,58 +1494,60 @@ const MESSAGE_IDS = {
|
|
|
530
1494
|
missingAnchor: "missingAnchor",
|
|
531
1495
|
invalidHeadingAnchor: "invalidHeadingAnchor"
|
|
532
1496
|
};
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
1497
|
+
//#endregion
|
|
1498
|
+
//#region src/rules/index.ts
|
|
1499
|
+
const rules = {
|
|
1500
|
+
"padding-around-custom-container": padding_around_custom_container_default,
|
|
1501
|
+
"space-around-inline-element": space_around_inline_element_default,
|
|
1502
|
+
"space-around-number": space_around_number_default,
|
|
1503
|
+
"space-around-word": space_around_word_default,
|
|
1504
|
+
"space-around-custom-container": space_around_custom_container_default,
|
|
1505
|
+
"valid-custom-container-type": valid_custom_container_type_default,
|
|
1506
|
+
"valid-heading-anchor": createRule({
|
|
1507
|
+
name: RULE_NAME,
|
|
1508
|
+
meta: {
|
|
1509
|
+
type: "layout",
|
|
1510
|
+
docs: { description: "Require strict lowercase anchors for headings that contain CJK text." },
|
|
1511
|
+
messages: {
|
|
1512
|
+
missingAnchor: "Non-ASCII heading must have an anchor in the format \"{#lowercase-anchor}\".",
|
|
1513
|
+
invalidHeadingAnchor: "Anchor must use lowercase letters and valid characters only."
|
|
1514
|
+
},
|
|
1515
|
+
fixable: "whitespace",
|
|
1516
|
+
schema: []
|
|
541
1517
|
},
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
1518
|
+
defaultOptions: [],
|
|
1519
|
+
create(context) {
|
|
1520
|
+
return { heading(node) {
|
|
1521
|
+
const { position, start, end } = getNodePosition(node);
|
|
1522
|
+
/* v8 ignore if -- @preserve */
|
|
1523
|
+
if (!position) return;
|
|
1524
|
+
const source = context.sourceCode.text.slice(start, end);
|
|
1525
|
+
if (isStrictAnchor(source) || !hasChinese(source)) return;
|
|
1526
|
+
if (hasFrontmatter(source, getNodeContext(context, node).prev)) return;
|
|
1527
|
+
const liked = getLikeAnchor(source);
|
|
1528
|
+
if (!liked) {
|
|
1529
|
+
context.report({
|
|
1530
|
+
node,
|
|
1531
|
+
messageId: MESSAGE_IDS.missingAnchor
|
|
1532
|
+
});
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
const { rawLikeAnchor, isLikeAnchor } = liked;
|
|
1536
|
+
const compensate = calcAnchorPositionCompensate(source);
|
|
1537
|
+
const remainingContent = source.slice(0, -rawLikeAnchor.length - compensate).trim();
|
|
1538
|
+
const anchor = normalizeAnchor(rawLikeAnchor);
|
|
1539
|
+
if (rawLikeAnchor === anchor) return;
|
|
556
1540
|
context.report({
|
|
557
1541
|
node,
|
|
558
|
-
messageId: MESSAGE_IDS.missingAnchor
|
|
1542
|
+
messageId: isLikeAnchor ? MESSAGE_IDS.missingAnchor : MESSAGE_IDS.invalidHeadingAnchor,
|
|
1543
|
+
fix(fixer) {
|
|
1544
|
+
return fixer.replaceTextRange([start, end], `${remainingContent} {#${anchor}}`);
|
|
1545
|
+
}
|
|
559
1546
|
});
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
const compensate = calcAnchorPositionCompensate(source);
|
|
564
|
-
const remainingContent = source.slice(0, -rawLikeAnchor.length - compensate).trim();
|
|
565
|
-
const anchor = normalizeAnchor(rawLikeAnchor);
|
|
566
|
-
if (rawLikeAnchor === anchor) return;
|
|
567
|
-
context.report({
|
|
568
|
-
node,
|
|
569
|
-
messageId: isLikeAnchor ? MESSAGE_IDS.missingAnchor : MESSAGE_IDS.invalidHeadingAnchor,
|
|
570
|
-
fix(fixer) {
|
|
571
|
-
return fixer.replaceTextRange([start, end], `${remainingContent} {#${anchor}}`);
|
|
572
|
-
}
|
|
573
|
-
});
|
|
574
|
-
} };
|
|
575
|
-
}
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
//#endregion
|
|
579
|
-
//#region src/rules/index.ts
|
|
580
|
-
const rules = {
|
|
581
|
-
"space-between-inline-element": space_between_inline_element_default,
|
|
582
|
-
"valid-heading-anchor": valid_heading_anchor_default
|
|
1547
|
+
} };
|
|
1548
|
+
}
|
|
1549
|
+
})
|
|
583
1550
|
};
|
|
584
|
-
|
|
585
1551
|
//#endregion
|
|
586
1552
|
//#region src/index.ts
|
|
587
1553
|
const plugin = {
|
|
@@ -589,15 +1555,20 @@ const plugin = {
|
|
|
589
1555
|
processors: markdown.processors,
|
|
590
1556
|
languages: { gfm: new MarkdownLanguage({ mode: "gfm" }) }
|
|
591
1557
|
};
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
|
|
1558
|
+
const recommendedRules = {
|
|
1559
|
+
"md-style/valid-heading-anchor": "error",
|
|
1560
|
+
"md-style/space-around-inline-element": "error",
|
|
1561
|
+
"md-style/padding-around-custom-container": "error",
|
|
1562
|
+
"md-style/valid-custom-container-type": "error"
|
|
1563
|
+
};
|
|
1564
|
+
const allRules = Object.fromEntries(Object.keys(rules).map((ruleName) => [`md-style/${ruleName}`, "error"]));
|
|
595
1565
|
const configs = {
|
|
596
1566
|
recommended: {
|
|
597
1567
|
name: "md-style/recommended",
|
|
598
1568
|
files: ["**/*.md"],
|
|
599
1569
|
plugins: { "md-style": plugin },
|
|
600
1570
|
language: "md-style/gfm",
|
|
1571
|
+
languageOptions: { frontmatter: "yaml" },
|
|
601
1572
|
rules: recommendedRules
|
|
602
1573
|
},
|
|
603
1574
|
all: {
|
|
@@ -605,11 +1576,10 @@ const configs = {
|
|
|
605
1576
|
files: ["**/*.md"],
|
|
606
1577
|
plugins: { "md-style": plugin },
|
|
607
1578
|
language: "md-style/gfm",
|
|
1579
|
+
languageOptions: { frontmatter: "yaml" },
|
|
608
1580
|
rules: allRules
|
|
609
1581
|
}
|
|
610
1582
|
};
|
|
611
1583
|
const mdStylePlugin = Object.assign(plugin, { configs });
|
|
612
|
-
var src_default = mdStylePlugin;
|
|
613
|
-
|
|
614
1584
|
//#endregion
|
|
615
|
-
export { configs,
|
|
1585
|
+
export { configs, mdStylePlugin as default, plugin };
|