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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -13,6 +13,8 @@ page's own path (so `` in `/docs/design` is served from
|
||||
classes, e.g. `{.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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user