mdlint is a Python Markdown linter. It checks Markdown files for style violations and outputs results in human-readable or JSON format.
src/mdlint/
├── __init__.py # Version info
├── cli.py # CLI interface (rich-click)
├── config.py # Configuration management (TOML)
├── document.py # Markdown parsing (markdown-it-py)
├── linter.py # Core linting orchestration
├── violation.py # Violation data model
├── rules/ # Rule implementations
│ ├── __init__.py # Rule base class and registry
│ ├── md001.py
│ ├── md002.py
│ └── ...
└── output/ # Output formatters
├── __init__.py
├── terminal.py # Rich terminal output
└── json.py # JSON output
tests/
├── unit/ # Unit tests
├── integration/ # Integration tests
├── fixtures/ # Test markdown files
└── conftest.py # Pytest configuration
docs/
├── index.md # Documentation home
├── hooks.py # MkDocs hooks (generates rule docs and index table at build time)
├── assets/ # Static assets
├── stylesheets/ # Custom CSS
└── rules/ # Stub files for rule docs (content generated by hooks.py)uvx tox -e test # Run tests
uvx tox -e lint # Run linter (use lint-fix to auto-fix)
uvx tox -e check-format # Check formatting (use format to auto-fix)IMPORTANT: run tests, linting, and formatting after every code change.
When running mdlint directly, use:
uv run mdlint -h # Print help
uv run mdlint check README.md # Check local fileTo build and test the documentation site:
uv run mkdocs build --strict # Build the site
rm -rf site/ # Clean up after testingIMPORTANT: Always remove the site/ directory after testing documentation builds.
Rules are defined in src/mdlint/rules/ and registered in the RULE_REGISTRY dict in rules/__init__.py:
class MD001(Rule):
id = "MD001"
name = "heading-increment"
description = "Headings should only increment by one level at a time"
config_class = RuleConfig # or a custom dataclass extending RuleConfig
def check(self, document: Document, config: RuleConfig) -> list[Violation]:
# Return list of violations
returnUses markdown-it-py for token-based parsing. The Document class provides:
path: File path or<stdin>content: Raw markdown contenttokens: Parsed markdown-it tokenslines: Content split by lines (1-indexed viaget_line())front_matter: Extracted YAML/TOML front mattercode_block_lines: (cached)set[int]of 1-indexed line numbers inside fenced/indented code blockshtml_block_lines: (cached)set[int]of 1-indexed line numbers inside HTML blockscode_span_positions: (cached)dict[int, set[int]]mapping line numbers to 1-indexed column positions inside inline code spansreference_definitions: (cached)dict[str, str]mapping lowercase reference IDs to destinationsREFERENCE_DEF_PATTERN: compiled regex for matching reference definition lines
Precedence (highest to lowest):
--configCLI flag (file path or inline TOML).mdlint.tomlin projectpyproject.tomlunder[tool.mdlint]- Rule defaults
Uses the ignore library for gitignore-aware file traversal. The discover_files() function in linter.py handles
both files and directories, respecting .gitignore patterns by default.
0: No violations, no errors1: Violations found (with or without errors)2: Errors only (file read failures, invalid config)
- Python 3.10+ with type hints
- Ruff for linting and formatting (see pyproject.toml for configuration)
- Dataclasses for configuration and data models
- Abstract base classes for extensibility (Rule)
- Always use file-level (top-of-file) imports; only use function-level imports if absolutely necessary (e.g., to avoid circular imports)
- Registry Pattern: Rules are registered in
RULE_REGISTRYinrules/__init__.py - Strategy Pattern: Rules implement common
check()interface - Immutable Data Models:
Violationis a frozen dataclass
The Rule base class in rules/base.py provides:
_overlaps_ranges(start, end, ranges)— checks if a 0-indexed position range overlaps with any existing ranges
Document-level cached properties (code_block_lines, code_span_positions, etc.) and REFERENCE_DEF_PATTERN (compiled
regex for matching reference definition lines) are accessed directly on the Document class — see Document Parsing
above.
- Create
src/mdlint/rules/mdXXX.py - Define config class if needed (extend
RuleConfig) - Implement rule class extending
Rule - Import and add the rule to
RULE_REGISTRYinrules/__init__.py - Add tests in
tests/unit/rules/test_mdXXX.py - Add fixtures: at least one
valid.mdand oneinvalid.mdfile that shows good and bad examples- Keep
valid.mdandinvalid.mdminimal — they should cover the common/basic cases only - For edge cases, regressions, or special scenarios, create separate fixture files (e.g.,
multiline_and_nested.md) - Always use fixtures loaded via
load_fixture()in tests; avoid inline content strings for anything beyond trivial one-line cases
- Keep
- Add a stub file
docs/rules/mdXXX.mdand a nav entry inmkdocs.ymlfor the new rule - Run all checks:
uvx tox -e py313,lint,check-format
Rule documentation is auto-generated at build time by the MkDocs hook in docs/hooks.py. Each rule page in
docs/rules/ is a stub file whose content is replaced entirely by the hook during mkdocs build or mkdocs serve.
The hook extracts these attributes from rule classes in RULE_REGISTRY:
id,name,summary- Basic rule infodescription,rationale- Detailed explanationsexample_valid,example_invalid- Code examples (conditionally rendered)config_fields- Configuration options from the rule's config dataclass
The hook also auto-generates the rules table on docs/rules/index.md.
No separate generation script needs to be run. Changes to rule class attributes are picked up automatically on the next
build. When using mkdocs serve, changes to src/mdlint/rules/ trigger a live reload (configured via watch in
mkdocs.yml).
- Stub files in
docs/rules/must exist for MkDocs to recognize the pages; their content is ignored - Examples use 4 backticks for outer fences to allow triple backticks in content
- Sections are conditionally rendered (config only if
has_config, examples only if defined)