| 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:
module.exports = {
names: ["rule-name", "RULE001"],
description: "Description of what this rule checks",
tags: ["custom", "style"],
parser: "markdownit",
function: function(params, onError) {
}
};
Required Properties
{
names: Array<String>,
description: String,
tags: Array<String>,
parser: String,
function: Function
}
Optional Properties
{
information: URL,
asynchronous: Boolean
}
Parser Selection
markdown-it Parser
Best for token-based parsing with rich metadata:
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:
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:
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:
function rule(params, onError) {
}
Accessing Lines
function: (params, onError) => {
params.lines.forEach((line, index) => {
const lineNumber = index + 1;
if (someCondition(line)) {
onError({
lineNumber,
detail: "Issue description",
context: line.trim()
});
}
});
}
Using Configuration
{
"custom-rule": {
"max_length": 50,
"pattern": "^[A-Z]"
}
}
function: (params, onError) => {
const config = params.config || {};
const maxLength = config.max_length || 40;
const pattern = config.pattern ? new RegExp(config.pattern) : null;
}
Working with Front Matter
function: (params, onError) => {
const frontMatterLines = params.frontMatterLines;
if (frontMatterLines.length > 0) {
const frontMatter = frontMatterLines.join('\n');
}
}
Error Reporting with onError
Basic Error Reporting
onError({
lineNumber: 5,
detail: "Line exceeds maximum length",
context: "This is the problematic..."
});
Error with Range
Highlight specific portion of the line:
onError({
lineNumber: 10,
detail: "Invalid heading format",
context: "### Heading",
range: [1, 3]
});
Error with Fix Information
Enable automatic fixing:
onError({
lineNumber: 15,
detail: "Extra whitespace",
context: " text ",
fixInfo: {
editColumn: 1,
deleteCount: 2,
insertText: ""
}
});
Automatic Fixing with fixInfo
Delete Characters
fixInfo: {
lineNumber: 5,
editColumn: 10,
deleteCount: 5
}
Insert Text
fixInfo: {
lineNumber: 3,
editColumn: 1,
insertText: "# "
}
Replace Text
fixInfo: {
lineNumber: 7,
editColumn: 5,
deleteCount: 3,
insertText: "new"
}
Delete Entire Line
fixInfo: {
lineNumber: 10,
deleteCount: -1
}
Insert New Line
fixInfo: {
lineNumber: 8,
insertText: "\n"
}
Multi-Line Fix
Report multiple fixes for the same violation:
function: (params, onError) => {
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
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: ,
: headingLine,
: [hashCount + , ],
: {
: hashCount + ,
: ,
: firstChar.()
}
});
}
}
}
}
};
Require Blank Line Before Headings
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;
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 - ,
: previousLine. + ,
:
}
});
}
}
}
};
Validate Code Block Language
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.,
: ,
: fence.
});
}
}
}
};
Detect Broken Relative Links
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];
if (!href.startsWith('http://') && !href.startsWith('https://')) {
const filePath = path.(path.(params.), href);
{
fs..(filePath);
} (err) {
({
: link.,
: ,
: link.
});
}
}
}
}
}
};
Enforce Consistent List Markers
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 - );
( item items.()) {
marker = params.[item. - ]
.(item. - );
(marker !== firstMarker) {
({
: item.,
: ,
: params.[item. - ],
: [item., ],
: {
: item.,
: ,
: firstMarker
}
});
}
}
}
}
}
};
Asynchronous Rules
Basic Async Rule
module.exports = {
names: ["async-rule-example"],
description: "Example asynchronous rule",
tags: ["async", "custom"],
parser: "none",
asynchronous: true,
function: async (params, onError) => {
const result = await someAsyncOperation();
if (!result.valid) {
onError({
lineNumber: 1,
detail: "Async validation failed"
});
}
}
};
Network Validation
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.( attr[] === );
(hrefToken) {
href = hrefToken[];
(href.() || href.()) {
valid = (href);
(!valid) {
({
: link.,
: ,
: link.
});
}
}
}
}
}
};
Using Custom Rules
In Configuration File
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
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
markdownlint -c .markdownlint.js -r ./custom-rules/*.js *.md
TypeScript Support
Type-Safe Rule Definition
import { Rule } from 'markdownlint';
const rule: Rule = {
names: ['typescript-rule', 'TS001'],
description: 'Example TypeScript custom rule',
tags: ['custom'],
parser: 'markdownit',
function: (params, onError) => {
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
- Clear Rule Names - Use descriptive names that indicate purpose
- Comprehensive Descriptions - Document what the rule checks
- Appropriate Tags - Categorize rules for easy filtering
- Choose Right Parser - Use markdownit for most cases, micromark for precision
- Provide Information URLs - Link to detailed rule documentation
- Support Configuration - Allow rule customization via params.config
- Helpful Error Messages - Provide clear detail and context
- Use Range When Possible - Highlight exact problem location
- Implement fixInfo - Enable automatic fixing when possible
- Handle Edge Cases - Account for front matter, empty files, etc.
- Performance Consideration - Avoid expensive operations in rules
- Test Thoroughly - Test with various markdown files
- Version Documentation - Document which markdownlint version required
- Export Properly - Use module.exports or ES6 exports consistently
- Async When Needed - Only use asynchronous for I/O operations
Common Pitfalls
- Wrong Line Numbers - Forgetting lines are 1-based, not 0-based
- Missing Parser - Not specifying parser property
- Incorrect Token Types - Using wrong token type names
- No Error Context - Not providing helpful context in errors
- Synchronous I/O - Using sync functions instead of async
- Ignoring Front Matter - Not handling front matter correctly
- Hardcoded Values - Not using configuration parameters
- Poor Performance - Using inefficient algorithms on large files
- Missing Fixability - Not implementing fixInfo when possible
- Incomplete Testing - Not testing edge cases and error conditions
- Parser Mismatch - Accessing wrong parser output
- Column Off-by-One - Columns are 1-based like line numbers
- Memory Leaks - Not cleaning up in async rules
- Blocking Operations - Long-running synchronous operations
- Type Confusion - Mixing up token properties between parsers
Resources