From 4191b0461b4be7106c09baae9cd0e8fcad982ece Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Sun, 27 Sep 2026 21:53:40 +0300 Subject: [PATCH 1/2] fix(remote): keep page markup out of the first TOC entry On GitHub file pages the first heading of a document shares a line with the page markup, including the file tree

. The heading grep started its match at that

, so the first entry got the page HTML, a level-2 indent and a link to the repository tree. Match only

..

tags that carry class="heading-element", and re-enable the remote tests that cover this case. Fixes #166 --- gh-md-toc | 2 +- tests/tests.bats | 144 +++++++++++++++++++++++------------------------ 2 files changed, 73 insertions(+), 73 deletions(-) diff --git a/gh-md-toc b/gh-md-toc index 35239bf..bce5271 100755 --- a/gh-md-toc +++ b/gh-md-toc @@ -274,7 +274,7 @@ gh_toc_grab() { sed -e ':a' -e 'N' -e '$!ba' -e 's/\n]*class="heading-element".*//g' | sed 's/<\/code>//g' | diff --git a/tests/tests.bats b/tests/tests.bats index c7b3b75..f8b0443 100755 --- a/tests/tests.bats +++ b/tests/tests.bats @@ -54,48 +54,48 @@ load test_helper assert_equal "${lines[17]}" "" } -# @test "TOC for remote README.md" { -# run $BATS_TEST_DIRNAME/../gh-md-toc https://github.com/ekalinin/sitemap.js/blob/6bc3eb12c898c1037a35a11b2eb24ababdeb3580/README.md -# assert_success -# -# assert_equal "${lines[0]}" "Table of Contents" -# assert_equal "${lines[1]}" "=================" -# assert_equal "${lines[2]}" "* [sitemap.js](#sitemapjs)" -# assert_equal "${lines[3]}" " * [Installation](#installation)" -# assert_equal "${lines[4]}" " * [Usage](#usage)" -# assert_equal "${lines[5]}" " * [License](#license)" -# assert_equal "${lines[6]}" "" -# } - -# @test "TOC for mixed README.md (remote/local)" { -# run $BATS_TEST_DIRNAME/../gh-md-toc \ -# README.md \ -# https://github.com/ekalinin/sitemap.js/blob/6bc3eb12c898c1037a35a11b2eb24ababdeb3580/README.md -# assert_success -# -# assert_equal "${lines[0]}" "* [gh-md-toc](README.md#gh-md-toc)" -# assert_equal "${lines[1]}" "* [Table of contents](README.md#table-of-contents)" -# assert_equal "${lines[2]}" "* [Installation](README.md#installation)" -# assert_equal "${lines[3]}" "* [Usage](README.md#usage)" -# assert_equal "${lines[4]}" " * [STDIN](README.md#stdin)" -# assert_equal "${lines[5]}" " * [Local files](README.md#local-files)" -# assert_equal "${lines[6]}" " * [Remote files](README.md#remote-files)" -# assert_equal "${lines[7]}" " * [Multiple files](README.md#multiple-files)" -# assert_equal "${lines[8]}" " * [Combo](README.md#combo)" -# assert_equal "${lines[9]}" " * [Auto insert and update TOC](README.md#auto-insert-and-update-toc)" -# assert_equal "${lines[10]}" " * [GitHub token](README.md#github-token)" -# assert_equal "${lines[11]}" " * [TOC generation with Github Actions](README.md#toc-generation-with-github-actions)" -# assert_equal "${lines[12]}" "* [Tests](README.md#tests)" -# assert_equal "${lines[13]}" "* [Dependency](README.md#dependency)" -# assert_equal "${lines[14]}" "* [Docker](README.md#docker)" -# assert_equal "${lines[15]}" " * [Local](README.md#local)" -# assert_equal "${lines[16]}" " * [Public](README.md#public)" -# assert_equal "${lines[17]}" "* [sitemap.js](https://github.com/ekalinin/sitemap.js/blob/6bc3eb12c898c1037a35a11b2eb24ababdeb3580/README.md#sitemapjs)" -# assert_equal "${lines[18]}" " * [Installation](https://github.com/ekalinin/sitemap.js/blob/6bc3eb12c898c1037a35a11b2eb24ababdeb3580/README.md#installation)" -# assert_equal "${lines[19]}" " * [Usage](https://github.com/ekalinin/sitemap.js/blob/6bc3eb12c898c1037a35a11b2eb24ababdeb3580/README.md#usage)" -# assert_equal "${lines[20]}" " * [License](https://github.com/ekalinin/sitemap.js/blob/6bc3eb12c898c1037a35a11b2eb24ababdeb3580/README.md#license)" -# assert_equal "${lines[21]}" "" -# } +@test "TOC for remote README.md" { + run $BATS_TEST_DIRNAME/../gh-md-toc https://github.com/ekalinin/sitemap.js/blob/6bc3eb12c898c1037a35a11b2eb24ababdeb3580/README.md + assert_success + + assert_equal "${lines[0]}" "Table of Contents" + assert_equal "${lines[1]}" "=================" + assert_equal "${lines[2]}" "* [sitemap.js](#sitemapjs)" + assert_equal "${lines[3]}" " * [Installation](#installation)" + assert_equal "${lines[4]}" " * [Usage](#usage)" + assert_equal "${lines[5]}" " * [License](#license)" + assert_equal "${lines[6]}" "" +} + +@test "TOC for mixed README.md (remote/local)" { + run $BATS_TEST_DIRNAME/../gh-md-toc \ + README.md \ + https://github.com/ekalinin/sitemap.js/blob/6bc3eb12c898c1037a35a11b2eb24ababdeb3580/README.md + assert_success + + assert_equal "${lines[0]}" "* [gh-md-toc](README.md#gh-md-toc)" + assert_equal "${lines[1]}" "* [Table of contents](README.md#table-of-contents)" + assert_equal "${lines[2]}" "* [Installation](README.md#installation)" + assert_equal "${lines[3]}" "* [Usage](README.md#usage)" + assert_equal "${lines[4]}" " * [STDIN](README.md#stdin)" + assert_equal "${lines[5]}" " * [Local files](README.md#local-files)" + assert_equal "${lines[6]}" " * [Remote files](README.md#remote-files)" + assert_equal "${lines[7]}" " * [Multiple files](README.md#multiple-files)" + assert_equal "${lines[8]}" " * [Combo](README.md#combo)" + assert_equal "${lines[9]}" " * [Auto insert and update TOC](README.md#auto-insert-and-update-toc)" + assert_equal "${lines[10]}" " * [GitHub token](README.md#github-token)" + assert_equal "${lines[11]}" " * [TOC generation with Github Actions](README.md#toc-generation-with-github-actions)" + assert_equal "${lines[12]}" "* [Tests](README.md#tests)" + assert_equal "${lines[13]}" "* [Dependency](README.md#dependency)" + assert_equal "${lines[14]}" "* [Docker](README.md#docker)" + assert_equal "${lines[15]}" " * [Local](README.md#local)" + assert_equal "${lines[16]}" " * [Public](README.md#public)" + assert_equal "${lines[17]}" "* [sitemap.js](https://github.com/ekalinin/sitemap.js/blob/6bc3eb12c898c1037a35a11b2eb24ababdeb3580/README.md#sitemapjs)" + assert_equal "${lines[18]}" " * [Installation](https://github.com/ekalinin/sitemap.js/blob/6bc3eb12c898c1037a35a11b2eb24ababdeb3580/README.md#installation)" + assert_equal "${lines[19]}" " * [Usage](https://github.com/ekalinin/sitemap.js/blob/6bc3eb12c898c1037a35a11b2eb24ababdeb3580/README.md#usage)" + assert_equal "${lines[20]}" " * [License](https://github.com/ekalinin/sitemap.js/blob/6bc3eb12c898c1037a35a11b2eb24ababdeb3580/README.md#license)" + assert_equal "${lines[21]}" "" +} @test "TOC for markdown from stdin" { cat README.md | { @@ -163,36 +163,36 @@ test_help() { assert_equal "${lines[5]}" " * [日常使用](#日常使用)" } -# @test "TOC for remote non-english chars (remote load), #6, #10" { -# run $BATS_TEST_DIRNAME/../gh-md-toc \ -# https://github.com/ekalinin/envirius/blob/f939d3b6882bfb6ecb28ef7b6e62862f934ba945/README.ru.md -# assert_success -# -# assert_equal "${lines[2]}" "* [envirius](#envirius)" -# assert_equal "${lines[3]}" " * [Идея](#идея)" -# assert_equal "${lines[4]}" " * [Особенности](#особенности)" -# assert_equal "${lines[5]}" "* [Установка](#установка)" -# -# -# run $BATS_TEST_DIRNAME/../gh-md-toc \ -# https://github.com/jlevy/the-art-of-command-line/blob/217da3b4fa751014ecc122fd9fede2328a7eeb3e/README-zh.md -# assert_success -# -# assert_equal "${lines[2]}" "* [命令行的艺术](#命令行的艺术)" -# assert_equal "${lines[3]}" " * [必读](#必读)" -# assert_equal "${lines[4]}" " * [基础](#基础)" -# assert_equal "${lines[5]}" " * [日常使用](#日常使用)" -# -# -# run $BATS_TEST_DIRNAME/../gh-md-toc \ -# https://github.com/jlevy/the-art-of-command-line/blob/217da3b4fa751014ecc122fd9fede2328a7eeb3e/README-pt.md -# assert_success -# -# assert_equal "${lines[2]}" "* [A arte da linha de comando](#a-arte-da-linha-de-comando)" -# assert_equal "${lines[3]}" " * [Meta](#meta)" -# assert_equal "${lines[4]}" " * [Básico](#básico)" -# assert_equal "${lines[5]}" " * [Uso diário](#uso-diário)" -# } +@test "TOC for remote non-english chars (remote load), #6, #10" { + run $BATS_TEST_DIRNAME/../gh-md-toc \ + https://github.com/ekalinin/envirius/blob/f939d3b6882bfb6ecb28ef7b6e62862f934ba945/README.ru.md + assert_success + + assert_equal "${lines[2]}" "* [envirius](#envirius)" + assert_equal "${lines[3]}" " * [Идея](#идея)" + assert_equal "${lines[4]}" " * [Особенности](#особенности)" + assert_equal "${lines[5]}" "* [Установка](#установка)" + + + run $BATS_TEST_DIRNAME/../gh-md-toc \ + https://github.com/jlevy/the-art-of-command-line/blob/217da3b4fa751014ecc122fd9fede2328a7eeb3e/README-zh.md + assert_success + + assert_equal "${lines[2]}" "* [命令行的艺术](#命令行的艺术)" + assert_equal "${lines[3]}" " * [必读](#必读)" + assert_equal "${lines[4]}" " * [基础](#基础)" + assert_equal "${lines[5]}" " * [日常使用](#日常使用)" + + + run $BATS_TEST_DIRNAME/../gh-md-toc \ + https://github.com/jlevy/the-art-of-command-line/blob/217da3b4fa751014ecc122fd9fede2328a7eeb3e/README-pt.md + assert_success + + assert_equal "${lines[2]}" "* [A arte da linha de comando](#a-arte-da-linha-de-comando)" + assert_equal "${lines[3]}" " * [Meta](#meta)" + assert_equal "${lines[4]}" " * [Básico](#básico)" + assert_equal "${lines[5]}" " * [Uso diário](#uso-diário)" +} @test "TOC for text with backquote, #13" { run $BATS_TEST_DIRNAME/../gh-md-toc tests/test\ directory/test_backquote.md From 1677deb0d22fe864eeee0b06129e1f6c42660b08 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Sun, 27 Sep 2026 21:59:57 +0300 Subject: [PATCH 2/2] docs(openspec): add fix-remote-first-heading change --- .../fix-remote-first-heading/.openspec.yaml | 2 + .../fix-remote-first-heading/design.md | 84 +++++++++++++++++++ .../fix-remote-first-heading/proposal.md | 49 +++++++++++ .../specs/remote-toc/spec.md | 59 +++++++++++++ .../changes/fix-remote-first-heading/tasks.md | 21 +++++ 5 files changed, 215 insertions(+) create mode 100644 openspec/changes/fix-remote-first-heading/.openspec.yaml create mode 100644 openspec/changes/fix-remote-first-heading/design.md create mode 100644 openspec/changes/fix-remote-first-heading/proposal.md create mode 100644 openspec/changes/fix-remote-first-heading/specs/remote-toc/spec.md create mode 100644 openspec/changes/fix-remote-first-heading/tasks.md diff --git a/openspec/changes/fix-remote-first-heading/.openspec.yaml b/openspec/changes/fix-remote-first-heading/.openspec.yaml new file mode 100644 index 0000000..7f2ad57 --- /dev/null +++ b/openspec/changes/fix-remote-first-heading/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/fix-remote-first-heading/design.md b/openspec/changes/fix-remote-first-heading/design.md new file mode 100644 index 0000000..94dd4fb --- /dev/null +++ b/openspec/changes/fix-remote-first-heading/design.md @@ -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 `

`, followed + by a link to `///tree/`. +- Every other heading starts on its own line with `
`, not the document `

`. +- The awk step then takes the level from the 3rd character (`2`), the text from the + first `">` to the last `]*class="heading-element".*]*` 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 ``, 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 `
` 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 `.*`..`
` heading tag, not at any earlier `<\/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. diff --git a/openspec/changes/fix-remote-first-heading/specs/remote-toc/spec.md b/openspec/changes/fix-remote-first-heading/specs/remote-toc/spec.md new file mode 100644 index 0000000..c75e5c9 --- /dev/null +++ b/openspec/changes/fix-remote-first-heading/specs/remote-toc/spec.md @@ -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 +`* [](#)`, 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 + `` (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: `* [](#)`. + +#### 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)` diff --git a/openspec/changes/fix-remote-first-heading/tasks.md b/openspec/changes/fix-remote-first-heading/tasks.md new file mode 100644 index 0000000..807bd6d --- /dev/null +++ b/openspec/changes/fix-remote-first-heading/tasks.md @@ -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 `']*class="heading-element".*