eslint-plugin-md-style 0.2.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/dist/index.mjs CHANGED
@@ -1,18 +1,8 @@
1
1
  import markdown, { MarkdownLanguage } from "@eslint/markdown";
2
-
3
- //#region src/utils/index.ts
4
- function createRule({ create, defaultOptions, meta }) {
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 !!node && typeof node === "object" && "children" in node && Array.isArray(node.children);
13
+ return isObject(node) && "children" in node && Array.isArray(node.children);
24
14
  }
25
- function isTableCell(node) {
26
- return node.type === "tableCell";
15
+ function isCodeNode(node) {
16
+ return node?.type === "code";
27
17
  }
28
- const INLINE_ELEMENT_TYPES = new Set([
29
- "link",
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);
40
- }
41
- /**
42
- * Checks whether the current inline element is nested inside another selected inline element.
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.
@@ -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,
@@ -114,11 +86,587 @@ function getAdjacentChar(str, position) {
114
86
  str = str.trim();
115
87
  return position === "head" ? str[0] : str[str.length - 1];
116
88
  }
117
-
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
+ });
118
666
  //#endregion
119
667
  //#region src/utils/anchor.ts
120
668
  /**
121
- * Match the trailing anchor-like fragment from a heading string.
669
+ * Match the trailing anchor-like fragment from a heading or adjacent text.
122
670
  * @example `中文标题 {#Chinese-Title}` -> `{#Chinese-Title}`
123
671
  * @example `使用 describe #Grouping Tests` -> `#Grouping Tests`
124
672
  */
@@ -138,52 +686,15 @@ function getLikeAnchor(str) {
138
686
  if (str === void 0) return null;
139
687
  const match = getLikeAnchorMatch(str);
140
688
  if (!match) return null;
141
- const rawLikeAnchor = match.replace(/(\{|\})/g, "").replace(/^#/, "").trimStart();
689
+ const rawLikeAnchor = match.replace(/[{}]/g, "").replace(/^#/, "").trimStart();
142
690
  return {
143
691
  isLikeAnchor: rawLikeAnchor.includes(" "),
144
692
  rawLikeAnchor
145
693
  };
146
694
  }
147
- /**
148
- * Check if the string has an anchor.
149
- * @example: {#chinese-anchor}
150
- */
151
- function isStrictAnchor(str) {
152
- return /\s\{#[a-z0-9]+(?:-[a-z0-9]+)*\}/.test(str);
153
- }
154
- /**
155
- * Check whether the string contains CJK Han characters.
156
- */
157
- function hasChinese(str) {
158
- return /\p{Script=Han}/u.test(str);
159
- }
160
- /**
161
- * Normalize raw anchor text into the strict anchor format content.
162
- * - lowercase all letters
163
- * - convert spaces to `-`
164
- * - remove unsupported characters
165
- * - trim leading/trailing `-`
166
- */
167
- function normalizeAnchor(anchor) {
168
- return anchor.toLowerCase().replace(/[\s.]+/g, "-").replace(/[^a-z0-9_-]/g, "").replace(/^-+|-+$/g, "");
169
- }
170
- /**
171
- * Count wrapper characters contributed by the trailing like-anchor fragment.
172
- * The value is the length difference between the raw matched fragment and the
173
- * cleaned anchor text returned by `getLikeAnchor`.
174
- * @example `# 中文标题 {#Chinese-Title}` -> 3
175
- * @example `## 使用 \`describe\` 编组测试 #Grouping Tests with \`describe\`` -> 1
176
- */
177
- function calcAnchorPositionCompensate(content) {
178
- const match = getLikeAnchorMatch(content);
179
- const anchor = getLikeAnchor(content);
180
- if (!match || !anchor) return 0;
181
- return match.length - anchor.rawLikeAnchor.length;
182
- }
183
-
184
695
  //#endregion
185
696
  //#region src/utils/punctuation.ts
186
- const OPENING_PAIRED_PUNCTUATION = new Set([
697
+ const OPENING_PAIRED_PUNCTUATION = /* @__PURE__ */ new Set([
187
698
  "(",
188
699
  "[",
189
700
  "{",
@@ -194,7 +705,7 @@ const OPENING_PAIRED_PUNCTUATION = new Set([
194
705
  "“",
195
706
  "‘"
196
707
  ]);
197
- const CLOSING_PAIRED_PUNCTUATION = new Set([
708
+ const CLOSING_PAIRED_PUNCTUATION = /* @__PURE__ */ new Set([
198
709
  ")",
199
710
  "]",
200
711
  "}",
@@ -262,17 +773,8 @@ function hasPunctuation(str, position = "head") {
262
773
  if (position === "head") return isPunctuation(str[0]);
263
774
  else return isPunctuation(str[str.length - 1]);
264
775
  }
265
-
266
776
  //#endregion
267
777
  //#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
- };
276
778
  /**
277
779
  * Gets the count and range of consecutive whitespace at the start or end of a string.
278
780
  * @example ` text`, `head` -> { count: 2, start: 0, end: 2 }
@@ -303,45 +805,8 @@ function getWhiteSpace(str, position = "head") {
303
805
  };
304
806
  }
305
807
  }
306
- /**
307
- * Gets whitespace and punctuation information for text adjacent to a link or inline code node.
308
- */
309
- function getSpaceContext(nodeContext) {
310
- const { prev, next } = nodeContext;
311
- const prevValue = getNodeValue(prev);
312
- const nextValue = getNodeValue(next);
313
- return {
314
- prev: {
315
- value: prevValue,
316
- whiteSpace: getWhiteSpace(prevValue, "tail"),
317
- hasPunctuation: hasPunctuation(prevValue, "tail"),
318
- punctuationType: isFullwidthPunctuation(getAdjacentChar(prevValue, "tail")) ? "full" : "half"
319
- },
320
- next: {
321
- value: nextValue,
322
- whiteSpace: getWhiteSpace(nextValue),
323
- hasPunctuation: hasPunctuation(nextValue),
324
- punctuationType: isFullwidthPunctuation(getAdjacentChar(nextValue, "head")) ? "full" : "half"
325
- }
326
- };
327
- }
328
-
329
808
  //#endregion
330
- //#region src/utils/inline-element.ts
331
- /**
332
- * Checks whether adjacent text is a custom container marker on the next line.
333
- *
334
- * @deprecated Temporary workaround to prevent space-between-link from reporting
335
- * false positives on custom containers. Remove this and handle the case in a
336
- * dedicated custom container rule when one exists.
337
- * @see https://vitepress.dev/guide/markdown#custom-containers
338
- * @example `\n:::` -> true
339
- * @example `\n::::` -> true
340
- * @example `:::` -> false
341
- */
342
- function isCustomContainerMarker(str) {
343
- return /^[ \t]*\n[ \t]*:{3,}[ \t]*$/u.test(str || "");
344
- }
809
+ //#region src/rules/space-around-inline-element/analyze.ts
345
810
  /**
346
811
  * Validates whether a spacing run contains exactly one required space.
347
812
  */
@@ -355,18 +820,18 @@ function validateSingleRequiredSpace(count, missingSpaceMessageId, multipleSpace
355
820
  function validateBeforePunctuation(context) {
356
821
  const adjacentChar = getAdjacentChar(context.value, "tail");
357
822
  if (OPENING_PAIRED_PUNCTUATION.has(adjacentChar || "") || isSlashPunctuation(adjacentChar)) {
358
- if (context.whiteSpace.count > 0) return MESSAGE_IDS$1.unexpectedSpaceBefore;
823
+ if (context.whiteSpace.count > 0) return "unexpected-space-before";
359
824
  return;
360
825
  }
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;
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";
363
828
  }
364
829
  /**
365
830
  * Validates the spacing between the previous node and the current inline node.
366
831
  */
367
832
  function validateSpaceBeforeNode(context) {
368
833
  if (context.hasPunctuation) return validateBeforePunctuation(context);
369
- return validateSingleRequiredSpace(context.whiteSpace.count, MESSAGE_IDS$1.missingSpaceBefore, MESSAGE_IDS$1.multipleSpacesBefore);
834
+ return validateSingleRequiredSpace(context.whiteSpace.count, "missing-space-before", "multiple-spaces-before");
370
835
  }
371
836
  /**
372
837
  * Validates spacing after an inline node when the next character is punctuation.
@@ -374,16 +839,55 @@ function validateSpaceBeforeNode(context) {
374
839
  function validateSpaceAfterPunctuation(context) {
375
840
  const adjacentChar = getAdjacentChar(context.value, "head");
376
841
  if (getLikeAnchor(context.value) || isCustomContainerMarker(context.value)) 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;
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";
380
845
  }
381
846
  /**
382
847
  * Validates the spacing between the current inline node and the next node.
383
848
  */
384
849
  function validateSpaceAfterNode(context) {
385
850
  if (context.hasPunctuation) return validateSpaceAfterPunctuation(context);
386
- return validateSingleRequiredSpace(context.whiteSpace.count, MESSAGE_IDS$1.missingSpaceAfter, MESSAGE_IDS$1.multipleSpacesAfter);
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
+ };
387
891
  }
388
892
  /**
389
893
  * Validates spacing around an inline element inside a table cell.
@@ -422,11 +926,10 @@ function validateSpace(nodeContext) {
422
926
  if (parent && isTableCell(parent)) return validateTableCellSpace(nodeContext);
423
927
  return validateDefaultSpace(nodeContext);
424
928
  }
425
-
426
929
  //#endregion
427
930
  //#region src/rules/space-around-inline-element/index.ts
428
- const RULE_NAME$3 = "space-around-inline-element";
429
- const MESSAGE_IDS$1 = {
931
+ const RULE_NAME$4 = "space-around-inline-element";
932
+ const MESSAGE_IDS$2 = {
430
933
  missingSpaceBefore: "missingSpaceBefore",
431
934
  missingSpaceAfter: "missingSpaceAfter",
432
935
  multipleSpacesBefore: "multipleSpacesBefore",
@@ -435,14 +938,23 @@ const MESSAGE_IDS$1 = {
435
938
  unexpectedSpaceBefore: "unexpectedSpaceBefore",
436
939
  unexpectedSpaceAfter: "unexpectedSpaceAfter"
437
940
  };
438
- const BEFORE_INLINE_ELEMENT_MESSAGE_IDS = new Set([
439
- MESSAGE_IDS$1.missingSpaceBefore,
440
- MESSAGE_IDS$1.multipleSpacesBefore,
441
- MESSAGE_IDS$1.multipleSpacesAfterPunctuation,
442
- MESSAGE_IDS$1.unexpectedSpaceBefore
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
443
955
  ]);
444
956
  var space_around_inline_element_default = createRule({
445
- name: RULE_NAME$3,
957
+ name: RULE_NAME$4,
446
958
  meta: {
447
959
  type: "layout",
448
960
  docs: { description: "Enforce spacing around Markdown inline elements." },
@@ -489,11 +1001,12 @@ function checkInlineElement(context, node) {
489
1001
  const nodeContext = getNodeContext(context, node);
490
1002
  if (isNestedInlineElement(nodeContext)) return;
491
1003
  const spaceContext = getSpaceContext(nodeContext);
492
- const messageId = validateSpace(nodeContext);
493
- if (!messageId) return;
1004
+ const issue = validateSpace(nodeContext);
1005
+ if (!issue) return;
1006
+ const messageId = ISSUE_TO_MESSAGE_ID[issue];
494
1007
  if (BEFORE_INLINE_ELEMENT_MESSAGE_IDS.has(messageId) && spaceContext.prev) {
495
1008
  const { count } = spaceContext.prev.whiteSpace;
496
- const replaceText = messageId === MESSAGE_IDS$1.unexpectedSpaceBefore ? "" : " ";
1009
+ const replaceText = messageId === MESSAGE_IDS$2.unexpectedSpaceBefore ? "" : " ";
497
1010
  context.report({
498
1011
  node,
499
1012
  messageId,
@@ -505,7 +1018,7 @@ function checkInlineElement(context, node) {
505
1018
  }
506
1019
  if (spaceContext.next) {
507
1020
  const { count } = spaceContext.next.whiteSpace;
508
- const replaceText = messageId === MESSAGE_IDS$1.unexpectedSpaceAfter ? "" : " ";
1021
+ const replaceText = messageId === MESSAGE_IDS$2.unexpectedSpaceAfter ? "" : " ";
509
1022
  context.report({
510
1023
  node,
511
1024
  messageId,
@@ -515,7 +1028,6 @@ function checkInlineElement(context, node) {
515
1028
  });
516
1029
  }
517
1030
  }
518
-
519
1031
  //#endregion
520
1032
  //#region src/utils/text/tokenizer.ts
521
1033
  const TEXT_TYPE = {
@@ -539,7 +1051,7 @@ const SYMBOL_RE = /^\p{Symbol}$/u;
539
1051
  const EMOJI_RE = /^\p{Extended_Pictographic}$/u;
540
1052
  const SPACE_RE = /^[\t\v\f \u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]$/u;
541
1053
  const NEWLINE_RE = /^[\n\r\u2028\u2029]$/u;
542
- const INVISIBLE_CODE_POINTS = new Set([
1054
+ const INVISIBLE_CODE_POINTS = /* @__PURE__ */ new Set([
543
1055
  173,
544
1056
  847,
545
1057
  1564,
@@ -560,15 +1072,13 @@ function isInvisible(char) {
560
1072
  const codePoint = char.codePointAt(0);
561
1073
  return codePoint != null && (INVISIBLE_CODE_POINTS.has(codePoint) || codePoint >= 8203 && codePoint <= 8207 || codePoint >= 8234 && codePoint <= 8238 || codePoint >= 8288 && codePoint <= 8303);
562
1074
  }
563
- function isLatinWordType(type) {
564
- return type === TEXT_TYPE.latin;
565
- }
566
- function isNumberType(type) {
567
- return type === TEXT_TYPE.number;
568
- }
569
1075
  function isNumber(char, prev) {
570
1076
  return NUMBER_RE.test(char) || prev?.type === TEXT_TYPE.number && (char === "." || char === "%");
571
1077
  }
1078
+ function isLatinWordType(type) {
1079
+ if (!type) return false;
1080
+ return type === "latin";
1081
+ }
572
1082
  const TEXT_TYPE_MATCHERS = [
573
1083
  {
574
1084
  type: TEXT_TYPE.newline,
@@ -621,15 +1131,16 @@ const DEFAULT_START_POINT = {
621
1131
  offset: 0
622
1132
  };
623
1133
  function advancePoint(point, char) {
1134
+ const { line, column, offset = 0 } = point;
624
1135
  if (NEWLINE_RE.test(char)) return {
625
- line: point.line + 1,
1136
+ line: line + 1,
626
1137
  column: 1,
627
- offset: point.offset + char.length
1138
+ offset: offset + char.length
628
1139
  };
629
1140
  return {
630
- line: point.line,
631
- column: point.column + char.length,
632
- offset: point.offset + char.length
1141
+ line,
1142
+ column: column + char.length,
1143
+ offset: offset + char.length
633
1144
  };
634
1145
  }
635
1146
  /**
@@ -694,9 +1205,8 @@ function buildTextNodeAst(node) {
694
1205
  node
695
1206
  };
696
1207
  }
697
-
698
1208
  //#endregion
699
- //#region src/utils/text/boundary-space.ts
1209
+ //#region src/rules/shared/text-boundary-spacing.ts
700
1210
  /**
701
1211
  * Normalizes an existing space token around the target token type.
702
1212
  * When multiple spaces are collapsed, the redundant side is recorded so the
@@ -740,12 +1250,12 @@ function processTargetToken(ctx, result) {
740
1250
  * collected missing or redundant space flags.
741
1251
  */
742
1252
  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;
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";
749
1259
  }
750
1260
  /**
751
1261
  * Rebuilds a text node with normalized spacing between CJK text and a target
@@ -770,12 +1280,8 @@ function fixBoundarySpace(node, isTargetType) {
770
1280
  }
771
1281
  return result;
772
1282
  }
773
-
774
- //#endregion
775
- //#region src/rules/space-around-number/index.ts
776
- const RULE_NAME$2 = "space-around-number";
777
1283
  var space_around_number_default = createRule({
778
- name: RULE_NAME$2,
1284
+ name: "space-around-number",
779
1285
  meta: {
780
1286
  type: "layout",
781
1287
  docs: { description: "Enforce a single space between CJK characters and numbers." },
@@ -793,7 +1299,7 @@ var space_around_number_default = createRule({
793
1299
  defaultOptions: [],
794
1300
  create(context) {
795
1301
  return { text(node) {
796
- const { fixed, missingBefore, missingAfter, unexpectedBefore, unexpectedAfter } = fixBoundarySpace(node, isNumberType);
1302
+ const { fixed, missingBefore, missingAfter, unexpectedBefore, unexpectedAfter } = fixBoundarySpace(node, (type) => type === "number");
797
1303
  if (fixed === node.value) return;
798
1304
  context.report({
799
1305
  node,
@@ -803,19 +1309,13 @@ var space_around_number_default = createRule({
803
1309
  unexpectedBefore,
804
1310
  unexpectedAfter
805
1311
  }),
806
- fix(fixer) {
807
- return fixer.replaceText(node, fixed);
808
- }
1312
+ fix: (fixer) => fixer.replaceText(node, fixed)
809
1313
  });
810
1314
  } };
811
1315
  }
812
1316
  });
813
-
814
- //#endregion
815
- //#region src/rules/space-around-word/index.ts
816
- const RULE_NAME$1 = "space-around-word";
817
1317
  var space_around_word_default = createRule({
818
- name: RULE_NAME$1,
1318
+ name: "space-around-word",
819
1319
  meta: {
820
1320
  type: "layout",
821
1321
  docs: { description: "Enforce a single space between CJK characters and Latin words." },
@@ -850,20 +1350,121 @@ var space_around_word_default = createRule({
850
1350
  } };
851
1351
  }
852
1352
  });
853
-
854
1353
  //#endregion
855
- //#region src/utils/markdown.ts
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
+ });
1394
+ }
1395
+ } };
1396
+ }
1397
+ });
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
+ }
1455
+ //#endregion
1456
+ //#region src/parser/markdown.ts
856
1457
  const language = new MarkdownLanguage({ mode: "gfm" });
857
1458
  /**
858
1459
  * Parses Markdown with the same GFM language implementation used by the
859
1460
  * plugin tests and returns both the mdast tree and ESLint SourceCode wrapper.
860
1461
  */
861
- function parseMarkdown(markdown$1) {
1462
+ function parseMarkdown(markdown) {
862
1463
  const file = {
863
1464
  path: "test.md",
864
1465
  physicalPath: "test.md",
865
1466
  bom: false,
866
- body: markdown$1
1467
+ body: markdown
867
1468
  };
868
1469
  const parseResult = language.parse(file, { languageOptions: {
869
1470
  ...language.defaultLanguageOptions,
@@ -876,18 +1477,16 @@ function parseMarkdown(markdown$1) {
876
1477
  sourceCode: language.createSourceCode(file, parseResult)
877
1478
  };
878
1479
  }
879
-
880
1480
  //#endregion
881
- //#region src/utils/heading.ts
1481
+ //#region src/rules/valid-heading-anchor/frontmatter.ts
882
1482
  /**
883
1483
  * Returns true when the Markdown document starts with YAML frontmatter.
884
1484
  */
885
- function hasFrontmatter(markdown$1, prevNode) {
886
- if (prevNode?.type === "thematicBreak") markdown$1 = `---\n${markdown$1}`;
887
- const { ast } = parseMarkdown(markdown$1);
1485
+ function hasFrontmatter(markdown, prevNode) {
1486
+ if (prevNode?.type === "thematicBreak") markdown = `---\n${markdown}`;
1487
+ const { ast } = parseMarkdown(markdown);
888
1488
  return ast.children[0]?.type === "yaml";
889
1489
  }
890
-
891
1490
  //#endregion
892
1491
  //#region src/rules/valid-heading-anchor/index.ts
893
1492
  const RULE_NAME = "valid-heading-anchor";
@@ -895,60 +1494,60 @@ const MESSAGE_IDS = {
895
1494
  missingAnchor: "missingAnchor",
896
1495
  invalidHeadingAnchor: "invalidHeadingAnchor"
897
1496
  };
898
- var valid_heading_anchor_default = createRule({
899
- name: RULE_NAME,
900
- meta: {
901
- type: "layout",
902
- docs: { description: "Require strict lowercase anchors for headings that contain CJK text." },
903
- messages: {
904
- missingAnchor: "Non-ASCII heading must have an anchor in the format \"{#lowercase-anchor}\".",
905
- invalidHeadingAnchor: "Anchor must use lowercase letters and valid characters only."
906
- },
907
- fixable: "whitespace",
908
- schema: []
909
- },
910
- defaultOptions: [],
911
- create(context) {
912
- return { heading(node) {
913
- const { position, start, end } = getNodePosition(node);
914
- /* v8 ignore if -- @preserve */
915
- if (!position) return;
916
- const source = context.sourceCode.text.slice(start, end);
917
- if (isStrictAnchor(source) || !hasChinese(source)) return;
918
- if (hasFrontmatter(source, getNodeContext(context, node).prev)) return;
919
- const liked = getLikeAnchor(source);
920
- if (!liked) {
921
- context.report({
922
- node,
923
- messageId: MESSAGE_IDS.missingAnchor
924
- });
925
- return;
926
- }
927
- const { rawLikeAnchor, isLikeAnchor } = liked;
928
- const compensate = calcAnchorPositionCompensate(source);
929
- const remainingContent = source.slice(0, -rawLikeAnchor.length - compensate).trim();
930
- const anchor = normalizeAnchor(rawLikeAnchor);
931
- if (rawLikeAnchor === anchor) return;
932
- context.report({
933
- node,
934
- messageId: isLikeAnchor ? MESSAGE_IDS.missingAnchor : MESSAGE_IDS.invalidHeadingAnchor,
935
- fix(fixer) {
936
- return fixer.replaceTextRange([start, end], `${remainingContent} {#${anchor}}`);
937
- }
938
- });
939
- } };
940
- }
941
- });
942
-
943
1497
  //#endregion
944
1498
  //#region src/rules/index.ts
945
1499
  const rules = {
1500
+ "padding-around-custom-container": padding_around_custom_container_default,
946
1501
  "space-around-inline-element": space_around_inline_element_default,
947
1502
  "space-around-number": space_around_number_default,
948
1503
  "space-around-word": space_around_word_default,
949
- "valid-heading-anchor": valid_heading_anchor_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: []
1517
+ },
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;
1540
+ context.report({
1541
+ node,
1542
+ messageId: isLikeAnchor ? MESSAGE_IDS.missingAnchor : MESSAGE_IDS.invalidHeadingAnchor,
1543
+ fix(fixer) {
1544
+ return fixer.replaceTextRange([start, end], `${remainingContent} {#${anchor}}`);
1545
+ }
1546
+ });
1547
+ } };
1548
+ }
1549
+ })
950
1550
  };
951
-
952
1551
  //#endregion
953
1552
  //#region src/index.ts
954
1553
  const plugin = {
@@ -958,7 +1557,9 @@ const plugin = {
958
1557
  };
959
1558
  const recommendedRules = {
960
1559
  "md-style/valid-heading-anchor": "error",
961
- "md-style/space-around-inline-element": "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"
962
1563
  };
963
1564
  const allRules = Object.fromEntries(Object.keys(rules).map((ruleName) => [`md-style/${ruleName}`, "error"]));
964
1565
  const configs = {
@@ -980,7 +1581,5 @@ const configs = {
980
1581
  }
981
1582
  };
982
1583
  const mdStylePlugin = Object.assign(plugin, { configs });
983
- var src_default = mdStylePlugin;
984
-
985
1584
  //#endregion
986
- export { configs, src_default as default, plugin };
1585
+ export { configs, mdStylePlugin as default, plugin };