-
Notifications
You must be signed in to change notification settings - Fork 0
/
toc.go
238 lines (203 loc) · 5.47 KB
/
toc.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
// Package mdtoc generates a table of contents for an existing markdown document
package mdtoc
import (
"bufio"
"bytes"
_ "embed"
"errors"
"fmt"
"regexp"
"strings"
)
const (
tocBegin = "<!--mdtoc: begin-->"
tocEnd = "<!--mdtoc: end-->"
tocIgnore = "<!--mdtoc: ignore-->"
)
var (
// ErrExistingToc is thrown if the provided document already contains a mdtoc-generated
// table of contents
ErrExistingToc = errors.New("document has existing table of contents")
// Version is the current library/CLI version
//go:embed VERSION
Version string
// DefaultConfig defines the default configuration settings
//
// These field values will align with the default flag values from the CLI
DefaultConfig = &Config{
Force: false,
WithTocHeading: false,
TocHeading: DefaultTocHeading,
}
// DefaultTocHeading is the default heading applied when enabled
DefaultTocHeading = "Table of Contents"
// headingRegex is the expression which will match non-title heading lines
headingRegex = regexp.MustCompile("^([#]{2,})[ ]+(.+)")
)
// Item represents a single line in the table of contents
type Item struct {
Indent int
Text string
Link string
}
// Toc stores table of contents metadata
type Toc struct {
Items []Item
Config *Config
}
// Config stores settings to be used when inserting a new Toc
type Config struct {
Force bool
WithTocHeading bool
TocHeading string
}
// Bytes returns a markdown formatted slice of bytes
func (t *Toc) Bytes() []byte {
var buf []byte
w := bytes.NewBuffer(buf)
w.WriteString(fmt.Sprintf("%s\n", tocBegin))
if t.Config != nil && t.Config.WithTocHeading {
w.WriteString(fmt.Sprintf("## %s %s\n\n", t.Config.TocHeading, tocIgnore))
}
for _, item := range t.Items {
w.WriteString(fmt.Sprintf("%s* [%s](#%s)\n", strings.Repeat(" ", item.Indent*2), item.Text, item.Link))
}
w.WriteString(fmt.Sprintf("%s\n", tocEnd))
return w.Bytes()
}
// String returns a markdown formatted string
func (t *Toc) String() string {
return string(t.Bytes())
}
// Insert returns a copy of an existing document with a table of contents inserted
func Insert(b []byte, cfg *Config) ([]byte, error) {
toc, err := New(b)
if err != nil {
return b, err
}
toc.Config = cfg
var new []byte
buf := bytes.NewBuffer(new)
var inOld, newAdded bool
r := bytes.NewReader(b)
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := scanner.Text()
// handle any previously existing toc's
// begin comment, set flag and skip
if strings.EqualFold(tocBegin, line) {
if !cfg.Force {
return nil, ErrExistingToc
}
inOld = true
continue
}
// end comment, write toc in same location and reset flag
if inOld && strings.EqualFold(line, tocEnd) {
buf.Write(toc.Bytes())
newAdded = true
inOld = false
continue
}
// old toc line, skip
if inOld {
continue
}
// when the first non-title heading is encoutered, insert new toc just before it
if !newAdded && headingRegex.FindStringSubmatch(line) != nil {
buf.Write(append(toc.Bytes(), []byte("\n")...))
newAdded = true
}
buf.Write(append(scanner.Bytes(), []byte("\n")...))
}
if err := scanner.Err(); err != nil {
return buf.Bytes(), err
}
return buf.Bytes(), nil
}
// New extacts table of contents attributes from an existing document
func New(b []byte) (*Toc, error) {
toc := Toc{Config: DefaultConfig}
var inCodeBlock bool
r := bytes.NewReader(b)
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := scanner.Text()
// handle code blocks to ensure `#` are not captured as headings
// begin code block, set flag and skip
if strings.HasPrefix(line, "```") && !inCodeBlock {
inCodeBlock = true
continue
}
// end code block, reset flag and skip
if inCodeBlock && strings.HasPrefix(line, "```") {
inCodeBlock = false
continue
}
// code block line, skip
if inCodeBlock {
continue
}
m := headingRegex.FindStringSubmatch(line)
if len(m) == 3 {
// skip headings with ignore comments
if strings.Contains(line, tocIgnore) {
continue
}
// m[0]: Full regular expression match
// m[1]: First match group (two or more `#` characters)
// m[2]: Second match group (text of heading)
toc.Items = append(toc.Items,
Item{
Indent: len(m[1]) - 2,
Text: m[2],
Link: textToLink(m[2]),
})
}
}
if err := scanner.Err(); err != nil {
return &toc, err
}
toc.updateRepeatLinks()
return &toc, nil
}
// textToLink returns the heading link formatted version of a string
//
// ex. `Heading One Two` = `heading-one-two`
func textToLink(s string) string {
// TODO: find a more comprehensive/formally documented list of these
rep := strings.NewReplacer(
" ", "-",
"/", "",
",", "",
".", "",
"+", "",
":", "",
";", "",
"`", "",
`"`, "",
`'`, "",
"{", "",
"}", "",
"(", "",
")", "",
)
return strings.ToLower(rep.Replace(s))
}
// updateRepeatLinks fixes the generated link text if the generated text is repeated
// in the same contents
func (t *Toc) updateRepeatLinks() {
lookup := make(map[string]int, len(t.Items))
for i, item := range t.Items {
// if key already exists in the lookup, the link text needs to append a `-n`,
// where `n` is the number of previous occurrences. if the key does not already
// exist, add a new key and set occurrences to 1.
if val, ok := lookup[item.Link]; ok {
key := item.Link // preserve the original lookup key
t.Items[i].Link = fmt.Sprintf("%s-%d", item.Link, val)
lookup[key]++
continue
}
lookup[item.Link] = 1
}
}