skim-md-toc
#!/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"
bin/skim-md-toc · 5.8 KiB · d72d74260c