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:

  1. Package Scope Resolution: If the path does not have an extension, is a single word, or begins with @, markdownlint-cli treats it as an NPM package. The tool searches your local node_modules directory relative to your working directory, and checks global system folders if not found.
  2. Directory Resolution: If the path is a folder, the CLI reads and processes all .js, .cjs, and .mjs files inside the directory.
  3. Glob Resolution: If the path is a glob string, the tool resolves all matching JavaScript files.
  4. 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) and onError (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.'
      });
    }
  }
};