Skip to content

docs: enforce and fix Markdown/MDX style properly — markdownlint can't parse MDX #499

Description

@EricAndrechek

Surfaced by the pre-push review gate on #489, which added the first Markdown/MDX lint rules (WH001 no-hard-wrapped-prose, WH002 mdx-fence-needs-blank-line). That PR ships a deliberately conservative arrangement; this issue is about doing it properly.

The root problem

markdownlint parses CommonMark. MDX does not parse as CommonMark. Every defect found during #489's review traces back to that one disagreement:

  • <TabItem label="YAML"> immediately followed by a fence opens a CommonMark HTML block that runs to the next blank line. markdownlint therefore does not see a code block, so the YAML inside it is "prose" — and MD022/MD023/MD026/MD034 de-indent its # comments (read as ATX headings), space them apart, and rewrite bare URLs into autolinks. Inside verbatim code. MDX itself renders that same file correctly; only the linter is confused.
  • The same is true for a fence glued to prose inside an open JSX block (<Aside> / Run this first: / fence).
  • Trying to detect "is this fence inside an HTML block?" by hand means reimplementing CommonMark's HTML-block types 1–7 — including the type-6 tag list and type-7's "complete tag alone on a line" rule. Six successive defects in openingTagAbove() came from getting that wrong in a new way each time.

What #489 shipped instead

The generic markdownlint fixers are scoped to **/*.md and never run over .mdx:

// package.json
"lint:md": "markdownlint-cli2",                                   // checks .md AND .mdx
"fix:md":  "node scripts/fix-mdx-fences.mjs && markdownlint-cli2 --no-globs --fix \"**/*.md\""

.mdx gets exactly one fixer — scripts/fix-mdx-fences.mjs — which only ever inserts a blank line beside a JSX tag, so its worst failure is a render-neutral blank line rather than rewritten code. .claude/hooks/markdown-on-save.sh makes the same split.

That kills the corruption class at the root. It costs two things:

  1. .mdx is reported but not repaired. make lint flags a hard-wrapped paragraph in .mdx; make fix will not unwrap it. Since the docs site is heavily .mdx, that is most of WH001's value gone on exactly the files that need it most.
  2. WH002 still under-reports the one-sided cases (a fence glued to prose inside an open block). Harmless now that nothing autofixes MDX, but it means the rule is not a reliable signal either.

What "properly" would look like

Options, roughly in order of cost:

  1. Ask a real parser instead of guessing. markdownlint rules can request parser: "micromark" and inspect the token stream, so WH002 could ask directly whether a fence lands inside an htmlFlow token rather than pattern-matching for it. Exact by construction. The wrinkle is fix-mdx-fences.mjs, which runs standalone and would need the same parse (micromark resolution under pnpm needs checking).
  2. Format MDX with something that understands MDX. Prettier has an MDX parser; it was rejected for this repo in build(docs): cloud referrals, standard fmt, and markdown lint rules #489 because --prose-wrap never collapses :::note[…] asides onto one line, rewrites *em*_em_, flattens table padding, and reformats JSON inside code blocks. A remark pipeline with remark-mdx + remark-directive + remark-gfm and a stringifier configured to preserve those would be the shape worth prototyping — it would give .mdx real formatting rather than a linter's guess.
  3. Grow our own safe MDX fixer. fix-mdx-fences.mjs already shares its detector with WH002; the same pattern could carry WH001's unwrapping, restoring auto-unwrap for .mdx without letting the generic CommonMark rules near it. Cheapest path to recovering the lost value, but it means maintaining a second small formatter.
  4. Decide .mdx is authored, not formatted — keep it lint-only forever and rely on review. Legitimate, but worth being an explicit choice rather than a default.

Constraints any solution has to respect

These were all established empirically in #489 and are cheap to re-break:

  • The generic rules must never act on a file whose fences they cannot locate.
  • markdownlint-cli2 always merges the nearest .markdownlint.json into a --config run, so a "rule X only" markdownlint pass is not expressible. That is why fix-mdx-fences.mjs is a standalone script.
  • WH001's fix carries the pre-fix text of the lines it joins, so a fixer chain is not a fixpoint in one pass (fix:md runs the .md pass twice for this reason).
  • markdownlint hands rules a masked copy of HTML-comment interiors; writing that buffer back redacts the comment.
  • Any write-time hook must be contained to the project root — ~/.claude/, /tmp scratch notes, and unrelated checkouts are all things a session writes routinely.

scripts/markdownlint-rules/rules.test.mjs (37 fixtures, driving the real CLI) is the asset that makes any of these swaps safe to attempt: it pins the constructs that must never be touched — pipe-less tables, single-character setext underlines, multi-line MDX ESM, HTML comments, hard breaks, $$ display math, list items, asides — and it is parser-agnostic, so it survives a reimplementation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions