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
2 changes: 1 addition & 1 deletion gh-md-toc
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ gh_toc_grab() {
sed -e ':a' -e 'N' -e '$!ba' -e 's/\n<span/<span/g' |

# find strings that corresponds to template
$grepcmd '<h.*class="heading-element".*</a' |
$grepcmd '<h[1-6][^>]*class="heading-element".*</a' |

# remove code tags
sed 's/<code>//g' | sed 's/<\/code>//g' |
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/fix-remote-first-heading/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-27
84 changes: 84 additions & 0 deletions openspec/changes/fix-remote-first-heading/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Design

## Context

See proposal.md for motivation and specs/remote-toc/spec.md for requirements.

Root cause, checked on the HTML that github.com returns for the pages from #166:

- On a file (blob) page the first heading of the document is on the same physical line
as the React root of the page (line 786, about 34 KB). Earlier on that line there is
the file tree pane heading `<h2 class="use-tree-pane-module__Heading...">`, followed
by a link to `/<owner>/<repo>/tree/<sha>`.
- Every other heading starts on its own line with `<div class="markdown-heading"`.
- `gh_toc_grab` selects headings with `$grepcmd '<h.*class="heading-element".*</a'`.
With `-o` the match starts at the leftmost `<h` on the line, which is the file tree
`<h2>`, not the document `<h1>`.
- The awk step then takes the level from the 3rd character (`2`), the text from the
first `">` to the last `</h` (the page markup), and the first `href` (the tree URL).
That gives all three symptoms from #166.
- Documents that do not start with a heading, and wiki pages, have nothing but
whitespace before the first heading tag on its line, so they are not affected.
- The embedded JSON line (785) also contains `heading-element`, but with escaped `<`,
so the grep does not match it.

Constraints: `grep -Eo` on Linux and macOS/BSD, `pcregrep -o` on OS/390, GNU and BSD
`sed`.

## Goals / Non-Goals

**Goals:**

- Fix remote file pages with a one-line change to the grep pattern, without changing
the output for local files, stdin, and wiki pages.

**Non-Goals:**

- Restructuring `gh_toc_grab` or making it independent of GitHub page layout.
- An offline test for the remote path: `gh_is_url` accepts only `http*` sources, so a
saved HTML fixture cannot be fed through `gh_toc_load` without code changes.

## Decisions

### Anchor the grep match at a real heading tag

Change the pattern to `'<h[1-6][^>]*class="heading-element".*</a'`.

`<h[1-6]` accepts only heading tags, and `[^>]*` keeps the match inside that one tag
up to `class="heading-element"`. The leftmost match is then the document heading, so
the awk step gets a line that starts with `<hN ...>`, as it expects. The syntax is the
same for ERE (`grep -E`) and PCRE (`pcregrep`), so the OS/390 branch keeps working
with no separate change.

Checked on a temporary copy of the script:

- Remote: `ekalinin/github-markdown-toc` README, `ekalinin/sitemap.js` README,
`ekalinin/envirius` README.ru.md are fixed. `the-art-of-command-line` README-zh.md and
README-pt.md and the `nodeenv` wiki page give the same output as before.
- Local: `README.md` and all 5 fixtures in `tests/test directory/` produce
byte-for-byte the same output as before.

Alternatives considered:

- Insert a newline before each `<div class="markdown-heading"` with `sed`: a `\n` in
the replacement is not portable to BSD `sed`.
- Cut the HTML down to `<article class="markdown-body">` before grepping: more code,
and wiki pages use different markup around the document.
- Read the headings from the embedded JSON (`headerInfo.toc`): exists only on file
pages, not on wiki pages or API output, and parsing JSON in awk is fragile.

### Re-enable remote tests without changes

The three commented-out remote tests already assert the expected output for pinned
commits. With the fix all three pass as written; with the original script all three
fail. No new tests are added.

## Risks / Trade-offs

- [The trailing `.*</a` is still greedy and runs to the last `</a` on the line] → On
all checked pages nothing follows the heading anchor on a heading line. Left as is to
keep the diff minimal; if GitHub adds markup after the heading on the same line, the
re-enabled remote tests are expected to catch it.
- [Remote tests depend on network access and on github.com markup] → Accepted. They
use pinned commit URLs, so only a markup change on GitHub's side can break them, and
CI runs twice a week on a schedule.
49 changes: 49 additions & 0 deletions openspec/changes/fix-remote-first-heading/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Proposal

## Why

For a GitHub file (`/blob/`) URL whose document starts with a heading, the first TOC
entry is replaced with about 30 KB of GitHub page markup, indented as a second-level
entry, with a link to the repository tree instead of the heading anchor (#166).
Reproduced with 0.10.0 on `ekalinin/github-markdown-toc` README, `ekalinin/sitemap.js`
README and `ekalinin/envirius` README.ru.md. Wiki pages and documents that do not start
with a heading are not affected.

## What Changes

- Fix the heading grep in `gh_toc_grab` so a match can start only at a real
`<h1>`..`<h6>` heading tag, not at any earlier `<h...` tag on the same line of the
page.
- Re-enable the three commented-out remote tests in `tests/tests.bats` as they are
written:
- `TOC for remote README.md`
- `TOC for mixed README.md (remote/local)`
- `TOC for remote non-english chars (remote load), #6, #10`

Non-goals:

- Changes to the local file and stdin paths (output stays byte-for-byte the same).
- The separate OS/390 awk text regex (`<\/span><\/a>[^<]*<\/h`), which matches an
older GitHub markup.
- Changes to README, the landing page (`site/`), or `openspec/specs/landing`.
- A version bump.

## Capabilities

### New Capabilities

- `remote-toc`: TOC generation for remote GitHub pages (file and wiki URLs).

### Modified Capabilities

None.

## Impact

- `gh-md-toc`: one line in `gh_toc_grab` (the `$grepcmd` pattern). The same pattern is
used by `grep -Eo` and by `pcregrep -o` on OS/390.
- `tests/tests.bats`: three tests uncommented. They fetch pages from github.com, so the
suite depends on network access and on GitHub page markup. They were commented out
in `9618358` ("commented out some tests (with remote logic)").
- `openspec/specs/landing` has the requirement "No GitHub file URLs in examples",
which applies only while #166 is open. This change does not touch it.
59 changes: 59 additions & 0 deletions openspec/changes/fix-remote-first-heading/specs/remote-toc/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Spec Delta

## Purpose

Generates a table of contents for a remote GitHub page (a file or a wiki page) given by
its URL, with anchors identical to the ones GitHub renders for that page.

## ADDED 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 - 1) * indent` spaces (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)`

### Requirement: Remote page among multiple inputs

When more than one input is given, the entries of a remote page SHALL follow the same
rules, with each link prefixed by the page URL: `* [<heading text>](<URL>#<anchor>)`.

#### Scenario: Local file and remote file

- **WHEN** the user runs `gh-md-toc README.md https://github.com/ekalinin/sitemap.js/blob/6bc3eb12c898c1037a35a11b2eb24ababdeb3580/README.md`
- **THEN** the README entries are followed by
`* [sitemap.js](https://github.com/ekalinin/sitemap.js/blob/6bc3eb12c898c1037a35a11b2eb24ababdeb3580/README.md#sitemapjs)`,
` * [Installation](https://github.com/ekalinin/sitemap.js/blob/6bc3eb12c898c1037a35a11b2eb24ababdeb3580/README.md#installation)`,
` * [Usage](https://github.com/ekalinin/sitemap.js/blob/6bc3eb12c898c1037a35a11b2eb24ababdeb3580/README.md#usage)`,
` * [License](https://github.com/ekalinin/sitemap.js/blob/6bc3eb12c898c1037a35a11b2eb24ababdeb3580/README.md#license)`
21 changes: 21 additions & 0 deletions openspec/changes/fix-remote-first-heading/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Tasks

## 1. Setup

- [x] 1.1 Create branch `fix/remote-first-heading` from `master` and verify `git branch --show-current` prints `fix/remote-first-heading`

## 2. Tests first

- [x] 2.1 Uncomment `TOC for remote README.md`, `TOC for mixed README.md (remote/local)` and `TOC for remote non-english chars (remote load), #6, #10` in `tests/tests.bats` without changing their assertions; verify `git diff tests/tests.bats` only removes the leading `# ` / `#` from those lines
- [x] 2.2 Run the suite with the unfixed script (`make test`, or `npx --yes bats@1 tests` when `bats` is not installed) and verify exactly those three tests fail and the other 11 pass

## 3. Fix

- [x] 3.1 In `gh_toc_grab` (`gh-md-toc`), change the `$grepcmd` pattern from `'<h.*class="heading-element".*</a'` to `'<h[1-6][^>]*class="heading-element".*</a'`; verify `git diff gh-md-toc` shows only that one line
- [x] 3.2 Run the suite again and verify all 14 tests pass
- [x] 3.3 Run `make lint` and verify shellcheck reports no new warnings compared to `master`

## 4. Manual checks

- [x] 4.1 Run `./gh-md-toc https://github.com/ekalinin/github-markdown-toc/blob/master/README.md` and verify the first entry is `* [gh-md-toc](#gh-md-toc)` and every entry links to `#...`
- [x] 4.2 Run `./gh-md-toc https://github.com/ekalinin/nodeenv/wiki/Who-Uses-Nodeenv` and verify the output is the same as with the script from `master`
Loading
Loading