From 597b0d7a2fefd95cee209ce047922420d97db8c1 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 28 Feb 2026 18:30:44 +0000 Subject: [PATCH] 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 --- relations/bp/fragments/routes.py | 5 +- shared/browser/app/errors.py | 128 ++++++++++++++++++++++++++++--- 2 files changed, 118 insertions(+), 15 deletions(-) diff --git a/relations/bp/fragments/routes.py b/relations/bp/fragments/routes.py index d323720..b2ccfea 100644 --- a/relations/bp/fragments/routes.py +++ b/relations/bp/fragments/routes.py @@ -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}/" diff --git a/shared/browser/app/errors.py b/shared/browser/app/errors.py index 71bc020..4b8e0e4 100644 --- a/shared/browser/app/errors.py +++ b/shared/browser/app/errors.py @@ -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'' + ) + label_html += f"{escape(title)}" + 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'
' + f'{post_row}' + f'
' + ) + + # Error content + error_html = ( + '
' + f'
{errnum}
' + f'
{escape(message)}
' + ) + if image: + error_html += f'
' + error_html += "
" + + 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)