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
|
main level pages, not their parent); it cannot have children, and
|
||||||
renaming its slug away leaves no front page ("/" redirects to the
|
renaming its slug away leaves no front page ("/" redirects to the
|
||||||
first nav item). `Node.content` is
|
first nav item). `Node.content` is
|
||||||
the Markdown page, or None for a pure category label whose URL
|
the Markdown page, or None for a pure category label whose URL renders
|
||||||
redirects to its first child; every label's title and slug are
|
a placeholder page (while nav links to it point at its first child);
|
||||||
editable. Siblings order by the fractional `Node.order` key: a moved
|
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
|
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
|
theirs. `resolve`/`find_slot` walk the tree by path; moves are slot
|
||||||
detach/attach carrying the whole subtree. Legacy flat `Data.pages`
|
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
|
- `views.py` — the shared page layout as an html5tagger `Template` with
|
||||||
placeholders (`Title`, `Brand`, `Banner`, `Nav`, `Sidebar`, `Main`), nav
|
placeholders (`Title`, `Brand`, `Banner`, `Nav`, `Sidebar`, `Main`), nav
|
||||||
rendering straight from the `Data.menu` tree (siblings sorted by
|
rendering straight from the `Data.menu` tree (siblings sorted by
|
||||||
`Node.order`; content-less labels redirect to their first child via
|
`Node.order`; nav links to content-less labels point at their first
|
||||||
`first_leaf`), and page/404 rendering. If the markdown contains its own h1, the page title
|
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
|
is NOT rendered as an additional h1 (it still supplies <title> and nav
|
||||||
labels). The navbar holds
|
labels). The navbar holds
|
||||||
top-level items only; the current section's subitems go to a left
|
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.
|
- Keep dependencies minimal; add via `uv add` and mention it.
|
||||||
- The public URL space belongs to content (pretty slugs at root). Reserve
|
- The public URL space belongs to content (pretty slugs at root). Reserve
|
||||||
only `/_/` for the machinery (files, API, built assets, admin), plus
|
only `/_/` for the machinery (files, API, built assets, admin), plus
|
||||||
`/favicon.ico` from the build. Slugs are lowercase ASCII `[a-z0-9-]`
|
`/favicon.ico` from the build. Slugs are lowercase ASCII letters, digits,
|
||||||
(the site editor filters input live via `slugify.js`, built on the
|
hyphens and underscores `[a-z0-9_-]` (the site editor filters input live
|
||||||
`transliteration` npm package — unicode folds to ASCII, spaces become
|
via `slugify.js`, built on the `transliteration` npm package — unicode
|
||||||
hyphens; an empty slug on a new page is derived from its title), may
|
folds to ASCII, spaces become hyphens; an empty slug on a new page is
|
||||||
not begin with `_` or `.`, and may not be a reserved file name
|
derived from its title), may not begin with `_` or `.`, and such URLs are
|
||||||
(`robots.txt`, `ads.txt`, `sitemap.xml`, `openapi.json`, `favicon.ico`,
|
never looked up as content.
|
||||||
`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.
|
|
||||||
- No auth in core code; trusted single author. Never add output
|
- No auth in core code; trusted single author. Never add output
|
||||||
sanitization "for safety" against the author — embedded HTML/scripts in
|
sanitization "for safety" against the author — embedded HTML/scripts in
|
||||||
Markdown are passed through deliberately.
|
Markdown are passed through deliberately.
|
||||||
|
|||||||
@@ -32,11 +32,10 @@ evolves.
|
|||||||
lives under `/_/` (the API at `/_/api/`, uploaded files at `/_/f/`, built
|
lives under `/_/` (the API at `/_/api/`, uploaded files at `/_/f/`, built
|
||||||
assets at `/_/assets/`, and the admin shell at `/_/admin`). The only
|
assets at `/_/assets/`, and the admin shell at `/_/admin`). The only
|
||||||
other reserved root path is `/favicon.ico`, served from the build.
|
other reserved root path is `/favicon.ico`, served from the build.
|
||||||
Slugs are lowercase ASCII (`[a-z0-9-]`; input is transliterated and
|
Slugs are lowercase ASCII letters, digits, hyphens and underscores
|
||||||
filtered as you type, and a new page's empty slug is derived from its
|
(`[a-z0-9_-]`; input is transliterated and filtered as you type, and a
|
||||||
title), may not begin with `_` or `.`, and may not occupy a reserved
|
new page's empty slug is derived from its title), may not begin with
|
||||||
root file name (`robots.txt`, `sitemap.xml`, `favicon.ico`, …) — such
|
`_` or `.`, and such URLs are never looked up as content.
|
||||||
URLs are never looked up as content.
|
|
||||||
- **Single user, trusted author.** No auth concerns in the core design.
|
- **Single user, trusted author.** No auth concerns in the core design.
|
||||||
Everything published is public; only editing tools will later sit behind
|
Everything published is public; only editing tools will later sit behind
|
||||||
access control (external SSO when that time comes). The author is trusted
|
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
|
the sidebar is empty (and hidden) elsewhere. Other sections' subitems
|
||||||
are never shown without navigating into them first.
|
are never shown without navigating into them first.
|
||||||
- **Landing pages are optional.** Every label can either have content
|
- **Landing pages are optional.** Every label can either have content
|
||||||
(`Node.content`, a Markdown page) or none — a content-less label
|
(`Node.content`, a Markdown page) or none — a content-less label renders
|
||||||
redirects to its first child instead of 404ing, so categories need no
|
a placeholder page (404 with a pen to create it) instead of redirecting,
|
||||||
filler content. Title and slug of every label are editable; renaming a
|
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
|
slug moves the whole subtree. The sidebar never lists the section
|
||||||
itself, avoiding title duplication with the navbar.
|
itself, avoiding title duplication with the navbar.
|
||||||
- **Menu order is manual.** Each node has a fractional `order` key among
|
- **Menu order is manual.** Each node has a fractional `order` key among
|
||||||
|
|||||||
@@ -120,8 +120,10 @@ function openPath(p) {
|
|||||||
send({ type: 'open', path: p })
|
send({ type: 'open', path: p })
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDocument(text) {
|
function setDocument(text, preserveSelection = false) {
|
||||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } })
|
const tr = { changes: { from: 0, to: view.state.doc.length, insert: text } }
|
||||||
|
if (preserveSelection) tr.selection = view.state.selection
|
||||||
|
view.dispatch(tr)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onHashChange() {
|
function onHashChange() {
|
||||||
@@ -272,6 +274,11 @@ onMounted(() => {
|
|||||||
parent: editorEl.value,
|
parent: editorEl.value,
|
||||||
})
|
})
|
||||||
view.scrollDOM.addEventListener('scroll', syncScroll)
|
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)
|
if (props.standalone) addEventListener('hashchange', onHashChange)
|
||||||
addEventListener('keydown', onKeydown)
|
addEventListener('keydown', onKeydown)
|
||||||
})
|
})
|
||||||
@@ -283,6 +290,7 @@ onUnmounted(() => {
|
|||||||
ws.close()
|
ws.close()
|
||||||
}
|
}
|
||||||
view?.destroy()
|
view?.destroy()
|
||||||
|
delete window.__pageritePageEditor
|
||||||
if (props.standalone) removeEventListener('hashchange', onHashChange)
|
if (props.standalone) removeEventListener('hashchange', onHashChange)
|
||||||
removeEventListener('keydown', onKeydown)
|
removeEventListener('keydown', onKeydown)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
//
|
//
|
||||||
// The tree comes from the server nested (GET /_/api/pages); every node is
|
// 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
|
// 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
|
// without (category whose URL renders a placeholder page). The front page
|
||||||
// top-level row with an empty slug, not the parent of the others.
|
// is a top-level row with an empty slug, not the parent of the others.
|
||||||
import { computed, onMounted, onUnmounted, provide, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, provide, ref } from 'vue'
|
||||||
import StructureTree from './StructureTree.vue'
|
import StructureTree from './StructureTree.vue'
|
||||||
import { EditorView, basicSetup } from 'codemirror'
|
import { EditorView, basicSetup } from 'codemirror'
|
||||||
@@ -125,8 +125,8 @@ async function loadPlain(p) {
|
|||||||
const res = await fetch(finalUrl)
|
const res = await fetch(finalUrl)
|
||||||
const type = res.headers.get('content-type') || ''
|
const type = res.headers.get('content-type') || ''
|
||||||
if (!type.includes('text/html')) return
|
if (!type.includes('text/html')) return
|
||||||
// Category URLs redirect to their first child; reflect that. A 404
|
// Category and missing URLs render a placeholder 404 page — fine to
|
||||||
// layout is fine too (new pages are created by editing them).
|
// swap in (new pages are created by editing them).
|
||||||
if (res.redirected) finalUrl = res.url
|
if (res.redirected) finalUrl = res.url
|
||||||
doc = new DOMParser().parseFromString(await res.text(), 'text/html')
|
doc = new DOMParser().parseFromString(await res.text(), 'text/html')
|
||||||
} catch { return }
|
} catch { return }
|
||||||
@@ -239,22 +239,6 @@ async function commitPending() {
|
|||||||
navigate(newPath)
|
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) ----------------------
|
// --- Site-wide brand (header link + <title> suffix) ----------------------
|
||||||
// Edits apply to the live page immediately and save while typing. An
|
// Edits apply to the live page immediately and save while typing. An
|
||||||
// empty brand removes the header link and the title suffix entirely.
|
// empty brand removes the header link and the title suffix entirely.
|
||||||
@@ -330,8 +314,8 @@ async function removePage(node) {
|
|||||||
refreshPages()
|
refreshPages()
|
||||||
const p = node.path
|
const p = node.path
|
||||||
if (p === path.value || (p && path.value.startsWith(`${p}/`))) {
|
if (p === path.value || (p && path.value.startsWith(`${p}/`))) {
|
||||||
// The current page was deleted — or reduced to a category that now
|
// The current page was deleted — or reduced to a category, which now
|
||||||
// redirects to its first child. Either way, re-render from the server.
|
// renders a placeholder page. Either way, re-render from the server.
|
||||||
if (node.children.length) loadPlain(path.value)
|
if (node.children.length) loadPlain(path.value)
|
||||||
else navigate('')
|
else navigate('')
|
||||||
} else {
|
} else {
|
||||||
@@ -429,7 +413,6 @@ provide('structureHandlers', {
|
|||||||
reorder: onReorder,
|
reorder: onReorder,
|
||||||
titleInput: onTitleInput,
|
titleInput: onTitleInput,
|
||||||
commitSlug,
|
commitSlug,
|
||||||
addContent,
|
|
||||||
commitPending,
|
commitPending,
|
||||||
discardPending,
|
discardPending,
|
||||||
newPage,
|
newPage,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
// Every node is real: a label whose title and slug are always editable
|
// 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),
|
// 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
|
// the slug commits on blur/Enter since it renames the path, moving the
|
||||||
// whole subtree. Nodes without content are category labels that redirect
|
// whole subtree. Nodes without content are category labels whose URL
|
||||||
// to their first child; the ➕ on their row gives them a landing page.
|
// 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:
|
// 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
|
// 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,
|
// server only on commit, ✓/Enter, Esc discards) at the end of that list,
|
||||||
@@ -140,13 +140,6 @@ function onEnd() {
|
|||||||
/>
|
/>
|
||||||
<span class="acts">
|
<span class="acts">
|
||||||
<span v-if="!element.published" class="draft">draft</span>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="act del"
|
class="act del"
|
||||||
|
|||||||
@@ -316,11 +316,17 @@ article ul ul ul li::before {
|
|||||||
content: "🔹";
|
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 {
|
article .task-list-item::before {
|
||||||
content: none;
|
content: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
article .task-list-item-checkbox {
|
||||||
|
cursor: pointer;
|
||||||
|
margin-inline-end: 0.35em;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
article {
|
article {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ import "./assets/style.css";
|
|||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
const type = res.headers.get("content-type") || "";
|
const type = res.headers.get("content-type") || "";
|
||||||
if (!res.ok || !type.includes("text/html")) throw new Error("not a page");
|
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;
|
if (res.redirected) finalUrl = res.url;
|
||||||
doc = new DOMParser().parseFromString(await res.text(), "text/html");
|
doc = new DOMParser().parseFromString(await res.text(), "text/html");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -255,5 +255,41 @@ import "./assets/style.css";
|
|||||||
|
|
||||||
addEventListener("popstate", () => load(location.href, false, true));
|
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();
|
applyEffects();
|
||||||
})();
|
})();
|
||||||
|
|||||||
+107
-45
@@ -14,6 +14,7 @@ walking the tree (``resolve``), moves are slot detach/attach
|
|||||||
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
@@ -37,7 +38,7 @@ from pagerite.data import (
|
|||||||
resolve,
|
resolve,
|
||||||
sorted_nodes,
|
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")
|
DB_PATH = os.getenv("PAGERITE_DB", "pagerite.kanta")
|
||||||
|
|
||||||
@@ -82,6 +83,25 @@ def _ensure(menu: dict[str, Node], path: str) -> Node:
|
|||||||
return 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:
|
def _migrate_legacy() -> None:
|
||||||
"""Rebuild the legacy flat page store as a tree (one-time migration)."""
|
"""Rebuild the legacy flat page store as a tree (one-time migration)."""
|
||||||
if not data.pages:
|
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).
|
"""Create or replace the page at a slug path ("" or "/" = front page).
|
||||||
|
|
||||||
Missing ancestors are created as content-less category labels. Giving
|
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("/")
|
path = path.strip("/")
|
||||||
_check_reserved(path)
|
_check_reserved(path)
|
||||||
with kanta.transaction("save page", extra=path):
|
with kanta.transaction("save page", extra=path):
|
||||||
node = _ensure(data.menu, path)
|
if page.markdown.strip() == "":
|
||||||
node.title = page.title
|
_remove_page_content(data.menu, path)
|
||||||
node.content = page.markdown
|
else:
|
||||||
node.published = page.published
|
node = _ensure(data.menu, path)
|
||||||
if page.banner is not None:
|
node.title = page.title
|
||||||
node.banner = page.banner
|
node.content = page.markdown
|
||||||
node.modified = datetime.now(UTC)
|
node.published = page.published
|
||||||
|
if page.banner is not None:
|
||||||
|
node.banner = page.banner
|
||||||
|
node.modified = datetime.now(UTC)
|
||||||
data.version += 1
|
data.version += 1
|
||||||
|
|
||||||
|
|
||||||
@@ -275,6 +299,44 @@ async def put_settings(settings: SettingsIn) -> None:
|
|||||||
data.version += 1
|
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}")
|
@app.put("/_/api/files/{name}")
|
||||||
async def upload_file(name: str, request: Request) -> dict[str, str]:
|
async def upload_file(name: str, request: Request) -> dict[str, str]:
|
||||||
"""Store an upload (image, video...) in the content-addressed store.
|
"""Store an upload (image, video...) in the content-addressed store.
|
||||||
@@ -342,32 +404,26 @@ async def delete_page(path: str) -> None:
|
|||||||
data.version += 1
|
data.version += 1
|
||||||
|
|
||||||
|
|
||||||
|
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
|
||||||
|
|
||||||
|
|
||||||
def _is_reserved(path: str) -> bool:
|
def _is_reserved(path: str) -> bool:
|
||||||
"""Slug shape that content may never use: any segment beginning with
|
"""Slug shape that content may never use: each segment must be lower-case
|
||||||
"_" or "." ("/_/" is the machinery prefix; dot-segments look like
|
ASCII letters, digits, hyphens and underscores (underscores may not be
|
||||||
hidden or filesystem paths)."""
|
the first character), and dots are never allowed.
|
||||||
return any(seg.startswith(("_", ".")) for seg in path.split("/"))
|
"""
|
||||||
|
if path == "":
|
||||||
|
return False
|
||||||
# Well-known root file names that content may not occupy (they would
|
return any(not _SLUG_RE.match(seg) for seg in path.split("/"))
|
||||||
# shadow real files or carry special meaning to crawlers/clients).
|
|
||||||
RESERVED_FILES = {
|
|
||||||
"robots.txt",
|
|
||||||
"ads.txt",
|
|
||||||
"sitemap.xml",
|
|
||||||
"openapi.json",
|
|
||||||
"favicon.ico",
|
|
||||||
"site.webmanifest",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _check_reserved(path: str) -> None:
|
def _check_reserved(path: str) -> None:
|
||||||
"""Reject configured slugs beginning with "_" or ".", and reserved
|
"""Reject paths that do not follow the slug charset."""
|
||||||
file names at the root."""
|
|
||||||
if _is_reserved(path):
|
if _is_reserved(path):
|
||||||
raise HTTPException(400, 'slugs cannot begin with "_" or "."')
|
raise HTTPException(
|
||||||
if path in RESERVED_FILES:
|
400,
|
||||||
raise HTTPException(400, f'"{path}" is a reserved file name')
|
'slugs may only use a-z, 0-9, "-" and "_" (not as the first character), and no dots',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/_/api/ws/editor")
|
@app.websocket("/_/api/ws/editor")
|
||||||
@@ -472,15 +528,21 @@ async def editor_ws(ws: WebSocket) -> None:
|
|||||||
tnodes[tslug] = node
|
tnodes[tslug] = node
|
||||||
else:
|
else:
|
||||||
node = old if old is not None else _ensure(data.menu, path)
|
node = old if old is not None else _ensure(data.menu, path)
|
||||||
if "title" in msg:
|
removed = False
|
||||||
node.title = msg["title"]
|
|
||||||
if "markdown" in msg:
|
if "markdown" in msg:
|
||||||
node.content = msg["markdown"]
|
if msg["markdown"].strip() == "":
|
||||||
if "published" in msg:
|
_remove_page_content(data.menu, path)
|
||||||
node.published = bool(msg["published"])
|
removed = True
|
||||||
if "banner" in msg:
|
else:
|
||||||
node.banner = msg["banner"]
|
node.content = msg["markdown"]
|
||||||
node.modified = datetime.now(UTC)
|
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
|
data.version += 1
|
||||||
await ws.send_json({"type": "saved", "path": path})
|
await ws.send_json({"type": "saved", "path": path})
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
@@ -508,12 +570,12 @@ frontend.route(app, "/")
|
|||||||
async def show_page(request: Request, path: str) -> HTMLResponse | Response:
|
async def show_page(request: Request, path: str) -> HTMLResponse | Response:
|
||||||
"""Render the content page at a slug path, or 404.
|
"""Render the content page at a slug path, or 404.
|
||||||
|
|
||||||
A node without content is a category label: its URL redirects to the
|
A node without content is a category label: its URL renders a
|
||||||
first child page in menu order.
|
placeholder page (nav links point straight at its first child).
|
||||||
"""
|
"""
|
||||||
path = path.strip("/")
|
path = path.strip("/")
|
||||||
if path and (_is_reserved(path) or path in RESERVED_FILES):
|
if path and _is_reserved(path):
|
||||||
# Reserved slug shape or file name: never content — no tree lookup.
|
# Reserved slug shape: never content — no tree lookup.
|
||||||
return HTMLResponse(views.render_not_found(data.menu, path, data.brand), 404)
|
return HTMLResponse(views.render_not_found(data.menu, path, data.brand), 404)
|
||||||
chain = resolve(data.menu, path)
|
chain = resolve(data.menu, path)
|
||||||
node = chain[-1] if chain else None
|
node = chain[-1] if chain else None
|
||||||
@@ -528,9 +590,9 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
|
|||||||
headers={"etag": etag},
|
headers={"etag": etag},
|
||||||
)
|
)
|
||||||
if node is not None and node.published and node.content is None:
|
if node is not None and node.published and node.content is None:
|
||||||
# Category label without a landing page: open its first child.
|
# Category label without a landing page: placeholder with the pen
|
||||||
if (leaf := views.first_leaf(data.menu, path)) is not None:
|
# to create it (404 — no page here, but the node is real).
|
||||||
return RedirectResponse(f"/{leaf}")
|
return HTMLResponse(views.render_category(data.menu, path, data.brand), 404)
|
||||||
if node is None and not path:
|
if node is None and not path:
|
||||||
# No front page (no top-level node with slug ""): "/" opens the
|
# No front page (no top-level node with slug ""): "/" opens the
|
||||||
# first item of the navigation instead.
|
# 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
|
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
|
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
|
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
|
node's Markdown page, or None for a pure category label, whose URL renders
|
||||||
redirects to the first child page.
|
a placeholder page while nav links point at its first child.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
@@ -29,7 +29,7 @@ class Node(msgspec.Struct, omit_defaults=True):
|
|||||||
title: str = ""
|
title: str = ""
|
||||||
order: float = 0
|
order: float = 0
|
||||||
#: Markdown source of the node's page; None = pure category label
|
#: 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
|
content: str | None = None
|
||||||
#: Raw HTML for the header banner (img, styled div, canvas+script...).
|
#: Raw HTML for the header banner (img, styled div, canvas+script...).
|
||||||
#: Empty inherits the nearest ancestor's banner, front page last.
|
#: 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}`.
|
classes, e.g. `{.right}`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
from markdown_it import MarkdownIt
|
from markdown_it import MarkdownIt
|
||||||
from markdown_it.common.utils import escapeHtml
|
from markdown_it.common.utils import escapeHtml
|
||||||
from markdown_it.renderer import RendererHTML
|
from markdown_it.renderer import RendererHTML
|
||||||
@@ -30,6 +32,9 @@ from pygments.util import ClassNotFound
|
|||||||
_formatter = HtmlFormatter(style="github-dark", nowrap=True)
|
_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:
|
def _highlight(text: str, lang: str, _attrs: str) -> str:
|
||||||
"""Syntax-highlight a fenced code block with Pygments.
|
"""Syntax-highlight a fenced code block with Pygments.
|
||||||
|
|
||||||
@@ -83,31 +88,37 @@ def _unwrap_lone_figures(state) -> None:
|
|||||||
tokens[i + 1].hidden = True
|
tokens[i + 1].hidden = True
|
||||||
|
|
||||||
|
|
||||||
md = (
|
def _tag_task_checkboxes(state) -> None:
|
||||||
MarkdownIt("default", {"html": True, "highlight": _highlight})
|
"""Tag rendered task-list checkboxes with a stable index.
|
||||||
.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.
|
|
||||||
|
|
||||||
A disabled <input> renders grey and washed out; a colored emoji
|
The public page and the editor preview use the index to identify which
|
||||||
shows the state without any styling.
|
`[ ]`/`[x]` marker in the Markdown source to toggle when a visitor
|
||||||
|
clicks the checkbox.
|
||||||
"""
|
"""
|
||||||
|
index = 0
|
||||||
for token in state.tokens:
|
for token in state.tokens:
|
||||||
if token.type != "inline" or not token.children:
|
if token.type != "inline" or not token.children:
|
||||||
continue
|
continue
|
||||||
for child in token.children:
|
for child in token.children:
|
||||||
if child.type == "html_inline" and 'type="checkbox"' in child.content:
|
if child.type == "html_inline" and 'type="checkbox"' in child.content:
|
||||||
child.type = "text"
|
child.content = child.content.replace(
|
||||||
child.content = "✅" if "checked" in child.content else "⬜"
|
'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.add_render_rule("image", _image_rule)
|
||||||
md.core.ruler.push("unwrap_lone_figures", _unwrap_lone_figures)
|
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:
|
def render(text: str, page_path: str = "") -> str:
|
||||||
@@ -123,3 +134,43 @@ def has_h1(text: str) -> bool:
|
|||||||
<title> and navigation labels).
|
<title> and navigation labels).
|
||||||
"""
|
"""
|
||||||
return any(t.type == "heading_open" and t.tag == "h1" for t in md.parse(text))
|
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).
|
#: path -> (title, markdown, {filename: bytes}, banner HTML, menu order).
|
||||||
#: Note there are deliberately no "docs" or "blog" landing pages: those
|
#: Note there are deliberately no "docs" or "blog" landing pages: those
|
||||||
#: labels are created without content, so entering them redirects to the
|
#: labels are created without content, so they render a placeholder page
|
||||||
#: first child (see views.first_leaf).
|
#: and their nav links point at the first child (see views.first_leaf).
|
||||||
PAGES: dict[str, tuple[str, str, dict[str, bytes], str, float]] = {
|
PAGES: dict[str, tuple[str, str, dict[str, bytes], str, float]] = {
|
||||||
"": ("Welcome", WELCOME, {"waves.svg": WAVES_SVG.encode()}, FRONT_BANNER, 1),
|
"": ("Welcome", WELCOME, {"waves.svg": WAVES_SVG.encode()}, FRONT_BANNER, 1),
|
||||||
"about": ("About", ABOUT, {}, "", 2),
|
"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
|
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
|
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
|
the parent of the others — and sidebar_html the children of the current
|
||||||
top-level section. Nodes without content are category labels; their URLs
|
top-level section. Nodes without content are category labels; nav links
|
||||||
redirect to the first child page (first_leaf).
|
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
|
from pathlib import Path
|
||||||
@@ -58,14 +59,18 @@ def _title(slug: str, node: Node) -> str:
|
|||||||
return node.title or prettify(slug) or "Home"
|
return node.title or prettify(slug) or "Home"
|
||||||
|
|
||||||
|
|
||||||
def _nav_link(doc, node: Node, path: str, current: str) -> None:
|
def _nav_link(doc, menu: dict[str, Node], node: Node, path: str, current: str) -> None:
|
||||||
"""Render one <li> linking the node (category labels redirect to their
|
"""Render one <li> linking the node. Category labels (no content of
|
||||||
first child server-side, so linking them is always fine)."""
|
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.
|
# A top-level item is current also when viewing any of its subpages.
|
||||||
is_current = current == path or (path and current.startswith(f"{path}/"))
|
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(
|
doc.li.a(
|
||||||
_title(path.rpartition("/")[2], node),
|
_title(path.rpartition("/")[2], node),
|
||||||
href=f"/{path}",
|
href=href,
|
||||||
**{"class": "current"} if is_current else {},
|
**{"class": "current"} if is_current else {},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -81,7 +86,7 @@ def nav_html(menu: dict[str, Node], current: str) -> HTML:
|
|||||||
with nav:
|
with nav:
|
||||||
for slug, node in sorted_nodes(menu):
|
for slug, node in sorted_nodes(menu):
|
||||||
if node.published:
|
if node.published:
|
||||||
_nav_link(nav, node, slug, current)
|
_nav_link(nav, menu, node, slug, current)
|
||||||
return HTML(str(nav))
|
return HTML(str(nav))
|
||||||
|
|
||||||
|
|
||||||
@@ -101,14 +106,14 @@ def sidebar_html(menu: dict[str, Node], current: str) -> HTML:
|
|||||||
with nav:
|
with nav:
|
||||||
for slug, child in sorted_nodes(node.children):
|
for slug, child in sorted_nodes(node.children):
|
||||||
if child.published:
|
if child.published:
|
||||||
_nav_link(nav, child, f"{section}/{slug}", current)
|
_nav_link(nav, menu, child, f"{section}/{slug}", current)
|
||||||
return HTML(str(nav))
|
return HTML(str(nav))
|
||||||
|
|
||||||
|
|
||||||
def first_leaf(menu: dict[str, Node], path: str) -> str | None:
|
def first_leaf(menu: dict[str, Node], path: str) -> str | None:
|
||||||
"""First published descendant page (content set) in menu order.
|
"""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)
|
chain = resolve(menu, path)
|
||||||
if chain is None:
|
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:
|
def render_not_found(menu: dict[str, Node], path: str, brand: str = SITE_NAME) -> str:
|
||||||
"""Render a 404 page within the normal layout."""
|
"""Render a 404 page within the normal layout."""
|
||||||
doc = E.article
|
doc = E.article
|
||||||
|
|||||||
Reference in New Issue
Block a user