Rework category labels, slug validation, and task-list checkboxes

- Remove the section-header  "add landing page" button; landing pages are
  now created via the page editor pen.
- Treat saving a page with empty (after stripping) Markdown as deleting it:
  childless nodes are removed, nodes with children become content-less labels.
- Restrict server-side slugs to [a-z0-9_-] with no leading underscore or dot;
  drop the reserved-filename blacklist.
- Render placeholder pages for content-less category labels and link them to
  their first published child in the navigation.
- Make task-list checkboxes live, non-disabled inputs: toggling updates the
  Markdown source, updates the open CodeMirror document when the page editor is
  open, and persists to the server when no editor is open. Errors revert the UI.
- Update docs and AGENTS.md conventions accordingly.
This commit is contained in:
2026-08-16 19:04:55 +00:00
parent 3dc438f8df
commit a8cf5a6a82
12 changed files with 304 additions and 131 deletions
+11 -14
View File
@@ -39,9 +39,9 @@ not for the public pages. See `docs/design-principles.md` for the design.
main level pages, not their parent); it cannot have children, and
renaming its slug away leaves no front page ("/" redirects to the
first nav item). `Node.content` is
the Markdown page, or None for a pure category label whose URL
redirects to its first child; every label's title and slug are
editable. Siblings order by the fractional `Node.order` key: a moved
the Markdown page, or None for a pure category label whose URL renders
a placeholder page (while nav links to it point at its first child);
every label's title and slug are editable. Siblings order by the fractional `Node.order` key: a moved
item gets a fresh key relative to its new siblings, all others keep
theirs. `resolve`/`find_slot` walk the tree by path; moves are slot
detach/attach carrying the whole subtree. Legacy flat `Data.pages`
@@ -65,8 +65,8 @@ not for the public pages. See `docs/design-principles.md` for the design.
- `views.py` — the shared page layout as an html5tagger `Template` with
placeholders (`Title`, `Brand`, `Banner`, `Nav`, `Sidebar`, `Main`), nav
rendering straight from the `Data.menu` tree (siblings sorted by
`Node.order`; content-less labels redirect to their first child via
`first_leaf`), and page/404 rendering. If the markdown contains its own h1, the page title
`Node.order`; nav links to content-less labels point at their first
child via `first_leaf`), and page/404 rendering. If the markdown contains its own h1, the page title
is NOT rendered as an additional h1 (it still supplies <title> and nav
labels). The navbar holds
top-level items only; the current section's subitems go to a left
@@ -171,15 +171,12 @@ not for the public pages. See `docs/design-principles.md` for the design.
- Keep dependencies minimal; add via `uv add` and mention it.
- The public URL space belongs to content (pretty slugs at root). Reserve
only `/_/` for the machinery (files, API, built assets, admin), plus
`/favicon.ico` from the build. Slugs are lowercase ASCII `[a-z0-9-]`
(the site editor filters input live via `slugify.js`, built on the
`transliteration` npm package — unicode folds to ASCII, spaces become
hyphens; an empty slug on a new page is derived from its title), may
not begin with `_` or `.`, and may not be a reserved file name
(`robots.txt`, `ads.txt`, `sitemap.xml`, `openapi.json`, `favicon.ico`,
`site.webmanifest`). The API rejects such paths with a human-readable
reason shown in the editor, and such URLs are never looked up as
content when serving.
`/favicon.ico` from the build. Slugs are lowercase ASCII letters, digits,
hyphens and underscores `[a-z0-9_-]` (the site editor filters input live
via `slugify.js`, built on the `transliteration` npm package — unicode
folds to ASCII, spaces become hyphens; an empty slug on a new page is
derived from its title), may not begin with `_` or `.`, and such URLs are
never looked up as content.
- No auth in core code; trusted single author. Never add output
sanitization "for safety" against the author — embedded HTML/scripts in
Markdown are passed through deliberately.
+9 -8
View File
@@ -32,11 +32,10 @@ evolves.
lives under `/_/` (the API at `/_/api/`, uploaded files at `/_/f/`, built
assets at `/_/assets/`, and the admin shell at `/_/admin`). The only
other reserved root path is `/favicon.ico`, served from the build.
Slugs are lowercase ASCII (`[a-z0-9-]`; input is transliterated and
filtered as you type, and a new page's empty slug is derived from its
title), may not begin with `_` or `.`, and may not occupy a reserved
root file name (`robots.txt`, `sitemap.xml`, `favicon.ico`, …) — such
URLs are never looked up as content.
Slugs are lowercase ASCII letters, digits, hyphens and underscores
(`[a-z0-9_-]`; input is transliterated and filtered as you type, and a
new page's empty slug is derived from its title), may not begin with
`_` or `.`, and such URLs are never looked up as content.
- **Single user, trusted author.** No auth concerns in the core design.
Everything published is public; only editing tools will later sit behind
access control (external SSO when that time comes). The author is trusted
@@ -105,9 +104,11 @@ evolves.
the sidebar is empty (and hidden) elsewhere. Other sections' subitems
are never shown without navigating into them first.
- **Landing pages are optional.** Every label can either have content
(`Node.content`, a Markdown page) or none — a content-less label
redirects to its first child instead of 404ing, so categories need no
filler content. Title and slug of every label are editable; renaming a
(`Node.content`, a Markdown page) or none — a content-less label renders
a placeholder page (404 with a pen to create it) instead of redirecting,
while nav links to it point straight at its first child, so categories
need no filler content and normal navigation never sees the placeholder.
Title and slug of every label are editable; renaming a
slug moves the whole subtree. The sidebar never lists the section
itself, avoiding title duplication with the navbar.
- **Menu order is manual.** Each node has a fractional `order` key among
+10 -2
View File
@@ -120,8 +120,10 @@ function openPath(p) {
send({ type: 'open', path: p })
}
function setDocument(text) {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } })
function setDocument(text, preserveSelection = false) {
const tr = { changes: { from: 0, to: view.state.doc.length, insert: text } }
if (preserveSelection) tr.selection = view.state.selection
view.dispatch(tr)
}
function onHashChange() {
@@ -272,6 +274,11 @@ onMounted(() => {
parent: editorEl.value,
})
view.scrollDOM.addEventListener('scroll', syncScroll)
window.__pageritePageEditor = {
getMarkdown: () => view.state.doc.toString(),
setMarkdown: (text) => setDocument(text, true),
path: () => path.value,
}
if (props.standalone) addEventListener('hashchange', onHashChange)
addEventListener('keydown', onKeydown)
})
@@ -283,6 +290,7 @@ onUnmounted(() => {
ws.close()
}
view?.destroy()
delete window.__pageritePageEditor
if (props.standalone) removeEventListener('hashchange', onHashChange)
removeEventListener('keydown', onKeydown)
})
+6 -23
View File
@@ -8,8 +8,8 @@
//
// The tree comes from the server nested (GET /_/api/pages); every node is
// real — a label with a title and slug, with content (landing page) or
// without (category redirecting to its first child). The front page is a
// top-level row with an empty slug, not the parent of the others.
// without (category whose URL renders a placeholder page). The front page
// is a top-level row with an empty slug, not the parent of the others.
import { computed, onMounted, onUnmounted, provide, ref } from 'vue'
import StructureTree from './StructureTree.vue'
import { EditorView, basicSetup } from 'codemirror'
@@ -125,8 +125,8 @@ async function loadPlain(p) {
const res = await fetch(finalUrl)
const type = res.headers.get('content-type') || ''
if (!type.includes('text/html')) return
// Category URLs redirect to their first child; reflect that. A 404
// layout is fine too (new pages are created by editing them).
// Category and missing URLs render a placeholder 404 page — fine to
// swap in (new pages are created by editing them).
if (res.redirected) finalUrl = res.url
doc = new DOMParser().parseFromString(await res.text(), 'text/html')
} catch { return }
@@ -239,22 +239,6 @@ async function commitPending() {
navigate(newPath)
}
// Give a content-less category a landing page (empty page at its path).
async function addContent(node) {
const res = await fetch(`/_/api/pages/${node.path}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: node.title, markdown: '', published: node.published }),
})
if (res.ok) {
saveError.value = ''
await refreshPages()
navigate(node.path)
} else {
saveError.value = '⚠️ changes could not be saved'
}
}
// --- Site-wide brand (header link + <title> suffix) ----------------------
// Edits apply to the live page immediately and save while typing. An
// empty brand removes the header link and the title suffix entirely.
@@ -330,8 +314,8 @@ async function removePage(node) {
refreshPages()
const p = node.path
if (p === path.value || (p && path.value.startsWith(`${p}/`))) {
// The current page was deleted — or reduced to a category that now
// redirects to its first child. Either way, re-render from the server.
// The current page was deleted — or reduced to a category, which now
// renders a placeholder page. Either way, re-render from the server.
if (node.children.length) loadPlain(path.value)
else navigate('')
} else {
@@ -429,7 +413,6 @@ provide('structureHandlers', {
reorder: onReorder,
titleInput: onTitleInput,
commitSlug,
addContent,
commitPending,
discardPending,
newPage,
+2 -9
View File
@@ -5,8 +5,8 @@
// Every node is real: a label whose title and slug are always editable
// inline — the title saves while typing (and focusing it opens the page),
// the slug commits on blur/Enter since it renames the path, moving the
// whole subtree. Nodes without content are category labels that redirect
// to their first child; the on their row gives them a landing page.
// whole subtree. Nodes without content are category labels whose URL
// renders a placeholder page; the on their row gives them a landing page.
// Every non-empty list (and the root list) ends with a footer row:
// clicking it adds a *pending* row (a local-only item persisted to the
// server only on commit, ✓/Enter, Esc discards) at the end of that list,
@@ -140,13 +140,6 @@ function onEnd() {
/>
<span class="acts">
<span v-if="!element.published" class="draft">draft</span>
<button
v-if="!element.has_content"
type="button"
class="act"
title="add a landing page (currently redirects to the first child)"
@click="handlers.addContent(element)"
></button>
<button
type="button"
class="act del"
+7 -1
View File
@@ -316,11 +316,17 @@ article ul ul ul li::before {
content: "🔹";
}
/* Task lists render emoji checkmarks (see markdown.py), no diamond. */
/* Task lists: real checkboxes (not emoji), clickable in the public page. */
article .task-list-item::before {
content: none;
}
article .task-list-item-checkbox {
cursor: pointer;
margin-inline-end: 0.35em;
vertical-align: middle;
}
article {
position: relative;
}
+37 -1
View File
@@ -169,7 +169,7 @@ import "./assets/style.css";
const res = await fetch(url);
const type = res.headers.get("content-type") || "";
if (!res.ok || !type.includes("text/html")) throw new Error("not a page");
// Section URLs redirect to their first child; reflect that.
// Reflect any redirect the server issued.
if (res.redirected) finalUrl = res.url;
doc = new DOMParser().parseFromString(await res.text(), "text/html");
} catch {
@@ -255,5 +255,41 @@ import "./assets/style.css";
addEventListener("popstate", () => load(location.href, false, true));
// --- Task-list checkboxes ------------------------------------------------
// Checkboxes in the rendered article are live: toggling them edits the
// Markdown source. If the page editor is open, its CodeMirror document is
// updated directly; otherwise the server copy is toggled and saved.
async function toggleTask(checkbox, index) {
const editor = window.__pageritePageEditor;
const pagePath = editor ? editor.path() : currentPath;
const path = pagePath.replace(/^\/+|\/+$/g, "");
const originalChecked = !checkbox.checked;
try {
const body = { path, index };
if (editor) body.markdown = editor.getMarkdown();
const res = await fetch("/_/api/toggle-task", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const detail = await res.json().catch(() => ({}));
throw new Error(detail.detail || res.statusText);
}
const { markdown } = await res.json();
if (editor) editor.setMarkdown(markdown);
} catch {
checkbox.checked = originalChecked;
}
}
addEventListener("change", (ev) => {
const checkbox = ev.target.closest(".task-list-item-checkbox");
if (!checkbox) return;
const index = Number(checkbox.dataset.taskIndex);
if (Number.isNaN(index)) return;
toggleTask(checkbox, index);
});
applyEffects();
})();
+107 -45
View File
@@ -14,6 +14,7 @@ walking the tree (``resolve``), moves are slot detach/attach
import mimetypes
import os
import re
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import UTC, datetime
@@ -37,7 +38,7 @@ from pagerite.data import (
resolve,
sorted_nodes,
)
from pagerite.markdown import has_h1, render
from pagerite.markdown import has_h1, render, toggle_task
DB_PATH = os.getenv("PAGERITE_DB", "pagerite.kanta")
@@ -82,6 +83,25 @@ def _ensure(menu: dict[str, Node], path: str) -> Node:
return node
def _remove_page_content(menu: dict[str, Node], path: str) -> None:
"""Delete a page's markdown content.
A node with children becomes a content-less category label; a childless
node is removed entirely. Does nothing if the path does not exist.
"""
slot = find_slot(menu, path)
if slot is None:
return
node = slot[0].get(slot[1])
if node is None:
return
if node.children:
node.content = None
node.modified = datetime.now(UTC)
else:
del slot[0][slot[1]]
def _migrate_legacy() -> None:
"""Rebuild the legacy flat page store as a tree (one-time migration)."""
if not data.pages:
@@ -179,18 +199,22 @@ async def save_page(path: str, page: PageIn) -> None:
"""Create or replace the page at a slug path ("" or "/" = front page).
Missing ancestors are created as content-less category labels. Giving
a category markdown turns it into a landing page.
a category markdown turns it into a landing page. An empty markdown
string (after stripping) deletes the page instead.
"""
path = path.strip("/")
_check_reserved(path)
with kanta.transaction("save page", extra=path):
node = _ensure(data.menu, path)
node.title = page.title
node.content = page.markdown
node.published = page.published
if page.banner is not None:
node.banner = page.banner
node.modified = datetime.now(UTC)
if page.markdown.strip() == "":
_remove_page_content(data.menu, path)
else:
node = _ensure(data.menu, path)
node.title = page.title
node.content = page.markdown
node.published = page.published
if page.banner is not None:
node.banner = page.banner
node.modified = datetime.now(UTC)
data.version += 1
@@ -275,6 +299,44 @@ async def put_settings(settings: SettingsIn) -> None:
data.version += 1
class ToggleTaskIn(BaseModel):
"""Payload for toggling one task-list checkbox."""
path: str
index: int
markdown: str | None = None
@app.post("/_/api/toggle-task")
async def toggle_task_endpoint(body: ToggleTaskIn) -> dict[str, str]:
"""Toggle the Nth task-list checkbox in a page's Markdown source.
If ``markdown`` is provided the source is left untouched and the toggled
Markdown is returned (used while the page editor is open, so the live
CodeMirror document can be updated). Otherwise the stored page at
``path`` is read, toggled, and saved.
"""
path = body.path.strip("/")
_check_reserved(path)
if body.markdown is not None:
new_markdown = toggle_task(body.markdown, body.index)
if new_markdown is None:
raise HTTPException(400, "invalid task index")
return {"markdown": new_markdown}
chain = resolve(data.menu, path)
node = chain[-1] if chain else None
if node is None or node.content is None:
raise HTTPException(404, "no such page")
new_markdown = toggle_task(node.content, body.index)
if new_markdown is None:
raise HTTPException(400, "invalid task index")
with kanta.transaction("toggle task", extra=path):
node.content = new_markdown
node.modified = datetime.now(UTC)
data.version += 1
return {"markdown": new_markdown}
@app.put("/_/api/files/{name}")
async def upload_file(name: str, request: Request) -> dict[str, str]:
"""Store an upload (image, video...) in the content-addressed store.
@@ -342,32 +404,26 @@ async def delete_page(path: str) -> None:
data.version += 1
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
def _is_reserved(path: str) -> bool:
"""Slug shape that content may never use: any segment beginning with
"_" or "." ("/_/" is the machinery prefix; dot-segments look like
hidden or filesystem paths)."""
return any(seg.startswith(("_", ".")) for seg in path.split("/"))
# Well-known root file names that content may not occupy (they would
# shadow real files or carry special meaning to crawlers/clients).
RESERVED_FILES = {
"robots.txt",
"ads.txt",
"sitemap.xml",
"openapi.json",
"favicon.ico",
"site.webmanifest",
}
"""Slug shape that content may never use: each segment must be lower-case
ASCII letters, digits, hyphens and underscores (underscores may not be
the first character), and dots are never allowed.
"""
if path == "":
return False
return any(not _SLUG_RE.match(seg) for seg in path.split("/"))
def _check_reserved(path: str) -> None:
"""Reject configured slugs beginning with "_" or ".", and reserved
file names at the root."""
"""Reject paths that do not follow the slug charset."""
if _is_reserved(path):
raise HTTPException(400, 'slugs cannot begin with "_" or "."')
if path in RESERVED_FILES:
raise HTTPException(400, f'"{path}" is a reserved file name')
raise HTTPException(
400,
'slugs may only use a-z, 0-9, "-" and "_" (not as the first character), and no dots',
)
@app.websocket("/_/api/ws/editor")
@@ -472,15 +528,21 @@ async def editor_ws(ws: WebSocket) -> None:
tnodes[tslug] = node
else:
node = old if old is not None else _ensure(data.menu, path)
if "title" in msg:
node.title = msg["title"]
removed = False
if "markdown" in msg:
node.content = msg["markdown"]
if "published" in msg:
node.published = bool(msg["published"])
if "banner" in msg:
node.banner = msg["banner"]
node.modified = datetime.now(UTC)
if msg["markdown"].strip() == "":
_remove_page_content(data.menu, path)
removed = True
else:
node.content = msg["markdown"]
if not removed:
if "title" in msg:
node.title = msg["title"]
if "published" in msg:
node.published = bool(msg["published"])
if "banner" in msg:
node.banner = msg["banner"]
node.modified = datetime.now(UTC)
data.version += 1
await ws.send_json({"type": "saved", "path": path})
except WebSocketDisconnect:
@@ -508,12 +570,12 @@ frontend.route(app, "/")
async def show_page(request: Request, path: str) -> HTMLResponse | Response:
"""Render the content page at a slug path, or 404.
A node without content is a category label: its URL redirects to the
first child page in menu order.
A node without content is a category label: its URL renders a
placeholder page (nav links point straight at its first child).
"""
path = path.strip("/")
if path and (_is_reserved(path) or path in RESERVED_FILES):
# Reserved slug shape or file name: never content — no tree lookup.
if path and _is_reserved(path):
# Reserved slug shape: never content — no tree lookup.
return HTMLResponse(views.render_not_found(data.menu, path, data.brand), 404)
chain = resolve(data.menu, path)
node = chain[-1] if chain else None
@@ -528,9 +590,9 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
headers={"etag": etag},
)
if node is not None and node.published and node.content is None:
# Category label without a landing page: open its first child.
if (leaf := views.first_leaf(data.menu, path)) is not None:
return RedirectResponse(f"/{leaf}")
# Category label without a landing page: placeholder with the pen
# to create it (404 — no page here, but the node is real).
return HTMLResponse(views.render_category(data.menu, path, data.brand), 404)
if node is None and not path:
# No front page (no top-level node with slug ""): "/" opens the
# first item of the navigation instead.
+3 -3
View File
@@ -3,8 +3,8 @@
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.
node's Markdown page, or None for a pure category label, whose URL renders
a placeholder page while nav links point at its first child.
"""
from datetime import UTC, datetime
@@ -29,7 +29,7 @@ class Node(msgspec.Struct, omit_defaults=True):
title: str = ""
order: float = 0
#: Markdown source of the node's page; None = pure category label
#: (redirects to the first child page).
#: (its URL renders a placeholder 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.
+65 -14
View File
@@ -13,6 +13,8 @@ page's own path (so `![alt](photo.avif)` in `/docs/design` is served from
classes, e.g. `![alt](photo.avif "Caption"){.right}`.
"""
import re
from markdown_it import MarkdownIt
from markdown_it.common.utils import escapeHtml
from markdown_it.renderer import RendererHTML
@@ -30,6 +32,9 @@ from pygments.util import ClassNotFound
_formatter = HtmlFormatter(style="github-dark", nowrap=True)
_TASK_MARKER_RE = re.compile(r"^(\s*(?:>\s*)*(?:[-*+]|\d+\.)\s+)\[( |x|X)\](\s+|$)")
def _highlight(text: str, lang: str, _attrs: str) -> str:
"""Syntax-highlight a fenced code block with Pygments.
@@ -83,31 +88,37 @@ def _unwrap_lone_figures(state) -> None:
tokens[i + 1].hidden = True
md = (
MarkdownIt("default", {"html": True, "highlight": _highlight})
.use(attrs_plugin)
.use(footnote_plugin)
.use(deflist_plugin)
.use(tasklists_plugin)
)
def _checkbox_emojis(state) -> None:
"""Render task-list checkboxes as emoji instead of disabled inputs.
def _tag_task_checkboxes(state) -> None:
"""Tag rendered task-list checkboxes with a stable index.
A disabled <input> renders grey and washed out; a colored emoji
shows the state without any styling.
The public page and the editor preview use the index to identify which
`[ ]`/`[x]` marker in the Markdown source to toggle when a visitor
clicks the checkbox.
"""
index = 0
for token in state.tokens:
if token.type != "inline" or not token.children:
continue
for child in token.children:
if child.type == "html_inline" and 'type="checkbox"' in child.content:
child.type = "text"
child.content = "" if "checked" in child.content else ""
child.content = child.content.replace(
'type="checkbox"',
f'data-task-index="{index}" type="checkbox"',
1,
)
index += 1
md = (
MarkdownIt("default", {"html": True, "highlight": _highlight})
.use(attrs_plugin)
.use(footnote_plugin)
.use(deflist_plugin)
.use(tasklists_plugin, enabled=True)
)
md.add_render_rule("image", _image_rule)
md.core.ruler.push("unwrap_lone_figures", _unwrap_lone_figures)
md.core.ruler.push("checkbox_emojis", _checkbox_emojis)
md.core.ruler.push("tag_task_checkboxes", _tag_task_checkboxes)
def render(text: str, page_path: str = "") -> str:
@@ -123,3 +134,43 @@ def has_h1(text: str) -> bool:
<title> and navigation labels).
"""
return any(t.type == "heading_open" and t.tag == "h1" for t in md.parse(text))
def toggle_task(text: str, index: int) -> str | None:
"""Toggle the Nth task-list checkbox marker in ``text``.
Returns the modified Markdown source, or ``None`` if the index is out
of range or the marker could not be found.
"""
tokens = md.parse(text, {"page_path": ""})
checkbox_lines: list[int | None] = []
for token in tokens:
if token.type == "inline" and token.children:
for child in token.children:
if child.type == "html_inline" and 'type="checkbox"' in child.content:
checkbox_lines.append(token.map[0] if token.map else None)
break
if not (0 <= index < len(checkbox_lines)):
return None
line_idx = checkbox_lines[index]
if line_idx is None or line_idx < 0:
return None
lines = text.splitlines(keepends=True)
if line_idx >= len(lines):
return None
line = lines[line_idx]
def repl(m: re.Match[str]) -> str:
prefix = m.group(1)
marker = m.group(2)
new_marker = "x" if marker.strip() == "" else " "
return f"{prefix}[{new_marker}]{m.group(3)}"
new_line = _TASK_MARKER_RE.sub(repl, line, count=1)
if new_line == line:
return None
lines[line_idx] = new_line
return "".join(lines)
+2 -2
View File
@@ -424,8 +424,8 @@ DUNES_SVG = """\
#: path -> (title, markdown, {filename: bytes}, banner HTML, menu order).
#: Note there are deliberately no "docs" or "blog" landing pages: those
#: labels are created without content, so entering them redirects to the
#: first child (see views.first_leaf).
#: labels are created without content, so they render a placeholder page
#: and their nav links point at the first child (see views.first_leaf).
PAGES: dict[str, tuple[str, str, dict[str, bytes], str, float]] = {
"": ("Welcome", WELCOME, {"waves.svg": WAVES_SVG.encode()}, FRONT_BANNER, 1),
"about": ("About", ABOUT, {}, "", 2),
+45 -9
View File
@@ -8,8 +8,9 @@ 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 children of the current
top-level section. Nodes without content are category labels; their URLs
redirect to the first child page (first_leaf).
top-level section. 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
@@ -58,14 +59,18 @@ def _title(slug: str, node: Node) -> str:
return node.title or prettify(slug) or "Home"
def _nav_link(doc, node: Node, path: str, current: str) -> None:
"""Render one <li> linking the node (category labels redirect to their
first child server-side, so linking them is always fine)."""
def _nav_link(doc, menu: dict[str, Node], node: Node, path: str, current: str) -> None:
"""Render one <li> linking the node. Category labels (no content of
their own) link straight to their first child page, so normal
navigation bypasses the placeholder page at their own URL."""
# A top-level item is current also when viewing any of its subpages.
is_current = current == path or (path and current.startswith(f"{path}/"))
href = f"/{path}"
if node.content is None and (leaf := first_leaf(menu, path)) is not None:
href = f"/{leaf}"
doc.li.a(
_title(path.rpartition("/")[2], node),
href=f"/{path}",
href=href,
**{"class": "current"} if is_current else {},
)
@@ -81,7 +86,7 @@ def nav_html(menu: dict[str, Node], current: str) -> HTML:
with nav:
for slug, node in sorted_nodes(menu):
if node.published:
_nav_link(nav, node, slug, current)
_nav_link(nav, menu, node, slug, current)
return HTML(str(nav))
@@ -101,14 +106,14 @@ def sidebar_html(menu: dict[str, Node], current: str) -> HTML:
with nav:
for slug, child in sorted_nodes(node.children):
if child.published:
_nav_link(nav, child, f"{section}/{slug}", current)
_nav_link(nav, menu, child, f"{section}/{slug}", current)
return HTML(str(nav))
def first_leaf(menu: dict[str, Node], path: str) -> str | None:
"""First published descendant page (content set) in menu order.
This is the redirect target for content-less category labels.
This is the nav-link target for content-less category labels.
"""
chain = resolve(menu, path)
if chain is None:
@@ -208,6 +213,37 @@ def render_page(menu: dict[str, Node], path: str, brand: str = SITE_NAME) -> str
)
def render_category(menu: dict[str, Node], path: str, brand: str = SITE_NAME) -> 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)
doc = E.article
with doc:
doc.h1(title)
# Editing works here too: the pen creates this category's page.
doc.button("🖊️", **_edit_attrs(path))
doc.p(
"Pages in this section are listed in the menu on the left."
)
scripts, styles = _page_assets()
return str(
_layout(styles, scripts)(
Title=f"{title} {brand}" if brand else title,
Brand=_brand_link(brand),
Nav=nav_html(menu, path),
Sidebar=sidebar_html(menu, path),
Banner=banner_html(menu, path),
BannerEdit=HTML(str(E.button("🖊️", **_edit_attrs(path, "site")))),
Main=HTML(str(doc)),
),
)
def render_not_found(menu: dict[str, Node], path: str, brand: str = SITE_NAME) -> str:
"""Render a 404 page within the normal layout."""
doc = E.article