Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,22 @@ Table of Contents
* [License](#license)
```

To include only headings up to a given level, use `--depth <NUM>`:

```bash
➥ ./gh-md-toc --depth 1 ~/projects/Dockerfile.vim/README.md

Table of Contents
=================

* [Dockerfile.vim](#dockerfilevim)
* [Screenshot](#screenshot)
* [Installation](#installation)
* [License](#license)

<!-- Created by https://github.com/ekalinin/github-markdown-toc -->
```

Remote files
------------

Expand Down
20 changes: 15 additions & 5 deletions gh-md-toc
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ gh_toc(){
local no_footer=$5
local indent=$6
local skip_header=$7
local depth=$8

if [ "$gh_src" = "" ]; then
echo "Please, enter URL or local path for a README.md"
Expand All @@ -140,7 +141,7 @@ gh_toc(){
fi

if [ "$(gh_is_url "$gh_src")" == "yes" ]; then
gh_toc_load "$gh_src" | gh_toc_grab "$gh_src_copy" "$indent"
gh_toc_load "$gh_src" | gh_toc_grab "$gh_src_copy" "$indent" "$depth"
if [ "${PIPESTATUS[0]}" != "0" ]; then
echo "Could not load remote document."
echo "Please check your url or network connectivity"
Expand Down Expand Up @@ -168,7 +169,7 @@ gh_toc(){
exit 1
fi
local toc
toc=$(echo "$rawhtml" | gh_toc_grab "$gh_src_copy" "$indent")
toc=$(echo "$rawhtml" | gh_toc_grab "$gh_src_copy" "$indent" "$depth")
echo "$toc"
if [ "$need_replace" = "yes" ]; then
if grep -Fxq "<!--ts-->" "$gh_src" && grep -Fxq "<!--te-->" "$gh_src"; then
Expand Down Expand Up @@ -221,11 +222,13 @@ gh_toc(){
# $1 - a source url of document.
# It's need if TOC is generated for multiple documents.
# $2 - number of spaces used to indent.
# $3 - max heading level to include. 0 means all levels.
#
gh_toc_grab() {

href_regex="/href=\"[^\"]+?\"/"
common_awk_script='
if (depth+0 > 0 && level+0 > depth+0) next
modified_href = ""
split(href, chars, "")
for (i=1;i <= length(href); i++) {
Expand Down Expand Up @@ -290,7 +293,7 @@ gh_toc_grab() {
# format result line
# * $0 - whole string
# * last element of each row: "</hN" where N in (1,2,3,...)
echo $echoargs "$(awk -v "gh_url=$1" "$awkscript")"
echo $echoargs "$(awk -v "gh_url=$1" -v "depth=$3" "$awkscript")"
}

# perl -lpE 's/(\[[^\]]*\]\()(.*?)(\))/my ($pre, $in, $post)=($1, $2, $3) ; $in =~ s{\+}{ }g; $in =~ s{%}{\\x}g; $pre.$in.$post/ems')"
Expand Down Expand Up @@ -333,6 +336,7 @@ show_help() {
echo ""
echo "Options:"
echo " --indent <NUM> Set indent size. Default: 3."
echo " --depth <NUM> Max heading level to include into TOC. Default: 0 (all levels)."
echo " --insert Insert new TOC into original file. For local files only. Default: false."
echo " See https://github.com/ekalinin/github-markdown-toc/issues/41 for details."
echo " --no-backup Remove backup file. Set --insert as well. Default: false."
Expand All @@ -348,6 +352,7 @@ show_help() {
gh_toc_app() {
local need_replace="no"
local indent=3
local depth=0

if [ "$1" = '--help' ] || [ $# -eq 0 ] ; then
show_help
Expand All @@ -364,6 +369,11 @@ gh_toc_app() {
shift 2
fi

if [ "$1" = '--depth' ]; then
depth="$2"
shift 2
fi

if [ "$1" = "-" ]; then
if [ -z "$TMPDIR" ]; then
TMPDIR="/tmp"
Expand All @@ -381,7 +391,7 @@ gh_toc_app() {
while read -r input; do
echo "$input" >> "$gh_tmp_md"
done
gh_toc_md2html "$gh_tmp_md" | gh_toc_grab "" "$indent"
gh_toc_md2html "$gh_tmp_md" | gh_toc_grab "" "$indent" "$depth"
return
fi

Expand Down Expand Up @@ -411,7 +421,7 @@ gh_toc_app() {
for md in "$@"
do
echo ""
gh_toc "$md" "$#" "$need_replace" "$no_backup" "$no_footer" "$indent" "$skip_header"
gh_toc "$md" "$#" "$need_replace" "$no_backup" "$no_footer" "$indent" "$skip_header" "$depth"
done

echo ""
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/add-toc-depth/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-28
91 changes: 91 additions & 0 deletions openspec/changes/add-toc-depth/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Design

## Context

Every input path ends in `gh_toc_grab`: stdin and local files go through
`gh_toc_md2html`, remote URLs through `gh_toc_load`. In `gh_toc_grab` an awk script takes
one line per heading, reads the level as `substr($0, 3, 1)` (the digit in `<hN`), and
prints the entry in `common_awk_script`, which is shared by the default and the OS/390
branches.

`gh_toc_app` checks each option once, in a fixed order: `--indent`, `-`, `--insert`,
`--no-backup`, `--hide-footer`, `--skip-header`, then the inputs. An option out of this
order is taken as an input.

## Goals / Non-Goals

**Goals:**

- One filter that covers all inputs and both awk branches.
- The existing output without `--depth` stays byte-for-byte the same.

**Non-Goals:** see proposal.md - Non-goals.

## Decisions

### Absolute level, as in the Go version

`--depth N` keeps headings with level `<= N`, and `0` means no limit. This is what
`internal/core/toc/renderer.go` in `github-markdown-toc.go` does
(`Depth > 0 && heading.Level > Depth` is skipped), so both versions behave the same.

Alternative: a relative depth, counted from the highest level present in the document.
It needs a second pass to find that level first, differs from the Go version, and
mostly helps documents without `#`, which is the topic of #30 and #75.

### Filter in `common_awk_script`

The condition goes at the start of `common_awk_script`, where `level` is already set
and before anything is printed:

```awk
if (depth+0 > 0 && level+0 > depth+0) next
```

`+0` makes the comparison numeric: `substr()` returns a string, and awk compares a
string with a `-v` value as strings.

Alternative: narrow the grep pattern to `<h[1-N]`. It changes the regex used by both
`grep -Eo` and `pcregrep -o`, needs a special case for `0`, and this pattern was the
source of #166.

### `depth` passed to awk with `-v`

`gh_toc_grab` gets the depth as a new third argument and passes it as
`-v "depth=$3"`, next to `-v "gh_url=$1"`. An empty value is then just `0` in awk.

Alternative: splice the value into the script text, as done for the indent
(`'"$2"'`). An empty value would leave an expression without an operand, which is an
awk syntax error, so the script would depend on the caller always passing a number.

### Parsing in the existing order

`gh_toc_app` gets `local depth=0` next to `local indent=3`, and a
`--depth` check right after the `--indent` check and before the `-` check, so stdin
supports it. The value goes to the stdin branch as `gh_toc_grab "" "$indent" "$depth"`
and to `gh_toc` as a new last (8th) parameter, which passes it to both of its
`gh_toc_grab` calls. Adding the parameter at the end keeps the positions of the
existing ones.

Alternative: a `while`/`case` loop that accepts options in any order. It is a rewrite
of `gh_toc_app` that touches every option and is out of scope.

### Dedicated test fixture

`tests/test directory/test_depth.md` with the headings `# Title one`, `## Section`,
`### Subsection`, `#### Deep`, `# Title two`. `test_backquote.md` already has three
levels, but it is the fixture for #13, and reusing it would tie two unrelated tests
together.

## Risks / Trade-offs

- [`--depth` after another option, e.g. `--insert --depth 2 README.md`, is taken as an
input and the run fails with a curl error] → `--help` lists `--depth` right after
`--indent`, which matches the parse order. The same limitation already applies to
every option.
- [A non-numeric value, e.g. `--depth abc`, becomes `0` in awk and silently means no
limit] → Accepted, `--indent` is not validated either.
- [`--depth 1` on a document without `#` gives an empty TOC] → Accepted, same as the
Go version.
- [The new tests call the GitHub API and can hit the rate limit] → Same as the existing
local file tests: `GH_TOC_TOKEN` locally, `secrets.GITHUB_TOKEN` in CI.
53 changes: 53 additions & 0 deletions openspec/changes/add-toc-depth/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Proposal

## Why

`gh-md-toc` puts every heading of a document into the TOC, from `h1` to `h6`, and there
is no way to keep only the top levels, e.g. only `#` and `##` (#25, open since 2016).
The Go version (`github-markdown-toc.go`) already has `--depth` for this.

## What Changes

- New option `--depth <NUM>`: the TOC keeps only headings with level `<= NUM`. The level
is absolute, as in the Go version: `--depth 2` keeps `h1` and `h2`. An empty value or
`0` means no limit, which is the current behavior.
- The option applies to every input: stdin, local file, remote URL, several inputs, and
`--insert`.
- `--depth` is parsed in the existing fixed order of options, right after `--indent` and
before `-`.
- `--help`: a new line for `--depth` right after `--indent`.
- README: an example with `--depth 1` in the "Local files" section, after the existing
example, without a new heading.
- Tests: a new fixture `tests/test directory/test_depth.md`, a test for a local file, a
test for stdin, and an updated `test_help`.

Non-goals:

- `--start-depth` (the Go version has it, #25 asks only for the upper bound).
- The `--depth=NUM` syntax.
- Validation of the value (`--indent` is not validated either).
- Changes to indentation: entries stay indented by `(level - 1) * indent` (#30, #75).
- A rewrite of the argument parser so options can come in any order.
- Updating the outdated README examples (#165).

## Capabilities

### New Capabilities

- `toc-depth`: limiting the TOC to headings up to a given level.

### Modified Capabilities

None.

## Impact

- `gh-md-toc`: `gh_toc_app` (parse `--depth` and pass it on), `gh_toc` (new
parameter), `gh_toc_grab` (one condition in the awk script shared by the default and
OS/390 branches), `show_help`.
- `tests/tests.bats`: two new tests and an updated `test_help` (15 lines instead of
14). The new tests call the GitHub API, like the existing local file tests.
- `README.md`: an example only, no new heading, so the README TOC asserted in
`tests/tests.bats` does not change.
- `openspec/specs/remote-toc` describes the output without `--depth`; its requirements
do not change. `openspec/specs/landing` does not list options and is not affected.
63 changes: 63 additions & 0 deletions openspec/changes/add-toc-depth/specs/toc-depth/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Spec Delta

## Purpose

Lets the user limit the generated TOC to headings up to a given level, e.g. only `#` and
`##`, for any input.

## ADDED Requirements

### Requirement: TOC limited by heading level

With `--depth <NUM>` where `NUM` is greater than 0, the TOC SHALL contain entries only for
headings whose level is less than or equal to `NUM` (`#` is level 1, `######` is level
6). The level is absolute: it does not depend on the highest level present in the
document. The remaining entries SHALL be the same as without `--depth`: same text, same
anchor, same order, same indentation of `(level - 1) * indent` spaces. This SHALL apply
to every input: stdin, a local file, a remote URL, several inputs, and `--insert`.

`--depth <NUM>` SHALL be accepted after `--indent <NUM>` (when given) and before `-`,
`--insert`, `--no-backup`, `--hide-footer`, `--skip-header` and the inputs.

#### Scenario: Local file

- **WHEN** the user runs `gh-md-toc --depth 2 "tests/test directory/test_depth.md"`,
where the file has the headings `# Title one`, `## Section`, `### Subsection`,
`#### Deep`, `# Title two`
- **THEN** the TOC entries are `* [Title one](#title-one)`, ` * [Section](#section)`,
`* [Title two](#title-two)`, followed by
`<!-- Created by https://github.com/ekalinin/github-markdown-toc -->`

#### Scenario: Stdin

- **WHEN** the user runs
`cat "tests/test directory/test_depth.md" | gh-md-toc --depth 1 -`
- **THEN** the output is `* [Title one](#title-one)` and `* [Title two](#title-two)`

### Requirement: No limit by default

Without `--depth`, or with `--depth 0`, the TOC SHALL contain an entry for every heading
of the document, as before this change.

#### Scenario: Without the option

- **WHEN** the user runs `gh-md-toc "tests/test directory/test_depth.md"`
- **THEN** the TOC entries are `* [Title one](#title-one)`, ` * [Section](#section)`,
` * [Subsection](#subsection)`, ` * [Deep](#deep)`,
`* [Title two](#title-two)`

#### Scenario: Zero depth

- **WHEN** the user runs `gh-md-toc --depth 0 "tests/test directory/test_depth.md"`
- **THEN** the TOC entries are the same as without the option

### Requirement: Help lists the option

`--help` SHALL list `--depth <NUM>` in the "Options" section right after
`--indent <NUM>`.

#### Scenario: Help output

- **WHEN** the user runs `gh-md-toc --help`
- **THEN** the line after ` --indent <NUM> Set indent size. Default: 3.` is
` --depth <NUM> Max heading level to include into TOC. Default: 0 (all levels).`
45 changes: 45 additions & 0 deletions openspec/changes/add-toc-depth/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Tasks

## 1. Tests

- [x] 1.1 Add the fixture `tests/test directory/test_depth.md` with the headings
`# Title one`, `## Section`, `### Subsection`, `#### Deep`, `# Title two`; verify that
`./gh-md-toc "tests/test directory/test_depth.md"` prints the five entries from the
spec scenario "Without the option"
- [x] 1.2 Add a test in `tests/tests.bats` for `--depth 2` on the fixture, as in the spec
scenario "Local file"; verify it fails before the implementation
(`bats --filter "<test name>" tests`)
- [x] 1.3 Add a test for `--depth 1 -` with the fixture on stdin, as in the spec scenario
"Stdin"; verify it fails before the implementation
- [x] 1.4 Update `test_help`: the `--depth` line at index 8, the `--insert`,
`--no-backup`, `--hide-footer`, `--skip-header` lines at 9, 11, 12, 13, and 15 lines
in total; verify `--help` and `no arguments` fail before the implementation

## 2. Implementation

- [x] 2.1 `gh_toc_grab`: take the depth as the third argument, pass it to awk with
`-v "depth=$3"`, add `if (depth+0 > 0 && level+0 > depth+0) next` at the start of
`common_awk_script`, and describe `$3` in the comment above the function; verify
`make lint` passes and the existing tests still pass
- [x] 2.2 `gh_toc_app`: add `local depth=0` and a `--depth` check right after the
`--indent` check; pass `$depth` to `gh_toc_grab` in the stdin branch and to `gh_toc`
as the 8th parameter. `gh_toc`: take `local depth=$8` and pass it to both
`gh_toc_grab` calls; verify the tests from 1.2 and 1.3 pass
- [x] 2.3 `show_help`: add
` --depth <NUM> Max heading level to include into TOC. Default: 0 (all levels).`
right after the `--indent` line; verify the tests from 1.4 pass
- [x] 2.4 Check the spec scenario "Zero depth": verify the output of
`./gh-md-toc --depth 0 "tests/test directory/test_depth.md"` is the same as without
the option

## 3. Documentation

- [x] 3.1 README, section "Local files": after the existing example, add a sentence
about `--depth` and the example `./gh-md-toc --depth 1 ~/projects/Dockerfile.vim/README.md`
with the output of a real run on the current `ekalinin/Dockerfile.vim` README; add no
new heading; verify the README TOC tests (`TOC for local README.md`,
`TOC for local README.md with skip headers`, `TOC for markdown from stdin`) still pass

## 4. Verification

- [x] 4.1 Run `make lint` and `make test` with `GH_TOC_TOKEN` set; verify both pass
19 changes: 19 additions & 0 deletions tests/test directory/test_depth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Title one

Blabla...

## Section

Blabla...

### Subsection

Blabla...

#### Deep

Blabla...

# Title two

Blabla...
Loading
Loading