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 + 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() {
/>
draft
-