From a8cf5a6a824bac27a466e2682e52386374fc1bc8 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 16 Aug 2026 19:04:55 +0000 Subject: [PATCH] Rework category labels, slug validation, and task-list checkboxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- AGENTS.md | 25 +++--- docs/design-principles.md | 17 ++-- frontend/src/PageEditor.vue | 12 ++- frontend/src/SiteEditor.vue | 29 ++----- frontend/src/StructureTree.vue | 11 +-- frontend/src/assets/style.css | 8 +- frontend/src/pagerite.js | 38 ++++++++- pagerite/app.py | 152 +++++++++++++++++++++++---------- pagerite/data.py | 6 +- pagerite/markdown.py | 79 ++++++++++++++--- pagerite/seed.py | 4 +- pagerite/views.py | 54 ++++++++++-- 12 files changed, 304 insertions(+), 131 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c8fb8c6..257509c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 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. diff --git a/docs/design-principles.md b/docs/design-principles.md index e21c202..afe36ea 100644 --- a/docs/design-principles.md +++ b/docs/design-principles.md @@ -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 diff --git a/frontend/src/PageEditor.vue b/frontend/src/PageEditor.vue index b67796f..47ed579 100644 --- a/frontend/src/PageEditor.vue +++ b/frontend/src/PageEditor.vue @@ -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) }) diff --git a/frontend/src/SiteEditor.vue b/frontend/src/SiteEditor.vue index d69cf75..41866ad 100644 --- a/frontend/src/SiteEditor.vue +++ b/frontend/src/SiteEditor.vue @@ -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, diff --git a/frontend/src/StructureTree.vue b/frontend/src/StructureTree.vue index 936957e..98b0ec2 100644 --- a/frontend/src/StructureTree.vue +++ b/frontend/src/StructureTree.vue @@ -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" diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css index 55e85d7..aa1bb64 100644 --- a/frontend/src/assets/style.css +++ b/frontend/src/assets/style.css @@ -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; } diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js index 70cc885..52f72d7 100644 --- a/frontend/src/pagerite.js +++ b/frontend/src/pagerite.js @@ -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(); })(); diff --git a/pagerite/app.py b/pagerite/app.py index 502e435..94dde4f 100644 --- a/pagerite/app.py +++ b/pagerite/app.py @@ -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. diff --git a/pagerite/data.py b/pagerite/data.py index 52d0623..99a75df 100644 --- a/pagerite/data.py +++ b/pagerite/data.py @@ -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. diff --git a/pagerite/markdown.py b/pagerite/markdown.py index 881bc10..fb29a78 100644 --- a/pagerite/markdown.py +++ b/pagerite/markdown.py @@ -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) diff --git a/pagerite/seed.py b/pagerite/seed.py index 3d1440a..0e2615a 100644 --- a/pagerite/seed.py +++ b/pagerite/seed.py @@ -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), diff --git a/pagerite/views.py b/pagerite/views.py index 2a10584..c8ce293 100644 --- a/pagerite/views.py +++ b/pagerite/views.py @@ -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