markdownlint
Advanced tools
Comparing version 0.29.0 to 0.30.0
# Changelog | ||
## 0.30.0 | ||
- Use `micromark` in MD022/MD026/MD032/MD037/MD045/MD051 | ||
- Incorporate `micromark-extension-math` for math syntax | ||
- Allow custom rules to override information URL | ||
- Update dependencies | ||
## 0.29.0 | ||
@@ -4,0 +11,0 @@ |
@@ -85,2 +85,4 @@ # Custom Rules | ||
location. | ||
- `information` is an optional (absolute) `URL` of a link to override the | ||
same-named value provided by the rule definition. (Uncommon) | ||
- `range` is an optional `Array` with two `Number` values identifying the | ||
@@ -87,0 +89,0 @@ 1-based column and length of the error. |
@@ -9,4 +9,4 @@ # `MD022` - Headings should be surrounded by blank lines | ||
- `lines_above`: Blank lines above heading (`integer`, default `1`) | ||
- `lines_below`: Blank lines below heading (`integer`, default `1`) | ||
- `lines_above`: Blank lines above heading (`integer|integer[]`, default `1`) | ||
- `lines_below`: Blank lines below heading (`integer|integer[]`, default `1`) | ||
@@ -40,6 +40,12 @@ Fixable: Some violations can be fixed by tooling | ||
The `lines_above` and `lines_below` parameters can be used to specify a | ||
different number of blank lines (including 0) above or below each heading. | ||
different number of blank lines (including `0`) above or below each heading. | ||
If the value `-1` is used for either parameter, any number of blank lines is | ||
allowed. To customize the number of lines above or below each heading level | ||
individually, specify a `number[]` where values correspond to heading levels | ||
1-6 (in order). | ||
Note: If `lines_above` or `lines_below` are configured to require more than one | ||
blank line, [MD012/no-multiple-blanks](md012.md) should also be customized. | ||
Notes: If `lines_above` or `lines_below` are configured to require more than one | ||
blank line, [MD012/no-multiple-blanks](md012.md) should also be customized. This | ||
rule checks for *at least* as many blank lines as specified; any extra blank | ||
lines are ignored. | ||
@@ -46,0 +52,0 @@ Rationale: Aside from aesthetic reasons, some parsers, including `kramdown`, |
@@ -9,4 +9,3 @@ # `MD026` - Trailing punctuation in heading | ||
- `punctuation`: Punctuation characters not allowed at end of headings | ||
(`string`, default `.,;:!。,;:!`) | ||
- `punctuation`: Punctuation characters (`string`, default `.,;:!。,;:!`) | ||
@@ -13,0 +12,0 @@ Fixable: Some violations can be fixed by tooling |
@@ -9,4 +9,4 @@ # `MD049` - Emphasis style should be consistent | ||
- `style`: Emphasis style should be consistent (`string`, default `consistent`, | ||
values `asterisk` / `consistent` / `underscore`) | ||
- `style`: Emphasis style (`string`, default `consistent`, values `asterisk` / | ||
`consistent` / `underscore`) | ||
@@ -13,0 +13,0 @@ Fixable: Some violations can be fixed by tooling |
@@ -9,4 +9,4 @@ # `MD050` - Strong style should be consistent | ||
- `style`: Strong style should be consistent (`string`, default `consistent`, | ||
values `asterisk` / `consistent` / `underscore`) | ||
- `style`: Strong style (`string`, default `consistent`, values `asterisk` / | ||
`consistent` / `underscore`) | ||
@@ -13,0 +13,0 @@ Fixable: Some violations can be fixed by tooling |
@@ -13,3 +13,3 @@ # `MD051` - Link fragments should be valid | ||
```markdown | ||
# Title | ||
# Heading Name | ||
@@ -19,14 +19,25 @@ [Link](#fragment) | ||
To fix this issue, change the link fragment to reference an existing heading: | ||
To fix this issue, change the link fragment to reference an existing heading's | ||
generated name (see below): | ||
```markdown | ||
# Title | ||
# Heading Name | ||
[Link](#title) | ||
[Link](#heading-name) | ||
``` | ||
Alternatively, an HTML `a` tag with an `id` or a `name` attribute can be used to | ||
define a fragment: | ||
Alternatively, some platforms allow the syntax `{#named-anchor}` to be used | ||
within a heading to provide a specific name (consisting of only lower-case | ||
letters, numbers, `-`, and `_`): | ||
```markdown | ||
# Heading Name {#custom-name} | ||
[Link](#custom-name) | ||
``` | ||
Alternatively, any HTML tag with an `id` attribute or an `a` tag with a `name` | ||
attribute can be used to define a fragment: | ||
```markdown | ||
<a id="bookmark"></a> | ||
@@ -33,0 +44,0 @@ |
@@ -809,4 +809,4 @@ # Rules | ||
- `lines_above`: Blank lines above heading (`integer`, default `1`) | ||
- `lines_below`: Blank lines below heading (`integer`, default `1`) | ||
- `lines_above`: Blank lines above heading (`integer|integer[]`, default `1`) | ||
- `lines_below`: Blank lines below heading (`integer|integer[]`, default `1`) | ||
@@ -840,6 +840,12 @@ Fixable: Some violations can be fixed by tooling | ||
The `lines_above` and `lines_below` parameters can be used to specify a | ||
different number of blank lines (including 0) above or below each heading. | ||
different number of blank lines (including `0`) above or below each heading. | ||
If the value `-1` is used for either parameter, any number of blank lines is | ||
allowed. To customize the number of lines above or below each heading level | ||
individually, specify a `number[]` where values correspond to heading levels | ||
1-6 (in order). | ||
Note: If `lines_above` or `lines_below` are configured to require more than one | ||
blank line, [MD012/no-multiple-blanks](md012.md) should also be customized. | ||
Notes: If `lines_above` or `lines_below` are configured to require more than one | ||
blank line, [MD012/no-multiple-blanks](md012.md) should also be customized. This | ||
rule checks for *at least* as many blank lines as specified; any extra blank | ||
lines are ignored. | ||
@@ -991,4 +997,3 @@ Rationale: Aside from aesthetic reasons, some parsers, including `kramdown`, | ||
- `punctuation`: Punctuation characters not allowed at end of headings | ||
(`string`, default `.,;:!。,;:!`) | ||
- `punctuation`: Punctuation characters (`string`, default `.,;:!。,;:!`) | ||
@@ -2085,4 +2090,4 @@ Fixable: Some violations can be fixed by tooling | ||
- `style`: Emphasis style should be consistent (`string`, default `consistent`, | ||
values `asterisk` / `consistent` / `underscore`) | ||
- `style`: Emphasis style (`string`, default `consistent`, values `asterisk` / | ||
`consistent` / `underscore`) | ||
@@ -2121,4 +2126,4 @@ Fixable: Some violations can be fixed by tooling | ||
- `style`: Strong style should be consistent (`string`, default `consistent`, | ||
values `asterisk` / `consistent` / `underscore`) | ||
- `style`: Strong style (`string`, default `consistent`, values `asterisk` / | ||
`consistent` / `underscore`) | ||
@@ -2161,3 +2166,3 @@ Fixable: Some violations can be fixed by tooling | ||
```markdown | ||
# Title | ||
# Heading Name | ||
@@ -2167,14 +2172,25 @@ [Link](#fragment) | ||
To fix this issue, change the link fragment to reference an existing heading: | ||
To fix this issue, change the link fragment to reference an existing heading's | ||
generated name (see below): | ||
```markdown | ||
# Title | ||
# Heading Name | ||
[Link](#title) | ||
[Link](#heading-name) | ||
``` | ||
Alternatively, an HTML `a` tag with an `id` or a `name` attribute can be used to | ||
define a fragment: | ||
Alternatively, some platforms allow the syntax `{#named-anchor}` to be used | ||
within a heading to provide a specific name (consisting of only lower-case | ||
letters, numbers, `-`, and `_`): | ||
```markdown | ||
# Heading Name {#custom-name} | ||
[Link](#custom-name) | ||
``` | ||
Alternatively, any HTML tag with an `id` attribute or an `a` tag with a `name` | ||
attribute can be used to define a fragment: | ||
```markdown | ||
<a id="bookmark"></a> | ||
@@ -2181,0 +2197,0 @@ |
@@ -7,5 +7,3 @@ // @ts-check | ||
// Regular expression for matching common newline characters | ||
// See NEWLINES_RE in markdown-it/lib/rules_core/normalize.js | ||
const newLineRe = /\r\n?|\n/g; | ||
const { newLineRe } = require("./shared.js"); | ||
module.exports.newLineRe = newLineRe; | ||
@@ -24,6 +22,2 @@ | ||
// Regular expression for matching HTML elements | ||
const htmlElementRe = /<(([A-Za-z][A-Za-z\d-]*)(?:\s[^`>]*)?)\/?>/g; | ||
module.exports.htmlElementRe = htmlElementRe; | ||
// Regular expressions for range matching | ||
@@ -33,5 +27,2 @@ module.exports.listItemMarkerRe = /^([\s>]*)(?:[*+-]|\d+[.)])\s+/; | ||
// Regular expression for all instances of emphasis markers | ||
const emphasisMarkersRe = /[_*]/g; | ||
// Regular expression for blockquote prefixes | ||
@@ -45,2 +36,12 @@ const blockquotePrefixRe = /^[>\s]*/; | ||
// Regular expression for identifying an HTML entity at the end of a line | ||
module.exports.endOfLineHtmlEntityRe = | ||
// eslint-disable-next-line max-len | ||
/&(?:#\d+|#[xX][\da-fA-F]+|[a-zA-Z]{2,31}|blk\d{2}|emsp1[34]|frac\d{2}|sup\d|there4);$/; | ||
// Regular expression for identifying a GitHub emoji code at the end of a line | ||
module.exports.endOfLineGemojiCodeRe = | ||
// eslint-disable-next-line max-len | ||
/:(?:[abmovx]|[-+]1|100|1234|(?:1st|2nd|3rd)_place_medal|8ball|clock\d{1,4}|e-mail|non-potable_water|o2|t-rex|u5272|u5408|u55b6|u6307|u6708|u6709|u6e80|u7121|u7533|u7981|u7a7a|[a-z]{2,15}2?|[a-z]{1,14}(?:_[a-z\d]{1,16})+):$/; | ||
// All punctuation characters (normal and full-width) | ||
@@ -53,23 +54,80 @@ const allPunctuation = ".,;:!?。,;:!?"; | ||
// Returns true iff the input is a number | ||
module.exports.isNumber = function isNumber(obj) { | ||
/** | ||
* Returns true iff the input is a Number. | ||
* | ||
* @param {Object} obj Object of unknown type. | ||
* @returns {boolean} True iff obj is a Number. | ||
*/ | ||
function isNumber(obj) { | ||
return typeof obj === "number"; | ||
}; | ||
} | ||
module.exports.isNumber = isNumber; | ||
// Returns true iff the input is a string | ||
module.exports.isString = function isString(obj) { | ||
/** | ||
* Returns true iff the input is a String. | ||
* | ||
* @param {Object} obj Object of unknown type. | ||
* @returns {boolean} True iff obj is a String. | ||
*/ | ||
function isString(obj) { | ||
return typeof obj === "string"; | ||
}; | ||
} | ||
module.exports.isString = isString; | ||
// Returns true iff the input string is empty | ||
module.exports.isEmptyString = function isEmptyString(str) { | ||
/** | ||
* Returns true iff the input String is empty. | ||
* | ||
* @param {string} str String of unknown length. | ||
* @returns {boolean} True iff the input String is empty. | ||
*/ | ||
function isEmptyString(str) { | ||
return str.length === 0; | ||
}; | ||
} | ||
module.exports.isEmptyString = isEmptyString; | ||
// Returns true iff the input is an object | ||
module.exports.isObject = function isObject(obj) { | ||
return (obj !== null) && (typeof obj === "object") && !Array.isArray(obj); | ||
}; | ||
/** | ||
* Returns true iff the input is an Object. | ||
* | ||
* @param {Object} obj Object of unknown type. | ||
* @returns {boolean} True iff obj is an Object. | ||
*/ | ||
function isObject(obj) { | ||
return !!obj && (typeof obj === "object") && !Array.isArray(obj); | ||
} | ||
module.exports.isObject = isObject; | ||
/** | ||
* Returns true iff the input is a URL. | ||
* | ||
* @param {Object} obj Object of unknown type. | ||
* @returns {boolean} True iff obj is a URL. | ||
*/ | ||
function isUrl(obj) { | ||
return !!obj && (Object.getPrototypeOf(obj) === URL.prototype); | ||
} | ||
module.exports.isUrl = isUrl; | ||
/** | ||
* Clones the input if it is an Array. | ||
* | ||
* @param {Object} arr Object of unknown type. | ||
* @returns {Object} Clone of obj iff obj is an Array. | ||
*/ | ||
function cloneIfArray(arr) { | ||
return Array.isArray(arr) ? [ ...arr ] : arr; | ||
} | ||
module.exports.cloneIfArray = cloneIfArray; | ||
/** | ||
* Clones the input if it is a URL. | ||
* | ||
* @param {Object} url Object of unknown type. | ||
* @returns {Object} Clone of obj iff obj is a URL. | ||
*/ | ||
function cloneIfUrl(url) { | ||
return isUrl(url) ? new URL(url) : url; | ||
} | ||
module.exports.cloneIfUrl = cloneIfUrl; | ||
/** | ||
* Returns true iff the input line is blank (contains nothing, whitespace, or | ||
@@ -289,21 +347,6 @@ * comments (unclosed start/end comments allowed)). | ||
/** | ||
* Returns whether a token is a math block (created by markdown-it-texmath). | ||
* | ||
* @param {Object} token MarkdownItToken instance. | ||
* @returns {boolean} True iff token is a math block. | ||
*/ | ||
function isMathBlock(token) { | ||
return ( | ||
((token.tag === "$$") || (token.tag === "math")) && | ||
token.type.startsWith("math_block") && | ||
!token.type.endsWith("_end") | ||
); | ||
} | ||
module.exports.isMathBlock = isMathBlock; | ||
// Get line metadata array | ||
module.exports.getLineMetadata = function getLineMetadata(params) { | ||
const lineMetadata = params.lines.map( | ||
(line, index) => [ line, index, false, 0, false, false, false, false ] | ||
(line, index) => [ line, index, false, 0, false, false, false ] | ||
); | ||
@@ -337,7 +380,2 @@ filterTokens(params, "fence", (token) => { | ||
}); | ||
for (const token of params.parsers.markdownit.tokens.filter(isMathBlock)) { | ||
for (let i = token.map[0]; i < token.map[1]; i++) { | ||
lineMetadata[i][7] = true; | ||
} | ||
} | ||
return lineMetadata; | ||
@@ -351,3 +389,3 @@ }; | ||
* @param {Function} handler Function taking (line, lineIndex, inCode, onFence, | ||
* inTable, inItem, inBreak, inMath). | ||
* inTable, inItem, inBreak). | ||
* @returns {void} | ||
@@ -413,19 +451,2 @@ */ | ||
/** | ||
* Calls the provided function for each specified inline child token. | ||
* | ||
* @param {Object} params RuleParams instance. | ||
* @param {string} type Token type identifier. | ||
* @param {Function} handler Callback function. | ||
* @returns {void} | ||
*/ | ||
function forEachInlineChild(params, type, handler) { | ||
filterTokens(params, "inline", (token) => { | ||
for (const child of token.children.filter((c) => c.type === type)) { | ||
handler(child, token); | ||
} | ||
}); | ||
} | ||
module.exports.forEachInlineChild = forEachInlineChild; | ||
// Calls the provided function for each heading's content | ||
@@ -601,47 +622,2 @@ module.exports.forEachHeading = function forEachHeading(params, handler) { | ||
/** | ||
* Returns an array of HTML element ranges. | ||
* | ||
* @param {Object} params RuleParams instance. | ||
* @param {Object} lineMetadata Line metadata object. | ||
* @returns {number[][]} Array of ranges (lineIndex, columnIndex, length). | ||
*/ | ||
module.exports.htmlElementRanges = (params, lineMetadata) => { | ||
const exclusions = []; | ||
// Match with htmlElementRe | ||
forEachLine(lineMetadata, (line, lineIndex, inCode) => { | ||
let match = null; | ||
// eslint-disable-next-line no-unmodified-loop-condition | ||
while (!inCode && ((match = htmlElementRe.exec(line)) !== null)) { | ||
exclusions.push([ lineIndex, match.index, match[0].length ]); | ||
} | ||
}); | ||
// Match with html_inline | ||
forEachInlineChild(params, "html_inline", (token, parent) => { | ||
const parentContent = parent.content; | ||
let tokenContent = token.content; | ||
const parentIndex = parentContent.indexOf(tokenContent); | ||
let deltaLines = 0; | ||
let indent = 0; | ||
for (let i = parentIndex - 1; i >= 0; i--) { | ||
if (parentContent[i] === "\n") { | ||
deltaLines++; | ||
} else if (deltaLines === 0) { | ||
indent++; | ||
} | ||
} | ||
let lineIndex = token.lineNumber - 1 + deltaLines; | ||
do { | ||
const index = tokenContent.indexOf("\n"); | ||
const length = (index === -1) ? tokenContent.length : index; | ||
exclusions.push([ lineIndex, indent, length ]); | ||
tokenContent = tokenContent.slice(length + 1); | ||
lineIndex++; | ||
indent = 0; | ||
} while (tokenContent.length > 0); | ||
}); | ||
// Return results | ||
return exclusions; | ||
}; | ||
/** | ||
* Determines whether the specified range is within another range. | ||
@@ -691,125 +667,2 @@ * | ||
/** | ||
* Calls the provided function for each link. | ||
* | ||
* @param {string} line Line of Markdown input. | ||
* @param {Function} handler Function taking (index, link, text, destination). | ||
* @returns {void} | ||
*/ | ||
function forEachLink(line, handler) { | ||
// Helper to find matching close symbol for link text/destination | ||
const findClosingSymbol = (index) => { | ||
const begin = line[index]; | ||
const end = (begin === "[") ? "]" : ")"; | ||
let nesting = 0; | ||
let escaping = false; | ||
let pointy = false; | ||
for (let i = index + 1; i < line.length; i++) { | ||
const current = line[i]; | ||
if (current === "\\") { | ||
escaping = !escaping; | ||
} else if (!escaping && (current === begin)) { | ||
nesting++; | ||
} else if (!escaping && (current === end)) { | ||
if (nesting > 0) { | ||
nesting--; | ||
} else if (!pointy) { | ||
// Return index after matching close symbol | ||
return i + 1; | ||
} | ||
} else if ((i === index + 1) && (begin === "(") && (current === "<")) { | ||
pointy = true; | ||
} else if (!escaping && pointy && current === ">") { | ||
pointy = false; | ||
nesting = 0; | ||
} else { | ||
escaping = false; | ||
} | ||
} | ||
// No match found | ||
return -1; | ||
}; | ||
// Scan line for unescaped "[" character | ||
let escaping = false; | ||
for (let i = 0; i < line.length; i++) { | ||
const current = line[i]; | ||
if (current === "\\") { | ||
escaping = !escaping; | ||
} else if (!escaping && (current === "[")) { | ||
// Scan for matching close "]" of link text | ||
const textEnd = findClosingSymbol(i); | ||
if (textEnd !== -1) { | ||
if ((line[textEnd] === "(") || (line[textEnd] === "[")) { | ||
// Scan for matching close ")" or "]" of link destination | ||
const destEnd = findClosingSymbol(textEnd); | ||
if (destEnd !== -1) { | ||
// Call handler with link text and destination | ||
const link = line.slice(i, destEnd); | ||
const text = line.slice(i, textEnd); | ||
const dest = line.slice(textEnd, destEnd); | ||
handler(i, link, text, dest); | ||
i = destEnd; | ||
} | ||
} | ||
if (i < textEnd) { | ||
// Call handler with link text only | ||
const text = line.slice(i, textEnd); | ||
handler(i, text, text); | ||
i = textEnd; | ||
} | ||
} | ||
} else { | ||
escaping = false; | ||
} | ||
} | ||
} | ||
module.exports.forEachLink = forEachLink; | ||
/** | ||
* Returns a list of emphasis markers in code spans and links. | ||
* | ||
* @param {Object} params RuleParams instance. | ||
* @returns {number[][]} List of markers. | ||
*/ | ||
function emphasisMarkersInContent(params) { | ||
const { lines } = params; | ||
const byLine = new Array(lines.length); | ||
// Search links | ||
for (const [ tokenLineIndex, tokenLine ] of lines.entries()) { | ||
const inLine = []; | ||
forEachLink(tokenLine, (index, match) => { | ||
let markerMatch = null; | ||
while ((markerMatch = emphasisMarkersRe.exec(match))) { | ||
inLine.push(index + markerMatch.index); | ||
} | ||
}); | ||
byLine[tokenLineIndex] = inLine; | ||
} | ||
// Search code spans | ||
filterTokens(params, "inline", (token) => { | ||
const { children, lineNumber, map } = token; | ||
if (children.some((child) => child.type === "code_inline")) { | ||
const tokenLines = lines.slice(map[0], map[1]); | ||
forEachInlineCodeSpan( | ||
tokenLines.join("\n"), | ||
(code, lineIndex, column, tickCount) => { | ||
const codeLines = code.split(newLineRe); | ||
for (const [ codeLineIndex, codeLine ] of codeLines.entries()) { | ||
const byLineIndex = lineNumber - 1 + lineIndex + codeLineIndex; | ||
const inLine = byLine[byLineIndex]; | ||
const codeLineOffset = codeLineIndex ? 0 : column - 1 + tickCount; | ||
let match = null; | ||
while ((match = emphasisMarkersRe.exec(codeLine))) { | ||
inLine.push(codeLineOffset + match.index); | ||
} | ||
byLine[byLineIndex] = inLine; | ||
} | ||
} | ||
); | ||
} | ||
}); | ||
return byLine; | ||
} | ||
module.exports.emphasisMarkersInContent = emphasisMarkersInContent; | ||
/** | ||
* Returns an object with information about reference links and images. | ||
@@ -816,0 +669,0 @@ * |
{ | ||
"name": "markdownlint-rule-helpers", | ||
"version": "0.20.0", | ||
"version": "0.21.0", | ||
"description": "A collection of markdownlint helper functions for custom rules", | ||
@@ -16,3 +16,3 @@ "main": "./helpers.js", | ||
"engines": { | ||
"node": ">=14.18.0" | ||
"node": ">=16" | ||
}, | ||
@@ -19,0 +19,0 @@ "dependencies": { |
@@ -18,4 +18,2 @@ // @ts-check | ||
() => map.get("flattenedLists"); | ||
module.exports.htmlElementRanges = | ||
() => map.get("htmlElementRanges"); | ||
module.exports.lineMetadata = | ||
@@ -22,0 +20,0 @@ () => map.get("lineMetadata"); |
@@ -14,2 +14,2 @@ // @ts-check | ||
module.exports.homepage = "https://github.com/DavidAnson/markdownlint"; | ||
module.exports.version = "0.29.0"; | ||
module.exports.version = "0.30.0"; |
@@ -222,2 +222,6 @@ export = markdownlint; | ||
/** | ||
* Link to more information. | ||
*/ | ||
information?: URL; | ||
/** | ||
* Column number (1-based) and length. | ||
@@ -224,0 +228,0 @@ */ |
@@ -62,3 +62,3 @@ // @ts-check | ||
rule.information && | ||
(Object.getPrototypeOf(rule.information) !== URL.prototype) | ||
!helpers.isUrl(rule.information) | ||
) { | ||
@@ -485,3 +485,3 @@ result = newError("information"); | ||
parameter, | ||
enabledRulesPerLineNumber[nextLineNumber] || {} | ||
enabledRulesPerLineNumber[nextLineNumber] | ||
); | ||
@@ -588,4 +588,2 @@ } | ||
helpers.flattenLists(paramsBase.parsers.markdownit.tokens); | ||
const htmlElementRanges = | ||
helpers.htmlElementRanges(paramsBase, lineMetadata); | ||
const referenceLinkImageData = | ||
@@ -596,3 +594,2 @@ helpers.getReferenceLinkImageData(paramsBase); | ||
flattenedLists, | ||
htmlElementRanges, | ||
lineMetadata, | ||
@@ -636,2 +633,6 @@ referenceLinkImageData | ||
} | ||
if (errorInfo.information && | ||
!helpers.isUrl(errorInfo.information)) { | ||
throwError("information"); | ||
} | ||
if (errorInfo.range && | ||
@@ -689,2 +690,3 @@ (!Array.isArray(errorInfo.range) || | ||
} | ||
const information = errorInfo.information || rule.information; | ||
results.push({ | ||
@@ -695,3 +697,3 @@ lineNumber, | ||
"ruleDescription": rule.description, | ||
"ruleInformation": rule.information ? rule.information.href : null, | ||
"ruleInformation": information ? information.href : null, | ||
"errorDetail": errorInfo.detail || null, | ||
@@ -874,4 +876,15 @@ "errorContext": errorInfo.context || null, | ||
callback = callback || function noop() {}; | ||
const customRuleList = | ||
[ options.customRules || [] ] | ||
.flat() | ||
.map((rule) => ({ | ||
"names": helpers.cloneIfArray(rule.names), | ||
"description": rule.description, | ||
"information": helpers.cloneIfUrl(rule.information), | ||
"tags": helpers.cloneIfArray(rule.tags), | ||
"asynchronous": rule.asynchronous, | ||
"function": rule.function | ||
})); | ||
// eslint-disable-next-line unicorn/prefer-spread | ||
const ruleList = rules.concat(options.customRules || []); | ||
const ruleList = rules.concat(customRuleList); | ||
const ruleErr = validateRuleList(ruleList, synchronous); | ||
@@ -1325,2 +1338,3 @@ if (ruleErr) { | ||
* @property {string} [context] Context for the error. | ||
* @property {URL} [information] Link to more information. | ||
* @property {number[]} [range] Column number (1-based) and length. | ||
@@ -1327,0 +1341,0 @@ * @property {RuleOnErrorFixInfo} [fixInfo] Fix information. |
120
lib/md022.js
@@ -5,5 +5,22 @@ // @ts-check | ||
const { addErrorDetailIf, blockquotePrefixRe, filterTokens, isBlankLine } = | ||
const { addErrorDetailIf, blockquotePrefixRe, isBlankLine } = | ||
require("../helpers"); | ||
const { filterByTypes, getHeadingLevel } = | ||
require("../helpers/micromark.cjs"); | ||
const defaultLines = 1; | ||
const getLinesFunction = (linesParam) => { | ||
if (Array.isArray(linesParam)) { | ||
const linesArray = new Array(6).fill(defaultLines); | ||
for (const [ index, value ] of [ ...linesParam.entries() ].slice(0, 6)) { | ||
linesArray[index] = value; | ||
} | ||
return (heading) => linesArray[getHeadingLevel(heading) - 1]; | ||
} | ||
// Coerce linesParam to a number | ||
const lines = (linesParam === undefined) ? defaultLines : Number(linesParam); | ||
return () => lines; | ||
}; | ||
const getBlockQuote = (str, count) => ( | ||
@@ -23,48 +40,71 @@ (str || "") | ||
"function": function MD022(params, onError) { | ||
let linesAbove = params.config.lines_above; | ||
linesAbove = Number((linesAbove === undefined) ? 1 : linesAbove); | ||
let linesBelow = params.config.lines_below; | ||
linesBelow = Number((linesBelow === undefined) ? 1 : linesBelow); | ||
const { lines } = params; | ||
filterTokens(params, "heading_open", (token) => { | ||
const [ topIndex, nextIndex ] = token.map; | ||
let actualAbove = 0; | ||
for (let i = 0; i < linesAbove; i++) { | ||
if (isBlankLine(lines[topIndex - i - 1])) { | ||
const getLinesAbove = getLinesFunction(params.config.lines_above); | ||
const getLinesBelow = getLinesFunction(params.config.lines_below); | ||
const { lines, parsers } = params; | ||
const headings = filterByTypes( | ||
parsers.micromark.tokens, | ||
[ "atxHeading", "setextHeading" ] | ||
); | ||
for (const heading of headings) { | ||
const { startLine, endLine } = heading; | ||
const line = lines[startLine - 1].trim(); | ||
// Check lines above | ||
const linesAbove = getLinesAbove(heading); | ||
if (linesAbove >= 0) { | ||
let actualAbove = 0; | ||
for ( | ||
let i = 0; | ||
(i < linesAbove) && isBlankLine(lines[startLine - 2 - i]); | ||
i++ | ||
) { | ||
actualAbove++; | ||
} | ||
addErrorDetailIf( | ||
onError, | ||
startLine, | ||
linesAbove, | ||
actualAbove, | ||
"Above", | ||
line, | ||
null, | ||
{ | ||
"insertText": getBlockQuote( | ||
lines[startLine - 2], | ||
linesAbove - actualAbove | ||
) | ||
} | ||
); | ||
} | ||
addErrorDetailIf( | ||
onError, | ||
topIndex + 1, | ||
linesAbove, | ||
actualAbove, | ||
"Above", | ||
lines[topIndex].trim(), | ||
null, | ||
{ | ||
"insertText": | ||
getBlockQuote(lines[topIndex - 1], linesAbove - actualAbove) | ||
}); | ||
let actualBelow = 0; | ||
for (let i = 0; i < linesBelow; i++) { | ||
if (isBlankLine(lines[nextIndex + i])) { | ||
// Check lines below | ||
const linesBelow = getLinesBelow(heading); | ||
if (linesBelow >= 0) { | ||
let actualBelow = 0; | ||
for ( | ||
let i = 0; | ||
(i < linesBelow) && isBlankLine(lines[endLine + i]); | ||
i++ | ||
) { | ||
actualBelow++; | ||
} | ||
addErrorDetailIf( | ||
onError, | ||
startLine, | ||
linesBelow, | ||
actualBelow, | ||
"Below", | ||
line, | ||
null, | ||
{ | ||
"lineNumber": endLine + 1, | ||
"insertText": getBlockQuote( | ||
lines[endLine], | ||
linesBelow - actualBelow | ||
) | ||
} | ||
); | ||
} | ||
addErrorDetailIf( | ||
onError, | ||
topIndex + 1, | ||
linesBelow, | ||
actualBelow, | ||
"Below", | ||
lines[topIndex].trim(), | ||
null, | ||
{ | ||
"lineNumber": nextIndex + 1, | ||
"insertText": | ||
getBlockQuote(lines[nextIndex], linesBelow - actualBelow) | ||
}); | ||
}); | ||
} | ||
} | ||
}; |
@@ -5,6 +5,6 @@ // @ts-check | ||
const { addError, allPunctuationNoQuestion, escapeForRegExp, forEachHeading } = | ||
require("../helpers"); | ||
const { addError, allPunctuationNoQuestion, endOfLineGemojiCodeRe, | ||
endOfLineHtmlEntityRe, escapeForRegExp } = require("../helpers"); | ||
const { filterByTypes } = require("../helpers/micromark.cjs"); | ||
const endOfLineHtmlEntityRe = /&#?[\da-zA-Z]+;$/; | ||
@@ -22,15 +22,22 @@ module.exports = { | ||
new RegExp("\\s*[" + escapeForRegExp(punctuation) + "]+$"); | ||
forEachHeading(params, (heading) => { | ||
const { line, lineNumber } = heading; | ||
const trimmedLine = line.replace(/([^\s#])[\s#]+$/, "$1"); | ||
const match = trailingPunctuationRe.exec(trimmedLine); | ||
if (match && !endOfLineHtmlEntityRe.test(trimmedLine)) { | ||
const headings = filterByTypes( | ||
params.parsers.micromark.tokens, | ||
[ "atxHeadingText", "setextHeadingText" ] | ||
); | ||
for (const heading of headings) { | ||
const { endLine, startColumn, text } = heading; | ||
const match = trailingPunctuationRe.exec(text); | ||
if ( | ||
match && | ||
!endOfLineHtmlEntityRe.test(text) && | ||
!endOfLineGemojiCodeRe.test(text) | ||
) { | ||
const fullMatch = match[0]; | ||
const column = match.index + 1; | ||
const column = startColumn + match.index; | ||
const length = fullMatch.length; | ||
addError( | ||
onError, | ||
lineNumber, | ||
endLine, | ||
`Punctuation: '${fullMatch}'`, | ||
null, | ||
undefined, | ||
[ column, length ], | ||
@@ -43,4 +50,4 @@ { | ||
} | ||
}); | ||
} | ||
} | ||
}; |
@@ -7,4 +7,34 @@ // @ts-check | ||
require("../helpers"); | ||
const { flattenedLists } = require("./cache"); | ||
const { filterByPredicate, flattenedChildren } = | ||
require("../helpers/micromark.cjs"); | ||
const nonContentTokens = new Set([ | ||
"blockQuoteMarker", | ||
"blockQuotePrefix", | ||
"blockQuotePrefixWhitespace", | ||
"lineEnding", | ||
"lineEndingBlank", | ||
"linePrefix", | ||
"listItemIndent" | ||
]); | ||
const isList = (token) => ( | ||
(token.type === "listOrdered") || (token.type === "listUnordered") | ||
); | ||
const addBlankLineError = (onError, lines, lineIndex, lineNumber) => { | ||
const line = lines[lineIndex]; | ||
const quotePrefix = line.match(blockquotePrefixRe)[0].trimEnd(); | ||
addErrorContext( | ||
onError, | ||
lineIndex + 1, | ||
line.trim(), | ||
null, | ||
null, | ||
null, | ||
{ | ||
lineNumber, | ||
"insertText": `${quotePrefix}\n` | ||
} | ||
); | ||
}; | ||
module.exports = { | ||
@@ -15,35 +45,31 @@ "names": [ "MD032", "blanks-around-lists" ], | ||
"function": function MD032(params, onError) { | ||
const { lines } = params; | ||
const filteredLists = flattenedLists().filter((list) => !list.nesting); | ||
for (const list of filteredLists) { | ||
const firstIndex = list.open.map[0]; | ||
const { lines, parsers } = params; | ||
// For every top-level list... | ||
const topLevelLists = filterByPredicate( | ||
parsers.micromark.tokens, | ||
isList, | ||
(token) => (isList(token) ? [] : token.children) | ||
); | ||
for (const list of topLevelLists) { | ||
// Look for a blank line above the list | ||
const firstIndex = list.startLine - 1; | ||
if (!isBlankLine(lines[firstIndex - 1])) { | ||
const line = lines[firstIndex]; | ||
const quotePrefix = line.match(blockquotePrefixRe)[0].trimEnd(); | ||
addErrorContext( | ||
onError, | ||
firstIndex + 1, | ||
line.trim(), | ||
null, | ||
null, | ||
null, | ||
{ | ||
"insertText": `${quotePrefix}\n` | ||
}); | ||
addBlankLineError(onError, lines, firstIndex); | ||
} | ||
const lastIndex = list.lastLineIndex - 1; | ||
// Find the "visual" end of the list | ||
let endLine = list.endLine; | ||
for (const child of flattenedChildren(list).reverse()) { | ||
if (!nonContentTokens.has(child.type)) { | ||
endLine = child.endLine; | ||
break; | ||
} | ||
} | ||
// Look for a blank line below the list | ||
const lastIndex = endLine - 1; | ||
if (!isBlankLine(lines[lastIndex + 1])) { | ||
const line = lines[lastIndex]; | ||
const quotePrefix = line.match(blockquotePrefixRe)[0].trimEnd(); | ||
addErrorContext( | ||
onError, | ||
lastIndex + 1, | ||
line.trim(), | ||
null, | ||
null, | ||
null, | ||
{ | ||
"lineNumber": lastIndex + 2, | ||
"insertText": `${quotePrefix}\n` | ||
}); | ||
addBlankLineError(onError, lines, lastIndex, lastIndex + 2); | ||
} | ||
@@ -50,0 +76,0 @@ } |
@@ -6,3 +6,3 @@ // @ts-check | ||
const { addError } = require("../helpers"); | ||
const { filterByTypes, getHtmlTagInfo, parse } = | ||
const { filterByHtmlTokens, getHtmlTagInfo } = | ||
require("../helpers/micromark.cjs"); | ||
@@ -20,46 +20,20 @@ | ||
allowedElements = allowedElements.map((element) => element.toLowerCase()); | ||
const pending = [ [ 0, params.parsers.micromark.tokens ] ]; | ||
let current = null; | ||
while ((current = pending.shift())) { | ||
const [ offset, tokens ] = current; | ||
for (const token of filterByTypes(tokens, [ "htmlFlow", "htmlText" ])) { | ||
if (token.type === "htmlText") { | ||
const htmlTagInfo = getHtmlTagInfo(token); | ||
if ( | ||
htmlTagInfo && | ||
!htmlTagInfo.close && | ||
!allowedElements.includes(htmlTagInfo.name.toLowerCase()) | ||
) { | ||
const range = [ | ||
token.startColumn, | ||
token.text.replace(nextLinesRe, "").length | ||
]; | ||
addError( | ||
onError, | ||
token.startLine + offset, | ||
"Element: " + htmlTagInfo.name, | ||
undefined, | ||
range | ||
); | ||
} | ||
} else { | ||
// token.type === "htmlFlow" | ||
// Re-parse without "htmlFlow" to get only "htmlText" tokens | ||
const options = { | ||
"extensions": [ | ||
{ | ||
"disable": { | ||
"null": [ "codeIndented", "htmlFlow" ] | ||
} | ||
} | ||
] | ||
}; | ||
// Use lines instead of token.text for accurate columns | ||
const lines = | ||
params.lines.slice(token.startLine - 1, token.endLine).join("\n"); | ||
const flowTokens = parse(lines, options); | ||
pending.push( | ||
[ token.startLine - 1, flowTokens ] | ||
); | ||
} | ||
for (const token of filterByHtmlTokens(params.parsers.micromark.tokens)) { | ||
const htmlTagInfo = getHtmlTagInfo(token); | ||
if ( | ||
htmlTagInfo && | ||
!htmlTagInfo.close && | ||
!allowedElements.includes(htmlTagInfo.name.toLowerCase()) | ||
) { | ||
const range = [ | ||
token.startColumn, | ||
token.text.replace(nextLinesRe, "").length | ||
]; | ||
addError( | ||
onError, | ||
token.startLine, | ||
"Element: " + htmlTagInfo.name, | ||
undefined, | ||
range | ||
); | ||
} | ||
@@ -66,0 +40,0 @@ } |
235
lib/md037.js
@@ -5,13 +5,6 @@ // @ts-check | ||
const { addErrorContext, emphasisMarkersInContent, forEachLine, isBlankLine, | ||
withinAnyRange } = require("../helpers"); | ||
const { htmlElementRanges, lineMetadata } = require("./cache"); | ||
const { addError } = require("../helpers"); | ||
const emphasisRe = /(^|[^\\]|\\\\)(?:(\*{1,3})|(_{1,3}))/g; | ||
const embeddedUnderscoreRe = /([A-Za-z\d])(_([A-Za-z\d]))+/g; | ||
const asteriskListItemMarkerRe = /^([\s>]*)\*(\s+)/; | ||
const leftSpaceRe = /^\s+/; | ||
const rightSpaceRe = /\s+$/; | ||
const tablePipeRe = /\|/; | ||
const allUnderscoresRe = /_/g; | ||
const emphasisStartTextRe = /^(\S{1,3})(\s+)\S/; | ||
const emphasisEndTextRe = /\S(\s+)(\S{1,3})$/; | ||
@@ -23,165 +16,85 @@ module.exports = { | ||
"function": function MD037(params, onError) { | ||
const exclusions = htmlElementRanges(); | ||
// eslint-disable-next-line init-declarations | ||
let effectiveEmphasisLength, emphasisIndex, emphasisKind, emphasisLength, | ||
pendingError = null; | ||
// eslint-disable-next-line jsdoc/require-jsdoc | ||
function resetRunTracking() { | ||
emphasisIndex = -1; | ||
emphasisLength = 0; | ||
emphasisKind = ""; | ||
effectiveEmphasisLength = 0; | ||
pendingError = null; | ||
// Initialize variables | ||
const { lines, parsers } = params; | ||
const emphasisTokensByMarker = new Map(); | ||
for (const marker of [ "_", "__", "___", "*", "**", "***" ]) { | ||
emphasisTokensByMarker.set(marker, []); | ||
} | ||
// eslint-disable-next-line jsdoc/require-jsdoc | ||
function handleRunEnd( | ||
line, lineIndex, contextLength, match, matchIndex, inTable | ||
) { | ||
// Close current run | ||
let content = line.substring(emphasisIndex, matchIndex); | ||
if (!emphasisLength) { | ||
content = content.trimStart(); | ||
const pending = [ ...parsers.micromark.tokens ]; | ||
let token = null; | ||
while ((token = pending.shift())) { | ||
// Use reparsed children of htmlFlow tokens | ||
if (token.type === "htmlFlow") { | ||
pending.unshift(...token.htmlFlowChildren); | ||
continue; | ||
} | ||
if (!match) { | ||
content = content.trimEnd(); | ||
pending.push(...token.children); | ||
// Build lists of bare tokens for each emphasis marker type | ||
for (const emphasisTokens of emphasisTokensByMarker.values()) { | ||
emphasisTokens.length = 0; | ||
} | ||
const leftSpace = leftSpaceRe.test(content); | ||
const rightSpace = rightSpaceRe.test(content); | ||
if ( | ||
(leftSpace || rightSpace) && | ||
(!inTable || !tablePipeRe.test(content)) | ||
) { | ||
// Report the violation | ||
const contextStart = emphasisIndex - emphasisLength; | ||
const contextEnd = matchIndex + contextLength; | ||
const column = contextStart + 1; | ||
const length = contextEnd - contextStart; | ||
if (!withinAnyRange(exclusions, lineIndex, column, length)) { | ||
const context = line.substring(contextStart, contextEnd); | ||
const leftMarker = line.substring(contextStart, emphasisIndex); | ||
const rightMarker = match ? (match[2] || match[3]) : ""; | ||
const fixedText = `${leftMarker}${content.trim()}${rightMarker}`; | ||
return [ | ||
onError, | ||
lineIndex + 1, | ||
context, | ||
leftSpace, | ||
rightSpace, | ||
[ column, length ], | ||
{ | ||
"editColumn": column, | ||
"deleteCount": length, | ||
"insertText": fixedText | ||
} | ||
]; | ||
for (const child of token.children) { | ||
const { text, type } = child; | ||
if ((type === "data") && (text.length <= 3)) { | ||
const emphasisTokens = emphasisTokensByMarker.get(text); | ||
if (emphasisTokens) { | ||
emphasisTokens.push(child); | ||
} | ||
} | ||
} | ||
return null; | ||
} | ||
// Initialize | ||
const ignoreMarkersByLine = emphasisMarkersInContent(params); | ||
resetRunTracking(); | ||
forEachLine( | ||
lineMetadata(), | ||
(line, lineIndex, inCode, onFence, inTable, inItem, onBreak, inMath) => { | ||
const onItemStart = (inItem === 1); | ||
if ( | ||
inCode || | ||
onFence || | ||
inTable || | ||
onBreak || | ||
onItemStart || | ||
isBlankLine(line) | ||
) { | ||
// Emphasis resets when leaving a block | ||
resetRunTracking(); | ||
} | ||
if ( | ||
inCode || | ||
onFence || | ||
onBreak || | ||
inMath | ||
) { | ||
// Emphasis has no meaning here | ||
return; | ||
} | ||
let patchedLine = line.replace( | ||
embeddedUnderscoreRe, | ||
(match) => match.replace(allUnderscoresRe, " ") | ||
); | ||
if (onItemStart) { | ||
// Trim overlapping '*' list item marker | ||
patchedLine = patchedLine.replace(asteriskListItemMarkerRe, "$1 $2"); | ||
} | ||
let match = null; | ||
// Match all emphasis-looking runs in the line... | ||
while ((match = emphasisRe.exec(patchedLine))) { | ||
const ignoreMarkersForLine = ignoreMarkersByLine[lineIndex]; | ||
const matchIndex = match.index + match[1].length; | ||
if (ignoreMarkersForLine.includes(matchIndex)) { | ||
// Ignore emphasis markers inside code spans and links | ||
continue; | ||
// Process bare tokens for each emphasis marker type | ||
for (const emphasisTokens of emphasisTokensByMarker.values()) { | ||
for (let i = 0; i + 1 < emphasisTokens.length; i += 2) { | ||
// Process start token of start/end pair | ||
const startToken = emphasisTokens[i]; | ||
const startText = | ||
lines[startToken.startLine - 1].slice(startToken.startColumn - 1); | ||
const startMatch = startText.match(emphasisStartTextRe); | ||
if (startMatch) { | ||
const [ startContext, startMarker, startSpaces ] = startMatch; | ||
if ((startMarker === startToken.text) && (startSpaces.length > 0)) { | ||
addError( | ||
onError, | ||
startToken.startLine, | ||
undefined, | ||
startContext, | ||
[ startToken.startColumn, startContext.length ], | ||
{ | ||
"editColumn": startToken.endColumn, | ||
"deleteCount": startSpaces.length | ||
} | ||
); | ||
} | ||
} | ||
const matchLength = match[0].length - match[1].length; | ||
const matchKind = (match[2] || match[3])[0]; | ||
if (emphasisIndex === -1) { | ||
// New run | ||
emphasisIndex = matchIndex + matchLength; | ||
emphasisLength = matchLength; | ||
emphasisKind = matchKind; | ||
effectiveEmphasisLength = matchLength; | ||
} else if (matchKind === emphasisKind) { | ||
// Matching emphasis markers | ||
if (matchLength === effectiveEmphasisLength) { | ||
// Ending an existing run, report any pending error | ||
if (pendingError) { | ||
// @ts-ignore | ||
addErrorContext(...pendingError); | ||
pendingError = null; | ||
} | ||
const error = handleRunEnd( | ||
line, | ||
lineIndex, | ||
effectiveEmphasisLength, | ||
match, | ||
matchIndex, | ||
inTable | ||
// Process end token of start/end pair | ||
const endToken = emphasisTokens[i + 1]; | ||
const endText = | ||
lines[endToken.startLine - 1].slice(0, endToken.endColumn - 1); | ||
const endMatch = endText.match(emphasisEndTextRe); | ||
if (endMatch) { | ||
const [ endContext, endSpace, endMarker ] = endMatch; | ||
if ((endMarker === endToken.text) && (endSpace.length > 0)) { | ||
addError( | ||
onError, | ||
endToken.startLine, | ||
undefined, | ||
endContext, | ||
[ endToken.endColumn - endContext.length, endContext.length ], | ||
{ | ||
"editColumn": endToken.startColumn - endSpace.length, | ||
"deleteCount": endSpace.length | ||
} | ||
); | ||
if (error) { | ||
// @ts-ignore | ||
addErrorContext(...error); | ||
} | ||
// Reset | ||
resetRunTracking(); | ||
} else if (matchLength === 3) { | ||
// Swap internal run length (1->2 or 2->1) | ||
effectiveEmphasisLength = matchLength - effectiveEmphasisLength; | ||
} else if (effectiveEmphasisLength === 3) { | ||
// Downgrade internal run (3->1 or 3->2) | ||
effectiveEmphasisLength -= matchLength; | ||
} else { | ||
// Upgrade to internal run (1->3 or 2->3) | ||
effectiveEmphasisLength += matchLength; | ||
} | ||
// Back up one character so RegExp has a chance to match the | ||
// next marker (ex: "**star**_underscore_") | ||
if (emphasisRe.lastIndex > 1) { | ||
emphasisRe.lastIndex--; | ||
} | ||
} else if (emphasisRe.lastIndex > 1) { | ||
// Back up one character so RegExp has a chance to match the | ||
// mis-matched marker (ex: "*text_*") | ||
emphasisRe.lastIndex--; | ||
} | ||
} | ||
if (emphasisIndex !== -1) { | ||
pendingError = pendingError || | ||
handleRunEnd(line, lineIndex, 0, null, line.length, inTable); | ||
// Adjust for pending run on new line | ||
emphasisIndex = 0; | ||
emphasisLength = 0; | ||
} | ||
} | ||
); | ||
} | ||
} | ||
}; |
@@ -5,3 +5,4 @@ // @ts-check | ||
const { addError, forEachInlineChild } = require("../helpers"); | ||
const { addError } = require("../helpers"); | ||
const { filterByTypes } = require("../helpers/micromark.cjs"); | ||
@@ -13,8 +14,19 @@ module.exports = { | ||
"function": function MD045(params, onError) { | ||
forEachInlineChild(params, "image", function forToken(token) { | ||
if (token.content === "") { | ||
addError(onError, token.lineNumber); | ||
const images = filterByTypes(params.parsers.micromark.tokens, [ "image" ]); | ||
for (const image of images) { | ||
const labelTexts = filterByTypes(image.children, [ "labelText" ]); | ||
if (labelTexts.some((labelText) => labelText.text.length === 0)) { | ||
const range = (image.startLine === image.endLine) ? | ||
[ image.startColumn, image.endColumn - image.startColumn ] : | ||
undefined; | ||
addError( | ||
onError, | ||
image.startLine, | ||
undefined, | ||
undefined, | ||
range | ||
); | ||
} | ||
}); | ||
} | ||
} | ||
}; |
158
lib/md051.js
@@ -5,4 +5,5 @@ // @ts-check | ||
const { addError, addErrorDetailIf, escapeForRegExp, filterTokens, | ||
forEachInlineChild, forEachHeading, htmlElementRe } = require("../helpers"); | ||
const { addError, addErrorDetailIf } = require("../helpers"); | ||
const { filterByHtmlTokens, filterByTypes, getHtmlTagInfo } = | ||
require("../helpers/micromark.cjs"); | ||
@@ -12,2 +13,3 @@ // Regular expression for identifying HTML anchor names | ||
const nameRe = /\sname\s*=\s*['"]?([^'"\s>]+)/iu; | ||
const anchorRe = /\{(#[a-z\d]+(?:[-_][a-z\d]+)*)\}/gu; | ||
@@ -18,10 +20,10 @@ /** | ||
* | ||
* @param {Object} inline Inline token for heading. | ||
* @param {Object} headingText Heading text token. | ||
* @returns {string} Fragment string for heading. | ||
*/ | ||
function convertHeadingToHTMLFragment(inline) { | ||
const inlineText = inline.children | ||
.filter((token) => token.type !== "html_inline") | ||
.map((token) => token.content) | ||
.join(""); | ||
function convertHeadingToHTMLFragment(headingText) { | ||
const inlineText = | ||
filterByTypes(headingText.children, [ "codeTextData", "data" ]) | ||
.map((token) => token.text) | ||
.join(""); | ||
return "#" + encodeURIComponent( | ||
@@ -47,6 +49,12 @@ inlineText | ||
"function": function MD051(params, onError) { | ||
const { tokens } = params.parsers.micromark; | ||
const fragments = new Map(); | ||
// Process headings | ||
forEachHeading(params, (heading, content, inline) => { | ||
const fragment = convertHeadingToHTMLFragment(inline); | ||
const headingTexts = filterByTypes( | ||
tokens, | ||
[ "atxHeadingText", "setextHeadingText" ] | ||
); | ||
for (const headingText of headingTexts) { | ||
const fragment = convertHeadingToHTMLFragment(headingText); | ||
const count = fragments.get(fragment) || 0; | ||
@@ -57,10 +65,17 @@ if (count) { | ||
fragments.set(fragment, count + 1); | ||
}); | ||
let match = null; | ||
while ((match = anchorRe.exec(headingText.text)) !== null) { | ||
const [ , anchor ] = match; | ||
if (!fragments.has(anchor)) { | ||
fragments.set(anchor, 1); | ||
} | ||
} | ||
} | ||
// Process HTML anchors | ||
const processHtmlToken = (token) => { | ||
let match = null; | ||
while ((match = htmlElementRe.exec(token.content)) !== null) { | ||
const [ tag, , element ] = match; | ||
const anchorMatch = idRe.exec(tag) || | ||
(element.toLowerCase() === "a" && nameRe.exec(tag)); | ||
for (const token of filterByHtmlTokens(tokens)) { | ||
const htmlTagInfo = getHtmlTagInfo(token); | ||
if (htmlTagInfo && !htmlTagInfo.close) { | ||
const anchorMatch = idRe.exec(token.text) || | ||
(htmlTagInfo.name.toLowerCase() === "a" && nameRe.exec(token.text)); | ||
if (anchorMatch) { | ||
@@ -70,56 +85,63 @@ fragments.set(`#${anchorMatch[1]}`, 0); | ||
} | ||
}; | ||
filterTokens(params, "html_block", processHtmlToken); | ||
forEachInlineChild(params, "html_inline", processHtmlToken); | ||
// Process link fragments | ||
forEachInlineChild(params, "link_open", (token) => { | ||
const { attrs, lineNumber, line } = token; | ||
const href = attrs.find((attr) => attr[0] === "href"); | ||
const id = href && href[1]; | ||
if (id && (id.length > 1) && (id[0] === "#") && !fragments.has(id)) { | ||
let context = id; | ||
let range = null; | ||
let fixInfo = null; | ||
const match = line.match( | ||
new RegExp(`\\[.*?\\]\\(${escapeForRegExp(context)}\\)`) | ||
); | ||
if (match) { | ||
[ context ] = match; | ||
const index = match.index; | ||
const length = context.length; | ||
range = [ index + 1, length ]; | ||
fixInfo = { | ||
"editColumn": index + (length - id.length), | ||
"deleteCount": id.length, | ||
"insertText": null | ||
}; | ||
} | ||
// Process link and definition fragments | ||
const parentChilds = [ | ||
[ "link", "resourceDestinationString" ], | ||
[ "definition", "definitionDestinationString" ] | ||
]; | ||
for (const [ parentType, definitionType ] of parentChilds) { | ||
const links = filterByTypes(tokens, [ parentType ]); | ||
for (const link of links) { | ||
const definitions = filterByTypes(link.children, [ definitionType ]); | ||
for (const definition of definitions) { | ||
if ( | ||
(definition.text.length > 1) && | ||
definition.text.startsWith("#") && | ||
!fragments.has(definition.text) | ||
) { | ||
// eslint-disable-next-line no-undef-init | ||
let context = undefined; | ||
// eslint-disable-next-line no-undef-init | ||
let range = undefined; | ||
// eslint-disable-next-line no-undef-init | ||
let fixInfo = undefined; | ||
if (link.startLine === link.endLine) { | ||
context = link.text; | ||
range = [ link.startColumn, link.endColumn - link.startColumn ]; | ||
fixInfo = { | ||
"editColumn": definition.startColumn, | ||
"deleteCount": definition.endColumn - definition.startColumn | ||
}; | ||
} | ||
const definitionTextLower = definition.text.toLowerCase(); | ||
const mixedCaseKey = [ ...fragments.keys() ] | ||
.find((key) => definitionTextLower === key.toLowerCase()); | ||
if (mixedCaseKey) { | ||
// @ts-ignore | ||
(fixInfo || {}).insertText = mixedCaseKey; | ||
addErrorDetailIf( | ||
onError, | ||
link.startLine, | ||
mixedCaseKey, | ||
definition.text, | ||
undefined, | ||
context, | ||
range, | ||
fixInfo | ||
); | ||
} else { | ||
addError( | ||
onError, | ||
link.startLine, | ||
undefined, | ||
context, | ||
range | ||
); | ||
} | ||
} | ||
} | ||
const idLower = id.toLowerCase(); | ||
const mixedCaseKey = [ ...fragments.keys() ] | ||
.find((key) => idLower === key.toLowerCase()); | ||
if (mixedCaseKey) { | ||
(fixInfo || {}).insertText = mixedCaseKey; | ||
addErrorDetailIf( | ||
onError, | ||
lineNumber, | ||
mixedCaseKey, | ||
id, | ||
undefined, | ||
context, | ||
range, | ||
fixInfo | ||
); | ||
} else { | ||
addError( | ||
onError, | ||
lineNumber, | ||
undefined, | ||
context, | ||
// @ts-ignore | ||
range | ||
); | ||
} | ||
} | ||
}); | ||
} | ||
} | ||
}; |
{ | ||
"name": "markdownlint", | ||
"version": "0.29.0", | ||
"version": "0.30.0", | ||
"description": "A Node.js style checker and lint tool for Markdown/CommonMark files.", | ||
@@ -36,6 +36,5 @@ "type": "commonjs", | ||
"clone-test-repos-dotnet-docs": "cd test-repos && git clone https://github.com/dotnet/docs dotnet-docs --depth 1 --no-tags --quiet", | ||
"clone-test-repos-electron-electron": "cd test-repos && git clone https://github.com/electron/electron electron-electron --depth 1 --no-tags --quiet && cd electron-electron && npm install --ignore-scripts @electron/lint-roller", | ||
"clone-test-repos-electron-electron": "cd test-repos && git clone https://github.com/electron/electron electron-electron --depth 1 --no-tags --quiet && cd electron-electron && npm install --ignore-scripts @electron/lint-roller typescript@4", | ||
"clone-test-repos-eslint-eslint": "cd test-repos && git clone https://github.com/eslint/eslint eslint-eslint --depth 1 --no-tags --quiet", | ||
"clone-test-repos-mdn-content": "cd test-repos && git clone https://github.com/mdn/content mdn-content --depth 1 --no-tags --quiet", | ||
"clone-test-repos-mdn-translated-content": "cd test-repos && git clone https://github.com/mdn/translated-content mdn-translated-content --depth 1 --no-tags --quiet", | ||
"clone-test-repos-mkdocs-mkdocs": "cd test-repos && git clone https://github.com/mkdocs/mkdocs mkdocs-mkdocs --depth 1 --no-tags --quiet", | ||
@@ -47,3 +46,3 @@ "clone-test-repos-mochajs-mocha": "cd test-repos && git clone https://github.com/mochajs/mocha mochajs-mocha --depth 1 --no-tags --quiet", | ||
"clone-test-repos-webpack-webpack-js-org": "cd test-repos && git clone https://github.com/webpack/webpack.js.org webpack-webpack-js-org --depth 1 --no-tags --quiet", | ||
"clone-test-repos": "mkdir test-repos && cd test-repos && npm run clone-test-repos-apache-airflow && npm run clone-test-repos-dotnet-docs && npm run clone-test-repos-electron-electron && npm run clone-test-repos-eslint-eslint && npm run clone-test-repos-mdn-content && npm run clone-test-repos-mdn-translated-content && npm run clone-test-repos-mkdocs-mkdocs && npm run clone-test-repos-mochajs-mocha && npm run clone-test-repos-pi-hole-docs && npm run clone-test-repos-v8-v8-dev && npm run clone-test-repos-webhintio-hint && npm run clone-test-repos-webpack-webpack-js-org", | ||
"clone-test-repos": "mkdir test-repos && cd test-repos && npm run clone-test-repos-apache-airflow && npm run clone-test-repos-dotnet-docs && npm run clone-test-repos-electron-electron && npm run clone-test-repos-eslint-eslint && npm run clone-test-repos-mdn-content && npm run clone-test-repos-mkdocs-mkdocs && npm run clone-test-repos-mochajs-mocha && npm run clone-test-repos-pi-hole-docs && npm run clone-test-repos-v8-v8-dev && npm run clone-test-repos-webhintio-hint && npm run clone-test-repos-webpack-webpack-js-org", | ||
"declaration": "npm run build-declaration && npm run test-declaration", | ||
@@ -59,6 +58,7 @@ "example": "cd example && node standalone.js && grunt markdownlint --force && gulp markdownlint", | ||
"test": "ava test/markdownlint-test.js test/markdownlint-test-config.js test/markdownlint-test-custom-rules.js test/markdownlint-test-helpers.js test/markdownlint-test-micromark.mjs test/markdownlint-test-result-object.js test/markdownlint-test-scenarios.js", | ||
"test-cover": "c8 --check-coverage --branches 100 --functions 100 --lines 100 --statements 100 --exclude 'test/**' --exclude 'micromark/**' npm test", | ||
"test-cover": "c8 --100 npm test", | ||
"test-declaration": "cd example/typescript && tsc && node type-check.js", | ||
"test-extra": "ava --timeout=5m test/markdownlint-test-extra-parse.js test/markdownlint-test-extra-type.js", | ||
"update-snapshots": "ava --update-snapshots test/markdownlint-test-micromark.mjs test/markdownlint-test-scenarios.js", | ||
"update-snapshots-test-repos": "ava --timeout=10m --update-snapshots test/markdownlint-test-repos.js", | ||
"upgrade": "npx --yes npm-check-updates --upgrade" | ||
@@ -71,17 +71,19 @@ }, | ||
"markdown-it": "13.0.1", | ||
"markdownlint-micromark": "0.1.5" | ||
"markdownlint-micromark": "0.1.7" | ||
}, | ||
"devDependencies": { | ||
"@babel/core": "7.22.1", | ||
"@babel/preset-env": "7.22.4", | ||
"ava": "5.3.0", | ||
"babel-loader": "9.1.2", | ||
"c8": "7.14.0", | ||
"eslint": "8.41.0", | ||
"@babel/core": "7.22.10", | ||
"@babel/preset-env": "7.22.10", | ||
"ava": "5.3.1", | ||
"babel-loader": "9.1.3", | ||
"c8": "8.0.1", | ||
"character-entities": "2.0.2", | ||
"eslint": "8.46.0", | ||
"eslint-plugin-es": "4.1.0", | ||
"eslint-plugin-jsdoc": "46.1.0", | ||
"eslint-plugin-n": "16.0.0", | ||
"eslint-plugin-jsdoc": "46.4.6", | ||
"eslint-plugin-n": "16.0.1", | ||
"eslint-plugin-regexp": "1.15.0", | ||
"eslint-plugin-unicorn": "47.0.0", | ||
"globby": "13.1.4", | ||
"eslint-plugin-unicorn": "48.0.1", | ||
"gemoji": "8.1.0", | ||
"globby": "13.2.2", | ||
"js-yaml": "4.1.0", | ||
@@ -91,12 +93,11 @@ "markdown-it-for-inline": "0.1.1", | ||
"markdown-it-sup": "1.0.0", | ||
"markdown-it-texmath": "1.0.0", | ||
"markdownlint-rule-helpers": "0.19.0", | ||
"markdownlint-rule-helpers": "0.20.0", | ||
"npm-run-all": "4.1.5", | ||
"strip-json-comments": "5.0.0", | ||
"strip-json-comments": "5.0.1", | ||
"terser-webpack-plugin": "5.3.9", | ||
"toml": "3.0.0", | ||
"tv4": "1.3.0", | ||
"typescript": "5.1.3", | ||
"webpack": "5.85.0", | ||
"webpack-cli": "5.1.1", | ||
"typescript": "5.1.6", | ||
"webpack": "5.88.2", | ||
"webpack-cli": "5.1.4", | ||
"yaml": "2.3.1" | ||
@@ -103,0 +104,0 @@ }, |
@@ -364,4 +364,10 @@ { | ||
"description": "Blank lines above heading", | ||
"type": "integer", | ||
"minimum": 0, | ||
"type": [ | ||
"integer", | ||
"array" | ||
], | ||
"items": { | ||
"type": "integer" | ||
}, | ||
"minimum": -1, | ||
"default": 1 | ||
@@ -371,4 +377,10 @@ }, | ||
"description": "Blank lines below heading", | ||
"type": "integer", | ||
"minimum": 0, | ||
"type": [ | ||
"integer", | ||
"array" | ||
], | ||
"items": { | ||
"type": "integer" | ||
}, | ||
"minimum": -1, | ||
"default": 1 | ||
@@ -461,3 +473,3 @@ } | ||
"punctuation": { | ||
"description": "Punctuation characters not allowed at end of headings", | ||
"description": "Punctuation characters", | ||
"type": "string", | ||
@@ -882,3 +894,3 @@ "default": ".,;:!。,;:!" | ||
"style": { | ||
"description": "Emphasis style should be consistent", | ||
"description": "Emphasis style", | ||
"type": "string", | ||
@@ -907,3 +919,3 @@ "enum": [ | ||
"style": { | ||
"description": "Strong style should be consistent", | ||
"description": "Strong style", | ||
"type": "string", | ||
@@ -910,0 +922,0 @@ "enum": [ |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
725583
125
28
13103
+ Addedmarkdownlint-micromark@0.1.7(transitive)
- Removedmarkdownlint-micromark@0.1.5(transitive)
Updatedmarkdownlint-micromark@0.1.7