Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,133 @@
# `markdown-fixer`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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<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.