From 4898780c1d97b16eb435f1b13e836c67e1c9ff31 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Fri, 14 Aug 2026 18:33:30 +0300 Subject: [PATCH 1/9] chore(release): publish a Docker image to ghcr.io --- .github/workflows/goreleaser.yml | 14 +++++++++++++ .goreleaser.yaml | 34 ++++++++++++++++++++++++++++++++ Dockerfile | 5 +++++ README.md | 11 +++++++++++ 4 files changed, 64 insertions(+) create mode 100644 Dockerfile diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index af49a59..158ffc5 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -10,6 +10,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + packages: write steps: - name: Checkout uses: actions/checkout@v7 @@ -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: diff --git a/.goreleaser.yaml b/.goreleaser.yaml index bac8b95..5bba65f 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -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: diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5f393d0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM gcr.io/distroless/static:nonroot + +COPY gh-md-toc /gh-md-toc + +ENTRYPOINT ["/gh-md-toc"] diff --git a/README.md b/README.md index 6a74655..08adc87 100644 --- a/README.md +++ b/README.md @@ -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) @@ -129,6 +130,16 @@ 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. + Tests ===== From a27e3528ab1d366f334b8281eb2befda46ca0419 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Fri, 14 Aug 2026 21:45:37 +0300 Subject: [PATCH 2/9] docs(changelog): record the bash parity work under Unreleased --- CHANGELOG.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9338cf7..914fd01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` and `` 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 `` 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 @@ -52,6 +61,14 @@ 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. +- `--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 @@ -81,6 +98,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 From e745d5d0e9fe29965a7eea418e8b3b1a24a67fcd Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Fri, 14 Aug 2026 21:45:54 +0300 Subject: [PATCH 3/9] fix(insert): reject --insert without a file path --- cmd/gh-md-toc/config.go | 4 ++++ cmd/gh-md-toc/config_test.go | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/cmd/gh-md-toc/config.go b/cmd/gh-md-toc/config.go index e2bfdc6..615949e 100644 --- a/cmd/gh-md-toc/config.go +++ b/cmd/gh-md-toc/config.go @@ -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") } diff --git a/cmd/gh-md-toc/config_test.go b/cmd/gh-md-toc/config_test.go index 59c6520..cbb5337 100644 --- a/cmd/gh-md-toc/config_test.go +++ b/cmd/gh-md-toc/config_test.go @@ -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) + } +} From 2cfa4ada3ed5613d75b5fbdb51629b3e4090b5ea Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Fri, 14 Aug 2026 21:55:57 +0300 Subject: [PATCH 4/9] fix(insert): write bare anchors into the document, not path prefixes --- README.md | 4 ++++ internal/app/new.go | 4 +++- internal/app/new_test.go | 45 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 08adc87..6a168f3 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,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 `.orig.`. Pass `--no-backup` to skip the backup; that flag requires `--insert` and is rejected on its own. diff --git a/internal/app/new.go b/internal/app/new.go index d679d4e..a042c40 100644 --- a/internal/app/new.go +++ b/internal/app/new.go @@ -82,7 +82,9 @@ func New(cfg Config, stderr io.Writer) (*App, error) { jsonExtractor := adapters.NewJSONExtractor() rendererCfg := cfg.TOC // bash gh-md-toc drops the path prefix only when a single document is requested. - rendererCfg.AbsolutePaths = len(cfg.Files) > 1 + // A TOC written into a document links to itself with bare anchors; only the + // stdout listing of several documents needs a path prefix. + rendererCfg.AbsolutePaths = len(cfg.Files) > 1 && !cfg.Insert.Enabled renderer := coretoc.NewRenderer(rendererCfg) grabberRe := coretoc.NewGenerator(regexpExtractor, renderer) grabberJSON := coretoc.NewGenerator(jsonExtractor, renderer) diff --git a/internal/app/new_test.go b/internal/app/new_test.go index c1b6f9e..afbbd8f 100644 --- a/internal/app/new_test.go +++ b/internal/app/new_test.go @@ -99,6 +99,51 @@ func TestNewSkipHeaderTrimsTheDocumentSentToGitHub(t *testing.T) { } } +func TestNewInsertMultipleFilesUsesBareAnchors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(`

Section

` + + ``)) + })) + defer server.Close() + + dir := t.TempDir() + fileA := filepath.Join(dir, "a.md") + fileB := filepath.Join(dir, "b.md") + content := "# Title\n\n\n\n## Section\n" + for _, file := range []string{fileA, fileB} { + if err := os.WriteFile(file, []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + + application, err := New(Config{ + Files: []string{fileA, fileB}, + GitHub: GitHubConfig{GHUrl: server.URL, GHVersion: version.GH_2024_03}, + TOC: coretoc.DefaultConfig(), + Insert: InsertConfig{Enabled: true, NoBackup: true}, + Presentation: PresentationConfig{HideFooter: true}, + }, io.Discard) + if err != nil { + t.Fatal(err) + } + if err := application.Run(context.Background(), &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(fileA) + if err != nil { + t.Fatal(err) + } + gotStr := string(got) + if !strings.Contains(gotStr, "](#") { + t.Errorf("got file content %q, want a bare anchor link into the document", gotStr) + } + if strings.Contains(gotStr, fileA) { + t.Errorf("got file content %q, want no reference to the document's own path", gotStr) + } +} + func TestNewWithoutSkipHeaderSendsTheWholeDocument(t *testing.T) { var gotBody string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 7499f1d8c6216ab9ff92c1c12809ef3a60db20c8 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Fri, 14 Aug 2026 21:57:48 +0300 Subject: [PATCH 5/9] fix(insert): follow symlinks instead of replacing them --- internal/adapters/filebackup.go | 8 +++++ internal/adapters/filebackup_test.go | 52 +++++++++++++++++++++++++++- internal/adapters/filewriter.go | 8 +++++ internal/adapters/filewriter_test.go | 31 +++++++++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) diff --git a/internal/adapters/filebackup.go b/internal/adapters/filebackup.go index b878a10..cb270a4 100644 --- a/internal/adapters/filebackup.go +++ b/internal/adapters/filebackup.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "time" ) @@ -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 diff --git a/internal/adapters/filebackup_test.go b/internal/adapters/filebackup_test.go index fc50ab3..59ad300 100644 --- a/internal/adapters/filebackup_test.go +++ b/internal/adapters/filebackup_test.go @@ -9,7 +9,14 @@ import ( ) 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) @@ -41,6 +48,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() diff --git a/internal/adapters/filewriter.go b/internal/adapters/filewriter.go index dc20587..334bdbd 100644 --- a/internal/adapters/filewriter.go +++ b/internal/adapters/filewriter.go @@ -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() diff --git a/internal/adapters/filewriter_test.go b/internal/adapters/filewriter_test.go index df038e3..b35cafa 100644 --- a/internal/adapters/filewriter_test.go +++ b/internal/adapters/filewriter_test.go @@ -92,6 +92,37 @@ func TestFileWriterWriteAtomicFailsWithoutDirectory(t *testing.T) { } } +func TestFileWriterWriteAtomicFollowsSymlinks(t *testing.T) { + dir := t.TempDir() + real := filepath.Join(dir, "real.md") + if err := os.WriteFile(real, []byte("old\n"), 0644); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "link.md") + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + + if err := NewFileWriter().WriteAtomic(context.Background(), link, []byte("new\n")); err != nil { + t.Fatal(err) + } + + info, err := os.Lstat(link) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Errorf("got %q replaced with a regular file, want the symlink kept", link) + } + data, err := os.ReadFile(real) + if err != nil { + t.Fatal(err) + } + if string(data) != "new\n" { + t.Errorf("got real file content %q, want %q", data, "new\n") + } +} + func TestFileWriterWriteAtomicRemovesTempFileOnFailure(t *testing.T) { dir := t.TempDir() // Renaming onto a directory fails, and by then the temp file exists. From cb8909318671b496edf1bd514058f76f42f3d942 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Fri, 14 Aug 2026 22:03:54 +0300 Subject: [PATCH 6/9] chore(release): add a Docker-free snapshot target --- Makefile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Makefile b/Makefile index 63ab20f..40b0e35 100644 --- a/Makefile +++ b/Makefile @@ -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} From 8ba039409e87c5d692a6ccffcf45ed020a35a6a1 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Fri, 14 Aug 2026 22:06:52 +0300 Subject: [PATCH 7/9] fix(cli): correct token logging, backup error text and docs --- README.md | 10 +++++++++- internal/adapters/filebackup.go | 3 +++ internal/adapters/filebackup_test.go | 7 ++++++- internal/adapters/notifier.go | 3 +++ internal/app/new.go | 17 ++++++++++------- 5 files changed, 31 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 6a168f3..a98fb19 100644 --- a/README.md +++ b/README.md @@ -66,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 @@ -140,6 +140,9 @@ $ docker run --rm -v "$PWD:/data" -w /data \ 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 ===== @@ -434,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 diff --git a/internal/adapters/filebackup.go b/internal/adapters/filebackup.go index cb270a4..919630c 100644 --- a/internal/adapters/filebackup.go +++ b/internal/adapters/filebackup.go @@ -53,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) diff --git a/internal/adapters/filebackup_test.go b/internal/adapters/filebackup_test.go index 59ad300..6fdbd43 100644 --- a/internal/adapters/filebackup_test.go +++ b/internal/adapters/filebackup_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "time" ) @@ -116,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 { diff --git a/internal/adapters/notifier.go b/internal/adapters/notifier.go index d6c145f..7c2b35d 100644 --- a/internal/adapters/notifier.go +++ b/internal/adapters/notifier.go @@ -15,6 +15,9 @@ func NewNotifier(w io.Writer) *Notifier { return &Notifier{w: w} } +// Notify is called from up to eight worker goroutines with no synchronization of its +// own, so w must be safe for concurrent use. An *os.File satisfies this: its Write is +// internally locked. func (n *Notifier) Notify(format string, args ...any) { if n.w == nil { return diff --git a/internal/app/new.go b/internal/app/new.go index a042c40..810d140 100644 --- a/internal/app/new.go +++ b/internal/app/new.go @@ -45,6 +45,16 @@ func New(cfg Config, stderr io.Writer) (*App, error) { log := adapters.NewLogger(cfg.Debug) notify := adapters.NewNotifier(stderr) + // Resolve the token.txt fallback before logging, so "token-configured" reflects + // the token that will actually be used, not just what --token/GH_TOC_TOKEN set. + if cfg.GitHub.GHToken == "" { + token, err := adapters.NewTokenResolver().Resolve() + if err != nil { + return nil, fmt.Errorf("read token file: %w", err) + } + cfg.GitHub.GHToken = token + } + log.Info( "App.New: init configs ...", "file-count", len(cfg.Files), @@ -64,13 +74,6 @@ func New(cfg Config, stderr io.Writer) (*App, error) { ctlCfg := controller.Config{Files: cfg.Files, Serial: cfg.Serial} log.Info("App.New: init adapters ...") - if cfg.GitHub.GHToken == "" { - token, err := adapters.NewTokenResolver().Resolve() - if err != nil { - return nil, fmt.Errorf("read token file: %w", err) - } - cfg.GitHub.GHToken = token - } httpClient := adapters.NewHTTPClient() checker := adapters.NewFileCheck(log) writer := adapters.NewFileWriter() From 9259fd7f306653bff4975d86b12122d752309bd4 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Fri, 14 Aug 2026 22:48:11 +0300 Subject: [PATCH 8/9] docs(insert): note the --insert exception to path-prefixed links --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 185d240..ef0307b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. `--insert` suppresses the prefix as well: a TOC written into a document links to itself with bare anchors, since GitHub resolves relative links against that document's own directory. `SkipHeader` selects whether `app.New` wraps `LocalMd` in `SkipHeader` before assigning the result to `localChain`; it takes no other parameters, since the diff --git a/CHANGELOG.md b/CHANGELOG.md index 914fd01..1c9c3f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,7 +63,9 @@ Planned release: 2.1.0. [#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. + 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. - `--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. From 37e7a8f0aeb6c46175ea7f275f9401b179d1fba1 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Sat, 15 Aug 2026 13:32:03 +0300 Subject: [PATCH 9/9] fix(insert): keep the path prefix for documents that are not inserted into --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 3 ++- e2e-tests/want.md | 1 + e2e-tests/want3.md | 1 + internal/app/new.go | 7 ++--- internal/core/usecase/insertmd/insertmd.go | 8 ++++-- .../core/usecase/insertmd/insertmd_test.go | 27 ++++++++++++++++--- 7 files changed, 39 insertions(+), 10 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ef0307b..b9f9385 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. `--insert` suppresses the prefix as well: a TOC written into a document links to itself with bare anchors, since GitHub resolves relative links against that document's own directory. +`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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c9c3f0..3bdc96e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,7 +65,8 @@ Planned release: 2.1.0. "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. + 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. diff --git a/e2e-tests/want.md b/e2e-tests/want.md index 4659f1b..a73dbb4 100644 --- a/e2e-tests/want.md +++ b/e2e-tests/want.md @@ -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) diff --git a/e2e-tests/want3.md b/e2e-tests/want3.md index 4a313da..03cd94a 100644 --- a/e2e-tests/want3.md +++ b/e2e-tests/want3.md @@ -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) diff --git a/internal/app/new.go b/internal/app/new.go index 810d140..6909c2b 100644 --- a/internal/app/new.go +++ b/internal/app/new.go @@ -85,9 +85,10 @@ func New(cfg Config, stderr io.Writer) (*App, error) { jsonExtractor := adapters.NewJSONExtractor() rendererCfg := cfg.TOC // bash gh-md-toc drops the path prefix only when a single document is requested. - // A TOC written into a document links to itself with bare anchors; only the - // stdout listing of several documents needs a path prefix. - rendererCfg.AbsolutePaths = len(cfg.Files) > 1 && !cfg.Insert.Enabled + // bash gh-md-toc drops the path prefix only when a single document is requested. + // InsertMd asks for bare anchors per document, so this stays a run-level rule and + // remote documents keep their URL prefix even when --insert is set. + rendererCfg.AbsolutePaths = len(cfg.Files) > 1 renderer := coretoc.NewRenderer(rendererCfg) grabberRe := coretoc.NewGenerator(regexpExtractor, renderer) grabberJSON := coretoc.NewGenerator(jsonExtractor, renderer) diff --git a/internal/core/usecase/insertmd/insertmd.go b/internal/core/usecase/insertmd/insertmd.go index b330272..4682bc2 100644 --- a/internal/core/usecase/insertmd/insertmd.go +++ b/internal/core/usecase/insertmd/insertmd.go @@ -11,8 +11,12 @@ import ( // createdBy is the attribution written into the document, next to the TOC. const createdBy = "" +// useCase is the local-document pipeline InsertMd wraps. It takes a display path +// because InsertMd needs the TOC rendered with bare anchors: the TOC is written into +// the document itself, and GitHub resolves relative links against that document's own +// directory, so a path prefix there would point somewhere else. type useCase interface { - Do(context.Context, string) (entity.Toc, error) + DoAs(context.Context, string, string) (entity.Toc, error) } type fileReader interface { @@ -90,7 +94,7 @@ func (uc *InsertMd) Do(ctx context.Context, file string) (entity.Toc, error) { } uc.log.Info("InsertMD: start", "file", file) - toc, err := uc.inner.Do(ctx, file) + toc, err := uc.inner.DoAs(ctx, file, "") if err != nil { return nil, err } diff --git a/internal/core/usecase/insertmd/insertmd_test.go b/internal/core/usecase/insertmd/insertmd_test.go index b400696..ae6d656 100644 --- a/internal/core/usecase/insertmd/insertmd_test.go +++ b/internal/core/usecase/insertmd/insertmd_test.go @@ -11,11 +11,17 @@ import ( ) type innerStub struct { - toc entity.Toc - err error + toc entity.Toc + err error + gotDisplayPath *string } -func (s innerStub) Do(context.Context, string) (entity.Toc, error) { return s.toc, s.err } +func (s innerStub) DoAs(_ context.Context, _, displayPath string) (entity.Toc, error) { + if s.gotDisplayPath != nil { + *s.gotDisplayPath = displayPath + } + return s.toc, s.err +} type readerStub struct { data []byte @@ -200,3 +206,18 @@ func TestInsertMdWriteFailurePropagates(t *testing.T) { t.Errorf("got messages %v, want none - the insert notice must not claim a write that failed", notify.messages) } } + +func TestInsertMdAsksTheInnerUseCaseForBareAnchors(t *testing.T) { + var gotDisplayPath string + uc := New(Config{NoBackup: true}, + innerStub{toc: entity.Toc{"* [A](#a)"}, gotDisplayPath: &gotDisplayPath}, + readerStub{data: []byte("\n\n")}, &writerSpy{}, + &backupperSpy{}, stamperStub{}, ¬ifierSpy{}, loggerStub{}) + + if _, err := uc.Do(context.Background(), "README.md"); err != nil { + t.Fatal(err) + } + if gotDisplayPath != "" { + t.Errorf("got display path %q, want an empty one so the TOC links to the document itself", gotDisplayPath) + } +}