compass

add summarize-md-toc: ATX + setext headings, fence- and frontmatter-aware

commit 0dbc57fe4e7430be2465665132b07ce8578b59c2 · parent 51870a0624 · browse this tree
Ximianth, 2026-06-24 13:16:22 UTC

A reusable primitive for synopsis-style output.  Emits one bullet per
heading, indented by level, with the hash prefix included so the level
reads without counting indents.

Recognizes both ATX (`# foo`) and setext (`===`/`---` underlines) headings,
since the vibeguide corpus uses setext heavily.  Skips:

- Lines inside fenced code blocks (``` and ~~~ both; closer must match opener).
- YAML frontmatter at the top of the file (so the closing `---` isn't
  misread as a setext underline).

Setext detection requires a non-blank text line directly above the underline,
which matches CommonMark and means properly-spaced `---` thematic breaks
don't false-positive as H2.

Silent on zero-heading files, per compass convention.
diff --git a/README.md b/README.md
index 3f5c92b..fd9ed3b 100644
--- a/README.md
+++ b/README.md
@@ -18,8 +18,11 @@ or compose into larger summaries (e.g. an agent's system prompt).
 
 ## Scripts
 
-- `orient-cwd` -- describes the current working directory.
-- `orient-git` -- describes the surrounding git repo, if any.
+- `orient-cwd`        -- describes the current working directory.
+- `orient-git`        -- describes the surrounding git repo, if any.
+- `orient-project`    -- inlines root orientation docs and detects a meta repo.
+- `summarize-md-toc`  -- emits a markdown file's heading structure as a bullet list.
 
-Scripts share a verb-prefix convention (`orient-*`, etc.) so they tab-complete as groups
+Scripts share verb-prefix conventions (`orient-*`, `summarize-*`, ...)
+so they tab-complete as groups
 when you remember the kind of thing you want but not the specific name.
diff --git a/summarize-md-toc b/summarize-md-toc
new file mode 100755
index 0000000..a89f84b
--- /dev/null
+++ b/summarize-md-toc
@@ -0,0 +1,141 @@
+#!/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"