Skip to content

Commit 3e7ad9a

Browse files
authored
Merge pull request #174 from davep/copilot/add-canonical-links-to-pages
Add `<link rel="canonical">` to every generated page
2 parents cc92aa4 + cc37760 commit 3e7ad9a

4 files changed

Lines changed: 342 additions & 59 deletions

File tree

ChangeLog.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# BlogMore ChangeLog
22

3+
## Unreleased
4+
5+
**Released: WiP**
6+
7+
- Every generated page now includes a `<link rel="canonical" href="...">`
8+
tag in the `<head>`, pointing to the fully-qualified URL for that page.
9+
([#174](https://github.com/davep/blogmore/pull/174))
10+
311
## v1.0.0
412

513
**Released: 2026-02-25**

src/blogmore/generator.py

Lines changed: 79 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,18 @@ def _get_global_context(self) -> dict[str, Any]:
280280
context.update(self.sidebar_config)
281281
return context
282282

283+
def _canonical_url_for_path(self, output_path: Path) -> str:
284+
"""Compute the fully-qualified canonical URL for a given output file path.
285+
286+
Args:
287+
output_path: Absolute path to the output file within the output directory.
288+
289+
Returns:
290+
The fully-qualified canonical URL for the given file.
291+
"""
292+
relative = output_path.relative_to(self.output_dir)
293+
return f"{self.site_url}/{relative.as_posix()}"
294+
283295
def generate(self, include_drafts: bool = False) -> None:
284296
"""Generate the complete static site.
285297
@@ -553,8 +565,6 @@ def _generate_post_page(
553565
context["prev_post"] = None
554566
context["next_post"] = None
555567

556-
html = self.renderer.render_post(post, **context)
557-
558568
# Determine output path based on date
559569
if post.date:
560570
# Create year/month/day directory structure
@@ -573,17 +583,19 @@ def _generate_post_page(
573583
# Fallback for posts without dates
574584
output_path = self.output_dir / f"{post.slug}.html"
575585

586+
context["canonical_url"] = self._canonical_url_for_path(output_path)
587+
html = self.renderer.render_post(post, **context)
576588
output_path.write_text(html, encoding="utf-8")
577589

578590
def _generate_page(self, page: Page, pages: list[Page]) -> None:
579591
"""Generate a single static page."""
580592
context = self._get_global_context()
581593
context["pages"] = pages
594+
output_path = self.output_dir / f"{page.slug}.html"
595+
context["canonical_url"] = self._canonical_url_for_path(output_path)
582596

583597
html = self.renderer.render_page(page, **context)
584598

585-
# Output to root of site
586-
output_path = self.output_dir / f"{page.slug}.html"
587599
output_path.write_text(html, encoding="utf-8")
588600

589601
def _generate_index_page(self, posts: list[Post], pages: list[Page]) -> None:
@@ -600,10 +612,6 @@ def _generate_index_page(self, posts: list[Post], pages: list[Page]) -> None:
600612

601613
# Generate each page
602614
for page_num, page_posts in enumerate(paginated_posts, start=1):
603-
html = self.renderer.render_index(
604-
page_posts, page=page_num, total_pages=total_pages, **context
605-
)
606-
607615
if page_num == 1:
608616
# First page is at root
609617
output_path = self.output_dir / "index.html"
@@ -613,16 +621,22 @@ def _generate_index_page(self, posts: list[Post], pages: list[Page]) -> None:
613621
page_dir.mkdir(exist_ok=True)
614622
output_path = page_dir / f"{page_num}.html"
615623

624+
context["canonical_url"] = self._canonical_url_for_path(output_path)
625+
html = self.renderer.render_index(
626+
page_posts, page=page_num, total_pages=total_pages, **context
627+
)
628+
616629
output_path.write_text(html, encoding="utf-8")
617630

618631
def _generate_archive_page(self, posts: list[Post], pages: list[Page]) -> None:
619632
"""Generate the archive page."""
620633
context = self._get_global_context()
621634
context["pages"] = pages
635+
output_path = self.output_dir / "archive.html"
636+
context["canonical_url"] = self._canonical_url_for_path(output_path)
622637
html = self.renderer.render_archive(
623638
posts, page=1, total_pages=1, base_path="/archive", **context
624639
)
625-
output_path = self.output_dir / "archive.html"
626640
output_path.write_text(html, encoding="utf-8")
627641

628642
def _generate_date_archives(self, posts: list[Post], pages: list[Page]) -> None:
@@ -659,15 +673,6 @@ def _generate_date_archives(self, posts: list[Post], pages: list[Page]) -> None:
659673
# Base path for pagination links
660674
base_path = f"/{year}"
661675

662-
html = self.renderer.render_archive(
663-
page_posts,
664-
archive_title=f"Posts from {year}",
665-
page=page_num,
666-
total_pages=total_pages,
667-
base_path=base_path,
668-
**context,
669-
)
670-
671676
if page_num == 1:
672677
# First page is at year/index.html
673678
output_path = year_dir / "index.html"
@@ -677,6 +682,16 @@ def _generate_date_archives(self, posts: list[Post], pages: list[Page]) -> None:
677682
page_dir.mkdir(exist_ok=True)
678683
output_path = page_dir / f"{page_num}.html"
679684

685+
context["canonical_url"] = self._canonical_url_for_path(output_path)
686+
html = self.renderer.render_archive(
687+
page_posts,
688+
archive_title=f"Posts from {year}",
689+
page=page_num,
690+
total_pages=total_pages,
691+
base_path=base_path,
692+
**context,
693+
)
694+
680695
output_path.write_text(html, encoding="utf-8")
681696

682697
# Generate month archives with pagination
@@ -695,15 +710,6 @@ def _generate_date_archives(self, posts: list[Post], pages: list[Page]) -> None:
695710
# Base path for pagination links
696711
base_path = f"/{year}/{month:02d}"
697712

698-
html = self.renderer.render_archive(
699-
page_posts,
700-
archive_title=f"Posts from {month_name}",
701-
page=page_num,
702-
total_pages=total_pages,
703-
base_path=base_path,
704-
**context,
705-
)
706-
707713
if page_num == 1:
708714
# First page is at year/month/index.html
709715
output_path = month_dir / "index.html"
@@ -713,6 +719,16 @@ def _generate_date_archives(self, posts: list[Post], pages: list[Page]) -> None:
713719
page_dir.mkdir(exist_ok=True)
714720
output_path = page_dir / f"{page_num}.html"
715721

722+
context["canonical_url"] = self._canonical_url_for_path(output_path)
723+
html = self.renderer.render_archive(
724+
page_posts,
725+
archive_title=f"Posts from {month_name}",
726+
page=page_num,
727+
total_pages=total_pages,
728+
base_path=base_path,
729+
**context,
730+
)
731+
716732
output_path.write_text(html, encoding="utf-8")
717733

718734
# Generate day archives with pagination
@@ -731,15 +747,6 @@ def _generate_date_archives(self, posts: list[Post], pages: list[Page]) -> None:
731747
# Base path for pagination links
732748
base_path = f"/{year}/{month:02d}/{day:02d}"
733749

734-
html = self.renderer.render_archive(
735-
page_posts,
736-
archive_title=f"Posts from {date_str}",
737-
page=page_num,
738-
total_pages=total_pages,
739-
base_path=base_path,
740-
**context,
741-
)
742-
743750
if page_num == 1:
744751
# First page is at year/month/day/index.html
745752
output_path = day_dir / "index.html"
@@ -749,6 +756,16 @@ def _generate_date_archives(self, posts: list[Post], pages: list[Page]) -> None:
749756
page_dir.mkdir(exist_ok=True)
750757
output_path = page_dir / f"{page_num}.html"
751758

759+
context["canonical_url"] = self._canonical_url_for_path(output_path)
760+
html = self.renderer.render_archive(
761+
page_posts,
762+
archive_title=f"Posts from {date_str}",
763+
page=page_num,
764+
total_pages=total_pages,
765+
base_path=base_path,
766+
**context,
767+
)
768+
752769
output_path.write_text(html, encoding="utf-8")
753770

754771
def _generate_tag_pages(self, posts: list[Post], pages: list[Page]) -> None:
@@ -786,15 +803,6 @@ def get_sort_key(post: Post) -> float:
786803

787804
# Generate each page
788805
for page_num, page_posts in enumerate(paginated_posts, start=1):
789-
html = self.renderer.render_tag_page(
790-
tag_display, # Use display name for rendering
791-
page_posts,
792-
page=page_num,
793-
total_pages=total_pages,
794-
safe_tag=safe_tag,
795-
**context,
796-
)
797-
798806
if page_num == 1:
799807
# First page is at tag/{tag}.html
800808
output_path = tag_dir / f"{safe_tag}.html"
@@ -804,6 +812,16 @@ def get_sort_key(post: Post) -> float:
804812
tag_page_dir.mkdir(exist_ok=True)
805813
output_path = tag_page_dir / f"{page_num}.html"
806814

815+
context["canonical_url"] = self._canonical_url_for_path(output_path)
816+
html = self.renderer.render_tag_page(
817+
tag_display, # Use display name for rendering
818+
page_posts,
819+
page=page_num,
820+
total_pages=total_pages,
821+
safe_tag=safe_tag,
822+
**context,
823+
)
824+
807825
output_path.write_text(html, encoding="utf-8")
808826

809827
def _group_posts_by_tag(
@@ -886,11 +904,11 @@ def _generate_tags_page(self, posts: list[Post], pages: list[Page]) -> None:
886904
# Render the tags page
887905
context = self._get_global_context()
888906
context["pages"] = pages
907+
output_path = self.output_dir / "tags.html"
908+
context["canonical_url"] = self._canonical_url_for_path(output_path)
889909

890910
html = self.renderer.render_tags_page(tag_data, **context)
891911

892-
# Output to root of site
893-
output_path = self.output_dir / "tags.html"
894912
output_path.write_text(html, encoding="utf-8")
895913

896914
def _generate_categories_page(self, posts: list[Post], pages: list[Page]) -> None:
@@ -954,11 +972,11 @@ def _generate_categories_page(self, posts: list[Post], pages: list[Page]) -> Non
954972
# Render the categories page
955973
context = self._get_global_context()
956974
context["pages"] = pages
975+
output_path = self.output_dir / "categories.html"
976+
context["canonical_url"] = self._canonical_url_for_path(output_path)
957977

958978
html = self.renderer.render_categories_page(category_data, **context)
959979

960-
# Output to root of site
961-
output_path = self.output_dir / "categories.html"
962980
output_path.write_text(html, encoding="utf-8")
963981

964982
def _generate_category_pages(self, posts: list[Post], pages: list[Page]) -> None:
@@ -1001,15 +1019,6 @@ def get_sort_key(post: Post) -> float:
10011019

10021020
# Generate each page
10031021
for page_num, page_posts in enumerate(paginated_posts, start=1):
1004-
html = self.renderer.render_category_page(
1005-
category_display, # Use display name for rendering
1006-
page_posts,
1007-
page=page_num,
1008-
total_pages=total_pages,
1009-
safe_category=safe_category,
1010-
**context,
1011-
)
1012-
10131022
if page_num == 1:
10141023
# First page is at category/{category}.html
10151024
output_path = category_dir / f"{safe_category}.html"
@@ -1019,6 +1028,16 @@ def get_sort_key(post: Post) -> float:
10191028
category_page_dir.mkdir(exist_ok=True)
10201029
output_path = category_page_dir / f"{page_num}.html"
10211030

1031+
context["canonical_url"] = self._canonical_url_for_path(output_path)
1032+
html = self.renderer.render_category_page(
1033+
category_display, # Use display name for rendering
1034+
page_posts,
1035+
page=page_num,
1036+
total_pages=total_pages,
1037+
safe_category=safe_category,
1038+
**context,
1039+
)
1040+
10221041
output_path.write_text(html, encoding="utf-8")
10231042

10241043
def _group_posts_by_category(
@@ -1093,8 +1112,9 @@ def _generate_search_page(self, pages: list[Page]) -> None:
10931112
"""
10941113
context = self._get_global_context()
10951114
context["pages"] = pages
1096-
html = self.renderer.render_search_page(**context)
10971115
output_path = self.output_dir / "search.html"
1116+
context["canonical_url"] = self._canonical_url_for_path(output_path)
1117+
html = self.renderer.render_search_page(**context)
10981118
output_path.write_text(html, encoding="utf-8")
10991119

11001120
def _remove_stale_search_files(self) -> None:

src/blogmore/templates/base.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
<meta charset="UTF-8">
55
<meta name="viewport" content="width=device-width, initial-scale=1.0">
66
<meta name="generator" content="blogmore v{{ blogmore_version }}">
7+
{% if canonical_url %}<link rel="canonical" href="{{ canonical_url }}">{% endif %}
78
<title>{% block title %}{{ site_title }}{% endblock %}</title>
89
{% if favicon_url %}
910
<link rel="icon" href="{{ favicon_url }}">

0 commit comments

Comments
 (0)