#!/bin/bash # Extracts headings from a markdown file as an indented bullet list. # # Usage: skim-md-toc PATH # # Output: one bullet per heading, indented by level, with the hash prefix # included so the level reads clearly without counting indents, and the # heading's line range appended. Example: # # - `#` Top title -- L1-40 # - `##` Section -- L5-22 # - `###` Subsection -- L12-22 # # The range covers the heading line through the line before the next heading # of the same or shallower level (or end of file). So a section's range # includes its subsections: that serves "read this whole section", which is # the reason to want ranges at all -- e.g. `sed -n '5,22p' FILE`. # # The range goes last, after the heading text. Heading text is arbitrary and # may itself contain " -- ", so the range is the *last* such separator on the # line, not the only one. That ambiguity is accepted: this output is read by # people and agents rather than parsed by tools, and trailing position is what # suits selecting the token to paste elsewhere. # # 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. # # A setext heading's range starts at its *text* line, not its underline, so # extracting the range yields text that still carries its own title. set -euo pipefail if [ $# -lt 1 ]; then echo "usage: skim-md-toc PATH" >&2 exit 2 fi file="$1" if [ ! -f "$file" ]; then echo "skim-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)? # n -- count of headings recorded so far. # h_level[], h_text[], h_start[] -- the recorded headings, in file order. # # Headings are recorded rather than printed as they're found, because a # heading's end line isn't known until the next heading of the same or # shallower level turns up (or the file ends). Only one record per heading is # held, never file content, so memory tracks heading count rather than size. 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 record(level, text, start) { n++ h_level[n] = level h_text[n] = text h_start[n] = start } function record_atx(line, lineno, m, text) { if (match(line, /^(#{1,6})[ \t]+(.*)$/, m)) { text = m[2] # Some authors close ATX headings with trailing hashes; strip them. sub(/[ \t]+#+[ \t]*$/, "", text) record(length(m[1]), trim_trail(text), lineno) return 1 } return 0 } BEGIN { in_fence = 0; fence_char = "" in_fm = 0 prev = ""; prev_blank = 1 n = 0 } { 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. # The heading starts on the text line (NR-1), not this underline. if (!prev_blank && trimmed ~ /^=+[ \t]*$/) { record(1, trim_trail(trim_lead(prev)), NR - 1) prev = ""; prev_blank = 1 next } if (!prev_blank && trimmed ~ /^-+[ \t]*$/ && length(trimmed) >= 2) { record(2, trim_trail(trim_lead(prev)), NR - 1) prev = ""; prev_blank = 1 next } # ATX heading: record it, and also clear the prev buffer so # the heading text itself cannot be misread as a setext target. if (record_atx($0, NR)) { prev = ""; prev_blank = 1 next } # Otherwise: update lookbehind state. if (trimmed == "") { prev = ""; prev_blank = 1 } else { prev = $0; prev_blank = 0 } } # Close each heading against the next one of the same or shallower level, # and print. NR here is the final line count of the file. END { for (i = 1; i <= n; i++) { end = NR for (j = i + 1; j <= n; j++) { if (h_level[j] <= h_level[i]) { end = h_start[j] - 1 break } } indent = "" for (k = 1; k < h_level[i]; k++) indent = indent " " hashes = "" for (k = 0; k < h_level[i]; k++) hashes = hashes "#" printf "%s- `%s` %s -- L%d-%d\n", indent, hashes, h_text[i], h_start[i], end } } ' "$file"