Skip to content

Commit bc37921

Browse files
authored
Added table_of_content method (#55)
* Added `table_of_content` method (aliased to `toc` and `table_of_contents`) This returns an array of objects (`Perron::Resource::TableOfContent::Item id="", text="", level=n, children=[]`) of all headings in a resource's content. It expects an `id` per heading, or, like commonmarker does, a nested `a` element with an `id`. * Standard fix
1 parent 6117bc8 commit bc37921

File tree

6 files changed

+214
-0
lines changed

6 files changed

+214
-0
lines changed

lib/perron/resource.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
require "perron/resource/renderer"
1010
require "perron/resource/slug"
1111
require "perron/resource/separator"
12+
require "perron/resource/table_of_content"
1213

1314
module Perron
1415
class Resource
@@ -18,6 +19,7 @@ class Resource
1819
include Perron::Resource::Core
1920
include Perron::Resource::ClassMethods
2021
include Perron::Resource::Publishable
22+
include Perron::Resource::TableOfContent
2123

2224
attr_reader :file_path, :id
2325

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# frozen_string_literal: true
2+
3+
module Perron
4+
class Resource
5+
module TableOfContent
6+
extend ActiveSupport::Concern
7+
8+
def table_of_content(levels: %w[h1 h2 h3 h4 h5 h6])
9+
return [] if content.blank? || metadata.toc == false
10+
11+
document = Nokogiri::HTML::DocumentFragment.parse(Markdown.render(content))
12+
headings = extract_headings from: document, levels: levels.join(", ")
13+
14+
Builder.new.build(headings)
15+
end
16+
alias_method :table_of_contents, :table_of_content
17+
alias_method :toc, :table_of_content
18+
19+
private
20+
21+
Item = ::Data.define(:id, :text, :level, :children)
22+
23+
def extract_headings(from:, levels:)
24+
from.css(levels).each_with_object([]) do |heading, headings|
25+
heading.tap do |node|
26+
heading_text = node.text.strip
27+
id = node["id"] || node.at("a")&.[]("id")
28+
29+
next if heading_text.empty? || id.blank?
30+
31+
headings << Item.new(
32+
id: id,
33+
text: heading_text,
34+
level: node.name[1..].to_i,
35+
children: []
36+
)
37+
end
38+
end
39+
end
40+
41+
class Builder
42+
def build(headings)
43+
parents = {0 => {children: []}}
44+
45+
headings.each_with_object(parents[0][:children]) do |heading, _|
46+
parents.delete_if { |level, _| level >= heading.level }
47+
48+
parent = parents[parents.keys.select { it < heading.level }.max || 0]
49+
50+
(parent.is_a?(Hash) ? parent[:children] : parent.children) << heading
51+
52+
parents[heading.level] = heading
53+
end
54+
end
55+
end
56+
end
57+
end
58+
end
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
toc: false
3+
---
4+
5+
<h1 id="main-heading">Main Heading</h1>
6+
7+
Some content here
8+
9+
10+
<h2 id="section-one">Section One</h2>
11+
12+
Content for section one
13+
14+
15+
<h3 id="subsection">Subsection</h3>
16+
17+
More detailed content
18+
19+
20+
<h2 id="section-two">Section Two</h2>
21+
22+
Final section content
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<h1>
2+
<a href="#main-heading" aria-hidden="true" class="anchor" id="main-heading"></a>
3+
Main Heading
4+
</h1>
5+
6+
Some content here
7+
8+
9+
<h2 id="section-one">
10+
<a href="#section-one" aria-hidden="true" class="anchor" id="section-one"></a>
11+
Section One
12+
</h2>
13+
14+
Content for section one
15+
16+
17+
<h3 id="subsection">
18+
<a href="#subsection" aria-hidden="true" class="anchor" id="subsection"></a>
19+
Subsection
20+
</h3>
21+
22+
More detailed content
23+
24+
25+
<h2 id="section-two">
26+
<a href="#section-two" aria-hidden="true" class="anchor" id="section-two"></a>
27+
Section Two
28+
</h2>
29+
30+
Final section content
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<h1 id="main-heading">Main Heading</h1>
2+
3+
Some content here
4+
5+
6+
<h2 id="section-one">Section One</h2>
7+
8+
Content for section one
9+
10+
11+
<h3 id="subsection">Subsection</h3>
12+
13+
More detailed content
14+
15+
16+
<h2 id="section-two">Section Two</h2>
17+
18+
Final section content
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
require "test_helper"
2+
3+
class Perron::Resource::TableOfContentTest < ActiveSupport::TestCase
4+
setup do
5+
page_path = "test/dummy/app/content/pages/about.md"
6+
toc_resource_path = "test/dummy/app/content/other/toc-resource.html"
7+
toc_commonmarker_path = "test/dummy/app/content/other/toc-commonmarker.html"
8+
exclude_toc_resource_path = "test/dummy/app/content/other/exclude-toc-resource.html"
9+
10+
@page = Content::Page.new(page_path)
11+
@toc_resource = Content::Page.new(toc_resource_path)
12+
@toc_commonmarker = Content::Page.new(toc_commonmarker_path)
13+
@exclude_toc_resource = Content::Page.new(exclude_toc_resource_path)
14+
end
15+
16+
test "table_of_content returns empty array" do
17+
toc = @page.table_of_content
18+
19+
assert_equal [], toc
20+
end
21+
22+
test "table_of_content returns empty array when `toc: false`" do
23+
toc = @exclude_toc_resource.table_of_content
24+
25+
assert_equal [], toc
26+
end
27+
28+
test "table_of_content returns headings structure" do
29+
toc = @toc_resource.table_of_content
30+
31+
assert_not_nil toc
32+
assert_equal 1, toc.size
33+
34+
h1 = toc.first
35+
assert_equal "main-heading", h1.id
36+
assert_equal "Main Heading", h1.text
37+
assert_equal 1, h1.level
38+
assert_equal 2, h1.children.size
39+
40+
h2_one = h1.children.first
41+
assert_equal "section-one", h2_one.id
42+
assert_equal "Section One", h2_one.text
43+
assert_equal 2, h2_one.level
44+
assert_equal 1, h2_one.children.size
45+
46+
h2_two = h1.children.last
47+
assert_equal "section-two", h2_two.id
48+
assert_equal "Section Two", h2_two.text
49+
assert_equal 2, h2_two.level
50+
assert_equal 0, h2_two.children.size
51+
52+
h3 = h2_one.children.first
53+
assert_equal "subsection", h3.id
54+
assert_equal "Subsection", h3.text
55+
assert_equal 3, h3.level
56+
assert_equal 0, h3.children.size
57+
end
58+
59+
test "table_of_content returns headings structure for parsed commonmarker markdown" do
60+
assert_not_nil @toc_commonmarker.table_of_content
61+
end
62+
63+
test "can limit heading levels" do
64+
toc_h1_only = @toc_resource.table_of_content(levels: %w[h1])
65+
assert_equal 1, toc_h1_only.size
66+
assert_equal 0, toc_h1_only.first.children.size
67+
68+
toc_h1_h2 = @toc_resource.table_of_content(levels: %w[h1 h2])
69+
assert_equal 1, toc_h1_h2.size
70+
assert_equal 2, toc_h1_h2.first.children.size
71+
72+
toc_h1_h2.first.children.each do |h2|
73+
assert_equal 0, h2.children.size
74+
end
75+
end
76+
77+
test "table_of_contents is an alias for table_of_content" do
78+
assert_equal @toc_resource.table_of_content, @toc_resource.table_of_contents
79+
end
80+
81+
test "toc is an alias for table_of_content" do
82+
assert_equal @toc_resource.table_of_content, @toc_resource.toc
83+
end
84+
end

0 commit comments

Comments
 (0)