Self-hosted service
ekalinin/github-markdown-toc.go avatar
ekalinin/github-markdown-toc.go

gh-md-toc: a table of contents generator with no external dependencies

Easy TOC creation for GitHub README.md (in go)

525 stars89 forksGoMIT

At a glance

What is it?
gh-md-toc is a Go implementation of a table of contents generator for GitHub READMEs, and the reason to reach for the Go version rather than the original shell tool is in a list of four advantages: no dependencies at all, no curl, wget or awk, cross-platform support, and parallel processing of multiple documents. It parses the headings with a regular expression, and it can read from standard input, a local file, a remote URL, or write the table back into the document it came from.
Who is it for?
Adopt gh-md-toc if you generate a table of contents for READMEs in CI and want a single static binary that needs no curl, wget or awk on the machine, or if you want to run the same tool on Windows and macOS. Do not reach for it if you need the tool to work without a network connection for remote documents, since the README is explicit that the tool needs an Internet connection to work properly.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 10 days ago.
What is it written in?
Mainly Go, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 20, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Why the Go rewrite exists

This is a golang based implementation of the github-markdown-toc tool, and the list of advantages is the whole pitch.

No dependencies, with the parenthetical that you need no curl, wget, awk or similar. Cross-platform, supporting Windows and Mac OS among others. Regexp for parsing the table of contents. And parallel processing of multiple documents.

The first of those is the one that matters most in practice. The original tool is a shell script, and shell scripts that fetch things need fetchers, so a CI runner or a minimal container ends up needing curl and wget and a text-processing toolchain installed before the tool can run. A single static binary has no such requirement, which is the difference between a tool you can drop into a distroless image and one you cannot.

The primary language is Go, the licence is MIT, the default branch is master, and the recent releases are v2.0.1 from 2026-04-03, v2.0.0 from 2025-04-13 and v1.4.0 from 2024-03-02. The last push was on 2026-10-12 with the project not archived, and the repository publishes no GitHub releases beyond those tags. The module path carries a major version suffix, github.com/ekalinin/github-markdown-toc.go/v2, so the 2.x line is a distinct module from the 1.x one.

The README also carries a warning that is worth knowing before you reach for it: gh-md-toc is able to work properly only if your machine is connected to the Internet. That is about the remote-document mode, and it is a limitation of the approach rather than a defect, since fetching a document from a URL obviously requires a network.

Installing it, and the version output that scripts depend on

There are four install routes, and the precompiled one is worth reading because of a small detail in its output.

The releases page has a Downloads section, and the example for Linux on amd64 is:

bash
$ wget https://github.com/ekalinin/github-markdown-toc.go/releases/download/v2.0.1/gh-md-toc.linux.amd64.tgz
$ tar xzvf gh-md-toc.linux.amd64.tgz
gh-md-toc
$ ./gh-md-toc --version
2.0.1

And then the tool prints its own environment:

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

The README explains why that matters. The first line is the bare version number, so scripts that parse gh-md-toc --version will continue to work. That is a small compatibility decision with a large practical effect, because the version flag is exactly the kind of thing a CI pipeline or a version-check script will parse, and putting extra diagnostics on later lines rather than the first one keeps those scripts working.

Compiling from source needs Go 1.26 or newer:

bash
$ make build
$ ./gh-md-toc --help
usage: gh-md-toc [<flags>] [<path>...]

And the full help output is the best single view of what the tool can do:

bash
$ ./gh-md-toc --help
usage: gh-md-toc [<flags>] [<path>...]

Flags:
  --help           Show context-sensitive help (also try --help-long and --help-man).
  --serial         Grab TOCs in the serial mode
  --hide-header    Hide TOC header
  --hide-footer    Hide TOC footer
  --start-depth=0  Start including from this level. Defaults to 0 (include all levels)
  --depth=0        How many levels of headings to include. Defaults to 0 (all)
  --no-escape      Do not escape chars in sections
  --token=TOKEN    GitHub personal token
  --indent=2       Indent space of generated list
  --debug          Show debug info
  --github-url="https://api.github.com"
                   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:
  [<path>]  Local path or URL of the document to grab TOC. Read MD from stdin if not entered.

The flags divide into four groups. The output shape is controlled by --serial for serial mode, --hide-header and --hide-footer for the table of contents heading, --start-depth to start at a given heading level and --depth to limit how many levels are included, and --indent for the indentation of the generated list. Escaping is controlled by --no-escape. Then there is the network side with --token, --github-url and --re-version, and the writing side with --insert and --no-backup.

The remaining routes are go install, brew, and Docker. The go install form is:

bash
go install "github.com/ekalinin/github-markdown-toc.go/cmd/gh-md-toc@latest"

The Homebrew formula is:

bash
$ brew install github-markdown-toc

And Docker is:

bash
$ docker run --rm -v "$PWD:/data" -w /data \
    ghcr.io/ekalinin/github-markdown-toc.go:latest README.md

The README then notes that you pass a token with GH_TOC_TOKEN when you hit the GitHub API rate limit, and that the image runs as a non-root user, so --insert against a bind-mounted file fails with a permission error unless you also pass --user with your own uid and gid.

That last note is a real operational detail. Running as a non-root user is the right default for a container, and it breaks the one mode that writes back into a file on the host, and the fix is spelled out rather than left to be discovered.

The four input modes

The usage section is organised by where the document comes from, and the four modes are genuinely different ways of using the tool rather than variations on one.

Standard input is the first. Piping a document in and getting a table of contents back:

bash
➥ cat ~/projects/Dockerfile.vim/README.md | ./gh-md-toc
  * [Dockerfile.vim](#dockerfilevim)
  * [Screenshot](#screenshot)
  * [Installation](#installation)
        * [OR using Pathogen:](#or-using-pathogen)
        * [OR using Vundle:](#or-using-vundle)
  * [License](#license)

So the tool is composable in the ordinary Unix way. A document in, a table of contents out, and the caller decides what to do with it.

Local files come next, and here the output is the full document with the table inserted:

bash
➥ ./gh-md-toc ~/projects/Dockerfile.vim/README.md                                                                                                                                                Вс. марта 22 22:51:46 MSK 2015

Table of Contents
=================

  * [Dockerfile.vim](#dockerfilevim)
  * [Screenshot](#screenshot)
  * [Installation](#installation)
        * [OR using Pathogen:](#or-using-pathogen)
        * [OR using Vundle:](#or-using-vundle)
  * [License](#license)

The notice is that the local mode reads the file, builds the table of contents, and prints the whole document back with the table of contents section included. That is a different contract from the stdin mode, and the distinction is the useful one: stdin gives you the table, local gives you the document with the table in it.

Remote files are third:

bash
➥ ./gh-md-toc https://github.com/ekalinin/envirius/blob/master/README.md

Table of Contents
=================

  * [envirius](#envirius)
    * [Idea](#idea)
    * [Features](#features)
  * [Installation](#installation)
  * [Uninstallation](#uninstallation)
  * [Available plugins](#available-plugins)
  * [Usage](#usage)
    * [Check available plugins](#check-available-plugins)
    * [Check available versions for each plugin](#check-available-versions-for-each-plugin)
    * [Create an environment](#create-an-environment)
    * [Activate/deactivate environment](#activatedeactivate-environment)
      * [Activating in a new shell](#activating-in-a-new-shell)
      * [Activating in the same shell](#activating-in-the-same-shell)

The URL can point at a rendered page on github.com or at a raw file, and both appear in the Makefile's end-to-end targets as separate cases, so the tool handles both the HTML view and the raw markdown.

The fourth is the insert mode, where --insert puts the table of contents into the file between the ts and te comment markers, and it is local files only. The --no-backup flag then suppresses keeping a copy of the original.

That marker-based insertion is the convention that makes the mode idempotent, because the tool can find the same anchors every time it runs, and --skip-header tells it to ignore everything up to the te marker when building the table, so the table it generates does not include the table of contents itself.

The two --depth flags are also different things, and the help text is careful to say so. --start-depth sets the level at which the table begins, defaulting to 0 to include all levels, while --depth sets how many levels are included, also defaulting to 0 for all.

The end-to-end tests, and what they compare

The Makefile is where the testing approach becomes clear, and it is the most interesting part of the repository for a reader deciding whether to trust the output.

There is a target named e2e, and it runs the tool and then diffs the result against a stored expectation:

bash
clean:
	@rm -f ${EXEC}
	@rm -f ${BUILD_DIR}/*
	@go clean

lint:
	@go vet ./...
	@golangci-lint run ./...

# make run ARGS="--help"
run:
	@go run ${CMD_SRC} $(ARGS)

build: clean lint
	go build -race -o ${EXEC} ${CMD_SRC}

test: clean lint
	@go test -cover ./...

So the test is a golden file comparison. The tool is run with a set of options, the output is written to a got file, and that is diffed against a want file in the e2e-tests directory. The same pattern repeats for the local document with no options, then with --hide-header --hide-footer --depth=1 --no-escape, then with --hide-header --hide-footer --indent=4, and then the remote document cases with and without options.

The variable definitions above the targets explain the remote part. The comment says the remote e2e sections read README.md from GitHub, that master is right once a branch is merged, and that to run them on an unmerged branch you should push it and pass its commit, with the example make e2e E2E_REF equal to the output of git rev-parse HEAD. And there is a separate variable for the raw markdown URL, built against the same reference.

That is a careful arrangement, because a test that reads a document from the internet is a test that breaks when someone edits the document. Pinning the reference to a commit rather than a branch name removes that dependency, and the two separate URLs, the rendered blob view and the raw file, cover the two ways a user might point the tool at a repository.

The version extraction is a small piece of the same care. The Makefile greps the version out of internal/version/version.go with a regular expression for a dotted number, sets TAG to v plus that version, and the build target depends on clean and lint, so a build always runs vet and golangci-lint first.

The unit test target is go test with cover, and the coverage target adds count mode, writes a profile, sorts the output and generates an HTML report.

The test output quoted in the README is worth reading for what it says about the current state. It shows coverage at 28.8 percent of statements, and the test passing. So the end-to-end golden file tests are the real test suite, and the unit coverage is low because the logic lives in the parsing and the golden files cover it.

The packaging, and the two flags that would be easy to miss

The module and container setup are minimal and worth reading because they show the shape of the project.

The go.mod names the module github.com/ekalinin/github-markdown-toc.go/v2 and requires Go 1.26. There is exactly one direct dependency:

go
module github.com/ekalinin/github-markdown-toc.go/v2

go 1.26

require gopkg.in/alecthomas/kingpin.v2 v2.2.6

require (
	github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
	github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect
	github.com/stretchr/testify v1.8.2 // indirect
)

gopkg.in/alecthomas/kingpin.v2 at 2.2.6, which is the command line parser. The help output in the README, with its --help-long and --help-man suggestions, is kingpin's own style, and the indirect dependencies are kingpin's own plus testify. So the claim of no dependencies means no external system tools, and the binary does still use a library for argument parsing.

The Dockerfile is four lines:

dockerfile
FROM gcr.io/distroless/static:nonroot

COPY gh-md-toc /gh-md-toc

ENTRYPOINT ["/gh-md-toc"]

A distroless static image with the nonroot tag, the binary copied in, and that binary as the entrypoint. There is no shell, no package manager and no libc, which is the direct payoff of the no-dependencies design, and it is why the --user flag is needed for the insert mode.

Two flags in the help output deserve a second look because they are easy to skim past.

The --re-version flag takes a date-shaped value, 2024-03 by default, and it is described as the RegExp version. That is a compatibility switch for a change in how the headings are recognised, and the fact that it is dated rather than numbered suggests the regex was tied to a particular rendering of GitHub markdown at a point in time, and that GitHub's rendering changes occasionally. A tool that parses GitHub's heading syntax is parsing something that can move under it, and the version flag is the author's way of not having that break silently.

The --github-url flag, defaulting to https://api.github.com, is there for GitHub Enterprise Server. A private instance has a different API host, and a tool that hard-codes the public one is a tool that cannot be used inside a company network.

So the flags fall into three groups that map onto the three ways the tool is used: the shape of the output, the depth and escaping of the parsing, and the network and file side. The --debug flag prints diagnostic information, which is what you would want when the table of contents comes out empty and you need to know whether the headings were not found or the escaping broke the anchors.

Editorial conclusion

Adopt gh-md-toc if you generate a table of contents for READMEs in CI and want a single static binary that needs no curl, wget or awk on the machine, or if you want to run the same tool on Windows and macOS. Do not reach for it if you need the tool to work without a network connection for remote documents, since the README is explicit that the tool needs an Internet connection to work properly. Before you commit, check the --re-version flag, which defaults to 2024-03 and exists because the heading syntax the tool parses can change, and note that the Docker image runs as a non-root user, so the insert mode against a bind-mounted file needs a --user override or it fails with a permission error.

Frequently asked questions

What is gh-md-toc?

It is a golang based implementation of the github-markdown-toc tool, a table of contents generator for GitHub README files. The stated advantages are no dependencies, so no need for curl, wget or awk, cross-platform support including Windows and Mac OS, regexp for parsing the table of contents, and parallel processing of multiple documents.

How do I install gh-md-toc?

Download a precompiled binary from the releases page, or compile from source with make build, which needs Go 1.26 or newer, or run go install github.com/ekalinin/github-markdown-toc.go/cmd/gh-md-toc@latest, or brew install github-markdown-toc, or use the Docker image, which runs as a non-root user.

What input can gh-md-toc read?

Standard input, a local file, or a remote URL, with a fourth mode that inserts the table into the file it came from. The insert mode works only on local files and places the table between the ts and te comment markers, with --skip-header ignoring everything up to that marker so the generated table does not list itself.

What is the difference between --start-depth and --depth?

The --start-depth flag sets the heading level at which the table begins and defaults to 0 to include all levels, while --depth sets how many levels of headings are included and also defaults to 0 for all. There is also --indent for the indentation of the generated list and --no-escape to leave characters unescaped in the section links.

Why does the Docker image need a --user flag for --insert?

Because the image runs as a non-root user, so writing back into a bind-mounted file fails with a permission error. The README says to pass --user with your own uid and gid to work around it. The same image is a distroless static build with no shell, which is the direct benefit of the tool having no external dependencies.

Official sources

  1. ekalinin/github-markdown-toc.go on GitHub
  2. Issues
  3. License: MIT
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/ekalinin-github-markdown-toc-go.svg)](https://hysenlabs.com/projects/ekalinin-github-markdown-toc-go)

Was this useful?