diff --git a/app/routes/public.py b/app/routes/public.py
index 51a0e2a..6aca31a 100644
--- a/app/routes/public.py
+++ b/app/routes/public.py
@@ -27,7 +27,7 @@ from fastapi.responses import HTMLResponse, Response
from fastapi.templating import Jinja2Templates
from app.config import Settings, get_settings
-from app.models.entities import Page
+from app.models.entities import Page, Post
from app.models.posts import PostSummary
from app.services.contact import ContactService
from app.services.hcaptcha import HCaptchaService
@@ -117,6 +117,33 @@ def home(
)
+@router.get(
+ "/posts/{slug}",
+ response_class=HTMLResponse,
+ summary="Single blog post",
+)
+def post_detail(
+ slug: str,
+ request: Request,
+ templates: Jinja2Templates = Depends(get_templates),
+ posts: PostService = Depends(get_post_service),
+) -> HTMLResponse:
+ """Render a single published post by slug.
+
+ Drafts and unknown slugs return 404 (same response so a mistyped
+ URL cannot be used to enumerate unpublished titles).
+ """
+ post: Post | None = posts.get_published_by_slug(slug)
+ if post is None:
+ raise HTTPException(status_code=404, detail="Post not found")
+
+ return templates.TemplateResponse(
+ request,
+ "public/post.html",
+ {"active_nav": "home", "post": post},
+ )
+
+
@router.get("/about", response_class=HTMLResponse, summary="About the farm")
def about(
request: Request,
diff --git a/app/services/posts.py b/app/services/posts.py
index 9f3e4b9..5817850 100644
--- a/app/services/posts.py
+++ b/app/services/posts.py
@@ -21,9 +21,9 @@ from typing import Optional
from fastapi import Request
from sqlalchemy import Engine, text
-from app.models.entities import PostStatus
+from app.models.entities import Post, PostStatus
from app.models.posts import PostSummary
-from app.models.mappers import _parse_datetime
+from app.models.mappers import _parse_datetime, row_to_post
from app.services.cache import TTLCache
@@ -155,6 +155,36 @@ class PostService:
self._cache.set(safe_limit, summaries)
return summaries
+ def get_published_by_slug(self, slug: str) -> Optional[Post]:
+ """Return the published :class:`Post` for ``slug`` or ``None``.
+
+ Drafts are invisible on the public path: the status filter
+ belongs in SQL so a mistyped slug and a draft slug are
+ indistinguishable to the caller (same 404 upstream).
+
+ SQL safety: ``slug`` and ``status`` are bound parameters; no
+ string interpolation.
+ """
+ with self._engine.connect() as conn:
+ row = (
+ conn.execute(
+ text(
+ "SELECT id, slug, title, body_md, body_html_cached,"
+ " status, published_at, updated_at, author_user_id"
+ " FROM posts"
+ " WHERE slug = :slug AND status = :status"
+ " LIMIT 1"
+ ),
+ {
+ "slug": slug,
+ "status": PostStatus.PUBLISHED.value,
+ },
+ )
+ .mappings()
+ .first()
+ )
+ return row_to_post(row) if row is not None else None
+
def invalidate_all(self) -> None:
"""Drop every cached post-list entry.
diff --git a/app/static/css/site.css b/app/static/css/site.css
index 58af2dd..e1982c4 100644
--- a/app/static/css/site.css
+++ b/app/static/css/site.css
@@ -348,6 +348,17 @@ a:focus-visible {
margin-bottom: var(--space-2);
}
+.page-article__date {
+ font-size: 0.875rem;
+ color: var(--c-sky-deep);
+ font-family: var(--font-sans);
+}
+
+.page-article__back {
+ margin-top: var(--space-4);
+ max-width: 48rem;
+}
+
/* Post list + card. */
.post-list {
display: grid;
diff --git a/app/templates/public/post.html b/app/templates/public/post.html
new file mode 100644
index 0000000..99d3674
--- /dev/null
+++ b/app/templates/public/post.html
@@ -0,0 +1,36 @@
+{#
+ Single blog post detail page.
+
+ Receives:
+ - post : app.models.entities.Post
+ - active_nav : str "home"
+
+ ``post.body_html_cached`` is the bleach-sanitized output of the
+ Markdown pipeline (allowlisted tags/attrs/protocols only), so
+ rendering with ``| safe`` does not reintroduce XSS risk. Same
+ rationale as ``public/about.html``.
+#}
+{% extends "public/base.html" %}
+
+{% block title %}{{ post.title }} — Chicken Babies R Us{% endblock %}
+{% block meta_description %}{{ post.title }} — a post from Chicken Babies R Us.{% endblock %}
+
+{% block content %}
+ {{ post.title }}
+ {% if post.published_at %}
+
+ {% endif %}
+