FastAPI backend rendering HTML with html5tagger, content persisted in a kanta database and rendered per request. Vue only for the editing tools (page editor over a WebSocket, site/structure editor); public pages are plain HTML with fetch navigation. No auth: single trusted author.
134 lines
4.6 KiB
Python
134 lines
4.6 KiB
Python
"""Data model persisted in the kanta database.
|
|
|
|
The site structure is a tree of Nodes. Every node is a menu label with a
|
|
configurable title and slug (its key in the parent's ``children``); the
|
|
URL path is the chain of slugs from the top level. ``content`` is the
|
|
node's Markdown page, or None for a pure category label, whose URL
|
|
redirects to the first child page.
|
|
"""
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
import msgspec
|
|
|
|
|
|
class Node(msgspec.Struct, omit_defaults=True):
|
|
"""One item of the site hierarchy.
|
|
|
|
Siblings are ordered by the fractional ``order`` key (never list
|
|
positions): a moved item takes a fresh key relative to its new
|
|
siblings, all other items keep theirs.
|
|
|
|
The front page is whichever top-level node has slug "" (URL "/") — an
|
|
item parallel to the other main-level pages, not their parent, so it
|
|
cannot have children. Renaming it away leaves no front page ("/"
|
|
redirects to the first nav item); any childless top-level node can
|
|
take the empty slug.
|
|
"""
|
|
|
|
title: str = ""
|
|
order: float = 0
|
|
#: Markdown source of the node's page; None = pure category label
|
|
#: (redirects to the first child page).
|
|
content: str | None = None
|
|
#: Raw HTML for the header banner (img, styled div, canvas+script...).
|
|
#: Empty inherits the nearest ancestor's banner, front page last.
|
|
banner: str = ""
|
|
published: bool = True
|
|
children: dict[str, "Node"] = {}
|
|
created: datetime = msgspec.field(
|
|
default_factory=lambda: datetime.now(UTC),
|
|
)
|
|
modified: datetime = msgspec.field(
|
|
default_factory=lambda: datetime.now(UTC),
|
|
)
|
|
|
|
|
|
class Page(msgspec.Struct, omit_defaults=True):
|
|
"""Legacy flat page record, from before the tree model.
|
|
|
|
Kept only so old databases still decode; app.py migrates any entries
|
|
into ``Data.menu`` on startup and clears this.
|
|
"""
|
|
|
|
title: str
|
|
markdown: str
|
|
published: bool = True
|
|
order: float = 0
|
|
banner: str = ""
|
|
created: datetime = msgspec.field(
|
|
default_factory=lambda: datetime.now(UTC),
|
|
)
|
|
modified: datetime = msgspec.field(
|
|
default_factory=lambda: datetime.now(UTC),
|
|
)
|
|
|
|
|
|
class Data(msgspec.Struct):
|
|
"""Root object of the kanta database. Owned and edited in place by us."""
|
|
|
|
#: Top-level menu items by slug; "" is the front page.
|
|
menu: dict[str, Node] = {}
|
|
#: Content-addressed file store: name (blake3 hash prefix + extension)
|
|
#: -> bytes, served immutable at "/_/{name}". Absolute URLs that stay
|
|
#: valid when pages move.
|
|
files: dict[str, bytes] = {}
|
|
#: Bumped on every structure/content write, so page ETags (which embed
|
|
#: it) invalidate cached copies when navigation-affecting changes happen.
|
|
version: int = 0
|
|
#: Site name shown in the header and <title> suffix; editable in the
|
|
#: site editor. Empty = no brand link in the header, no title suffix.
|
|
brand: str = "Pagerite"
|
|
#: Legacy flat page store (pre-tree databases); migrated into `menu`
|
|
#: on startup, then cleared. Never written otherwise.
|
|
pages: dict[str, Page] = {}
|
|
|
|
|
|
def prettify(slug: str) -> str:
|
|
"""Human-readable default title for a slug segment."""
|
|
return slug.replace("-", " ").replace("_", " ").title()
|
|
|
|
|
|
def resolve(menu: dict[str, Node], path: str) -> list[Node] | None:
|
|
"""Chain of nodes from the top level down to ``path`` ("" = front page).
|
|
|
|
chain[0] is a top-level node, chain[-1] the node itself — the chain is
|
|
useful for banner inheritance. None when any segment is missing.
|
|
"""
|
|
chain = []
|
|
nodes = menu
|
|
for seg in path.split("/"):
|
|
node = nodes.get(seg)
|
|
if node is None:
|
|
return None
|
|
chain.append(node)
|
|
nodes = node.children
|
|
return chain
|
|
|
|
|
|
def find_slot(menu: dict[str, Node], path: str) -> tuple[dict[str, Node], str] | None:
|
|
"""The (children dict, slug) slot holding the node at ``path``.
|
|
|
|
The returned dict is live: deleting or inserting the slug moves the
|
|
node (its whole subtree travels with it). None when the parent chain
|
|
does not resolve.
|
|
"""
|
|
segs = path.split("/")
|
|
nodes = menu
|
|
for seg in segs[:-1]:
|
|
node = nodes.get(seg)
|
|
if node is None:
|
|
return None
|
|
nodes = node.children
|
|
return nodes, segs[-1]
|
|
|
|
|
|
def sorted_nodes(nodes: dict[str, Node]) -> list[tuple[str, Node]]:
|
|
"""(slug, node) pairs in menu order: fractional order key, then title."""
|
|
return sorted(nodes.items(), key=lambda kv: (kv[1].order, kv[1].title.lower()))
|
|
|
|
|
|
def append_order(nodes: dict[str, Node]) -> float:
|
|
"""Order value appending an item at the end of a sibling level."""
|
|
return max((n.order for n in nodes.values()), default=0) + 1
|