From e6d65928bbfe8470fc00866f889c25e4038561e1 Mon Sep 17 00:00:00 2001
From: nagasrisai <59650078+nagasrisai@users.noreply.github.com>
Date: Wed, 18 Mar 2026 17:45:49 +0530
Subject: [PATCH 1/6] feat: add pages templatetags package
---
apps/pages/templatetags/__init__.py | 0
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 apps/pages/templatetags/__init__.py
diff --git a/apps/pages/templatetags/__init__.py b/apps/pages/templatetags/__init__.py
new file mode 100644
index 000000000..e69de29bb
From 859471d1092a918aae8fcb959c742f5584b04191 Mon Sep 17 00:00:00 2001
From: nagasrisai <59650078+nagasrisai@users.noreply.github.com>
Date: Wed, 18 Mar 2026 17:45:57 +0530
Subject: [PATCH 2/6] feat: add add_heading_anchors template filter
Adds a custom template filter that post-processes rendered page HTML
to inject id attributes and pilcrow self-link anchors into h2-h4
headings. Duplicate slugs get a -N suffix to prevent id collisions.
Headings that already carry an id are left untouched.
Part of #2349
---
apps/pages/templatetags/page_tags.py | 69 ++++++++++++++++++++++++++++
1 file changed, 69 insertions(+)
create mode 100644 apps/pages/templatetags/page_tags.py
diff --git a/apps/pages/templatetags/page_tags.py b/apps/pages/templatetags/page_tags.py
new file mode 100644
index 000000000..176705d6b
--- /dev/null
+++ b/apps/pages/templatetags/page_tags.py
@@ -0,0 +1,69 @@
+"""Custom template tags and filters for the pages app."""
+
+import re
+
+from django import template
+from django.utils.safestring import mark_safe
+from django.utils.text import slugify
+
+register = template.Library()
+
+# Match h2–h4 elements; capture tag name, existing attributes, and inner HTML.
+# Using DOTALL so inner content can span multiple lines.
+_HEADING_RE = re.compile(r"<(h[2-4])([^>]*)>(.*?)\1>", re.IGNORECASE | re.DOTALL)
+
+
+@register.filter(is_safe=True)
+def add_heading_anchors(html):
+ """Add ``id`` attributes and self-link anchors to h2–h4 headings.
+
+ Given the rendered HTML of a CMS page, this filter finds every ``
``,
+ ````, and ```` element and:
+
+ 1. Derives a URL-safe ``id`` from the heading's plain text (via
+ :func:`django.utils.text.slugify`).
+ 2. Appends a ``-N`` suffix when the same slug appears more than once on
+ the page, preventing duplicate ``id`` values.
+ 3. Injects a pilcrow (¶) anchor *inside* the heading so visitors can
+ copy a direct link to any section.
+
+ The filter is safe to apply to any page — headings that already carry an
+ ``id`` attribute are left untouched, and headings whose text produces an
+ empty slug are also skipped.
+
+ Usage in a template::
+
+ {% load page_tags %}
+ {{ page.content|add_heading_anchors }}
+ """
+ seen_slugs: dict[str, int] = {}
+
+ def _replace(match: re.Match) -> str:
+ tag = match.group(1).lower()
+ attrs = match.group(2)
+ inner = match.group(3)
+
+ # Skip headings that already have an id attribute.
+ if re.search(r'\bid\s*=', attrs, re.IGNORECASE):
+ return match.group(0)
+
+ # Derive a slug from the plain text (strip any nested HTML tags).
+ plain_text = re.sub(r"<[^>]+>", "", inner).strip()
+ base_slug = slugify(plain_text)
+
+ if not base_slug:
+ return match.group(0)
+
+ # Deduplicate: first occurrence keeps the bare slug; subsequent
+ # occurrences become slug-2, slug-3, …
+ count = seen_slugs.get(base_slug, 0) + 1
+ seen_slugs[base_slug] = count
+ anchor_id = base_slug if count == 1 else f"{base_slug}-{count}"
+
+ link = (
+ f''
+ )
+ return f'<{tag} id="{anchor_id}"{attrs}>{inner} {link}{tag}>'
+
+ return mark_safe(_HEADING_RE.sub(_replace, str(html)))
From 47b1dca9999b096f5e774b173029c04edf3191c0 Mon Sep 17 00:00:00 2001
From: nagasrisai <59650078+nagasrisai@users.noreply.github.com>
Date: Wed, 18 Mar 2026 17:46:06 +0530
Subject: [PATCH 3/6] feat(template): apply add_heading_anchors to PSF page
content
Loads the new page_tags library and pipes page content through the
add_heading_anchors filter so that every h2-h4 in a PSF page (including
the board resolutions listing) gets a stable id attribute and a
pilcrow anchor link for direct linking.
Part of #2349
---
templates/psf/default.html | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/templates/psf/default.html b/templates/psf/default.html
index 10cf0377d..0ce1f37d0 100644
--- a/templates/psf/default.html
+++ b/templates/psf/default.html
@@ -3,6 +3,7 @@
{% extends "base.html" %}
{% load boxes %}
{% load banners %}
+{% load page_tags %}
{# TODO: Try to deduplicate this and templates/pages/default.html. #}
{% block page_title %}{{ page.title }} | Python Software Foundation{% endblock %}
@@ -52,7 +53,7 @@
{{ page.title }}
- {{ page.content }}
+ {{ page.content|add_heading_anchors }}
{% endblock content %}
@@ -71,3 +72,4 @@ {{ page.title }}
{% endblock left_sidebar %}
+
From f6969878365ec20554e81c15b1b91f7f2e154fb5 Mon Sep 17 00:00:00 2001
From: nagasrisai <59650078+nagasrisai@users.noreply.github.com>
Date: Wed, 18 Mar 2026 17:46:15 +0530
Subject: [PATCH 4/6] test: add tests for add_heading_anchors template filter
10 test cases covering: h2/h3/h4 processing, h1/h5 exclusion,
duplicate-slug deduplication, existing-id passthrough, nested HTML
stripping, non-heading passthrough, empty string, empty text, and
anchor placement inside the heading element.
---
apps/pages/tests/test_templatetags.py | 77 +++++++++++++++++++++++++++
1 file changed, 77 insertions(+)
create mode 100644 apps/pages/tests/test_templatetags.py
diff --git a/apps/pages/tests/test_templatetags.py b/apps/pages/tests/test_templatetags.py
new file mode 100644
index 000000000..e2aa64ad6
--- /dev/null
+++ b/apps/pages/tests/test_templatetags.py
@@ -0,0 +1,77 @@
+"""Tests for apps/pages/templatetags/page_tags.py."""
+
+from django.test import SimpleTestCase
+
+from apps.pages.templatetags.page_tags import add_heading_anchors
+
+
+class AddHeadingAnchorsFilterTests(SimpleTestCase):
+ """Tests for the ``add_heading_anchors`` template filter."""
+
+ def test_h2_gets_id_and_anchor_link(self):
+ """An h2 heading receives an id attribute and a pilcrow anchor link."""
+ html = "2023
"
+ result = add_heading_anchors(html)
+ self.assertIn('id="2023"', result)
+ self.assertIn('href="#2023"', result)
+ self.assertIn("¶", result)
+
+ def test_h3_and_h4_also_processed(self):
+ """h3 and h4 headings are also processed."""
+ for tag in ("h3", "h4"):
+ html = f"<{tag}>Section Title{tag}>"
+ result = add_heading_anchors(html)
+ self.assertIn('id="section-title"', result)
+ self.assertIn('href="#section-title"', result)
+
+ def test_h1_and_h5_are_not_changed(self):
+ """h1 and h5 headings are left untouched."""
+ for tag in ("h1", "h5"):
+ html = f"<{tag}>Title{tag}>"
+ result = add_heading_anchors(html)
+ self.assertNotIn("id=", result)
+ self.assertNotIn("href=", result)
+
+ def test_duplicate_headings_get_unique_ids(self):
+ """Duplicate heading text produces unique, numbered ids."""
+ html = "Board Resolution
Board Resolution
"
+ result = add_heading_anchors(html)
+ self.assertIn('id="board-resolution"', result)
+ self.assertIn('id="board-resolution-2"', result)
+
+ def test_heading_with_existing_id_is_unchanged(self):
+ """A heading that already has an id attribute is left as-is."""
+ html = 'My Section
'
+ result = add_heading_anchors(html)
+ self.assertIn('id="custom-id"', result)
+ # No extra anchor link should be injected.
+ self.assertNotIn("headerlink", result)
+
+ def test_heading_with_nested_html_tags(self):
+ """Plain text is extracted from headings that contain nested tags."""
+ html = "Nested Heading
"
+ result = add_heading_anchors(html)
+ self.assertIn('id="nested-heading"', result)
+
+ def test_non_heading_html_is_unchanged(self):
+ """Non-heading elements are passed through unmodified."""
+ html = "
Some paragraph
"
+ result = add_heading_anchors(html)
+ self.assertEqual(str(result), html)
+
+ def test_empty_string_returns_empty_string(self):
+ """Passing an empty string returns an empty string."""
+ self.assertEqual(str(add_heading_anchors("")), "")
+
+ def test_heading_with_empty_text_is_unchanged(self):
+ """A heading whose text slugifies to an empty string is left alone."""
+ html = "
"
+ result = add_heading_anchors(html)
+ self.assertNotIn("id=", result)
+
+ def test_anchor_link_is_inside_heading(self):
+ """The pilcrow anchor link appears inside the heading element."""
+ html = "Resolutions 2022
"
+ result = str(add_heading_anchors(html))
+ # The closing must come after the anchor link.
+ self.assertIn("¶", result)
From c2ba895144922daaba3f8cca803efd8136df7126 Mon Sep 17 00:00:00 2001
From: nagasrisai <59650078+nagasrisai@users.noreply.github.com>
Date: Fri, 20 Mar 2026 20:51:39 +0530
Subject: [PATCH 5/6] fix: extend anchors to h1 and reuse existing ids from RST
content
Two bugs fixed:
1. The regex only matched h2-h4, so RST-generated pages like the board
resolutions page (which use h1 section headings) received no anchors.
Extended to h1-h4.
2. Headings that already carry an id attribute (docutils/RST injects
these automatically on every section heading) were silently skipped.
The filter now reuses the existing id and injects the pilcrow link
using it, which is exactly what is needed for RST-sourced pages like
the bylaws and resolutions pages.
Also added idempotency guard so running the filter twice is safe.
---
apps/pages/templatetags/page_tags.py | 54 ++++++++++++++++++----------
1 file changed, 36 insertions(+), 18 deletions(-)
diff --git a/apps/pages/templatetags/page_tags.py b/apps/pages/templatetags/page_tags.py
index 176705d6b..21e27bf7c 100644
--- a/apps/pages/templatetags/page_tags.py
+++ b/apps/pages/templatetags/page_tags.py
@@ -8,28 +8,35 @@
register = template.Library()
-# Match h2–h4 elements; capture tag name, existing attributes, and inner HTML.
+# Match h1–h4 elements; capture tag name, existing attributes, and inner HTML.
# Using DOTALL so inner content can span multiple lines.
-_HEADING_RE = re.compile(r"<(h[2-4])([^>]*)>(.*?)\1>", re.IGNORECASE | re.DOTALL)
+_HEADING_RE = re.compile(r"<(h[1-4])([^>]*)>(.*?)\1>", re.IGNORECASE | re.DOTALL)
+
+# Extract the value of an existing id attribute, e.g. id="my-section".
+_EXISTING_ID_RE = re.compile(r'\bid\s*=\s*["\'](.*?)["\']', re.IGNORECASE)
@register.filter(is_safe=True)
def add_heading_anchors(html):
- """Add ``id`` attributes and self-link anchors to h2–h4 headings.
+ """Add self-link anchors to h1\u2013h4 headings.
+
+ Given the rendered HTML of a CMS page, this filter finds every ````,
+ ````, ````, and ```` element and injects a pilcrow (\u00b6)
+ anchor inside it so visitors can copy a direct link to any section.
- Given the rendered HTML of a CMS page, this filter finds every ````,
- ````, and ```` element and:
+ Two cases are handled:
- 1. Derives a URL-safe ``id`` from the heading's plain text (via
- :func:`django.utils.text.slugify`).
- 2. Appends a ``-N`` suffix when the same slug appears more than once on
- the page, preventing duplicate ``id`` values.
- 3. Injects a pilcrow (¶) anchor *inside* the heading so visitors can
- copy a direct link to any section.
+ * **Heading already has an ``id``** (common for RST-generated content where
+ docutils injects ids automatically): the existing id is reused as the
+ anchor target and a pilcrow link is appended. The heading is otherwise
+ left intact.
+ * **Heading has no ``id``**: a URL-safe id is derived from the heading's
+ plain text via :func:`django.utils.text.slugify`, a ``-N`` suffix is
+ appended for duplicates, and both the id and the pilcrow link are added.
- The filter is safe to apply to any page — headings that already carry an
- ``id`` attribute are left untouched, and headings whose text produces an
- empty slug are also skipped.
+ Headings whose text produces an empty slug *and* that carry no existing id
+ are left completely untouched. The filter is idempotent: headings that
+ already contain a ``headerlink`` anchor are skipped.
Usage in a template::
@@ -43,10 +50,21 @@ def _replace(match: re.Match) -> str:
attrs = match.group(2)
inner = match.group(3)
- # Skip headings that already have an id attribute.
- if re.search(r'\bid\s*=', attrs, re.IGNORECASE):
+ # Idempotency: skip headings that already have a pilcrow link.
+ if "headerlink" in inner:
return match.group(0)
+ # If the heading already carries an id (e.g. from RST/docutils),
+ # reuse it for the pilcrow link rather than skipping the heading.
+ existing = _EXISTING_ID_RE.search(attrs)
+ if existing:
+ anchor_id = existing.group(1)
+ link = (
+ f''
+ )
+ return f'<{tag}{attrs}>{inner} {link}{tag}>'
+
# Derive a slug from the plain text (strip any nested HTML tags).
plain_text = re.sub(r"<[^>]+>", "", inner).strip()
base_slug = slugify(plain_text)
@@ -55,14 +73,14 @@ def _replace(match: re.Match) -> str:
return match.group(0)
# Deduplicate: first occurrence keeps the bare slug; subsequent
- # occurrences become slug-2, slug-3, …
+ # occurrences become slug-2, slug-3, ...
count = seen_slugs.get(base_slug, 0) + 1
seen_slugs[base_slug] = count
anchor_id = base_slug if count == 1 else f"{base_slug}-{count}"
link = (
f''
+ f'title="Link to this section" aria-label="Link to this section">\u00b6'
)
return f'<{tag} id="{anchor_id}"{attrs}>{inner} {link}{tag}>'
From 2072e91110fc8c74e68de5c40867f10a96c15212 Mon Sep 17 00:00:00 2001
From: nagasrisai <59650078+nagasrisai@users.noreply.github.com>
Date: Fri, 20 Mar 2026 20:52:07 +0530
Subject: [PATCH 6/6] test: update templatetag tests for h1 support and
existing-id behaviour
Reflects two changes to the filter:
- h1 headings are now processed (not just h2-h4)
- headings with existing ids now get pilcrow links injected
New tests added: RST-generated headings, idempotency guard, h1 processing.
---
apps/pages/tests/test_templatetags.py | 52 ++++++++++++++++++---------
1 file changed, 36 insertions(+), 16 deletions(-)
diff --git a/apps/pages/tests/test_templatetags.py b/apps/pages/tests/test_templatetags.py
index e2aa64ad6..71501ee84 100644
--- a/apps/pages/tests/test_templatetags.py
+++ b/apps/pages/tests/test_templatetags.py
@@ -16,21 +16,20 @@ def test_h2_gets_id_and_anchor_link(self):
self.assertIn('href="#2023"', result)
self.assertIn("¶", result)
- def test_h3_and_h4_also_processed(self):
- """h3 and h4 headings are also processed."""
- for tag in ("h3", "h4"):
+ def test_h1_h3_h4_also_processed(self):
+ """h1, h3, and h4 headings are also processed."""
+ for tag in ("h1", "h3", "h4"):
html = f"<{tag}>Section Title{tag}>"
result = add_heading_anchors(html)
self.assertIn('id="section-title"', result)
self.assertIn('href="#section-title"', result)
- def test_h1_and_h5_are_not_changed(self):
- """h1 and h5 headings are left untouched."""
- for tag in ("h1", "h5"):
- html = f"<{tag}>Title{tag}>"
- result = add_heading_anchors(html)
- self.assertNotIn("id=", result)
- self.assertNotIn("href=", result)
+ def test_h5_is_not_changed(self):
+ """h5 headings are left untouched."""
+ html = "Title
"
+ result = add_heading_anchors(html)
+ self.assertNotIn("id=", result)
+ self.assertNotIn("href=", result)
def test_duplicate_headings_get_unique_ids(self):
"""Duplicate heading text produces unique, numbered ids."""
@@ -39,13 +38,35 @@ def test_duplicate_headings_get_unique_ids(self):
self.assertIn('id="board-resolution"', result)
self.assertIn('id="board-resolution-2"', result)
- def test_heading_with_existing_id_is_unchanged(self):
- """A heading that already has an id attribute is left as-is."""
+ def test_heading_with_existing_id_gets_pilcrow_link(self):
+ """A heading with an existing id (e.g. from RST/docutils) gets a pilcrow
+ link using that id, without the id being changed or duplicated."""
html = 'My Section
'
- result = add_heading_anchors(html)
+ result = str(add_heading_anchors(html))
+ # Original id is preserved and not duplicated.
self.assertIn('id="custom-id"', result)
- # No extra anchor link should be injected.
- self.assertNotIn("headerlink", result)
+ self.assertEqual(result.count('id="'), 1)
+ # Pilcrow link is injected using the existing id.
+ self.assertIn('href="#custom-id"', result)
+ self.assertIn("headerlink", result)
+
+ def test_rst_generated_headings_get_pilcrow_links(self):
+ """RST/docutils headings that already carry ids get pilcrow links added."""
+ html = (
+ 'Board Resolutions
'
+ 'Resolution 1: Budget
'
+ )
+ result = str(add_heading_anchors(html))
+ self.assertIn('href="#board-resolutions"', result)
+ self.assertIn('href="#resolution-1-budget"', result)
+ self.assertEqual(result.count("headerlink"), 2)
+
+ def test_filter_is_idempotent(self):
+ """Running the filter twice does not add duplicate pilcrow links."""
+ html = "Section
"
+ once = str(add_heading_anchors(html))
+ twice = str(add_heading_anchors(once))
+ self.assertEqual(once, twice)
def test_heading_with_nested_html_tags(self):
"""Plain text is extracted from headings that contain nested tags."""
@@ -73,5 +94,4 @@ def test_anchor_link_is_inside_heading(self):
"""The pilcrow anchor link appears inside the heading element."""
html = "Resolutions 2022
"
result = str(add_heading_anchors(html))
- # The closing
must come after the anchor link.
self.assertIn("¶
", result)