4.5 KiB
markdown-fixer/rules
Purpose
rules.ts houses the individual remark plugins that implement each markdown-lint rule
the fixer enforces, plus the shared heuristic table (PROPER_NAMES) and the
inferCodeLanguage heuristic. It is the rule-by-rule contract of the fixer; the
markdown-fixer.ts entry point composes these plugins into a single unified()
pipeline so the orchestration code stays short and the rule logic stays in one place,
testable and reviewable per rule.
Features
- One exported plugin factory per rule —
fixMultipleH1s,fixEmphasisAsHeading,fixBlankLines,fixProperNames,fixOrderedListNumbering,fixHorizontalRules,fixCodeBlockLanguages. Each takes aRuleSetsink the caller mutates as the plugin walks the AST so the fixer can report exactly which rules altered a file. HEADING_HINT_BASICandHEADING_HINT_EXTENDEDregexes — the basic matcher recognisesStep N:,A), and all-capsLABEL:prefixes; the extended matcher addsNEVER,ALWAYS,CRITICAL,IMPORTANT,MANDATORYkeywords used in rules / safety docs.inferCodeLanguage(value)— heuristic mapper from code-block content to a language identifier (bash,javascript,typescript,python,markdown,html,json,text).PROPER_NAMES— closed allow-list of(lower, proper)pairs for MD044.
Usage: Basic
import {
fixMultipleH1s,
fixCodeBlockLanguages,
} from "@suaveplan/linter/markdown-fixer";
const rules = new Set<string>();
const sink = { add: (r: string) => rules.add(r) };
// then plug each factory into a `unified().use(...)` pipeline
unified().use(fixMultipleH1s, sink).use(fixCodeBlockLanguages, sink);
Usage: Advanced
Compose the same plugin set in a custom pipeline that strips Markdown to plain text:
import remarkParse from "remark-parse";
import remarkStringify from "remark-stringify";
import { unified } from "unified";
import {
fixCodeBlockLanguages,
fixMultipleH1s,
HEADING_HINT_EXTENDED,
fixEmphasisAsHeading,
} from "@suaveplan/linter/markdown-fixer";
const rules = new Set<string>();
const sink = { add: (r: string) => rules.add(r) };
const processor = unified()
.use(remarkParse)
.use(fixMultipleH1s, sink)
.use(fixEmphasisAsHeading, sink, HEADING_HINT_EXTENDED)
.use(fixCodeBlockLanguages, sink)
.use(remarkStringify);
const file = await processor.process("# A\n# B\n");
const applied = [...rules];
API Reference
interface RuleSet { add(rule: string): void }
The mutable sink the plugins write to. The fixer passes a Set<string> wrapper.
HEADING_HINT_BASIC: RegExp
Matches ^(Step \d+:|[A-Z]\)|[A-Z][A-Z\s]+:) — used by the first emphasis-as-heading
pass.
HEADING_HINT_EXTENDED: RegExp
Extended matcher used by the second pass; adds keyword prefixes.
PROPER_NAMES: ReadonlyArray<readonly [string, string]>
Closed pair list — every lowercase form on the left rewrites to the canonical form on the right with a case-insensitive word-boundary match.
inferCodeLanguage(value: string): string
Heuristic returning one of "bash" | "javascript" | "typescript" | "python" | "markdown" | "html" | "json" | "text".
Plugin factories
Each factory accepts the RuleSet (and a matcher where applicable) and returns a
unified transformer of type (tree: Root) => void. Exported names:
fixMultipleH1s, fixEmphasisAsHeading, fixBlankLines, fixProperNames,
fixOrderedListNumbering, fixHorizontalRules, fixCodeBlockLanguages.
Implementation Notes
- The blank-line pass walks the tree depth-first, fixing each
Parent's children bottom-up so a heading nested inside ablockquotegets the same surround-with-blanks treatment as one at the document root. The pass also collapses adjacent empty paragraphs as MD012. - The emphasis-as-heading pass runs TWICE in the pipeline — once with the basic
matcher, once with the extended matcher. The basic matcher handles the common
Step 1: …form; the extended pass picks up safety-doc prefixes (NEVER,MANDATORY) that the basic pattern is intentionally too narrow to catch. fixOrderedListNumberingresetslist.start = 1only when it isn't already 1. This avoids treating an already-canonical list as "changed" — necessary sofixMarkdowncan reportchanged: falsecorrectly.inferCodeLanguageis intentionally conservative — any block that doesn't match a known heuristic falls back to"text"rather than guessing. The remark fixer is a formatter, not a syntax analyser.