Reimplement MD041/first-line-heading/first-line-h1 using micromark tokens.

This commit is contained in:
David Anson 2024-03-18 20:48:22 -07:00
parent 0f5e65abd4
commit a83e2d8a09
4 changed files with 94 additions and 80 deletions

View file

@ -1487,11 +1487,19 @@ function getHeadingLevel(heading) {
return level; return level;
} }
/**
* 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. * Gets information about the tag in an HTML token.
* *
* @param {Token} token Micromark token. * @param {Token} token Micromark token.
* @returns {Object | null} HTML tag information. * @returns {HtmlTagInfo | null} HTML tag information.
*/ */
function getHtmlTagInfo(token) { function getHtmlTagInfo(token) {
const htmlTagNameRe = /^<([^!>][^/\s>]*)/; const htmlTagNameRe = /^<([^!>][^/\s>]*)/;
@ -1583,6 +1591,21 @@ function tokenIfType(token, type) {
return (token && (token.type === type)) ? token : null; return (token && (token.type === type)) ? token : null;
} }
/**
* Set containing token types that do not contain content.
*
* @type {Set<TokenType>}
*/
const nonContentTokens = new Set([
"blockQuoteMarker",
"blockQuotePrefix",
"blockQuotePrefixWhitespace",
"lineEnding",
"lineEndingBlank",
"linePrefix",
"listItemIndent"
]);
module.exports = { module.exports = {
"parse": micromarkParse, "parse": micromarkParse,
filterByPredicate, filterByPredicate,
@ -1593,7 +1616,9 @@ module.exports = {
getTokenParentOfType, getTokenParentOfType,
getTokenTextByType, getTokenTextByType,
inHtmlFlow, inHtmlFlow,
isHtmlFlowComment,
matchAndGetTokensByType, matchAndGetTokensByType,
nonContentTokens,
tokenIfType tokenIfType
}; };
@ -4987,19 +5012,9 @@ module.exports = {
const { addErrorContext, blockquotePrefixRe, isBlankLine } = const { addErrorContext, blockquotePrefixRe, isBlankLine } = __webpack_require__(/*! ../helpers */ "../helpers/helpers.js");
__webpack_require__(/*! ../helpers */ "../helpers/helpers.js"); const { filterByPredicate, nonContentTokens } = __webpack_require__(/*! ../helpers/micromark.cjs */ "../helpers/micromark.cjs");
const { filterByPredicate } = __webpack_require__(/*! ../helpers/micromark.cjs */ "../helpers/micromark.cjs");
const nonContentTokens = new Set([
"blockQuoteMarker",
"blockQuotePrefix",
"blockQuotePrefixWhitespace",
"lineEnding",
"lineEndingBlank",
"linePrefix",
"listItemIndent"
]);
const isList = (token) => ( const isList = (token) => (
(token.type === "listOrdered") || (token.type === "listUnordered") (token.type === "listOrdered") || (token.type === "listUnordered")
); );
@ -5732,6 +5747,8 @@ module.exports = {
const { addErrorContext, frontMatterHasTitle } = __webpack_require__(/*! ../helpers */ "../helpers/helpers.js"); const { addErrorContext, frontMatterHasTitle } = __webpack_require__(/*! ../helpers */ "../helpers/helpers.js");
const { filterByTypes, getHeadingLevel, getHtmlTagInfo, isHtmlFlowComment, nonContentTokens } =
__webpack_require__(/*! ../helpers/micromark.cjs */ "../helpers/micromark.cjs");
// eslint-disable-next-line jsdoc/valid-types // eslint-disable-next-line jsdoc/valid-types
/** @type import("./markdownlint").Rule */ /** @type import("./markdownlint").Rule */
@ -5739,33 +5756,23 @@ module.exports = {
"names": [ "MD041", "first-line-heading", "first-line-h1" ], "names": [ "MD041", "first-line-heading", "first-line-h1" ],
"description": "First line in a file should be a top-level heading", "description": "First line in a file should be a top-level heading",
"tags": [ "headings" ], "tags": [ "headings" ],
"parser": "markdownit", "parser": "micromark",
"function": function MD041(params, onError) { "function": function MD041(params, onError) {
const level = Number(params.config.level || 1); const level = Number(params.config.level || 1);
const tag = "h" + level; if (!frontMatterHasTitle(params.frontMatterLines, params.config.front_matter_title)) {
const foundFrontMatterTitle = params.parsers.micromark.tokens.
frontMatterHasTitle( filter((token) => !nonContentTokens.has(token.type) && !isHtmlFlowComment(token)).
params.frontMatterLines, every((token) => {
params.config.front_matter_title let isError = true;
); if ((token.type === "atxHeading") || (token.type === "setextHeading")) {
if (!foundFrontMatterTitle) { isError = (getHeadingLevel(token) !== level);
const htmlHeadingRe = new RegExp(`^<h${level}[ />]`, "i"); } else if (token.type === "htmlFlow") {
params.parsers.markdownit.tokens.every((token) => { const htmlTexts = filterByTypes(token.children, [ "htmlText" ]);
let isError = false; const tagInfo = (htmlTexts.length > 0) && getHtmlTagInfo(htmlTexts[0]);
if (token.type === "html_block") { isError = !tagInfo || (tagInfo.name.toLowerCase() !== `h${level}`);
if (token.content.startsWith("<!--")) {
// Ignore leading HTML comments
return true;
} else if (!htmlHeadingRe.test(token.content)) {
// Something other than an HTML heading
isError = true;
}
} else if ((token.type !== "heading_open") || (token.tag !== tag)) {
// Something other than a Markdown heading
isError = true;
} }
if (isError) { if (isError) {
addErrorContext(onError, token.lineNumber, token.line); addErrorContext(onError, token.startLine, params.lines[token.startLine - 1]);
} }
return false; return false;
}); });

View file

@ -309,11 +309,19 @@ function getHeadingLevel(heading) {
return level; return level;
} }
/**
* 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. * Gets information about the tag in an HTML token.
* *
* @param {Token} token Micromark token. * @param {Token} token Micromark token.
* @returns {Object | null} HTML tag information. * @returns {HtmlTagInfo | null} HTML tag information.
*/ */
function getHtmlTagInfo(token) { function getHtmlTagInfo(token) {
const htmlTagNameRe = /^<([^!>][^/\s>]*)/; const htmlTagNameRe = /^<([^!>][^/\s>]*)/;
@ -405,6 +413,21 @@ function tokenIfType(token, type) {
return (token && (token.type === type)) ? token : null; return (token && (token.type === type)) ? token : null;
} }
/**
* Set containing token types that do not contain content.
*
* @type {Set<TokenType>}
*/
const nonContentTokens = new Set([
"blockQuoteMarker",
"blockQuotePrefix",
"blockQuotePrefixWhitespace",
"lineEnding",
"lineEndingBlank",
"linePrefix",
"listItemIndent"
]);
module.exports = { module.exports = {
"parse": micromarkParse, "parse": micromarkParse,
filterByPredicate, filterByPredicate,
@ -415,6 +438,8 @@ module.exports = {
getTokenParentOfType, getTokenParentOfType,
getTokenTextByType, getTokenTextByType,
inHtmlFlow, inHtmlFlow,
isHtmlFlowComment,
matchAndGetTokensByType, matchAndGetTokensByType,
nonContentTokens,
tokenIfType tokenIfType
}; };

View file

@ -2,19 +2,9 @@
"use strict"; "use strict";
const { addErrorContext, blockquotePrefixRe, isBlankLine } = const { addErrorContext, blockquotePrefixRe, isBlankLine } = require("../helpers");
require("../helpers"); const { filterByPredicate, nonContentTokens } = require("../helpers/micromark.cjs");
const { filterByPredicate } = require("../helpers/micromark.cjs");
const nonContentTokens = new Set([
"blockQuoteMarker",
"blockQuotePrefix",
"blockQuotePrefixWhitespace",
"lineEnding",
"lineEndingBlank",
"linePrefix",
"listItemIndent"
]);
const isList = (token) => ( const isList = (token) => (
(token.type === "listOrdered") || (token.type === "listUnordered") (token.type === "listOrdered") || (token.type === "listUnordered")
); );

View file

@ -3,6 +3,8 @@
"use strict"; "use strict";
const { addErrorContext, frontMatterHasTitle } = require("../helpers"); const { addErrorContext, frontMatterHasTitle } = require("../helpers");
const { filterByTypes, getHeadingLevel, getHtmlTagInfo, isHtmlFlowComment, nonContentTokens } =
require("../helpers/micromark.cjs");
// eslint-disable-next-line jsdoc/valid-types // eslint-disable-next-line jsdoc/valid-types
/** @type import("./markdownlint").Rule */ /** @type import("./markdownlint").Rule */
@ -10,33 +12,23 @@ module.exports = {
"names": [ "MD041", "first-line-heading", "first-line-h1" ], "names": [ "MD041", "first-line-heading", "first-line-h1" ],
"description": "First line in a file should be a top-level heading", "description": "First line in a file should be a top-level heading",
"tags": [ "headings" ], "tags": [ "headings" ],
"parser": "markdownit", "parser": "micromark",
"function": function MD041(params, onError) { "function": function MD041(params, onError) {
const level = Number(params.config.level || 1); const level = Number(params.config.level || 1);
const tag = "h" + level; if (!frontMatterHasTitle(params.frontMatterLines, params.config.front_matter_title)) {
const foundFrontMatterTitle = params.parsers.micromark.tokens.
frontMatterHasTitle( filter((token) => !nonContentTokens.has(token.type) && !isHtmlFlowComment(token)).
params.frontMatterLines, every((token) => {
params.config.front_matter_title let isError = true;
); if ((token.type === "atxHeading") || (token.type === "setextHeading")) {
if (!foundFrontMatterTitle) { isError = (getHeadingLevel(token) !== level);
const htmlHeadingRe = new RegExp(`^<h${level}[ />]`, "i"); } else if (token.type === "htmlFlow") {
params.parsers.markdownit.tokens.every((token) => { const htmlTexts = filterByTypes(token.children, [ "htmlText" ]);
let isError = false; const tagInfo = (htmlTexts.length > 0) && getHtmlTagInfo(htmlTexts[0]);
if (token.type === "html_block") { isError = !tagInfo || (tagInfo.name.toLowerCase() !== `h${level}`);
if (token.content.startsWith("<!--")) {
// Ignore leading HTML comments
return true;
} else if (!htmlHeadingRe.test(token.content)) {
// Something other than an HTML heading
isError = true;
}
} else if ((token.type !== "heading_open") || (token.tag !== tag)) {
// Something other than a Markdown heading
isError = true;
} }
if (isError) { if (isError) {
addErrorContext(onError, token.lineNumber, token.line); addErrorContext(onError, token.startLine, params.lines[token.startLine - 1]);
} }
return false; return false;
}); });