Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5cb48c5
chore(ci): build with Go 1.26.6 to clear stdlib vulnerabilities
ekalinin Aug 15, 2026
d5d2a94
refactor(toc): pass the document path into Render and Grab
ekalinin Aug 13, 2026
3b57ef0
fix(toc): scope absolute paths to multiple files and refresh architec…
ekalinin Aug 13, 2026
6fcba77
docs(architecture): describe the multi-file AbsolutePaths condition
ekalinin Aug 13, 2026
bb5faa2
fix(toc): render remote Markdown links against the source URL
ekalinin Aug 13, 2026
50624c8
fix(toc): prefix links with the document path for multiple inputs
ekalinin Aug 13, 2026
4822cd7
feat(cli): accept - as the STDIN marker
ekalinin Aug 13, 2026
6e52544
feat(github): read the token from token.txt next to the binary
ekalinin Aug 13, 2026
9f07ac5
feat(cli): report os, arch and Go version in --version
ekalinin Aug 13, 2026
a29e325
fix(readme): make --version sample consistent with linux.amd64 tarball
ekalinin Aug 13, 2026
e00a3eb
feat(insert): add TOC markers and the block replacement rule
ekalinin Aug 13, 2026
eb8f944
feat(adapters): add file reader, atomic write, backup, stamp and notify
ekalinin Aug 13, 2026
b4bdfba
fix(adapters): cover WriteAtomic temp cleanup, refuse backup overwrite
ekalinin Aug 14, 2026
428b6ef
fix(adapters): remove partial backup file on write or close failure
ekalinin Aug 14, 2026
2c3d0c5
feat(insert): add the insertmd use case
ekalinin Aug 14, 2026
b5b538e
test(insert): add error-path coverage for insertmd
ekalinin Aug 14, 2026
5e3ca98
feat(insert): add --insert and --no-backup
ekalinin Aug 14, 2026
c381339
feat(skip-header): add the skipheader use case
ekalinin Aug 14, 2026
85dc113
test(skip-header): add cleanup mechanism tests
ekalinin Aug 14, 2026
188069b
feat(skip-header): add the --skip-header flag
ekalinin Aug 14, 2026
8bb5610
docs(skip-header): correct the README rationale for --skip-header
ekalinin Aug 14, 2026
b247cdf
fix(skip-header): write the debug dump next to the document
ekalinin Aug 15, 2026
55a6db3
fix(github): explain rate limiting in the 403 and 429 errors
ekalinin Aug 14, 2026
db7cdf2
fix(github): check response body for actual rate limit errors
ekalinin Aug 14, 2026
86946ff
Merge remote-tracking branch 'origin/master' into fix/restack-76-80
ekalinin Aug 15, 2026
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
181 changes: 158 additions & 23 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,14 @@ CLI behaviour, not about the output format.
- Building from source now requires Go 1.26 or newer. `go.mod` declares `go 1.26`, and CI
no longer tests against 1.21.x, so the toolchain used for tests, builds and releases is
the same one that ships the binaries.
([#58](https://github.com/ekalinin/github-markdown-toc.go/pull/58))
([#58](https://github.com/ekalinin/github-markdown-toc.go/pull/58),
[#84](https://github.com/ekalinin/github-markdown-toc.go/pull/84))

### Fixed

- `--debug` writes its HTML dump next to the document you named. With `--skip-header`
the dump used to be named after an internal temporary copy and was left behind in
the temp directory.
- `GH_TOC_URL` is honoured again when `--github-url` is not passed. The flag's non-empty
default used to shadow the environment variable, so the variable had no effect.
([#60](https://github.com/ekalinin/github-markdown-toc.go/pull/60))
Expand Down
18 changes: 16 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ CMD_SRC=cmd/${EXEC}/main.go
BUILD_DIR=build
E2E_DIR=e2e-tests
E2E_RUN=go run ./cmd/${EXEC} ./README.md
E2E_RUN_RHTML=go run ./cmd/${EXEC} https://github.com/ekalinin/github-markdown-toc.go/blob/master/README.md
E2E_RUN_RMD=go run ./cmd/${EXEC} https://raw.githubusercontent.com/ekalinin/github-markdown-toc.go/master/README.md
# The remote e2e sections read README.md from GitHub. `master` is right once a branch
# is merged; to run them on an unmerged branch, push it and pass its commit:
# make e2e E2E_REF=$(shell git rev-parse HEAD)
E2E_REF?=master
E2E_RUN_RHTML=go run ./cmd/${EXEC} https://github.com/ekalinin/github-markdown-toc.go/blob/${E2E_REF}/README.md
E2E_RUN_RMD=go run ./cmd/${EXEC} https://raw.githubusercontent.com/ekalinin/github-markdown-toc.go/${E2E_REF}/README.md
VERSION=$(shell grep "\tVersion" internal/version/version.go | grep -o -E '[0-9]\.[0-9]\.[0-9]{1,2}')
TAG=v${VERSION}
bold := $(shell tput bold)
Expand Down Expand Up @@ -58,6 +62,16 @@ e2e:
${E2E_RUN_RHTML} --hide-header --hide-footer --indent=4 > ${E2E_DIR}/got9.md
@diff ${E2E_DIR}/want3.md ${E2E_DIR}/got9.md

@echo "${bold}>> 4. Multiple files, links carry the document path ...${clear}"
go run ./cmd/${EXEC} --hide-footer ./README.md ./CHANGELOG.md > ${E2E_DIR}/got-combo.md
@grep -qF '](./README.md#' ${E2E_DIR}/got-combo.md
@grep -qF '](./CHANGELOG.md#' ${E2E_DIR}/got-combo.md

@echo "${bold}>> 5. Insert into a local file ...${clear}"
@cp ${E2E_DIR}/insert-src.md ${E2E_DIR}/got-insert.md
go run ./cmd/${EXEC} --insert --no-backup --hide-footer ${E2E_DIR}/got-insert.md > /dev/null
@diff ${E2E_DIR}/want-insert.md ${E2E_DIR}/got-insert.md

# Step 2: create the release tag locally. Does not push anything.
release: test release-local
@if git rev-parse -q --verify refs/tags/${TAG} >/dev/null; then \
Expand Down
68 changes: 67 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ Table of Contents
* [Remote files](#remote-files)
* [Multiple files](#multiple-files)
* [Combo](#combo)
* [Insert into a file](#insert-into-a-file)
* [Skip header](#skip-header)
* [Starting Depth](#starting-depth)
* [Depth](#depth)
* [No Escape](#no-escape)
Expand Down Expand Up @@ -67,9 +69,15 @@ $ wget https://github.com/ekalinin/github-markdown-toc.go/releases/download/1.1.
$ tar xzvf gh-md-toc.linux.amd64.tgz
gh-md-toc
$ ./gh-md-toc --version
1.1.0
2.0.1

os: linux
arch: amd64
go: go1.26.5
```

The first line is the bare version number, so scripts that parse `gh-md-toc --version` will continue to work.

Compiling from source
---------------------

Expand All @@ -95,6 +103,9 @@ Flags:
GitHub URL. Default: https://api.github.com
--re-version=2024-03
RegExp version. Default: 2024-03
--insert Insert the TOC into the file, between <!--ts--> and <!--te-->. Local files only
--no-backup Do not keep a backup copy of the file. Requires --insert
--skip-header Ignore everything up to <!--te--> when building the TOC
--version Show application version.

Args:
Expand Down Expand Up @@ -302,6 +313,60 @@ You can easily combine both ways:
Created by [gh-md-toc](https://github.com/ekalinin/github-markdown-toc)
```

Insert into a file
------------------

`gh-md-toc` can write the TOC directly into a document instead of only printing it.
Add a marker line containing `<!--ts-->` where the TOC should start, and below it a
marker line containing `<!--te-->` where it should end - each marker needs its own
line, with nothing else on it besides surrounding whitespace. Then run:

```bash
$ ./gh-md-toc --insert README.md
```

Everything between the two markers is replaced with the generated TOC; the markers
themselves and the rest of the document are left untouched. The `Table of Contents`
heading is not written into the file, only the list itself.

`--insert` only works on local files. A remote URL passed alongside `--insert` is
reported as not local and left unmodified, instead of failing the whole run.

Before rewriting the file, a backup copy is kept next to it, named
`<file>.orig.<timestamp>`. Pass `--no-backup` to skip the backup; that flag requires
`--insert` and is rejected on its own.

Unless `--hide-footer` is set, an attribution comment and a signature comment (who
ran the command, and when) are written right after the TOC, inside the markers.
`--hide-footer` suppresses both.

Status messages - the backup path, or a warning about a non-local input - are
printed to stderr, not stdout.

Skip header
-----------

Use `--skip-header` to make `gh-md-toc` ignore everything up to and including the
end marker (`<!--te-->`) when building the TOC. Only the content after that marker
is scanned for headings.

```bash
$ ./gh-md-toc --skip-header README.md
```

The point is to hide the topmost headlines - the document's own title, and any
other heading placed above the marker block, are excluded from the generated TOC.
Without `--skip-header`, a document's title heading gets picked up like any other
heading and shows up as an entry in its own TOC.

This matters when combined with `--insert`: place the markers right below your
title, e.g. `# Project` followed by `<!--ts-->`/`<!--te-->`, and add `--skip-header`
to keep `Project` from appearing as the first entry of the TOC sitting right under
it.

`--skip-header` has no effect on documents that don't contain an end marker; the
whole document is scanned, exactly as without the flag.

Starting Depth
--------------

Expand Down Expand Up @@ -351,6 +416,7 @@ No escape
GitHub token
------------

Without a GitHub token, the `/markdown/raw` endpoint allows very few requests per hour; when the rate limit is exceeded, the tool will suggest passing a token via `--token`, `GH_TOC_TOKEN`, or `token.txt`.
All your tokents are [here](https://github.com/settings/tokens).

Example for cli argument:
Expand Down
58 changes: 55 additions & 3 deletions cmd/gh-md-toc/config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package main

import (
"errors"

"gopkg.in/alecthomas/kingpin.v2"

"github.com/ekalinin/github-markdown-toc.go/v2/internal/app"
Expand All @@ -11,6 +13,7 @@ import (
const (
cliName = "gh-md-toc"
defaultGitHubURL = "https://api.github.com"
stdinMarker = "-"
)

type cliOptions struct {
Expand All @@ -26,11 +29,14 @@ type cliOptions struct {
debug *bool
githubURL *string
reVersion *string
insert *bool
noBackup *bool
skipHeader *bool
}

func newCLI() (*kingpin.Application, cliOptions) {
parser := kingpin.New(cliName, "")
parser.Version(version.Version)
parser.Version(version.Full())

pathsDesc := "Local path or URL of the document to grab TOC. Read MD from stdin if not entered."
options := cliOptions{
Expand All @@ -52,20 +58,62 @@ func newCLI() (*kingpin.Application, cliOptions) {
"re-version",
"RegExp version. Default: "+version.GH_2024_03,
).Default(version.GH_2024_03).Enum(version.SupportedGHVersions()...),
insert: parser.Flag(
"insert",
"Insert the TOC into the file, between <!--ts--> and <!--te-->. Local files only",
).Bool(),
noBackup: parser.Flag(
"no-backup",
"Do not keep a backup copy of the file. Requires --insert",
).Bool(),
skipHeader: parser.Flag(
"skip-header",
"Ignore everything up to <!--te--> when building the TOC",
).Bool(),
}

return parser, options
}

// extractStdinMarker removes the "-" STDIN marker from the argument list. The flag
// parser would otherwise try to read it as a flag.
func extractStdinMarker(args []string) ([]string, bool) {
found := false
rest := make([]string, 0, len(args))
for _, arg := range args {
if arg == stdinMarker {
found = true
continue
}
rest = append(rest, arg)
}
return rest, found
}

func parseConfig(args []string) (app.Config, error) {
args, useStdin := extractStdinMarker(args)

parser, options := newCLI()
if _, err := parser.Parse(args); err != nil {
return app.Config{}, err
}

files := *options.paths
if useStdin {
if len(files) > 0 {
return app.Config{}, errors.New(`the "-" STDIN marker cannot be combined with other paths`)
}
files = nil
}

if *options.noBackup && !*options.insert {
return app.Config{}, errors.New("--no-backup requires --insert")
}

return app.Config{
Files: *options.paths,
Serial: *options.serial,
Files: files,
Serial: *options.serial,
SkipHeader: *options.skipHeader,
Presentation: app.PresentationConfig{
HideHeader: *options.hideHeader,
HideFooter: *options.hideFooter,
Expand All @@ -81,6 +129,10 @@ func parseConfig(args []string) (app.Config, error) {
Escape: !*options.noEscape,
Indent: *options.indent,
},
Insert: app.InsertConfig{
Enabled: *options.insert,
NoBackup: *options.noBackup,
},
Debug: *options.debug,
}, nil
}
40 changes: 40 additions & 0 deletions cmd/gh-md-toc/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,43 @@ func TestCLIHelpShowsCurrentDefaultsWithoutToken(t *testing.T) {
t.Errorf("help contains GitHub token:\n%s", help)
}
}

func TestParseConfigStdinMarker(t *testing.T) {
cfg, err := parseConfig([]string{"-"})
if err != nil {
t.Fatal(err)
}
if len(cfg.Files) != 0 {
t.Errorf("got files %v, want none so STDIN is used", cfg.Files)
}
}

func TestParseConfigStdinMarkerWithPaths(t *testing.T) {
_, err := parseConfig([]string{"-", "README.md"})
if err == nil {
t.Fatal("got no error, want a usage error")
}
if !strings.Contains(err.Error(), "STDIN marker") {
t.Errorf("got error %q, want it to mention the STDIN marker", err)
}
}

func TestParseConfigInsertFlags(t *testing.T) {
cfg, err := parseConfig([]string{"--insert", "--no-backup", "README.md"})
if err != nil {
t.Fatal(err)
}
if !cfg.Insert.Enabled || !cfg.Insert.NoBackup {
t.Errorf("got insert config %+v, want both flags set", cfg.Insert)
}
}

func TestParseConfigNoBackupRequiresInsert(t *testing.T) {
_, err := parseConfig([]string{"--no-backup", "README.md"})
if err == nil {
t.Fatal("got no error, want a usage error")
}
if !strings.Contains(err.Error(), "--no-backup requires --insert") {
t.Errorf("got error %q, want it to explain the dependency", err)
}
}
2 changes: 1 addition & 1 deletion cmd/gh-md-toc/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return 1
}

application, err := app.New(cfg)
application, err := app.New(cfg, stderr)
if err != nil {
_, _ = fmt.Fprintln(stderr, err)
return 1
Expand Down
8 changes: 8 additions & 0 deletions e2e-tests/insert-src.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Title

<!--ts-->
<!--te-->

## Section one

## Section two
11 changes: 11 additions & 0 deletions e2e-tests/want-insert.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Title

<!--ts-->
* [Title](#title)
* [Section one](#section-one)
* [Section two](#section-two)
<!--te-->

## Section one

## Section two
2 changes: 2 additions & 0 deletions e2e-tests/want.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Table of Contents
* [Remote files](#remote-files)
* [Multiple files](#multiple-files)
* [Combo](#combo)
* [Insert into a file](#insert-into-a-file)
* [Skip header](#skip-header)
* [Starting Depth](#starting-depth)
* [Depth](#depth)
* [No escape](#no-escape)
Expand Down
2 changes: 2 additions & 0 deletions e2e-tests/want3.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
* [Remote files](#remote-files)
* [Multiple files](#multiple-files)
* [Combo](#combo)
* [Insert into a file](#insert-into-a-file)
* [Skip header](#skip-header)
* [Starting Depth](#starting-depth)
* [Depth](#depth)
* [No escape](#no-escape)
Expand Down
Loading