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

- Remove the section-header  "add landing page" button; landing pages are
  now created via the page editor pen.
- Treat saving a page with empty (after stripping) Markdown as deleting it:
  childless nodes are removed, nodes with children become content-less labels.
- Restrict server-side slugs to [a-z0-9_-] with no leading underscore or dot;
  drop the reserved-filename blacklist.
- Render placeholder pages for content-less category labels and link them to
  their first published child in the navigation.
- Make task-list checkboxes live, non-disabled inputs: toggling updates the
  Markdown source, updates the open CodeMirror document when the page editor is
  open, and persists to the server when no editor is open. Errors revert the UI.
- Update docs and AGENTS.md conventions accordingly.
This commit is contained in:
2026-08-16 19:04:55 +00:00
parent 3dc438f8df
commit a8cf5a6a82
12 changed files with 304 additions and 131 deletions
+10 -2
View File
@@ -120,8 +120,10 @@ function openPath(p) {
send({ type: 'open', path: p })
}
function setDocument(text) {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } })
function setDocument(text, preserveSelection = false) {
const tr = { changes: { from: 0, to: view.state.doc.length, insert: text } }
if (preserveSelection) tr.selection = view.state.selection
view.dispatch(tr)
}
function onHashChange() {
@@ -272,6 +274,11 @@ onMounted(() => {
parent: editorEl.value,
})
view.scrollDOM.addEventListener('scroll', syncScroll)
window.__pageritePageEditor = {
getMarkdown: () => view.state.doc.toString(),
setMarkdown: (text) => setDocument(text, true),
path: () => path.value,
}
if (props.standalone) addEventListener('hashchange', onHashChange)
addEventListener('keydown', onKeydown)
})
@@ -283,6 +290,7 @@ onUnmounted(() => {
ws.close()
}
view?.destroy()
delete window.__pageritePageEditor
if (props.standalone) removeEventListener('hashchange', onHashChange)
removeEventListener('keydown', onKeydown)
})
+6 -23
View File
@@ -8,8 +8,8 @@
//
// The tree comes from the server nested (GET /_/api/pages); every node is
// real — a label with a title and slug, with content (landing page) or
// without (category redirecting to its first child). The front page is a
// top-level row with an empty slug, not the parent of the others.
// without (category whose URL renders a placeholder page). The front page
// is a top-level row with an empty slug, not the parent of the others.
import { computed, onMounted, onUnmounted, provide, ref } from 'vue'
import StructureTree from './StructureTree.vue'
import { EditorView, basicSetup } from 'codemirror'
@@ -125,8 +125,8 @@ async function loadPlain(p) {
const res = await fetch(finalUrl)
const type = res.headers.get('content-type') || ''
if (!type.includes('text/html')) return
// Category URLs redirect to their first child; reflect that. A 404
// layout is fine too (new pages are created by editing them).
// Category and missing URLs render a placeholder 404 page — fine to
// swap in (new pages are created by editing them).
if (res.redirected) finalUrl = res.url
doc = new DOMParser().parseFromString(await res.text(), 'text/html')
} catch { return }
@@ -239,22 +239,6 @@ async function commitPending() {
navigate(newPath)
}
// Give a content-less category a landing page (empty page at its path).
async function addContent(node) {
const res = await fetch(`/_/api/pages/${node.path}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: node.title, markdown: '', published: node.published }),
})
if (res.ok) {
saveError.value = ''
await refreshPages()
navigate(node.path)
} else {
saveError.value = '⚠️ changes could not be saved'
}
}
// --- Site-wide brand (header link + <title> suffix) ----------------------
// Edits apply to the live page immediately and save while typing. An
// empty brand removes the header link and the title suffix entirely.
@@ -330,8 +314,8 @@ async function removePage(node) {
refreshPages()
const p = node.path
if (p === path.value || (p && path.value.startsWith(`${p}/`))) {
// The current page was deleted — or reduced to a category that now
// redirects to its first child. Either way, re-render from the server.
// The current page was deleted — or reduced to a category, which now
// renders a placeholder page. Either way, re-render from the server.
if (node.children.length) loadPlain(path.value)
else navigate('')
} else {
@@ -429,7 +413,6 @@ provide('structureHandlers', {
reorder: onReorder,
titleInput: onTitleInput,
commitSlug,
addContent,
commitPending,
discardPending,
newPage,
+2 -9
View File
@@ -5,8 +5,8 @@
// Every node is real: a label whose title and slug are always editable
// inline — the title saves while typing (and focusing it opens the page),
// the slug commits on blur/Enter since it renames the path, moving the
// whole subtree. Nodes without content are category labels that redirect
// to their first child; the on their row gives them a landing page.
// whole subtree. Nodes without content are category labels whose URL
// renders a placeholder page; the on their row gives them a landing page.
// Every non-empty list (and the root list) ends with a footer row:
// clicking it adds a *pending* row (a local-only item persisted to the
// server only on commit, ✓/Enter, Esc discards) at the end of that list,
@@ -140,13 +140,6 @@ function onEnd() {
/>
<span class="acts">
<span v-if="!element.published" class="draft">draft</span>
<button
v-if="!element.has_content"
type="button"
class="act"
title="add a landing page (currently redirects to the first child)"
@click="handlers.addContent(element)"
></button>
<button
type="button"
class="act del"
+7 -1
View File
@@ -316,11 +316,17 @@ article ul ul ul li::before {
content: "🔹";
}
/* Task lists render emoji checkmarks (see markdown.py), no diamond. */
/* Task lists: real checkboxes (not emoji), clickable in the public page. */
article .task-list-item::before {
content: none;
}
article .task-list-item-checkbox {
cursor: pointer;
margin-inline-end: 0.35em;
vertical-align: middle;
}
article {
position: relative;
}
+37 -1
View File
@@ -169,7 +169,7 @@ import "./assets/style.css";
const res = await fetch(url);
const type = res.headers.get("content-type") || "";
if (!res.ok || !type.includes("text/html")) throw new Error("not a page");
// Section URLs redirect to their first child; reflect that.
// Reflect any redirect the server issued.
if (res.redirected) finalUrl = res.url;
doc = new DOMParser().parseFromString(await res.text(), "text/html");
} catch {
@@ -255,5 +255,41 @@ import "./assets/style.css";
addEventListener("popstate", () => load(location.href, false, true));
// --- Task-list checkboxes ------------------------------------------------
// Checkboxes in the rendered article are live: toggling them edits the
// Markdown source. If the page editor is open, its CodeMirror document is
// updated directly; otherwise the server copy is toggled and saved.
async function toggleTask(checkbox, index) {
const editor = window.__pageritePageEditor;
const pagePath = editor ? editor.path() : currentPath;
const path = pagePath.replace(/^\/+|\/+$/g, "");
const originalChecked = !checkbox.checked;
try {
const body = { path, index };
if (editor) body.markdown = editor.getMarkdown();
const res = await fetch("/_/api/toggle-task", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const detail = await res.json().catch(() => ({}));
throw new Error(detail.detail || res.statusText);
}
const { markdown } = await res.json();
if (editor) editor.setMarkdown(markdown);
} catch {
checkbox.checked = originalChecked;
}
}
addEventListener("change", (ev) => {
const checkbox = ev.target.closest(".task-list-item-checkbox");
if (!checkbox) return;
const index = Number(checkbox.dataset.taskIndex);
if (Number.isNaN(index)) return;
toggleTask(checkbox, index);
});
applyEffects();
})();