Custom Linting Rules

This guide covers how to configure linting rules for your specific needs, including markdownlint customization, custom frontmatter validation, and creating project-specific style guides.

Table of Contents #


markdownlint Configuration #

Configuration File Formats #

markdownlint supports multiple configuration formats:

JSON (.markdownlint.json)

{
  "default": true,
  "MD013": false,
  "MD033": {
    "allowed_elements": ["details", "summary", "kbd"]
  }
}

YAML (.markdownlint.yaml)

default: true
MD013: false
MD033:
  allowed_elements:
    - details
    - summary
    - kbd

JavaScript (.markdownlint.cjs)

module.exports = {
  default: true,
  MD013: false,
  MD033: {
    allowed_elements: ['details', 'summary', 'kbd'],
  },
};

Complete Rule Reference #

Here’s a comprehensive configuration covering all common rules:

{
  "default": true,

  "MD001": true,
  "MD003": { "style": "atx" },
  "MD004": { "style": "dash" },
  "MD005": true,
  "MD007": { "indent": 2, "start_indented": false },
  "MD009": { "br_spaces": 2, "list_item_empty_lines": false },
  "MD010": { "code_blocks": true, "spaces_per_tab": 2 },
  "MD011": true,
  "MD012": { "maximum": 2 },

  "MD013": {
    "line_length": 120,
    "heading_line_length": 80,
    "code_block_line_length": 120,
    "code_blocks": false,
    "tables": false
  },

  "MD014": true,

  "MD018": true,
  "MD019": true,
  "MD020": true,
  "MD021": true,
  "MD022": { "lines_above": 1, "lines_below": 1 },
  "MD023": true,
  "MD024": { "siblings_only": true },
  "MD025": { "level": 1, "front_matter_title": "^\\s*title\\s*[:=]" },
  "MD026": { "punctuation": ".,;:!" },
  "MD027": true,
  "MD028": true,

  "MD029": { "style": "ordered" },
  "MD030": { "ul_single": 1, "ol_single": 1, "ul_multi": 1, "ol_multi": 1 },
  "MD031": { "list_items": true },
  "MD032": true,

  "MD033": {
    "allowed_elements": [
      "a", "abbr", "audio", "b", "br", "caption",
      "cite", "code", "col", "colgroup", "dd", "del",
      "details", "dfn", "div", "dl", "dt", "em",
      "figcaption", "figure", "h1", "h2", "h3", "h4",
      "h5", "h6", "hr", "i", "iframe", "img", "ins",
      "kbd", "li", "mark", "ol", "p", "picture", "pre",
      "q", "s", "samp", "small", "source", "span",
      "strong", "sub", "summary", "sup", "table",
      "tbody", "td", "tfoot", "th", "thead", "tr",
      "u", "ul", "var", "video"
    ]
  },

  "MD034": true,
  "MD035": { "style": "---" },
  "MD036": { "punctuation": ".,;:!?" },
  "MD037": true,
  "MD038": true,
  "MD039": true,
  "MD040": true,
  "MD041": false,
  "MD042": true,
  "MD043": false,
  "MD044": {
    "names": ["markata-go", "GitHub", "GitLab", "JavaScript", "TypeScript"],
    "code_blocks": false
  },
  "MD045": true,
  "MD046": { "style": "fenced" },
  "MD047": true,
  "MD048": { "style": "backtick" },
  "MD049": { "style": "underscore" },
  "MD050": { "style": "asterisk" },
  "MD051": true,
  "MD052": true,
  "MD053": true
}

Rule Categories #

Category Rules Description
Headings MD001-MD003, MD018-MD025 Heading structure and style
Lists MD004-MD007, MD029-MD032 List formatting
Whitespace MD009-MD012, MD027-MD028 Spacing and blank lines
Code MD014, MD031, MD038, MD040, MD046, MD048 Code blocks and inline code
Links MD034, MD039, MD042, MD051-MD053 Link formatting
Emphasis MD036-MD037, MD049-MD050 Bold/italic style

Custom markdownlint Rules #

Creating a Custom Rule #

Custom rules are JavaScript modules. Create custom-rules/no-todo-comments.js:

// custom-rules/no-todo-comments.js
module.exports = {
  names: ['no-todo-comments'],
  description: 'Disallow TODO comments in content',
  tags: ['content', 'todo'],
  function: function rule(params, onError) {
    params.tokens.forEach((token) => {
      if (token.type === 'inline') {
        const todoMatch = token.content.match(/\bTODO\b/i);
        if (todoMatch) {
          onError({
            lineNumber: token.lineNumber,
            detail: 'Remove TODO comment before publishing',
            context: token.content,
          });
        }
      }
    });
  },
};

Registering Custom Rules #

In .markdownlint.cjs:

const noTodoComments = require('./custom-rules/no-todo-comments');
const requireDescription = require('./custom-rules/require-description');

module.exports = {
  default: true,
  MD013: false,
  customRules: [noTodoComments, requireDescription],
  'no-todo-comments': true,
  'require-description': { minLength: 50 },
};

Example Custom Rules #

Require minimum content length:

// custom-rules/min-content-length.js
module.exports = {
  names: ['min-content-length'],
  description: 'Enforce minimum content length',
  tags: ['content', 'length'],
  function: function rule(params, onError) {
    const minLength = params.config.minLength || 300;
    const content = params.lines.join('\n');
    
    // Remove frontmatter
    const bodyMatch = content.match(/^---[\s\S]*?---\n([\s\S]*)$/);
    const body = bodyMatch ? bodyMatch[1] : content;
    
    // Remove code blocks and count words
    const textOnly = body.replace(/```[\s\S]*?```/g, '').replace(/`[^`]+`/g, '');
    const wordCount = textOnly.split(/\s+/).filter(Boolean).length;
    
    if (wordCount < minLength) {
      onError({
        lineNumber: 1,
        detail: `Content has ${wordCount} words, minimum is ${minLength}`,
      });
    }
  },
};

Check heading hierarchy:

// custom-rules/heading-hierarchy.js
module.exports = {
  names: ['heading-hierarchy'],
  description: 'Ensure headings follow proper hierarchy',
  tags: ['headings', 'structure'],
  function: function rule(params, onError) {
    let lastLevel = 0;
    
    params.tokens
      .filter((token) => token.type === 'heading_open')
      .forEach((token) => {
        const level = parseInt(token.tag.substring(1), 10);
        
        if (lastLevel > 0 && level > lastLevel + 1) {
          onError({
            lineNumber: token.lineNumber,
            detail: `Heading level jumped from h${lastLevel} to h${level}`,
            context: token.line,
          });
        }
        
        lastLevel = level;
      });
  },
};

Frontmatter Validation Rules #

YAML Schema Validation #

Create a JSON Schema for frontmatter validation. Create frontmatter-schema.json:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "markata-go Frontmatter",
  "type": "object",
  "required": ["title", "date", "published"],
  "properties": {
    "title": {
      "type": "string",
      "minLength": 1,
      "maxLength": 100
    },
    "description": {
      "type": "string",
      "minLength": 50,
      "maxLength": 160
    },
    "date": {
      "type": "string",
      "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
    },
    "published": {
      "type": "boolean"
    },
    "slug": {
      "type": "string",
      "pattern": "^/[a-z0-9-/]+/?$"
    },
    "tags": {
      "type": "array",
      "items": {
        "type": "string",
        "pattern": "^[a-z0-9-]+$"
      },
      "minItems": 1,
      "uniqueItems": true
    },
    "author": {
      "type": "string"
    },
    "image": {
      "type": "string",
      "format": "uri-reference"
    },
    "draft": {
      "type": "boolean"
    }
  },
  "additionalProperties": true
}

Validation Script #

Create scripts/validate-frontmatter-schema.sh:

#!/bin/bash
# scripts/validate-frontmatter-schema.sh
# Validates frontmatter against JSON Schema

set -e

# Requires: pip install check-jsonschema pyyaml

schema_file="frontmatter-schema.json"
errors=0

for file in "$@"; do
    [[ "$file" != *.md ]] && continue
    
    # Extract frontmatter
    frontmatter=$(sed -n '/^---$/,/^---$/p' "$file" | sed '1d;$d')
    
    if [ -z "$frontmatter" ]; then
        echo "WARNING: $file - No frontmatter found"
        continue
    fi
    
    # Convert to JSON and validate
    echo "$frontmatter" | python3 -c "
import sys, yaml, json
data = yaml.safe_load(sys.stdin.read())
print(json.dumps(data))
" > /tmp/frontmatter.json
    
    if ! check-jsonschema --schemafile "$schema_file" /tmp/frontmatter.json 2>/dev/null; then
        echo "ERROR: $file - Frontmatter validation failed"
        check-jsonschema --schemafile "$schema_file" /tmp/frontmatter.json 2>&1 | sed 's/^/  /'
        ((errors++))
    fi
done

if [ $errors -gt 0 ]; then
    echo ""
    echo "Found $errors frontmatter validation error(s)"
    exit 1
fi

echo "All frontmatter valid!"

Pre-commit Hook #

repos:
  - repo: local
    hooks:
      - id: validate-frontmatter-schema
        name: Validate Frontmatter Schema
        entry: bash scripts/validate-frontmatter-schema.sh
        language: system
        files: \.(md|markdown)$
        pass_filenames: true
        additional_dependencies:
          - check-jsonschema
          - pyyaml

Project Style Guides #

Documentation Style Guide #

Create a comprehensive style configuration for documentation projects:

{
  "default": true,
  
  "MD003": { "style": "atx" },
  "MD004": { "style": "dash" },
  "MD007": { "indent": 2 },
  
  "MD013": false,
  
  "MD022": { "lines_above": 1, "lines_below": 1 },
  "MD024": { "siblings_only": true },
  "MD025": { "front_matter_title": "^\\s*title\\s*[:=]" },
  
  "MD033": {
    "allowed_elements": [
      "details", "summary", "kbd", "br", "sup", "sub",
      "img", "video", "audio", "source", "iframe",
      "table", "thead", "tbody", "tr", "th", "td"
    ]
  },
  
  "MD036": false,
  "MD041": false,
  
  "MD044": {
    "names": [
      "markata-go",
      "GitHub",
      "GitLab",
      "JavaScript",
      "TypeScript",
      "Go",
      "Markdown",
      "YAML",
      "TOML",
      "JSON"
    ],
    "code_blocks": false
  },
  
  "MD046": { "style": "fenced" },
  "MD048": { "style": "backtick" }
}

Blog Style Guide #

For blog-style content with more flexibility:

{
  "default": true,
  
  "MD003": { "style": "atx" },
  "MD004": { "style": "dash" },
  
  "MD013": false,
  
  "MD022": { "lines_above": 1, "lines_below": 1 },
  "MD024": { "siblings_only": true },
  
  "MD033": false,
  
  "MD036": false,
  "MD041": false,
  
  "MD046": { "style": "fenced" },
  "MD048": { "style": "backtick" }
}

API Documentation Style #

For technical API documentation:

{
  "default": true,
  
  "MD003": { "style": "atx" },
  "MD004": { "style": "dash" },
  "MD007": { "indent": 2 },
  
  "MD013": {
    "line_length": 100,
    "code_blocks": false,
    "tables": false
  },
  
  "MD022": { "lines_above": 1, "lines_below": 1 },
  "MD024": false,
  "MD025": { "front_matter_title": "^\\s*title\\s*[:=]" },
  
  "MD033": {
    "allowed_elements": ["br", "code", "pre"]
  },
  
  "MD036": false,
  "MD041": false,
  
  "MD040": true,
  "MD046": { "style": "fenced" },
  "MD048": { "style": "backtick" }
}

Rule Inheritance and Overrides #

Base Configuration #

Create a base configuration in config/.markdownlint-base.json:

{
  "default": true,
  "MD013": false,
  "MD033": false,
  "MD041": false
}

Extending Base Config #

In project root .markdownlint.json:

{
  "extends": "config/.markdownlint-base.json",
  "MD024": { "siblings_only": true },
  "MD044": {
    "names": ["MyProject"],
    "code_blocks": false
  }
}

Directory-Specific Overrides #

Create override files for specific directories:

docs/.markdownlint.json:

{
  "extends": "../.markdownlint.json",
  "MD013": {
    "line_length": 100
  },
  "MD025": {
    "front_matter_title": "^\\s*title\\s*[:=]"
  }
}

blog/.markdownlint.json:

{
  "extends": "../.markdownlint.json",
  "MD013": false,
  "MD033": false
}

Inline Overrides #

Disable rules for specific sections:

<!-- markdownlint-disable MD033 -->
<details>
<summary>Click to expand</summary>

This content uses raw HTML.

</details>
<!-- markdownlint-enable MD033 -->

Disable rules for entire file:

<!-- markdownlint-disable-file MD013 MD033 -->

# This File Has Custom Rules

Long lines are allowed here...

Creating Custom Validators #

Python Validator #

Create a reusable Python validator:

#!/usr/bin/env python3
# scripts/validate_content.py
"""Content validation for markata-go sites."""

import argparse
import re
import sys
from pathlib import Path
from typing import List, Tuple

import yaml


def extract_frontmatter(content: str) -> Tuple[dict, str]:
    """Extract frontmatter and body from content."""
    match = re.match(r'^---\n(.*?)\n---\n(.*)$', content, re.DOTALL)
    if not match:
        return {}, content
    
    try:
        frontmatter = yaml.safe_load(match.group(1))
        return frontmatter or {}, match.group(2)
    except yaml.YAMLError:
        return {}, content


def check_required_fields(
    frontmatter: dict,
    required: List[str],
    file_path: Path
) -> List[str]:
    """Check for required frontmatter fields."""
    errors = []
    for field in required:
        if field not in frontmatter:
            errors.append(f"{file_path}: Missing required field '{field}'")
    return errors


def check_description_length(
    frontmatter: dict,
    min_length: int,
    max_length: int,
    file_path: Path
) -> List[str]:
    """Check description length for SEO."""
    errors = []
    description = frontmatter.get('description', '')
    
    if description and len(description) < min_length:
        errors.append(
            f"{file_path}: Description too short "
            f"({len(description)} < {min_length})"
        )
    
    if description and len(description) > max_length:
        errors.append(
            f"{file_path}: Description too long "
            f"({len(description)} > {max_length})"
        )
    
    return errors


def check_tags(
    frontmatter: dict,
    allowed_tags: List[str],
    file_path: Path
) -> List[str]:
    """Check that tags are from allowed list."""
    warnings = []
    tags = frontmatter.get('tags', [])
    
    for tag in tags:
        if allowed_tags and tag not in allowed_tags:
            warnings.append(
                f"{file_path}: Unknown tag '{tag}'"
            )
    
    return warnings


def check_images_have_alt(body: str, file_path: Path) -> List[str]:
    """Check that all images have alt text."""
    errors = []
    
    # Find images with empty alt text
    empty_alt = re.findall(r'!\[\s*\]\([^)]+\)', body)
    for img in empty_alt:
        errors.append(f"{file_path}: Image missing alt text: {img[:50]}...")
    
    return errors


def validate_file(
    file_path: Path,
    config: dict
) -> Tuple[List[str], List[str]]:
    """Validate a single file."""
    errors = []
    warnings = []
    
    content = file_path.read_text()
    frontmatter, body = extract_frontmatter(content)
    
    # Required fields
    required = config.get('required_fields', ['title', 'date', 'published'])
    errors.extend(check_required_fields(frontmatter, required, file_path))
    
    # Description length
    desc_min = config.get('description_min_length', 50)
    desc_max = config.get('description_max_length', 160)
    errors.extend(check_description_length(
        frontmatter, desc_min, desc_max, file_path
    ))
    
    # Tags
    allowed_tags = config.get('allowed_tags', [])
    warnings.extend(check_tags(frontmatter, allowed_tags, file_path))
    
    # Images
    if config.get('require_alt_text', True):
        errors.extend(check_images_have_alt(body, file_path))
    
    return errors, warnings


def main():
    parser = argparse.ArgumentParser(description='Validate content files')
    parser.add_argument('files', nargs='+', help='Files to validate')
    parser.add_argument(
        '--config', '-c',
        default='.content-lint.yaml',
        help='Configuration file'
    )
    parser.add_argument(
        '--strict',
        action='store_true',
        help='Treat warnings as errors'
    )
    args = parser.parse_args()
    
    # Load config
    config = {}
    config_path = Path(args.config)
    if config_path.exists():
        config = yaml.safe_load(config_path.read_text())
    
    all_errors = []
    all_warnings = []
    
    for file_arg in args.files:
        file_path = Path(file_arg)
        if not file_path.suffix == '.md':
            continue
        
        errors, warnings = validate_file(file_path, config)
        all_errors.extend(errors)
        all_warnings.extend(warnings)
    
    # Output results
    for error in all_errors:
        print(f"ERROR: {error}")
    
    for warning in all_warnings:
        print(f"WARNING: {warning}")
    
    # Exit code
    if all_errors or (args.strict and all_warnings):
        sys.exit(1)
    
    print(f"Validated {len(args.files)} files successfully")
    sys.exit(0)


if __name__ == '__main__':
    main()

Configuration File #

Create .content-lint.yaml:

# .content-lint.yaml
required_fields:
  - title
  - date
  - published

description_min_length: 50
description_max_length: 160

require_alt_text: true

allowed_tags:
  - documentation
  - tutorial
  - guide
  - reference
  - blog
  - announcement
  - quality-assurance

Pre-commit Integration #

repos:
  - repo: local
    hooks:
      - id: content-validator
        name: Validate Content
        entry: python scripts/validate_content.py
        language: python
        files: \.(md|markdown)$
        pass_filenames: true
        additional_dependencies:
          - pyyaml

See Also #