feat: use go tool

This commit is contained in:
2026-07-03 19:24:01 -04:00
parent 39548b4332
commit 4d0cab1fe0
1542 changed files with 252399 additions and 14 deletions
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) David Anson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+29
View File
@@ -0,0 +1,29 @@
# markdownlint-rule-helpers
> A collection of `markdownlint` helper functions for custom rules
## Overview
The [Markdown][markdown] linter [`markdownlint`][markdownlint] offers a variety
of built-in validation [rules][rules] and supports the creation of [custom
rules][custom-rules]. The internal rules share various helper functions; this
package exposes those for reuse by custom rules.
## API
*Undocumented* - This package exports the internal functions as-is. The APIs
were not originally meant to be public, are not officially supported, and may
change from release to release. There are brief descriptive comments above each
function, but no [JSDoc][jsdoc] annotations. That said, some of what's here will
be useful to custom rule authors and may avoid duplicating code.
## Tests
*None* - The entire body of code is tested to 100% coverage by the core
`markdownlint` project, so there are no additional tests here.
[custom-rules]: https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/CustomRules.md
[jsdoc]: https://en.m.wikipedia.org/wiki/JSDoc
[markdown]: https://en.wikipedia.org/wiki/Markdown
[markdownlint]: https://github.com/DavidAnson/markdownlint
[rules]: https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/Rules.md
+696
View File
@@ -0,0 +1,696 @@
// @ts-check
"use strict";
const micromark = require("./micromark-helpers.cjs");
const { newLineRe, nextLinesRe } = require("./shared.cjs");
module.exports.newLineRe = newLineRe;
module.exports.nextLinesRe = nextLinesRe;
/** @typedef {import("../lib/exports.mjs").RuleOnError} RuleOnError */
/** @typedef {import("../lib/exports.mjs").RuleOnErrorFixInfo} RuleOnErrorFixInfo */
/** @typedef {import("../lib/exports.mjs").MicromarkToken} MicromarkToken */
// eslint-disable-next-line jsdoc/valid-types
/** @typedef {import("micromark-extension-gfm-footnote", { with: { "resolution-mode": "import" } })} */
// eslint-disable-next-line jsdoc/valid-types
/** @typedef {import("../lib/micromark-types.d.mts", { with: { "resolution-mode": "import" } })} */
// Regular expression for matching common front matter (YAML and TOML)
// @ts-ignore
module.exports.frontMatterRe =
/((^---[^\S\r\n\u2028\u2029]*$[\s\S]+?^---\s*)|(^\+\+\+[^\S\r\n\u2028\u2029]*$[\s\S]+?^(\+\+\+|\.\.\.)\s*)|(^\{[^\S\r\n\u2028\u2029]*$[\s\S]+?^\}\s*))(\r\n|\r|\n|$)/m;
// Regular expression for matching the start of inline disable/enable comments
const inlineCommentStartRe =
/(<!--\s*markdownlint-(disable|enable|capture|restore|disable-file|enable-file|disable-line|disable-next-line|configure-file))(?:\s|-->)/gi;
module.exports.inlineCommentStartRe = inlineCommentStartRe;
// Regular expression for identifying an HTML entity at the end of a line
module.exports.endOfLineHtmlEntityRe =
/&(?:#\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 =
/:(?:[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)
const allPunctuation = ".,;:!?。,;:!?";
module.exports.allPunctuation = allPunctuation;
// All punctuation characters without question mark (normal and full-width)
module.exports.allPunctuationNoQuestion = allPunctuation.replace(/[?]/gu, "");
/**
* 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.
*
* @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.
*
* @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.
*
* @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 | undefined} url Object of unknown type.
* @returns {Object} Clone of obj iff obj is a URL.
*/
function cloneIfUrl(url) {
// @ts-ignore
return isUrl(url) ? new URL(url) : url;
}
module.exports.cloneIfUrl = cloneIfUrl;
/**
* Gets a Regular Expression for matching the specified HTML attribute.
*
* @param {string} name HTML attribute name.
* @returns {RegExp} Regular Expression for matching.
*/
module.exports.getHtmlAttributeRe = function getHtmlAttributeRe(name) {
return new RegExp(`\\s${name}\\s*=\\s*['"]?([^'"\\s>]*)`, "iu");
};
/**
* Returns true iff the input line is blank (contains nothing, whitespace, or
* comments (unclosed start/end comments allowed)).
*
* @param {string} line Input line.
* @returns {boolean} True iff line is blank.
*/
function isBlankLine(line) {
const startComment = "<!--";
const endComment = "-->";
const removeComments = (/** @type {string} */ s) => {
while (true) {
const start = s.indexOf(startComment);
const end = s.indexOf(endComment);
if ((end !== -1) && ((start === -1) || (end < start))) {
// Unmatched end comment is first
s = s.slice(end + endComment.length);
} else if ((start !== -1) && (end !== -1)) {
// Start comment is before end comment
s = s.slice(0, start) + s.slice(end + endComment.length);
} else if ((start !== -1) && (end === -1)) {
// Unmatched start comment is last
s = s.slice(0, start);
} else {
// No more comments to remove
return s;
}
}
};
return (
!line ||
!line.trim() ||
!removeComments(line).replace(/>/g, "").trim()
);
}
module.exports.isBlankLine = isBlankLine;
// Replaces the content of properly-formatted CommonMark comments with "."
// This preserves the line/column information for the rest of the document
// https://spec.commonmark.org/0.29/#html-blocks
// https://spec.commonmark.org/0.29/#html-comment
const htmlCommentBegin = "<!--";
const htmlCommentEnd = "-->";
const safeCommentCharacter = ".";
const startsWithPipeRe = /^ *\|/;
const notCrLfRe = /[^\r\n]/g;
const notSpaceCrLfRe = /[^ \r\n]/g;
const trailingSpaceRe = / +[\r\n]/g;
const replaceTrailingSpace = (/** @type {string} */ s) => s.replace(notCrLfRe, safeCommentCharacter);
module.exports.clearHtmlCommentText = function clearHtmlCommentText(/** @type {string} */ text) {
let i = 0;
while ((i = text.indexOf(htmlCommentBegin, i)) !== -1) {
const j = text.indexOf(htmlCommentEnd, i + 2);
if (j === -1) {
// Un-terminated comments are treated as text
break;
}
// If the comment has content...
if (j > i + htmlCommentBegin.length) {
const content = text.slice(i + htmlCommentBegin.length, j);
const lastLf = text.lastIndexOf("\n", i) + 1;
const preText = text.slice(lastLf, i);
const isBlock = preText.trim().length === 0;
const couldBeTable = startsWithPipeRe.test(preText);
const spansTableCells = couldBeTable && content.includes("\n");
const isValid =
isBlock ||
!(
spansTableCells ||
content.startsWith(">") ||
content.startsWith("->") ||
content.endsWith("-") ||
content.includes("--")
);
// If a valid block/inline comment...
if (isValid) {
const clearedContent = content
.replace(notSpaceCrLfRe, safeCommentCharacter)
.replace(trailingSpaceRe, replaceTrailingSpace);
text =
text.slice(0, i + htmlCommentBegin.length) +
clearedContent +
text.slice(j);
}
}
i = j + htmlCommentEnd.length;
}
return text;
};
// Escapes a string for use in a RegExp
module.exports.escapeForRegExp = function escapeForRegExp(/** @type {string} */ str) {
return str.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
};
/**
* Adds ellipsis to the left/right/middle of the specified text.
*
* @param {string} text Text to ellipsify.
* @param {boolean} [start] True iff the start of the text is important.
* @param {boolean} [end] True iff the end of the text is important.
* @returns {string} Ellipsified text.
*/
function ellipsify(text, start, end) {
if (text.length <= 30) {
// Nothing to do
} else if (start && end) {
text = text.slice(0, 15) + "..." + text.slice(-15);
} else if (end) {
text = "..." + text.slice(-30);
} else {
text = text.slice(0, 30) + "...";
}
return text;
}
module.exports.ellipsify = ellipsify;
/**
* Adds a generic error object via the onError callback.
*
* @param {RuleOnError} onError RuleOnError instance.
* @param {number} lineNumber Line number.
* @param {string} [detail] Error details.
* @param {string} [context] Error context.
* @param {number[]} [range] Column and length of error.
* @param {RuleOnErrorFixInfo} [fixInfo] RuleOnErrorFixInfo instance.
* @returns {void}
*/
function addError(onError, lineNumber, detail, context, range, fixInfo) {
onError({
lineNumber,
detail,
context,
range,
fixInfo
});
}
module.exports.addError = addError;
/**
* Adds an error object with details conditionally via the onError callback.
*
* @param {RuleOnError} onError RuleOnError instance.
* @param {number} lineNumber Line number.
* @param {Object} expected Expected value.
* @param {Object} actual Actual value.
* @param {string} [detail] Error details.
* @param {string} [context] Error context.
* @param {number[]} [range] Column and length of error.
* @param {RuleOnErrorFixInfo} [fixInfo] RuleOnErrorFixInfo instance.
* @returns {void}
*/
function addErrorDetailIf(
onError, lineNumber, expected, actual, detail, context, range, fixInfo) {
if (expected !== actual) {
addError(
onError,
lineNumber,
"Expected: " + expected + "; Actual: " + actual +
(detail ? "; " + detail : ""),
context,
range,
fixInfo);
}
}
module.exports.addErrorDetailIf = addErrorDetailIf;
/**
* Adds an error object with context via the onError callback.
*
* @param {RuleOnError} onError RuleOnError instance.
* @param {number} lineNumber Line number.
* @param {string} context Error context.
* @param {boolean} [start] True iff the start of the text is important.
* @param {boolean} [end] True iff the end of the text is important.
* @param {number[]} [range] Column and length of error.
* @param {RuleOnErrorFixInfo} [fixInfo] RuleOnErrorFixInfo instance.
* @returns {void}
*/
function addErrorContext(onError, lineNumber, context, start, end, range, fixInfo) {
// Normalize new line characters so Linux and Windows trim consistently
context = ellipsify(context.replace(newLineRe, "\n"), start, end);
addError(onError, lineNumber, undefined, context, range, fixInfo);
}
module.exports.addErrorContext = addErrorContext;
/**
* Defines a range within a file (start line/column to end line/column, subset of MicromarkToken).
*
* @typedef {Object} FileRange
* @property {number} startLine Start line (1-based).
* @property {number} startColumn Start column (1-based).
* @property {number} endLine End line (1-based).
* @property {number} endColumn End column (1-based).
*/
/**
* Returns whether line/column A is less than or equal to line/column B.
*
* @param {number} lineA Line A.
* @param {number} columnA Column A.
* @param {number} lineB Line B.
* @param {number} columnB Column B.
* @returns {boolean} True iff A is less than or equal to B.
*/
const positionLessThanOrEqual = (lineA, columnA, lineB, columnB) => (
(lineA < lineB) ||
((lineA === lineB) && (columnA <= columnB))
);
/**
* Returns whether two ranges (or MicromarkTokens) overlap anywhere.
*
* @param {FileRange|MicromarkToken} rangeA Range A.
* @param {FileRange|MicromarkToken} rangeB Range B.
* @returns {boolean} True iff the two ranges overlap.
*/
module.exports.hasOverlap = function hasOverlap(rangeA, rangeB) {
const lte = positionLessThanOrEqual(rangeA.startLine, rangeA.startColumn, rangeB.startLine, rangeB.startColumn);
const first = lte ? rangeA : rangeB;
const second = lte ? rangeB : rangeA;
return positionLessThanOrEqual(second.startLine, second.startColumn, first.endLine, first.endColumn);
};
// Determines if the front matter includes a title
module.exports.frontMatterHasTitle =
function frontMatterHasTitle(/** @type {readonly string[]} */ frontMatterLines, /** @type {string} */ frontMatterTitlePattern) {
const ignoreFrontMatter =
(frontMatterTitlePattern !== undefined) && !frontMatterTitlePattern;
const frontMatterTitleRe =
new RegExp(
String(frontMatterTitlePattern || "^\\s*\"?title\"?\\s*[:=]"),
"i"
);
return !ignoreFrontMatter &&
frontMatterLines.some((line) => frontMatterTitleRe.test(line));
};
/**
* Result object for getReferenceLinkImageData.
*
* @typedef {Object} GetReferenceLinkImageDataResult
* @property {Map<string, number[][]>} references References.
* @property {Map<string, number[][]>} shortcuts Shortcuts.
* @property {Map<string, [number, string]>} definitions Definitions.
* @property {[string, number][]} duplicateDefinitions Duplicate definitions.
* @property {number[]} definitionLineIndices Definition line indices.
*/
/**
* Returns an object with information about reference links and images.
*
* @param {MicromarkToken[]} tokens Micromark tokens.
* @returns {GetReferenceLinkImageDataResult} Reference link/image data.
*/
function getReferenceLinkImageData(tokens) {
const normalizeReference = (/** @type {string} */ s) => s.toLowerCase().trim().replace(/\s+/g, " ");
const getText = (/** @type {MicromarkToken} */ t) => t?.children.filter((c) => c.type !== "blockQuotePrefix").map((c) => c.text).join("");
/** @type {Map<string, number[][]>} */
const references = new Map();
/** @type {Map<string, number[][]>} */
const shortcuts = new Map();
const addReferenceToDictionary = (/** @type {MicromarkToken} */ token, /** @type {string} */ label, /** @type {boolean} */ isShortcut) => {
const referenceDatum = [
token.startLine - 1,
token.startColumn - 1,
token.text.length
];
const reference = normalizeReference(label);
const dictionary = isShortcut ? shortcuts : references;
const referenceData = dictionary.get(reference) || [];
referenceData.push(referenceDatum);
dictionary.set(reference, referenceData);
};
/** @type {Map<string, [number, string]>} */
const definitions = new Map();
/** @type {number[]} */
const definitionLineIndices = [];
/** @type {[string, number][]} */
const duplicateDefinitions = [];
const filteredTokens =
micromark.filterByTypes(
tokens,
[
// definitionLineIndices
"definition", "gfmFootnoteDefinition",
// definitions and definitionLineIndices
"definitionLabelString", "gfmFootnoteDefinitionLabelString",
// references and shortcuts
"gfmFootnoteCall", "image", "link",
// undefined link labels
"undefinedReferenceCollapsed", "undefinedReferenceFull", "undefinedReferenceShortcut"
]
);
for (const token of filteredTokens) {
let labelPrefix = "";
// eslint-disable-next-line default-case
switch (token.type) {
case "definition":
case "gfmFootnoteDefinition":
// definitionLineIndices
for (let i = token.startLine; i <= token.endLine; i++) {
definitionLineIndices.push(i - 1);
}
break;
case "gfmFootnoteDefinitionLabelString":
labelPrefix = "^";
case "definitionLabelString": // eslint-disable-line no-fallthrough
{
// definitions and definitionLineIndices
const reference = normalizeReference(`${labelPrefix}${token.text}`);
if (definitions.has(reference)) {
duplicateDefinitions.push([ reference, token.startLine - 1 ]);
} else {
const parent =
micromark.getParentOfType(token, [ "definition" ]);
const destinationString = parent &&
micromark.getDescendantsByType(parent, [ "definitionDestination", "definitionDestinationRaw", "definitionDestinationString" ])[0]?.text;
definitions.set(
reference,
[ token.startLine - 1, destinationString || "" ]
);
}
}
break;
case "gfmFootnoteCall":
case "image":
case "link":
{
// Identify if shortcut or full/collapsed
let isShortcut = (token.children.length === 1);
const isFullOrCollapsed = (token.children.length === 2) && !token.children.some((t) => t.type === "resource");
const [ labelText ] = micromark.getDescendantsByType(token, [ "label", "labelText" ]);
const [ referenceString ] = micromark.getDescendantsByType(token, [ "reference", "referenceString" ]);
let label = getText(labelText);
// Identify if footnote
if (!isShortcut && !isFullOrCollapsed) {
const [ footnoteCallMarker, footnoteCallString ] = token.children.filter(
(t) => [ "gfmFootnoteCallMarker", "gfmFootnoteCallString" ].includes(t.type)
);
if (footnoteCallMarker && footnoteCallString) {
label = `${footnoteCallMarker.text}${footnoteCallString.text}`;
isShortcut = true;
}
}
// Track link (handle shortcuts separately due to ambiguity in "text [text] text")
if (isShortcut || isFullOrCollapsed) {
addReferenceToDictionary(token, getText(referenceString) || label, isShortcut);
}
}
break;
case "undefinedReferenceCollapsed":
case "undefinedReferenceFull":
case "undefinedReferenceShortcut":
{
const undefinedReference = micromark.getDescendantsByType(token, [ "undefinedReference" ])[0];
const label = undefinedReference.children.map((t) => t.text).join("");
const isShortcut = (token.type === "undefinedReferenceShortcut");
addReferenceToDictionary(token, label, isShortcut);
}
break;
}
}
return {
references,
shortcuts,
definitions,
duplicateDefinitions,
definitionLineIndices
};
}
module.exports.getReferenceLinkImageData = getReferenceLinkImageData;
/**
* Gets the most common line ending, falling back to the platform default.
*
* @param {string} input Markdown content to analyze.
* @param {{EOL: string}} [os] Node.js "os" module.
* @returns {string} Preferred line ending.
*/
function getPreferredLineEnding(input, os) {
let cr = 0;
let lf = 0;
let crlf = 0;
const endings = input.match(newLineRe) || [];
for (const ending of endings) {
// eslint-disable-next-line default-case
switch (ending) {
case "\r":
cr++;
break;
case "\n":
lf++;
break;
case "\r\n":
crlf++;
break;
}
}
let preferredLineEnding = null;
if (!cr && !lf && !crlf) {
preferredLineEnding = (os && os.EOL) || "\n";
} else if ((lf >= crlf) && (lf >= cr)) {
preferredLineEnding = "\n";
} else if (crlf >= cr) {
preferredLineEnding = "\r\n";
} else {
preferredLineEnding = "\r";
}
return preferredLineEnding;
}
module.exports.getPreferredLineEnding = getPreferredLineEnding;
/**
* Expands a path with a tilde to an absolute path.
*
* @param {string} file Path that may begin with a tilde.
* @param {{homedir: () => string}} os Node.js "os" module.
* @returns {string} Absolute path (or original path).
*/
function expandTildePath(file, os) {
const homedir = os && os.homedir && os.homedir();
return homedir ? file.replace(/^~($|\/|\\)/, `${homedir}$1`) : file;
}
module.exports.expandTildePath = expandTildePath;
/** @typedef {import("../lib/markdownlint.mjs").LintError[]} LintErrors */
/** @typedef {import("../lib/markdownlint.mjs").LintResults} LintResults */
/**
* Converts lint errors from resultVersion 3 to 2.
*
* @param {LintErrors} errors Lint errors (v3).
* @returns {LintErrors} Lint errors (v2).
*/
function convertLintErrorsVersion3To2(errors) {
const noPrevious = {
"ruleNames": [],
"lineNumber": -1
};
return errors.filter((error, index, array) => {
// @ts-ignore
delete error.fixInfo;
// @ts-ignore
delete error.severity;
const previous = array[index - 1] || noPrevious;
return (
(error.ruleNames[0] !== previous.ruleNames[0]) ||
(error.lineNumber !== previous.lineNumber)
);
});
}
/**
* Converts lint errors from resultVersion 2 to 1.
*
* @param {LintErrors} errors Lint errors (v2).
* @returns {LintErrors} Lint errors (v1).
*/
function convertLintErrorsVersion2To1(errors) {
for (const error of errors) {
// @ts-ignore
error.ruleName = error.ruleNames[0];
// @ts-ignore
error.ruleAlias = error.ruleNames[1] || error.ruleName;
// @ts-ignore
delete error.ruleNames;
}
return errors;
}
/**
* Converts lint errors from resultVersion 2 to 0.
*
* @param {LintErrors} errors Lint errors (v2).
* @returns {LintErrors} Lint errors (v0).
*/
function convertLintErrorsVersion2To0(errors) {
/** @type {Object.<string, number[]>} */
const dictionary = {};
for (const error of errors) {
const ruleName = error.ruleNames[0];
const ruleLines = dictionary[ruleName] || [];
ruleLines.push(error.lineNumber);
dictionary[ruleName] = ruleLines;
}
// @ts-ignore
return dictionary;
}
/**
* Copies and transforms lint results from resultVersion 3 to ?.
*
* @param {LintResults} results Lint results (v3).
* @param {(errors: LintErrors) => LintErrors} transform Lint errors (v?).
* @returns {LintResults} Lint results (v?).
*/
function copyAndTransformResults(results, transform) {
/** @type {Object.<string, LintErrors>} */
const newResults = {};
Object.defineProperty(newResults, "toString", { "value": results.toString });
for (const key of Object.keys(results)) {
const arr = results[key].map((r) => ({ ...r }));
newResults[key] = transform(arr);
}
// @ts-ignore
return newResults;
}
/**
* Converts lint results from resultVersion 3 to 0.
*
* @param {LintResults} results Lint results (v3).
* @returns {LintResults} Lint results (v0).
*/
module.exports.convertToResultVersion0 = function convertToResultVersion0(results) {
return copyAndTransformResults(results, (r) => convertLintErrorsVersion2To0(convertLintErrorsVersion3To2(r)));
};
/**
* Converts lint results from resultVersion 3 to 1.
*
* @param {LintResults} results Lint results (v3).
* @returns {LintResults} Lint results (v1).
*/
module.exports.convertToResultVersion1 = function convertToResultVersion1(results) {
return copyAndTransformResults(results, (r) => convertLintErrorsVersion2To1(convertLintErrorsVersion3To2(r)));
};
/**
* Converts lint results from resultVersion 3 to 2.
*
* @param {LintResults} results Lint results (v3).
* @returns {LintResults} Lint results (v2).
*/
module.exports.convertToResultVersion2 = function convertToResultVersion2(results) {
return copyAndTransformResults(results, convertLintErrorsVersion3To2);
};
/**
* Formats lint results to an array of strings.
*
* @param {LintResults|undefined} lintResults Lint results.
* @returns {string[]} Lint error strings.
*/
module.exports.formatLintResults = function formatLintResults(lintResults) {
const results = [];
const entries = Object.entries(lintResults || {});
entries.sort((a, b) => a[0].localeCompare(b[0]));
for (const [ source, lintErrors ] of entries) {
for (const lintError of lintErrors) {
const { lineNumber, ruleNames, ruleDescription, errorDetail, errorContext, errorRange, severity } = lintError;
const rule = ruleNames.join("/");
const line = `:${lineNumber}`;
const rangeStart = (errorRange && errorRange[0]) || 0;
const column = rangeStart ? `:${rangeStart}` : "";
const description = ruleDescription;
const detail = (errorDetail ? ` [${errorDetail}]` : "");
const context = (errorContext ? ` [Context: "${errorContext}"]` : "");
results.push(`${source}${line}${column} ${severity} ${rule} ${description}${detail}${context}`);
}
}
return results;
};
+331
View File
@@ -0,0 +1,331 @@
// @ts-check
"use strict";
const { flatTokensSymbol, htmlFlowSymbol, newLineRe } = require("./shared.cjs");
// eslint-disable-next-line jsdoc/valid-types
/** @typedef {import("micromark-util-types", { with: { "resolution-mode": "import" } }).TokenType} TokenType */
/** @typedef {import("../lib/exports.mjs").MicromarkToken} Token */
// eslint-disable-next-line jsdoc/valid-types
/** @typedef {import("../lib/micromark-types.d.mts", { with: { "resolution-mode": "import" } })} */
/**
* Determines if a Micromark token is within an htmlFlow type.
*
* @param {Token} token Micromark token.
* @returns {boolean} True iff the token is within an htmlFlow type.
*/
function inHtmlFlow(token) {
// @ts-ignore
return Boolean(token[htmlFlowSymbol]);
}
/**
* Returns whether a token is an htmlFlow type containing an HTML comment.
*
* @param {Token} token Micromark token.
* @returns {boolean} True iff token is htmlFlow containing a comment.
*/
function isHtmlFlowComment(token) {
const { text, type } = token;
if (
(type === "htmlFlow") &&
text.startsWith("<!--") &&
text.endsWith("-->")
) {
const comment = text.slice(4, -3);
return (
!comment.startsWith(">") &&
!comment.startsWith("->") &&
!comment.endsWith("-")
// The following condition from the CommonMark specification is commented
// to avoid parsing HTML comments that include "--" because that is NOT a
// condition of the HTML specification.
// https://spec.commonmark.org/0.30/#raw-html
// https://html.spec.whatwg.org/multipage/syntax.html#comments
// && !comment.includes("--")
);
}
return false;
}
/**
* Adds a range of numbers to a set.
*
* @param {Set<number>} set Set of numbers.
* @param {number} start Starting number.
* @param {number} end Ending number.
* @returns {void}
*/
function addRangeToSet(set, start, end) {
for (let i = start; i <= end; i++) {
set.add(i);
}
}
/**
* @callback AllowedPredicate
* @param {Token} token Micromark token.
* @returns {boolean} True iff allowed.
*/
/**
* @callback TransformPredicate
* @param {Token} token Micromark token.
* @returns {Token[]} Child tokens.
*/
/**
* Filter a list of Micromark tokens by predicate.
*
* @param {Token[]} tokens Micromark tokens.
* @param {AllowedPredicate} allowed Allowed token predicate.
* @param {TransformPredicate} [transformChildren] Transform predicate.
* @returns {Token[]} Filtered tokens.
*/
function filterByPredicate(tokens, allowed, transformChildren) {
const result = [];
const queue = [
{
"array": tokens,
"index": 0
}
];
while (queue.length > 0) {
const current = queue[queue.length - 1];
const { array, index } = current;
if (index < array.length) {
const token = array[current.index++];
if (allowed(token)) {
result.push(token);
}
const { children } = token;
if (children.length > 0) {
const transformed =
transformChildren ? transformChildren(token) : children;
queue.push(
{
"array": transformed,
"index": 0
}
);
}
} else {
queue.pop();
}
}
return result;
}
/**
* Filter a list of Micromark tokens by type.
*
* @param {Token[]} tokens Micromark tokens.
* @param {TokenType[]} types Types to allow.
* @param {boolean} [htmlFlow] Whether to include htmlFlow content.
* @returns {Token[]} Filtered tokens.
*/
function filterByTypes(tokens, types, htmlFlow) {
const predicate = (/** @type {Token} */ token) => types.includes(token.type) && (htmlFlow || !inHtmlFlow(token));
/** @type {Token[]} */
const flatTokens =
// @ts-ignore
tokens[flatTokensSymbol];
if (flatTokens) {
return flatTokens.filter(predicate);
}
return filterByPredicate(tokens, predicate);
}
/**
* Gets the blockquote prefix text (if any) for the specified line number.
*
* @param {Token[]} tokens Micromark tokens.
* @param {number} lineNumber Line number to examine.
* @param {number} [count] Number of times to repeat.
* @returns {string} Blockquote prefix text.
*/
function getBlockQuotePrefixText(tokens, lineNumber, count = 1) {
return filterByTypes(tokens, [ "blockQuotePrefix", "linePrefix" ])
.filter((prefix) => prefix.startLine === lineNumber)
.map((prefix) => prefix.text)
.join("")
.trimEnd()
// eslint-disable-next-line unicorn/prefer-spread
.concat("\n")
.repeat(count);
};
/**
* Gets a list of nested Micromark token descendants by type path.
*
* @param {Token|Token[]} parent Micromark token parent or parents.
* @param {(TokenType|TokenType[])[]} typePath Micromark token type path.
* @returns {Token[]} Micromark token descendants.
*/
function getDescendantsByType(parent, typePath) {
let tokens = Array.isArray(parent) ? parent : [ parent ];
for (const type of typePath) {
const predicate = (/** @type {Token} */ token) => Array.isArray(type) ? type.includes(token.type) : (type === token.type);
tokens = tokens.flatMap((t) => t.children.filter(predicate));
}
return tokens;
}
/**
* Gets the heading level of a Micromark heading tokan.
*
* @param {Token} heading Micromark heading token.
* @returns {number} Heading level.
*/
function getHeadingLevel(heading) {
let level = 1;
const headingSequence = heading.children.find(
(child) => [ "atxHeadingSequence", "setextHeadingLine" ].includes(child.type)
);
// @ts-ignore
const { text } = headingSequence;
if (text[0] === "#") {
level = Math.min(text.length, 6);
} else if (text[0] === "-") {
level = 2;
}
return level;
}
/**
* Gets the heading style of a Micromark heading tokan.
*
* @param {Token} heading Micromark heading token.
* @returns {"atx" | "atx_closed" | "setext"} Heading style.
*/
function getHeadingStyle(heading) {
if (heading.type === "setextHeading") {
return "setext";
}
const atxHeadingSequenceLength = heading.children.filter(
(child) => child.type === "atxHeadingSequence"
).length;
if (atxHeadingSequenceLength === 1) {
return "atx";
}
return "atx_closed";
}
/**
* Gets the heading text of a Micromark heading token.
*
* @param {Token} heading Micromark heading token.
* @returns {string} Heading text.
*/
function getHeadingText(heading) {
return getDescendantsByType(heading, [ [ "atxHeadingText", "setextHeadingText" ] ])
.flatMap((descendant) => descendant.children.filter((child) => child.type !== "htmlText"))
.map((data) => data.text)
.join("")
.replace(newLineRe, " ");
}
/**
* HTML tag information.
*
* @typedef {Object} HtmlTagInfo
* @property {boolean} close True iff close tag.
* @property {string} name Tag name.
*/
/**
* Gets information about the tag in an HTML token.
*
* @param {Token} token Micromark token.
* @returns {HtmlTagInfo | null} HTML tag information.
*/
function getHtmlTagInfo(token) {
const htmlTagNameRe = /^<([^!>][^/\s>]*)/;
if (token.type === "htmlText") {
const match = htmlTagNameRe.exec(token.text);
if (match) {
const name = match[1];
const close = name.startsWith("/");
return {
close,
"name": close ? name.slice(1) : name
};
}
}
return null;
}
/**
* Gets the nearest parent of the specified type for a Micromark token.
*
* @param {Token} token Micromark token.
* @param {TokenType[]} types Types to allow.
* @returns {Token | null} Parent token.
*/
function getParentOfType(token, types) {
/** @type {Token | null} */
let current = token;
while ((current = current.parent) && !types.includes(current.type)) {
// Empty
}
return current;
}
const docfxTabSyntaxRe = /^#tab\//;
/**
* Returns whether the specified Micromark token looks like a Docfx tab.
*
* @param {Token | null} heading Micromark token.
* @returns {boolean} True iff the token looks like a Docfx tab.
*/
function isDocfxTab(heading) {
// See https://dotnet.github.io/docfx/docs/markdown.html?tabs=linux%2Cdotnet#tabs
if (heading?.type === "atxHeading") {
const headingTexts = getDescendantsByType(heading, [ "atxHeadingText" ]);
if ((headingTexts.length === 1) && (headingTexts[0].children.length === 1) && (headingTexts[0].children[0].type === "link")) {
const resourceDestinationStrings = filterByTypes(headingTexts[0].children[0].children, [ "resourceDestinationString" ]);
return (resourceDestinationStrings.length === 1) && docfxTabSyntaxRe.test(resourceDestinationStrings[0].text);
}
}
return false;
}
/**
* Set containing token types that do not contain content.
*
* @type {Set<TokenType>}
*/
const nonContentTokens = new Set([
"blockQuoteMarker",
"blockQuotePrefix",
"blockQuotePrefixWhitespace",
"gfmFootnoteDefinitionIndent",
"lineEnding",
"lineEndingBlank",
"linePrefix",
"listItemIndent",
"undefinedReference",
"undefinedReferenceCollapsed",
"undefinedReferenceFull",
"undefinedReferenceShortcut"
]);
module.exports = {
addRangeToSet,
filterByPredicate,
filterByTypes,
getBlockQuotePrefixText,
getDescendantsByType,
getHeadingLevel,
getHeadingStyle,
getHeadingText,
getHtmlTagInfo,
getParentOfType,
inHtmlFlow,
isDocfxTab,
isHtmlFlowComment,
nonContentTokens
};
+26
View File
@@ -0,0 +1,26 @@
{
"name": "markdownlint-rule-helpers",
"version": "0.30.0",
"description": "A collection of markdownlint helper functions for custom rules",
"main": "./helpers.cjs",
"exports": {
".": "./helpers.cjs",
"./micromark": "./micromark-helpers.cjs"
},
"author": "David Anson (https://dlaa.me/)",
"license": "MIT",
"homepage": "https://github.com/DavidAnson/markdownlint",
"repository": {
"type": "git",
"url": "git+https://github.com/DavidAnson/markdownlint.git"
},
"bugs": "https://github.com/DavidAnson/markdownlint/issues",
"funding": "https://github.com/sponsors/DavidAnson",
"engines": {
"node": ">=18"
},
"keywords": [
"markdownlint",
"markdownlint-rule"
]
}
+16
View File
@@ -0,0 +1,16 @@
// @ts-check
"use strict";
// Symbol for identifing the flat tokens array from micromark parse
module.exports.flatTokensSymbol = Symbol("flat-tokens");
// Symbol for identifying the htmlFlow token from micromark parse
module.exports.htmlFlowSymbol = Symbol("html-flow");
// Regular expression for matching common newline characters
// See NEWLINES_RE in markdown-it/lib/rules_core/normalize.js
module.exports.newLineRe = /\r\n?|\n/g;
// Regular expression for matching next lines
module.exports.nextLinesRe = /[\r\n][\s\S]*$/;