Custom Rules
While markdownlint-cli ships with a robust default ruleset, you can expand its checks using custom rules written in JavaScript. Custom rules are useful to enforce team-specific constraints, like prohibiting outdated product names, requiring specific front matter properties, or validating blockquote formats.
Loading Rules with -r or --rules
To load custom rules, pass their location using the -r or --rules parameter:
markdownlint --rules custom-rules/rule.cjs README.md
You can define multiple rule paths by specifying the flag multiple times:
markdownlint --rules rules/branding.cjs --rules rules/structure/ .
Rule Resolution and Resolution Logic
When parsing arguments, the CLI handles rules with the following logic:
- Package Scope Resolution: If the path does not have an extension, is a single word, or begins with
@,markdownlint-clitreats it as an NPM package. The tool searches your localnode_modulesdirectory relative to your working directory, and checks global system folders if not found. - Directory Resolution: If the path is a folder, the CLI reads and processes all
.js,.cjs, and.mjsfiles inside the directory. - Glob Resolution: If the path is a glob string, the tool resolves all matching JavaScript files.
- Fallback: If none of these match, the CLI treats the path as a direct local file relative to your working directory.
Anatomy of a Custom Rule
A custom rule is defined as a JavaScript module that exports a rule configuration object or an array of rule objects.
Custom Rule Properties:
names: An array of unique identifiers for your rule. The first name is treated as the primary identifier, with subsequent names acting as aliases.description: A short explanation of the style violation.tags: An array of categories for grouping rules (e.g.,headings,links,spaces).function: The validation logic, invoked with two arguments:params(containing document tokens and lines) andonError(a callback used to report errors).
Code Examples
Example 1: Exclude Draft Placeholders (test-rule-1.cjs)
This rule reports an error on any line containing standard draft placeholder tags like TODO or FIXME:
module.exports = {
names: ['no-draft-placeholders', 'draft-tags'],
description: 'Prohibits draft placeholders like TODO or FIXME in production docs',
tags: ['editorial', 'content'],
function: (params, onError) => {
const { lines } = params;
lines.forEach((line, index) => {
const match = line.match(/\b(TODO|FIXME)\b/i);
if (match) {
onError({
lineNumber: index + 1,
detail: `Found draft tag: "${match[1]}"`,
context: line
});
}
});
}
};
Example 2: Multi-Rule Module (test-rule-bundle.cjs)
You can bundle and export multiple rules inside a single file:
const ruleH1 = {
names: ['single-h1-rule'],
description: 'Document must contain exactly one top-level H1 header',
tags: ['headers'],
function: (params, onError) => {
// Inspect heading levels using markdown-it AST tokens
const h1Tokens = params.tokens.filter(t => t.type === 'heading_open' && t.tag === 'h1');
if (h1Tokens.length !== 1) {
onError({
lineNumber: 1,
detail: `Expected 1 H1 header, found ${h1Tokens.length}`
});
}
}
};
const ruleNoBake = {
names: ['no-deprecated-product-name'],
description: 'Product names must match latest brand guidelines',
tags: ['branding'],
function: (params, onError) => {
params.lines.forEach((line, index) => {
if (line.includes('BetaProduct')) {
onError({
lineNumber: index + 1,
detail: 'Do not use BetaProduct; use "Enterprise Edition" instead.',
context: line
});
}
});
}
};
module.exports = [ruleH1, ruleNoBake];
Example 3: Packages with Nested Exports
If you are packaging rules inside NPM structures, define a standard package structure:
Package entrypoint configuration (package.json):
{
"name": "markdownlint-cli-local-test-rule",
"version": "0.0.1",
"main": "./index.js",
"type": "commonjs",
"private": true
}
Main export loader (index.js):
module.exports = {
names: ['package-level-rule'],
description: 'Validation provided by custom NPM package format',
tags: ['package'],
function: (params, onError) => {
if (params.lines.length > 5000) {
onError({
lineNumber: 1,
detail: 'Document is too long (over 5000 lines). Break it down into sub-modules.'
});
}
}
};