# `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 ```ts 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: ```ts 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` - `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` - `options.patterns?: ReadonlyArray` — 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` 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.