Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plans/features/N8-tag-index-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

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

## Status: Not Started (Phase B, Batch 1)
## Status: Complete ✅

## Goal

Expand Down
2 changes: 1 addition & 1 deletion plans/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ The [PyOhio static website](https://github.com/pyohio/static-website) (Astro + P
| 18 | [Accessibility](18-accessibility.md) | ✅ | A | Skip links, ARIA, focus styles |
| N6 | Broken Link Handling | ✅ | A | Visual indication + build warnings |
| N7 | Tag Display | ✅ | A | Show frontmatter tags on pages |
| N8 | [Tag Index Pages](N8-tag-index-pages.md) | | B | Generate `/tags/<tag>/` listing pages |
| N8 | [Tag Index Pages](N8-tag-index-pages.md) | | B | Generate `/tags/<tag>/` listing pages |
| N9 | Template Decomposition | ✅ | A | Named blocks as customization hooks in page templates |
| N10 | Newline Handling | ✅ | A | Obsidian-style single newline → `<br>` |
| N11 | [Config Validation](N11-config-validation.md) | ❌ | B | `validate` command, unknown-key warnings, theme manifest |
Expand Down
9 changes: 4 additions & 5 deletions plans/implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,11 +195,10 @@ Work in batches, one feature per PR.

### Batch 1 — Independent Quick Wins

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

#### Feature 17: SEO & Meta Tags ✅
- [x] Add `description` and `og_image` to `SiteConfig`
Expand Down
1 change: 1 addition & 0 deletions src/rockgarden/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class ThemeConfig(BaseModel):
toc: bool = True
backlinks: bool = True
search: bool = True
tag_index: bool = True

# Default theme specific
daisyui_default: str = "light"
Expand Down
8 changes: 8 additions & 0 deletions src/rockgarden/output/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from rockgarden.output.build_info import get_build_info
from rockgarden.output.search import build_search_index
from rockgarden.output.sitemap import build_sitemap
from rockgarden.output.tags import build_tag_pages, collect_tags
from rockgarden.render import create_engine, render_markdown, render_page
from rockgarden.urls import get_folder_url, get_output_path, get_url

Expand Down Expand Up @@ -170,6 +171,7 @@ def build_site(config: Config, source: Path, output: Path) -> BuildResult:
"og_image": config.site.og_image,
"base_url": config.site.base_url,
"clean_urls": config.site.clean_urls,
"tag_index": config.theme.tag_index,
"nav": nav_tree,
"nav_default_state": config.theme.nav_default_state,
"daisyui_theme": config.theme.daisyui_default,
Expand Down Expand Up @@ -303,6 +305,12 @@ def build_site(config: Config, source: Path, output: Path) -> BuildResult:
search_index_file = output / "search-index.json"
search_index_file.write_text(json.dumps(search_index))

# Generate tag index pages if enabled
if config.theme.tag_index:
tags = collect_tags(pages)
if tags:
build_tag_pages(tags, env, site_config, output, clean_urls)

# Generate sitemap if base_url is configured
if config.site.base_url:
sitemap_xml = build_sitemap(
Expand Down
64 changes: 64 additions & 0 deletions src/rockgarden/output/tags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Tag index page generation."""

from pathlib import Path

from jinja2 import Environment

from rockgarden.content.models import Page
from rockgarden.urls import get_tag_url, get_tags_root_url, get_url, normalize_tag


def collect_tags(pages: list[Page]) -> dict[str, list[Page]]:
"""Return a mapping of normalized tag slug → list of pages with that tag.

Pages are included in the order they appear in the input list. Tags with
no pages are not included. Result is sorted alphabetically by tag slug.
"""
tags: dict[str, list[Page]] = {}
for page in pages:
raw_tags = page.frontmatter.get("tags", [])
if isinstance(raw_tags, str):
raw_tags = [raw_tags]
for tag in raw_tags:
slug = normalize_tag(tag)
if slug:
tags.setdefault(slug, []).append(page)
return dict(sorted(tags.items()))


def build_tag_pages(
tags: dict[str, list[Page]],
env: Environment,
site_config: dict,
output: Path,
clean_urls: bool = True,

This comment was marked as outdated.

) -> None:
"""Generate /tags/<slug>/ and /tags/ pages in the output directory."""
tag_index_template = env.get_template("tag_index.html")
tags_root_template = env.get_template("tags_root.html")

for tag_slug, tagged_pages in tags.items():
page_entries = [
{"title": p.title, "url": get_url(p.slug, clean_urls)}
for p in tagged_pages
]
html = tag_index_template.render(
tag=tag_slug,
pages=page_entries,
site=site_config,
)
if clean_urls:
out_file = output / "tags" / tag_slug / "index.html"
else:
out_file = output / "tags" / f"{tag_slug}.html"
out_file.parent.mkdir(parents=True, exist_ok=True)
out_file.write_text(html)

tag_counts = {slug: len(pages) for slug, pages in tags.items()}
html = tags_root_template.render(
tags=tag_counts,
site=site_config,
)
out_file = output / "tags" / "index.html"
out_file.parent.mkdir(parents=True, exist_ok=True)
out_file.write_text(html)
5 changes: 5 additions & 0 deletions src/rockgarden/render/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from rockgarden.config import Config
from rockgarden.content.models import Page
from rockgarden.nav.tree import NavNode
from rockgarden.urls import get_tag_url, get_tags_root_url, normalize_tag


def _make_format_datetime(tz_name: str):
Expand Down Expand Up @@ -62,6 +63,10 @@ def create_engine(
autoescape=True,
)
env.filters["format_datetime"] = _make_format_datetime(config.dates.timezone)
clean_urls = config.site.clean_urls
env.globals["normalize_tag"] = normalize_tag
env.globals["tag_url"] = lambda slug: get_tag_url(slug, clean_urls)
env.globals["tags_root_url"] = get_tags_root_url(clean_urls)
return env


Expand Down
2 changes: 1 addition & 1 deletion src/rockgarden/static/rockgarden.css

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion src/rockgarden/templates/folder_index.html
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,12 @@
{% if child.tags %}
<div class="flex flex-wrap gap-1">
{% for tag in child.tags %}
<span class="badge badge-sm badge-ghost">{{ tag }}</span>
{% set tag_slug = normalize_tag(tag) %}
{% if site.tag_index %}
<a href="{{ tag_url(tag_slug) }}" class="badge badge-sm badge-ghost hover:badge-primary">{{ tag.lstrip('#') }}</a>
{% else %}
<span class="badge badge-sm badge-ghost">{{ tag.lstrip('#') }}</span>
{% endif %}
{% endfor %}
</div>
{% endif %}
Expand Down
5 changes: 5 additions & 0 deletions src/rockgarden/templates/page.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@
{% if tags %}
<div class="flex flex-wrap gap-1">
{% for tag in tags %}
{% set tag_slug = normalize_tag(tag) %}
{% if site.tag_index %}
<a href="{{ tag_url(tag_slug) }}" class="badge badge-sm badge-ghost hover:badge-primary">{{ tag.lstrip('#') }}</a>
{% else %}
<span class="badge badge-sm badge-ghost">{{ tag.lstrip('#') }}</span>
{% endif %}
{% endfor %}
</div>
{% endif %}
Expand Down
30 changes: 30 additions & 0 deletions src/rockgarden/templates/tag_index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{% extends "base.html" %}

{% block title %}#{{ tag }}{% if site.title %} - {{ site.title }}{% endif %}{% endblock %}

{% block content %}
<div class="flex gap-8">
<div class="flex-1 min-w-0">
<nav aria-label="Breadcrumb" class="text-sm breadcrumbs mb-4">
<ul>
<li><a href="/">Home</a></li>
<li><a href="{{ tags_root_url }}">Tags</a></li>
<li>#{{ tag }}</li>
</ul>
</nav>
<article>
<h1 class="text-3xl font-bold mb-6">#{{ tag }}</h1>
<p class="text-base-content/60 mb-6">{{ pages | length }} {{ "page" if pages | length == 1 else "pages" }}</p>
{% if pages %}
<ul class="space-y-2">
{% for entry in pages %}
<li>
<a href="{{ entry.url }}" class="link link-hover link-primary text-lg">{{ entry.title }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
</article>
</div>
</div>
{% endblock %}
29 changes: 29 additions & 0 deletions src/rockgarden/templates/tags_root.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{% extends "base.html" %}

{% block title %}Tags{% if site.title %} - {{ site.title }}{% endif %}{% endblock %}

{% block content %}
<div class="flex gap-8">
<div class="flex-1 min-w-0">
<nav aria-label="Breadcrumb" class="text-sm breadcrumbs mb-4">
<ul>
<li><a href="/">Home</a></li>
<li>Tags</li>
</ul>
</nav>
<article>
<h1 class="text-3xl font-bold mb-6">Tags</h1>
{% if tags %}
<div class="flex flex-wrap gap-3">
{% for tag_slug, count in tags.items() %}
<a href="{{ tag_url(tag_slug) }}" class="badge badge-lg badge-ghost hover:badge-primary gap-1">
#{{ tag_slug }}
<span class="text-base-content/50 text-xs">{{ count }}</span>
</a>
{% endfor %}
</div>
{% endif %}
</article>
</div>
</div>
{% endblock %}
38 changes: 38 additions & 0 deletions src/rockgarden/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@
import re


def normalize_tag(tag: str) -> str:
"""Normalize a tag to a URL-safe slug.

Strips leading '#', lowercases, and replaces any character that is not
alphanumeric, hyphen, or underscore with a hyphen. Prevents path traversal
via tags containing '/' or '..'.

Tags 'Python', '#python', and 'python' all normalize to 'python'.
Obsidian nested tags like 'character/pc' normalize to 'character-pc'.
"""
slug = tag.lstrip("#").lower()
slug = re.sub(r"[^a-z0-9_-]", "-", slug)
slug = re.sub(r"-+", "-", slug)
return slug.strip("-")


def generate_slug(relative_path: str) -> str:
"""Generate URL-safe slug from a relative file path.

Expand Down Expand Up @@ -73,6 +89,28 @@ def get_url(slug: str, clean_urls: bool = True) -> str:
return f"/{slug}.html"


def get_tag_url(tag_slug: str, clean_urls: bool = True) -> str:
"""Get URL for a tag index page.

Args:
tag_slug: Normalized tag slug (e.g., "python").
clean_urls: If True, uses trailing slash format.

Returns:
URL path:
- clean_urls=True: "python" → "/tags/python/"
- clean_urls=False: "python" → "/tags/python.html"
"""
if clean_urls:
return f"/tags/{tag_slug}/"
return f"/tags/{tag_slug}.html"


def get_tags_root_url(clean_urls: bool = True) -> str:
"""Get URL for the tags root index page."""
return "/tags/" if clean_urls else "/tags/index.html"


def get_folder_url(folder_path: str, clean_urls: bool = True) -> str:
"""Get URL for a folder.

Expand Down