Skip to content

Commit caae8cc

Browse files
authored
fix: implement tag index pages (#28)
* fix: implement tag index pages * fix path traversal * normalize tag
1 parent 3a05632 commit caae8cc

13 files changed

Lines changed: 193 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: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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_tag_url, get_tags_root_url, get_url, normalize_tag
9+
10+
11+
def collect_tags(pages: list[Page]) -> dict[str, list[Page]]:
12+
"""Return a mapping of normalized tag slug → list of pages with that tag.
13+
14+
Pages are included in the order they appear in the input list. Tags with
15+
no pages are not included. Result is sorted alphabetically by tag slug.
16+
"""
17+
tags: dict[str, list[Page]] = {}
18+
for page in pages:
19+
raw_tags = page.frontmatter.get("tags", [])
20+
if isinstance(raw_tags, str):
21+
raw_tags = [raw_tags]
22+
for tag in raw_tags:
23+
slug = normalize_tag(tag)
24+
if slug:
25+
tags.setdefault(slug, []).append(page)
26+
return dict(sorted(tags.items()))
27+
28+
29+
def build_tag_pages(
30+
tags: dict[str, list[Page]],
31+
env: Environment,
32+
site_config: dict,
33+
output: Path,
34+
clean_urls: bool = True,
35+
) -> None:
36+
"""Generate /tags/<slug>/ and /tags/ pages in the output directory."""
37+
tag_index_template = env.get_template("tag_index.html")
38+
tags_root_template = env.get_template("tags_root.html")
39+
40+
for tag_slug, tagged_pages in tags.items():
41+
page_entries = [
42+
{"title": p.title, "url": get_url(p.slug, clean_urls)}
43+
for p in tagged_pages
44+
]
45+
html = tag_index_template.render(
46+
tag=tag_slug,
47+
pages=page_entries,
48+
site=site_config,
49+
)
50+
if clean_urls:
51+
out_file = output / "tags" / tag_slug / "index.html"
52+
else:
53+
out_file = output / "tags" / f"{tag_slug}.html"
54+
out_file.parent.mkdir(parents=True, exist_ok=True)
55+
out_file.write_text(html)
56+
57+
tag_counts = {slug: len(pages) for slug, pages in tags.items()}
58+
html = tags_root_template.render(
59+
tags=tag_counts,
60+
site=site_config,
61+
)
62+
out_file = output / "tags" / "index.html"
63+
out_file.parent.mkdir(parents=True, exist_ok=True)
64+
out_file.write_text(html)

src/rockgarden/render/engine.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from rockgarden.config import Config
1111
from rockgarden.content.models import Page
1212
from rockgarden.nav.tree import NavNode
13+
from rockgarden.urls import get_tag_url, get_tags_root_url, normalize_tag
1314

1415

1516
def _make_format_datetime(tz_name: str):
@@ -62,6 +63,10 @@ def create_engine(
6263
autoescape=True,
6364
)
6465
env.filters["format_datetime"] = _make_format_datetime(config.dates.timezone)
66+
clean_urls = config.site.clean_urls
67+
env.globals["normalize_tag"] = normalize_tag
68+
env.globals["tag_url"] = lambda slug: get_tag_url(slug, clean_urls)
69+
env.globals["tags_root_url"] = get_tags_root_url(clean_urls)
6570
return env
6671

6772

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 = normalize_tag(tag) %}
53+
{% if site.tag_index %}
54+
<a href="{{ tag_url(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 = normalize_tag(tag) %}
30+
{% if site.tag_index %}
31+
<a href="{{ tag_url(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 %}

0 commit comments

Comments
 (0)