compass

summarize-md-toc

#!/bin/bash
# Extracts headings from a markdown file as an indented bullet list.
#
# Usage: summarize-md-toc PATH
#
# Output: one bullet per heading, indented by level, with the hash prefix
# included so the level reads clearly without counting indents.  Example:
#
#     - `#` Top title
#       - `##` Section
#         - `###` Subsection
#
# 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.

set -euo pipefail

if [ $# -lt 1 ]; then
	echo "usage: summarize-md-toc PATH" >&2
	exit 2
fi
file="$1"
if [ ! -f "$file" ]; then
	echo "summarize-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)?
#
# Output is buffered through emit() so we can decide a heading after seeing
# the *next* line (the setext underline).
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 emit_atx(line,   m, level, text, indent, hashes, i) {
		if (match(line, /^(#{1,6})[ \t]+(.*)$/, m)) {
			level = length(m[1])
			text = m[2]
			# Some authors close ATX headings with trailing hashes; strip them.
			sub(/[ \t]+#+[ \t]*$/, "", text)
			text = trim_trail(text)
			indent = ""
			for (i = 1; i < level; i++) indent = indent "  "
			hashes = ""
			for (i = 0; i < level; i++) hashes = hashes "#"
			printf "%s- `%s` %s\n", indent, hashes, text
			return 1
		}
		return 0
	}

	function emit_setext(text, level,   indent, hashes, i) {
		text = trim_trail(trim_lead(text))
		indent = ""
		for (i = 1; i < level; i++) indent = indent "  "
		hashes = ""
		for (i = 0; i < level; i++) hashes = hashes "#"
		printf "%s- `%s` %s\n", indent, hashes, text
	}

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

	{
		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.
		if (!prev_blank && trimmed ~ /^=+[ \t]*$/) {
			emit_setext(prev, 1)
			prev = ""; prev_blank = 1
			next
		}
		if (!prev_blank && trimmed ~ /^-+[ \t]*$/ && length(trimmed) >= 2) {
			emit_setext(prev, 2)
			prev = ""; prev_blank = 1
			next
		}

		# ATX heading: emit immediately, but also clear the prev buffer so
		# the heading text itself cannot be misread as a setext target.
		if (emit_atx($0)) {
			prev = ""; prev_blank = 1
			next
		}

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

summarize-md-toc · 4.4 KiB · fd4110d019