eslint-plugin-md-style 0.1.0 → 0.2.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 +4 -2
- package/dist/index.mjs +430 -59
- package/package.json +19 -18
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ export default [
|
|
|
70
70
|
},
|
|
71
71
|
language: 'md-style/gfm',
|
|
72
72
|
rules: {
|
|
73
|
-
'md-style/space-
|
|
73
|
+
'md-style/space-around-inline-element': 'error',
|
|
74
74
|
'md-style/valid-heading-anchor': 'error',
|
|
75
75
|
},
|
|
76
76
|
},
|
|
@@ -120,7 +120,9 @@ export default antfu(
|
|
|
120
120
|
|
|
121
121
|
| Rule | Included in `recommended` | Autofix |
|
|
122
122
|
| --- | --- | --- |
|
|
123
|
-
| `md-style/space-
|
|
123
|
+
| `md-style/space-around-inline-element` | ✅ | 🔧 |
|
|
124
|
+
| `md-style/space-around-number` | | 🔧 |
|
|
125
|
+
| `md-style/space-around-word` | | 🔧 |
|
|
124
126
|
| `md-style/valid-heading-anchor` | ✅ | 🔧 |
|
|
125
127
|
|
|
126
128
|
## Why `@eslint/markdown` Is Required
|
package/dist/index.mjs
CHANGED
|
@@ -52,7 +52,7 @@ function isNestedInlineElement(nodeContext) {
|
|
|
52
52
|
function getNodeValue(node) {
|
|
53
53
|
if (!node) return;
|
|
54
54
|
if ("value" in node) return node.value;
|
|
55
|
-
if (hasChildren(node)) return node.children.map(getNodeValue).join("")
|
|
55
|
+
if (hasChildren(node)) return node.children.map(getNodeValue).join("");
|
|
56
56
|
}
|
|
57
57
|
/**
|
|
58
58
|
* Gets the start and end offsets for a node.
|
|
@@ -95,7 +95,19 @@ function getNodeContext(context, node) {
|
|
|
95
95
|
};
|
|
96
96
|
}
|
|
97
97
|
/**
|
|
98
|
-
*
|
|
98
|
+
* Returns the current node and adjacent siblings from a known children array.
|
|
99
|
+
* Useful when iterating a tokenized child list directly without an ESLint ancestor context.
|
|
100
|
+
*/
|
|
101
|
+
function getNodeContextByParent(childrenNodes, currentIndex) {
|
|
102
|
+
return {
|
|
103
|
+
prev: childrenNodes[currentIndex - 1],
|
|
104
|
+
current: childrenNodes[currentIndex],
|
|
105
|
+
next: childrenNodes[currentIndex + 1]
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Gets the first or last visible character of a string after trimming
|
|
110
|
+
* surrounding whitespace.
|
|
99
111
|
*/
|
|
100
112
|
function getAdjacentChar(str, position) {
|
|
101
113
|
if (!str) return void 0;
|
|
@@ -143,7 +155,7 @@ function isStrictAnchor(str) {
|
|
|
143
155
|
* Check whether the string contains CJK Han characters.
|
|
144
156
|
*/
|
|
145
157
|
function hasChinese(str) {
|
|
146
|
-
return /
|
|
158
|
+
return /\p{Script=Han}/u.test(str);
|
|
147
159
|
}
|
|
148
160
|
/**
|
|
149
161
|
* Normalize raw anchor text into the strict anchor format content.
|
|
@@ -153,7 +165,7 @@ function hasChinese(str) {
|
|
|
153
165
|
* - trim leading/trailing `-`
|
|
154
166
|
*/
|
|
155
167
|
function normalizeAnchor(anchor) {
|
|
156
|
-
return anchor.toLowerCase().replace(/[\s.]
|
|
168
|
+
return anchor.toLowerCase().replace(/[\s.]+/g, "-").replace(/[^a-z0-9_-]/g, "").replace(/^-+|-+$/g, "");
|
|
157
169
|
}
|
|
158
170
|
/**
|
|
159
171
|
* Count wrapper characters contributed by the trailing like-anchor fragment.
|
|
@@ -208,6 +220,16 @@ function isFullwidthPunctuation(str) {
|
|
|
208
220
|
if (!str || str.length !== 1) return false;
|
|
209
221
|
return /^[\u3001-\u303F\uFE10-\uFE1F\uFE30-\uFE4F\uFF01-\uFF0F\uFF1A-\uFF20\uFF3B-\uFF40\uFF5B-\uFF65“”‘’…]$/u.test(str);
|
|
210
222
|
}
|
|
223
|
+
const HALFWIDTH_PUNCTUATION_RE = /^\p{P}$/u;
|
|
224
|
+
/**
|
|
225
|
+
* Checks whether the character is halfwidth punctuation.
|
|
226
|
+
* @example `,` -> true
|
|
227
|
+
* @example `,` -> false
|
|
228
|
+
*/
|
|
229
|
+
function isHalfwidthPunctuation(str) {
|
|
230
|
+
if (!str || str.length !== 1) return false;
|
|
231
|
+
return str.charCodeAt(0) <= 126 && HALFWIDTH_PUNCTUATION_RE.test(str);
|
|
232
|
+
}
|
|
211
233
|
const DASH_PUNCTUATION_RE = /^[-\u2013\u2014\u2212]$/u;
|
|
212
234
|
/**
|
|
213
235
|
* Checks whether the character is hyphen-like punctuation.
|
|
@@ -243,6 +265,14 @@ function hasPunctuation(str, position = "head") {
|
|
|
243
265
|
|
|
244
266
|
//#endregion
|
|
245
267
|
//#region src/utils/space.ts
|
|
268
|
+
const SPACE_MESSAGE_IDS = {
|
|
269
|
+
missingSpaceBefore: "missingSpaceBefore",
|
|
270
|
+
missingSpaceAfter: "missingSpaceAfter",
|
|
271
|
+
missingSpacesAround: "missingSpacesAround",
|
|
272
|
+
unexpectedSpaceBefore: "unexpectedSpaceBefore",
|
|
273
|
+
unexpectedSpaceAfter: "unexpectedSpaceAfter",
|
|
274
|
+
unexpectedSpaceAround: "unexpectedSpaceAround"
|
|
275
|
+
};
|
|
246
276
|
/**
|
|
247
277
|
* Gets the count and range of consecutive whitespace at the start or end of a string.
|
|
248
278
|
* @example ` text`, `head` -> { count: 2, start: 0, end: 2 }
|
|
@@ -298,15 +328,6 @@ function getSpaceContext(nodeContext) {
|
|
|
298
328
|
|
|
299
329
|
//#endregion
|
|
300
330
|
//#region src/utils/inline-element.ts
|
|
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
331
|
/**
|
|
311
332
|
* Checks whether adjacent text is a custom container marker on the next line.
|
|
312
333
|
*
|
|
@@ -334,18 +355,18 @@ function validateSingleRequiredSpace(count, missingSpaceMessageId, multipleSpace
|
|
|
334
355
|
function validateBeforePunctuation(context) {
|
|
335
356
|
const adjacentChar = getAdjacentChar(context.value, "tail");
|
|
336
357
|
if (OPENING_PAIRED_PUNCTUATION.has(adjacentChar || "") || isSlashPunctuation(adjacentChar)) {
|
|
337
|
-
if (context.whiteSpace.count > 0) return
|
|
358
|
+
if (context.whiteSpace.count > 0) return MESSAGE_IDS$1.unexpectedSpaceBefore;
|
|
338
359
|
return;
|
|
339
360
|
}
|
|
340
|
-
if (context.punctuationType === "half") return validateSingleRequiredSpace(context.whiteSpace.count,
|
|
341
|
-
if (context.whiteSpace.count > 0) return
|
|
361
|
+
if (context.punctuationType === "half") return validateSingleRequiredSpace(context.whiteSpace.count, MESSAGE_IDS$1.missingSpaceBefore, MESSAGE_IDS$1.multipleSpacesAfterPunctuation);
|
|
362
|
+
if (context.whiteSpace.count > 0) return MESSAGE_IDS$1.unexpectedSpaceBefore;
|
|
342
363
|
}
|
|
343
364
|
/**
|
|
344
365
|
* Validates the spacing between the previous node and the current inline node.
|
|
345
366
|
*/
|
|
346
367
|
function validateSpaceBeforeNode(context) {
|
|
347
368
|
if (context.hasPunctuation) return validateBeforePunctuation(context);
|
|
348
|
-
return validateSingleRequiredSpace(context.whiteSpace.count,
|
|
369
|
+
return validateSingleRequiredSpace(context.whiteSpace.count, MESSAGE_IDS$1.missingSpaceBefore, MESSAGE_IDS$1.multipleSpacesBefore);
|
|
349
370
|
}
|
|
350
371
|
/**
|
|
351
372
|
* Validates spacing after an inline node when the next character is punctuation.
|
|
@@ -353,16 +374,16 @@ function validateSpaceBeforeNode(context) {
|
|
|
353
374
|
function validateSpaceAfterPunctuation(context) {
|
|
354
375
|
const adjacentChar = getAdjacentChar(context.value, "head");
|
|
355
376
|
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
|
|
377
|
+
if (CLOSING_PAIRED_PUNCTUATION.has(adjacentChar || "") && context.whiteSpace.count > 0) return MESSAGE_IDS$1.unexpectedSpaceAfter;
|
|
378
|
+
if (context.punctuationType === "half" && OPENING_PAIRED_PUNCTUATION.has(adjacentChar || "") || isDashPunctuation(adjacentChar)) return validateSingleRequiredSpace(context.whiteSpace.count, MESSAGE_IDS$1.missingSpaceAfter, MESSAGE_IDS$1.multipleSpacesAfter);
|
|
379
|
+
if (context.whiteSpace.count > 0) return MESSAGE_IDS$1.unexpectedSpaceAfter;
|
|
359
380
|
}
|
|
360
381
|
/**
|
|
361
382
|
* Validates the spacing between the current inline node and the next node.
|
|
362
383
|
*/
|
|
363
384
|
function validateSpaceAfterNode(context) {
|
|
364
385
|
if (context.hasPunctuation) return validateSpaceAfterPunctuation(context);
|
|
365
|
-
return validateSingleRequiredSpace(context.whiteSpace.count,
|
|
386
|
+
return validateSingleRequiredSpace(context.whiteSpace.count, MESSAGE_IDS$1.missingSpaceAfter, MESSAGE_IDS$1.multipleSpacesAfter);
|
|
366
387
|
}
|
|
367
388
|
/**
|
|
368
389
|
* Validates spacing around an inline element inside a table cell.
|
|
@@ -403,14 +424,61 @@ function validateSpace(nodeContext) {
|
|
|
403
424
|
}
|
|
404
425
|
|
|
405
426
|
//#endregion
|
|
406
|
-
//#region src/rules/space-
|
|
407
|
-
const RULE_NAME$
|
|
427
|
+
//#region src/rules/space-around-inline-element/index.ts
|
|
428
|
+
const RULE_NAME$3 = "space-around-inline-element";
|
|
429
|
+
const MESSAGE_IDS$1 = {
|
|
430
|
+
missingSpaceBefore: "missingSpaceBefore",
|
|
431
|
+
missingSpaceAfter: "missingSpaceAfter",
|
|
432
|
+
multipleSpacesBefore: "multipleSpacesBefore",
|
|
433
|
+
multipleSpacesAfter: "multipleSpacesAfter",
|
|
434
|
+
multipleSpacesAfterPunctuation: "multipleSpacesAfterPunctuation",
|
|
435
|
+
unexpectedSpaceBefore: "unexpectedSpaceBefore",
|
|
436
|
+
unexpectedSpaceAfter: "unexpectedSpaceAfter"
|
|
437
|
+
};
|
|
408
438
|
const BEFORE_INLINE_ELEMENT_MESSAGE_IDS = new Set([
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
439
|
+
MESSAGE_IDS$1.missingSpaceBefore,
|
|
440
|
+
MESSAGE_IDS$1.multipleSpacesBefore,
|
|
441
|
+
MESSAGE_IDS$1.multipleSpacesAfterPunctuation,
|
|
442
|
+
MESSAGE_IDS$1.unexpectedSpaceBefore
|
|
413
443
|
]);
|
|
444
|
+
var space_around_inline_element_default = createRule({
|
|
445
|
+
name: RULE_NAME$3,
|
|
446
|
+
meta: {
|
|
447
|
+
type: "layout",
|
|
448
|
+
docs: { description: "Enforce spacing around Markdown inline elements." },
|
|
449
|
+
messages: {
|
|
450
|
+
missingSpaceBefore: "A space is required before the inline element.",
|
|
451
|
+
missingSpaceAfter: "A space is required after the inline element.",
|
|
452
|
+
multipleSpacesBefore: "Use exactly one space before the inline element.",
|
|
453
|
+
multipleSpacesAfter: "Use exactly one space after the inline element.",
|
|
454
|
+
multipleSpacesAfterPunctuation: "Use one space after punctuation.",
|
|
455
|
+
unexpectedSpaceBefore: "Do not add a space between punctuation and the inline element.",
|
|
456
|
+
unexpectedSpaceAfter: "Do not add a space between the inline element and punctuation."
|
|
457
|
+
},
|
|
458
|
+
fixable: "whitespace",
|
|
459
|
+
schema: []
|
|
460
|
+
},
|
|
461
|
+
defaultOptions: [],
|
|
462
|
+
create(context) {
|
|
463
|
+
return {
|
|
464
|
+
link(node) {
|
|
465
|
+
checkInlineElement(context, node);
|
|
466
|
+
},
|
|
467
|
+
image(node) {
|
|
468
|
+
checkInlineElement(context, node);
|
|
469
|
+
},
|
|
470
|
+
inlineCode(node) {
|
|
471
|
+
checkInlineElement(context, node);
|
|
472
|
+
},
|
|
473
|
+
emphasis(node) {
|
|
474
|
+
checkInlineElement(context, node);
|
|
475
|
+
},
|
|
476
|
+
strong(node) {
|
|
477
|
+
checkInlineElement(context, node);
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
});
|
|
414
482
|
/**
|
|
415
483
|
* Checks one selected inline element and reports the fix range around it.
|
|
416
484
|
*/
|
|
@@ -425,7 +493,7 @@ function checkInlineElement(context, node) {
|
|
|
425
493
|
if (!messageId) return;
|
|
426
494
|
if (BEFORE_INLINE_ELEMENT_MESSAGE_IDS.has(messageId) && spaceContext.prev) {
|
|
427
495
|
const { count } = spaceContext.prev.whiteSpace;
|
|
428
|
-
const replaceText = messageId ===
|
|
496
|
+
const replaceText = messageId === MESSAGE_IDS$1.unexpectedSpaceBefore ? "" : " ";
|
|
429
497
|
context.report({
|
|
430
498
|
node,
|
|
431
499
|
messageId,
|
|
@@ -437,7 +505,7 @@ function checkInlineElement(context, node) {
|
|
|
437
505
|
}
|
|
438
506
|
if (spaceContext.next) {
|
|
439
507
|
const { count } = spaceContext.next.whiteSpace;
|
|
440
|
-
const replaceText = messageId ===
|
|
508
|
+
const replaceText = messageId === MESSAGE_IDS$1.unexpectedSpaceAfter ? "" : " ";
|
|
441
509
|
context.report({
|
|
442
510
|
node,
|
|
443
511
|
messageId,
|
|
@@ -447,42 +515,339 @@ function checkInlineElement(context, node) {
|
|
|
447
515
|
});
|
|
448
516
|
}
|
|
449
517
|
}
|
|
450
|
-
|
|
518
|
+
|
|
519
|
+
//#endregion
|
|
520
|
+
//#region src/utils/text/tokenizer.ts
|
|
521
|
+
const TEXT_TYPE = {
|
|
522
|
+
"cjk": "cjk",
|
|
523
|
+
"latin": "latin",
|
|
524
|
+
"number": "number",
|
|
525
|
+
"space": "space",
|
|
526
|
+
"newline": "newline",
|
|
527
|
+
"fullwidth-punctuation": "fullwidth-punctuation",
|
|
528
|
+
"halfwidth-punctuation": "halfwidth-punctuation",
|
|
529
|
+
"dash": "dash",
|
|
530
|
+
"symbol": "symbol",
|
|
531
|
+
"emoji": "emoji",
|
|
532
|
+
"invisible": "invisible",
|
|
533
|
+
"other": "other"
|
|
534
|
+
};
|
|
535
|
+
const CJK_RE = /^\p{Script=Han}$|^\p{Script=Hiragana}$|^\p{Script=Katakana}$|^\p{Script=Hangul}$/u;
|
|
536
|
+
const LATIN_RE = /^\p{Script=Latin}$/u;
|
|
537
|
+
const NUMBER_RE = /^\p{Number}$/u;
|
|
538
|
+
const SYMBOL_RE = /^\p{Symbol}$/u;
|
|
539
|
+
const EMOJI_RE = /^\p{Extended_Pictographic}$/u;
|
|
540
|
+
const SPACE_RE = /^[\t\v\f \u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]$/u;
|
|
541
|
+
const NEWLINE_RE = /^[\n\r\u2028\u2029]$/u;
|
|
542
|
+
const INVISIBLE_CODE_POINTS = new Set([
|
|
543
|
+
173,
|
|
544
|
+
847,
|
|
545
|
+
1564,
|
|
546
|
+
4447,
|
|
547
|
+
4448,
|
|
548
|
+
6068,
|
|
549
|
+
6069,
|
|
550
|
+
6158,
|
|
551
|
+
65279,
|
|
552
|
+
65440
|
|
553
|
+
]);
|
|
554
|
+
/**
|
|
555
|
+
* Checks whether the character is an invisible Unicode control or filler.
|
|
556
|
+
* These characters affect rendering, cursor movement, or text direction but
|
|
557
|
+
* should not be treated as visible spacing, punctuation, or symbols.
|
|
558
|
+
*/
|
|
559
|
+
function isInvisible(char) {
|
|
560
|
+
const codePoint = char.codePointAt(0);
|
|
561
|
+
return codePoint != null && (INVISIBLE_CODE_POINTS.has(codePoint) || codePoint >= 8203 && codePoint <= 8207 || codePoint >= 8234 && codePoint <= 8238 || codePoint >= 8288 && codePoint <= 8303);
|
|
562
|
+
}
|
|
563
|
+
function isLatinWordType(type) {
|
|
564
|
+
return type === TEXT_TYPE.latin;
|
|
565
|
+
}
|
|
566
|
+
function isNumberType(type) {
|
|
567
|
+
return type === TEXT_TYPE.number;
|
|
568
|
+
}
|
|
569
|
+
function isNumber(char, prev) {
|
|
570
|
+
return NUMBER_RE.test(char) || prev?.type === TEXT_TYPE.number && (char === "." || char === "%");
|
|
571
|
+
}
|
|
572
|
+
const TEXT_TYPE_MATCHERS = [
|
|
573
|
+
{
|
|
574
|
+
type: TEXT_TYPE.newline,
|
|
575
|
+
test: (char) => NEWLINE_RE.test(char)
|
|
576
|
+
},
|
|
577
|
+
{
|
|
578
|
+
type: TEXT_TYPE.space,
|
|
579
|
+
test: (char) => SPACE_RE.test(char)
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
type: TEXT_TYPE.invisible,
|
|
583
|
+
test: (char) => isInvisible(char)
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
type: TEXT_TYPE.cjk,
|
|
587
|
+
test: (char) => CJK_RE.test(char)
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
type: TEXT_TYPE.latin,
|
|
591
|
+
test: (char) => LATIN_RE.test(char)
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
type: TEXT_TYPE.number,
|
|
595
|
+
test: isNumber
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
type: TEXT_TYPE.dash,
|
|
599
|
+
test: (char) => isDashPunctuation(char)
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
type: TEXT_TYPE["fullwidth-punctuation"],
|
|
603
|
+
test: (char) => isFullwidthPunctuation(char)
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
type: TEXT_TYPE["halfwidth-punctuation"],
|
|
607
|
+
test: (char) => isHalfwidthPunctuation(char)
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
type: TEXT_TYPE.emoji,
|
|
611
|
+
test: (char) => EMOJI_RE.test(char)
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
type: TEXT_TYPE.symbol,
|
|
615
|
+
test: (char) => SYMBOL_RE.test(char)
|
|
616
|
+
}
|
|
617
|
+
];
|
|
618
|
+
const DEFAULT_START_POINT = {
|
|
619
|
+
line: 1,
|
|
620
|
+
column: 1,
|
|
621
|
+
offset: 0
|
|
622
|
+
};
|
|
623
|
+
function advancePoint(point, char) {
|
|
624
|
+
if (NEWLINE_RE.test(char)) return {
|
|
625
|
+
line: point.line + 1,
|
|
626
|
+
column: 1,
|
|
627
|
+
offset: point.offset + char.length
|
|
628
|
+
};
|
|
629
|
+
return {
|
|
630
|
+
line: point.line,
|
|
631
|
+
column: point.column + char.length,
|
|
632
|
+
offset: point.offset + char.length
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Classifies a single Unicode code point for Markdown text style rules.
|
|
637
|
+
*/
|
|
638
|
+
function getTextType(char, prev) {
|
|
639
|
+
for (const matcher of TEXT_TYPE_MATCHERS) if (matcher.test(char, prev)) return matcher.type;
|
|
640
|
+
return TEXT_TYPE.other;
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Tokenizes text into consecutive typed runs while preserving source positions.
|
|
644
|
+
*/
|
|
645
|
+
function tokenizeText(value, start = DEFAULT_START_POINT) {
|
|
646
|
+
const tokens = [];
|
|
647
|
+
let point = start;
|
|
648
|
+
let prevToken;
|
|
649
|
+
for (const char of value) {
|
|
650
|
+
const type = getTextType(char, prevToken);
|
|
651
|
+
const tokenStart = point;
|
|
652
|
+
const tokenEnd = advancePoint(point, char);
|
|
653
|
+
if (prevToken?.type === type) {
|
|
654
|
+
prevToken.value += char;
|
|
655
|
+
prevToken.position.end = tokenEnd;
|
|
656
|
+
} else {
|
|
657
|
+
prevToken = {
|
|
658
|
+
type,
|
|
659
|
+
value: char,
|
|
660
|
+
position: {
|
|
661
|
+
start: tokenStart,
|
|
662
|
+
end: tokenEnd
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
tokens.push(prevToken);
|
|
666
|
+
}
|
|
667
|
+
point = tokenEnd;
|
|
668
|
+
}
|
|
669
|
+
return tokens;
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Converts an mdast text node into a tokenized text AST with normalized source positions.
|
|
673
|
+
*/
|
|
674
|
+
function buildTextNodeAst(node) {
|
|
675
|
+
const start = node.position?.start;
|
|
676
|
+
const end = node.position?.end;
|
|
677
|
+
const position = {
|
|
678
|
+
start: {
|
|
679
|
+
line: start?.line ?? DEFAULT_START_POINT.line,
|
|
680
|
+
column: start?.column ?? DEFAULT_START_POINT.column,
|
|
681
|
+
offset: start?.offset ?? DEFAULT_START_POINT.offset
|
|
682
|
+
},
|
|
683
|
+
end: {
|
|
684
|
+
line: end?.line ?? DEFAULT_START_POINT.line,
|
|
685
|
+
column: end?.column ?? DEFAULT_START_POINT.column + node.value.length,
|
|
686
|
+
offset: end?.offset ?? DEFAULT_START_POINT.offset + node.value.length
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
return {
|
|
690
|
+
type: "text",
|
|
691
|
+
value: node.value,
|
|
692
|
+
position,
|
|
693
|
+
children: tokenizeText(node.value, position.start),
|
|
694
|
+
node
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
//#endregion
|
|
699
|
+
//#region src/utils/text/boundary-space.ts
|
|
700
|
+
/**
|
|
701
|
+
* Normalizes an existing space token around the target token type.
|
|
702
|
+
* When multiple spaces are collapsed, the redundant side is recorded so the
|
|
703
|
+
* caller can choose the most specific lint message.
|
|
704
|
+
*/
|
|
705
|
+
function processSpaceToken(ctx, result, isTargetType) {
|
|
706
|
+
const { prev, current, next } = ctx;
|
|
707
|
+
/* v8 ignore if -- @preserve */
|
|
708
|
+
if (!current) return;
|
|
709
|
+
const cjkToTarget = prev?.type === TEXT_TYPE.cjk && isTargetType(next?.type);
|
|
710
|
+
const targetToCjk = isTargetType(prev?.type) && next?.type === TEXT_TYPE.cjk;
|
|
711
|
+
const targets = isTargetType(prev?.type) && isTargetType(next?.type);
|
|
712
|
+
const hasUnexpectedSpaces = current.value.length !== 1;
|
|
713
|
+
if (hasUnexpectedSpaces) {
|
|
714
|
+
if (cjkToTarget || targets) result.unexpectedBefore = true;
|
|
715
|
+
if (targetToCjk || targets) result.unexpectedAfter = true;
|
|
716
|
+
}
|
|
717
|
+
if (cjkToTarget || targetToCjk || hasUnexpectedSpaces) result.fixed += " ";
|
|
718
|
+
else result.fixed += current.value;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Inserts missing spaces before or after the target token when it directly
|
|
722
|
+
* touches adjacent CJK text.
|
|
723
|
+
*/
|
|
724
|
+
function processTargetToken(ctx, result) {
|
|
725
|
+
const { prev, current, next } = ctx;
|
|
726
|
+
/* v8 ignore if -- @preserve */
|
|
727
|
+
if (!current) return;
|
|
728
|
+
if (prev?.type === TEXT_TYPE.cjk) {
|
|
729
|
+
result.fixed += " ";
|
|
730
|
+
result.missingBefore = true;
|
|
731
|
+
}
|
|
732
|
+
result.fixed += current.value;
|
|
733
|
+
if (next?.type === TEXT_TYPE.cjk) {
|
|
734
|
+
result.fixed += " ";
|
|
735
|
+
result.missingAfter = true;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Chooses the most specific shared boundary-space message id from the
|
|
740
|
+
* collected missing or redundant space flags.
|
|
741
|
+
*/
|
|
742
|
+
function getBoundarySpaceMessageId(boundary) {
|
|
743
|
+
if (boundary.missingBefore && boundary.missingAfter) return SPACE_MESSAGE_IDS.missingSpacesAround;
|
|
744
|
+
if (boundary.unexpectedBefore && boundary.unexpectedAfter) return SPACE_MESSAGE_IDS.unexpectedSpaceAround;
|
|
745
|
+
if (boundary.missingBefore) return SPACE_MESSAGE_IDS.missingSpaceBefore;
|
|
746
|
+
if (boundary.missingAfter) return SPACE_MESSAGE_IDS.missingSpaceAfter;
|
|
747
|
+
if (boundary.unexpectedBefore) return SPACE_MESSAGE_IDS.unexpectedSpaceBefore;
|
|
748
|
+
return SPACE_MESSAGE_IDS.unexpectedSpaceAfter;
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Rebuilds a text node with normalized spacing between CJK text and a target
|
|
752
|
+
* token class such as Latin words or numbers.
|
|
753
|
+
*/
|
|
754
|
+
function fixBoundarySpace(node, isTargetType) {
|
|
755
|
+
const { children } = buildTextNodeAst(node);
|
|
756
|
+
const result = {
|
|
757
|
+
fixed: "",
|
|
758
|
+
missingBefore: false,
|
|
759
|
+
missingAfter: false,
|
|
760
|
+
unexpectedBefore: false,
|
|
761
|
+
unexpectedAfter: false
|
|
762
|
+
};
|
|
763
|
+
for (let i = 0; i < children.length; i += 1) {
|
|
764
|
+
const ctx = getNodeContextByParent(children, i);
|
|
765
|
+
/* v8 ignore if -- @preserve */
|
|
766
|
+
if (!ctx.current) continue;
|
|
767
|
+
if (ctx.current.type === TEXT_TYPE.space) processSpaceToken(ctx, result, isTargetType);
|
|
768
|
+
else if (isTargetType(ctx.current.type)) processTargetToken(ctx, result);
|
|
769
|
+
else result.fixed += ctx.current.value;
|
|
770
|
+
}
|
|
771
|
+
return result;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
//#endregion
|
|
775
|
+
//#region src/rules/space-around-number/index.ts
|
|
776
|
+
const RULE_NAME$2 = "space-around-number";
|
|
777
|
+
var space_around_number_default = createRule({
|
|
778
|
+
name: RULE_NAME$2,
|
|
779
|
+
meta: {
|
|
780
|
+
type: "layout",
|
|
781
|
+
docs: { description: "Enforce a single space between CJK characters and numbers." },
|
|
782
|
+
messages: {
|
|
783
|
+
missingSpaceBefore: "Add a space before the number.",
|
|
784
|
+
missingSpaceAfter: "Add a space after the number.",
|
|
785
|
+
missingSpacesAround: "Add spaces before and after the number.",
|
|
786
|
+
unexpectedSpaceBefore: "Remove the unexpected space before the number.",
|
|
787
|
+
unexpectedSpaceAfter: "Remove the unexpected space after the number.",
|
|
788
|
+
unexpectedSpaceAround: "Remove the unexpected spaces around the number."
|
|
789
|
+
},
|
|
790
|
+
fixable: "whitespace",
|
|
791
|
+
schema: []
|
|
792
|
+
},
|
|
793
|
+
defaultOptions: [],
|
|
794
|
+
create(context) {
|
|
795
|
+
return { text(node) {
|
|
796
|
+
const { fixed, missingBefore, missingAfter, unexpectedBefore, unexpectedAfter } = fixBoundarySpace(node, isNumberType);
|
|
797
|
+
if (fixed === node.value) return;
|
|
798
|
+
context.report({
|
|
799
|
+
node,
|
|
800
|
+
messageId: getBoundarySpaceMessageId({
|
|
801
|
+
missingBefore,
|
|
802
|
+
missingAfter,
|
|
803
|
+
unexpectedBefore,
|
|
804
|
+
unexpectedAfter
|
|
805
|
+
}),
|
|
806
|
+
fix(fixer) {
|
|
807
|
+
return fixer.replaceText(node, fixed);
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
} };
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
//#endregion
|
|
815
|
+
//#region src/rules/space-around-word/index.ts
|
|
816
|
+
const RULE_NAME$1 = "space-around-word";
|
|
817
|
+
var space_around_word_default = createRule({
|
|
451
818
|
name: RULE_NAME$1,
|
|
452
819
|
meta: {
|
|
453
820
|
type: "layout",
|
|
454
|
-
docs: { description: "Enforce
|
|
821
|
+
docs: { description: "Enforce a single space between CJK characters and Latin words." },
|
|
455
822
|
messages: {
|
|
456
|
-
missingSpaceBefore: "
|
|
457
|
-
missingSpaceAfter: "
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
unexpectedSpaceAfter: "Do not add a space between the inline element and punctuation."
|
|
823
|
+
missingSpaceBefore: "Add a space before the word.",
|
|
824
|
+
missingSpaceAfter: "Add a space after the word.",
|
|
825
|
+
missingSpacesAround: "Add spaces before and after the word.",
|
|
826
|
+
unexpectedSpaceBefore: "Remove the unexpected space before the word.",
|
|
827
|
+
unexpectedSpaceAfter: "Remove the unexpected space after the word.",
|
|
828
|
+
unexpectedSpaceAround: "Remove the unexpected spaces around the word."
|
|
463
829
|
},
|
|
464
830
|
fixable: "whitespace",
|
|
465
831
|
schema: []
|
|
466
832
|
},
|
|
467
833
|
defaultOptions: [],
|
|
468
834
|
create(context) {
|
|
469
|
-
return {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
};
|
|
835
|
+
return { text(node) {
|
|
836
|
+
const { fixed, missingBefore, missingAfter, unexpectedBefore, unexpectedAfter } = fixBoundarySpace(node, isLatinWordType);
|
|
837
|
+
if (fixed === node.value) return;
|
|
838
|
+
context.report({
|
|
839
|
+
node,
|
|
840
|
+
messageId: getBoundarySpaceMessageId({
|
|
841
|
+
missingBefore,
|
|
842
|
+
missingAfter,
|
|
843
|
+
unexpectedBefore,
|
|
844
|
+
unexpectedAfter
|
|
845
|
+
}),
|
|
846
|
+
fix(fixer) {
|
|
847
|
+
return fixer.replaceText(node, fixed);
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
} };
|
|
486
851
|
}
|
|
487
852
|
});
|
|
488
853
|
|
|
@@ -578,7 +943,9 @@ var valid_heading_anchor_default = createRule({
|
|
|
578
943
|
//#endregion
|
|
579
944
|
//#region src/rules/index.ts
|
|
580
945
|
const rules = {
|
|
581
|
-
"space-
|
|
946
|
+
"space-around-inline-element": space_around_inline_element_default,
|
|
947
|
+
"space-around-number": space_around_number_default,
|
|
948
|
+
"space-around-word": space_around_word_default,
|
|
582
949
|
"valid-heading-anchor": valid_heading_anchor_default
|
|
583
950
|
};
|
|
584
951
|
|
|
@@ -589,15 +956,18 @@ const plugin = {
|
|
|
589
956
|
processors: markdown.processors,
|
|
590
957
|
languages: { gfm: new MarkdownLanguage({ mode: "gfm" }) }
|
|
591
958
|
};
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
|
|
959
|
+
const recommendedRules = {
|
|
960
|
+
"md-style/valid-heading-anchor": "error",
|
|
961
|
+
"md-style/space-around-inline-element": "error"
|
|
962
|
+
};
|
|
963
|
+
const allRules = Object.fromEntries(Object.keys(rules).map((ruleName) => [`md-style/${ruleName}`, "error"]));
|
|
595
964
|
const configs = {
|
|
596
965
|
recommended: {
|
|
597
966
|
name: "md-style/recommended",
|
|
598
967
|
files: ["**/*.md"],
|
|
599
968
|
plugins: { "md-style": plugin },
|
|
600
969
|
language: "md-style/gfm",
|
|
970
|
+
languageOptions: { frontmatter: "yaml" },
|
|
601
971
|
rules: recommendedRules
|
|
602
972
|
},
|
|
603
973
|
all: {
|
|
@@ -605,6 +975,7 @@ const configs = {
|
|
|
605
975
|
files: ["**/*.md"],
|
|
606
976
|
plugins: { "md-style": plugin },
|
|
607
977
|
language: "md-style/gfm",
|
|
978
|
+
languageOptions: { frontmatter: "yaml" },
|
|
608
979
|
rules: allRules
|
|
609
980
|
}
|
|
610
981
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-md-style",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"packageManager": "pnpm@10.21.0",
|
|
6
6
|
"description": "ESLint plugin for enforcing style rules in Markdown-based documentation",
|
|
7
7
|
"author": "noisefan <noisefan@163.com>",
|
|
@@ -18,8 +18,6 @@
|
|
|
18
18
|
".": "./dist/index.mjs",
|
|
19
19
|
"./package.json": "./package.json"
|
|
20
20
|
},
|
|
21
|
-
"main": "./dist/index.mjs",
|
|
22
|
-
"module": "./dist/index.mjs",
|
|
23
21
|
"types": "./dist/index.d.mts",
|
|
24
22
|
"files": [
|
|
25
23
|
"dist"
|
|
@@ -52,24 +50,25 @@
|
|
|
52
50
|
"optional": true
|
|
53
51
|
}
|
|
54
52
|
},
|
|
55
|
-
"dependencies": {},
|
|
56
53
|
"devDependencies": {
|
|
57
|
-
"@antfu/eslint-config": "^
|
|
58
|
-
"@eslint/markdown": "^
|
|
54
|
+
"@antfu/eslint-config": "^9.0.0",
|
|
55
|
+
"@eslint/markdown": "^8.0.1",
|
|
59
56
|
"@types/mdast": "^4.0.4",
|
|
60
|
-
"@types/node": "^
|
|
61
|
-
"@typescript-eslint/utils": "^8.
|
|
62
|
-
"@vitest/coverage-v8": "^4.1.
|
|
63
|
-
"bumpp": "^
|
|
64
|
-
"eslint": "
|
|
65
|
-
"eslint-
|
|
66
|
-
"eslint-
|
|
67
|
-
"
|
|
57
|
+
"@types/node": "^25.7.0",
|
|
58
|
+
"@typescript-eslint/utils": "^8.59.3",
|
|
59
|
+
"@vitest/coverage-v8": "^4.1.6",
|
|
60
|
+
"bumpp": "^11.1.0",
|
|
61
|
+
"eslint": "10.3.0",
|
|
62
|
+
"eslint-factory": "^0.1.2",
|
|
63
|
+
"eslint-plugin-format": "^2.0.1",
|
|
64
|
+
"eslint-plugin-md-style": "^0.1.0",
|
|
65
|
+
"eslint-vitest-rule-tester": "^3.1.0",
|
|
66
|
+
"lint-staged": "^17.0.4",
|
|
68
67
|
"simple-git-hooks": "^2.13.1",
|
|
69
68
|
"tinyglobby": "^0.2.16",
|
|
70
|
-
"tsdown": "^0.
|
|
71
|
-
"typescript": "^
|
|
72
|
-
"vitest": "^4.
|
|
69
|
+
"tsdown": "^0.22.0",
|
|
70
|
+
"typescript": "^6.0.3",
|
|
71
|
+
"vitest": "^4.1.6"
|
|
73
72
|
},
|
|
74
73
|
"simple-git-hooks": {
|
|
75
74
|
"pre-commit": "pnpx lint-staged"
|
|
@@ -78,5 +77,7 @@
|
|
|
78
77
|
"*.{js,ts,md,yml,yaml,json}": [
|
|
79
78
|
"eslint --cache --fix"
|
|
80
79
|
]
|
|
81
|
-
}
|
|
80
|
+
},
|
|
81
|
+
"main": "./dist/index.mjs",
|
|
82
|
+
"module": "./dist/index.mjs"
|
|
82
83
|
}
|