compass

skim-md-toc

#!/bin/bash
# Extracts headings from a markdown file as an indented bullet list.
#
# Usage: skim-md-toc PATH
#
# Output: one bullet per heading, indented by level, with the hash prefix
# included so the level reads clearly without counting indents, and the
# heading's line range appended.  Example:
#
#     - `#` Top title -- L1-40
#       - `##` Section -- L5-22
#         - `###` Subsection -- L12-22
#
# The range covers the heading line through the line before the next heading
# of the same or shallower level (or end of file).  So a section's range
# includes its subsections: that serves "read this whole section", which is
# the reason to want ranges at all -- e.g. `sed -n '5,22p' FILE`.
#
# The range goes last, after the heading text.  Heading text is arbitrary and
# may itself contain " -- ", so the range is the *last* such separator on the
# line, not the only one.  That ambiguity is accepted: this output is read by
# people and agents rather than parsed by tools, and trailing position is what
# suits selecting the token to paste elsewhere.
#
# Silent (no output, exit 0) for files with no headings.
# Errors (missing arg, missing file) go to stderr with non-zero exit.
#
# Heading recognition:
# - ATX:    `#` through `######` at the start of a line, space required.
# - Setext: a line of `=` (H1) or `-` (H2) characters directly below a
#           non-blank text line.  This is common in the vibeguide corpus.
# - Headings inside fenced code blocks are skipped, so tutorial snippets
#   showing `# comment` lines don't get mistaken for structure.
#   Both ``` and ~~~ fences are recognized; the closer must match the opener.
# - YAML frontmatter (a `---` block at the very top of the file) is also
#   skipped, so the closing `---` isn't misread as a setext underline.
# - Heading text is emitted verbatim (minus leading hashes and whitespace).
#   No anchor inference, no link unwrapping.  This is a TOC, not an index.
#
# A setext heading's range starts at its *text* line, not its underline, so
# extracting the range yields text that still carries its own title.

set -euo pipefail

if [ $# -lt 1 ]; then
	echo "usage: skim-md-toc PATH" >&2
	exit 2
fi
file="$1"
if [ ! -f "$file" ]; then
	echo "skim-md-toc: not a file: $file" >&2
	exit 2
fi

# Awk handles the state: fenced code blocks, YAML frontmatter, and
# previous-line buffering for setext detection.
#
# State variables:
#   in_fence   -- inside a ``` or ~~~ block.
#   fence_char -- which kind of fence is currently open.
#   in_fm      -- inside a YAML frontmatter block (only valid at file start).
#   prev       -- the previous non-emitted line, for setext lookbehind.
#   prev_blank -- was the previous line blank (or treated as such)?
#   n          -- count of headings recorded so far.
#   h_level[], h_text[], h_start[] -- the recorded headings, in file order.
#
# Headings are recorded rather than printed as they're found, because a
# heading's end line isn't known until the next heading of the same or
# shallower level turns up (or the file ends).  Only one record per heading is
# held, never file content, so memory tracks heading count rather than size.
awk '
	function trim_lead(s,   t) { t = s; sub(/^[ \t]+/, "", t); return t }
	function trim_trail(s,  t) { t = s; sub(/[ \t]+$/, "", t); return t }

	function record(level, text, start) {
		n++
		h_level[n] = level
		h_text[n] = text
		h_start[n] = start
	}

	function record_atx(line, lineno,   m, text) {
		if (match(line, /^(#{1,6})[ \t]+(.*)$/, m)) {
			text = m[2]
			# Some authors close ATX headings with trailing hashes; strip them.
			sub(/[ \t]+#+[ \t]*$/, "", text)
			record(length(m[1]), trim_trail(text), lineno)
			return 1
		}
		return 0
	}

	BEGIN {
		in_fence = 0; fence_char = ""
		in_fm = 0
		prev = ""; prev_blank = 1
		n = 0
	}

	{
		trimmed = trim_lead($0)

		# YAML frontmatter: only valid as the very first line.
		if (NR == 1 && trimmed == "---") {
			in_fm = 1
			prev = ""; prev_blank = 1
			next
		}
		if (in_fm) {
			if (trimmed == "---" || trimmed == "...") { in_fm = 0 }
			prev = ""; prev_blank = 1
			next
		}

		# Fenced code blocks.
		if (in_fence) {
			if (fence_char == "`"  && trimmed ~ /^```/) { in_fence = 0; fence_char = "" }
			if (fence_char == "~"  && trimmed ~ /^~~~/) { in_fence = 0; fence_char = "" }
			prev = ""; prev_blank = 1
			next
		}
		if (trimmed ~ /^```/) { in_fence = 1; fence_char = "`"; prev = ""; prev_blank = 1; next }
		if (trimmed ~ /^~~~/) { in_fence = 1; fence_char = "~"; prev = ""; prev_blank = 1; next }

		# Setext underline detection: only valid when prev was a non-blank text line.
		# We require at least 2 underline characters; pure `-` or `=` of length 1
		# is too easily a false positive.
		# The heading starts on the text line (NR-1), not this underline.
		if (!prev_blank && trimmed ~ /^=+[ \t]*$/) {
			record(1, trim_trail(trim_lead(prev)), NR - 1)
			prev = ""; prev_blank = 1
			next
		}
		if (!prev_blank && trimmed ~ /^-+[ \t]*$/ && length(trimmed) >= 2) {
			record(2, trim_trail(trim_lead(prev)), NR - 1)
			prev = ""; prev_blank = 1
			next
		}

		# ATX heading: record it, and also clear the prev buffer so
		# the heading text itself cannot be misread as a setext target.
		if (record_atx($0, NR)) {
			prev = ""; prev_blank = 1
			next
		}

		# Otherwise: update lookbehind state.
		if (trimmed == "") {
			prev = ""; prev_blank = 1
		} else {
			prev = $0; prev_blank = 0
		}
	}

	# Close each heading against the next one of the same or shallower level,
	# and print.  NR here is the final line count of the file.
	END {
		for (i = 1; i <= n; i++) {
			end = NR
			for (j = i + 1; j <= n; j++) {
				if (h_level[j] <= h_level[i]) {
					end = h_start[j] - 1
					break
				}
			}
			indent = ""
			for (k = 1; k < h_level[i]; k++) indent = indent "  "
			hashes = ""
			for (k = 0; k < h_level[i]; k++) hashes = hashes "#"
			printf "%s- `%s` %s -- L%d-%d\n", indent, hashes, h_text[i], h_start[i], end
		}
	}
' "$file"

bin/skim-md-toc · 5.8 KiB · d72d74260c