From e7309238e5b88eda43281d7b2c7ad47da67b31bf Mon Sep 17 00:00:00 2001 From: Gulliby-development <20404543+Gulliby@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:13:19 +0200 Subject: [PATCH] Add --min-depth option to skip shallow headings --- README.md | 19 ++++++++++ src/cli.ts | 24 ++++++++++-- src/toc.ts | 18 ++++++--- tests/cli.test.ts | 95 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index c43681d..bb9aef4 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,24 @@ 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 | @@ -110,6 +128,7 @@ bun run src/cli.ts "docs/User Guide.md" | `...` | 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 diff --git a/src/cli.ts b/src/cli.ts index ee69e5a..fec2c6c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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>] ... +export const USAGE = `Usage: bun run src/cli.ts [--check] [--max-depth <1-6>] [--min-depth <1-6>] ... Generate a Markdown table of contents between and . ... 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]; @@ -39,6 +41,16 @@ 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))) { @@ -46,6 +58,10 @@ export function parseArgs(args: string[]): CliOptions { } } 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."); } @@ -65,7 +81,7 @@ export async function main(args: string[] = Bun.argv.slice(2)): Promise 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.`); diff --git a/src/toc.ts b/src/toc.ts index aa1edc4..04514da 100644 --- a/src/toc.ts +++ b/src/toc.ts @@ -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 @@ -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 ( @@ -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}`; } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index a067c1a..f02b6a7 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -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( + `\n${previousDefault}\n\n${markdown}`, + ); + }); + test.each([ [1, "- [One](#one)"], [3, "- [One](#one)\n - [Two](#two)\n - [Three](#three)"], @@ -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( + "\n\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)", @@ -193,6 +221,7 @@ describe("CLI argument parsing", () => { files: ["guide.md"], check: false, maxDepth: 6, + minDepth: 1, help: false, }); }); @@ -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); @@ -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]); @@ -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")); @@ -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);