A comprehensive markdown linter and formatter that normalizes formatting and wraps text. Available in both Python and Rust implementations.
md-fixup performs 36 different normalization and formatting rules:
- Normalizes line endings to Unix
- Trims trailing whitespace (preserves exactly 2 spaces for line breaks)
- Collapses multiple blank lines (max 1 consecutive, except in code blocks) and compresses definition lists (
:\s+) - Normalizes headline spacing (exactly 1 space after #)
- Ensures blank line after headline
- Ensures blank line before code block
- Ensures blank line after code block
- Ensures blank line before list
- Ensures blank line after list
- Ensures blank line before horizontal rule
- Ensures blank line after horizontal rule
- Converts list indentation spaces to tabs consistently
- Normalizes list marker spacing
- Wraps text at specified width (preserving links, code spans, fenced blocks)
- Ensures exactly one blank line at end of file
- Normalizes IAL (Inline Attribute List) spacing for both Kramdown and Pandoc styles
- Normalizes fenced code block language identifier spacing
- Normalizes reference-style link definition spacing
- Normalizes task list checkbox (lowercase x)
- Normalizes blockquote spacing
- Normalizes display math block spacing (handles multi-line, preserves currency)
- Normalizes table formatting (aligns columns, handles relaxed and headerless tables)
- Normalizes emoji names (spellcheck and correct typos using fuzzy matching)
- Normalizes typography (curly quotes to straight, en/em dashes, ellipses, guillemets)
- Normalizes bold/italic markers (bold: always __, italic: always *). Intra-word underscores (e.g., in filenames like
_my_file_name.md) are preserved and not converted to emphasis markers. - Normalizes list markers (renumber ordered lists, standardize bullet markers by level)
- Resets ordered lists to start at 1 (if disabled, preserves starting number)
- Converts links to numeric reference links
- Places link definitions at the end of the document
- Converts links to inline format (overrides numeric reference links)
- Normalizes Liquid tag spacing (
{%tag%}->{% tag %}) - Normalizes blockquote marker chains (removes spaces between leading
>markers, e.g.> >->>>) - Compresses list spacing by removing unnecessary blank lines between list items (bulleted and numbered)
- Normalizes setext headings (
===/---) to ATX headings (#/##) - Converts dash-only horizontal rules (
---or longer) to star-spaced rules (* * * * *) (off by default) - Rewraps hard-wrapped paragraphs to the configured width (enabled with wrap; skips lists, tables, and code blocks)
Definition lists: md-fixup compresses definition lists by removing blank lines before and between consecutive definition items (:\s+). This also works inside blockquotes (removing quote-only blank lines like > between definition items). This behavior is part of rule 3 (blank-lines).
Table cleanup algorithm by Dr. Drang.
Install using Homebrew:
brew tap ttscoff/thelab
brew install md-fixupThe Python version requires Python 3 and has no external dependencies (uses only standard library).
Note: The Python implementation is frozen at version 0.1.28 and will not receive new features going forward. There is no longer full feature parity between the Python script and the Rust/binary version, and the rest of this README and all option/feature documentation describe the Rust version only. The Python script remains available for existing workflows that depend on it, but new projects should prefer the Rust binary.
# Make the script executable
chmod +x python/md-fixup.py
# Optionally, create a symlink or add to PATH
ln -s $(pwd)/python/md-fixup.py /usr/local/bin/md-fixupThe Rust version compiles to a single binary with no runtime dependencies.
cd rust
cargo build --releaseThe binary will be at target/release/md-fixup. You can install it system-wide:
# Install using cargo
cargo install --path rust/
# Or manually copy the binary
cp rust/target/release/md-fixup /usr/local/bin/md-fixupThe Rust binary is the primary implementation, and the options and examples in this section describe the Rust version. The legacy Python script shares most of the same flags but may not support newer features added after 0.1.28.
# Process a file (outputs to stdout)
md-fixup file.md
# Overwrite files in place
md-fixup --overwrite file.md
# Set wrap width
md-fixup --width 80 file.md
# Process multiple files
md-fixup --width 72 file1.md file2.md *.md
# Skip specific rules (by number or keyword)
md-fixup --skip 2,3 file.md
md-fixup --skip wrap,end-newline file.md
# Enable specific rules (opposite of --skip; useful with config skip: all)
md-fixup --include wrap,line-endings file.md
# Process all .md files in current directory (if no files specified)
md-fixup
# Read file paths from stdin
find . -name "*.md" | md-fixup --width 100Rules can be skipped using either their number or keyword:
1/line-endings- Normalize line endings to Unix2/trailing- Trim trailing whitespace3/blank-lines- Collapse multiple blank lines (also compresses definition lists,:\s+)4/header-spacing- Normalize headline spacing5/header-newline- Ensure blank line after headline6/code-before- Ensure blank line before code block7/code-after- Ensure blank line after code block8/list-before- Ensure blank line before list9/list-after- Ensure blank line after list10/rule-before- Ensure blank line before horizontal rule11/rule-after- Ensure blank line after horizontal rule12/list-tabs- Convert list indentation spaces to tabs13/list-marker- Normalize list marker spacing14/wrap- Wrap text at specified width15/end-newline- Ensure exactly one blank line at end of file16/ial-spacing- Normalize IAL spacing17/code-lang-spacing- Normalize fenced code block language identifier spacing18/ref-link-spacing- Normalize reference-style link definition spacing19/task-checkbox- Normalize task list checkbox20/blockquote-spacing- Normalize blockquote spacing21/math-spacing- Normalize display math block spacing (including surrounding newlines)22/table-format- Normalize table formatting23/emoji-spellcheck- Normalize emoji names24/typography- Normalize typography (sub-keywords:em-dash,guillemet)25/bold-italic- Normalize bold/italic markers (preserves intra-word underscores in filenames like_my_file_name.md)26/list-markers- Normalize list markers (renumber ordered lists, standardize bullet markers by level)27/list-reset- Reset ordered lists to start at 1 (if disabled, preserves starting number)28/reference-links- Convert links to numeric reference links29/links-at-end- Place link definitions at the end of the document (if skipped and reference-links enabled, places at beginning)30/inline-links- Convert links to inline format (overrides reference-links if enabled; off by default)31/liquid-tags- Normalize Liquid tag spacing32/blockquote-markers- Normalize blockquote marker chains (remove spaces between>markers)33/compress-lists- Compress list spacing by removing unnecessary blank lines between list items34/setext-to-atx- Normalize setext headings (===/---) to ATX headings (#/##)35/hr-stars- Convert dash-only horizontal rules to star-spaced rules (* * * * *)36/rewrap- Rewrap hard-wrapped paragraphs to the configured width (on by default whenwrapis enabled; use--skip rewrapto only wrap lines longer than the width)
Group keywords (expand to multiple rules):
code-block-newlines- Skip or enable all code block newline rules (equivalent to rules6and7)display-math-newlines- Skip or enable display math newline handling (equivalent to rule21)
Use --skip to turn rules off and --include to turn rules on (removes them from the skip set). With a config that uses skip: all, --include wrap,line-endings enables only those rules for that run.
You can create a configuration file to set default options. The config file is located at:
$XDG_CONFIG_HOME/md-fixup/config.yml(orconfig.yaml)~/.config/md-fixup/config.yml(fallback ifXDG_CONFIG_HOMEis not set)
To create an initial config file with all rules enabled, use:
md-fixup --init-configThis creates ~/.config/md-fixup/config.yml using the recommended include: all pattern, with inline-links in the skip list (inline-links is also off by default when no config is present). Remove entries from skip to turn rules on, or add keywords to skip to turn rules off.
Note: If no config file exists and you run md-fixup interactively (from a terminal), it will automatically create the initial config file for you. This only happens when running interactively to avoid creating files during background/automated runs.
The rules section supports three patterns:
| Pattern | YAML | Effect |
|---|---|---|
| Enable all, then opt out | include: all plus optional skip: [list] |
Every rule runs, including inline-links, except those listed under skip. |
| Skip only | skip: [list] (no include) |
Listed rules are off; inline-links stays off unless enabled explicitly. |
| Legacy allowlist | skip: all plus include: [list] |
Everything off first; only listed rules run. Still supported for existing configs. Pair with CLI --include to enable rules per invocation. |
Recommended starting point:
width: 60
overwrite: false
rules:
include: all
skip:
- inline-links
- wrapinclude: all is the straightforward way to turn on every built-in rule, then use skip for exceptions. Rules listed under skip are not run; there is no separate include list in this mode.
Skip specific rules without enabling opt-in defaults:
width: 80
overwrite: true
rules:
skip:
- line-endings
- blank-lines
- wrap
- setext-to-atxLegacy allowlist (everything disabled until named in include):
rules:
skip: all
include:
- line-endings
- blank-lines
- setext-to-atxIf the same rule appears in both skip and include under the legacy pattern, include wins and the rule runs. With include: all, only skip is consulted.
Configuration merging:
- Command-line arguments always override config file settings
- Rules specified in
--skipare added to the skip set;--includeremoves rules from the skip set (applied after--skip) - Group keywords (
code-block-newlines,display-math-newlines) work in config files and on the CLI
md-fixup can also run user-defined regex search/replace patterns as part of a fixup pass. Patterns are defined in a YAML file and can be scoped to run before or after the built-in rules, and optionally inside code blocks or YAML frontmatter.
Replacements are enabled by default if a replacements file exists in one of these locations (in order of precedence):
.md-fixup-replacementsin the current directory- The path set in
replacements_file:in the config file ~/.config/md-fixup/replacements.yml(or$XDG_CONFIG_HOME/md-fixup/replacements.yml)
You can control replacements via the config file:
width: 80
overwrite: true
replacements: true # enable/disable replacements (default: true if a file exists)
replacements_file: ~/my-replacements.yml
rules:
skip:
- wrapThe replacements file itself is also YAML, with this structure:
replacements:
- name: "fix-double-spaces"
pattern: " +"
replacement: " "
# Optional fields (defaults shown):
timing: after # "before" or "after" built-in rules
in_code_blocks: false
in_frontmatter: false
- name: "swap-version"
pattern: '(\\d+)\\.(\\d+)'
replacement: '$2.$1'
timing: beforeEach replacement:
- name: Human-readable identifier for logging and debugging
- pattern: A Rust
regexpattern (supports capture groups) - replacement: The replacement string (supports
$1,$2, etc. for capture groups) - timing: When to run the replacement (
beforeorafterthe built-in rules) - in_code_blocks: If
true, pattern is allowed to run inside fenced code blocks - in_frontmatter: If
true, pattern is allowed to run inside YAML frontmatter
YAML quoting and escaping tips:
- Prefer single quotes for
pattern:when your regex includes backslashes (common with\[\]\d\s, etc.). Single-quoted YAML strings do not treat backslashes as escapes, so the regex reaches the engine unchanged. - If you use double quotes for
pattern:, you often need to double-escape backslashes (e.g. write\\d+instead of\d+) because YAML will interpret backslashes inside double-quoted strings. - In double-quoted YAML strings, some sequences like
\|are not valid YAML escapes and can cause parse errors. Either remove the backslash (often you do not need it) or use single quotes.
Example (BBCode-style tags, multi-line, and anchors):
replacements:
- name: "subhead"
pattern: '(?m)^\[b\](.*?)\[/b\]'
replacement: '## $1'
timing: before
- name: "quote"
pattern: '(?s)\[quote\]\n(.*?)\[/quote\]'
replacement: '> $1'
timing: beforeMulti-line patterns are supported. If your pattern includes \n or uses inline flags like (?s)/(?m), it will be applied to the whole document (still respecting in_code_blocks and in_frontmatter). If you use ^/$ and want them to match line starts/ends within the document, include (?m) in the pattern.
You can override config and defaults on the command line:
--replacements/--no-replacements– force-enable or disable replacements--replacements-file FILE– use a specific replacements YAML file for this run
# Format a single file in place
md-fixup --overwrite README.md
# Format with custom width, skipping wrapping
md-fixup --width 100 --skip wrap file.md
# Format multiple files, preserving em dashes
md-fixup --skip typography,em-dash *.md
# Process all markdown files in a project
find . -name "*.md" -not -path "./.git/*" | md-fixup --overwrite
# Run with a specific replacements file
md-fixup --replacements-file ./replacements.yml --overwrite file.mdThis project is licensed under the MIT License - see the LICENSE.txt file for details.