md-formatter

Crates.iomd-formatter
lib.rsmd-formatter
version0.4.1
created_at2025-11-29 18:30:32.530039+00
updated_at2025-12-01 00:56:17.04794+00
descriptionA fast, opinionated Markdown formatter
homepagehttps://github.com/rewdy/md-formatter
repositoryhttps://github.com/rewdy/md-formatter
max_upload_size
id1957189
size194,322
Andrew Meyer (rewdy)

documentation

https://docs.rs/md-formatter

README

md-formatter

The mad formatter

The md-formatter ("mad formatter") is a fast, opinionated Markdown formatter written in Rust.

Why/Approach

Many now use modern tools for linting and formatting node code (biome, oxlint, etc), but these do not support formatting markdown. This tools is meant to provide a rust-based formatter for markdown only. The approach it takes is to parse the markdown with Rust's pulldown-cmark, then pump it back out with opinionated formatting. For simplicity, it explicitly ignores code blocks (for now), tables are handled minimally, and otherwise is pretty rudimentary.

Quick Start

Fast - Formats 360KB of Markdown in 4ms (~90MB/s)
Opinionated - Minimal configuration (--width, --wrap, --ordered-list)
Idempotent - format(format(x)) == format(x) guaranteed
Safe - Uses hard breaks to preserve structure across re-parsing
Complete - All CommonMark + GFM elements supported

Installation

Rust (CLI)

# Install from crates.io
cargo install md-formatter

# Or build from source
cargo build --release
./target/release/mdfmt --help

Node.js

# npm
npm install @rewdy/md-formatter

# pnpm
pnpm add @rewdy/md-formatter

# bun
bun add @rewdy/md-formatter

Usage

CLI (Rust)

# Format all markdown files in current directory (prints to stdout)
mdfmt .

# Format all markdown files in-place
mdfmt . --write

# Check if all files are formatted (for CI)
mdfmt . --check

# Format a specific file
mdfmt README.md

# Format multiple files or directories
mdfmt src/ docs/ README.md

# Custom line width
mdfmt . --width 100

# Read from stdin
cat file.md | mdfmt -

Glob Patterns

# Use glob patterns
mdfmt "**/*.md"

# Format files in a specific directory
mdfmt docs/

# Multiple paths
mdfmt src/ tests/ README.md

Exclusions

By default, mdfmt excludes common directories: node_modules, target, .git, vendor, dist, build.

# Add additional exclusions
mdfmt . --exclude my-vendor --exclude tmp

# Include everything (no default exclusions)
mdfmt . --no-default-excludes

Prose Wrapping

Control how prose (paragraph text) is wrapped with the --wrap option:

# Reflow prose to fit line width (default: 80)
mdfmt . --wrap always

# Unwrap prose into single lines per paragraph
mdfmt . --wrap never

# Keep existing line breaks (default)
mdfmt . --wrap preserve
Mode Description
always Reflow text to fit within line width
never Unwrap each paragraph to a single long line
preserve Leave existing line breaks unchanged (default)

Ordered Lists

Control how ordered list items are numbered with the --ordered-list option:

# Renumber items sequentially (default)
mdfmt . --ordered-list ascending

# Use 1. for all items
mdfmt . --ordered-list one
Mode Description
ascending Renumber items sequentially: 1, 2, 3, ... (default)
one Use 1. for all items

Integration

# Pre-commit hook
mdfmt . --check

# CI pipeline
mdfmt . --check || exit 1

# Format only changed files
git diff --name-only -- '*.md' | xargs mdfmt --write

Node.js Integration

The npm package includes the mdfmt binary, making it easy to add markdown formatting to your existing Node.js toolchain alongside Biome, ESLint, or other tools.

package.json Scripts

{
  "scripts": {
    "format": "biome format --write . && mdfmt . --write",
    "format:check": "biome format . && mdfmt . --check",
    "format:md": "mdfmt . --write",
    "format:md:check": "mdfmt . --check",
    "lint": "biome lint .",
    "check": "biome check . && mdfmt . --check"
  }
}

CI Example (GitHub Actions)

- name: Check formatting
  run: |
    pnpm biome format .
    pnpm mdfmt . --check

With Husky/lint-staged

{
  "lint-staged": {
    "*.{js,ts,json}": ["biome check --write"],
    "*.md": ["mdfmt --write"]
  }
}

Programmatic API

For advanced use cases, you can also use the formatter programmatically:

import { formatMarkdown, checkMarkdown } from '@rewdy/md-formatter';

// Format a string
const formatted = formatMarkdown(input, {
  width: 80,
  wrap: 'preserve',
  orderedList: 'ascending'
});

// Check if formatted (returns boolean)
const isFormatted = checkMarkdown(input);

Formatting Rules

Supported Elements

  • Paragraphs (line breaks controlled by --wrap mode)
  • Headings (normalized to # Heading format)
  • Lists (unordered -, ordered with --ordered-list mode, with nesting)
  • Blockquotes (with > prefix per depth)
  • Code blocks (fenced, language tags preserved)
  • Inline code, emphasis, links
  • Horizontal rules (normalized to ---)
  • Frontmatter (YAML blocks preserved)
  • GFM strikethrough and autolinks

Design Philosophy

Uses hard breaks (two spaces + newline) instead of soft breaks to ensure
idempotence. This prevents Markdown parsers from reinterpreting wrapped lines as
soft breaks on re-parsing.

Performance

Scenario Time Throughput
360KB file 4ms ~90MB/s
Average file (2KB) <1ms Instant

Architecture

Input Markdown
    ↓
Extract Frontmatter (if present)
    ↓
Parse to Event Stream (pulldown-cmark)
    ↓
Format Events (state machine with hard breaks)
    ↓
Prepend Frontmatter
    ↓
Output Markdown

The formatter never parses the output, so idempotence is guaranteed by design.

CLI Options

Usage: mdfmt [OPTIONS] [PATH]...

Arguments:
  [PATH]...  Files or directories to format (supports glob patterns, use - for stdin)

Options:
  -w, --write                   Write formatted output to file in-place
      --check                   Check if files are formatted (exit with 1 if not)
      --stdin                   Read from stdin
      --width <WIDTH>           Line width for wrapping [default: 80]
      --wrap <MODE>             How to wrap prose: always, never, preserve [default: preserve]
      --ordered-list <MODE>     How to number ordered lists: ascending, one [default: ascending]
      --exclude <DIR>           Additional directories to exclude
      --no-default-excludes     Don't exclude any directories by default
  -h, --help                    Print help
  -V, --version                 Print version

Default exclusions: node_modules, target, .git, vendor, dist, build

Testing

# Run all tests
cargo test --release --lib

# Run specific test
cargo test --release --lib test_idempotence -- --nocapture

# Build release binary
cargo build --release

Current status: 25 unit tests passing ✓

Known Limitations

  • Autolinks - Converted to regular links (parser limitation)
  • Configuration - Only --width, --wrap, and --ordered-list options supported (by design)
  • MDX - Not supported (different language)

Project Structure

src/
├── main.rs       - CLI entry point and file I/O
├── cli.rs        - Argument parsing (clap)
├── formatter.rs  - Core formatting logic (~430 lines)
├── parser.rs     - Markdown parsing and frontmatter extraction
└── lib.rs        - Public API and unit tests (14 tests)

Status

Version: 0.1.1
MVP: Complete ✓
Tests: 25/25 passing ✓
Idempotence: Verified ✓
Performance: Excellent ✓

See STATUS.md for detailed feature matrix and quality metrics.

Contributing

As long as changes are conceptually in-line with the project, I welcome all contributions. 😄

License

MIT

Commit count: 0

cargo fmt