Skip to content

Commit

Permalink
feat: Add Compact option
Browse files Browse the repository at this point in the history
Adds a new 'Compact' option.
This option transforms the TOC,
collapsing away empty intermediate levels
when there's a difference of greater than one between levels.

Adds entries to the README and an option to the demo playground
for the new feature.

Testing:
Besides hand-written test cases, rapid-based property tests were added
to verify that the resulting TOC contains all headings.

Resolves #42
  • Loading branch information
abhinav committed Sep 11, 2023
1 parent 59d86b7 commit 9a57e80
Show file tree
Hide file tree
Showing 14 changed files with 444 additions and 5 deletions.
4 changes: 4 additions & 0 deletions .changes/unreleased/Added-20230911-053948.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
kind: Added
body: The new `Compact` option removes empty nodes in the TOC. If you have >1 level
of difference between headings, this will render a cleaner TOC.
time: 2023-09-11T05:39:48.419723018-07:00
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,43 @@ use the `MaxDepth` field.
Headers with a level higher than the specified value
will not be included in the table of contents.

#### Compacting the Table of Contents

The Table of Contents generated by goldmark-toc matches your heading hierarchy
exactly.
This can be a problem if you have multiple levels of difference between items.
For example, if you have the document:

```markdown
# h1
### h3
```

goldmark-toc will generate a TOC with the equivalent of the following,
resulting in an empty entry between h1 and h3.

```markdown
- h1
- <blank>
- h3
```

You can use the `Compact` option to collapse away these intermediate items.

```go
&toc.Extender{
Compact: true,
}
```

With this option enabled, the hierarchy above
will render as the equivalent of the following.

```markdown
- h1
- h3
```

### Transformer

Installing this package as an AST Transformer provides slightly more control
Expand Down
3 changes: 3 additions & 0 deletions demo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ type request struct {
Markdown string
Title string
MaxDepth int
Compact bool
}

func (r *request) Decode(v js.Value) {
r.Markdown = v.Get("markdown").String()
r.Title = v.Get("title").String()
r.MaxDepth = v.Get("maxDepth").Int()
r.Compact = v.Get("compact").Bool()
}

func formatMarkdown(req request) string {
Expand All @@ -41,6 +43,7 @@ func formatMarkdown(req request) string {
&toc.Extender{
Title: req.Title,
MaxDepth: req.MaxDepth,
Compact: req.Compact,
},
),
)
Expand Down
6 changes: 6 additions & 0 deletions demo/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ <h2>Input</h2>
<option value="5">5</option>
<option value="6">6</option>
</select>

<label for="compact">Compact</label>
<input type="checkbox" id="compact" name="compact" checked />
</div>

<div class="output-container">
Expand All @@ -80,18 +83,21 @@ <h2>Output</h2>
<script>
const input = document.getElementById("input");
const maxDepth = document.getElementById("maxDepth");
const compact = document.getElementById("compact");
const title = document.getElementById("title");
const output = document.getElementById("output");

input.addEventListener("input", refresh);
maxDepth.addEventListener("change", refresh);
title.addEventListener("input", refresh);
compact.addEventListener("change", refresh);

function refresh() {
output.innerHTML = formatMarkdown({
markdown: input.value,
maxDepth: parseInt(maxDepth.value),
title: title.value,
compact: compact.checked,
});
}
</script>
Expand Down
7 changes: 7 additions & 0 deletions extend.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ type Extender struct {
//
// See the documentation for Transformer.ListID for more information.
ListID string

// Compact controls whether empty items should be removed
// from the table of contents.
//
// See the documentation for Compact for more information.
Compact bool
}

// Extend adds support for rendering a table of contents to the provided
Expand All @@ -54,6 +60,7 @@ func (e *Extender) Extend(md goldmark.Markdown) {
Title: e.Title,
MaxDepth: e.MaxDepth,
ListID: e.ListID,
Compact: e.Compact,
}, 100),
),
)
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/stretchr/testify v1.7.0
github.com/yuin/goldmark v1.3.3
gopkg.in/yaml.v3 v3.0.1
pgregory.net/rapid v1.1.0
)

require (
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
pgregory.net/rapid v1.1.0 h1:CMa0sjHSru3puNx+J0MIAuiiEV4N0qj8/cMWGBBCsjw=
pgregory.net/rapid v1.1.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
98 changes: 95 additions & 3 deletions inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type InspectOption interface {

type inspectOptions struct {
maxDepth int
compact bool
}

// MaxDepth limits the depth of the table of contents.
Expand Down Expand Up @@ -64,6 +65,67 @@ func (d maxDepthOption) String() string {
return fmt.Sprintf("MaxDepth(%d)", int(d))
}

// Compact instructs Inspect to remove empty items from the table of contents.
// Children of removed items will be promoted to the parent item.
//
// For example, given the following:
//
// # A
// ### B
// #### C
// # D
// #### E
//
// Compact(false), which is the default, will result in the following:
//
// TOC{Items: ...}
// |
// +--- &Item{Title: "A", ...}
// | |
// | +--- &Item{Title: "", ...}
// | |
// | +--- &Item{Title: "B", ...}
// | |
// | +--- &Item{Title: "C"}
// |
// +--- &Item{Title: "D", ...}
// |
// +--- &Item{Title: "", ...}
// |
// +--- &Item{Title: "", ...}
// |
// +--- &Item{Title: "E", ...}
//
// Whereas, Compact(true) will result in the following:
//
// TOC{Items: ...}
// |
// +--- &Item{Title: "A", ...}
// | |
// | +--- &Item{Title: "B", ...}
// | |
// | +--- &Item{Title: "C"}
// |
// +--- &Item{Title: "D", ...}
// |
// +--- &Item{Title: "E", ...}
//
// Notice that the empty items have been removed
// and the generated TOC is more compact.
func Compact(compact bool) InspectOption {
return compactOption(compact)
}

type compactOption bool

func (c compactOption) apply(opts *inspectOptions) {
opts.compact = bool(c)
}

func (c compactOption) String() string {
return fmt.Sprintf("Compact(%v)", bool(c))
}

// Inspect builds a table of contents by inspecting the provided document.
//
// The table of contents is represents as a tree where each item represents a
Expand Down Expand Up @@ -121,7 +183,7 @@ func Inspect(n ast.Node, src []byte, options ...InspectOption) (*TOC, error) {

var root Item

stack := []*Item{&root}
stack := []*Item{&root} // inv: len(stack) >= 1
err := ast.Walk(n, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
Expand All @@ -136,13 +198,17 @@ func Inspect(n ast.Node, src []byte, options ...InspectOption) (*TOC, error) {
return ast.WalkSkipChildren, nil
}

// The heading is deeper than the current depth.
// Append empty items to match the heading's level.
for len(stack) < heading.Level {
parent := stack[len(stack)-1]
stack = append(stack, lastChild(parent))
}

for len(stack) > heading.Level {
stack = stack[:len(stack)-1]
// The heading is shallower than the current depth.
// Move back up the stack until we reach the heading's level.
if len(stack) > heading.Level {
stack = stack[:heading.Level]
}

parent := stack[len(stack)-1]
Expand All @@ -159,5 +225,31 @@ func Inspect(n ast.Node, src []byte, options ...InspectOption) (*TOC, error) {
return ast.WalkSkipChildren, nil
})

if opts.compact {
compactItems(&root.Items)
}

return &TOC{Items: root.Items}, err
}

// compactItems removes items with no titles
// from the given list of items.
//
// Children of removed items will be promoted to the parent item.
func compactItems(items *Items) {
for i := 0; i < len(*items); i++ {
item := (*items)[i]
if len(item.Title) > 0 {
compactItems(&item.Items)
continue
}

children := item.Items
newItems := make(Items, 0, len(*items)-1+len(children))
newItems = append(newItems, (*items)[:i]...)
newItems = append(newItems, children...)
newItems = append(newItems, (*items)[i+1:]...)
*items = newItems
i-- // start with first child
}
}
Loading

0 comments on commit 9a57e80

Please sign in to comment.