[GH-ISSUE #1885] Add a Format Check rule to convert $$E=mc^2$$ into multi‑line display math for Typora compatibility #2617

Closed
opened 2026-03-07 20:09:25 +03:00 by kerem · 6 comments
Owner

Originally created by @sleepyy-dog on GitHub (Dec 7, 2025).
Original GitHub issue: https://github.com/DavidAnson/markdownlint/issues/1885

In many AI answers, the format for the display equations is as follows:

$$E=mc^2$$

And it can be successfully rendered on AI web pages. However, in Typora, the above would only be rendered as an inline equation.
I hope to add a rule to the 'Format Check' plugin's rule document that can fix:

$$E=mc^2$$

to

$$
E=mc^2
$$

This way, the requirement for successfully rendering display equations can be achieved. Due to my limited expertise, I am not familiar with the rule document. I hope someone more experienced can add this rule to the rule document.Hope i can use this new hunction in plugin.

Originally created by @sleepyy-dog on GitHub (Dec 7, 2025). Original GitHub issue: https://github.com/DavidAnson/markdownlint/issues/1885 In many AI answers, the format for the display equations is as follows: ``` $$E=mc^2$$ ``` And it can be successfully rendered on AI web pages. However, in Typora, the above would only be rendered as an inline equation. I hope to add a rule to the 'Format Check' plugin's rule document that can fix: ``` $$E=mc^2$$ ``` to ``` $$ E=mc^2 $$ ``` This way, the requirement for successfully rendering display equations can be achieved. Due to my limited expertise, I am not familiar with the rule document. I hope someone more experienced can add this rule to the rule document.Hope i can use this new hunction in plugin.
kerem 2026-03-07 20:09:25 +03:00
  • closed this issue
  • added the
    new rule
    label
Author
Owner

@obgnail commented on GitHub (Dec 7, 2025):

How to distinguish inline $$ vs $ for a custom rule?

Hi, @DavidAnson

I am trying to implement a custom rule to enforce single-dollar delimiters ($) for inline math, as AI-generated content often incorrectly uses double-dollars ($$) for inline equations.

I am facing two technical hurdles:

  1. Parser Ambiguity: I know markdownlint has moved away from markdown-it. Testing with micromark reveals that $math$ and $$math$$ produce identical output, making them indistinguishable in the result.
  2. Regex Limitations: Using Regex on raw lines is unreliable as it causes false positives within code blocks.

Reproduction of the parser issue:

import { micromark } from 'micromark'
import { math, mathHtml } from 'micromark-extension-math'

const options = { extensions: [math()], htmlExtensions: [mathHtml()]}
const output1 = micromark('$E=mc^2$', options)
const output2 = micromark('$$E=mc^2$$', options)

console.log(output1)  // <p><span class="math math-inline">...</span></p>
console.log(output1 === output2)  // true

While markdown-it clearly differentiates them:

const MarkdownIt = require('markdown-it')
const mk = require("@vscode/markdown-it-katex").default
const md = MarkdownIt().use(mk)

console.log(md.parseInline('$$E=mc^2$$')[0].children)
// Result: Token { type: 'math_block', markup: '$$', ... }

console.log(md.parseInline('$E=mc^2$')[0].children)
// Result: Token { type: 'math_inline', markup: '$', ... }

My Question: Is there a way within the current markdownlint architecture to access similar metadata (specifically the raw markup or a distinct token type) for math nodes? I need to validate the delimiter used in the source code without relying on Regex, which causes false positives in code blocks.

Thanks.

<!-- gh-comment-id:3622382087 --> @obgnail commented on GitHub (Dec 7, 2025): ## How to distinguish inline `$$` vs `$` for a custom rule? Hi, @DavidAnson I am trying to implement a custom rule to enforce single-dollar delimiters (`$`) for inline math, as AI-generated content often incorrectly uses double-dollars (`$$`) for inline equations. I am facing two technical hurdles: 1. **Parser Ambiguity:** I know `markdownlint` has moved away from `markdown-it`. Testing with `micromark` reveals that `$math$` and `$$math$$` produce **identical output**, making them indistinguishable in the result. 2. **Regex Limitations:** Using Regex on raw lines is unreliable as it causes false positives within code blocks. **Reproduction of the parser issue:** ```js import { micromark } from 'micromark' import { math, mathHtml } from 'micromark-extension-math' const options = { extensions: [math()], htmlExtensions: [mathHtml()]} const output1 = micromark('$E=mc^2$', options) const output2 = micromark('$$E=mc^2$$', options) console.log(output1) // <p><span class="math math-inline">...</span></p> console.log(output1 === output2) // true ``` While `markdown-it` clearly differentiates them: ```js const MarkdownIt = require('markdown-it') const mk = require("@vscode/markdown-it-katex").default const md = MarkdownIt().use(mk) console.log(md.parseInline('$$E=mc^2$$')[0].children) // Result: Token { type: 'math_block', markup: '$$', ... } console.log(md.parseInline('$E=mc^2$')[0].children) // Result: Token { type: 'math_inline', markup: '$', ... } ``` **My Question:** Is there a way within the current `markdownlint` architecture to access similar metadata (specifically the raw `markup` or a distinct token type) for math nodes? I need to validate the delimiter used in the source code without relying on Regex, which causes false positives in code blocks. Thanks.
Author
Owner

@DavidAnson commented on GitHub (Dec 7, 2025):

If you register the custom rule as using the micromark parser, you can examine the token stream for your scenario. I find it easiest to create a very small Markdown file and then look through the tokens to see how it presents. In this case, you will be looking for tokens with names like those enumerated at the bottom of this file: https://github.com/micromark/micromark-extension-math/blob/main/dev/index.d.ts

In this case, I think you just need to look for the start/stop markers for inline math content and provide a single fix action that replaces the entire math block with itself plus newline characters.

Actually, I guess the two of you are asking slightly different questions. At any rate, the approach you both take should be similar and along the lines of what I outlined above.

I hope this helps – good luck!

<!-- gh-comment-id:3622920680 --> @DavidAnson commented on GitHub (Dec 7, 2025): If you register the custom rule as using the micromark parser, you can examine the token stream for your scenario. I find it easiest to create a very small Markdown file and then look through the tokens to see how it presents. In this case, you will be looking for tokens with names like those enumerated at the bottom of this file: https://github.com/micromark/micromark-extension-math/blob/main/dev/index.d.ts In this case, I think you just need to look for the start/stop markers for inline math content and provide a single fix action that replaces the entire math block with itself plus newline characters. Actually, I guess the two of you are asking slightly different questions. At any rate, the approach you both take should be similar and along the lines of what I outlined above. I hope this helps – good luck!
Author
Owner

@DavidAnson commented on GitHub (Dec 7, 2025):

FYI if you hadn't seen this: https://github.com/DavidAnson/markdownlint/blob/main/doc/CustomRules.md

<!-- gh-comment-id:3623185717 --> @DavidAnson commented on GitHub (Dec 7, 2025): FYI if you hadn't seen this: https://github.com/DavidAnson/markdownlint/blob/main/doc/CustomRules.md
Author
Owner

@obgnail commented on GitHub (Dec 7, 2025):

@DavidAnson

Thanks for the support! I managed to implement the rule.

I discovered that inspecting the mathTextSequence token within mathText is the key. By simply checking if mathTextSequence.text.length > 1, I can reliably detect and handle cases like $$ directly.

Appreciate the help! 🙌

Click to view my code
// @ts-check

import { addErrorContext, isEmptyString } from "markdownlint-rule-helpers"
import { filterByTypes } from "markdownlint-rule-helpers/micromark"

const myRule = {
    names: ["inline-math-delimiter"],
    description: "Inline math delimiters should follow the configured style ($ or block)",
    tags: ["math"],
    parser: "micromark",
    function: (params, onError) => {
        const fixStyle = params.config.style || "inline"
        const mathTexts = filterByTypes(params.parsers.micromark.tokens, ["mathText"])
        for (const mathText of mathTexts) {
            const sequence = mathText.children.find((t) => t.type === "mathTextSequence")
            const textData = mathText.children.find((t) => t.type === "mathTextData")
            if (!sequence || sequence.text.length <= 1) continue

            const length = mathText.endColumn - mathText.startColumn
            const content = textData ? textData.text : ""

            let fixedText = ""
            if (fixStyle === "inline") {
                fixedText = "$" + content + "$"
            } else {
                const line = params.lines[mathText.startLine - 1]
                const pre = isEmptyString(line.slice(0, mathText.startColumn - 1)) ? "" : "\n"
                const post = isEmptyString(line.slice(mathText.endColumn - 1)) ? "" : "\n"
                fixedText = pre + "$$\n" + content + "\n$$" + post
            }

            addErrorContext(
                onError,
                mathText.startLine,
                mathText.text,
                undefined,
                undefined,
                [mathText.startColumn, length],
                {
                    editColumn: mathText.startColumn,
                    deleteCount: length,
                    insertText: fixedText,
                }
            )
        }
    },
};

export default myRule;
<!-- gh-comment-id:3623500922 --> @obgnail commented on GitHub (Dec 7, 2025): @DavidAnson Thanks for the support! I managed to implement the rule. I discovered that inspecting the **`mathTextSequence`** token within `mathText` is the key. By simply checking if `mathTextSequence.text.length > 1`, I can reliably detect and handle cases like `$$` directly. Appreciate the help! 🙌 <details> <summary>Click to view my code</summary> ```js // @ts-check import { addErrorContext, isEmptyString } from "markdownlint-rule-helpers" import { filterByTypes } from "markdownlint-rule-helpers/micromark" const myRule = { names: ["inline-math-delimiter"], description: "Inline math delimiters should follow the configured style ($ or block)", tags: ["math"], parser: "micromark", function: (params, onError) => { const fixStyle = params.config.style || "inline" const mathTexts = filterByTypes(params.parsers.micromark.tokens, ["mathText"]) for (const mathText of mathTexts) { const sequence = mathText.children.find((t) => t.type === "mathTextSequence") const textData = mathText.children.find((t) => t.type === "mathTextData") if (!sequence || sequence.text.length <= 1) continue const length = mathText.endColumn - mathText.startColumn const content = textData ? textData.text : "" let fixedText = "" if (fixStyle === "inline") { fixedText = "$" + content + "$" } else { const line = params.lines[mathText.startLine - 1] const pre = isEmptyString(line.slice(0, mathText.startColumn - 1)) ? "" : "\n" const post = isEmptyString(line.slice(mathText.endColumn - 1)) ? "" : "\n" fixedText = pre + "$$\n" + content + "\n$$" + post } addErrorContext( onError, mathText.startLine, mathText.text, undefined, undefined, [mathText.startColumn, length], { editColumn: mathText.startColumn, deleteCount: length, insertText: fixedText, } ) } }, }; export default myRule; ``` </details>
Author
Owner

@sleepyy-dog commented on GitHub (Dec 8, 2025):

thanks to @DavidAnson @obgnail ,i solve my probelm with the following rule code,thanks your helps!

click here to view code
var Me={
  names:["MD103","inline-display-math-to-block"],
  description:"Convert inline $$...$$ math to block-style with appropriate surrounding lines",
  tags:["math","formatting"],
  parser:"micromark",
  function:(e,t)=>{
    for(let n of _(e.parsers.micromark.tokens,["mathText"])){
      // 只处理 $$...$$ (两个及以上 $),跳过 $...$
      let seq = n.children && n.children.find((c)=>c.type==="mathTextSequence");
      if(!seq || !seq.text || seq.text.length < 2){
        continue;
      }
      // 只处理单行内联
      if(n.startLine !== n.endLine){
        continue;
      }
      
      let line = e.lines[n.startLine - 1];
      let startCol0 = n.startColumn - 1;
      let endCol0   = n.endColumn - 1;
      let before = line.slice(0, startCol0);
      let inner = line.slice(startCol0, endCol0);
      let after = line.slice(endCol0);
      
      let m = inner.match(/^\s*\$\$(.*)\$\$\s*$/);
      if(!m){
        continue;
      }
      
      let content = m[1].trim();
      if(!content){
        continue;
      }
      
      // 判断是否独立成行(前后都只有空白字符或为空)
      let isStandalone = before.trim() === "" && after.trim() === "";
      
      // 独立成行:不需要额外空行
      // 不独立成行:需要前后各补一个空行
      let fixedText = isStandalone 
        ? `\n$$\n${content}\n$$\n`
        : `\n\n$$\n${content}\n$$\n\n`;
      
      let length = n.endColumn - n.startColumn;
      
      U(
        t,
        n.startLine,
        inner.trim(),
        void 0,
        void 0,
        [n.startColumn, length],
        {
          editColumn: n.startColumn,
          deleteCount: length,
          insertText: fixedText
        }
      );
    }
  }
};

module.exports=[Oe,Pe,Me];

<!-- gh-comment-id:3624280261 --> @sleepyy-dog commented on GitHub (Dec 8, 2025): thanks to @DavidAnson @obgnail ,i solve my probelm with the following rule code,thanks your helps! <details> <summary>click here to view code</summary> ```js var Me={ names:["MD103","inline-display-math-to-block"], description:"Convert inline $$...$$ math to block-style with appropriate surrounding lines", tags:["math","formatting"], parser:"micromark", function:(e,t)=>{ for(let n of _(e.parsers.micromark.tokens,["mathText"])){ // 只处理 $$...$$ (两个及以上 $),跳过 $...$ let seq = n.children && n.children.find((c)=>c.type==="mathTextSequence"); if(!seq || !seq.text || seq.text.length < 2){ continue; } // 只处理单行内联 if(n.startLine !== n.endLine){ continue; } let line = e.lines[n.startLine - 1]; let startCol0 = n.startColumn - 1; let endCol0 = n.endColumn - 1; let before = line.slice(0, startCol0); let inner = line.slice(startCol0, endCol0); let after = line.slice(endCol0); let m = inner.match(/^\s*\$\$(.*)\$\$\s*$/); if(!m){ continue; } let content = m[1].trim(); if(!content){ continue; } // 判断是否独立成行(前后都只有空白字符或为空) let isStandalone = before.trim() === "" && after.trim() === ""; // 独立成行:不需要额外空行 // 不独立成行:需要前后各补一个空行 let fixedText = isStandalone ? `\n$$\n${content}\n$$\n` : `\n\n$$\n${content}\n$$\n\n`; let length = n.endColumn - n.startColumn; U( t, n.startLine, inner.trim(), void 0, void 0, [n.startColumn, length], { editColumn: n.startColumn, deleteCount: length, insertText: fixedText } ); } } }; module.exports=[Oe,Pe,Me]; ```
Author
Owner

@tichaonacherai-sudo commented on GitHub (Dec 23, 2025):

Very well done

<!-- gh-comment-id:3684662823 --> @tichaonacherai-sudo commented on GitHub (Dec 23, 2025): Very well done
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/markdownlint#2617
No description provided.