markup/tableofcontents: Skip empty TOC levels

Fixes #7128
This commit is contained in:
Alexandre Vaz
2026-05-12 14:02:23 -03:00
committed by GitHub
parent 28147cb040
commit 7d4af7a179
3 changed files with 279 additions and 49 deletions
+95
View File
@@ -305,6 +305,101 @@ title: home
b.AssertFileExists("public/index.html", true)
}
func TestTableOfContentsSkippedHeadingLevelsIssue7128(t *testing.T) {
t.Parallel()
filesTemplate := `
-- hugo.toml --
disableKinds = ['home','rss','section','sitemap','taxonomy','term']
TOC_CONFIG
-- layouts/page.html --
{{ .TableOfContents }}
-- content/p1.md --
---
title: p1
---
## Extra-Curriculars
#### Heading Four Here without Heading Three Before It
## Technology
#### Heading Four Here without Heading Three Before It
`
t.Run("default end level", func(t *testing.T) {
t.Parallel()
files := strings.ReplaceAll(filesTemplate, "TOC_CONFIG", "")
b := hugolib.Test(t, files)
b.AssertFileContentExact("public/p1/index.html", `<nav id="TableOfContents">
<ul>
<li><a href="#extra-curriculars">Extra-Curriculars</a></li>
<li><a href="#technology">Technology</a></li>
</ul>
</nav>`)
})
t.Run("include skipped level", func(t *testing.T) {
t.Parallel()
files := strings.ReplaceAll(filesTemplate, "TOC_CONFIG", `
[markup.tableOfContents]
startLevel = 2
endLevel = -1`)
b := hugolib.Test(t, files)
b.AssertFileContentExact("public/p1/index.html", `<nav id="TableOfContents">
<ul>
<li><a href="#extra-curriculars">Extra-Curriculars</a>
<ul>
<li><a href="#heading-four-here-without-heading-three-before-it">Heading Four Here without Heading Three Before It</a></li>
</ul>
</li>
<li><a href="#technology">Technology</a>
<ul>
<li><a href="#heading-four-here-without-heading-three-before-it-1">Heading Four Here without Heading Three Before It</a></li>
</ul>
</li>
</ul>
</nav>`)
})
t.Run("starts at h4", func(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
disableKinds = ['home','rss','section','sitemap','taxonomy','term']
[markup.tableOfContents]
startLevel = 2
endLevel = -1
-- layouts/page.html --
{{ .TableOfContents }}
-- content/p1.md --
---
title: p1
---
#### Heading Four First
###### Heading Six After Heading Four
`
b := hugolib.Test(t, files)
b.AssertFileContentExact("public/p1/index.html", `<nav id="TableOfContents">
<ul>
<li><a href="#heading-four-first">Heading Four First</a>
<ul>
<li><a href="#heading-six-after-heading-four">Heading Six After Heading Four</a></li>
</ul>
</li>
</ul>
</nav>`)
})
}
// Issue 12605
func TestTableOfContentsWithGoldmarkExtras(t *testing.T) {
t.Parallel()
+31 -19
View File
@@ -195,23 +195,13 @@ func (b *tocBuilder) Build() {
func (b *tocBuilder) writeNav(h Headings) {
b.s.WriteString("<nav id=\"TableOfContents\">")
b.writeHeadings(1, 0, b.h)
b.writeHeadings(1, 0, h)
b.s.WriteString("</nav>")
}
func (b *tocBuilder) writeHeadings(level, indent int, h Headings) {
if level < b.startLevel {
for _, h := range h {
b.writeHeadings(level+1, indent, h.Headings)
}
return
}
if b.stopLevel != -1 && level > b.stopLevel {
return
}
hasChildren := len(h) > 0
headings := b.collectHeadings(level, h)
hasChildren := len(headings) > 0
if hasChildren {
b.s.WriteString("\n")
@@ -223,8 +213,8 @@ func (b *tocBuilder) writeHeadings(level, indent int, h Headings) {
}
}
for _, h := range h {
b.writeHeading(level+1, indent+2, h)
for _, h := range headings {
b.writeHeading(h.level, indent+2, h.h)
}
if hasChildren {
@@ -239,13 +229,35 @@ func (b *tocBuilder) writeHeadings(level, indent int, h Headings) {
}
}
type tocHeading struct {
h *Heading
level int
}
func (b *tocBuilder) collectHeadings(level int, h Headings) []tocHeading {
var out []tocHeading
for _, h := range h {
if h.IsZero() || level < b.startLevel {
out = append(out, b.collectHeadings(level+1, h.Headings)...)
continue
}
if b.stopLevel != -1 && level > b.stopLevel {
continue
}
out = append(out, tocHeading{h: h, level: level})
}
return out
}
func (b *tocBuilder) writeHeading(level, indent int, h *Heading) {
b.indent(indent)
b.s.WriteString("<li>")
if !h.IsZero() {
b.s.WriteString("<a href=\"#" + h.ID + "\">" + h.Title + "</a>")
}
b.writeHeadings(level, indent, h.Headings)
b.s.WriteString("<a href=\"#" + h.ID + "\">" + h.Title + "</a>")
b.writeHeadings(level+1, indent, h.Headings)
b.s.WriteString("</li>\n")
}
+153 -30
View File
@@ -14,6 +14,7 @@
package tableofcontents
import (
"strings"
"testing"
qt "github.com/frankban/quicktest"
@@ -127,21 +128,9 @@ func TestTocMissingParent(t *testing.T) {
got := string(tocHTML)
c.Assert(got, qt.Equals, `<nav id="TableOfContents">
<ul>
<li>
<ul>
<li><a href="#h2">H2</a></li>
</ul>
</li>
<li>
<ul>
<li>
<ul>
<li><a href="#h3">H3</a></li>
<li><a href="#h3">H3</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="#h2">H2</a></li>
<li><a href="#h3">H3</a></li>
<li><a href="#h3">H3</a></li>
</ul>
</nav>`, qt.Commentf(got))
@@ -158,25 +147,159 @@ func TestTocMissingParent(t *testing.T) {
got = string(tocHTML)
c.Assert(got, qt.Equals, `<nav id="TableOfContents">
<ol>
<li>
<ol>
<li><a href="#h2">H2</a></li>
</ol>
</li>
<li>
<ol>
<li>
<ol>
<li><a href="#h3">H3</a></li>
<li><a href="#h3">H3</a></li>
</ol>
</li>
</ol>
</li>
<li><a href="#h2">H2</a></li>
<li><a href="#h3">H3</a></li>
<li><a href="#h3">H3</a></li>
</ol>
</nav>`, qt.Commentf(got))
}
func TestTocMissingIntermediateLevels(t *testing.T) {
c := qt.New(t)
type item struct {
title string
id string
row int
level int
}
for _, test := range []struct {
name string
items []item
expected string
}{
{
name: "h2 to h4",
items: []item{
{title: "H2", id: "h2", level: 1},
{title: "H4", id: "h4", level: 3},
},
expected: `<nav id="TableOfContents">
<ul>
<li><a href="#h2">H2</a>
<ul>
<li><a href="#h4">H4</a></li>
</ul>
</li>
</ul>
</nav>`,
},
{
name: "h2 to h5",
items: []item{
{title: "H2", id: "h2", level: 1},
{title: "H5", id: "h5", level: 4},
},
expected: `<nav id="TableOfContents">
<ul>
<li><a href="#h2">H2</a>
<ul>
<li><a href="#h5">H5</a></li>
</ul>
</li>
</ul>
</nav>`,
},
{
name: "h2 to h6",
items: []item{
{title: "H2", id: "h2", level: 1},
{title: "H6", id: "h6", level: 5},
},
expected: `<nav id="TableOfContents">
<ul>
<li><a href="#h2">H2</a>
<ul>
<li><a href="#h6">H6</a></li>
</ul>
</li>
</ul>
</nav>`,
},
{
name: "h3 to h5",
items: []item{
{title: "H3", id: "h3", level: 2},
{title: "H5", id: "h5", level: 4},
},
expected: `<nav id="TableOfContents">
<ul>
<li><a href="#h3">H3</a>
<ul>
<li><a href="#h5">H5</a></li>
</ul>
</li>
</ul>
</nav>`,
},
{
name: "starts at h4",
items: []item{
{title: "H4", id: "h4", level: 3},
},
expected: `<nav id="TableOfContents">
<ul>
<li><a href="#h4">H4</a></li>
</ul>
</nav>`,
},
{
name: "starts at h6",
items: []item{
{title: "H6", id: "h6", level: 5},
},
expected: `<nav id="TableOfContents">
<ul>
<li><a href="#h6">H6</a></li>
</ul>
</nav>`,
},
} {
c.Run(test.name, func(c *qt.C) {
toc := &Fragments{}
for _, item := range test.items {
toc.addAt(&Heading{Title: item.title, ID: item.id}, item.row, item.level)
}
tocHTML, err := toc.ToHTML(2, -1, false)
c.Assert(err, qt.IsNil)
got := string(tocHTML)
c.Assert(got, qt.Equals, test.expected, qt.Commentf(got))
c.Assert(got, qt.Not(qt.Contains), "<li>\n")
c.Assert(hasListItemWithoutAnchor(got), qt.Equals, false)
})
}
toc := &Fragments{}
toc.addAt(&Heading{Title: "H2", ID: "h2"}, 0, 1)
toc.addAt(&Heading{Title: "H4", ID: "h4"}, 0, 3)
tocHTML, err := toc.ToHTML(2, 3, false)
c.Assert(err, qt.IsNil)
got := string(tocHTML)
c.Assert(got, qt.Equals, `<nav id="TableOfContents">
<ul>
<li><a href="#h2">H2</a></li>
</ul>
</nav>`, qt.Commentf(got))
c.Assert(got, qt.Not(qt.Contains), "<li>\n")
c.Assert(hasListItemWithoutAnchor(got), qt.Equals, false)
}
func hasListItemWithoutAnchor(s string) bool {
for {
i := strings.Index(s, "<li>")
if i == -1 {
return false
}
s = s[i+len("<li>"):]
if !strings.HasPrefix(strings.TrimLeft(s, " \n\t\r"), "<a ") {
return true
}
}
}
func TestTocMisc(t *testing.T) {
c := qt.New(t)