You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
.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:
.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.
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:
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).
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.
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.
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.
Surfaced by the pre-push review gate on #489, which added the first Markdown/MDX lint rules (
WH001no-hard-wrapped-prose,WH002mdx-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" — andMD022/MD023/MD026/MD034de-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.<Aside>/Run this first:/ fence).openingTagAbove()came from getting that wrong in a new way each time.What #489 shipped instead
The generic markdownlint fixers are scoped to
**/*.mdand never run over.mdx:.mdxgets 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.shmakes the same split.That kills the corruption class at the root. It costs two things:
.mdxis reported but not repaired.make lintflags a hard-wrapped paragraph in.mdx;make fixwill 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.WH002still 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:
parser: "micromark"and inspect the token stream, soWH002could ask directly whether a fence lands inside anhtmlFlowtoken rather than pattern-matching for it. Exact by construction. The wrinkle isfix-mdx-fences.mjs, which runs standalone and would need the same parse (micromark resolution under pnpm needs checking).--prose-wrap nevercollapses:::note[…]asides onto one line, rewrites*em*→_em_, flattens table padding, and reformats JSON inside code blocks. A remark pipeline withremark-mdx+remark-directive+remark-gfmand a stringifier configured to preserve those would be the shape worth prototyping — it would give.mdxreal formatting rather than a linter's guess.fix-mdx-fences.mjsalready shares its detector withWH002; the same pattern could carryWH001's unwrapping, restoring auto-unwrap for.mdxwithout letting the generic CommonMark rules near it. Cheapest path to recovering the lost value, but it means maintaining a second small formatter..mdxis 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:
markdownlint-cli2always merges the nearest.markdownlint.jsoninto a--configrun, so a "rule X only" markdownlint pass is not expressible. That is whyfix-mdx-fences.mjsis 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:mdruns the.mdpass twice for this reason).~/.claude/,/tmpscratch 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.