Skip to content

Commit 6407680

Browse files
committed
fix: implement tag index pages
1 parent 3a05632 commit 6407680

11 files changed

Lines changed: 156 additions & 9 deletions

File tree

plans/features/N8-tag-index-pages.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Generate `/tags/<tag>/` listing pages for all unique tags found in content.
44

5-
## Status: Not Started (Phase B, Batch 1)
5+
## Status: Complete ✅
66

77
## Goal
88

plans/features/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ The [PyOhio static website](https://github.com/pyohio/static-website) (Astro + P
3838
| 18 | [Accessibility](18-accessibility.md) || A | Skip links, ARIA, focus styles |
3939
| N6 | Broken Link Handling || A | Visual indication + build warnings |
4040
| N7 | Tag Display || A | Show frontmatter tags on pages |
41-
| N8 | [Tag Index Pages](N8-tag-index-pages.md) | | B | Generate `/tags/<tag>/` listing pages |
41+
| N8 | [Tag Index Pages](N8-tag-index-pages.md) | | B | Generate `/tags/<tag>/` listing pages |
4242
| N9 | Template Decomposition || A | Named blocks as customization hooks in page templates |
4343
| N10 | Newline Handling || A | Obsidian-style single newline → `<br>` |
4444
| N11 | [Config Validation](N11-config-validation.md) || B | `validate` command, unknown-key warnings, theme manifest |

plans/implementation.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,11 +195,10 @@ Work in batches, one feature per PR.
195195

196196
### Batch 1 — Independent Quick Wins
197197

198-
#### N8: Tag Index Pages
199-
- [ ] Generate `/tags/<tag>/` listing pages for all unique tags
200-
- [ ] Link tag badges on pages to their index page
201-
- [ ] Add tag index entry to nav (optional, config-controlled)
202-
- [ ] Generate tag index root page (`/tags/`) listing all tags
198+
#### N8: Tag Index Pages ✅
199+
- [x] Generate `/tags/<tag>/` listing pages for all unique tags
200+
- [x] Link tag badges on pages to their index page
201+
- [x] Generate tag index root page (`/tags/`) listing all tags
203202

204203
#### Feature 17: SEO & Meta Tags ✅
205204
- [x] Add `description` and `og_image` to `SiteConfig`

src/rockgarden/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ class ThemeConfig(BaseModel):
4646
toc: bool = True
4747
backlinks: bool = True
4848
search: bool = True
49+
tag_index: bool = True
4950

5051
# Default theme specific
5152
daisyui_default: str = "light"

src/rockgarden/output/builder.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from rockgarden.output.build_info import get_build_info
3838
from rockgarden.output.search import build_search_index
3939
from rockgarden.output.sitemap import build_sitemap
40+
from rockgarden.output.tags import build_tag_pages, collect_tags
4041
from rockgarden.render import create_engine, render_markdown, render_page
4142
from rockgarden.urls import get_folder_url, get_output_path, get_url
4243

@@ -170,6 +171,7 @@ def build_site(config: Config, source: Path, output: Path) -> BuildResult:
170171
"og_image": config.site.og_image,
171172
"base_url": config.site.base_url,
172173
"clean_urls": config.site.clean_urls,
174+
"tag_index": config.theme.tag_index,
173175
"nav": nav_tree,
174176
"nav_default_state": config.theme.nav_default_state,
175177
"daisyui_theme": config.theme.daisyui_default,
@@ -303,6 +305,12 @@ def build_site(config: Config, source: Path, output: Path) -> BuildResult:
303305
search_index_file = output / "search-index.json"
304306
search_index_file.write_text(json.dumps(search_index))
305307

308+
# Generate tag index pages if enabled
309+
if config.theme.tag_index:
310+
tags = collect_tags(pages)
311+
if tags:
312+
build_tag_pages(tags, env, site_config, output, clean_urls)
313+
306314
# Generate sitemap if base_url is configured
307315
if config.site.base_url:
308316
sitemap_xml = build_sitemap(

src/rockgarden/output/tags.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Tag index page generation."""
2+
3+
from pathlib import Path
4+
5+
from jinja2 import Environment
6+
7+
from rockgarden.content.models import Page
8+
from rockgarden.urls import get_url
9+
10+
11+
def normalize_tag(tag: str) -> str:
12+
"""Normalize a tag to a URL-safe slug.
13+
14+
Strips leading '#' and lowercases. Tags 'Python', '#python', and 'python'
15+
all normalize to 'python'.
16+
"""
17+
return tag.lstrip("#").lower()
18+
19+
20+
def collect_tags(pages: list[Page]) -> dict[str, list[Page]]:
21+
"""Return a mapping of normalized tag slug → list of pages with that tag.
22+
23+
Pages are included in the order they appear in the input list. Tags with
24+
no pages are not included. Result is sorted alphabetically by tag slug.
25+
"""
26+
tags: dict[str, list[Page]] = {}
27+
for page in pages:
28+
raw_tags = page.frontmatter.get("tags", [])
29+
if isinstance(raw_tags, str):
30+
raw_tags = [raw_tags]
31+
for tag in raw_tags:
32+
slug = normalize_tag(tag)
33+
if slug:
34+
tags.setdefault(slug, []).append(page)
35+
return dict(sorted(tags.items()))
36+
37+
38+
def build_tag_pages(
39+
tags: dict[str, list[Page]],
40+
env: Environment,
41+
site_config: dict,
42+
output: Path,
43+
clean_urls: bool = True,
44+
) -> None:
45+
"""Generate /tags/<slug>/ and /tags/ pages in the output directory."""
46+
tag_index_template = env.get_template("tag_index.html")
47+
tags_root_template = env.get_template("tags_root.html")
48+
49+
for tag_slug, tagged_pages in tags.items():
50+
page_entries = [
51+
{"title": p.title, "url": get_url(p.slug, clean_urls)}
52+
for p in tagged_pages
53+
]
54+
html = tag_index_template.render(
55+
tag=tag_slug,
56+
pages=page_entries,
57+
site=site_config,
58+
)
59+
out_file = output / "tags" / tag_slug / "index.html"
60+
out_file.parent.mkdir(parents=True, exist_ok=True)
61+
out_file.write_text(html)
62+
63+
tag_counts = {slug: len(pages) for slug, pages in tags.items()}
64+
html = tags_root_template.render(
65+
tags=tag_counts,
66+
site=site_config,
67+
)
68+
out_file = output / "tags" / "index.html"
69+
out_file.parent.mkdir(parents=True, exist_ok=True)
70+
out_file.write_text(html)

src/rockgarden/static/rockgarden.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/rockgarden/templates/folder_index.html

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,12 @@
4949
{% if child.tags %}
5050
<div class="flex flex-wrap gap-1">
5151
{% for tag in child.tags %}
52-
<span class="badge badge-sm badge-ghost">{{ tag }}</span>
52+
{% set tag_slug = tag.lstrip('#').lower() %}
53+
{% if site.tag_index %}
54+
<a href="/tags/{{ tag_slug }}/" class="badge badge-sm badge-ghost hover:badge-primary">{{ tag.lstrip('#') }}</a>
55+
{% else %}
56+
<span class="badge badge-sm badge-ghost">{{ tag.lstrip('#') }}</span>
57+
{% endif %}
5358
{% endfor %}
5459
</div>
5560
{% endif %}

src/rockgarden/templates/page.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@
2626
{% if tags %}
2727
<div class="flex flex-wrap gap-1">
2828
{% for tag in tags %}
29+
{% set tag_slug = tag.lstrip('#').lower() %}
30+
{% if site.tag_index %}
31+
<a href="/tags/{{ tag_slug }}/" class="badge badge-sm badge-ghost hover:badge-primary">{{ tag.lstrip('#') }}</a>
32+
{% else %}
2933
<span class="badge badge-sm badge-ghost">{{ tag.lstrip('#') }}</span>
34+
{% endif %}
3035
{% endfor %}
3136
</div>
3237
{% endif %}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{% extends "base.html" %}
2+
3+
{% block title %}#{{ tag }}{% if site.title %} - {{ site.title }}{% endif %}{% endblock %}
4+
5+
{% block content %}
6+
<div class="flex gap-8">
7+
<div class="flex-1 min-w-0">
8+
<nav aria-label="Breadcrumb" class="text-sm breadcrumbs mb-4">
9+
<ul>
10+
<li><a href="/">Home</a></li>
11+
<li><a href="/tags/">Tags</a></li>
12+
<li>#{{ tag }}</li>
13+
</ul>
14+
</nav>
15+
<article>
16+
<h1 class="text-3xl font-bold mb-6">#{{ tag }}</h1>
17+
<p class="text-base-content/60 mb-6">{{ pages | length }} {{ "page" if pages | length == 1 else "pages" }}</p>
18+
{% if pages %}
19+
<ul class="space-y-2">
20+
{% for entry in pages %}
21+
<li>
22+
<a href="{{ entry.url }}" class="link link-hover link-primary text-lg">{{ entry.title }}</a>
23+
</li>
24+
{% endfor %}
25+
</ul>
26+
{% endif %}
27+
</article>
28+
</div>
29+
</div>
30+
{% endblock %}

0 commit comments

Comments
 (0)