Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 35 additions & 8 deletions apps/core/models/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,41 @@ def create_table_of_contents(body):
template = Template("{% load wagtailcore_tags %}{% include_block body %}")
content = template.render(Context({"body": body}))
soup = BeautifulSoup(content, "html.parser")
headings = soup.select("h2,h3")
toc = ""
if headings:
toc += "<ul>"
for heading in headings:
anchor = heading.attrs.get("id", slugify(heading.text))
toc += format_html('<li><a href="#{}">{}</a></li>', anchor, heading.text)
toc += "</ul>"
headings = soup.select("h2,h3,h4")
if not headings:
return ""

# Nest h3 within h2, and h4 within h3. The first heading is always the
# top level, and a heading can only be one level deeper than the previous
# one, so content skipping levels (e.g. h2 to h4) nests a single level
# rather than leaving an empty list item.
toc = "<ul>"
open_items = [] # Heading levels of the <li> elements still open, deepest last.
for heading in headings:
level = int(heading.name[1])
if open_items:
if level > open_items[-1]:
# Deeper heading: nest a new list within the previous item.
open_items.append(level)
toc += "<ul>"
else:
# Same or shallower heading: close the lists of the deeper
# items, up to the closest ancestor shallower than this one.
while len(open_items) > 1 and level <= open_items[-2]:
open_items.pop()
toc += "</li></ul>"
toc += "</li>"
# The new item replaces the current one, so record its level
# for the next heading’s comparison.
open_items[-1] = level
else:
open_items.append(level)
anchor = heading.attrs.get("id", slugify(heading.text))
toc += format_html('<li><a href="#{}">{}</a>', anchor, heading.text)
toc += "</li>"
for _ in open_items[1:]:
toc += "</ul></li>"
toc += "</ul>"
return toc


Expand Down
2 changes: 1 addition & 1 deletion apps/core/templatetags/core_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def header(context):

pages = (
Page.objects.descendant_of(home)
.filter(depth__gt=2, depth__lte=4)
.filter(depth__gt=2, depth__lte=5)
.live()
.public()
.in_menu()
Expand Down
81 changes: 81 additions & 0 deletions apps/core/tests/test_content_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,87 @@ def test_create_table_of_contents_existing_id(self):
'<ul><li><a href="#something">ekkie</a></li></ul>',
)

def test_create_table_of_contents_nests_h3_and_h4(self):
self.content_page.body = json.dumps(
[
{
"type": "text",
"value": (
"<h2>One</h2><h3>One one</h3><h4>One one one</h4>"
"<h3>One two</h3><h2>Two</h2>"
),
}
]
)

self.assertEqual(
self.content_page.table_of_contents,
'<ul><li><a href="#one">One</a>'
'<ul><li><a href="#one-one">One one</a>'
'<ul><li><a href="#one-one-one">One one one</a></li></ul></li>'
'<li><a href="#one-two">One two</a></li></ul></li>'
'<li><a href="#two">Two</a></li></ul>',
)

def test_create_table_of_contents_skipped_heading_level(self):
# A heading can only nest one level deeper than the previous one, so
# content skipping levels (h2 to h4) still gets a well-formed list.
self.content_page.body = json.dumps(
[{"type": "text", "value": "<h2>One</h2><h4>Deep</h4><h2>Two</h2>"}]
)

self.assertEqual(
self.content_page.table_of_contents,
'<ul><li><a href="#one">One</a>'
'<ul><li><a href="#deep">Deep</a></li></ul></li>'
'<li><a href="#two">Two</a></li></ul>',
)

def test_create_table_of_contents_without_h2(self):
# The first heading is the top level, whatever it is.
self.content_page.body = json.dumps(
[{"type": "text", "value": "<h3>One</h3><h3>Two</h3>"}]
)

self.assertEqual(
self.content_page.table_of_contents,
'<ul><li><a href="#one">One</a></li><li><a href="#two">Two</a></li></ul>',
)

def test_create_table_of_contents_heading_back_to_skipped_level(self):
# h2 → h4 → h3: the h3 is a sibling of the h4 (both children of the
# h2), and a following h4 nests under the h3.
self.content_page.body = json.dumps(
[
{
"type": "text",
"value": "<h2>One</h2><h4>Two</h4><h3>Three</h3><h4>Four</h4>",
}
]
)

self.assertEqual(
self.content_page.table_of_contents,
'<ul><li><a href="#one">One</a>'
'<ul><li><a href="#two">Two</a></li>'
'<li><a href="#three">Three</a>'
'<ul><li><a href="#four">Four</a></li></ul></li></ul></li></ul>',
)

def test_create_table_of_contents_shallower_heading_mid_page(self):
# h3 → h2 → h3: the h2 becomes a top-level item, and the following
# h3 nests under it rather than staying flat.
self.content_page.body = json.dumps(
[{"type": "text", "value": "<h3>One</h3><h2>Two</h2><h3>Three</h3>"}]
)

self.assertEqual(
self.content_page.table_of_contents,
'<ul><li><a href="#one">One</a></li>'
'<li><a href="#two">Two</a>'
'<ul><li><a href="#three">Three</a></li></ul></li></ul>',
)

def test_escapes_heading_text(self):
self.content_page.body = json.dumps(
[
Expand Down
6 changes: 5 additions & 1 deletion apps/core/tests/test_header.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ def setUp(self):
self.home.add_child(instance=self.a)
self.ab = Page(title="ab", slug="ab", show_in_menus=True)
self.a.add_child(instance=self.ab)
self.aba = Page(title="aba", slug="aba", show_in_menus=True)
self.ab.add_child(instance=self.aba)
self.ac = Page(title="ac", slug="ac", show_in_menus=True)
self.a.add_child(instance=self.ac)
self.b = Page(title="b", slug="b", show_in_menus=True)
Expand All @@ -25,14 +27,15 @@ def setUp(self):
def test_get_annotated_list_qs(self):
pages = (
Page.objects.descendant_of(self.home)
.filter(depth__gt=2, depth__lte=4)
.filter(depth__gt=2, depth__lte=5)
.live()
.in_menu()
)
result = Page.get_annotated_list_qs(pages)
expected = [
(self.a, {"close": [], "level": 0, "open": True}),
(self.ab, {"close": [], "level": 1, "open": True}),
(self.aba, {"close": [0], "level": 2, "open": True}),
(self.ac, {"close": [0], "level": 1, "open": False}),
(self.b, {"close": [0], "level": 0, "open": False}),
]
Expand All @@ -47,6 +50,7 @@ def test_header_inclusion_tag(self):
self.assertIn('<nav class="primary-nav" data-mobile-menu>', result)
self.assertIn('href="/en/a/"', result)
self.assertIn('href="/en/a/ab/"', result)
self.assertIn('href="/en/a/ab/aba/"', result)
self.assertIn('href="/en/a/ac/"', result)
self.assertIn('href="/en/b/"', result)

Expand Down
1 change: 1 addition & 0 deletions apps/frontend/static_src/scss/components/navigation.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

.navigation {
margin-bottom: $gutter; // keep this as small as possible to prevent scroll in left col
padding-inline-start: ($gutter * 0.75); // small visual nesting of the page hierarchy
}

&__item {
Expand Down
13 changes: 13 additions & 0 deletions apps/frontend/static_src/scss/components/toc.scss
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,18 @@
margin: 0;
}

// h3 within h2, h4 within h3.
ul ul {
box-sizing: border-box;
flex-basis: 100%; // wrap onto its own line below the parent item’s link
margin-top: ($gutter * 0.25);
padding-inline-start: 33px; // 20px icon + 13px icon margin, to align with the parent item’s text
}

li {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: ($gutter * 0.5);

&::before {
Expand All @@ -37,6 +46,10 @@
}

a {
// Fill the row next to the arrow so long headings wrap their text
// beside it, rather than moving to their own flex line below.
flex: 1 1 0;
min-width: 0;
text-decoration: none;
font-weight: 700;
}
Expand Down
Loading