---
name: markdownlint-custom-rules
user-invocable: false
description: Create custom linting rules for markdownlint including rule structure, parser integration, error reporting, and automatic fixing.
allowed-tools: [Bash, Read]
---

# Markdownlint Custom Rules

Master creating custom markdownlint rules including rule structure, markdown-it and micromark parser integration, error reporting with fixInfo, and asynchronous rule development.

## Overview

Markdownlint allows you to create custom rules tailored to your project's specific documentation requirements. Custom rules can enforce project-specific conventions, validate content patterns, and ensure consistency beyond what built-in rules provide.

## Rule Object Structure

### Basic Rule Definition

Every custom rule must be a JavaScript object with specific properties:

```javascript
module.exports = {
  names: ["rule-name", "RULE001"],
  description: "Description of what this rule checks",
  tags: ["custom", "style"],
  parser: "markdownit",
  function: function(params, onError) {
    // Rule implementation
  }
};
```

### Required Properties

```javascript
{
  names: Array<String>,        // Rule identifiers (required)
  description: String,         // What the rule checks (required)
  tags: Array<String>,         // Categorization tags (required)
  parser: String,              // "markdownit", "micromark", or "none" (required)
  function: Function          // Rule logic (required)
}
```

### Optional Properties

```javascript
{
  information: URL,           // Link to rule documentation
  asynchronous: Boolean      // If true, function returns Promise
}
```

## Parser Selection

### markdown-it Parser

Best for token-based parsing with rich metadata:

```javascript
module.exports = {
  names: ["any-blockquote-markdown-it"],
  description: "Rule that reports an error for any blockquote",
  information: new URL("https://example.com/rules/any-blockquote"),
  tags: ["test"],
  parser: "markdownit",
  function: (params, onError) => {
    const blockquotes = params.parsers.markdownit.tokens
      .filter((token) => token.type === "blockquote_open");

    for (const blockquote of blockquotes) {
      const [startIndex, endIndex] = blockquote.map;
      const lines = endIndex - startIndex;

      onError({
        lineNumber: blockquote.lineNumber,
        detail: `Blockquote spans ${lines} line(s).`,
        context: blockquote.line
      });
    }
  }
};
```

### micromark Parser

Best for detailed token analysis and precise positioning:

```javascript
module.exports = {
  names: ["any-blockquote-micromark"],
  description: "Rule that reports an error for any blockquote",
  information: new URL("https://example.com/rules/any-blockquote"),
  tags: ["test"],
  parser: "micromark",
  function: (params, onError) => {
    const blockquotes = params.parsers.micromark.tokens
      .filter((token) => token.type === "blockQuote");

    for (const blockquote of blockquotes) {
      const lines = blockquote.endLine - blockquote.startLine + 1;

      onError({
        lineNumber: blockquote.startLine,
        detail: `Blockquote spans ${lines} line(s).`,
        context: params.lines[blockquote.startLine - 1]
      });
    }
  }
};
```

### No Parser

For simple line-based rules:

```javascript
module.exports = {
  names: ["no-todo-comments"],
  description: "Disallow TODO comments in markdown",
  tags: ["custom"],
  parser: "none",
  function: (params, onError) => {
    params.lines.forEach((line, index) => {
      if (line.includes("TODO:") || line.includes("FIXME:")) {
        onError({
          lineNumber: index + 1,
          detail: "TODO/FIXME comments should be resolved",
          context: line.trim()
        });
      }
    });
  }
};
```

## Function Parameters

### params Object

The `params` object contains all information about the markdown content:

```javascript
function rule(params, onError) {
  // params.name - Input file/string name
  // params.lines - Array of lines (string[])
  // params.frontMatterLines - Lines of front matter
  // params.config - Rule's configuration from .markdownlint.json
  // params.version - markdownlint library version
  // params.parsers - Parser outputs
}
```

### Accessing Lines

```javascript
function: (params, onError) => {
  params.lines.forEach((line, index) => {
    const lineNumber = index + 1;  // Lines are 1-based

    if (someCondition(line)) {
      onError({
        lineNumber,
        detail: "Issue description",
        context: line.trim()
      });
    }
  });
}
```

### Using Configuration

```javascript
// In .markdownlint.json
{
  "custom-rule": {
    "max_length": 50,
    "pattern": "^[A-Z]"
  }
}

// In rule
function: (params, onError) => {
  const config = params.config || {};
  const maxLength = config.max_length || 40;
  const pattern = config.pattern ? new RegExp(config.pattern) : null;

  // Use configuration values
}
```

### Working with Front Matter

```javascript
function: (params, onError) => {
  const frontMatterLines = params.frontMatterLines;

  if (frontMatterLines.length > 0) {
    // Process YAML front matter
    const frontMatter = frontMatterLines.join('\n');
    // Validate front matter
  }
}
```

## Error Reporting with onError

### Basic Error Reporting

```javascript
onError({
  lineNumber: 5,                          // Required: 1-based line number
  detail: "Line exceeds maximum length",  // Optional: Additional info
  context: "This is the problematic..."   // Optional: Relevant text
});
```

### Error with Range

Highlight specific portion of the line:

```javascript
onError({
  lineNumber: 10,
  detail: "Invalid heading format",
  context: "### Heading",
  range: [1, 3]  // Column 1, length 3 (highlights "###")
});
```

### Error with Fix Information

Enable automatic fixing:

```javascript
onError({
  lineNumber: 15,
  detail: "Extra whitespace",
  context: "  text  ",
  fixInfo: {
    editColumn: 1,
    deleteCount: 2,
    insertText: ""
  }
});
```

## Automatic Fixing with fixInfo

### Delete Characters

```javascript
// Remove 5 characters starting at column 10
fixInfo: {
  lineNumber: 5,
  editColumn: 10,
  deleteCount: 5
}
```

### Insert Text

```javascript
// Insert text at column 1
fixInfo: {
  lineNumber: 3,
  editColumn: 1,
  insertText: "# "
}
```

### Replace Text

```javascript
// Replace 3 characters with new text
fixInfo: {
  lineNumber: 7,
  editColumn: 5,
  deleteCount: 3,
  insertText: "new"
}
```

### Delete Entire Line

```javascript
// Delete the entire line
fixInfo: {
  lineNumber: 10,
  deleteCount: -1
}
```

### Insert New Line

```javascript
// Insert a blank line
fixInfo: {
  lineNumber: 8,
  insertText: "\n"
}
```

### Multi-Line Fix

Report multiple fixes for the same violation:

```javascript
function: (params, onError) => {
  // Fix requires changes on multiple lines
  onError({
    lineNumber: 5,
    detail: "Inconsistent list markers",
    fixInfo: {
      lineNumber: 5,
      editColumn: 1,
      deleteCount: 1,
      insertText: "-"
    }
  });

  onError({
    lineNumber: 6,
    detail: "Inconsistent list markers",
    fixInfo: {
      lineNumber: 6,
      editColumn: 1,
      deleteCount: 1,
      insertText: "-"
    }
  });
}
```

## Complete Rule Examples

### Enforce Heading Capitalization

```javascript
module.exports = {
  names: ["heading-capitalization", "HC001"],
  description: "Headings must start with a capital letter",
  tags: ["headings", "custom"],
  parser: "markdownit",
  function: (params, onError) => {
    const headings = params.parsers.markdownit.tokens
      .filter(token => token.type === "heading_open");

    for (const heading of headings) {
      const headingLine = params.lines[heading.lineNumber - 1];
      const match = headingLine.match(/^#+\s+(.+)$/);

      if (match) {
        const text = match[1];
        const firstChar = text.charAt(0);

        if (firstChar !== firstChar.toUpperCase()) {
          const hashCount = headingLine.indexOf(' ');

          onError({
            lineNumber: heading.lineNumber,
            detail: "Heading must start with capital letter",
            context: headingLine,
            range: [hashCount + 2, 1],
            fixInfo: {
              editColumn: hashCount + 2,
              deleteCount: 1,
              insertText: firstChar.toUpperCase()
            }
          });
        }
      }
    }
  }
};
```

### Require Blank Line Before Headings

```javascript
module.exports = {
  names: ["blank-line-before-heading", "BLH001"],
  description: "Require blank line before headings (except first line)",
  tags: ["headings", "custom", "whitespace"],
  parser: "markdownit",
  function: (params, onError) => {
    const headings = params.parsers.markdownit.tokens
      .filter(token => token.type === "heading_open");

    for (const heading of headings) {
      const lineNumber = heading.lineNumber;

      // Skip if first line or after front matter
      if (lineNumber <= params.frontMatterLines.length + 1) {
        continue;
      }

      const previousLine = params.lines[lineNumber - 2];

      if (previousLine.trim() !== "") {
        onError({
          lineNumber: lineNumber - 1,
          detail: "Expected blank line before heading",
          context: previousLine,
          fixInfo: {
            lineNumber: lineNumber - 1,
            editColumn: previousLine.length + 1,
            insertText: "\n"
          }
        });
      }
    }
  }
};
```

### Validate Code Block Language

```javascript
module.exports = {
  names: ["code-block-language", "CBL001"],
  description: "Code blocks must specify a language",
  tags: ["code", "custom"],
  parser: "markdownit",
  function: (params, onError) => {
    const config = params.config || {};
    const allowedLanguages = config.allowed_languages || [];

    const fences = params.parsers.markdownit.tokens
      .filter(token => token.type === "fence");

    for (const fence of fences) {
      const language = fence.info.trim();

      if (!language) {
        onError({
          lineNumber: fence.lineNumber,
          detail: "Code block must specify a language",
          context: fence.line
        });
      } else if (allowedLanguages.length > 0 && !allowedLanguages.includes(language)) {
        onError({
          lineNumber: fence.lineNumber,
          detail: `Language '${language}' not in allowed list: ${allowedLanguages.join(', ')}`,
          context: fence.line
        });
      }
    }
  }
};
```

### Detect Broken Relative Links

```javascript
const fs = require('fs');
const path = require('path');

module.exports = {
  names: ["no-broken-links", "NBL001"],
  description: "Detect broken relative links",
  tags: ["links", "custom"],
  parser: "markdownit",
  asynchronous: true,
  function: async (params, onError) => {
    const links = params.parsers.markdownit.tokens
      .filter(token => token.type === "link_open");

    for (const link of links) {
      const hrefToken = link.attrs.find(attr => attr[0] === "href");

      if (hrefToken) {
        const href = hrefToken[1];

        // Only check relative links
        if (!href.startsWith('http://') && !href.startsWith('https://')) {
          const filePath = path.join(path.dirname(params.name), href);

          try {
            await fs.promises.access(filePath);
          } catch (err) {
            onError({
              lineNumber: link.lineNumber,
              detail: `Broken link: ${href}`,
              context: link.line
            });
          }
        }
      }
    }
  }
};
```

### Enforce Consistent List Markers

```javascript
module.exports = {
  names: ["consistent-list-markers", "CLM001"],
  description: "Lists must use consistent markers within the same level",
  tags: ["lists", "custom"],
  parser: "micromark",
  function: (params, onError) => {
    const lists = params.parsers.micromark.tokens
      .filter(token => token.type === "listUnordered");

    for (const list of lists) {
      const items = params.parsers.micromark.tokens.filter(
        token => token.type === "listItemMarker" &&
                 token.startLine >= list.startLine &&
                 token.endLine <= list.endLine
      );

      if (items.length > 0) {
        const firstMarker = params.lines[items[0].startLine - 1]
          .charAt(items[0].startColumn - 1);

        for (const item of items.slice(1)) {
          const marker = params.lines[item.startLine - 1]
            .charAt(item.startColumn - 1);

          if (marker !== firstMarker) {
            onError({
              lineNumber: item.startLine,
              detail: `Inconsistent list marker: expected '${firstMarker}', found '${marker}'`,
              context: params.lines[item.startLine - 1],
              range: [item.startColumn, 1],
              fixInfo: {
                editColumn: item.startColumn,
                deleteCount: 1,
                insertText: firstMarker
              }
            });
          }
        }
      }
    }
  }
};
```

## Asynchronous Rules

### Basic Async Rule

```javascript
module.exports = {
  names: ["async-rule-example"],
  description: "Example asynchronous rule",
  tags: ["async", "custom"],
  parser: "none",
  asynchronous: true,
  function: async (params, onError) => {
    // Can use await
    const result = await someAsyncOperation();

    if (!result.valid) {
      onError({
        lineNumber: 1,
        detail: "Async validation failed"
      });
    }

    // Must return Promise (implicitly returned by async function)
  }
};
```

### Network Validation

```javascript
const https = require('https');

module.exports = {
  names: ["validate-external-links"],
  description: "Validate external HTTP links return 200",
  tags: ["links", "async"],
  parser: "markdownit",
  asynchronous: true,
  function: async (params, onError) => {
    const links = params.parsers.markdownit.tokens
      .filter(token => token.type === "link_open");

    const checkLink = (url) => {
      return new Promise((resolve) => {
        https.get(url, (res) => {
          resolve(res.statusCode === 200);
        }).on('error', () => {
          resolve(false);
        });
      });
    };

    for (const link of links) {
      const hrefToken = link.attrs.find(attr => attr[0] === "href");

      if (hrefToken) {
        const href = hrefToken[1];

        if (href.startsWith('http://') || href.startsWith('https://')) {
          const valid = await checkLink(href);

          if (!valid) {
            onError({
              lineNumber: link.lineNumber,
              detail: `External link may be broken: ${href}`,
              context: link.line
            });
          }
        }
      }
    }
  }
};
```

## Using Custom Rules

### In Configuration File

```javascript
// .markdownlint.js
const customRules = require('./custom-rules');

module.exports = {
  default: true,
  customRules: [
    customRules.headingCapitalization,
    customRules.blankLineBeforeHeading,
    customRules.codeBlockLanguage
  ],
  "heading-capitalization": true,
  "blank-line-before-heading": true,
  "code-block-language": {
    "allowed_languages": ["javascript", "typescript", "bash", "json"]
  }
};
```

### In Node.js Script

```javascript
const markdownlint = require('markdownlint');
const customRules = require('./custom-rules');

const options = {
  files: ['README.md'],
  customRules: [
    customRules.headingCapitalization,
    customRules.blankLineBeforeHeading
  ],
  config: {
    default: true,
    "heading-capitalization": true,
    "blank-line-before-heading": true
  }
};

markdownlint(options, (err, result) => {
  if (!err) {
    console.log(result.toString());
  }
});
```

### With markdownlint-cli

```bash
# Using custom rules with CLI
markdownlint -c .markdownlint.js -r ./custom-rules/*.js *.md
```

## TypeScript Support

### Type-Safe Rule Definition

```typescript
import { Rule } from 'markdownlint';

const rule: Rule = {
  names: ['typescript-rule', 'TS001'],
  description: 'Example TypeScript custom rule',
  tags: ['custom'],
  parser: 'markdownit',
  function: (params, onError) => {
    // Type-safe implementation
    params.parsers.markdownit.tokens.forEach(token => {
      if (token.type === 'heading_open') {
        onError({
          lineNumber: token.lineNumber,
          detail: 'Example error'
        });
      }
    });
  }
};

export default rule;
```

## When to Use This Skill

- Enforcing project-specific documentation standards
- Validating custom markdown patterns
- Checking domain-specific requirements
- Extending markdownlint beyond built-in rules
- Creating reusable rule packages
- Automating documentation quality checks
- Implementing team coding standards
- Building custom linting toolchains

## Best Practices

1. **Clear Rule Names** - Use descriptive names that indicate purpose
2. **Comprehensive Descriptions** - Document what the rule checks
3. **Appropriate Tags** - Categorize rules for easy filtering
4. **Choose Right Parser** - Use markdownit for most cases, micromark for precision
5. **Provide Information URLs** - Link to detailed rule documentation
6. **Support Configuration** - Allow rule customization via params.config
7. **Helpful Error Messages** - Provide clear detail and context
8. **Use Range When Possible** - Highlight exact problem location
9. **Implement fixInfo** - Enable automatic fixing when possible
10. **Handle Edge Cases** - Account for front matter, empty files, etc.
11. **Performance Consideration** - Avoid expensive operations in rules
12. **Test Thoroughly** - Test with various markdown files
13. **Version Documentation** - Document which markdownlint version required
14. **Export Properly** - Use module.exports or ES6 exports consistently
15. **Async When Needed** - Only use asynchronous for I/O operations

## Common Pitfalls

1. **Wrong Line Numbers** - Forgetting lines are 1-based, not 0-based
2. **Missing Parser** - Not specifying parser property
3. **Incorrect Token Types** - Using wrong token type names
4. **No Error Context** - Not providing helpful context in errors
5. **Synchronous I/O** - Using sync functions instead of async
6. **Ignoring Front Matter** - Not handling front matter correctly
7. **Hardcoded Values** - Not using configuration parameters
8. **Poor Performance** - Using inefficient algorithms on large files
9. **Missing Fixability** - Not implementing fixInfo when possible
10. **Incomplete Testing** - Not testing edge cases and error conditions
11. **Parser Mismatch** - Accessing wrong parser output
12. **Column Off-by-One** - Columns are 1-based like line numbers
13. **Memory Leaks** - Not cleaning up in async rules
14. **Blocking Operations** - Long-running synchronous operations
15. **Type Confusion** - Mixing up token properties between parsers

## Resources

- [markdownlint Custom Rules Guide](https://github.com/DavidAnson/markdownlint/blob/main/doc/CustomRules.md)
- [markdownlint GitHub Repository](https://github.com/DavidAnson/markdownlint)
- [markdown-it Parser](https://github.com/markdown-it/markdown-it)
- [micromark Parser](https://github.com/micromark/micromark)
- [Community Custom Rules on npm](https://www.npmjs.com/search?q=keywords:markdownlint-rule)


---
Source attribution: TheBushidoCollective
Original project: https://github.com/TheBushidoCollective/han/blob/19caa51d5fd144ef6b2764f2332f4c856aa5d1af/plugins/validation/markdown/skills/markdownlint-custom-rules/SKILL.md
License: Apache-2.0
License information: https://www.apache.org/licenses/LICENSE-2.0
Original upstream instructions; downloaded from Mega AI Hub.

## Upstream license notice

# Functional Source License, Version 1.1, ALv2 Future License

## Abbreviation

FSL-1.1-ALv2

## Notice

Copyright 2025 The Bushido Collective

## Terms and Conditions

### Licensor ("We")

The party offering the Software under these Terms and Conditions.

### The Software

The "Software" is each version of the software that we make available under
these Terms and Conditions, as indicated by our inclusion of these Terms and
Conditions with the Software.

### License Grant

Subject to your compliance with this License Grant and the Patents,
Redistribution and Trademark clauses below, we hereby grant you the right to
use, copy, modify, create derivative works, publicly perform, publicly display
and redistribute the Software for any Permitted Purpose identified below.

### Permitted Purpose

A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
means making the Software available to others in a commercial product or
service that:

1. substitutes for the Software;

2. substitutes for any other product or service we offer using the Software
   that exists as of the date we make the Software available; or

3. offers the same or substantially similar functionality as the Software.

Permitted Purposes specifically include using the Software:

1. for your internal use and access;

2. for non-commercial education;

3. for non-commercial research; and

4. in connection with professional services that you provide to a licensee
   using the Software in accordance with these Terms and Conditions.

### Patents

To the extent your use for a Permitted Purpose would necessarily infringe our
patents, the license grant above includes a license under our patents. If you
make a claim against any party that the Software infringes or contributes to
the infringement of any patent, then your patent license to the Software ends
immediately.

### Redistribution

The Terms and Conditions apply to all copies, modifications and derivatives of
the Software.

If you redistribute any copies, modifications or derivatives of the Software,
you must include a copy of or a link to these Terms and Conditions and not
remove any copyright notices provided in or with the Software.

### Disclaimer

THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.

IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.

### Trademarks

Except for displaying the License Details and identifying us as the origin of
the Software, you have no right under these Terms and Conditions to use our
trademarks, trade names, service marks or product names.

## Grant of Future License

We hereby irrevocably grant you an additional license to use the Software under
the Apache License, Version 2.0 that is effective on the second anniversary of
the date we make the Software available. On or after that date, you may use the
Software under the Apache License, Version 2.0, in which case the following
will apply:

Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License.

You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.



# Mega AI Hub resource metadata

Creator / publisher: TheBushidoCollective
Original project: https://github.com/TheBushidoCollective/han/blob/19caa51d5fd144ef6b2764f2332f4c856aa5d1af/plugins/validation/markdown/skills/markdownlint-custom-rules/SKILL.md
Attribution basis: Source repository record

Fields of work: Content & Media, Design, Marketing
Roles: Content Marketer, Technical Writer, UX Writer
Specialties: API
Tools: Bash
Country applicability: Country not specified
Classification basis: Source catalog labels
Instruction SHA-256: 71efe853a286cd39de25fcaac409800d7760d605b4772182fb0ed8cb406288d7
