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 aMarkdownFixReportper 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
FsAdapterandGlobAdapterseams — tests passMap-backed adapters, production callers use the bundlednodeFsAdapter(default). - Stable error taxonomy via
LinterError—FILE_NOT_FOUND,READ_FAILED,WRITE_FAILED,MARKDOWN_PARSE_FAILED,NO_TARGETS_FOUNDare the documented failures, all carrying the file path underdetails. - 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 onfs.exists.options.fs?: FsAdapter— defaults tonodeFsAdapter.
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:
fixEmphasisAsHeadingMUST run BEFOREfixBlankLines, 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-stringifydoesn'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. changedis 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
RuleSetis aSet<string>wrapper exposed as{ add(rule) }. SortingrulesAppliedin the report keeps snapshot output stable across runs. - The fixer NEVER calls
process.exit. Callers (the CLI) translate the report into exit codes.