diff options
-rw-r--r-- | changelogs/unreleased/fix_wiki_toc_indent.yml | 5 | ||||
-rw-r--r-- | lib/banzai/filter/table_of_contents_filter.rb | 47 | ||||
-rw-r--r-- | spec/lib/banzai/filter/table_of_contents_filter_spec.rb | 44 |
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 |