#!/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"