summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--changelogs/unreleased/fix_wiki_toc_indent.yml5
-rw-r--r--lib/banzai/filter/table_of_contents_filter.rb47
-rw-r--r--spec/lib/banzai/filter/table_of_contents_filter_spec.rb44
3 files changed, 86 insertions, 10 deletions
diff --git a/changelogs/unreleased/fix_wiki_toc_indent.yml b/changelogs/unreleased/fix_wiki_toc_indent.yml
new file mode 100644
index 00000000000..60da2e455f2
--- /dev/null
+++ b/changelogs/unreleased/fix_wiki_toc_indent.yml
@@ -0,0 +1,5 @@
+---
+title: Wiki table of contents are now properly nested to reflect header level
+merge_request: 13650
+author: Akihiro Nakashima
+type: fixed
diff --git a/lib/banzai/filter/table_of_contents_filter.rb b/lib/banzai/filter/table_of_contents_filter.rb
index 8e7084f2543..8cb860c5a92 100644
--- a/lib/banzai/filter/table_of_contents_filter.rb
+++ b/lib/banzai/filter/table_of_contents_filter.rb
@@ -15,6 +15,7 @@ module Banzai
# `li` child elements.
class TableOfContentsFilter < HTML::Pipeline::Filter
PUNCTUATION_REGEXP = /[^\p{Word}\- ]/u
+ HeaderNode = Struct.new(:level, :href, :text, :children, :parent)
def call
return doc if context[:no_header_anchors]
@@ -23,6 +24,10 @@ module Banzai
headers = Hash.new(0)
+ # root node of header-tree
+ header_root = HeaderNode.new(0, nil, nil, [], nil)
+ current_header = header_root
+
doc.css('h1, h2, h3, h4, h5, h6').each do |node|
text = node.text
@@ -38,12 +43,38 @@ module Banzai
# namespace detection will be automatically handled via javascript (see issue #22781)
namespace = "user-content-"
href = "#{id}#{uniq}"
- push_toc(href, text)
+
+ level = node.name[1].to_i # get this header level
+ if level == current_header.level
+ # same as previous
+ parent = current_header.parent
+ elsif level > current_header.level
+ # larger (weaker) than previous
+ parent = current_header
+ else
+ # smaller (stronger) than previous
+ # search parent
+ parent = current_header
+ parent = parent.parent while parent.level >= level
+ end
+
+ # create header-node and push as child
+ header_node = HeaderNode.new(level, href, text, [], parent)
+ parent.children.push(header_node)
+ current_header = header_node
+
header_content.add_previous_sibling(anchor_tag("#{namespace}#{href}", href))
end
end
- result[:toc] = %Q{<ul class="section-nav">\n#{result[:toc]}</ul>} unless result[:toc].empty?
+ # extract header-tree
+ if header_root.children.length > 0
+ result[:toc] = %Q{<ul class="section-nav">\n}
+ header_root.children.each do |child|
+ push_toc(child)
+ end
+ result[:toc] << '</ul>'
+ end
doc
end
@@ -54,8 +85,16 @@ module Banzai
%Q{<a id="#{id}" class="anchor" href="##{href}" aria-hidden="true"></a>}
end
- def push_toc(href, text)
- result[:toc] << %Q{<li><a href="##{href}">#{text}</a></li>\n}
+ def push_toc(header_node)
+ result[:toc] << %Q{<li><a href="##{header_node.href}">#{header_node.text}</a>}
+ if header_node.children.length > 0
+ result[:toc] << '<ul>'
+ header_node.children.each do |child|
+ push_toc(child)
+ end
+ result[:toc] << '</ul>'
+ end
+ result[:toc] << '</li>\n'
end
end
end
diff --git a/spec/lib/banzai/filter/table_of_contents_filter_spec.rb b/spec/lib/banzai/filter/table_of_contents_filter_spec.rb
index ff6b19459bb..f28022f61b7 100644
--- a/spec/lib/banzai/filter/table_of_contents_filter_spec.rb
+++ b/spec/lib/banzai/filter/table_of_contents_filter_spec.rb
@@ -78,7 +78,7 @@ describe Banzai::Filter::TableOfContentsFilter do
HTML::Pipeline.new([described_class]).call(html)
end
- let(:results) { result(header(1, 'Header 1') + header(2, 'Header 2')) }
+ let(:results) { result(header(1, 'Header 1') + header(2, 'Header 1-1') + header(3, 'Header 1-1-1') + header(2, 'Header 1-2') + header(1, 'Header 2') + header(2, 'Header 2-1')) }
let(:doc) { Nokogiri::XML::DocumentFragment.parse(results[:toc]) }
it 'is contained within a `ul` element' do
@@ -87,14 +87,46 @@ describe Banzai::Filter::TableOfContentsFilter do
end
it 'contains an `li` element for each header' do
- expect(doc.css('li').length).to eq 2
+ expect(doc.css('li').length).to eq 6
links = doc.css('li a')
- expect(links.first.attr('href')).to eq '#header-1'
- expect(links.first.text).to eq 'Header 1'
- expect(links.last.attr('href')).to eq '#header-2'
- expect(links.last.text).to eq 'Header 2'
+ expect(links[0].attr('href')).to eq '#header-1'
+ expect(links[0].text).to eq 'Header 1'
+ expect(links[1].attr('href')).to eq '#header-1-1'
+ expect(links[1].text).to eq 'Header 1-1'
+ expect(links[2].attr('href')).to eq '#header-1-1-1'
+ expect(links[2].text).to eq 'Header 1-1-1'
+ expect(links[3].attr('href')).to eq '#header-1-2'
+ expect(links[3].text).to eq 'Header 1-2'
+ expect(links[4].attr('href')).to eq '#header-2'
+ expect(links[4].text).to eq 'Header 2'
+ expect(links[5].attr('href')).to eq '#header-2-1'
+ expect(links[5].text).to eq 'Header 2-1'
+ end
+
+ it 'keeps list levels regarding header levels' do
+ items = doc.css('li')
+
+ # Header 1
+ expect(items[0].ancestors.any? {|node| node.name == 'li'}).to eq false
+
+ # Header 1-1
+ expect(items[1].ancestors.include?(items[0])).to eq true
+
+ # Header 1-1-1
+ expect(items[2].ancestors.include?(items[0])).to eq true
+ expect(items[2].ancestors.include?(items[1])).to eq true
+
+ # Header 1-2
+ expect(items[3].ancestors.include?(items[0])).to eq true
+ expect(items[3].ancestors.include?(items[1])).to eq false
+
+ # Header 2
+ expect(items[4].ancestors.any? {|node| node.name == 'li'}).to eq false
+
+ # Header 2-1
+ expect(items[5].ancestors.include?(items[4])).to eq true
end
end
end