markdownlint icon indicating copy to clipboard operation
markdownlint copied to clipboard

Proposal: Add rules for math block separation and heading emphasis

Open obgnail opened this issue 4 months ago • 2 comments

I would like to propose two new rules for markdownlint that are currently implemented in my own repository:

  1. Math blocks should be surrounded by blank lines :This rule ensures that math blocks (delimited by $$ ... $$) have blank lines before and after them, improving readability and consistency.

  2. Headings should not be fully emphasized :This rule prevents headings from being composed entirely of emphasized text (e.g., ## **bold** or ## _italic_), which can reduce clarity or hinder accessibility. Similar issue: #1608

I believe these rules could benefit the markdownlint community by promoting best practices in markdown documents. Please let me know if you would consider including these rules, or if there are any requirements or guidelines I should follow when proposing new rules.

obgnail avatar Aug 09 '25 12:08 obgnail

Here is my simple implementation:

const { addErrorContext, isBlankLine } = require("markdownlint-rule-helpers")
const { getParentOfType, filterByTypes } = require("markdownlint-rule-helpers/micromark")

const mathBlockPrefixRe = /^(.*?)[$\[]/

// eslint-disable-next-line jsdoc/valid-types
/** @typedef {readonly string[]} ReadonlyStringArray */

/**
 * Adds an error for the top or bottom of a math fence.
 *
 * @param {import("markdownlint").RuleOnError} onError Error-reporting callback.
 * @param {ReadonlyStringArray} lines Lines of Markdown content.
 * @param {number} lineNumber Line number.
 * @param {boolean} top True if top math.
 * @returns {void}
 */
function addError(onError, lines, lineNumber, top) {
    const line = lines[lineNumber - 1]
    const [, prefix] = line.match(mathBlockPrefixRe) || []
    const fixInfo = (prefix === undefined) ?
        undefined :
        {
            "lineNumber": lineNumber + (top ? 0 : 1),
            "insertText": `${prefix.replace(/[^>]/g, " ").trim()}\n`
        }
    addErrorContext(
        onError,
        lineNumber,
        line.trim(),
        undefined,
        undefined,
        undefined,
        fixInfo
    )
}

const MD101 = {
    "names": ["MD101", "math-surrounded-by-blank-lines"],
    "description": "Math Blocks should be surrounded by blank lines",
    "tags": ["math", "blank_lines"],
    "parser": "micromark",
    "function": (params, onError) => {
        const listItems = params.config.list_items
        const includeListItems = (listItems === undefined) ? true : !!listItems
        const { lines } = params

        for (const mathBlock of filterByTypes(params.parsers.micromark.tokens, ["mathFlow"])) {
            if (includeListItems || !(getParentOfType(mathBlock, ["listOrdered", "listUnordered"]))) {
                if (!isBlankLine(lines[mathBlock.startLine - 2])) {
                    addError(onError, lines, mathBlock.startLine, true)
                }
                if (!isBlankLine(lines[mathBlock.endLine]) && !isBlankLine(lines[mathBlock.endLine - 1])) {
                    addError(onError, lines, mathBlock.endLine, false)
                }
            }
        }
    }
}

function checkFullyEmphasize(token, headContentToken, onError) {
    const isEmphasis = token.type === "emphasis"
    const isStrong = token.type === "strong"

    if (isEmphasis || isStrong) {
        const type = isEmphasis ? "emphasisText" : "strongText"
        const textToken = token.children.find(t => t.type === type)
        if (textToken?.children.length === 1) {
            checkFullyEmphasize(textToken.children[0], headContentToken, onError)
            return
        }
        token = textToken
    }

    const column = headContentToken.startColumn
    const length = headContentToken.endColumn - column
    const fixInfo = token ? { editColumn: column, deleteCount: length, insertText: token.text } : undefined
    addErrorContext(
        onError,
        headContentToken.startLine,
        headContentToken.text.trim(),
        true,
        true,
        [column, length],
        fixInfo,
    )
}

const MD102 = {
    names: ["MD102", "no-fully-emphasized-heading"],
    description: "Headings should not be fully emphasized",
    tags: ["headings", "emphasis", "strong"],
    parser: "micromark",
    "function": (params, onError) => {
        const headings = filterByTypes(params.parsers.micromark.tokens, ["atxHeading"])
        for (const heading of headings) {
            const headingTextToken = heading.children.find(t => t.type === "atxHeadingText")
            if (!headingTextToken || headingTextToken.children.length !== 1) continue

            const headContentToken = headingTextToken.children[0]
            if (headContentToken.type === "emphasis" || headContentToken.type === "strong") {
                checkFullyEmphasize(headContentToken, headContentToken, onError)
            }
        }
    }
}

module.exports = { MD101, MD102 }

lint

obgnail avatar Aug 09 '25 12:08 obgnail

The rule about blanks surrounding math blocks seems very natural and a quick scan of your implementation makes me think I would have relatively few feedback comments. If you want to create a pull request for that as rule MD061 (MD060 does not exist yet, but it is in progress), I think that would be reasonable.

Regarding your second rule about emphasized headings, I understand the intent and again the implementation looks reasonable. However I wonder if this is more popular in practice than we realize. If you are familiar with how this repository works, I would be very interested what happens when that rule is applied to the set of external test repositories. If/when you decide to add the first rule, it should be fairly clear how to evaluate the second in a similar manner. While the test repositories are somewhat arbitrary, they've been quite helpful in flagging patterns that are more popular than I expect.

DavidAnson avatar Aug 09 '25 16:08 DavidAnson