Fix relations nav_label URL bug and add rich 404 pages with headers
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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user