Fix relations nav_label URL bug and add rich 404 pages with headers
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m24s

The relations container-nav fragment was inserting nav_label (e.g.
"calendars", "markets") as a URL path segment, generating wrong links
like /the-village-hall/markets/suma/ instead of /the-village-hall/suma/.
The nav_label is for display only, not URL construction.

Also adds a rich 404 handler that shows site headers and post breadcrumb
when a slug can be resolved from the URL path. Falls back gracefully to
the minimal error page if context building fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 18:30:44 +00:00
parent ee41e30d5b
commit 597b0d7a2f
2 changed files with 118 additions and 15 deletions

View File

@@ -78,10 +78,7 @@ def register():
slug = (child.metadata_ or {}).get("slug", "")
if not slug:
continue
nav_label = defn.nav_label or ""
if post_slug and nav_label:
path = f"/{post_slug}/{nav_label}/{slug}/"
elif post_slug:
if post_slug:
path = f"/{post_slug}/{slug}/"
else:
path = f"/{slug}/"

View File

@@ -69,6 +69,107 @@ def _sexp_error_page(errnum: str, message: str, image: str | None = None) -> str
)
async def _rich_error_page(errnum: str, message: str, image: str | None = None) -> str | None:
"""Try to render an error page with site headers and post breadcrumb.
Returns HTML string on success, None if we should fall back to the
minimal error page. All failures are swallowed — this must never
make a bad situation worse.
"""
from quart import g
# Skip for internal/static requests
if request.path.startswith(("/internal/", "/static/", "/auth/")):
return None
try:
# If the app's url_value_preprocessor didn't run (no route match),
# manually extract the first path segment as a candidate slug and
# try to hydrate post data so context processors can use it.
segments = [s for s in request.path.strip("/").split("/") if s]
slug = segments[0] if segments else None
if slug and not getattr(g, "post_data", None):
# Set g.post_slug / g.page_slug so app context processors
# (inject_post, etc.) can pick it up.
from shared.infrastructure.data_client import fetch_data
raw = await fetch_data(
"blog", "post-by-slug",
params={"slug": slug},
required=False,
)
if raw:
g.post_slug = slug
g.page_slug = slug # cart uses page_slug
g.post_data = {
"post": {
"id": raw["id"],
"title": raw["title"],
"slug": raw["slug"],
"feature_image": raw.get("feature_image"),
"status": raw["status"],
"visibility": raw["visibility"],
},
}
# Also set page_post for cart app
from shared.contracts.dtos import PostDTO, dto_from_dict
post_dto = dto_from_dict(PostDTO, raw)
if post_dto and getattr(post_dto, "is_page", False):
g.page_post = post_dto
# Build template context (runs app context processors → fragments)
from shared.sexp.page import get_template_context
ctx = await get_template_context()
# Build headers: root header + post header if available
from shared.sexp.jinja_bridge import render
from shared.sexp.helpers import (
root_header_html, call_url, full_page,
)
hdr = root_header_html(ctx)
post = ctx.get("post") or {}
if post.get("slug"):
title = (post.get("title") or "")[:160]
feature_image = post.get("feature_image")
label_html = ""
if feature_image:
label_html += (
f'<img src="{feature_image}" '
f'class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0">'
)
label_html += f"<span>{escape(title)}</span>"
post_row = render(
"menu-row",
id="post-row", level=1,
link_href=call_url(ctx, "blog_url", f"/{post['slug']}/"),
link_label_html=label_html,
child_id="post-header-child",
external=True,
)
hdr += (
f'<div id="root-header-child" class="w-full">'
f'{post_row}'
f'</div>'
)
# Error content
error_html = (
'<div class="text-center p-8 max-w-lg mx-auto">'
f'<div class="font-bold text-2xl md:text-4xl text-red-500 mb-4">{errnum}</div>'
f'<div class="text-stone-600 mb-4">{escape(message)}</div>'
)
if image:
error_html += f'<div class="flex justify-center"><img src="{image}" width="300" height="300"></div>'
error_html += "</div>"
return full_page(ctx, header_rows_html=hdr, content_html=error_html)
except Exception:
current_app.logger.debug("Rich error page failed, falling back", exc_info=True)
return None
def errors(app):
def _info(e):
return {
@@ -94,17 +195,22 @@ def errors(app):
errnum='404'
)
else:
# Render via s-expressions (Phase 5 proof-of-concept)
try:
html = _sexp_error_page(
"404", "NOT FOUND",
image="/static/errors/404.gif",
)
except Exception:
html = await render_template(
"_types/root/exceptions/_.html",
errnum='404',
)
# Try a rich error page with site headers + post breadcrumb
html = await _rich_error_page(
"404", "NOT FOUND",
image="/static/errors/404.gif",
)
if html is None:
try:
html = _sexp_error_page(
"404", "NOT FOUND",
image="/static/errors/404.gif",
)
except Exception:
html = await render_template(
"_types/root/exceptions/_.html",
errnum='404',
)
return await make_response(html, 404)