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

5.1 KiB

markdown-fixer

(no submodules)

Purpose

@suaveplan/linter/markdown-fixer is the remark-based markdown auto-fixer at the core of the package. It applies a curated set of markdownlint-equivalent rules — MD012, MD022, MD025, MD029, MD031, MD032, MD035, MD036, MD040, MD044, plus trailing-newline normalisation and a bold-as-heading post-process — by transforming the parsed mdast AST through a deterministic pipeline of plugins. Every file the fixer touches is read, parsed, transformed, stringified, post-processed, and rewritten only when the canonical form differs from the input, so re-running the fixer over an already-clean tree is a no-op that records the unchanged outcome and never touches disk.

Features

  • Pure-function entrypoint fixMarkdown(filePath, { fs? }) that returns a MarkdownFixReport per file ({ file, changed, rulesApplied }).
  • Bulk walker processMarkdownFiles({ patterns, fs?, glob? }) that defaults to **/*.md, dedupes via the glob resolver, and yields one report per file.
  • Injectable FsAdapter and GlobAdapter seams — tests pass Map-backed adapters, production callers use the bundled nodeFsAdapter (default).
  • Stable error taxonomy via LinterError — FILE_NOT_FOUND, READ_FAILED, WRITE_FAILED, MARKDOWN_PARSE_FAILED, NO_TARGETS_FOUND are the documented failures, all carrying the file path under details.
  • OTel emission on every run — linter_files_processed_total{result} for changed / unchanged, linter_files_fixed_total{rule} for each rule applied.

Usage: Basic

import { fixMarkdown } from "@suaveplan/linter/markdown-fixer";

const report = await fixMarkdown("README.md");
if (report.changed) {
    console.log(`fixed ${report.file}: ${report.rulesApplied.join(", ")}`);
}

Usage: Advanced

Drive the fixer over an in-memory tree from a test with custom adapters:

import {
    fixMarkdown,
    processMarkdownFiles,
} from "@suaveplan/linter/markdown-fixer";
import type { FsAdapter, GlobAdapter } from "@suaveplan/linter";

const store = new Map([
    ["a.md", "# A\n# A2\n"],
    ["b.md", "**Step 1: GO**\n\nbody\n"],
]);
const fs: FsAdapter = {
    async readFile(p) {
        const v = store.get(p);
        if (v === undefined) {
            throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
        }
        return v;
    },
    async writeFile(p, data) {
        store.set(p, data);
    },
    async exists(p) {
        return store.has(p);
    },
};
const glob: GlobAdapter = {
    async find() {
        return [...store.keys()];
    },
};

const reports = await processMarkdownFiles({ patterns: ["*.md"], fs, glob });
// reports[0].rulesApplied includes "MD025"
// reports[1].rulesApplied includes "MD036"

API Reference

fixMarkdown(filePath, options?): Promise<MarkdownFixReport>

  • filePath: string — absolute or relative path. MUST exist on fs.exists.
  • options.fs?: FsAdapter — defaults to nodeFsAdapter.

Throws LinterError with code FILE_NOT_FOUND / READ_FAILED / WRITE_FAILED / MARKDOWN_PARSE_FAILED.

processMarkdownFiles(options?): Promise<MarkdownFixReport[]>

  • options.patterns?: ReadonlyArray<string> — defaults to ["**/*.md"].
  • options.cwd?: string — defaults to ".".
  • options.fs?: FsAdapter, options.glob?: GlobAdapter — adapter overrides.

Throws LinterError with code NO_TARGETS_FOUND when the resolver returns an empty list (delegated through resolveMarkdownTargets).

nodeFsAdapter: FsAdapter

Default adapter wrapping node:fs/promises. exists swallows access errors and returns false.

Rule plugin factories

Re-exported from ./rules.js: fixMultipleH1s, fixEmphasisAsHeading, fixBlankLines, fixProperNames, fixOrderedListNumbering, fixHorizontalRules, fixCodeBlockLanguages, plus HEADING_HINT_BASIC / HEADING_HINT_EXTENDED / PROPER_NAMES / inferCodeLanguage.

Implementation Notes

  • The remark pipeline order is load-bearing: fixEmphasisAsHeading MUST run BEFORE fixBlankLines, otherwise the rewritten headings won't be surrounded by blanks. The two emphasis passes are sequenced basic → extended so the basic matcher catches the common form first and the extended pass picks up the keyword variants without re-walking already-rewritten paragraphs.
  • Post-processing is intentionally string-level. remark-stringify doesn't expose hooks for the bold-without-backticks rewrite or the multi-newline collapse, and running another AST pass for them would be heavier than two regexes that operate on already-canonical output.
  • changed is computed by byte-comparison of the post-processed output against the original. This means whitespace-only transformations (e.g. trailing-newline trimming) count as a change — that's intentional; the fixer's contract is "the file matches the canonical form on disk", not "the rendered HTML is identical".
  • The RuleSet is a Set<string> wrapper exposed as { add(rule) }. Sorting rulesApplied in the report keeps snapshot output stable across runs.
  • The fixer NEVER calls process.exit. Callers (the CLI) translate the report into exit codes.