From 93b2b34f3c68787a189bed3dc7cecbe8925962d2 Mon Sep 17 00:00:00 2001 From: Abhinav Gupta Date: Mon, 11 Sep 2023 06:12:34 -0700 Subject: [PATCH] feat: Add Compact option (#43) 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 --- .../unreleased/Added-20230911-053948.yaml | 4 + README.md | 37 +++++ demo/main.go | 3 + demo/static/index.html | 6 + extend.go | 7 + go.mod | 1 + go.sum | 2 + inspect.go | 98 ++++++++++- inspect_test.go | 153 ++++++++++++++++++ integration_test.go | 4 +- ...ctRandomHeadings-20230911051733-28626.fail | 68 ++++++++ ...estInspect_rapid-20230911050748-19573.fail | 28 ++++ testdata/tests.yaml | 31 ++++ transform.go | 7 +- 14 files changed, 444 insertions(+), 5 deletions(-) create mode 100644 .changes/unreleased/Added-20230911-053948.yaml create mode 100644 testdata/rapid/TestInspectCompactRandomHeadings/TestInspectCompactRandomHeadings-20230911051733-28626.fail create mode 100644 testdata/rapid/TestInspect_rapid/TestInspect_rapid-20230911050748-19573.fail diff --git a/.changes/unreleased/Added-20230911-053948.yaml b/.changes/unreleased/Added-20230911-053948.yaml new file mode 100644 index 0000000..b92bfc2 --- /dev/null +++ b/.changes/unreleased/Added-20230911-053948.yaml @@ -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 diff --git a/README.md b/README.md index b0713d2..5ae05d7 100644 --- a/README.md +++ b/README.md @@ -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 + - + - 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 diff --git a/demo/main.go b/demo/main.go index 1f30412..c67a022 100644 --- a/demo/main.go +++ b/demo/main.go @@ -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 { @@ -41,6 +43,7 @@ func formatMarkdown(req request) string { &toc.Extender{ Title: req.Title, MaxDepth: req.MaxDepth, + Compact: req.Compact, }, ), ) diff --git a/demo/static/index.html b/demo/static/index.html index 82e8741..ce3a66a 100644 --- a/demo/static/index.html +++ b/demo/static/index.html @@ -68,6 +68,9 @@

Input

+ + +
@@ -80,18 +83,21 @@

Output

diff --git a/extend.go b/extend.go index 5486ee7..2dd11de 100644 --- a/extend.go +++ b/extend.go @@ -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 @@ -54,6 +60,7 @@ func (e *Extender) Extend(md goldmark.Markdown) { Title: e.Title, MaxDepth: e.MaxDepth, ListID: e.ListID, + Compact: e.Compact, }, 100), ), ) diff --git a/go.mod b/go.mod index 1da29ad..d248f9b 100644 --- a/go.mod +++ b/go.mod @@ -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 ( diff --git a/go.sum b/go.sum index bbf1ada..9a0c1e1 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/inspect.go b/inspect.go index 13d0cb8..daddcb4 100644 --- a/inspect.go +++ b/inspect.go @@ -14,6 +14,7 @@ type InspectOption interface { type inspectOptions struct { maxDepth int + compact bool } // MaxDepth limits the depth of the table of contents. @@ -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 @@ -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 @@ -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] @@ -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 + } +} diff --git a/inspect_test.go b/inspect_test.go index f19a65f..ed5c483 100644 --- a/inspect_test.go +++ b/inspect_test.go @@ -1,7 +1,9 @@ package toc import ( + "bytes" "fmt" + "strconv" "strings" "testing" @@ -9,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/text" + "pgregory.net/rapid" ) func item(title, id string, items ...*Item) *Item { @@ -168,6 +171,59 @@ func TestInspect(t *testing.T) { item("G", "g"), }, }, + { + desc: "compact", + give: []string{ + "# A", + "### B", + "#### C", + "# D", + "#### E", + }, + opts: []InspectOption{Compact(true)}, + want: Items{ + item("A", "a", + item("B", "b", + item("C", "c"), + ), + ), + item("D", "d", + item("E", "e"), + ), + }, + }, + { + desc: "compact complex", + give: []string{ + "## A", + "##### B", + "###### C", + "## D", + "# E", + "### F", + "# G", + "#### H", + "### I", + "## J", + }, + opts: []InspectOption{Compact(true)}, + want: Items{ + item("A", "a", + item("B", "b", + item("C", "c"), + ), + ), + item("D", "d"), + item("E", "e", + item("F", "f"), + ), + item("G", "g", + item("H", "h"), + item("I", "i"), + item("J", "j"), + ), + }, + }, } for _, tt := range tests { @@ -199,6 +255,7 @@ func TestInspectOption_String(t *testing.T) { {give: MaxDepth(3), want: "MaxDepth(3)"}, {give: MaxDepth(0), want: "MaxDepth(0)"}, {give: MaxDepth(-1), want: "MaxDepth(-1)"}, + {give: Compact(true), want: "Compact(true)"}, } for _, tt := range tests { @@ -210,3 +267,99 @@ func TestInspectOption_String(t *testing.T) { }) } } + +func TestInspectRandomHeadings(t *testing.T) { + t.Parallel() + + rapid.Check(t, testInspectRandomHeadings) +} + +func FuzzInspectRandomHeadings(f *testing.F) { + f.Fuzz(rapid.MakeFuzz(testInspectRandomHeadings)) +} + +func testInspectRandomHeadings(t *rapid.T) { + // Generate a random hierarchy. + levels := rapid.SliceOf(rapid.IntRange(1, 6)).Draw(t, "levels") + var buf bytes.Buffer + for i, level := range levels { + buf.WriteString(strings.Repeat("#", level)) + buf.WriteString(" Heading ") + buf.WriteString(strconv.Itoa(i)) + buf.WriteByte('\n') + } + + src := buf.Bytes() + doc := parser.NewParser( + parser.WithInlineParsers(parser.DefaultInlineParsers()...), + parser.WithBlockParsers(parser.DefaultBlockParsers()...), + parser.WithAutoHeadingID(), + ).Parse(text.NewReader(src)) + + toc, err := Inspect(doc, src) + require.NoError(t, err, "inspect error") + + // Verify that the number of items in the TOC is the same as the number + // of headings in the document. + assert.Equal(t, len(levels), nonEmptyItems(toc.Items), + "number of non-empty items in TOC "+ + "does not match number of headings in document:\n%s", src) +} + +func TestInspectCompactRandomHeadings(t *testing.T) { + t.Parallel() + + rapid.Check(t, testInspectCompactRandomHeadings) +} + +func FuzzInspectCompactRandomHeadings(f *testing.F) { + f.Fuzz(rapid.MakeFuzz(testInspectCompactRandomHeadings)) +} + +func testInspectCompactRandomHeadings(t *rapid.T) { + // Generate a random hierarchy. + levels := rapid.SliceOf(rapid.IntRange(1, 6)).Draw(t, "levels") + var buf bytes.Buffer + for i, level := range levels { + buf.WriteString(strings.Repeat("#", level)) + buf.WriteString(" Heading ") + buf.WriteString(strconv.Itoa(i)) + buf.WriteByte('\n') + } + + src := buf.Bytes() + doc := parser.NewParser( + parser.WithInlineParsers(parser.DefaultInlineParsers()...), + parser.WithBlockParsers(parser.DefaultBlockParsers()...), + parser.WithAutoHeadingID(), + ).Parse(text.NewReader(src)) + + toc, err := Inspect(doc, src, Compact(true)) + require.NoError(t, err, "inspect error") + + // There must be no empty items in the TOC. + assert.Equal(t, nonEmptyItems(toc.Items), totalItems(toc.Items), + "number of non-empty items in TOC "+ + "does not match number of items in TOC:\n%s", src) + assert.Equal(t, len(levels), totalItems(toc.Items), + "number of items in TOC "+ + "does not match number of headings in document:\n%s", src) +} + +func totalItems(items Items) (total int) { + for _, item := range items { + total++ + total += totalItems(item.Items) + } + return total +} + +func nonEmptyItems(items Items) (total int) { + for _, item := range items { + if len(item.Title) > 0 { + total++ + } + total += nonEmptyItems(item.Items) + } + return total +} diff --git a/integration_test.go b/integration_test.go index dfa217a..57021b4 100644 --- a/integration_test.go +++ b/integration_test.go @@ -25,7 +25,8 @@ func TestIntegration(t *testing.T) { Title string `yaml:"title"` ListID string `yaml:"listID"` - MaxDepth int `yaml:"maxDepth"` + MaxDepth int `yaml:"maxDepth"` + Compact bool `yaml:"compact"` } require.NoError(t, yaml.Unmarshal(testsdata, &tests)) @@ -38,6 +39,7 @@ func TestIntegration(t *testing.T) { goldmark.WithExtensions(&toc.Extender{ Title: tt.Title, MaxDepth: tt.MaxDepth, + Compact: tt.Compact, ListID: tt.ListID, }), goldmark.WithParserOptions(parser.WithAutoHeadingID()), diff --git a/testdata/rapid/TestInspectCompactRandomHeadings/TestInspectCompactRandomHeadings-20230911051733-28626.fail b/testdata/rapid/TestInspectCompactRandomHeadings/TestInspectCompactRandomHeadings-20230911051733-28626.fail new file mode 100644 index 0000000..7423e9d --- /dev/null +++ b/testdata/rapid/TestInspectCompactRandomHeadings/TestInspectCompactRandomHeadings-20230911051733-28626.fail @@ -0,0 +1,68 @@ +# 2023/09/11 05:17:33.722915 [TestInspectCompactRandomHeadings] [rapid] draw levels: []int{2, 5, 6, 2, 1, 3, 1, 4, 3, 2} +# 2023/09/11 05:17:33.723216 [TestInspectCompactRandomHeadings] +# Error Trace: inspect_test.go:308 +# engine.go:368 +# engine.go:377 +# engine.go:203 +# engine.go:118 +# inspect_test.go:279 +# Error: Not equal: +# expected: 10 +# actual : 11 +# Test: TestInspectCompactRandomHeadings +# Messages: number of items in TOC does not match the number of headings in document: +# ## Heading 0 +# ##### Heading 1 +# ###### Heading 2 +# ## Heading 3 +# # Heading 4 +# ### Heading 5 +# # Heading 6 +# #### Heading 7 +# ### Heading 8 +# ## Heading 9 +# +v0.4.8#15085304404995422177 +0x19e69db68bc036 +0x24b2760ec7eea +0x361cf0f89a682 +0x1 +0x114bbf838dcdca +0xa99b52221b8f2 +0xaa728ac0c3547 +0x4 +0xc319bcd62bde9 +0x1619b1a7f18de4 +0x1f3f0819edfced +0xffffffffffffffff +0xf33d03ec7011b +0xe7a41614a516f +0x11f801679f14d +0x1 +0x1e0c62b0aef7f3 +0x1083214304416c +0x15c0d0227625e1 +0x7 +0x0 +0x93b9472e87f78 +0x18d7fd4765e191 +0xa2002bbe747c1 +0x2 +0xf90b18698fc9b +0x88c2784cdf072 +0x1d89327185965b +0x0 +0x1cd1834e693805 +0x1ca752d0b81d79 +0x1aaf49eefd05f7 +0x6 +0x3 +0x13e6badd851116 +0x1566b82ad6e58b +0x1e1087cd9c9328 +0x2 +0x19fd0c5ddd4b5f +0x11063d20c9ab63 +0xe15850039e3cb +0x1 +0x2921518ffb8f7 \ No newline at end of file diff --git a/testdata/rapid/TestInspect_rapid/TestInspect_rapid-20230911050748-19573.fail b/testdata/rapid/TestInspect_rapid/TestInspect_rapid-20230911050748-19573.fail new file mode 100644 index 0000000..cc65432 --- /dev/null +++ b/testdata/rapid/TestInspect_rapid/TestInspect_rapid-20230911050748-19573.fail @@ -0,0 +1,28 @@ +# 2023/09/11 05:07:48.217912 [TestInspect_rapid] [rapid] draw levels: []int{5, 5, 3} +# 2023/09/11 05:07:48.218098 [TestInspect_rapid] +# Error Trace: inspect_test.go:263 +# engine.go:368 +# engine.go:377 +# engine.go:203 +# engine.go:118 +# inspect_test.go:241 +# Error: Not equal: +# expected: 5 +# actual : 3 +# Test: TestInspect_rapid +# Messages: number of items in TOC does not match number of headings in document for headings at levels: [5 5 3] +# +v0.4.8#11432231941130037350 +0x1a2114dbb9dddc +0xace527c94949d +0x1971bf7670446a +0x4 +0x8525d57e698fe +0x124700ca6cd931 +0x15703ea2934efe +0x4 +0x7ea3fc979834b +0x17be3bd12902a7 +0x1a32d19baee9a6 +0x2 +0x19d8bf33c52c7 \ No newline at end of file diff --git a/testdata/tests.yaml b/testdata/tests.yaml index c52d81f..6d28df7 100644 --- a/testdata/tests.yaml +++ b/testdata/tests.yaml @@ -146,3 +146,34 @@

Hello

World

+ +# From https://github.com/abhinav/goldmark-toc/issues/42 +- desc: compact single + compact: true + give: | + ### h3 + want: | +

Table of Contents

+
    +
  • + h3
  • +
+

h3

+ +- desc: compact multiple + compact: true + give: | + # h1 + ### h3 + want: | +

Table of Contents

+
    +
  • + h1
      +
    • + h3
    • +
    +
  • +
+

h1

+

h3

diff --git a/transform.go b/transform.go index edfa840..23237c5 100644 --- a/transform.go +++ b/transform.go @@ -45,6 +45,11 @@ type Transformer struct { // // The HTML element does not have an ID if ListID is empty. 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 } var _ parser.ASTTransformer = (*Transformer)(nil) // interface compliance @@ -54,7 +59,7 @@ var _ parser.ASTTransformer = (*Transformer)(nil) // interface compliance // Errors encountered while transforming are ignored. For more fine-grained // control, use Inspect and transform the document manually. func (t *Transformer) Transform(doc *ast.Document, reader text.Reader, _ parser.Context) { - toc, err := Inspect(doc, reader.Source(), MaxDepth(t.MaxDepth)) + toc, err := Inspect(doc, reader.Source(), MaxDepth(t.MaxDepth), Compact(t.Compact)) if err != nil { // There are currently no scenarios under which Inspect // returns an error but we have to account for it anyway.