Skip to content
Open
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
21 changes: 16 additions & 5 deletions gh-md-toc
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
# It's a level of the current header:
# substr($0, length($0), 1)
#
# 3.2 Get level from 3.1 and insert corresponding number of spaces before '*':
# sprintf("%*s", (level-1)*'"$nb_spaces"', "")
# 3.2 Get level from 3.1 and insert corresponding number of spaces before '*'
# (min_level is the smallest level in the document):
# sprintf("%*s", (level-min_level)*'"$nb_spaces"', "")
#
# 4. Find head's text and insert it inside "* [ ... ]":
# substr($0, match($0, /a>.*<\/h/)+2, RLENGTH-5)
Expand Down Expand Up @@ -258,8 +259,18 @@ gh_toc_grab() {
prefix = ""
if (numbered == "list") marker = "1. "
if (numbered == "outline") prefix = num " "
print sprintf("%*s", (level-1)*'"$2"', "") marker "[" prefix text "](" gh_url modified_href ")"
# keep the entry, the indent is known only after the last one
count++
entry_level[count] = level+0
entry[count] = marker "[" prefix text "](" gh_url modified_href ")"
if (count == 1 || level+0 < min_level) min_level = level+0
'
# indent relative to the top level of the document, see #30
common_awk_end='
END {
for (i = 1; i <= count; i++)
print sprintf("%*s", (entry_level[i]-min_level)*'"$2"', "") entry[i]
}'
if [ "$(uname -s)" == "OS/390" ]; then
grepcmd="pcregrep -o"
echoargs=""
Expand All @@ -269,7 +280,7 @@ gh_toc_grab() {
text = substr($0, match($0, /<\/span><\/a>[^<]*<\/h/)+11, RLENGTH-14)
href = substr($0, match($0, '$href_regex')+6, RLENGTH-7)
'"$common_awk_script"'
}'
}'"$common_awk_end"
else
grepcmd="grep -Eo"
echoargs="-e"
Expand All @@ -279,7 +290,7 @@ gh_toc_grab() {
text = substr($0, match($0, /">.*<\/h/)+2, RLENGTH-5)
href = substr($0, match($0, '$href_regex')+6, RLENGTH-7)
'"$common_awk_script"'
}'
}'"$common_awk_end"
fi

# if closed <h[1-6]> is on the new line, then move it on the prev line
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/fix-relative-toc-indent/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-15
82 changes: 82 additions & 0 deletions openspec/changes/fix-relative-toc-indent/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Design

## Context

Every input path ends in `gh_toc_grab`. Its awk script reads the level as
`substr($0, 3, 1)` and runs `common_awk_script`, which is shared by the default and the
OS/390 branches. `common_awk_script` drops entries deeper than `--depth` with `next`,
builds the `--numbered outline` number from a stack of levels, and prints each entry
right away as `sprintf("%*s", (level-1)*'"$2"', "") marker "[" prefix text "](" gh_url
modified_href ")"`, where `$2` is the indent spliced into the script by the shell.

`common_awk_script` already uses the names `top`, `lvls`, `nums`, `prev`, `num`, `k`,
`modified_href`, `chars`, `c`, `res`, `i`, `marker` and `prefix`. `top` is the stack
pointer of the outline numbers, not the top heading level from the specs.

Each input of several inputs goes through its own `gh_toc_grab` call (`gh_toc` is called
once per input), so one awk run sees one document.

## Goals / Non-Goals

**Goals:**

- One change point that covers all inputs and both awk branches.
- The output for a document with a `#` heading stays byte-for-byte the same.

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

## Decisions

### `top` is the smallest level, not the level of the first entry

The indent is counted from the smallest heading level among the entries.

Alternative: count from the level of the first entry. For `###` followed by `##`, the
`##` entry would get a negative indent and would need a clamp to 0, which puts `###` and
`##` at the same indent. The smallest level never gives a negative indent and keeps the
distance between levels as it is today.

### Keep the entries in awk and print them in `END`

The smallest level is known only after the last heading. `common_awk_script` stores
each entry without the indent and its level in two arrays and updates the smallest
level. A new `END` block prints the entries in order with
`sprintf("%*s", (level - min_level) * '"$2"', "")` in front.

New awk names: `count`, `entry_level`, `entry`, `min_level`. They do not clash with the
names above.

The `END` block is a separate shell variable next to `common_awk_script`, appended to
both `awkscript` branches, so OS/390 gets the same change.

Alternatives:

- Run awk twice over the grep output (first pass for the smallest level). This needs the
intermediate output in a variable or a temp file and doubles the pipeline tail.
- Strip the common leading spaces after awk with `sed`. The awk output still goes
through `echo -e`, which turns `\x..` into characters, so a second text pass is harder
to keep correct than an integer subtraction in awk.

Keeping the entries in memory is fine: a TOC has tens or hundreds of entries.

### `--depth` and `--numbered` need no changes

`--depth` drops entries with `next` before they are stored, so the smallest level is
taken over the entries in the TOC. `--depth` only drops levels deeper than `NUM`, so the
smallest level stays the same whenever the TOC is not empty.

The outline numbers are built from the order of levels, not from their absolute values,
so they already start from `1.` at the top entries.

The `--numbered list requires --indent 3 or more` check stays: nested entries still
need at least 3 spaces under the `1.` marker.

## Risks / Trade-offs

- [A TOC that `--insert` put into a file without a `#` heading changes on the next run]
→ This is the fix itself. The proposal states it as an output change.
- [A document whose first heading is deeper than a later one, with `--indent 4` or
more, still gets a code block] → Out of scope (proposal.md - Non-goals). Handling it
would need a different indent rule than the specs define.
- [awk portability] → Arrays and `END` are POSIX awk and work in gawk, mawk and BWK awk.
`%*s` is already used today.
60 changes: 60 additions & 0 deletions openspec/changes/fix-relative-toc-indent/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Proposal

## Why

The indent of a TOC entry is `(level - 1) * indent` spaces, counted from `#` even when the
document has no `#` heading (#30). When the first entry gets 4 or more spaces, GitHub
renders the whole TOC as a code block instead of a list. Reproduced with 0.10.0 and the
GitHub markdown API:

- `### Foo`, `#### Bar` with the default indent: the first entry has 6 spaces, the TOC
is rendered as `<pre><code>` (also with `--insert`).
- `## Foo`, `### Bar` with `--indent 4`: the first entry has 4 spaces, the TOC is
rendered as `<pre><code>`.

The example from the issue (`## Foo`, `### Bar` with the default indent) is rendered as a
list today, because the first entry has 3 spaces, but the cause is the same.

## What Changes

- Indent every TOC entry by `(level - top) * indent` spaces, where `top` is the smallest
heading level among the entries of the same document. For a document with a `#`
heading the output stays byte-for-byte the same.
- Add a test fixture without a `#` heading and tests for a local file, stdin with
`--indent 4`, and stdin with `--depth`.

Output change: for a document without a `#` heading, every entry moves left by
`(top - 1) * indent` spaces. A TOC that `--insert` put into such a file changes on the
next run.

Non-goals:

- `--depth`: the level stays absolute, as `toc-depth` requires.
- `--numbered outline` numbers: they already start from `1.` at the top entries.
- Skipped heading levels (e.g. `#` followed by `###`): the indent between entries does
not change.
- A document whose first heading is deeper than a later one (e.g. `###` before `##`)
with `--indent 4` or more: the first entry still gets `indent` spaces.
- Changes to README, the landing page (`site/`), or `openspec/specs/landing`.
- A version bump.

## Capabilities

### New Capabilities

- `toc-indent`: indentation of TOC entries relative to the top heading level of the
document, for every input.

### Modified Capabilities

- `remote-toc`: "TOC for a remote GitHub page" states `(level - 1) * indent`.
- `toc-depth`: "TOC limited by heading level" states `(level - 1) * indent`.
- `toc-numbered`: "Ordered list" and "Hierarchical numbers" state `(level - 1) * indent`.

## Impact

- `gh-md-toc`: the awk script in `gh_toc_grab` keeps the entries and prints them at the
end, when the top level is known. The same script is used on OS/390. Each input of
several inputs goes through its own `gh_toc_grab` call, so `top` is per document.
- `tests/tests.bats` and `tests/test directory/`: one new fixture and three new tests.
Existing fixtures all have a `#` heading, so their assertions do not change.
41 changes: 41 additions & 0 deletions openspec/changes/fix-relative-toc-indent/specs/remote-toc/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Spec Delta

## MODIFIED Requirements

### Requirement: TOC for a remote GitHub page

For a GitHub file (`/blob/`) URL or a GitHub wiki page URL, the TOC SHALL contain one
entry per heading of the rendered document, in document order, including the first
heading when the document starts with a heading. Each entry SHALL have the form
`* [<heading text>](#<anchor>)`, indented by `(level - top) * indent` spaces, where `top`
is the smallest heading level among the entries of the document (indent is 3 by
default). No entry SHALL contain markup of the GitHub page around the document or
link to anything other than a heading anchor of the document.

#### Scenario: File that starts with a heading

- **WHEN** the user runs `gh-md-toc https://github.com/ekalinin/sitemap.js/blob/6bc3eb12c898c1037a35a11b2eb24ababdeb3580/README.md`
- **THEN** the output is `Table of Contents`, `=================`,
`* [sitemap.js](#sitemapjs)`, ` * [Installation](#installation)`,
` * [Usage](#usage)`, ` * [License](#license)`, and
`<!-- Created by https://github.com/ekalinin/github-markdown-toc -->` (ignoring empty
lines)

#### Scenario: File with non-English headings

- **WHEN** the user runs `gh-md-toc https://github.com/ekalinin/envirius/blob/f939d3b6882bfb6ecb28ef7b6e62862f934ba945/README.ru.md`
- **THEN** the first entries are `* [envirius](#envirius)`, ` * [Идея](#идея)`,
` * [Особенности](#особенности)`, `* [Установка](#установка)`

#### Scenario: File that does not start with a heading

- **WHEN** the user runs `gh-md-toc https://github.com/jlevy/the-art-of-command-line/blob/217da3b4fa751014ecc122fd9fede2328a7eeb3e/README-pt.md`
- **THEN** the first entries are
`* [A arte da linha de comando](#a-arte-da-linha-de-comando)`, ` * [Meta](#meta)`,
` * [Básico](#básico)`, ` * [Uso diário](#uso-diário)`

#### Scenario: Wiki page

- **WHEN** the user runs `gh-md-toc https://github.com/ekalinin/nodeenv/wiki/Who-Uses-Nodeenv`
- **THEN** the first entries are `* [Who Uses Nodeenv?](#who-uses-nodeenv)`,
` * [edx](#edx)`, ` * [OpenStack](#openstack)`
39 changes: 39 additions & 0 deletions openspec/changes/fix-relative-toc-indent/specs/toc-depth/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Spec Delta

## MODIFIED 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 - top) * indent` spaces, where `top` is
the smallest heading level among the entries of the document. 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)`

#### Scenario: Document without a `#` heading

- **WHEN** the user runs
`cat "tests/test directory/test_noh1.md" | gh-md-toc --depth 2 -`, where the file has
the headings `## Section one`, `### Subsection`, `## Section two`
- **THEN** the output is `* [Section one](#section-one)` and
`* [Section two](#section-two)`
48 changes: 48 additions & 0 deletions openspec/changes/fix-relative-toc-indent/specs/toc-indent/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Spec Delta

## Purpose

Keeps the TOC a markdown list when the document has no `#` heading, by indenting the
entries relative to the top heading level of the document instead of `#`.

## ADDED Requirements

### Requirement: Indent relative to the top level

Every TOC entry SHALL be indented by `(level - top) * indent` spaces, where `level` is
the heading level of the entry (`#` is level 1, `######` is level 6), `top` is the
smallest heading level among the TOC entries of the same document, and `indent` is the
value of `--indent <NUM>` (3 by default). Entries at the `top` level SHALL have no
indent. This SHALL apply to every input: stdin, a local file, a remote URL, several
inputs, and `--insert`. With several inputs, each document SHALL have its own `top`.

#### Scenario: Document with a `#` heading

- **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: Local file without a `#` heading

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

#### Scenario: Stdin with an indent of 4

- **WHEN** the user runs
`cat "tests/test directory/test_noh1.md" | gh-md-toc --indent 4 -`
- **THEN** the output is `* [Section one](#section-one)`,
` * [Subsection](#subsection)`, `* [Section two](#section-two)`

#### Scenario: Several inputs

- **WHEN** the user runs
`gh-md-toc "tests/test directory/test_plussign.md" "tests/test directory/test_noh1.md"`
- **THEN** the entries of `test_noh1.md` are
`* [Section one](tests/test directory/test_noh1.md#section-one)`,
` * [Subsection](tests/test directory/test_noh1.md#subsection)`,
`* [Section two](tests/test directory/test_noh1.md#section-two)`
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Spec Delta

## MODIFIED Requirements

### Requirement: Ordered list

With `--numbered list`, every TOC entry SHALL have the form `1. [<heading text>](<link>)`,
with the marker `1.` for every entry, indented by `(level - top) * indent` spaces, where
`top` is the smallest heading level among the entries of the document. The heading text
and the link SHALL be the same as without `--numbered`.

#### Scenario: Local file

- **WHEN** the user runs `gh-md-toc --numbered list "tests/test directory/test_numbered.md"`,
where the file has the headings `# Title one`, `## Section`, `### Subsection`,
`## Other section`, `# Title two`, `### Skipped`, `## After skip`
- **THEN** the TOC entries are `1. [Title one](#title-one)`, ` 1. [Section](#section)`,
` 1. [Subsection](#subsection)`, ` 1. [Other section](#other-section)`,
`1. [Title two](#title-two)`, ` 1. [Skipped](#skipped)`,
` 1. [After skip](#after-skip)`, followed by
`<!-- Created by https://github.com/ekalinin/github-markdown-toc -->`

### Requirement: Hierarchical numbers

With `--numbered outline`, every TOC entry SHALL have the form
`* [<number> <heading text>](<link>)`, indented by `(level - top) * indent` spaces, where
`top` is the smallest heading level among the entries of the document. The heading text
and the link SHALL be the same as without `--numbered`.

The number SHALL follow the heading tree of the entries in the TOC. The parent of an
entry is the nearest previous entry with a lower heading level; entries without a parent
are top entries. The number of a top entry SHALL be its position among the top entries
followed by a dot (`1.`, `2.`). The number of any other entry SHALL be the number of its
parent followed by its position among the children of that parent and a dot (`2.1.`,
`2.1.3.`). A skipped heading level SHALL NOT add a part to the number.

#### Scenario: Local file

- **WHEN** the user runs
`gh-md-toc --numbered outline "tests/test directory/test_numbered.md"`
- **THEN** the TOC entries are `* [1. Title one](#title-one)`,
` * [1.1. Section](#section)`, ` * [1.1.1. Subsection](#subsection)`,
` * [1.2. Other section](#other-section)`, `* [2. Title two](#title-two)`,
` * [2.1. Skipped](#skipped)`, ` * [2.2. After skip](#after-skip)`
Loading
Loading