@datalackey/update-markdown-toc 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,211 @@
1
+ # update-markdown-toc
2
+
3
+ - [update-markdown-toc](#update-markdown-toc)
4
+ - [Introduction](#introduction)
5
+ - [Installation](#installation)
6
+ - [Usage](#usage)
7
+ - [Using npx (recommended)](#using-npx-recommended)
8
+ - [Using npm scripts](#using-npm-scripts)
9
+ - [Using a direct path (advanced)](#using-a-direct-path-advanced)
10
+ - [Options](#options)
11
+ - [TOC Markers](#toc-markers)
12
+ - [Usage Scenarios](#usage-scenarios)
13
+ - [As Part of code/test/debug Work Flow](#as-part-of-codetestdebug-work-flow)
14
+ - [Continuous Integration (CI)](#continuous-integration--ci)
15
+ - [Recursively Traversing a Folder Hierarchy to Process all files vs. Single File Processing](#recursively-traversing-a-folder-hierarchy-to-process-all-files-vs-single-file-processing)
16
+ - [Single-File Processing (Strict Mode)](#single-file-processing-strict-mode)
17
+ - [Recursive Folder Traversal (Lenient Mode)](#recursive-folder-traversal-lenient-mode)
18
+ - [Guidelines For Project Contributors](#guidelines-for-project-contributors)
19
+
20
+ ## Introduction
21
+
22
+ A Node.js command-line **documentation helper** which automatically:
23
+
24
+ - generates Table of Contents (TOC) blocks for Markdown files
25
+ - operates on either a single file, or recursively finds all `*.md` files from a root path
26
+ - regenerates TOCs from headings, replacing only explicitly marked regions, with no gratuitous reformatting
27
+ - avoids updating files when the generated TOC is already correct
28
+ - provides a `--check` mode which flags Markdown files with stale TOCs (intended for CI)
29
+
30
+
31
+
32
+ ## Installation
33
+
34
+ Install as a development dependency (recommended):
35
+
36
+
37
+ ```bash
38
+ npm install --save-dev @datalackey/update-markdown-toc
39
+ ```
40
+
41
+ This installs the update-markdown-toc command into your project’s
42
+ node_modules/.bin/ directory.
43
+
44
+
45
+
46
+ ## Usage
47
+
48
+
49
+ After installation, the `update-markdown-toc` command can be invoked in any
50
+ of the following ways from the project root (or a subdirectory) where the package was installed.
51
+
52
+
53
+ ### Using npx (recommended)
54
+
55
+ ```bash
56
+ npx update-markdown-toc README.md
57
+ ````
58
+
59
+
60
+ ### Using npm scripts
61
+
62
+ You may also add a script entry to your package.json:
63
+
64
+ ```json
65
+ {
66
+ "scripts": {
67
+ "docs:toc": "update-markdown-toc README.md"
68
+ }
69
+ }
70
+ ```
71
+ Then run:
72
+
73
+ ```bash
74
+ npm run docs:toc
75
+ ```
76
+
77
+ ### Using a direct path (advanced)
78
+
79
+ ```bash
80
+ ./node_modules/.bin/update-markdown-toc README.md
81
+ ```
82
+
83
+
84
+
85
+ ## Options
86
+
87
+ This section assumes the command is invoked using `npx`, an npm script,
88
+ or another method that resolves the local `update-markdown-toc` binary.
89
+
90
+
91
+ ```text
92
+ update-markdown-toc [options] [file]
93
+
94
+ Options:
95
+ -c, --check <path-to-file-or-folder> Do not write files; exit non-zero if TOC is stale
96
+ -r, --recursive <path-to-folder> Recursively process all .md files under the given folder
97
+ -v, --verbose Print status for every file processed
98
+ -q, --quiet Suppress all non-error output
99
+ -d, --debug Print debug diagnostics to stderr
100
+ -h, --help Show this help message and exit
101
+ ```
102
+
103
+ ## TOC Markers
104
+
105
+ The tool operates only on files containing **both** markers:
106
+
107
+ ```md
108
+ <!-- TOC:START -->
109
+ <!-- TOC:END -->
110
+ ```
111
+
112
+ Any existing content between these markers is lost. The new content will be the generated TOC that
113
+ reflects the section headers marked with '#'s in the Markdown document.
114
+
115
+ Content outside the markers is preserved verbatim.
116
+
117
+
118
+ ## Usage Scenarios
119
+
120
+
121
+
122
+
123
+ ### As Part of code/test/debug Work Flow
124
+
125
+ To ensure that your code is built afresh, passes tests, and that your documentation TOCs are up to date, you could
126
+ use invoke the tool in something akin to the package.json below.
127
+ Before commit and push, you would type: 'npm run build'
128
+
129
+
130
+ Your `package.json` might look like this:
131
+ ```json
132
+ {
133
+ "scripts": {
134
+ "clean": "rm -rf dist",
135
+ "compile": "tsc -p tsconfig.json",
136
+ "pretest": "npm run compile",
137
+ "test": "jest",
138
+ "docs:toc": "update-markdown-toc --recursive docs/",
139
+ "bundle": "esbuild src/index.ts --bundle --platform=node --outdir=dist",
140
+ "package": "npm run clean && npm run compile && npm run bundle",
141
+ "build": "npm run docs:toc && npm run test && npm run package"
142
+ }
143
+ }
144
+ ```
145
+
146
+ ### Continuous Integration (CI)
147
+
148
+ The --check flag is designed primarily for continuous integration.
149
+
150
+ In this mode, the tool:
151
+
152
+ - never writes files
153
+ - compares the existing TOC block against the generated TOC
154
+ - exits with a non-zero status if any TOC is stale
155
+
156
+
157
+ Example:
158
+
159
+ ```bash
160
+ npx update-markdown-toc --check --recursive docs/
161
+ ```
162
+
163
+
164
+ If a pull request modifies documentation headings but forgets to update TOCs, this command will fail the build, forcing the contributor to regenerate and commit the correct TOC.
165
+
166
+ ### Recursively Traversing a Folder Hierarchy to Process all files vs. Single File Processing
167
+
168
+ The tool supports two distinct operating modes with intentionally different error-handling semantics:
169
+
170
+ - Single-file mode (--recursive not specified)
171
+ - Recursive folder traversal mode (--recursive specified)
172
+
173
+ These modes are designed to support both strict validation and incremental adoption across real-world repositories.
174
+ In the case of the latter mode, we assume some files may not yet have TOC markers, and that this is acceptable.
175
+
176
+ #### Single-File Processing (Strict Mode)
177
+
178
+ When a single Markdown file is explicitly specified (or when the default README.md is used), the tool operates in strict mode.
179
+
180
+ In this mode:
181
+
182
+ The file must contain both TOC markers:
183
+ ```md
184
+ <!-- TOC:START -->
185
+ <!-- TOC:END -->
186
+
187
+ ```
188
+
189
+ If either marker is missing, the tool prints an error message and exits with a non-zero status code.
190
+
191
+
192
+ #### Recursive Folder Traversal (Lenient Mode)
193
+
194
+ When operating in recursive mode, the tool traverses a directory tree and processes all *.md files it finds.
195
+ In this mode, files without TOC markers are silently skipped, and files with TOC markers are processed normally.
196
+
197
+ When combined with --verbose, skipped files are reported explicitly in this mode.
198
+
199
+ update-markdown-toc --recursive docs/ --verbose
200
+
201
+ Example output:
202
+
203
+ ```
204
+ Skipped (no markers): docs/legacy-notes.md
205
+ Updated: docs/guide.md
206
+ Up-to-date: docs/api.md
207
+ ```
208
+
209
+ ## Guidelines For Project Contributors
210
+
211
+ Contributors to the project should consult [this document](GUIDELINES-FOR-PROJECT-CONTRIBUTORS.md)
@@ -0,0 +1,93 @@
1
+ import { parseArgs } from "node:util";
2
+ import { dedent } from "ts-dedent";
3
+
4
+ export function parseCli() {
5
+ let values, positionals;
6
+
7
+ try {
8
+ ({ values, positionals } = parseArgs({
9
+ options: {
10
+ check: { type: "boolean", short: "c" },
11
+ recursive: { type: "string", short: "r" },
12
+ verbose: { type: "boolean", short: "v" },
13
+ quiet: { type: "boolean", short: "q" },
14
+ debug: { type: "boolean", short: "d" },
15
+ help: { type: "boolean", short: "h" }
16
+ },
17
+ allowShort: true,
18
+ allowPositionals: true
19
+ }));
20
+ } catch (err) {
21
+ console.error(`ERROR: ${err.message}`);
22
+ process.exit(1);
23
+ }
24
+
25
+ if (values.help) {
26
+ printHelp();
27
+ process.exit(0);
28
+ }
29
+
30
+ const checkMode = values.check === true;
31
+ const verbose = values.verbose === true;
32
+ const quiet = values.quiet === true;
33
+ const debug = values.debug === true;
34
+
35
+ const recursivePath =
36
+ typeof values.recursive === "string" ? values.recursive : null;
37
+
38
+ let targetFile = null;
39
+
40
+ if (positionals.length > 1) {
41
+ console.error("ERROR: Only one file argument may be provided");
42
+ process.exit(1);
43
+ }
44
+
45
+ if (positionals.length === 1) {
46
+ targetFile = positionals[0];
47
+ }
48
+
49
+ // -------------------------
50
+ // Contract validation
51
+ // -------------------------
52
+
53
+ if (quiet && verbose) {
54
+ console.error("ERROR: --quiet and --verbose cannot be used together");
55
+ process.exit(1);
56
+ }
57
+
58
+ if (checkMode && !recursivePath && !targetFile) {
59
+ console.error("ERROR: --check requires a file or --recursive <path>");
60
+ process.exit(1);
61
+ }
62
+
63
+ if (recursivePath && targetFile) {
64
+ console.error("ERROR: Cannot use --recursive with a file argument");
65
+ process.exit(1);
66
+ }
67
+
68
+ return Object.freeze({
69
+ checkMode,
70
+ verbose,
71
+ quiet,
72
+ debug,
73
+
74
+ recursivePath,
75
+ targetFile,
76
+
77
+ isRecursive: Boolean(recursivePath)
78
+ });
79
+ }
80
+
81
+ function printHelp() {
82
+ console.log(dedent`
83
+ update-markdown-toc [options] [file]
84
+
85
+ Options:
86
+ -c, --check <path-to-file-or-folder> Do not write files; exit non-zero if TOC is stale
87
+ -r, --recursive <path-to-folder> Recursively process all .md files under the given folder
88
+ -v, --verbose Print status for every file processed
89
+ -q, --quiet Suppress all non-error output
90
+ -d, --debug Print debug diagnostics to stderr
91
+ -h, --help Show this help message and exit
92
+ `);
93
+ }
@@ -0,0 +1,263 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import { parseCli } from "./parseCli.js";
6
+
7
+ /* ============================================================
8
+ * Constants
9
+ * ============================================================ */
10
+
11
+ const START = "<!-- TOC:START -->";
12
+ const END = "<!-- TOC:END -->";
13
+
14
+ /* ============================================================
15
+ * CLI configuration
16
+ * ============================================================ */
17
+
18
+ const {
19
+ checkMode,
20
+ verbose,
21
+ quiet,
22
+ debug,
23
+ recursivePath,
24
+ targetFile,
25
+ isRecursive
26
+ } = parseCli();
27
+
28
+ /* ============================================================
29
+ * Debug helper
30
+ * ============================================================ */
31
+
32
+ function debugLog(msg) {
33
+ if (debug) {
34
+ console.error(`[debug] ${msg}`);
35
+ }
36
+ }
37
+
38
+ /* ============================================================
39
+ * Helpers
40
+ * ============================================================ */
41
+
42
+ function collectMarkdownFiles(dir) {
43
+ const results = [];
44
+
45
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
46
+ const full = path.join(dir, entry.name);
47
+
48
+ if (entry.isDirectory()) {
49
+ results.push(...collectMarkdownFiles(full));
50
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
51
+ results.push(full);
52
+ }
53
+ }
54
+
55
+ return results;
56
+ }
57
+
58
+ function generateTOC(content) {
59
+ const hasStart = content.includes(START);
60
+ const hasEnd = content.includes(END);
61
+
62
+ if (!hasStart && !hasEnd) {
63
+ throw new Error("TOC delimiters not found");
64
+ }
65
+ if (hasStart && !hasEnd) {
66
+ throw new Error("TOC start delimiter found without end");
67
+ }
68
+ if (!hasStart && hasEnd) {
69
+ throw new Error("TOC end delimiter found without start");
70
+ }
71
+
72
+ const startIndex = content.indexOf(START);
73
+ const endIndex = content.indexOf(END);
74
+
75
+ const before = content.slice(0, startIndex + START.length);
76
+ const after = content.slice(endIndex);
77
+
78
+ const contentWithoutTOC =
79
+ content.slice(0, startIndex) +
80
+ content.slice(endIndex + END.length);
81
+
82
+ const lines = contentWithoutTOC.split("\n");
83
+ const headings = [];
84
+
85
+ for (const line of lines) {
86
+ const m = /^(#{1,6})\s+(.*)$/.exec(line);
87
+ if (!m) continue;
88
+
89
+ const level = m[1].length;
90
+ const title = m[2].trim();
91
+
92
+ const anchor = title
93
+ .toLowerCase()
94
+ .replace(/[^\w\s-]/g, "")
95
+ .replace(/\s/g, "-")
96
+ .replace(/^-|-$/g, "");
97
+
98
+ headings.push({ level, title, anchor });
99
+ }
100
+
101
+ if (headings.length === 0) {
102
+ throw new Error("No headings found to generate TOC");
103
+ }
104
+
105
+ const minLevel = Math.min(...headings.map(h => h.level));
106
+ const tocLines = headings.map(h => {
107
+ const indent = " ".repeat(h.level - minLevel);
108
+ return `${indent}- [${h.title}](#${h.anchor})`;
109
+ });
110
+
111
+ const tocBlock = "\n" + tocLines.join("\n") + "\n";
112
+ return before + tocBlock + after;
113
+ }
114
+
115
+ /* ============================================================
116
+ * File processing
117
+ * ============================================================ */
118
+
119
+ function processFile(filePath) {
120
+ debugLog(`processing file: ${filePath}`);
121
+
122
+ let content;
123
+ try {
124
+ content = fs.readFileSync(filePath, "utf8");
125
+ } catch {
126
+ throw new Error(`Unable to read markdown file: ${filePath}`);
127
+ }
128
+
129
+ let updated;
130
+ try {
131
+ updated = generateTOC(content);
132
+ } catch (err) {
133
+ if (err.message === "TOC delimiters not found") {
134
+ if (isRecursive) {
135
+ debugLog("result: skipped (no markers)");
136
+ return { status: "skipped" };
137
+ }
138
+ throw err; // single-file mode → hard error
139
+ }
140
+ throw err;
141
+ }
142
+
143
+ if (updated === content) {
144
+ debugLog("result: unchanged");
145
+ return { status: "unchanged" };
146
+ }
147
+
148
+ if (checkMode) {
149
+ debugLog("result: stale");
150
+ return { status: "stale" };
151
+ }
152
+
153
+ fs.writeFileSync(filePath, updated, "utf8");
154
+ debugLog("result: updated");
155
+ return { status: "updated" };
156
+ }
157
+
158
+ /* ============================================================
159
+ * Output
160
+ * ============================================================ */
161
+
162
+ function maybePrintStatus(status, filePath) {
163
+ debugLog(`printing decision: status=${status}`);
164
+
165
+ if (quiet) return;
166
+
167
+ if (checkMode) {
168
+ // In --check mode we ALWAYS report stale files (unless --quiet).
169
+ // This makes CI output actionable without requiring --verbose.
170
+ if (status === "stale") {
171
+ console.log(`Stale: ${filePath}`);
172
+ return;
173
+ }
174
+
175
+ // In recursive mode, files without markers are intentionally ignored.
176
+ // They should not cause failures and do not need reporting by default.
177
+ if (status === "skipped") {
178
+ if (verbose) {
179
+ console.log(`Skipped (no markers): ${filePath}`);
180
+ }
181
+ return;
182
+ }
183
+
184
+ if (status === "unchanged") {
185
+ if (verbose) {
186
+ console.log(`Up-to-date: ${filePath}`);
187
+ }
188
+ return;
189
+ }
190
+
191
+ return;
192
+ }
193
+
194
+ if (verbose) {
195
+ if (status === "updated") {
196
+ console.log(`Updated: ${filePath}`);
197
+ } else if (status === "unchanged") {
198
+ console.log(`Up-to-date: ${filePath}`);
199
+ } else if (status === "skipped") {
200
+ console.log(`Skipped (no markers): ${filePath}`);
201
+ }
202
+ return;
203
+ }
204
+
205
+ if (status === "updated") {
206
+ console.log(`Updated: ${filePath}`);
207
+ }
208
+ }
209
+
210
+
211
+ /* ============================================================
212
+ * Execution
213
+ * ============================================================ */
214
+
215
+ let files = [];
216
+
217
+ if (recursivePath) {
218
+ const resolved = path.resolve(process.cwd(), recursivePath);
219
+
220
+ if (!fs.existsSync(resolved)) {
221
+ console.error("ERROR: Recursive path does not exist");
222
+ process.exit(1);
223
+ }
224
+ if (!fs.statSync(resolved).isDirectory()) {
225
+ console.error("ERROR: --recursive requires a directory");
226
+ process.exit(1);
227
+ }
228
+
229
+ files = collectMarkdownFiles(resolved);
230
+ files.sort(); // deterministic order
231
+ } else {
232
+ const resolved = path.resolve(
233
+ process.cwd(),
234
+ targetFile || "README.md"
235
+ );
236
+ files = [resolved];
237
+ }
238
+
239
+ let staleFound = false;
240
+
241
+ for (const file of files) {
242
+ try {
243
+ const result = processFile(file);
244
+
245
+ if (checkMode && result.status === "stale") {
246
+ staleFound = true;
247
+ debugLog("staleFound set true");
248
+ }
249
+
250
+ maybePrintStatus(result.status, file);
251
+ } catch (err) {
252
+ console.error(`ERROR: ${err.message}`);
253
+ process.exit(1);
254
+ }
255
+ }
256
+
257
+ if (checkMode && staleFound) {
258
+ debugLog("exiting with status 1 due to stale TOC");
259
+ process.exit(1);
260
+ }
261
+
262
+ debugLog("exiting with status 0");
263
+ process.exit(0);
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@datalackey/update-markdown-toc",
3
+ "version": "0.1.3",
4
+ "description": "Auto-generate Table of Contents for a Markdown file (or files, recursively from a top level folder)",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "update-markdown-toc": "./bin/update-markdown-toc.js"
9
+ },
10
+ "files": [
11
+ "bin/"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "keywords": [
17
+ "build",
18
+ "automation",
19
+ "javascript",
20
+ "node",
21
+ "documentation",
22
+ "readme",
23
+ "toc",
24
+ "markdown",
25
+ "ci"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/datalackey/build-tools.git",
33
+ "directory": "javascript/update-markdown-toc"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/datalackey/build-tools/issues"
37
+ },
38
+ "homepage": "https://github.com/datalackey/build-tools/tree/main/javascript/update-markdown-toc",
39
+ "dependencies": {
40
+ "ts-dedent": "^2.2.0"
41
+ }
42
+ }