From 5a3a9474ffa2d64634f05d88adbc823d62607670 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Mon, 28 Sep 2026 23:12:58 +0300 Subject: [PATCH] feat(toc): add --depth option to limit heading level --- README.md | 16 ++++ gh-md-toc | 20 +++- openspec/changes/add-toc-depth/.openspec.yaml | 2 + openspec/changes/add-toc-depth/design.md | 91 +++++++++++++++++++ openspec/changes/add-toc-depth/proposal.md | 53 +++++++++++ .../add-toc-depth/specs/toc-depth/spec.md | 63 +++++++++++++ openspec/changes/add-toc-depth/tasks.md | 45 +++++++++ tests/test directory/test_depth.md | 19 ++++ tests/tests.bats | 32 ++++++- 9 files changed, 331 insertions(+), 10 deletions(-) create mode 100644 openspec/changes/add-toc-depth/.openspec.yaml create mode 100644 openspec/changes/add-toc-depth/design.md create mode 100644 openspec/changes/add-toc-depth/proposal.md create mode 100644 openspec/changes/add-toc-depth/specs/toc-depth/spec.md create mode 100644 openspec/changes/add-toc-depth/tasks.md create mode 100644 tests/test directory/test_depth.md diff --git a/README.md b/README.md index 6231976..366de29 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,22 @@ Table of Contents * [License](#license) ``` +To include only headings up to a given level, use `--depth `: + +```bash +➥ ./gh-md-toc --depth 1 ~/projects/Dockerfile.vim/README.md + +Table of Contents +================= + +* [Dockerfile.vim](#dockerfilevim) +* [Screenshot](#screenshot) +* [Installation](#installation) +* [License](#license) + + +``` + Remote files ------------ diff --git a/gh-md-toc b/gh-md-toc index 641abcb..269f35a 100755 --- a/gh-md-toc +++ b/gh-md-toc @@ -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" @@ -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" @@ -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 "" "$gh_src" && grep -Fxq "" "$gh_src"; then @@ -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++) { @@ -290,7 +293,7 @@ gh_toc_grab() { # format result line # * $0 - whole string # * last element of each row: " Set indent size. Default: 3." + echo " --depth 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." @@ -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 @@ -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" @@ -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 @@ -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 "" diff --git a/openspec/changes/add-toc-depth/.openspec.yaml b/openspec/changes/add-toc-depth/.openspec.yaml new file mode 100644 index 0000000..ee7c544 --- /dev/null +++ b/openspec/changes/add-toc-depth/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-28 diff --git a/openspec/changes/add-toc-depth/design.md b/openspec/changes/add-toc-depth/design.md new file mode 100644 index 0000000..e3467a0 --- /dev/null +++ b/openspec/changes/add-toc-depth/design.md @@ -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 ` 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 ``: 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. diff --git a/openspec/changes/add-toc-depth/specs/toc-depth/spec.md b/openspec/changes/add-toc-depth/specs/toc-depth/spec.md new file mode 100644 index 0000000..7edfa87 --- /dev/null +++ b/openspec/changes/add-toc-depth/specs/toc-depth/spec.md @@ -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 ` 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 ` SHALL be accepted after `--indent ` (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 + `` + +#### 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 ` in the "Options" section right after +`--indent `. + +#### Scenario: Help output + +- **WHEN** the user runs `gh-md-toc --help` +- **THEN** the line after ` --indent Set indent size. Default: 3.` is + ` --depth Max heading level to include into TOC. Default: 0 (all levels).` diff --git a/openspec/changes/add-toc-depth/tasks.md b/openspec/changes/add-toc-depth/tasks.md new file mode 100644 index 0000000..1a38f62 --- /dev/null +++ b/openspec/changes/add-toc-depth/tasks.md @@ -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 "" 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 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 diff --git a/tests/test directory/test_depth.md b/tests/test directory/test_depth.md new file mode 100644 index 0000000..d1ec8e8 --- /dev/null +++ b/tests/test directory/test_depth.md @@ -0,0 +1,19 @@ +# Title one + +Blabla... + +## Section + +Blabla... + +### Subsection + +Blabla... + +#### Deep + +Blabla... + +# Title two + +Blabla... diff --git a/tests/tests.bats b/tests/tests.bats index b7b60b5..a64f3e1 100755 --- a/tests/tests.bats +++ b/tests/tests.bats @@ -126,11 +126,12 @@ test_help() { assert_equal "${lines[5]}" " gh-md-toc --version Show version" assert_equal "${lines[6]}" "Options:" assert_equal "${lines[7]}" " --indent Set indent size. Default: 3." - assert_equal "${lines[8]}" " --insert Insert new TOC into original file. For local files only. Default: false." - assert_equal "${lines[10]}" " --no-backup Remove backup file. Set --insert as well. Default: false." - assert_equal "${lines[11]}" " --hide-footer Do not write date & author of the last TOC update. Set --insert as well. Default: false." - assert_equal "${lines[12]}" " --skip-header Hide entry of the topmost headlines. Default: false." - assert_equal "${#lines[@]}" "14" + assert_equal "${lines[8]}" " --depth Max heading level to include into TOC. Default: 0 (all levels)." + assert_equal "${lines[9]}" " --insert Insert new TOC into original file. For local files only. Default: false." + assert_equal "${lines[11]}" " --no-backup Remove backup file. Set --insert as well. Default: false." + assert_equal "${lines[12]}" " --hide-footer Do not write date & author of the last TOC update. Set --insert as well. Default: false." + assert_equal "${lines[13]}" " --skip-header Hide entry of the topmost headlines. Default: false." + assert_equal "${#lines[@]}" "15" } @test "--help" { @@ -242,3 +243,24 @@ test_help() { assert_equal "${lines[2]}" "Parsing local markdown file requires access to github API" assert_equal "${lines[3]}" "Please make sure curl is installed and check your network connectivity" } + +@test "TOC with depth for local file, #25" { + run $BATS_TEST_DIRNAME/../gh-md-toc --depth 2 tests/test\ directory/test_depth.md + assert_success + + assert_equal "${lines[2]}" "* [Title one](#title-one)" + assert_equal "${lines[3]}" " * [Section](#section)" + assert_equal "${lines[4]}" "* [Title two](#title-two)" + assert_equal "${lines[5]}" "" +} + +@test "TOC with depth for markdown from stdin, #25" { + cat tests/test\ directory/test_depth.md | { + run $BATS_TEST_DIRNAME/../gh-md-toc --depth 1 - + assert_success + + assert_equal "${lines[0]}" "* [Title one](#title-one)" + assert_equal "${lines[1]}" "* [Title two](#title-two)" + assert_equal "${#lines[@]}" "2" + } +}