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
14 changes: 14 additions & 0 deletions .github/workflows/goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Checkout
uses: actions/checkout@v7
Expand All @@ -29,6 +30,19 @@ jobs:
exit 1
fi

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
with:
Expand Down
34 changes: 34 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,40 @@ builds:
- amd64
- arm64
- arm
dockers:
- image_templates:
- "ghcr.io/ekalinin/github-markdown-toc.go:{{ .Version }}-amd64"
- "ghcr.io/ekalinin/github-markdown-toc.go:latest-amd64"
dockerfile: Dockerfile
use: buildx
goos: linux
goarch: amd64
build_flag_templates:
- "--platform=linux/amd64"
- "--label=org.opencontainers.image.source=https://github.com/ekalinin/github-markdown-toc.go"
- "--label=org.opencontainers.image.version={{ .Version }}"
- image_templates:
- "ghcr.io/ekalinin/github-markdown-toc.go:{{ .Version }}-arm64"
- "ghcr.io/ekalinin/github-markdown-toc.go:latest-arm64"
dockerfile: Dockerfile
use: buildx
goos: linux
goarch: arm64
build_flag_templates:
- "--platform=linux/arm64"
- "--label=org.opencontainers.image.source=https://github.com/ekalinin/github-markdown-toc.go"
- "--label=org.opencontainers.image.version={{ .Version }}"

docker_manifests:
- name_template: "ghcr.io/ekalinin/github-markdown-toc.go:{{ .Version }}"
image_templates:
- "ghcr.io/ekalinin/github-markdown-toc.go:{{ .Version }}-amd64"
- "ghcr.io/ekalinin/github-markdown-toc.go:{{ .Version }}-arm64"
- name_template: "ghcr.io/ekalinin/github-markdown-toc.go:latest"
image_templates:
- "ghcr.io/ekalinin/github-markdown-toc.go:latest-amd64"
- "ghcr.io/ekalinin/github-markdown-toc.go:latest-arm64"

checksum:
name_template: 'checksums.txt'
snapshot:
Expand Down
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ app.Config
└── NoBackup bool
```

`cmd/gh-md-toc` maps flags and environment variables into this structure. `app.New` derives `TOC.AbsolutePaths` from whether the CLI received multiple file arguments, matching bash `gh-md-toc`, which drops the prefix when a single document is requested.
`cmd/gh-md-toc` maps flags and environment variables into this structure. `app.New` derives `TOC.AbsolutePaths` from whether the CLI received multiple file arguments, matching bash `gh-md-toc`, which drops the prefix when a single document is requested. `InsertMd` overrides this per document rather than per run: it asks its inner use case for a TOC rendered against an empty display path, so a TOC written into a document links to itself with bare anchors, since GitHub resolves relative links against that document's own directory. Documents that are not inserted into, such as a remote URL passed in the same run, keep their prefix.

`SkipHeader` selects whether `app.New` wraps `LocalMd` in `SkipHeader` before
assigning the result to `localChain`; it takes no other parameters, since the
Expand Down
28 changes: 26 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

Planned release: 2.1.0.

The generated table of contents is byte-identical to 2.0.1. Everything below is about
CLI behaviour, not about the output format.
### Added

- `--insert` writes the generated TOC directly into a document, replacing everything
between a `<!--ts-->` and `<!--te-->` marker pair. A backup copy is kept next to the
file unless `--no-backup` is also passed.
- `--skip-header` ignores everything up to and including `<!--te-->` when building the
TOC, so a document's own title heading is not picked up as an entry.
- `-` is now accepted as an explicit marker for reading Markdown from STDIN.
- `token.txt`, read from next to the executable, is now the last fallback for a GitHub
token, after `--token` and `GH_TOC_TOKEN`.
- A Docker image is published to `ghcr.io/ekalinin/github-markdown-toc.go`.

### Security

Expand Down Expand Up @@ -52,6 +61,17 @@ CLI behaviour, not about the output format.
the same one that ships the binaries.
([#58](https://github.com/ekalinin/github-markdown-toc.go/pull/58),
[#84](https://github.com/ekalinin/github-markdown-toc.go/pull/84))
- Multi-document runs now prefix links with the document path, which is what the
"Multiple files" and "Combo" sections of the README always documented but the tool
never actually did. A TOC written into a file with `--insert` is the exception: it
always uses bare anchors, because GitHub resolves relative links against the
document's own directory. Other documents in the same run, such as a remote URL,
keep their prefix.
- `--version` now also reports the OS, architecture and Go version used to build the
binary. The bare version number stays on the first line, so scripts that parse it
keep working.
- `--hide-footer` gains a second meaning under `--insert`: it also suppresses the
signature comment written into the file, not just the printed footer.

### Fixed

Expand Down Expand Up @@ -81,6 +101,10 @@ CLI behaviour, not about the output format.
- `gopkg.in/alecthomas/kingpin.v2` updated from v2.2.4 to v2.2.6, and the indirect module
graph was tidied. The CLI surface is unchanged.
([#68](https://github.com/ekalinin/github-markdown-toc.go/pull/68))
- GitHub rate-limit responses now explain that a token raises the limit, instead of
surfacing a bare HTTP status.
- Remote Markdown documents now render links against their source URL instead of the
path of the temporary file they were downloaded to.

## [2.0.1] - 2026-04-03

Expand Down
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
FROM gcr.io/distroless/static:nonroot

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

ENTRYPOINT ["/gh-md-toc"]
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ release-local:
@goreleaser check
@goreleaser release --snapshot --clean

# Same as release-local, but without the container images. Useful when no Docker
# daemon is available.
release-local-nodocker:
@goreleaser check
@goreleaser release --snapshot --clean --skip=docker

# Step 3: publish the tag, which triggers the goreleaser workflow.
release-push:
@git push origin ${TAG}
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Table of Contents
* [Compiling from source](#compiling-from-source)
* [Go Install](#go-install)
* [Homebew (Mac only)](#homebew-mac-only)
* [Docker](#docker)
* [Tests](#tests)
* [Usage](#usage)
* [STDIN](#stdin)
Expand Down Expand Up @@ -65,7 +66,7 @@ See the releases page, "Downloads" section:
For example:

```bash
$ wget https://github.com/ekalinin/github-markdown-toc.go/releases/download/1.1.0/gh-md-toc.linux.amd64.tgz
$ 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
Expand Down Expand Up @@ -129,6 +130,19 @@ Homebew (Mac only)
$ brew install github-markdown-toc
```

Docker
------

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

Pass a token with `-e GH_TOC_TOKEN=...` when you hit the GitHub API rate limit.

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 "$(id -u):$(id -g)"`.

Tests
=====

Expand Down Expand Up @@ -332,6 +346,10 @@ 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.

An inserted TOC always links with bare anchors (`#section`), even when several files
are passed at once. GitHub resolves relative links against the document's own
directory, so prefixing the links with the document's path would break them.

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.
Expand Down Expand Up @@ -419,6 +437,11 @@ 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).

The token is resolved in this order: the `--token` flag, then the `GH_TOC_TOKEN`
environment variable, then a `token.txt` file placed next to the executable. The file
is the last fallback and is only used when neither the flag nor the environment
variable is set.

Example for cli argument:

```bash
Expand Down
4 changes: 4 additions & 0 deletions cmd/gh-md-toc/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ func parseConfig(args []string) (app.Config, error) {
files = nil
}

if *options.insert && len(files) == 0 {
return app.Config{}, errors.New("--insert requires at least one file path")
}

if *options.noBackup && !*options.insert {
return app.Config{}, errors.New("--no-backup requires --insert")
}
Expand Down
20 changes: 20 additions & 0 deletions cmd/gh-md-toc/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,23 @@ func TestParseConfigNoBackupRequiresInsert(t *testing.T) {
t.Errorf("got error %q, want it to explain the dependency", err)
}
}

func TestParseConfigInsertRequiresFilePath(t *testing.T) {
_, err := parseConfig([]string{"--insert"})
if err == nil {
t.Fatal("got no error, want a usage error")
}
if !strings.Contains(err.Error(), "--insert requires at least one file path") {
t.Errorf("got error %q, want it to explain the dependency", err)
}
}

func TestParseConfigInsertRejectsStdinMarker(t *testing.T) {
_, err := parseConfig([]string{"--insert", "-"})
if err == nil {
t.Fatal("got no error, want a usage error")
}
if !strings.Contains(err.Error(), "--insert requires at least one file path") {
t.Errorf("got error %q, want it to explain the dependency", err)
}
}
1 change: 1 addition & 0 deletions e2e-tests/want.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Table of Contents
* [Compiling from source](#compiling-from-source)
* [Go Install](#go-install)
* [Homebew (Mac only)](#homebew-mac-only)
* [Docker](#docker)
* [Tests](#tests)
* [Usage](#usage)
* [STDIN](#stdin)
Expand Down
1 change: 1 addition & 0 deletions e2e-tests/want3.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* [Compiling from source](#compiling-from-source)
* [Go Install](#go-install)
* [Homebew (Mac only)](#homebew-mac-only)
* [Docker](#docker)
* [Tests](#tests)
* [Usage](#usage)
* [STDIN](#stdin)
Expand Down
11 changes: 11 additions & 0 deletions internal/adapters/filebackup.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
)

Expand Down Expand Up @@ -33,6 +34,13 @@ func (b *FileBackupper) Backup(ctx context.Context, file string) (string, error)
return "", err
}

// Resolve a symlink to its target, so the backup lands next to the real document
// rather than next to the link. A path that cannot be resolved (e.g. it does not
// exist) is handled below exactly as before.
if resolved, resolveErr := filepath.EvalSymlinks(file); resolveErr == nil {
file = resolved
}

info, err := os.Stat(file)
if err != nil {
return "", err
Expand All @@ -45,6 +53,9 @@ func (b *FileBackupper) Backup(ctx context.Context, file string) (string, error)
backup := fmt.Sprintf("%s.orig.%s", file, b.now().Format(backupTimeLayout))
dst, err := os.OpenFile(backup, os.O_WRONLY|os.O_CREATE|os.O_EXCL, info.Mode().Perm())
if err != nil {
if os.IsExist(err) {
return "", fmt.Errorf("backup %q already exists, refusing to overwrite it: %w", backup, err)
}
return "", err
}
_, writeErr := dst.Write(data)
Expand Down
59 changes: 57 additions & 2 deletions internal/adapters/filebackup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,20 @@ import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
)

func TestFileBackupperBackup(t *testing.T) {
dir := t.TempDir()
// Resolve the temp dir itself first: on macOS it lives under a symlink
// (/tmp -> /private/tmp), which would otherwise make "want" below diverge from
// the resolved path Backup now returns, for reasons unrelated to what this test
// is checking.
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
file := filepath.Join(dir, "README.md")
if err := os.WriteFile(file, []byte("original\n"), 0640); err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -41,6 +49,49 @@ func TestFileBackupperBackup(t *testing.T) {
}
}

func TestFileBackupperBackupFollowsSymlinks(t *testing.T) {
// Resolve the temp dir itself first: on macOS it lives under a symlink
// (/tmp -> /private/tmp), which would otherwise make "want" below diverge from
// the resolved path for reasons unrelated to what this test is checking.
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
real := filepath.Join(dir, "real.md")
if err := os.WriteFile(real, []byte("original\n"), 0644); err != nil {
t.Fatal(err)
}
link := filepath.Join(dir, "link.md")
if err := os.Symlink(real, link); err != nil {
t.Fatal(err)
}
stamp := time.Date(2026, 8, 12, 13, 45, 6, 0, time.UTC)

got, err := NewFileBackupperX(func() time.Time { return stamp }).Backup(context.Background(), link)
if err != nil {
t.Fatal(err)
}

want := real + ".orig.2026-08-12_134506"
if got != want {
t.Errorf("got backup path %q, want %q next to the real file", got, want)
}
linkInfo, err := os.Lstat(link)
if err != nil {
t.Fatal(err)
}
if linkInfo.Mode()&os.ModeSymlink == 0 {
t.Errorf("got %q replaced with a regular file, want the symlink kept", link)
}
data, err := os.ReadFile(got)
if err != nil {
t.Fatal(err)
}
if string(data) != "original\n" {
t.Errorf("got backup contents %q, want %q", data, "original\n")
}
}

func TestFileBackupperMissingFile(t *testing.T) {
dir := t.TempDir()

Expand All @@ -66,9 +117,13 @@ func TestFileBackupperRefusesToOverwriteExistingBackup(t *testing.T) {
t.Fatal(err)
}

if _, err := backupper.Backup(context.Background(), file); err == nil {
_, err = backupper.Backup(context.Background(), file)
if err == nil {
t.Fatal("got no error, want a refusal to overwrite the existing backup")
}
if !strings.Contains(err.Error(), "already exists") {
t.Errorf("got error %q, want it to explain that the backup already exists", err)
}

data, err := os.ReadFile(first)
if err != nil {
Expand Down
8 changes: 8 additions & 0 deletions internal/adapters/filewriter.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ func (f *FileWriter) WriteAtomic(ctx context.Context, file string, data []byte)
return err
}

// Resolve a symlink to its target, so the rename below replaces the target
// document instead of dropping a regular file in place of the link. A path that
// cannot be resolved (e.g. it does not exist yet) is handled below exactly as
// before.
if resolved, resolveErr := filepath.EvalSymlinks(file); resolveErr == nil {
file = resolved
}

perm := os.FileMode(0644)
if info, statErr := os.Stat(file); statErr == nil {
perm = info.Mode().Perm()
Expand Down
Loading