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
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Table of Contents
* [Remote files](#remote-files)
* [Multiple files](#multiple-files)
* [Combo](#combo)
* [Auto insert and update TOC](#auto-insert-and-update-toc)
* [Starting Depth](#starting-depth)
* [Depth](#depth)
* [No Escape](#no-escape)
Expand Down Expand Up @@ -91,6 +92,8 @@ Flags:
--token=TOKEN GitHub personal token
--indent=2 Indent space of generated list
--debug Show debug info
--insert Insert TOC into file (auto-insert at top or between <!--ts--> and <!--te--> markers)
--no-backup Skip creating backup file when using --insert
--version Show application version.

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

Auto insert and update TOC
----------

You can easily insert a TOC into an existing Markdown file. Just add the following placeholder in your document:

```markdown
<!--ts-->
<!--te-->
```

Now run the tool:

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

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

* [gh-md-toc](#gh-md-toc)
* [Installation](#installation)
* [Usage](#usage)

Created by [gh-md-toc](https://github.com/ekalinin/github-markdown-toc.go)
```

The TOC will be automatically inserted between the `<!--ts-->` and `<!--te-->` markers.

If your file doesn't have these markers, the TOC will be auto-inserted at the top (before the first heading).

Next time when your file will be changed just repeat the command (`./gh-md-toc --insert ...`) and TOC will be refreshed again.

A backup of your original file will be created with the `.YYYY-MM-DD_HHMMSS` suffix.

If you don't want to create a backup, use `--no-backup` option:

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

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

Expand Down
4 changes: 4 additions & 0 deletions cmd/gh-md-toc/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ var (
debug = kingpin.Flag("debug", "Show debug info").Bool()
ghurl = kingpin.Flag("github-url", "GitHub URL, default=https://api.github.com").Default("https://api.github.com").String()
reVersion = kingpin.Flag("re-version", "RegExp version, default=0").Default(version.GH_2024_03).String()
insert = kingpin.Flag("insert", "Insert TOC into file (auto-insert at top or between <!--ts--> and <!--te--> markers)").Bool()
noBackup = kingpin.Flag("no-backup", "Skip creating backup file when using --insert").Bool()
)

// Entry point
Expand Down Expand Up @@ -52,6 +54,8 @@ func main() {
GHToken: *token,
GHUrl: *ghurl,
GHVersion: *reVersion,
Insert: *insert,
NoBackup: *noBackup,
}

if err := app.New(cfg).Run(os.Stdout); err != nil {
Expand Down
41 changes: 41 additions & 0 deletions internal/adapters/filebackup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package adapters

import (
"fmt"
"io"
"os"
"time"
)

type FileBackupper struct{}

func NewFileBackupper() *FileBackupper {
return &FileBackupper{}
}

func (fb *FileBackupper) CreateBackup(filepath string) (string, error) {
timestamp := time.Now().Format("2006-01-02_150405")
backupPath := fmt.Sprintf("%s.%s", filepath, timestamp)

src, err := os.Open(filepath)
if err != nil {
return "", err
}
defer func() {
_ = src.Close()
}()

dst, err := os.Create(backupPath)
if err != nil {
return "", err
}
defer func() {
_ = dst.Close()
}()

if _, err := io.Copy(dst, src); err != nil {
return "", err
}

return backupPath, nil
}
174 changes: 174 additions & 0 deletions internal/adapters/tocinserter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package adapters

import (
"bytes"
"fmt"
"os"
"os/user"
"strings"
"time"

"github.com/ekalinin/github-markdown-toc.go/v2/internal/utils"
)

const (
MarkerStart = "<!--ts-->"
MarkerEnd = "<!--te-->"
)

type TocInserter struct {
hideHeader bool
hideFooter bool
}

func NewTocInserter(hideHeader, hideFooter bool) *TocInserter {
return &TocInserter{
hideHeader: hideHeader,
hideFooter: hideFooter,
}
}

func (ti *TocInserter) InsertToc(filepath string, toc string) error {
stat, err := os.Stat(filepath)
if err != nil {
return err
}

content, err := os.ReadFile(filepath)
if err != nil {
return err
}

contentStr := string(content)

if err := ti.validateMarkers(contentStr); err != nil {
return fmt.Errorf("invalid markers in %s: %w", filepath, err)
}

var newContent string
if ti.hasMarkers(contentStr) {
newContent, err = ti.replaceContent(contentStr, toc)
if err != nil {
return err
}
} else {
newContent = ti.insertAtTop(contentStr, toc)
}

return os.WriteFile(filepath, []byte(newContent), stat.Mode().Perm())
}

func (ti *TocInserter) hasMarkers(content string) bool {
return strings.Contains(content, MarkerStart) && strings.Contains(content, MarkerEnd)
}

func (ti *TocInserter) validateMarkers(content string) error {
startCount := strings.Count(content, MarkerStart)
endCount := strings.Count(content, MarkerEnd)

if startCount != endCount {
return fmt.Errorf("mismatched markers: found %d start markers and %d end markers", startCount, endCount)
}
if startCount > 1 {
return fmt.Errorf("multiple marker pairs found (%d pairs), only one pair is supported", startCount)
}
if startCount == 0 && endCount == 0 {
return nil
}
return nil
}

func (ti *TocInserter) replaceContent(content, toc string) (string, error) {
lines := strings.Split(content, "\n")
var result []string
var insideMarkers bool
var markerFound bool

for _, line := range lines {
trimmed := strings.TrimSpace(line)

if trimmed == MarkerStart {
result = append(result, line)
insideMarkers = true
markerFound = true
result = append(result, ti.formatToc(toc))
continue
}

if trimmed == MarkerEnd {
result = append(result, line)
insideMarkers = false
continue
}

if !insideMarkers {
result = append(result, line)
}
}

if !markerFound {
return "", fmt.Errorf("markers not found")
}

return strings.Join(result, "\n"), nil
}

func (ti *TocInserter) formatToc(toc string) string {
var buf bytes.Buffer

if !ti.hideHeader {
buf.WriteString(utils.GetHeaderText())
}

buf.WriteString("\n")
buf.WriteString(toc)

if !ti.hideFooter {
buf.WriteString("\n")
buf.WriteString(ti.generateTimestamp())
buf.WriteString("\n")
buf.WriteString(utils.GetFooterText())
buf.WriteString("\n")
}

return buf.String()
}

func (ti *TocInserter) generateTimestamp() string {
username := "unknown"
if u, err := user.Current(); err == nil {
username = u.Username
}

timestamp := time.Now().Format("2006-01-02T15:04-07:00")
return fmt.Sprintf("<!-- Added by: %s, at: %s -->", username, timestamp)
}

func (ti *TocInserter) insertAtTop(content, toc string) string {
lines := strings.Split(content, "\n")
var result []string
var insertIndex int
var foundHeading bool

for i, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") {
insertIndex = i
foundHeading = true
break
}
}

if !foundHeading {
insertIndex = 0
}

result = append(result, lines[:insertIndex]...)
result = append(result, MarkerStart)
result = append(result, ti.formatToc(toc))
result = append(result, MarkerEnd)
result = append(result, "")
result = append(result, lines[insertIndex:]...)

return strings.Join(result, "\n")
}
4 changes: 4 additions & 0 deletions internal/app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ type Config struct {
GHToken string
GHUrl string
GHVersion string
Insert bool
NoBackup bool
}

func (c Config) ToControllerConfig() controller.Config {
Expand All @@ -35,6 +37,8 @@ func (c Config) ToControllerConfig() controller.Config {
GHToken: c.GHToken,
GHUrl: c.GHUrl,
GHVersion: c.GHVersion,
Insert: c.Insert,
NoBackup: c.NoBackup,
}
}

Expand Down
4 changes: 3 additions & 1 deletion internal/app/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ func New(cfg Config) *App {
grabberJson := adapters.NewJsonGrabber(cfg.ToGrabberConfig())
getter := adapters.NewRemoteGetter(true)
temper := adapters.NewFileTemper()
fileBackupper := adapters.NewFileBackupper()
tocInserter := adapters.NewTocInserter(cfg.HideHeader, cfg.HideFooter)

log.Info("App.New: init usecases ...")
ucLocalMD, ucRemoteMD, ucRemoteHTML := usecase.New(
Expand All @@ -40,7 +42,7 @@ func New(cfg Config) *App {
)

log.Info("App.New: init controller ...")
ctl := controller.New(ctlCfg, ucLocalMD, ucRemoteMD, ucRemoteHTML, log)
ctl := controller.New(ctlCfg, ucLocalMD, ucRemoteMD, ucRemoteHTML, log, fileBackupper, tocInserter)

log.Info("App.New: done.")
return &App{
Expand Down
4 changes: 4 additions & 0 deletions internal/controller/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ type Config struct {
GHToken string
GHUrl string
GHVersion string
Insert bool
NoBackup bool
}

func (c Config) ToUseCaseConfig() config.Config {
Expand All @@ -33,5 +35,7 @@ func (c Config) ToUseCaseConfig() config.Config {
GHUrl: c.GHUrl,
GHVersion: c.GHVersion,
AbsPathInToc: len(c.Files) > 1,
Insert: c.Insert,
NoBackup: c.NoBackup,
}
}
Loading