"""
Blog service s-expression page components.
Renders home, blog index (posts/pages), new post/page, post detail,
post admin, post data, post entries, post edit, post settings,
settings home, cache, snippets, menu items, and tag groups pages.
Called from route handlers in place of ``render_template()``.
"""
from __future__ import annotations
from typing import Any
from markupsafe import escape
from shared.sexp.jinja_bridge import sexp
from shared.sexp.helpers import (
call_url, get_asset_url, root_header_html,
search_mobile_html, search_desktop_html,
full_page, oob_page,
)
# ---------------------------------------------------------------------------
# OOB header helper
# ---------------------------------------------------------------------------
def _oob_header_html(parent_id: str, child_id: str, row_html: str) -> str:
"""Wrap a header row in OOB div with child placeholder."""
return (
f'
'
f'
{row_html}'
f'
'
)
# ---------------------------------------------------------------------------
# Blog header (root-header-child -> blog-header-child)
# ---------------------------------------------------------------------------
def _blog_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Blog header row — empty child of root."""
return sexp(
'(~menu-row :id "blog-row" :level 1'
' :link-label-html llh'
' :child-id "blog-header-child" :oob oob)',
llh="",
oob=oob,
)
# ---------------------------------------------------------------------------
# Post header helpers
# ---------------------------------------------------------------------------
def _post_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the post-level header row."""
post = ctx.get("post") or {}
slug = post.get("slug", "")
title = (post.get("title") or "")[:160]
feature_image = post.get("feature_image")
label_parts = []
if feature_image:
label_parts.append(
f''
)
label_parts.append(f"{escape(title)}")
label_html = "".join(label_parts)
nav_parts = []
page_cart_count = ctx.get("page_cart_count", 0)
if page_cart_count and page_cart_count > 0:
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
nav_parts.append(
f''
f''
f'{page_cart_count}'
)
# Container nav fragments (calendars, markets)
container_nav = ctx.get("container_nav_html", "")
if container_nav:
nav_parts.append(
f'
{container_nav}
'
)
# Admin link
from quart import url_for as qurl, g
rights = ctx.get("rights") or {}
has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
if has_admin:
hx_select = ctx.get("hx_select_search", "#main-panel")
select_colours = ctx.get("select_colours", "")
styles = ctx.get("styles") or {}
nav_btn = styles.get("nav_button", "") if isinstance(styles, dict) else getattr(styles, "nav_button", "")
admin_href = qurl("blog.post.admin.admin", slug=slug)
nav_parts.append(
f'
')
parts.append(_at_bar_html(post, ctx))
parts.append('')
return "".join(parts)
def _at_bar_html(post: dict, ctx: dict) -> str:
"""Tags + authors bar below a card."""
tags = post.get("tags") or []
authors = post.get("authors") or []
if not tags and not authors:
return ""
all_tags = ctx.get("tags") or []
tag_slugs = {t.get("slug") or getattr(t, "slug", "") for t in all_tags} if all_tags else set()
parts = ['
']
if tags:
parts.append('
in
')
for t in tags:
t_slug = t.get("slug") or getattr(t, "slug", "")
t_name = t.get("name") or getattr(t, "name", "")
t_fi = t.get("feature_image") or getattr(t, "feature_image", None)
if t_fi:
icon = f''
else:
init = escape(t_name[:1]) if t_name else ""
icon = (
f'
')
for a in authors:
a_name = a.get("name") or getattr(a, "name", "")
a_img = a.get("profile_image") or getattr(a, "profile_image", None)
if a_img:
parts.append(
f'
'
f''
f'{escape(a_name)}
'
)
else:
parts.append(f'
{escape(a_name)}
')
parts.append('
')
parts.append('
')
return "".join(parts)
def _blog_sentinel_html(ctx: dict) -> str:
"""Infinite scroll sentinels for blog post list."""
page = ctx.get("page", 1)
total_pages = ctx.get("total_pages", 1)
if isinstance(total_pages, str):
total_pages = int(total_pages)
if page >= total_pages:
return '
End of results
'
current_local_href = ctx.get("current_local_href", "/index")
qs_fn = ctx.get("qs")
# Build next page URL
next_url = f"{current_local_href}?page={page + 1}"
parts = []
# Mobile sentinel
parts.append(
f'
'
f'
'
f'
Loading failed — retrying…
'
f'
'
)
# Desktop sentinel
parts.append(
f'
'
f'
'
f'
Retry…
'
f'
'
)
return "".join(parts)
def _page_cards_html(ctx: dict) -> str:
"""Render page cards with sentinel."""
pages = ctx.get("pages") or ctx.get("posts") or []
page_num = ctx.get("page", 1)
total_pages = ctx.get("total_pages", 1)
if isinstance(total_pages, str):
total_pages = int(total_pages)
hx_select = ctx.get("hx_select_search", "#main-panel")
parts = []
for pg in pages:
parts.append(_page_card_html(pg, ctx))
if page_num < total_pages:
current_local_href = ctx.get("current_local_href", "/index?type=pages")
next_url = f"{current_local_href}&page={page_num + 1}" if "?" in current_local_href else f"{current_local_href}?page={page_num + 1}"
parts.append(
f''
)
elif pages:
parts.append('
']
if has_admin:
new_href = call_url(ctx, "blog_url", "/new/")
parts.append(
f' New Post'
)
new_page_href = call_url(ctx, "blog_url", "/new-page/")
parts.append(
f' New Page'
)
if user and (draft_count or drafts):
if drafts:
off_href = f"{current_local_href}"
parts.append(
f' Drafts'
f' {draft_count}'
)
else:
on_href = f"{current_local_href}{'&' if '?' in current_local_href else '?'}drafts=1"
parts.append(
f' Drafts'
f' {draft_count}'
)
parts.append('
')
return "".join(parts)
def _tag_groups_filter_html(ctx: dict) -> str:
"""Tag group filter bar for desktop/mobile."""
tag_groups = ctx.get("tag_groups") or []
selected_groups = ctx.get("selected_groups") or ()
selected_tags = ctx.get("selected_tags") or ()
hx_select = ctx.get("hx_select_search", "#main-panel")
parts = ['')
return "".join(parts)
def _authors_filter_html(ctx: dict) -> str:
"""Author filter bar for desktop/mobile."""
authors = ctx.get("authors") or []
selected_authors = ctx.get("selected_authors") or ()
hx_select = ctx.get("hx_select_search", "#main-panel")
parts = ['')
return "".join(parts)
def _tag_groups_filter_summary_html(ctx: dict) -> str:
"""Mobile filter summary for tag groups."""
selected_groups = ctx.get("selected_groups") or ()
tag_groups = ctx.get("tag_groups") or []
if not selected_groups:
return ""
names = []
for g in tag_groups:
g_slug = getattr(g, "slug", "") if hasattr(g, "slug") else g.get("slug", "")
g_name = getattr(g, "name", "") if hasattr(g, "name") else g.get("name", "")
if g_slug in selected_groups:
names.append(g_name)
if not names:
return ""
return f'{escape(", ".join(names))}'
def _authors_filter_summary_html(ctx: dict) -> str:
"""Mobile filter summary for authors."""
selected_authors = ctx.get("selected_authors") or ()
authors = ctx.get("authors") or []
if not selected_authors:
return ""
names = []
for a in authors:
a_slug = getattr(a, "slug", "") if hasattr(a, "slug") else a.get("slug", "")
a_name = getattr(a, "name", "") if hasattr(a, "name") else a.get("name", "")
if a_slug in selected_authors:
names.append(a_name)
if not names:
return ""
return f'{escape(", ".join(names))}'
# ---------------------------------------------------------------------------
# Post detail main panel
# ---------------------------------------------------------------------------
def _post_main_panel_html(ctx: dict) -> str:
"""Post/page article content."""
from quart import g, url_for as qurl
post = ctx.get("post") or {}
slug = post.get("slug", "")
user = getattr(g, "user", None)
rights = ctx.get("rights") or {}
is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
hx_select = ctx.get("hx_select_search", "#main-panel")
parts = ['']
# Draft indicator
if post.get("status") == "draft":
parts.append('
')
parts.append('Draft')
if post.get("publish_requested"):
parts.append('Publish requested')
if is_admin or (user and post.get("user_id") == getattr(user, "id", None)):
edit_href = qurl("blog.post.admin.edit", slug=slug)
parts.append(
f''
f' Edit'
)
parts.append('
')
# Blog post chrome (not for pages)
if not post.get("is_page"):
if user:
liked = post.get("is_liked", False)
like_url = call_url(ctx, "blog_url", f"/{slug}/like/toggle/")
parts.append(
f'
'
f'
'
)
if post.get("custom_excerpt"):
parts.append(f'
{post["custom_excerpt"]}
')
# Desktop at_bar
parts.append(f'
{_at_bar_html(post, ctx)}
')
# Feature image
fi = post.get("feature_image")
if fi:
parts.append(f'
')
# Post HTML content
html_content = post.get("html", "")
if html_content:
parts.append(f'
{html_content}
')
parts.append('')
return "".join(parts)
def _post_meta_html(ctx: dict) -> str:
"""Post SEO meta tags (Open Graph, Twitter, JSON-LD)."""
post = ctx.get("post") or {}
base_title = ctx.get("base_title", "")
is_public = post.get("visibility") == "public"
is_published = post.get("status") == "published"
email_only = post.get("email_only", False)
robots = "index,follow" if (is_public and is_published and not email_only) else "noindex,nofollow"
# Description
desc = (post.get("meta_description") or post.get("og_description") or
post.get("twitter_description") or post.get("custom_excerpt") or
post.get("excerpt") or "")
if not desc and post.get("html"):
import re
desc = re.sub(r'<[^>]+>', '', post["html"])
desc = desc.replace("\n", " ").replace("\r", " ").strip()[:160]
# Image
image = (post.get("og_image") or post.get("twitter_image") or post.get("feature_image") or "")
# Canonical
from quart import request as req
canonical = post.get("canonical_url") or (req.url if req else "")
og_title = post.get("og_title") or base_title
tw_title = post.get("twitter_title") or base_title
is_article = not post.get("is_page")
parts = [f'']
parts.append(f'{escape(base_title)}')
parts.append(f'')
if canonical:
parts.append(f'')
parts.append(f'')
parts.append(f'')
parts.append(f'')
if canonical:
parts.append(f'')
if image:
parts.append(f'')
parts.append(f'')
parts.append(f'')
parts.append(f'')
if image:
parts.append(f'')
return "".join(parts)
# ---------------------------------------------------------------------------
# Home page (Ghost "home" page)
# ---------------------------------------------------------------------------
def _home_main_panel_html(ctx: dict) -> str:
"""Home page content — renders the Ghost page HTML."""
post = ctx.get("post") or {}
html = post.get("html", "")
return f'
{html}
'
# ---------------------------------------------------------------------------
# Post admin - empty main panel
# ---------------------------------------------------------------------------
def _post_admin_main_panel_html(ctx: dict) -> str:
return ''
# ---------------------------------------------------------------------------
# Settings main panels
# ---------------------------------------------------------------------------
def _settings_main_panel_html(ctx: dict) -> str:
return ''
def _cache_main_panel_html(ctx: dict) -> str:
from quart import url_for as qurl
csrf = ctx.get("csrf_token", "")
clear_url = qurl("settings.cache_clear")
return (
f'
'
f'
'
f''
f''
f'
'
)
# ---------------------------------------------------------------------------
# Snippets main panel
# ---------------------------------------------------------------------------
def _snippets_main_panel_html(ctx: dict) -> str:
return (
f'
'
f'
'
f'
Snippets
'
f'
{_snippets_list_html(ctx)}
'
)
def _snippets_list_html(ctx: dict) -> str:
"""Snippets list with visibility badges and delete buttons."""
from quart import url_for as qurl, g
snippets = ctx.get("snippets") or []
is_admin = ctx.get("is_admin", False)
csrf = ctx.get("csrf_token", "")
user = getattr(g, "user", None)
user_id = getattr(user, "id", None)
if not snippets:
return (
'
']
for s in snippets:
s_id = getattr(s, "id", None) or s.get("id")
s_name = getattr(s, "name", "") if hasattr(s, "name") else s.get("name", "")
s_uid = getattr(s, "user_id", None) if hasattr(s, "user_id") else s.get("user_id")
s_vis = getattr(s, "visibility", "private") if hasattr(s, "visibility") else s.get("visibility", "private")
owner = "You" if s_uid == user_id else f"User #{s_uid}"
badge_cls = badge_colours.get(s_vis, "bg-stone-200 text-stone-700")
parts.append(
f'
'
f'
{escape(s_name)}
'
f'
{owner}
'
f'{s_vis}'
)
if is_admin:
patch_url = qurl("snippets.patch_visibility", snippet_id=s_id)
parts.append(
f'')
if s_uid == user_id or is_admin:
del_url = qurl("snippets.delete_snippet", snippet_id=s_id)
parts.append(
f''
)
parts.append('
')
parts.append('
')
return "".join(parts)
# ---------------------------------------------------------------------------
# Menu items main panel
# ---------------------------------------------------------------------------
def _menu_items_main_panel_html(ctx: dict) -> str:
from quart import url_for as qurl
new_url = qurl("menu_items.new_menu_item")
return (
f'
'
f'
'
f'
'
f''
f'
{_menu_items_list_html(ctx)}
'
)
def _menu_items_list_html(ctx: dict) -> str:
from quart import url_for as qurl
menu_items = ctx.get("menu_items") or []
csrf = ctx.get("csrf_token", "")
if not menu_items:
return (
'
'
'
'
''
'
No menu items yet. Add one to get started!
'
)
parts = ['
']
for item in menu_items:
i_id = getattr(item, "id", None) or item.get("id")
label = getattr(item, "label", "") if hasattr(item, "label") else item.get("label", "")
slug = getattr(item, "slug", "") if hasattr(item, "slug") else item.get("slug", "")
fi = getattr(item, "feature_image", None) if hasattr(item, "feature_image") else item.get("feature_image")
sort = getattr(item, "sort_order", 0) if hasattr(item, "sort_order") else item.get("sort_order", 0)
edit_url = qurl("menu_items.edit_menu_item", item_id=i_id)
del_url = qurl("menu_items.delete_menu_item_route", item_id=i_id)
img = (f''
if fi else '')
parts.append(
f'
'
f'
'
f'{img}'
f'
{escape(label)}
'
f'
{escape(slug)}
'
f'
Order: {sort}
'
f'
'
f''
f'
'
)
parts.append('
')
return "".join(parts)
# ---------------------------------------------------------------------------
# Tag groups main panel
# ---------------------------------------------------------------------------
def _tag_groups_main_panel_html(ctx: dict) -> str:
from quart import url_for as qurl
groups = ctx.get("groups") or []
unassigned_tags = ctx.get("unassigned_tags") or []
csrf = ctx.get("csrf_token", "")
parts = ['
']
# Create form
create_url = qurl("blog.tag_groups_admin.create")
parts.append(
f''
)
# Groups list
if groups:
parts.append('
')
for group in groups:
g_id = getattr(group, "id", None) or group.get("id")
g_name = getattr(group, "name", "") if hasattr(group, "name") else group.get("name", "")
g_slug = getattr(group, "slug", "") if hasattr(group, "slug") else group.get("slug", "")
g_fi = getattr(group, "feature_image", None) if hasattr(group, "feature_image") else group.get("feature_image")
g_colour = getattr(group, "colour", None) if hasattr(group, "colour") else group.get("colour")
g_sort = getattr(group, "sort_order", 0) if hasattr(group, "sort_order") else group.get("sort_order", 0)
edit_href = qurl("blog.tag_groups_admin.edit", id=g_id)
if g_fi:
icon = f''
else:
style = f"background-color: {g_colour}; color: white;" if g_colour else "background-color: #e7e5e4; color: #57534e;"
icon = (
f'