Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,32 @@ Quote paths that contain spaces:
bun run src/cli.ts "docs/User Guide.md"
```

### Skip the page title

Use `--min-depth` to leave the page title out of the table of contents while keeping deeper headings:

```sh
bun run src/cli.ts --min-depth 2 README.md
```

For a file with `# Guide`, `## Installation`, `## Usage`, and `### Sub`, the generated entries are:

```markdown
- [Installation](#installation)
- [Usage](#usage)
- [Sub](#sub)
```

`--min-depth` accepts integers from 1 through 6. Entries are indented relative to the selected minimum depth. Use the same depth when generating and checking.

### Options

| Option | Description |
|---|---|
| `<file>...` | Markdown files to update (or check). |
| `--check` | Do not write; exit 1 if the table of contents is stale. |
| `--max-depth <1-6>` | Include headings only up to this level. |
| `--min-depth <1-6>` | Skip heading levels above this depth (default 1). |

### Headings and anchors

Expand Down
24 changes: 20 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { updateToc, validateMaxDepth } from "./toc";
import { updateToc, validateMaxDepth, validateMinDepth } from "./toc";

export interface CliOptions {
files: string[];
check: boolean;
maxDepth: number;
minDepth: number;
help: boolean;
}

export const USAGE = `Usage: bun run src/cli.ts [--check] [--max-depth <1-6>] <file>...
export const USAGE = `Usage: bun run src/cli.ts [--check] [--max-depth <1-6>] [--min-depth <1-6>] <file>...

Generate a Markdown table of contents between <!-- toc --> and <!-- tocstop -->.
<file>... Markdown files to update (or check).
--check Exit 1 if the TOC is stale; never write the files.
--max-depth <1-6> Include heading levels up to this depth (default: 6).
--min-depth <1-6> Skip heading levels above this depth (default: 1).
--help, -h Show this help.
-- Treat remaining arguments as file paths.`;

export function parseArgs(args: string[]): CliOptions {
const options: CliOptions = { files: [], check: false, maxDepth: 6, help: false };
const options: CliOptions = { files: [], check: false, maxDepth: 6, minDepth: 1, help: false };
let positionalOnly = false;
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
Expand All @@ -39,13 +41,27 @@ export function parseArgs(args: string[]): CliOptions {
throw new Error("--max-depth must be an integer from 1 through 6.");
}
options.maxDepth = Number(value);
} else if (
!positionalOnly &&
(argument === "--min-depth" || argument.startsWith("--min-depth="))
) {
const value =
argument === "--min-depth" ? args[++index] : argument.slice("--min-depth=".length);
if (!value || !/^[1-6]$/.test(value)) {
throw new Error("--min-depth must be an integer from 1 through 6.");
}
options.minDepth = Number(value);
} else if (!positionalOnly && argument.startsWith("-")) {
throw new Error(`Unknown option: ${argument}. Use --help for usage.`);
} else if (!options.files.some((file) => resolve(file) === resolve(argument))) {
options.files.push(argument);
}
}
validateMaxDepth(options.maxDepth);
validateMinDepth(options.minDepth);
if (options.minDepth > options.maxDepth) {
throw new Error("--min-depth must not be greater than --max-depth.");
}
if (!options.help && options.files.length === 0) {
throw new Error("Provide a Markdown file path. Use --help for usage.");
}
Expand All @@ -65,7 +81,7 @@ export async function main(args: string[] = Bun.argv.slice(2)): Promise<number>
for (const file of options.files) {
try {
const markdown = await readFile(file, "utf8");
const updated = updateToc(markdown, options.maxDepth);
const updated = updateToc(markdown, options.maxDepth, options.minDepth);
if (options.check) {
if (updated !== markdown) {
console.error(`TOC is stale in "${file}". Run without --check to update it.`);
Expand Down
18 changes: 13 additions & 5 deletions src/toc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export function validateMaxDepth(maxDepth: number): void {
}
}

export function validateMinDepth(minDepth: number): void {
if (!Number.isInteger(minDepth) || minDepth < 1 || minDepth > 6) {
throw new Error("--min-depth must be an integer from 1 through 6.");
}
}

/** Reduce the supported inline Markdown forms to their visible text. */
export function headingText(markdown: string): string {
return markdown
Expand Down Expand Up @@ -95,20 +101,22 @@ export function extractHeadings(markdown: string): Heading[] {
return headings;
}

export function generateToc(markdown: string, maxDepth = 6, newline = "\n"): string {
export function generateToc(markdown: string, maxDepth = 6, newline = "\n", minDepth = 1): string {
validateMaxDepth(maxDepth);
validateMinDepth(minDepth);
return extractHeadings(markdown)
.filter((heading) => heading.level <= maxDepth)
.filter((heading) => heading.level >= minDepth && heading.level <= maxDepth)
.map((heading) => {
const label = heading.text.replace(/\\/g, "\\\\").replace(/[[\]]/g, "\\$&");
return `${" ".repeat(heading.level - 1)}- [${label}](#${heading.anchor})`;
return `${" ".repeat(heading.level - minDepth)}- [${label}](#${heading.anchor})`;
})
.join(newline);
}

/** Require one ordered marker pair before constructing any replacement. */
export function updateToc(markdown: string, maxDepth = 6): string {
export function updateToc(markdown: string, maxDepth = 6, minDepth = 1): string {
validateMaxDepth(maxDepth);
validateMinDepth(minDepth);
const start = markdown.indexOf(TOC_START);
const end = markdown.indexOf(TOC_END);
if (
Expand All @@ -127,6 +135,6 @@ export function updateToc(markdown: string, maxDepth = 6): string {
const newline = /\r\n|\n|\r/.exec(markdown)?.[0] ?? "\n";
// A stale TOC must not contribute headings to its own replacement.
const source = `${before}${newline}${after}`;
const toc = generateToc(source, maxDepth, newline);
const toc = generateToc(source, maxDepth, newline, minDepth);
return `${before}${newline}${toc ? toc + newline : ""}${after}`;
}
95 changes: 95 additions & 0 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,16 @@ describe("TOC rendering and marker replacement", () => {
);
});

test("default output remains byte-identical to the established rendering", () => {
const markdown = "# Guide\n## Installation\n### Details";
const previousDefault =
"- [Guide](#guide)\n - [Installation](#installation)\n - [Details](#details)";
expect(generateToc(markdown)).toBe(previousDefault);
expect(updateToc(marked(markdown))).toBe(
`<!-- toc -->\n${previousDefault}\n<!-- tocstop -->\n${markdown}`,
);
});

test.each([
[1, "- [One](#one)"],
[3, "- [One](#one)\n - [Two](#two)\n - [Three](#three)"],
Expand All @@ -144,6 +154,24 @@ describe("TOC rendering and marker replacement", () => {
expect(generateToc("#### Hello\n## Hello", 3)).toBe(" - [Hello](#hello-1)");
});

test("minimum depth filters shallow headings and indents relative to the minimum", () => {
const markdown = "# Guide\n## Installation\n## Usage\n### Sub";
expect(generateToc(markdown, 6, "\n", 2)).toBe(
"- [Installation](#installation)\n- [Usage](#usage)\n - [Sub](#sub)",
);
expect(generateToc(markdown, 3, "\n", 3)).toBe("- [Sub](#sub)");
});

test("minimum-depth filtering retains anchors assigned to skipped headings", () => {
expect(generateToc("# Usage\n## Usage", 6, "\n", 2)).toBe("- [Usage](#usage-1)");
});

test("minimum depth can produce an empty TOC block", () => {
expect(updateToc(marked("# Guide\n## Installation"), 6, 3)).toBe(
"<!-- toc -->\n<!-- tocstop -->\n# Guide\n## Installation",
);
});

test("uses visible inline text in link labels", () => {
expect(generateToc("## **Bold** and [Setup](https://example.com)")).toBe(
" - [Bold and Setup](#bold-and-setup)",
Expand Down Expand Up @@ -193,6 +221,7 @@ describe("CLI argument parsing", () => {
files: ["guide.md"],
check: false,
maxDepth: 6,
minDepth: 1,
help: false,
});
});
Expand All @@ -215,6 +244,12 @@ describe("CLI argument parsing", () => {
);
});

test("accepts minimum depth in separated and equals forms", () => {
expect(parseArgs(["--min-depth", "2", "guide.md"])).toEqual(
parseArgs(["--min-depth=2", "guide.md"]),
);
});

test("supports help and option-like paths after --", () => {
expect(parseArgs(["--help"]).help).toBe(true);
expect(parseArgs(["-h"]).help).toBe(true);
Expand Down Expand Up @@ -368,6 +403,55 @@ describe("CLI subprocess behavior", () => {
expect(readFileSync(second, "utf8")).toBe(updateToc(marked(body), 1));
});

test("generates with minimum depth and checks matching or stale TOCs", async () => {
const body = "# Guide\n## Installation\n## Usage\n### Sub";
writeFileSync(file, marked(body));
const generated = await run(["--min-depth", "2", file]);
expect(generated.exitCode).toBe(0);
expect(readFileSync(file, "utf8")).toBe(updateToc(marked(body), 6, 2));

const current = snapshot();
const checked = await run(["--check", "--min-depth=2", file]);
expect(checked.exitCode).toBe(0);
expect(checked.stderr).toBe("");
expect(snapshot()).toEqual(current);

const stale = await run(["--check", "--min-depth", "3", file]);
expect(stale.exitCode).toBe(1);
expect(stale.stderr).toContain("TOC is stale");
expect(snapshot()).toEqual(current);
});

test.each([
{
args: ["--min-depth=0", "unused.md"],
message: "--min-depth must be an integer from 1 through 6.",
},
{
args: ["--min-depth=7", "unused.md"],
message: "--min-depth must be an integer from 1 through 6.",
},
{
args: ["--min-depth=abc", "unused.md"],
message: "--min-depth must be an integer from 1 through 6.",
},
{ args: ["--min-depth"], message: "--min-depth must be an integer from 1 through 6." },
{
args: ["--min-depth=3", "--max-depth=2", "unused.md"],
message: "--min-depth must not be greater than --max-depth.",
},
])(
"rejects invalid minimum depth before reading or writing: $args",
async ({ args, message }) => {
writeFileSync(file, marked("# Guide"));
const before = snapshot();
const result = await run([...args]);
expect(result.exitCode).toBe(1);
expect(result.stderr).toBe(`mdtoc: ${message}\n`);
expect(snapshot()).toEqual(before);
},
);

test("single-file failures retain the unprefixed error", async () => {
writeFileSync(file, "# Missing markers");
const result = await run([file]);
Expand Down Expand Up @@ -465,6 +549,11 @@ describe("CLI subprocess behavior", () => {
expect(result.stderr).toBe("");
});

test("help documents minimum depth", async () => {
const result = await run(["--help"]);
expect(result.stdout).toContain("--min-depth");
});

test("accepts a relative, option-like filename after --", async () => {
const name = "-guide.md";
writeFileSync(join(directory, name), marked("# Guide"));
Expand Down Expand Up @@ -535,6 +624,12 @@ describe("documentation, license, and existing scaffold", () => {
expect(prose).toMatch(/--max-depth.*integers from 1 through 6/i);
});

test("README documents minimum depth and the page-title example", () => {
expect(readme).toContain("--min-depth <1-6>");
expect(readme).toMatch(/^### Skip the page title$/m);
expect(readme).toContain("bun run src/cli.ts --min-depth 2 README.md");
});

test("README includes development instructions for all required checks", () => {
expect(readme).toMatch(/^## Development$/m);
expect(prose).toMatch(/run the automated tests/i);
Expand Down
Loading