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