Files
pagerite/pagerite/views.py
T
LeoVasanko a9693e1dcb Nested sidebar nav, banner inherit label fix, editor UX polish
- Sidebar renders the section's whole subtree as nested lists (third
  level and deeper, article-list-style markers); the one-item sidebar
  rule yields when that item has children of its own; only the viewed
  page is highlighted (navbar keeps ancestor highlighting); first_leaf
  skips unpublished branches
- Banner design selector: inherit option names the resolved design and
  its true source (backend banner_design_source excludes the node's own
  setting; doc payload carries banner_design_inherited)
- Article editor: fenced code block on empty lines, table size picker
  popup, borderless save icon disabled by saturate(0) alone
- While editing, window scroll is locked and only #main scrolls; the
  panel exactly fills the available window height; editor scroll drives
  the article
2026-08-19 00:25:07 +00:00

546 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""HTML rendering: page layout template, navigation, content pages.
All pages share one static layout, defined once as an html5tagger Template
with placeholders (capitalized attributes) filled per request. The dynamic
regions carry stable ids (#nav, #main) so that the fetch-navigation script
can swap them without reloading the page chrome.
Navigation walks the Node tree directly (see data.py): nav_html lists the
top level — the front page (slug "") is an ordinary top-level item, not
the parent of the others — and sidebar_html the sub-navigation of the
current top-level section, rendered only when the section offers at
least two published items. Nodes without content are category labels; nav links
to them point straight at their first child page (first_leaf), and their
own URL renders a placeholder page (render_category).
"""
from pathlib import Path
import json
import os
from html5tagger import HTML, Document, E, Template
from pagerite.data import Node, prettify, resolve, sorted_nodes
from pagerite.markdown import has_h1, render
SITE_NAME = "Pagerite"
BUILD = Path(__file__).with_name("frontend-build")
THEMES = Path(__file__).parent / "themes"
# The base CSS is built by Vite as a separate entry so the backend can link
# it independently of the theme. Themes and banner designs are plain .css
# files in THEMES/{name}/, served by the backend at /_themes/{name}/... and
# re-read from disk on every request (see app.py), so they are never built.
_BASE_CSS_KEY = "src/assets/pagerite.css"
_manifest_cache: dict | None = None
_asset_cache: dict[str, tuple] = {}
def _manifest() -> dict:
global _manifest_cache
if _manifest_cache is None:
_manifest_cache = json.loads((BUILD / ".vite/manifest.json").read_text())
return _manifest_cache
def _theme_names() -> list[str]:
"""Theme folders on disk (a folder is a theme when it has theme.css)."""
return sorted(
d.name for d in THEMES.iterdir() if d.is_dir() and (d / "theme.css").exists()
)
def _banner_design_names() -> list[str]:
"""Available banner designs: theme folders with artwork and/or styles."""
return sorted(
d.name
for d in THEMES.iterdir()
if d.is_dir()
and any((d / f).exists() for f in ("banner.css", "banner.svg", "banner.html"))
)
def _valid_name(name: str) -> bool:
"""Guard against path traversal in theme/design names."""
return bool(name) and "/" not in name and not name.startswith(".")
def _base_css_url(vite_url: str | None) -> str | None:
"""URL for the base stylesheet (None in dev: Vite injects it from JS,
avoiding the HMR-wrapped module output)."""
if vite_url:
return None
manifest = _manifest()
if _BASE_CSS_KEY in manifest:
return f"/{manifest[_BASE_CSS_KEY]['file']}"
return None
def _theme_css_url(theme: str) -> str | None:
"""URL for the theme stylesheet, served by the backend (dev and prod)."""
if theme and _valid_name(theme) and (THEMES / theme / "theme.css").exists():
return f"/_themes/{theme}/theme.css"
return None
def _banner_css_url(design: str) -> str | None:
"""URL for a banner design's stylesheet, served by the backend."""
if design and _valid_name(design) and (THEMES / design / "banner.css").exists():
return f"/_themes/{design}/banner.css"
return None
def _editor_css_url(vite_url: str | None) -> str | None:
"""URL for the editor-specific stylesheet (Vue component styles).
This is linked by the public-page edit pen so the editor styles are
loaded before the editor JS dynamic-import resolves.
"""
if vite_url:
return None
manifest = _manifest()
entry = manifest["src/main.js"]
base = manifest.get(_BASE_CSS_KEY, {}).get("file")
for css in entry.get("css", []):
if css != base:
return f"/{css}"
return None
def _layout(
modules: list[str] = (),
custom_css: str = "",
theme: str = "",
banner_design: str = "",
favicon: str = "",
) -> Template:
"""Page layout template with standard asset URLs and ES-module scripts.
Stylesheets use ``blocking="render"`` so the browser waits for them before
showing the page, avoiding a flash of unstyled content. Order matters and
is fixed: base (Vite build, absent in dev where Vite injects it from JS),
theme and banner design (backend-served from pagerite/themes/), then the
user's custom CSS last so it always wins.
In dev, pagerite.js re-appends the backend-rendered theme/design links
(and the custom CSS) after the Vite-injected base styles, keeping this
order intact.
"""
doc = Document(E.Title, lang="en")
# Responsive layout (see the 48rem breakpoint in pagerite.css) needs
# the real device width, not the default 980px layout viewport.
doc.meta(name="viewport", content="width=device-width, initial-scale=1")
# A custom favicon (from the site editor) is linked explicitly; without
# one, browsers fall back to the build's /favicon.ico by convention.
if favicon:
doc.link(rel="icon", href=f"/_f/{favicon}", id="pagerite-favicon")
# Editor asset URLs for pagerite.js, which injects the 🖊️ edit pens
# itself once it has validated the session (pages render identically
# for everyone; editing is gated by the auth proxy in front of /_api).
script, editor_css = _editor_assets()
doc.meta(name="pagerite:editor-src", content=script[-1])
if editor_css:
doc.meta(name="pagerite:editor-css", content=editor_css)
# Stylesheet links carry stable ids so the site editor's hot swap can
# keep each sheet at its rendered position (see swapRegions).
vite_url = os.environ.get("PAGERITE_VITE_URL")
sheets = [
("pagerite-base", _base_css_url(vite_url)),
("pagerite-theme", _theme_css_url(theme)),
("pagerite-banner", _banner_css_url(banner_design)),
]
for id_, url in sheets:
if url:
doc.link(rel="stylesheet", href=url, blocking="render", id=id_)
for src in modules:
doc.script(src=src, type="module")
if custom_css.strip():
doc.style(custom_css, id="pagerite-user")
return Template(
doc
.header(
E.div(E.Banner, id="page-banner"),
E.Brand,
E.nav(E.Nav, id="nav"),
id="banner",
)
.div(
E.Sidebar,
E.main(E.Main, id="main"),
id="content",
)
.footer(None), # kept empty for now; zero-height (see pagerite.css)
)
def _brand_link(brand: str, brand_html: str = "") -> HTML:
"""Header brand: custom HTML (in a #brand wrapper, rendered instead of
the link) when configured, else the plain brand link; omitted entirely
when neither is set."""
if brand_html.strip():
return HTML(str(E.div(HTML(brand_html), id="brand")))
return HTML(str(E.a(brand, href="/", id="brand"))) if brand else HTML("")
def _title(slug: str, node: Node) -> str:
"""Menu label: the configured title, prettified slug, "Home" fallback."""
return node.title or prettify(slug) or "Home"
def _nav_link(
doc, menu: dict[str, Node], node: Node, path: str, current: str,
ancestors_current: bool = True,
) -> None:
"""Render one <li> linking the node. Category labels (no content of
their own — None, or empty markdown as left by the site editor's
page creation) link straight to their first child page, so normal
navigation bypasses the placeholder/empty page at their own URL."""
# The navbar highlights a top-level item also when viewing any of its
# subpages; the sidebar highlights only the actually viewed page.
is_current = current == path or (
ancestors_current and path and current.startswith(f"{path}/")
)
href = f"/{path}"
if not node.content and (leaf := first_leaf(menu, path)) is not None:
href = f"/{leaf}"
doc.li.a(
_title(path.rpartition("/")[2], node),
href=href,
**{"class": "current"} if is_current else {},
)
def nav_html(menu: dict[str, Node], current: str) -> HTML:
"""Render the contents of the #nav element for the current path.
Top-level items in menu order; the front page (slug "", href "/")
competes by its order key like any sibling. Subitems of the current
section go to the sidebar (sidebar_html).
"""
nav = E.ul
with nav:
for slug, node in sorted_nodes(menu):
if node.published:
_nav_link(nav, menu, node, slug, current)
return HTML(str(nav))
def sidebar_html(menu: dict[str, Node], current: str) -> HTML:
"""Render the #sidebar element for the current path (empty when none).
The sidebar is the current main level section's sub-navigation: the
section's direct children as the top list level, with each item's own
published children nested under it (third level and deeper), so it
exists only when there is something to navigate: the section must
offer at least two published items, or exactly one while viewing
anything else than that only page — the section index, a 404, a
grandchild (otherwise those pages offer no way to reach the child) —
and viewing that only page itself still shows the sidebar when the
page has published children of its own to reach.
The front page, leaf pages, the sole childless page of a one-page
section and childless sections get no aside element at all (rather
than an empty or useless one-item box).
"""
if not current:
return HTML("")
section = current.split("/", 1)[0]
node = menu.get(section)
if node is None:
return HTML("")
items = [(s, c) for s, c in sorted_nodes(node.children) if c.published]
if not items:
return HTML("")
if len(items) == 1 and current == f"{section}/{items[0][0]}":
# Viewing the only item: useless unless it has children to reach.
if not any(c.published for c in items[0][1].children.values()):
return HTML("")
nav = E.ul
with nav:
for slug, child in items:
_sidebar_item(nav, menu, child, f"{section}/{slug}", current)
return HTML(str(E.aside(nav, id="sidebar")))
def _sidebar_item(doc, menu: dict[str, Node], node: Node, path: str, current: str) -> None:
"""One sidebar <li>: the node link, with its published children as a
nested list (third level and deeper, recursively)."""
_nav_link(doc, menu, node, path, current, ancestors_current=False)
sub = [(s, c) for s, c in sorted_nodes(node.children) if c.published]
if sub:
# doc.li.a(...) above left the <li> open for nesting.
with doc.ul:
for slug, child in sub:
_sidebar_item(doc, menu, child, f"{path}/{slug}", current)
def first_leaf(menu: dict[str, Node], path: str) -> str | None:
"""First published descendant page (content set) in menu order.
This is the nav-link target for content-less category labels.
"""
chain = resolve(menu, path)
if chain is None:
return None
return _first_leaf(chain[-1], path)
def _first_leaf(node: Node, path: str) -> str | None:
for slug, child in sorted_nodes(node.children):
if not child.published:
continue
cpath = f"{path}/{slug}" if path else slug
if child.content:
return cpath
if (leaf := _first_leaf(child, cpath)) is not None:
return leaf
return None
def banner_html(menu: dict[str, Node], path: str, theme: str = "") -> HTML:
"""Resolve the banner for a path: the effective banner design's inline
SVG artwork first, then the user's own banner HTML — always last, so
author code (e.g. <style> overrides) wins over the design's own styles.
The design comes from banner_design(); the user banner from the nearest
node on the ancestor chain (the node itself first), then the front page.
The front page is a top-level *sibling* of the other main-level nodes,
not their parent, so it never appears in the chain and is consulted
explicitly, last. The snippet is raw trusted HTML, so a banner can be
anything — an img, a styled div, canvas + script.
"""
parts = []
design = banner_design(menu, path, theme)
if design:
parts.append(_design_banner(design))
source = banner_source(menu, path)
if source is not None:
parts.append(HTML(resolve(menu, source)[-1].banner))
return HTML("".join(str(p) for p in parts))
def _design_banner(design: str) -> HTML:
"""The design's inline banner artwork (empty for none/unknown designs).
banner.html (arbitrary markup: canvas + style + script...) takes
precedence over banner.svg. Read from disk on every request: design
files are never built/cached, so editing them on disk shows on the
next page load, even in prod. The data-design wrapper marks the
artwork as the design's (not author code), so the site editor's live
banner preview keeps it in place.
"""
if not _valid_name(design):
return HTML("")
html = THEMES / design / "banner.html"
svg = THEMES / design / "banner.svg"
if html.exists():
body = html.read_text()
elif svg.exists():
body = svg.read_text()
else:
return HTML("")
return HTML(f'<div data-design="{design}">{body}</div>')
def theme_banner_design(theme: str) -> str:
"""The theme's own banner design (a theme folder doubles as a banner
design when it ships banner.css, banner.svg or banner.html), "" if it
has none."""
if _valid_name(theme) and any(
(THEMES / theme / f).exists()
for f in ("banner.css", "banner.svg", "banner.html")
):
return theme
return ""
def banner_design(menu: dict[str, Node], path: str, theme: str = "") -> str:
"""The effective banner design name at ``path`` ("" = no design).
Nodes set ``banner_design`` to a design name, "" (explicitly none) or
None (inherit). Resolution walks the ancestor chain from the node
upwards, then the front page, then falls back to the active theme's own
design.
"""
chain = resolve(menu, path) or []
for node in reversed(chain):
if node.banner_design is not None:
return node.banner_design
front = menu.get("")
if front and front.banner_design is not None:
return front.banner_design
return theme_banner_design(theme)
def banner_design_source(
menu: dict[str, Node], path: str, theme: str = ""
) -> str | None:
"""Which node's banner-design setting *would* apply at ``path`` if the
node itself set nothing: the nearest ancestor's path ("" = the front
page), or None when the active theme's default applies. Used by the
banner editor for the design selector's inherit label."""
chain = resolve(menu, path) or []
segs = path.split("/")
# Skip the node itself (chain[-1]): its own setting is not inheritance.
for i in range(len(chain) - 2, -1, -1):
if chain[i].banner_design is not None:
return "/".join(segs[: i + 1])
# The front page is a top-level sibling of the chain, not an ancestor;
# it does not inherit from itself.
front = menu.get("")
if path and front and front.banner_design is not None:
return ""
return None
def banner_source(menu: dict[str, Node], path: str) -> str | None:
"""Which node's banner HTML applies at ``path``: the nearest ancestor with
one set (the front page, a top-level sibling of the chain, last). None =
no user banner anywhere (only the design artwork renders, if any).
Whitespace-only banners count as empty: clearing the editor can leave a
stray newline behind."""
chain = resolve(menu, path) or []
segs = path.split("/")
for i in range(len(chain) - 1, -1, -1):
if chain[i].banner.strip():
return "/".join(segs[: i + 1])
front = menu.get("")
if front and front.banner.strip():
return ""
return None
def page_content(menu: dict[str, Node], path: str) -> HTML:
"""Render the contents of the #main element for a page."""
node = resolve(menu, path)[-1]
doc = E.article
with doc:
# An h1 in the markdown owns the article heading; the title is
# only rendered as h1 when the markdown has none of its own.
if not has_h1(node.content or ""):
doc.h1(node.title)
doc.div(
HTML(render(node.content or "", path, node.created, node.modified)),
class_="body",
)
return HTML(str(doc))
def render_page(
menu: dict[str, Node],
path: str,
brand: str = SITE_NAME,
custom_css: str = "",
theme: str = "",
favicon: str = "",
brand_html: str = "",
) -> str:
"""Render a full HTML page for the slug path."""
node = resolve(menu, path)[-1]
title = _title(path.rpartition("/")[2], node)
return str(
_layout(_page_assets(), custom_css, theme, banner_design(menu, path, theme), favicon)(
Title=f"{title} {brand}" if brand else title,
Brand=_brand_link(brand, brand_html),
Nav=nav_html(menu, path),
Sidebar=sidebar_html(menu, path),
Banner=banner_html(menu, path, theme),
Main=page_content(menu, path),
),
)
def render_category(
menu: dict[str, Node],
path: str,
brand: str = SITE_NAME,
custom_css: str = "",
theme: str = "",
favicon: str = "",
brand_html: str = "",
) -> str:
"""Render the placeholder for a content-less category label (404).
The node exists in the tree but has no page of its own. Nav links
point straight at its first child, so this is mainly seen in the site
editor, where the pen creates the landing page.
"""
node = resolve(menu, path)[-1]
title = _title(path.rpartition("/")[2], node)
sidebar = sidebar_html(menu, path)
doc = E.article
with doc:
doc.h1(title)
if sidebar:
doc.p("Pages in this section are listed in the menu on the left.")
else:
doc.p("This section has no page of its own yet.")
return str(
_layout(_page_assets(), custom_css, theme, banner_design(menu, path, theme), favicon)(
Title=f"{title} {brand}" if brand else title,
Brand=_brand_link(brand, brand_html),
Nav=nav_html(menu, path),
Sidebar=sidebar,
Banner=banner_html(menu, path, theme),
Main=HTML(str(doc)),
),
)
def render_not_found(
menu: dict[str, Node],
path: str,
brand: str = SITE_NAME,
custom_css: str = "",
theme: str = "",
favicon: str = "",
brand_html: str = "",
) -> str:
"""Render a 404 page within the normal layout."""
doc = E.article
with doc:
doc.h1("Not Found")
doc.p(f"No article at /{path}. If there was before, it may have been deleted.")
return str(
_layout(_page_assets(), custom_css, theme, banner_design(menu, path, theme), favicon)(
Title=f"Not Found {brand}" if brand else "Not Found",
Brand=_brand_link(brand, brand_html),
Nav=nav_html(menu, path),
Sidebar=sidebar_html(menu, path),
Banner=banner_html(menu, path, theme),
Main=HTML(str(doc)),
),
)
def _page_assets() -> list[str]:
"""Script URLs for public pages (pagerite entry).
Dev mode loads the entry from the Vite dev server; production uses
the Vite build manifest to resolve the hashed asset names.
"""
vite_url = os.environ.get("PAGERITE_VITE_URL")
if vite_url:
return [f"{vite_url}/src/pagerite.js"]
if "page" not in _asset_cache:
manifest = _manifest()
entry = manifest["src/pagerite.js"]
_asset_cache["page"] = [f"/{entry['file']}"]
return _asset_cache["page"]
def _editor_assets() -> tuple[list[str], str | None]:
"""Script URL and editor-specific CSS URL for the public-page edit pen.
The shared CSS is already linked on the page, so the pen only needs the
editor-specific stylesheet.
"""
vite_url = os.environ.get("PAGERITE_VITE_URL")
if vite_url:
return [f"{vite_url}/@vite/client", f"{vite_url}/src/main.js"], None
if "editor" not in _asset_cache:
manifest = _manifest()
entry = manifest["src/main.js"]
_asset_cache["editor"] = [f"/{entry['file']}"], _editor_css_url(None)
return _asset_cache["editor"]