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"