Files
skills/docs/devtools/tooling/linter/src/markdown-fixer/rules.md
2026-05-29 08:20:56 +01:00

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 a RuleSet sink the caller mutates as the plugin walks the AST so the fixer can report exactly which rules altered a file.
  • HEADING_HINT_BASIC and HEADING_HINT_EXTENDED regexes — the basic matcher recognises Step N:, A), and all-caps LABEL: prefixes; the extended matcher adds NEVER, ALWAYS, CRITICAL, IMPORTANT, MANDATORY keywords 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 a blockquote gets 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.
  • fixOrderedListNumbering resets list.start = 1 only when it isn't already 1. This avoids treating an already-canonical list as "changed" — necessary so fixMarkdown can report changed: false correctly.
  • inferCodeLanguage is 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.