markdownlint 0.24.0 → 0.26.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.
Files changed (51) hide show
  1. package/CONTRIBUTING.md +15 -3
  2. package/LICENSE +1 -1
  3. package/README.md +138 -87
  4. package/demo/markdownlint-browser.js +2028 -1364
  5. package/doc/CustomRules.md +15 -4
  6. package/doc/Rules.md +217 -26
  7. package/helpers/LICENSE +1 -1
  8. package/helpers/helpers.js +541 -188
  9. package/helpers/package.json +4 -1
  10. package/lib/cache.js +15 -25
  11. package/lib/constants.js +7 -0
  12. package/lib/markdownlint.d.ts +27 -19
  13. package/lib/markdownlint.js +438 -344
  14. package/lib/md004.js +4 -4
  15. package/lib/md005.js +4 -4
  16. package/lib/md006.js +4 -4
  17. package/lib/md007.js +8 -6
  18. package/lib/md009.js +20 -13
  19. package/lib/md010.js +34 -16
  20. package/lib/md011.js +4 -4
  21. package/lib/md013.js +4 -5
  22. package/lib/md014.js +4 -4
  23. package/lib/md021.js +3 -1
  24. package/lib/md026.js +1 -1
  25. package/lib/md027.js +2 -2
  26. package/lib/md028.js +2 -2
  27. package/lib/md029.js +5 -4
  28. package/lib/md030.js +4 -4
  29. package/lib/md031.js +2 -2
  30. package/lib/md032.js +3 -2
  31. package/lib/md033.js +12 -9
  32. package/lib/md034.js +2 -2
  33. package/lib/md035.js +10 -5
  34. package/lib/md036.js +2 -2
  35. package/lib/md037.js +5 -3
  36. package/lib/md038.js +7 -4
  37. package/lib/md039.js +8 -5
  38. package/lib/md042.js +17 -10
  39. package/lib/md043.js +2 -3
  40. package/lib/md044.js +21 -10
  41. package/lib/md046.js +14 -13
  42. package/lib/md048.js +13 -14
  43. package/lib/md049-md050.js +73 -0
  44. package/lib/md051.js +84 -0
  45. package/lib/md052.js +35 -0
  46. package/lib/md053.js +52 -0
  47. package/lib/rules.js +8 -6
  48. package/package.json +37 -32
  49. package/schema/.markdownlint.jsonc +30 -3
  50. package/schema/.markdownlint.yaml +26 -1
  51. package/schema/markdownlint-config-schema.json +159 -683
@@ -2,8 +2,6 @@
2
2
 
3
3
  "use strict";
4
4
 
5
- const os = require("os");
6
-
7
5
  // Regular expression for matching common newline characters
8
6
  // See NEWLINES_RE in markdown-it/lib/rules_core/normalize.js
9
7
  const newLineRe = /\r\n?|\n/g;
@@ -14,11 +12,15 @@ module.exports.frontMatterRe =
14
12
  // eslint-disable-next-line max-len
15
13
  /((^---\s*$[^]*?^---\s*$)|(^\+\+\+\s*$[^]*?^(\+\+\+|\.\.\.)\s*$)|(^\{\s*$[^]*?^\}\s*$))(\r\n|\r|\n|$)/m;
16
14
 
17
- // Regular expression for matching inline disable/enable comments
18
- const inlineCommentRe =
15
+ // Regular expression for matching the start of inline disable/enable comments
16
+ const inlineCommentStartRe =
19
17
  // eslint-disable-next-line max-len
20
- /<!--\s*markdownlint-(?:(?:(disable|enable|capture|restore|disable-file|enable-file|disable-next-line)((?:\s+[a-z0-9_-]+)*))|(?:(configure-file)\s+([\s\S]*?)))\s*-->/ig;
21
- module.exports.inlineCommentRe = inlineCommentRe;
18
+ /(<!--\s*markdownlint-(disable|enable|capture|restore|disable-file|enable-file|disable-line|disable-next-line|configure-file))(?:\s|-->)/ig;
19
+ module.exports.inlineCommentStartRe = inlineCommentStartRe;
20
+
21
+ // Regular expression for matching HTML elements
22
+ const htmlElementRe = /<(([A-Za-z][A-Za-z0-9-]*)(?:\s[^`>]*)?)\/?>/g;
23
+ module.exports.htmlElementRe = htmlElementRe;
22
24
 
23
25
  // Regular expressions for range matching
24
26
  module.exports.bareUrlRe = /(?:http|ftp)s?:\/\/[^\s\]"']*(?:\/|[^\s\]"'\W])/ig;
@@ -28,12 +30,13 @@ module.exports.orderedListItemMarkerRe = /^[\s>]*0*(\d+)[.)]/;
28
30
  // Regular expression for all instances of emphasis markers
29
31
  const emphasisMarkersRe = /[_*]/g;
30
32
 
31
- // Regular expression for inline links and shortcut reference links
32
- const linkRe = /(\[(?:[^[\]]|\[[^\]]*\])*\])(\(\S*\)|\[\S*\])?/g;
33
- module.exports.linkRe = linkRe;
33
+ // Regular expression for reference links (full and collapsed but not shortcut)
34
+ const referenceLinkRe =
35
+ /!?\\?\[((?:\[[^\]\0]*]|[^\]\0])*)](?:(?:\[([^\]\0]*)\])|[^(]|$)/g;
34
36
 
35
- // Regular expression for link reference definition lines
36
- module.exports.linkReferenceRe = /^ {0,3}\[[^\]]+]:\s.*$/;
37
+ // Regular expression for link reference definitions
38
+ const linkReferenceDefinitionRe = /^ {0,3}\[([^\]]*[^\\])]:/;
39
+ module.exports.linkReferenceDefinitionRe = linkReferenceDefinitionRe;
37
40
 
38
41
  // All punctuation characters (normal and full-width)
39
42
  const allPunctuation = ".,;:!?。,;:!?";
@@ -62,14 +65,43 @@ module.exports.isObject = function isObject(obj) {
62
65
  return (obj !== null) && (typeof obj === "object") && !Array.isArray(obj);
63
66
  };
64
67
 
65
- // Returns true iff the input line is blank (no content)
66
- // Example: Contains nothing, whitespace, or comments
67
- const blankLineRe = />|(?:<!--.*?-->)/g;
68
- module.exports.isBlankLine = function isBlankLine(line) {
69
- // Call to String.replace follows best practices and is not a security check
70
- // False-positive for js/incomplete-multi-character-sanitization
71
- return !line || !line.trim() || !line.replace(blankLineRe, "").trim();
72
- };
68
+ /**
69
+ * Returns true iff the input line is blank (contains nothing, whitespace, or
70
+ * comments (unclosed start/end comments allowed)).
71
+ *
72
+ * @param {string} line Input line.
73
+ * @returns {boolean} True iff line is blank.
74
+ */
75
+ function isBlankLine(line) {
76
+ const startComment = "<!--";
77
+ const endComment = "-->";
78
+ const removeComments = (s) => {
79
+ // eslint-disable-next-line no-constant-condition
80
+ while (true) {
81
+ const start = s.indexOf(startComment);
82
+ const end = s.indexOf(endComment);
83
+ if ((end !== -1) && ((start === -1) || (end < start))) {
84
+ // Unmatched end comment is first
85
+ s = s.slice(end + endComment.length);
86
+ } else if ((start !== -1) && (end !== -1)) {
87
+ // Start comment is before end comment
88
+ s = s.slice(0, start) + s.slice(end + endComment.length);
89
+ } else if ((start !== -1) && (end === -1)) {
90
+ // Unmatched start comment is last
91
+ s = s.slice(0, start);
92
+ } else {
93
+ // No more comments to remove
94
+ return s;
95
+ }
96
+ }
97
+ };
98
+ return (
99
+ !line ||
100
+ !line.trim() ||
101
+ !removeComments(line).replace(/>/g, "").trim()
102
+ );
103
+ }
104
+ module.exports.isBlankLine = isBlankLine;
73
105
 
74
106
  /**
75
107
  * Compare function for Array.prototype.sort for ascending order of numbers.
@@ -129,16 +161,10 @@ module.exports.clearHtmlCommentText = function clearHtmlCommentText(text) {
129
161
  !content.endsWith("-") && !content.includes("--"));
130
162
  // If a valid block/inline comment...
131
163
  if (isValid) {
132
- const inlineCommentIndex = text
133
- .slice(i, j + htmlCommentEnd.length)
134
- .search(inlineCommentRe);
135
- // If not a markdownlint inline directive...
136
- if (inlineCommentIndex === -1) {
137
- text =
138
- text.slice(0, i + htmlCommentBegin.length) +
139
- content.replace(/[^\r\n]/g, ".") +
140
- text.slice(j);
141
- }
164
+ text =
165
+ text.slice(0, i + htmlCommentBegin.length) +
166
+ content.replace(/[^\r\n]/g, ".") +
167
+ text.slice(j);
142
168
  }
143
169
  }
144
170
  }
@@ -181,6 +207,22 @@ module.exports.fencedCodeBlockStyleFor =
181
207
  }
182
208
  };
183
209
 
210
+ /**
211
+ * Return the string representation of a emphasis or strong markup character.
212
+ *
213
+ * @param {string} markup Emphasis or strong string.
214
+ * @returns {string} String representation.
215
+ */
216
+ module.exports.emphasisOrStrongStyleFor =
217
+ function emphasisOrStrongStyleFor(markup) {
218
+ switch (markup[0]) {
219
+ case "*":
220
+ return "asterisk";
221
+ default:
222
+ return "underscore";
223
+ }
224
+ };
225
+
184
226
  /**
185
227
  * Return the number of characters of indent for a token.
186
228
  *
@@ -231,11 +273,11 @@ module.exports.unorderedListStyleFor = function unorderedListStyleFor(token) {
231
273
  * @returns {void}
232
274
  */
233
275
  function filterTokens(params, type, handler) {
234
- params.tokens.forEach(function forToken(token) {
276
+ for (const token of params.tokens) {
235
277
  if (token.type === type) {
236
278
  handler(token);
237
279
  }
238
- });
280
+ }
239
281
  }
240
282
  module.exports.filterTokens = filterTokens;
241
283
 
@@ -247,11 +289,12 @@ module.exports.filterTokens = filterTokens;
247
289
  */
248
290
  function isMathBlock(token) {
249
291
  return (
250
- (token.tag === "math") &&
292
+ ((token.tag === "$$") || (token.tag === "math")) &&
251
293
  token.type.startsWith("math_block") &&
252
294
  !token.type.endsWith("_end")
253
295
  );
254
296
  }
297
+ module.exports.isMathBlock = isMathBlock;
255
298
 
256
299
  // Get line metadata array
257
300
  module.exports.getLineMetadata = function getLineMetadata(params) {
@@ -285,22 +328,28 @@ module.exports.getLineMetadata = function getLineMetadata(params) {
285
328
  filterTokens(params, "hr", (token) => {
286
329
  lineMetadata[token.map[0]][6] = true;
287
330
  });
288
- params.tokens.filter(isMathBlock).forEach((token) => {
331
+ for (const token of params.tokens.filter(isMathBlock)) {
289
332
  for (let i = token.map[0]; i < token.map[1]; i++) {
290
333
  lineMetadata[i][7] = true;
291
334
  }
292
- });
335
+ }
293
336
  return lineMetadata;
294
337
  };
295
338
 
296
- // Calls the provided function for each line (with context)
297
- module.exports.forEachLine = function forEachLine(lineMetadata, handler) {
298
- lineMetadata.forEach(function forMetadata(metadata) {
299
- // Parameters:
300
- // line, lineIndex, inCode, onFence, inTable, inItem, inBreak, inMath
339
+ /**
340
+ * Calls the provided function for each line.
341
+ *
342
+ * @param {Object} lineMetadata Line metadata object.
343
+ * @param {Function} handler Function taking (line, lineIndex, inCode, onFence,
344
+ * inTable, inItem, inBreak, inMath).
345
+ * @returns {void}
346
+ */
347
+ function forEachLine(lineMetadata, handler) {
348
+ for (const metadata of lineMetadata) {
301
349
  handler(...metadata);
302
- });
303
- };
350
+ }
351
+ }
352
+ module.exports.forEachLine = forEachLine;
304
353
 
305
354
  // Returns (nested) lists as a flat array (in order)
306
355
  module.exports.flattenLists = function flattenLists(tokens) {
@@ -310,11 +359,7 @@ module.exports.flattenLists = function flattenLists(tokens) {
310
359
  let nesting = 0;
311
360
  const nestingStack = [];
312
361
  let lastWithMap = { "map": [ 0, 1 ] };
313
- tokens.forEach((token) => {
314
- if (isMathBlock(token) && token.map[1]) {
315
- // markdown-it-texmath plugin does not account for math_block_end
316
- token.map[1]++;
317
- }
362
+ for (const token of tokens) {
318
363
  if ((token.type === "bullet_list_open") ||
319
364
  (token.type === "ordered_list_open")) {
320
365
  // Save current context and start a new one
@@ -347,12 +392,13 @@ module.exports.flattenLists = function flattenLists(tokens) {
347
392
  nestingStack.push(nesting);
348
393
  nesting = 0;
349
394
  } else if (token.type === "blockquote_close") {
350
- nesting = nestingStack.pop();
351
- } else if (token.map) {
395
+ nesting = nestingStack.pop() || 0;
396
+ }
397
+ if (token.map) {
352
398
  // Track last token with map
353
399
  lastWithMap = token;
354
400
  }
355
- });
401
+ }
356
402
  return flattenedLists;
357
403
  };
358
404
 
@@ -360,114 +406,102 @@ module.exports.flattenLists = function flattenLists(tokens) {
360
406
  module.exports.forEachInlineChild =
361
407
  function forEachInlineChild(params, type, handler) {
362
408
  filterTokens(params, "inline", function forToken(token) {
363
- token.children.forEach(function forChild(child) {
409
+ for (const child of token.children) {
364
410
  if (child.type === type) {
365
411
  handler(child, token);
366
412
  }
367
- });
413
+ }
368
414
  });
369
415
  };
370
416
 
371
417
  // Calls the provided function for each heading's content
372
418
  module.exports.forEachHeading = function forEachHeading(params, handler) {
373
419
  let heading = null;
374
- params.tokens.forEach(function forToken(token) {
420
+ for (const token of params.tokens) {
375
421
  if (token.type === "heading_open") {
376
422
  heading = token;
377
423
  } else if (token.type === "heading_close") {
378
424
  heading = null;
379
425
  } else if ((token.type === "inline") && heading) {
380
- handler(heading, token.content);
426
+ handler(heading, token.content, token);
381
427
  }
382
- });
428
+ }
383
429
  };
384
430
 
385
431
  /**
386
432
  * Calls the provided function for each inline code span's content.
387
433
  *
388
434
  * @param {string} input Markdown content.
389
- * @param {Function} handler Callback function.
435
+ * @param {Function} handler Callback function taking (code, lineIndex,
436
+ * columnIndex, ticks).
390
437
  * @returns {void}
391
438
  */
392
439
  function forEachInlineCodeSpan(input, handler) {
393
- let currentLine = 0;
394
- let currentColumn = 0;
395
- let index = 0;
396
- while (index < input.length) {
397
- let startIndex = -1;
398
- let startLine = -1;
399
- let startColumn = -1;
400
- let tickCount = 0;
401
- let currentTicks = 0;
402
- let state = "normal";
403
- // Deliberate <= so trailing 0 completes the last span (ex: "text `code`")
404
- // False-positive for js/index-out-of-bounds
405
- for (; index <= input.length; index++) {
406
- const char = input[index];
407
- // Ignore backticks in link destination
408
- if ((char === "[") && (state === "normal")) {
409
- state = "linkTextOpen";
410
- } else if ((char === "]") && (state === "linkTextOpen")) {
411
- state = "linkTextClosed";
412
- } else if ((char === "(") && (state === "linkTextClosed")) {
413
- state = "linkDestinationOpen";
414
- } else if (
415
- ((char === "(") && (state === "linkDestinationOpen")) ||
416
- ((char === ")") && (state === "linkDestinationOpen")) ||
417
- (state === "linkTextClosed")) {
418
- state = "normal";
419
- }
420
- // Parse backtick open/close
421
- if ((char === "`") && (state !== "linkDestinationOpen")) {
422
- // Count backticks at start or end of code span
423
- currentTicks++;
424
- if ((startIndex === -1) || (startColumn === -1)) {
425
- startIndex = index + 1;
426
- }
427
- } else {
428
- if ((startIndex >= 0) &&
429
- (startColumn >= 0) &&
430
- (tickCount === currentTicks)) {
431
- // Found end backticks; invoke callback for code span
440
+ const backtickRe = /`+/g;
441
+ let match = null;
442
+ const backticksLengthAndIndex = [];
443
+ while ((match = backtickRe.exec(input)) !== null) {
444
+ backticksLengthAndIndex.push([ match[0].length, match.index ]);
445
+ }
446
+ const newLinesIndex = [];
447
+ while ((match = newLineRe.exec(input)) !== null) {
448
+ newLinesIndex.push(match.index);
449
+ }
450
+ let lineIndex = 0;
451
+ let lineStartIndex = 0;
452
+ let k = 0;
453
+ for (let i = 0; i < backticksLengthAndIndex.length - 1; i++) {
454
+ const [ startLength, startIndex ] = backticksLengthAndIndex[i];
455
+ if ((startIndex === 0) || (input[startIndex - 1] !== "\\")) {
456
+ for (let j = i + 1; j < backticksLengthAndIndex.length; j++) {
457
+ const [ endLength, endIndex ] = backticksLengthAndIndex[j];
458
+ if (startLength === endLength) {
459
+ for (; k < newLinesIndex.length; k++) {
460
+ const newLineIndex = newLinesIndex[k];
461
+ if (startIndex < newLineIndex) {
462
+ break;
463
+ }
464
+ lineIndex++;
465
+ lineStartIndex = newLineIndex + 1;
466
+ }
467
+ const columnIndex = startIndex - lineStartIndex + startLength;
432
468
  handler(
433
- input.substring(startIndex, index - currentTicks),
434
- startLine, startColumn, tickCount);
435
- startIndex = -1;
436
- startColumn = -1;
437
- } else if ((startIndex >= 0) && (startColumn === -1)) {
438
- // Found start backticks
439
- tickCount = currentTicks;
440
- startLine = currentLine;
441
- startColumn = currentColumn;
469
+ input.slice(startIndex + startLength, endIndex),
470
+ lineIndex,
471
+ columnIndex,
472
+ startLength
473
+ );
474
+ i = j;
475
+ break;
442
476
  }
443
- // Not in backticks
444
- currentTicks = 0;
445
- }
446
- if (char === "\n") {
447
- // On next line
448
- currentLine++;
449
- currentColumn = 0;
450
- } else if ((char === "\\") &&
451
- ((startIndex === -1) || (startColumn === -1)) &&
452
- (input[index + 1] !== "\n")) {
453
- // Escape character outside code, skip next
454
- index++;
455
- currentColumn += 2;
456
- } else {
457
- // On next column
458
- currentColumn++;
459
477
  }
460
478
  }
461
- if (startIndex >= 0) {
462
- // Restart loop after unmatched start backticks (ex: "`text``code``")
463
- index = startIndex;
464
- currentLine = startLine;
465
- currentColumn = startColumn;
466
- }
467
479
  }
468
480
  }
469
481
  module.exports.forEachInlineCodeSpan = forEachInlineCodeSpan;
470
482
 
483
+ /**
484
+ * Adds ellipsis to the left/right/middle of the specified text.
485
+ *
486
+ * @param {string} text Text to ellipsify.
487
+ * @param {boolean} [start] True iff the start of the text is important.
488
+ * @param {boolean} [end] True iff the end of the text is important.
489
+ * @returns {string} Ellipsified text.
490
+ */
491
+ function ellipsify(text, start, end) {
492
+ if (text.length <= 30) {
493
+ // Nothing to do
494
+ } else if (start && end) {
495
+ text = text.slice(0, 15) + "..." + text.slice(-15);
496
+ } else if (end) {
497
+ text = "..." + text.slice(-30);
498
+ } else {
499
+ text = text.slice(0, 30) + "...";
500
+ }
501
+ return text;
502
+ }
503
+ module.exports.ellipsify = ellipsify;
504
+
471
505
  /**
472
506
  * Adds a generic error object via the onError callback.
473
507
  *
@@ -508,58 +542,83 @@ module.exports.addErrorDetailIf = function addErrorDetailIf(
508
542
  // Adds an error object with context via the onError callback
509
543
  module.exports.addErrorContext = function addErrorContext(
510
544
  onError, lineNumber, context, left, right, range, fixInfo) {
511
- if (context.length <= 30) {
512
- // Nothing to do
513
- } else if (left && right) {
514
- context = context.substr(0, 15) + "..." + context.substr(-15);
515
- } else if (right) {
516
- context = "..." + context.substr(-30);
517
- } else {
518
- context = context.substr(0, 30) + "...";
519
- }
520
- addError(onError, lineNumber, null, context, range, fixInfo);
545
+ context = ellipsify(context, left, right);
546
+ addError(onError, lineNumber, undefined, context, range, fixInfo);
521
547
  };
522
548
 
523
549
  /**
524
- * Returns an array of code span ranges.
550
+ * Returns an array of code block and span content ranges.
525
551
  *
526
- * @param {string[]} lines Lines to scan for code span ranges.
527
- * @returns {number[][]} Array of ranges (line, index, length).
552
+ * @param {Object} params RuleParams instance.
553
+ * @param {Object} lineMetadata Line metadata object.
554
+ * @returns {number[][]} Array of ranges (lineIndex, columnIndex, length).
528
555
  */
529
- module.exports.inlineCodeSpanRanges = (lines) => {
556
+ module.exports.codeBlockAndSpanRanges = (params, lineMetadata) => {
530
557
  const exclusions = [];
531
- forEachInlineCodeSpan(
532
- lines.join("\n"),
533
- (code, lineIndex, columnIndex) => {
534
- const codeLines = code.split(newLineRe);
535
- // eslint-disable-next-line unicorn/no-for-loop
536
- for (let i = 0; i < codeLines.length; i++) {
537
- exclusions.push(
538
- [ lineIndex + i, columnIndex, codeLines[i].length ]
539
- );
540
- columnIndex = 0;
541
- }
558
+ // Add code block ranges (excludes fences)
559
+ forEachLine(lineMetadata, (line, lineIndex, inCode, onFence) => {
560
+ if (inCode && !onFence) {
561
+ exclusions.push([ lineIndex, 0, line.length ]);
542
562
  }
543
- );
563
+ });
564
+ // Add code span ranges (excludes ticks)
565
+ filterTokens(params, "inline", (token) => {
566
+ if (token.children.some((child) => child.type === "code_inline")) {
567
+ const tokenLines = params.lines.slice(token.map[0], token.map[1]);
568
+ forEachInlineCodeSpan(
569
+ tokenLines.join("\n"),
570
+ (code, lineIndex, columnIndex) => {
571
+ const codeLines = code.split(newLineRe);
572
+ for (const [ i, line ] of codeLines.entries()) {
573
+ exclusions.push([
574
+ token.lineNumber - 1 + lineIndex + i,
575
+ i ? 0 : columnIndex,
576
+ line.length
577
+ ]);
578
+ }
579
+ }
580
+ );
581
+ }
582
+ });
583
+ return exclusions;
584
+ };
585
+
586
+ /**
587
+ * Returns an array of HTML element ranges.
588
+ *
589
+ * @param {Object} params RuleParams instance.
590
+ * @param {Object} lineMetadata Line metadata object.
591
+ * @returns {number[][]} Array of ranges (lineIndex, columnIndex, length).
592
+ */
593
+ module.exports.htmlElementRanges = (params, lineMetadata) => {
594
+ const exclusions = [];
595
+ forEachLine(lineMetadata, (line, lineIndex, inCode) => {
596
+ let match = null;
597
+ // eslint-disable-next-line no-unmodified-loop-condition
598
+ while (!inCode && ((match = htmlElementRe.exec(line)) !== null)) {
599
+ exclusions.push([ lineIndex, match.index, match[0].length ]);
600
+ }
601
+ });
544
602
  return exclusions;
545
603
  };
546
604
 
547
605
  /**
548
- * Determines whether the specified range overlaps another range.
606
+ * Determines whether the specified range is within another range.
549
607
  *
550
608
  * @param {number[][]} ranges Array of ranges (line, index, length).
551
609
  * @param {number} lineIndex Line index to check.
552
610
  * @param {number} index Index to check.
553
611
  * @param {number} length Length to check.
554
- * @returns {boolean} True iff the specified range overlaps.
612
+ * @returns {boolean} True iff the specified range is within.
555
613
  */
556
- module.exports.overlapsAnyRange = (ranges, lineIndex, index, length) => (
614
+ const withinAnyRange = (ranges, lineIndex, index, length) => (
557
615
  !ranges.every((span) => (
558
616
  (lineIndex !== span[0]) ||
559
- (index + length < span[1]) ||
560
- (index > span[1] + span[2])
617
+ (index < span[1]) ||
618
+ (index + length > span[1] + span[2])
561
619
  ))
562
620
  );
621
+ module.exports.withinAnyRange = withinAnyRange;
563
622
 
564
623
  // Returns a range object for a line by applying a RegExp
565
624
  module.exports.rangeFromRegExp = function rangeFromRegExp(line, regexp) {
@@ -587,6 +646,82 @@ module.exports.frontMatterHasTitle =
587
646
  frontMatterLines.some((line) => frontMatterTitleRe.test(line));
588
647
  };
589
648
 
649
+ /**
650
+ * Calls the provided function for each link.
651
+ *
652
+ * @param {string} line Line of Markdown input.
653
+ * @param {Function} handler Function taking (index, link, text, destination).
654
+ * @returns {void}
655
+ */
656
+ function forEachLink(line, handler) {
657
+ // Helper to find matching close symbol for link text/destination
658
+ const findClosingSymbol = (index) => {
659
+ const begin = line[index];
660
+ const end = (begin === "[") ? "]" : ")";
661
+ let nesting = 0;
662
+ let escaping = false;
663
+ let pointy = false;
664
+ for (let i = index + 1; i < line.length; i++) {
665
+ const current = line[i];
666
+ if (current === "\\") {
667
+ escaping = !escaping;
668
+ } else if (!escaping && (current === begin)) {
669
+ nesting++;
670
+ } else if (!escaping && (current === end)) {
671
+ if (nesting > 0) {
672
+ nesting--;
673
+ } else if (!pointy) {
674
+ // Return index after matching close symbol
675
+ return i + 1;
676
+ }
677
+ } else if ((i === index + 1) && (begin === "(") && (current === "<")) {
678
+ pointy = true;
679
+ } else if (!escaping && pointy && current === ">") {
680
+ pointy = false;
681
+ nesting = 0;
682
+ } else {
683
+ escaping = false;
684
+ }
685
+ }
686
+ // No match found
687
+ return -1;
688
+ };
689
+ // Scan line for unescaped "[" character
690
+ let escaping = false;
691
+ for (let i = 0; i < line.length; i++) {
692
+ const current = line[i];
693
+ if (current === "\\") {
694
+ escaping = !escaping;
695
+ } else if (!escaping && (current === "[")) {
696
+ // Scan for matching close "]" of link text
697
+ const textEnd = findClosingSymbol(i);
698
+ if (textEnd !== -1) {
699
+ if ((line[textEnd] === "(") || (line[textEnd] === "[")) {
700
+ // Scan for matching close ")" or "]" of link destination
701
+ const destEnd = findClosingSymbol(textEnd);
702
+ if (destEnd !== -1) {
703
+ // Call handler with link text and destination
704
+ const link = line.slice(i, destEnd);
705
+ const text = line.slice(i, textEnd);
706
+ const dest = line.slice(textEnd, destEnd);
707
+ handler(i, link, text, dest);
708
+ i = destEnd;
709
+ }
710
+ }
711
+ if (i < textEnd) {
712
+ // Call handler with link text only
713
+ const text = line.slice(i, textEnd);
714
+ handler(i, text, text);
715
+ i = textEnd;
716
+ }
717
+ }
718
+ } else {
719
+ escaping = false;
720
+ }
721
+ }
722
+ }
723
+ module.exports.forEachLink = forEachLink;
724
+
590
725
  /**
591
726
  * Returns a list of emphasis markers in code spans and links.
592
727
  *
@@ -596,6 +731,17 @@ module.exports.frontMatterHasTitle =
596
731
  function emphasisMarkersInContent(params) {
597
732
  const { lines } = params;
598
733
  const byLine = new Array(lines.length);
734
+ // Search links
735
+ for (const [ tokenLineIndex, tokenLine ] of lines.entries()) {
736
+ const inLine = [];
737
+ forEachLink(tokenLine, (index, match) => {
738
+ let markerMatch = null;
739
+ while ((markerMatch = emphasisMarkersRe.exec(match))) {
740
+ inLine.push(index + markerMatch.index);
741
+ }
742
+ });
743
+ byLine[tokenLineIndex] = inLine;
744
+ }
599
745
  // Search code spans
600
746
  filterTokens(params, "inline", (token) => {
601
747
  const { children, lineNumber, map } = token;
@@ -605,48 +751,172 @@ function emphasisMarkersInContent(params) {
605
751
  tokenLines.join("\n"),
606
752
  (code, lineIndex, column, tickCount) => {
607
753
  const codeLines = code.split(newLineRe);
608
- codeLines.forEach((codeLine, codeLineIndex) => {
754
+ for (const [ codeLineIndex, codeLine ] of codeLines.entries()) {
755
+ const byLineIndex = lineNumber - 1 + lineIndex + codeLineIndex;
756
+ const inLine = byLine[byLineIndex];
757
+ const codeLineOffset = codeLineIndex ? 0 : column - 1 + tickCount;
609
758
  let match = null;
610
759
  while ((match = emphasisMarkersRe.exec(codeLine))) {
611
- const byLineIndex = lineNumber - 1 + lineIndex + codeLineIndex;
612
- const inLine = byLine[byLineIndex] || [];
613
- const codeLineOffset = codeLineIndex ? 0 : column - 1 + tickCount;
614
760
  inLine.push(codeLineOffset + match.index);
615
- byLine[byLineIndex] = inLine;
616
761
  }
617
- });
762
+ byLine[byLineIndex] = inLine;
763
+ }
618
764
  }
619
765
  );
620
766
  }
621
767
  });
622
- // Search links
623
- lines.forEach((tokenLine, tokenLineIndex) => {
624
- let linkMatch = null;
625
- while ((linkMatch = linkRe.exec(tokenLine))) {
626
- let markerMatch = null;
627
- while ((markerMatch = emphasisMarkersRe.exec(linkMatch[0]))) {
628
- const inLine = byLine[tokenLineIndex] || [];
629
- inLine.push(linkMatch.index + markerMatch.index);
630
- byLine[tokenLineIndex] = inLine;
768
+ return byLine;
769
+ }
770
+ module.exports.emphasisMarkersInContent = emphasisMarkersInContent;
771
+
772
+ /**
773
+ * Returns an object with information about reference links and images.
774
+ *
775
+ * @param {Object} lineMetadata Line metadata object.
776
+ * @returns {Object} Reference link/image data.
777
+ */
778
+ function getReferenceLinkImageData(lineMetadata) {
779
+ // Initialize return values
780
+ const references = new Map();
781
+ const shortcuts = new Set();
782
+ const definitions = new Map();
783
+ const duplicateDefinitions = [];
784
+ // Define helper functions
785
+ const normalizeLabel = (s) => s.toLowerCase().trim().replace(/\s+/g, " ");
786
+ const exclusions = [];
787
+ const excluded = (match) => withinAnyRange(
788
+ exclusions, 0, match.index, match[0].length
789
+ );
790
+ // Convert input to single-line so multi-line links/images are easier
791
+ const lineOffsets = [];
792
+ let currentOffset = 0;
793
+ const contentLines = [];
794
+ forEachLine(lineMetadata, (line, lineIndex, inCode) => {
795
+ lineOffsets[lineIndex] = currentOffset;
796
+ if (!inCode) {
797
+ if (line.trim().length === 0) {
798
+ // Allow RegExp to detect the end of a block
799
+ line = "\0";
631
800
  }
801
+ contentLines.push(line);
802
+ currentOffset += line.length + 1;
632
803
  }
633
804
  });
634
- return byLine;
805
+ lineOffsets.push(currentOffset);
806
+ const contentLine = contentLines.join(" ");
807
+ // Determine single-line exclusions for inline code spans
808
+ forEachInlineCodeSpan(contentLine, (code, lineIndex, columnIndex) => {
809
+ exclusions.push([ 0, columnIndex, code.length ]);
810
+ });
811
+ // Identify all link/image reference definitions
812
+ forEachLine(lineMetadata, (line, lineIndex, inCode) => {
813
+ if (!inCode) {
814
+ const linkReferenceDefinitionMatch = linkReferenceDefinitionRe.exec(line);
815
+ if (linkReferenceDefinitionMatch) {
816
+ const label = normalizeLabel(linkReferenceDefinitionMatch[1]);
817
+ if (definitions.has(label)) {
818
+ duplicateDefinitions.push([ label, lineIndex ]);
819
+ } else {
820
+ definitions.set(label, lineIndex);
821
+ }
822
+ exclusions.push([ 0, lineOffsets[lineIndex], line.length ]);
823
+ }
824
+ }
825
+ });
826
+ // Identify all link and image references
827
+ let lineIndex = 0;
828
+ const pendingContents = [
829
+ {
830
+ "content": contentLine,
831
+ "contentLineIndex": 0,
832
+ "contentIndex": 0,
833
+ "topLevel": true
834
+ }
835
+ ];
836
+ let pendingContent = null;
837
+ while ((pendingContent = pendingContents.shift())) {
838
+ const { content, contentLineIndex, contentIndex, topLevel } =
839
+ pendingContent;
840
+ let referenceLinkMatch = null;
841
+ while ((referenceLinkMatch = referenceLinkRe.exec(content)) !== null) {
842
+ const [ matchString, matchText, matchLabel ] = referenceLinkMatch;
843
+ if (
844
+ !matchString.startsWith("\\") &&
845
+ !matchString.startsWith("!\\") &&
846
+ !matchText.endsWith("\\") &&
847
+ !(matchLabel || "").endsWith("\\") &&
848
+ (topLevel || matchString.startsWith("!")) &&
849
+ !excluded(referenceLinkMatch)
850
+ ) {
851
+ const shortcutLink = (matchLabel === undefined);
852
+ const collapsedLink =
853
+ (!shortcutLink && (matchLabel.length === 0));
854
+ const label = normalizeLabel(
855
+ (shortcutLink || collapsedLink) ? matchText : matchLabel
856
+ );
857
+ if (label.length > 0) {
858
+ if (shortcutLink) {
859
+ // Track, but don't validate due to ambiguity: "text [text] text"
860
+ shortcuts.add(label);
861
+ } else {
862
+ const referenceindex = referenceLinkMatch.index;
863
+ if (topLevel) {
864
+ // Calculate line index
865
+ while (lineOffsets[lineIndex + 1] <= referenceindex) {
866
+ lineIndex++;
867
+ }
868
+ } else {
869
+ // Use provided line index
870
+ lineIndex = contentLineIndex;
871
+ }
872
+ const referenceIndex = referenceindex +
873
+ (topLevel ? -lineOffsets[lineIndex] : contentIndex);
874
+ // Track reference and location
875
+ const referenceData = references.get(label) || [];
876
+ referenceData.push([
877
+ lineIndex,
878
+ referenceIndex,
879
+ matchString.length
880
+ ]);
881
+ references.set(label, referenceData);
882
+ // Check for images embedded in top-level link text
883
+ if (!matchString.startsWith("!")) {
884
+ pendingContents.push(
885
+ {
886
+ "content": matchText,
887
+ "contentLineIndex": lineIndex,
888
+ "contentIndex": referenceIndex + 1,
889
+ "topLevel": false
890
+ }
891
+ );
892
+ }
893
+ }
894
+ }
895
+ }
896
+ }
897
+ }
898
+ return {
899
+ references,
900
+ shortcuts,
901
+ definitions,
902
+ duplicateDefinitions
903
+ };
635
904
  }
636
- module.exports.emphasisMarkersInContent = emphasisMarkersInContent;
905
+ module.exports.getReferenceLinkImageData = getReferenceLinkImageData;
637
906
 
638
907
  /**
639
908
  * Gets the most common line ending, falling back to the platform default.
640
909
  *
641
910
  * @param {string} input Markdown content to analyze.
911
+ * @param {Object} [os] Node.js "os" module.
642
912
  * @returns {string} Preferred line ending.
643
913
  */
644
- function getPreferredLineEnding(input) {
914
+ function getPreferredLineEnding(input, os) {
645
915
  let cr = 0;
646
916
  let lf = 0;
647
917
  let crlf = 0;
648
918
  const endings = input.match(newLineRe) || [];
649
- endings.forEach((ending) => {
919
+ for (const ending of endings) {
650
920
  // eslint-disable-next-line default-case
651
921
  switch (ending) {
652
922
  case "\r":
@@ -659,10 +929,10 @@ function getPreferredLineEnding(input) {
659
929
  crlf++;
660
930
  break;
661
931
  }
662
- });
932
+ }
663
933
  let preferredLineEnding = null;
664
934
  if (!cr && !lf && !crlf) {
665
- preferredLineEnding = os.EOL;
935
+ preferredLineEnding = (os && os.EOL) || "\n";
666
936
  } else if ((lf >= crlf) && (lf >= cr)) {
667
937
  preferredLineEnding = "\n";
668
938
  } else if (crlf >= cr) {
@@ -695,8 +965,8 @@ function normalizeFixInfo(fixInfo, lineNumber) {
695
965
  *
696
966
  * @param {string} line Line of Markdown content.
697
967
  * @param {Object} fixInfo RuleOnErrorFixInfo instance.
698
- * @param {string} lineEnding Line ending to use.
699
- * @returns {string} Fixed content.
968
+ * @param {string} [lineEnding] Line ending to use.
969
+ * @returns {string | null} Fixed content.
700
970
  */
701
971
  function applyFix(line, fixInfo, lineEnding) {
702
972
  const { editColumn, deleteCount, insertText } = normalizeFixInfo(fixInfo);
@@ -709,9 +979,15 @@ function applyFix(line, fixInfo, lineEnding) {
709
979
  }
710
980
  module.exports.applyFix = applyFix;
711
981
 
712
- // Applies as many fixes as possible to the input lines
713
- module.exports.applyFixes = function applyFixes(input, errors) {
714
- const lineEnding = getPreferredLineEnding(input);
982
+ /**
983
+ * Applies as many fixes as possible to Markdown content.
984
+ *
985
+ * @param {string} input Lines of Markdown content.
986
+ * @param {Object[]} errors RuleOnErrorInfo instances.
987
+ * @returns {string} Corrected content.
988
+ */
989
+ function applyFixes(input, errors) {
990
+ const lineEnding = getPreferredLineEnding(input, require("os"));
715
991
  const lines = input.split(newLineRe);
716
992
  // Normalize fixInfo objects
717
993
  let fixInfos = errors
@@ -741,8 +1017,10 @@ module.exports.applyFixes = function applyFixes(input, errors) {
741
1017
  return unique;
742
1018
  });
743
1019
  // Collapse insert/no-delete and no-insert/delete for same line/column
744
- lastFixInfo = {};
745
- fixInfos.forEach((fixInfo) => {
1020
+ lastFixInfo = {
1021
+ "lineNumber": -1
1022
+ };
1023
+ for (const fixInfo of fixInfos) {
746
1024
  if (
747
1025
  (fixInfo.lineNumber === lastFixInfo.lineNumber) &&
748
1026
  (fixInfo.editColumn === lastFixInfo.editColumn) &&
@@ -754,12 +1032,12 @@ module.exports.applyFixes = function applyFixes(input, errors) {
754
1032
  lastFixInfo.lineNumber = 0;
755
1033
  }
756
1034
  lastFixInfo = fixInfo;
757
- });
1035
+ }
758
1036
  fixInfos = fixInfos.filter((fixInfo) => fixInfo.lineNumber);
759
1037
  // Apply all (remaining/updated) fixes
760
1038
  let lastLineIndex = -1;
761
1039
  let lastEditIndex = -1;
762
- fixInfos.forEach((fixInfo) => {
1040
+ for (const fixInfo of fixInfos) {
763
1041
  const { lineNumber, editColumn, deleteCount } = fixInfo;
764
1042
  const lineIndex = lineNumber - 1;
765
1043
  const editIndex = editColumn - 1;
@@ -769,11 +1047,86 @@ module.exports.applyFixes = function applyFixes(input, errors) {
769
1047
  ((editIndex + deleteCount) <=
770
1048
  (lastEditIndex - ((deleteCount > 0) ? 0 : 1)))
771
1049
  ) {
1050
+ // @ts-ignore
772
1051
  lines[lineIndex] = applyFix(lines[lineIndex], fixInfo, lineEnding);
773
1052
  }
774
1053
  lastLineIndex = lineIndex;
775
1054
  lastEditIndex = editIndex;
776
- });
1055
+ }
777
1056
  // Return corrected input
778
1057
  return lines.filter((line) => line !== null).join(lineEnding);
779
- };
1058
+ }
1059
+ module.exports.applyFixes = applyFixes;
1060
+
1061
+ /**
1062
+ * Gets the range and fixInfo values for reporting an error if the expected
1063
+ * text is found on the specified line.
1064
+ *
1065
+ * @param {string[]} lines Lines of Markdown content.
1066
+ * @param {number} lineIndex Line index to check.
1067
+ * @param {string} search Text to search for.
1068
+ * @param {string} replace Text to replace with.
1069
+ * @param {number} [instance] Instance on the line (1-based).
1070
+ * @returns {Object} Range and fixInfo wrapper.
1071
+ */
1072
+ module.exports.getRangeAndFixInfoIfFound =
1073
+ (lines, lineIndex, search, replace, instance = 1) => {
1074
+ let range = null;
1075
+ let fixInfo = null;
1076
+ let searchIndex = -1;
1077
+ while (instance > 0) {
1078
+ searchIndex = lines[lineIndex].indexOf(search, searchIndex + 1);
1079
+ instance--;
1080
+ }
1081
+ if (searchIndex !== -1) {
1082
+ const column = searchIndex + 1;
1083
+ const length = search.length;
1084
+ range = [ column, length ];
1085
+ fixInfo = {
1086
+ "editColumn": column,
1087
+ "deleteCount": length,
1088
+ "insertText": replace
1089
+ };
1090
+ }
1091
+ return {
1092
+ range,
1093
+ fixInfo
1094
+ };
1095
+ };
1096
+
1097
+ /**
1098
+ * Gets the next (subsequent) child token if it is of the expected type.
1099
+ *
1100
+ * @param {Object} parentToken Parent token.
1101
+ * @param {Object} childToken Child token basis.
1102
+ * @param {string} nextType Token type of next token.
1103
+ * @param {string} nextNextType Token type of next-next token.
1104
+ * @returns {Object} Next token.
1105
+ */
1106
+ function getNextChildToken(parentToken, childToken, nextType, nextNextType) {
1107
+ const { children } = parentToken;
1108
+ const index = children.indexOf(childToken);
1109
+ if (
1110
+ (index !== -1) &&
1111
+ (children.length > index + 2) &&
1112
+ (children[index + 1].type === nextType) &&
1113
+ (children[index + 2].type === nextNextType)
1114
+ ) {
1115
+ return children[index + 1];
1116
+ }
1117
+ return null;
1118
+ }
1119
+ module.exports.getNextChildToken = getNextChildToken;
1120
+
1121
+ /**
1122
+ * Expands a path with a tilde to an absolute path.
1123
+ *
1124
+ * @param {string} file Path that may begin with a tilde.
1125
+ * @param {Object} os Node.js "os" module.
1126
+ * @returns {string} Absolute path (or original path).
1127
+ */
1128
+ function expandTildePath(file, os) {
1129
+ const homedir = os && os.homedir();
1130
+ return homedir ? file.replace(/^~($|\/|\\)/, `${homedir}$1`) : file;
1131
+ }
1132
+ module.exports.expandTildePath = expandTildePath;