Hongdown: A CommonMark formatter with highly opinionated styles

I’m sharing Hongdown, a CommonMark formatter I built to solve a specific
problem: existing formatters didn’t support the style conventions I needed.

Background

I initially tried using markdownlint with custom rules, but manually fixing
lint errors became tedious. An auto-formatter seemed like the natural solution,
but existing tools (remark, Prettier) didn’t support the particular quirks I
required—especially around link reference ordering and footnote placement
with context-aware heuristics.

Technical details

  • Parser: comrak (CommonMark + GFM extensions)
  • Language: Rust (edition 2024)
  • Style enforcements:
    • Setext headings (underline style) for H1/H2, ATX for H3+
    • Reference-style links with smart ordering (numeric refs sorted at end, grouped
      by section)
    • Four-tilde code fences instead of backticks
    • Consistent list formatting with - markers
    • GitHub admonitions (NOTE, WARNING, CAUTION, etc.)

CLI usage

hongdown file.md              # Format to stdout
hongdown --write *.md         # Format in place
hongdown --check docs/        # CI/linting mode
hongdown --diff file.md       # Show changes

The formatter is idempotent and configuration is optional via .hongdown.toml.

I’d love feedback from the CommonMark community—especially on edge cases,
interoperability concerns, or similar challenges others have faced!

4 Likes

I see that your style already got quite some traction. 200+ stars etc.

As no one wrote sth yet, let me maybe challenge a few things. Or give some subjective opinions.
Bear in mind: This also always depends on if this markup language is for writers or readers, or supposed to be somewhat an ideal compromise for both (which my Carve markup is supposed to be).

Setext for H1/H2.
Setext heading content is a paragraph line, so it cannot hold text that would itself start a block: 1. Intro, - Overview, > Note, # Hash can never be setext, at any nesting. Same for a heading whose text carries a hard break. Does it fall back to ATX in those cases, or is there input where the output re-parses as a list or a thematic break? Related but cosmetic: a --- underline and a --- thematic break look identical to a human reading a diff, even though the parser knows better.

That said: I personally find the underlining with the same “length” super cumbersome and annoying and a thing from the past.
It really doesn’t make writing pleasant. So in my style (and in most modern markups like MD, Djot etc) this would not be considered useful to have. The `#` ATX headers exist here for a reason.

Four-tilde fences.
The real win here is cross-character immunity, not width: a closing fence has to use the same character as the opener, so a tilde-fenced block can hold backtick fences of any width without counting anything. That is the common case in Markdown documentation. But it only moves the collision rather than removing it, and it moves it straight into your own style guide, where every example is itself a tilde fence and therefore needs five or more tildes around it. STYLE.md says four tildes and does not mention content that contains tildes, so the question is whether the implementation computes the width anyway. The case that bites is not exotic: a closing fence is any line of only fence characters at up to three spaces of indentation, and reStructuredText section underlines are exactly that. Embed an RST snippet, or an ASCII divider, or hongdown’s own output in a hongdown-formatted file, and the block closes early. So: is the fence width derived from the content, or fixed at four? Interop-wise tilde fences are plain CommonMark, but plenty of downstream consumers (editors, highlighters, non-CommonMark renderers) only pattern-match on backticks.

I would recommend 3+ to open, and then always one more per nesting levels (or the other way around). This solves it already cleanly without having to set a fixed “4” length.

Fixed geometry in variable-indent containers.
Two rules with the same shape. Ordered list markers hold a fixed four-column width by shrinking the trailing padding, which is a nice trick at the 9 to 10 boundary, but it cannot survive 100, where the minimum one space pushes content indent to five and re-indents every continuation line in the list. Thematic breaks are 37 spaced dashes at three spaces of indentation: three is the maximum indentation a thematic break may have, so there is no margin left, and the fixed 76-column geometry has to live inside list items, nested lists and blockquotes that each shift it right. Does either rule scale with the wrap width or with nesting depth? The spaced dashes themselves are a good call, incidentally, since an underline with internal spaces can never be read as a setext H2, which matters exactly because H2 is setext here.

That said: The same issue as above: Humans writing such long manual breaks would go mad really fast :slight_smile: I prefer a simple `—` in my markup. Clear and concise.

Idempotency evidence.
The strongest proof is a round-trip property over the spec suite: for every example, parse(md) and parse(format(md)) yield the same AST (or at least the same HTML), plus format(format(md)) == format(md).

I use that for CI and it really helps to find the little bugs in between, especially with some fuzzy testing on top.

Hope this helps a bit to further streamline your specs.

Thanks for this. It is the kind of feedback I was hoping for when I posted, and a couple of your points expose cases the implementation does not handle even though the style guide implies it should.

Hongdown assumes you are not formatting by hand. The mechanical work is automated, so the rules favor reading the plain-text source over typing it. Some of these conventions would be tedious to maintain by hand, and the premise is that the formatter does that work.

With Setext headings, a body is a paragraph line, as you say, so heading text that starts with block syntax (1. Intro, - Overview, > Note, # Hash) cannot be written that way. I did not account for that, and there is no ATX fallback, so it is a bug. The --- ambiguity is the same kind of thing: the thematic break uses spaced dashes and sits at a small indent, so a reader can tell it apart from an underline, but the two are easy to mistake at a glance in a diff.

On four-tilde fences: the width is derived from the content, not fixed. The fence is one character longer than the longest run of fence characters at the start of a line in the block, with four as the floor, so an embedded reStructuredText underline, an ASCII divider, or Hongdown’s own output gets a longer fence and cannot close the block early. The documentation does not explain this: STYLE.md gives the four-tilde minimum and uses longer fences around its own examples, but never states that the width adapts. On downstream consumers that only recognize backticks: tilde fences are what the style asks for, but fence_char and min_fence_length are configurable if you need compatibility with such tools.

On fixed geometry: the intent is that these rules follow the wrap width and the nesting depth, and a case where they do not is a bug, not a documented limitation. The two you found are real. At three-digit ordered-list numbers the marker grows to five columns while continuation lines still indent to four, so they disagree; I had not handled that. A thematic break inside a list item does not pick up the list indent either, which can drop it out of the item. Both are on my list now. The spaces in the dash pattern are there so a thematic break can never read as a Setext H2 underline.

On idempotency: I have unit tests for formatting idempotency, and --check on the project’s own Markdown fails the build when formatting is not stable. I do not yet have the parse-preservation harness you describe: a round-trip over the CommonMark spec suite, comparing parse(md) with parse(format(md)), plus fuzzing over the same property. That would catch things the current tests miss, and I have wanted it for a while.

Thanks again for reading it closely.

Thx for the quick reply.

Indeed, I was guessing wrong on the fence length. Sounds legit then. Even though Djot and Carve here go a different way as well for a reason IMO.

On “Hongdown assumes you are not formatting by hand”: It does answer the typing-ergonomics complaint. But does that mean you always need to have a supporting editor or it is back to tedious manual work? I guess so.

PS: CommonMark doesn’t require the underline to match the text. Any run of - or = works. Hongdown chooses to match it, so renaming a heading changes two lines, and the second line carries no information: it’s the underline growing or shrinking to follow. That’s noise in a diff, and reading a diff is reading the source, which is the axis you said the style optimizes for. ATX doesn’t have the property. One line changes, and it’s the line that changed.

Additionally, there is a semantic issue with all header levels >= 3, since then its more like what characters means what and it can get very confusing quickly.
Using multiple prefix # or = as most ATX versions do here seems to me the better convention still.

The rest of the specs seem all very well thought through and make sense to me!

1 Like