Skip to content

Commit 79b9ba3

Browse files
authored
Merge pull request #513 from davep/post-header-order
Retain relative header structure in archive pages
2 parents b93d97f + ec52c5c commit 79b9ba3

4 files changed

Lines changed: 96 additions & 1 deletion

File tree

ChangeLog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
- Changed the links and socials headings in the sidebar to `div`s from
1212
`h2`s, leaving headings only used in the main page or article.
1313
([#512](https://github.com/davep/blogmore/pull/512))
14+
- Changed the heading level of post content in list views (index, archives,
15+
tags, categories) to be relative to the post title. For example, if a post
16+
uses `h2` headings, they will be rendered as `h3` in list views where the
17+
post title itself is an `h2`, maintaining a correct heading hierarchy.
18+
([#513](https://github.com/davep/blogmore/pull/513))
1419

1520
**Released: WiP**
1621

src/blogmore/renderer.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Template rendering using Jinja2."""
22

33
import datetime as dt
4+
import re
45
from pathlib import Path
56
from typing import Any
67
from urllib.parse import urlparse
@@ -66,6 +67,7 @@ def __init__(
6667
self.env.filters["format_date"] = self._format_date
6768
self.env.filters["format_date_plain"] = self._format_date_plain
6869
self.env.filters["is_external_link"] = self._is_external_link
70+
self.env.filters["shift_headings"] = self._shift_headings
6971

7072
# Provide default values for pagination context variables so that
7173
# templates rendering without a full generator context (e.g. tests)
@@ -189,6 +191,31 @@ def _is_external_link(self, href: str) -> bool:
189191
# All other links with schemes are external
190192
return True
191193

194+
@staticmethod
195+
def _shift_headings(html: str, shift: int = 1) -> str:
196+
"""Shift HTML heading levels (h1-h6) by a given amount.
197+
198+
Args:
199+
html: The HTML content to modify.
200+
shift: The number of levels to shift headings by. Positive values
201+
make headings smaller (e.g. h1 -> h2), negative values make
202+
them larger. Results are clamped to the h1-h6 range.
203+
204+
Returns:
205+
The HTML with heading levels shifted.
206+
"""
207+
if shift == 0:
208+
return html
209+
210+
def shift_tag(match: re.Match[str]) -> str:
211+
prefix, level_str = match.groups()
212+
level = int(level_str)
213+
new_level = max(1, min(6, level + shift))
214+
return f"<{prefix}{new_level}"
215+
216+
# Matches <h1, <h2, ..., </h1, </h2, ...
217+
return re.sub(r"<(/?h)([1-6])", shift_tag, html)
218+
192219
def render_post(self, post: Post, **context: Any) -> str:
193220
"""Render a single blog post.
194221

src/blogmore/templates/_post_summary.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ <h2><a href="{{ post.url }}">{{ post.title }}{% if post.draft %} 🚧{% endif %}
3333
</div>
3434
{% endif %}
3535
<div class="post-content">
36-
{{ post.html_content|safe }}
36+
{{ post.html_content|shift_headings(1)|safe }}
3737
</div>
3838
{% if post.tags %}
3939
<div class="tags">

tests/test_renderer.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,6 +1132,69 @@ def test_post_summary_omits_reading_time_by_default(
11321132

11331133
assert "reading-time" not in html
11341134

1135+
def test_shift_headings_filter(self) -> None:
1136+
"""Test the shift_headings filter directly."""
1137+
html = "<h1>Title</h1><h2>Subtitle</h2><p>Text</p><h3>Small</h3>"
1138+
1139+
# Shift by 1
1140+
shifted = TemplateRenderer._shift_headings(html, 1)
1141+
assert "<h2>Title</h2>" in shifted
1142+
assert "<h3>Subtitle</h3>" in shifted
1143+
assert "<h4>Small</h4>" in shifted
1144+
assert "<p>Text</p>" in shifted
1145+
1146+
# Shift by 2
1147+
shifted = TemplateRenderer._shift_headings(html, 2)
1148+
assert "<h3>Title</h3>" in shifted
1149+
assert "<h4>Subtitle</h4>" in shifted
1150+
assert "<h5>Small</h5>" in shifted
1151+
1152+
# Clamping to h6
1153+
html_h6 = "<h6>Already small</h6>"
1154+
shifted = TemplateRenderer._shift_headings(html_h6, 1)
1155+
assert "<h6>Already small</h6>" in shifted
1156+
1157+
# Shift by 0
1158+
assert TemplateRenderer._shift_headings(html, 0) == html
1159+
1160+
# Negative shift (shift up)
1161+
html_h2 = "<h2>Title</h2>"
1162+
shifted = TemplateRenderer._shift_headings(html_h2, -1)
1163+
assert "<h1>Title</h1>" in shifted
1164+
1165+
# Clamping to h1
1166+
shifted = TemplateRenderer._shift_headings(html_h2, -2)
1167+
assert "<h1>Title</h1>" in shifted
1168+
1169+
def test_shift_headings_closing_tags(self) -> None:
1170+
"""Test that closing heading tags are also shifted."""
1171+
html = "<h2>Title</h2>"
1172+
shifted = TemplateRenderer._shift_headings(html, 1)
1173+
assert "<h3>Title</h3>" in shifted
1174+
1175+
def test_shift_headings_in_summary(self) -> None:
1176+
"""Test that headings are shifted when rendering a post summary."""
1177+
renderer = TemplateRenderer()
1178+
post = Post(
1179+
path=Path("test.md"),
1180+
title="Post Title",
1181+
content="## Subheading",
1182+
html_content="<h2>Subheading</h2>",
1183+
date=dt.datetime(2024, 1, 15, 12, 0, 0, tzinfo=dt.UTC),
1184+
)
1185+
1186+
html = renderer.render_index(
1187+
posts=[post],
1188+
page=1,
1189+
total_pages=1,
1190+
site_title="Test Blog",
1191+
)
1192+
1193+
# Post title in index is h2
1194+
assert "<h2>" in html
1195+
# Subheading in content should be shifted to h3
1196+
assert "<h3>Subheading</h3>" in html
1197+
11351198
def test_listing_meta_tags_on_tag_page(self, sample_post: Post) -> None:
11361199
"""Test that tag pages include standard listing meta tags."""
11371200
renderer = TemplateRenderer()

0 commit comments

Comments
 (0)