Skip to content
Open
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
17 changes: 17 additions & 0 deletions apps/core/migrations/0012_remove_contentpage_table_of_contents.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 5.2.9 on 2026-01-15 16:55

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('core', '0011_alter_footercontent_locale_alter_footeritem_locale'),
]

operations = [
migrations.RemoveField(
model_name='contentpage',
name='table_of_contents',
),
]
50 changes: 43 additions & 7 deletions apps/core/models/content.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import json

from bs4 import BeautifulSoup
from django.db import models
from django.http import HttpResponse
from django.template import Context, Template
from django.utils.text import slugify
Expand All @@ -20,14 +19,52 @@
def create_table_of_contents(body):
template = Template("{% load wagtailcore_tags %}{% include_block body %}")
content = template.render(Context({"body": body}))

soup = BeautifulSoup(content, "lxml")
headings = soup.select("h2,h3")
toc = ""
if headings:
toc += "<ul>"
# Track the current state of nesting
in_h2_li = False
in_nested_ul = False

for heading in headings:
anchor = heading.attrs.get("id", slugify(heading.text))
toc += f'<li><a href="#{anchor}">{heading.text}</a></li>'

if heading.name == "h2":
# If we were in a nested ul (under a previous h2), close it
if in_nested_ul:
toc += "</ul>"
in_nested_ul = False

# If we were in an h2 li, close it
if in_h2_li:
toc += "</li>"

# Start new h2 li
toc += f'<li><a href="#{anchor}">{heading.text}</a>'
in_h2_li = True

elif heading.name == "h3":
# If this H3 is the very first thing or we are strictly compliant,
# it should be inside an LI.
# If we are inside an h2_li, we can open a nested UL.
if in_h2_li:
if not in_nested_ul:
toc += "<ul>"
in_nested_ul = True
toc += f'<li><a href="#{anchor}">{heading.text}</a></li>'
else:
# Fallback for H3 at top level (orphaned)
toc += f'<li><a href="#{anchor}">{heading.text}</a></li>'

# Cleanup at the end
if in_nested_ul:
toc += "</ul>"
if in_h2_li:
toc += "</li>"

toc += "</ul>"
return toc

Expand All @@ -37,7 +74,10 @@ class ContentPage(MarkdownRouteMixin, Page):
subpage_types = ["core.ContentPage"]

body = StreamField(CONTENT_BLOCKS)
table_of_contents = models.TextField(blank=True)

@property
def table_of_contents(self):
return create_table_of_contents(self.body)
Comment on lines +78 to +80
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Converting table_of_contents from a stored field to a property means the ToC will be computed on every access. This involves rendering the StreamField body to HTML and parsing it with BeautifulSoup, which could have performance implications if the property is accessed multiple times per request or if pages have large bodies. Consider adding caching (e.g., using @cached_property) to avoid recomputing the ToC multiple times during a single request.

Copilot uses AI. Check for mistakes.

content_panels = [
AITitleFieldPanel("title"),
Expand Down Expand Up @@ -77,7 +117,3 @@ def serve(self, request, *args, **kwargs):
return HttpResponse(json.dumps(data))
else:
return super().serve(request, *args, **kwargs)

def save_revision(self, *args, **kwargs):
self.table_of_contents = create_table_of_contents(self.body)
return super().save_revision(*args, **kwargs)
67 changes: 57 additions & 10 deletions apps/core/tests/test_content_page.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
from django.test import TestCase

from apps.core.factories import ContentPageFactory
from apps.core.models.content import create_table_of_contents


class TestContentPage(TestCase):
def setUp(self):
self.content_page = ContentPageFactory()

def test_can_create_page(self):
self.assertIsNotNone(self.content_page)
self.assertIsNotNone(self.content_page.id)

def test_table_of_contents_property_exists(self):
toc = self.content_page.table_of_contents
self.assertIsInstance(toc, str)


class TableOfContentsTest(TestCase):
def test_create_table_of_contents_empty(self):
toc = create_table_of_contents("")
self.assertEqual(toc, "")

def test_create_table_of_contents_no_id(self):
# If there's no id, generate it by slugifying the text.
self.assertEqual(self.content_page.table_of_contents, "")
self.content_page.body = '[{"type": "text", "value": "<h2>Foo bar</h2>"}]'
self.content_page.save_revision()
body_html = "<h2>Foo bar</h2>"
toc = create_table_of_contents(body_html)
self.assertEqual(
self.content_page.table_of_contents,
toc,
'<ul><li><a href="#foo-bar">Foo bar</a></li></ul>',
)

Expand All @@ -22,12 +36,45 @@ def test_create_table_of_contents_existing_id(self):
# translating with wagtail-localize, so the id isn't updated.
# If there's an existing id, make sure to use that instead so the link
# still works.
self.assertEqual(self.content_page.table_of_contents, "")
self.content_page.body = (
'[{"type": "text", "value": "<h2 id=\\"something\\">ekkie</h2>"}]'
)
self.content_page.save_revision()
body_html = '<h2 id="something">ekkie</h2>'
toc = create_table_of_contents(body_html)
self.assertEqual(
self.content_page.table_of_contents,
toc,
'<ul><li><a href="#something">ekkie</a></li></ul>',
)

def test_create_table_of_contents_simple(self):
body_html = """
<h2>Introduction</h2>
<p>Some text</p>
<h3>Details</h3>
<p>More text</p>
<h2>Conclusion</h2>
"""
toc = create_table_of_contents(body_html)

self.assertIn('<li><a href="#introduction">Introduction</a>', toc)
self.assertIn('<ul><li><a href="#details">Details</a></li></ul>', toc)
self.assertIn('<li><a href="#conclusion">Conclusion</a>', toc)
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test expects 'Conclusion' to appear without a closing </li> tag, which seems incomplete. Looking at the ToC generation logic, when an h2 is the last heading, the closing </li> tag is added in the cleanup section (line 66). The assertion should verify the complete structure including the closing tag, or the test should check the full expected HTML to ensure proper tag closure.

Copilot uses AI. Check for mistakes.

def test_create_table_of_contents_nested(self):
body_html = """
<h2>First</h2>
<h3>Nested 1</h3>
<h3>Nested 2</h3>
<h2>Second</h2>
"""
toc = create_table_of_contents(body_html)

expected_part = '<li><a href="#first">First</a><ul><li><a href="#nested-1">Nested 1</a></li><li><a href="#nested-2">Nested 2</a></li></ul></li>'
self.assertIn(expected_part, toc)

def test_create_table_of_contents_orphaned_h3(self):
body_html = """
<h3>Orphaned</h3>
<h2>Main</h2>
"""
toc = create_table_of_contents(body_html)

self.assertIn('<li><a href="#orphaned">Orphaned</a></li>', toc)
self.assertIn('<li><a href="#main">Main</a>', toc)
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test expects 'Main' to appear without a closing </li> tag, which seems incomplete. This assertion should verify the complete structure including closing tags. Consider using assertEqual with the full expected HTML output rather than assertIn with partial HTML to ensure the entire structure is correct.

Copilot uses AI. Check for mistakes.
Comment on lines +72 to +80
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test for orphaned h3 headings should verify the complete HTML structure to ensure all tags are properly closed. Consider adding a full assertEqual check for the expected output: '<ul><li><a href="#orphaned">Orphaned</a></li><li><a href="#main">Main</a></li></ul>' to ensure the ToC is well-formed HTML.

Copilot uses AI. Check for mistakes.
23 changes: 13 additions & 10 deletions apps/frontend/static_src/scss/components/toc.scss
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,29 @@
list-style-type: none;
padding: 0;
margin: 0;

ul {
margin-left: $gutter + 10px;
}
}

li {
a {
display: flex;
align-items: center;
text-decoration: none;
font-weight: 700;
margin-bottom: ($gutter * 0.5);

&::before {
content: '';
display: inline-block;
height: 20px;
width: 20px;
margin-inline-end: 13px;
height: $gutter;
width: $gutter;
min-width: $gutter;
margin-inline-end: 10px;
// arrow in circle fa icon
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj48cGF0aCBmaWxsPSIjMDA3RDdFIiBkPSJNMjgwLjIgMTUwLjJjLTcuMS02LjQtMTguMS04LTI1LjktNC4xcy0xNS4yIDEyLjQtMTUuMiAyMWwuMDAyIDU2TDE1MiAyMjRjLTEzLjIgMC0yNCAxMC44LTI0IDI0djE2YzAgMTMuMyAxMC44IDI0IDI0IDI0bDg4LS45djU2YzAgOS41MzEgNS42NTYgMTguMTYgMTQuMzggMjJhMjQuMDI1IDI0LjAyNSAwIDAgMCAyNS45MS00LjM3NWw5Ni04OC43NUMzODEuMiAyNjguMyAzODQgMjYxLjkgMzg0IDI1NS4yYy0uMzEzLTcuNzgxLTIuODc1LTEzLjI1LTcuODQ0LTE3Ljc1TDI4MC4yIDE1MC4yek0yNTYgMEMxMTQuNiAwIDAgMTE0LjYgMCAyNTZzMTE0LjYgMjU2IDI1NiAyNTYgMjU2LTExNC42IDI1Ni0yNTZTMzk3LjQgMCAyNTYgMHptMCA0NjRjLTExNC43IDAtMjA4LTkzLjMxLTIwOC0yMDhTMTQxLjMgNDggMjU2IDQ4czIwOCA5My4zMSAyMDggMjA4LTkzLjMgMjA4LTIwOCAyMDh6Ii8+PC9zdmc+');
}

a {
text-decoration: none;
font-weight: 700;
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath fill='black' d='M280.2 150.2c-7.1-6.4-18.1-8-25.9-4.1s-15.2 12.4-15.2 21l.002 56L152 224c-13.2 0-24 10.8-24 24v16c0 13.3 10.8 24 24 24l88-.9v56c0 9.531 5.656 18.16 14.38 22a24.025 24.025 0 0 0 25.91-4.375l96-88.75C381.2 268.3 384 261.9 384 255.2c-.313-7.781-2.875-13.25-7.844-17.75L280.2 150.2zM256 0C114.6 0 0 114.6 0 256s114.6 256 256 256 256-114.6 256-256S397.4 0 256 0zm0 464c-114.7 0-208-93.31-208-208S141.3 48 256 48s208 93.31 208 208-93.3 208-208 208z'/%3E%3C/svg%3E");
background-color: currentColor;
}
}
}
Expand Down
Loading