- Add failing test in
tests/format/fixtures/(format: description, input, expected output separated by.) - Run test:
tox -e test -- -vv - Fix code: implement minimal fix
- Verify:
tox -e test -- --snapshot-update - Full suite:
tox
Whenever a fixture's input differs from its expected output (A → B), also add a second fixture testing that the output is stable (B → B). This catches cases where the transformation is correct but the result is not idempotent, a class of bug that canary testing only finds if the transformed form happens to appear in a tracked downstream repo.
Issue #N: descriptive title of the forward transformation
.
<input that needs fixing>
.
<corrected output>
.
Issue #N: descriptive title (idempotency)
.
<corrected output>
.
<corrected output>
.# Run all tests using tox
tox
# Run tests with coverage (Python 3.14 - current version)
tox -e test
# Run tests with coverage (Python 3.10 - minimum version)
tox -e test-min
# Run specific tests with pytest flags
tox -e test -- --exitfirst --failed-first --new-first -vv --snapshot-update# Run all pre-commit hooks (using prek)
tox -e prek
# Or run directly with prek
prek run --all
# Run ruff for linting and formatting
tox -e ruff
# With unsafe fixes
tox -e ruff -- --unsafe-fixes# Run mypy type checking
tox -e type# Run idempotency checks against all tracked downstream repos
tox -e canary
# Test a subset by name
tox -e canary -- uv ruffClones real consumer repos via git sparse checkout and runs a two-pass idempotency check: format once, format again, compare. Not part of the default tox run, so invoke it before releasing to catch crashes or unstable output on real MkDocs content. Configure tracked repos in scripts/canary_repos.json and see scripts/canary.py for excludes.
# Test the plugin as a pre-commit hook
tox -e hook-min# Create a development environment with local code installed
tox devenv .venv
# Test mdformat on inline content
echo '- \[test\]: value' | .venv/bin/mdformat - --extension mkdocs 2>&1
# Test mdformat on a specific file
.venv/bin/mdformat tests/pre-commit-test.md --extension mkdocs
# Run Python code with local package installed
.venv/bin/python3 << 'PYTHON'
import mdformat
output = mdformat.text("- \[test\]: value", extensions={"mkdocs"})
print(output)
PYTHONThe package implements mdformat's plugin interface with up to four exports in __init__.py:
update_mdit: Registers markdown-it parser extensionsadd_cli_argument_group: Optionally adds CLI flagsRENDERERS: Maps syntax tree node types to render functionsPOSTPROCESSORS: Post-processes rendered output of a syntax node. Multiple plugins can register a postprocessor for the same node type, and they run in series
mdformat_mkdocs/plugin.py
- Entry point that configures the mdformat plugin, registers all mdit_plugins, defines custom renders, and handles CLI configuration options
mdformat_mkdocs/_normalize_list.py
- Complex list indentation normalization logic
- Enforces 4-space indentation (MkDocs standard) instead of mdformat's default 2-space
- Handles semantic line breaks with 3-space alignment for numbered lists when
--align-semantic-breaks-in-listsis enabled - Parses list structure, code blocks, HTML blocks, and nested content
- Uses functional programming patterns with
map_lookbackfor stateful line processing
mdformat_mkdocs/mdit_plugins/
- Each file implements a markdown-it plugin for specific MkDocs/Python-Markdown syntax
- Plugins parse syntax into tokens during the parsing phase
- Corresponding renderers in
plugin.pyconvert tokens back to formatted markdown
Inline rule protocol (StateInline rules)
Every inline rule (md.inline.ruler.*) must follow this contract:
- Returning
True(match): advancestate.posto just past the match, in both silent and non-silent mode. - Returning
False(no match): leavestate.posunchanged.
skipToken (used by parseLinkLabel during link-label scanning) calls rules with silent=True and only auto-advances state.pos += 1 when ok=False. If a rule returns True without moving state.pos, the parser stalls in an infinite loop at that position. Verify this contract with a unit test in tests/test_inline_rule_protocol.py.
Link-boundary detection using bracket counting (text_before.count("[") - text_before.count("]")) must search the full state.src[:state.pos] with no length cap. A previous version capped this scan (e.g. to 100 chars), which silently failed for image URLs longer than the cap, causing attr-list tokens to be matched inside link labels.
mdformat_mkdocs/_helpers.py
- Shared utilities:
MKDOCS_INDENT_COUNT(4 spaces),separate_indent,get_conf - Configuration reading from mdformat options (CLI, TOML, or API)
mdformat_mkdocs/_synced/
- Code synced from other mdformat plugins in this family (e.g. admonition factories). Check each subdirectory's README before modifying, since changes flow back upstream
Configuration can be passed via:
- CLI argument, e.g. the scaffolded
--argumentflag - TOML config file (
.mdformat.toml):[plugin.mkdocs] argument = true
- API:
mdformat.text(content, extensions={"mkdocs"}, options={...})
Three footguns to avoid:
POSTPROCESSORSis keyed onnode.type, and the node covering the whole document is"root", not"document". A postprocessor filed under the wrong key never runs, and mdformat reports nothing, so the plugin is silently inert everywhere except tests that build the node themselves. The root node's text also arrives before mdformat appends link reference definitions and the trailing newline- Boolean flags in
add_cli_argument_groupmust useaction="store_const", const=True(defaultNone), notstore_true. Astore_truedefault (False) is indistinguishable from an explicit choice toget_conf(), so it silently overridesargument = truefrom.mdformat.tomlwhenever the CLI flag isn't passed. mdformat's CLI builder raises aDeprecationWarningfor any plugin flag whose default isn'tNoneorargparse.SUPPRESS - Read config lazily. Call
get_conf()(or readRenderContext.options) inside the rule/renderer function itself, not insideupdate_mdit. mdformat runs extensions'update_mditin an unguaranteed order, so a value captured there can be stale by the time every extension has finished configuring options
Fixture Testing
- Fixture files (before/after markdown pairs) live in
tests/format/fixtures/andtests/render/fixtures/, parsed withmarkdown_it.utils.read_fixture_file tests/test_mdformat.pyverifies idempotent formatting againsttests/pre-commit-test.md- A downstream project may layer a snapshot library (e.g. syrupy) on top of these fixtures; check
pyproject.tomlbefore assuming--snapshot-updateapplies - At least one test per feature must drive
mdformat.textormdformat._cli.runend to end. A test that hands a hand-built node or a mockedRenderContextto a renderer or postprocessor passes whether or not mdformat ever calls it, so a suite made only of those stays green over a plugin that does nothing
Test Organization
tests/format/: Tests formatting output (input markdown → formatted markdown)tests/render/: Tests HTML rendering (markdown → HTML via markdown-it)tests/test_hypothesis.py: Property-based idempotency testing over generated markdown documents
- Do not use
uvcommands (there is nouv.lockfile). Always usetox(installed via mise and available on PATH), which manages environments and dependencies
This file is template-owned and copier update keeps it current. Put project-specific guidance in AGENTS.local.md (loaded below when present) or in a nested AGENTS.md scoped to its directory.
@AGENTS.local.md