Initial Commit
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user