diff --git a/AGENTS.md b/AGENTS.md
index 9bedb41..bea3bb7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -162,9 +162,15 @@ 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 may not begin with `_` or `.` —
- validated in the site editor, rejected by the API, and such URLs are
- never looked up as content when serving.
+ `/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.
- 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 733003a..1fe147d 100644
--- a/docs/design-principles.md
+++ b/docs/design-principles.md
@@ -32,8 +32,11 @@ 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 may not begin with `_` or `.` anywhere in the path — such URLs
- are never looked up as content.
+ 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.
- **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
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 6910824..c70a586 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -14,6 +14,7 @@
"@codemirror/view": "^6.43.8",
"@lezer/highlight": "^1.2.3",
"codemirror": "^6.0.2",
+ "transliteration": "^2.6.1",
"vue": "^3.5.26",
"vuedraggable": "^4.1.0"
},
@@ -2528,6 +2529,19 @@
"node": ">=6"
}
},
+ "node_modules/transliteration": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/transliteration/-/transliteration-2.6.1.tgz",
+ "integrity": "sha512-hJ9BhrQAOnNTbpOr1MxsNjZISkn7ppvF5TKUeFmTE1mG4ZPD/XVxF0L0LUoIUCWmQyxH0gJpVtfYLAWf298U9w==",
+ "license": "MIT",
+ "bin": {
+ "slugify": "dist/bin/slugify",
+ "transliterate": "dist/bin/transliterate"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/unplugin-utils": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 9ba1f39..3054016 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -17,6 +17,7 @@
"@codemirror/view": "^6.43.8",
"@lezer/highlight": "^1.2.3",
"codemirror": "^6.0.2",
+ "transliteration": "^2.6.1",
"vue": "^3.5.26",
"vuedraggable": "^4.1.0"
},
diff --git a/frontend/src/SiteEditor.vue b/frontend/src/SiteEditor.vue
index d69a6c6..81e1e69 100644
--- a/frontend/src/SiteEditor.vue
+++ b/frontend/src/SiteEditor.vue
@@ -17,6 +17,7 @@ import { EditorState, Compartment } from '@codemirror/state'
import { placeholder } from '@codemirror/view'
import { html } from '@codemirror/lang-html'
import { cmHighlight, cmTheme } from './cmtheme'
+import { slugify } from './slugify'
const props = defineProps({
pagePath: { type: String, default: '' },
@@ -196,14 +197,11 @@ function discardPending() {
async function commitPending() {
const node = pending.value
if (!node) return
- const slug = node.slug.trim().replace(/\/+/g, '')
+ // Empty slug: derive one from the title (transliterated to ASCII).
+ const slug = slugify(node.slug.trim()) || slugify(node.title)
if (!slug) {
return
}
- if (invalidSlug(slug)) {
- saveError.value = '⚠️ slugs cannot begin with _ or .'
- return // keep the pending row so the slug can be fixed
- }
const loc = locatePending(tree.value, '')
const parentPath = loc?.parentPath ?? ''
const newPath = parentPath ? `${parentPath}/${slug}` : slug
@@ -217,7 +215,9 @@ async function commitPending() {
}),
})
if (!res.ok) {
- saveError.value = '⚠️ changes could not be saved'
+ // Show the server's reason (e.g. a reserved file name); the pending
+ // row stays so it can be edited and committed again.
+ saveError.value = `⚠️ ${await errorDetail(res)}`
return
}
// Place it exactly where the row was dropped: a fresh order key halfway
@@ -348,6 +348,13 @@ async function refreshPages() {
} catch { /* list stays stale; not fatal */ }
}
+// Human-readable reason from a failed API call (FastAPI errors carry a
+// JSON {detail}), falling back to a generic message.
+async function errorDetail(res) {
+ const body = await res.json().catch(() => null)
+ return body?.detail || 'changes could not be saved'
+}
+
async function postStructure(op) {
const res = await fetch('/_/api/structure', {
method: 'POST',
@@ -358,7 +365,7 @@ async function postStructure(op) {
saveError.value = ''
loadPlain(path.value) // refresh menus and content from the server
} else {
- saveError.value = '⚠️ changes could not be saved'
+ saveError.value = `⚠️ ${await errorDetail(res)}`
}
await refreshPages()
return res.ok
@@ -398,20 +405,11 @@ function onTitleInput(node, ev) {
})
}
-// Slugs may not begin with _ or . (reserved for the /_/ machinery and
-// dot-paths); enforced on every commit, and again server-side.
-function invalidSlug(slug) {
- return slug.startsWith('_') || slug.startsWith('.')
-}
-
+// The slug inputs are filtered as you type (StructureTree onSlugInput,
+// see slugify.js); the server re-validates and its reason is shown.
async function commitSlug(node, ev) {
- const slug = ev.target.value.trim().replace(/\/+/g, '')
+ const slug = ev.target.value.trim()
if (slug === node.slug) return
- if (invalidSlug(slug)) {
- saveError.value = '⚠️ slugs cannot begin with _ or .'
- ev.target.value = node.slug
- return
- }
const parent = node.path.split('/').slice(0, -1).join('/')
// Empty slug at top level = the front page (path "").
const moveTo = parent ? (slug ? `${parent}/${slug}` : parent) : slug
diff --git a/frontend/src/StructureTree.vue b/frontend/src/StructureTree.vue
index 9e70c11..de24fd3 100644
--- a/frontend/src/StructureTree.vue
+++ b/frontend/src/StructureTree.vue
@@ -17,6 +17,7 @@
// forwarding.
import { inject } from 'vue'
import draggable from 'vuedraggable'
+import { slugify } from './slugify'
defineOptions({ name: 'StructureTree' })
const props = defineProps({
@@ -27,6 +28,18 @@ const props = defineProps({
const handlers = inject('structureHandlers')
+// Live-filter the slug inputs as they are typed (oninput): invalid
+// characters are simply not accepted, spaces become hyphens and unicode
+// folds to ASCII (see slugify.js). Existing rows commit on change, the
+// pending row is v-modeled.
+function onSlugInput(ev) {
+ ev.target.value = slugify(ev.target.value)
+}
+
+function onPendingSlugInput(element, ev) {
+ element.slug = slugify(ev.target.value)
+}
+
// Focus the title input of a fresh pending row.
const vFocus = { mounted: (el) => el.focus() }
@@ -92,7 +105,8 @@ function onEnd() {
@@ -115,6 +129,7 @@ function onEnd() {
:value="element.slug"
placeholder="front page"
title="Slug (last path segment) — renames move the whole subtree. Empty at top level = front page"
+ @input="onSlugInput"
@change="handlers.commitSlug(element, $event)"
/>
diff --git a/frontend/src/slugify.js b/frontend/src/slugify.js
new file mode 100644
index 0000000..00fc129
--- /dev/null
+++ b/frontend/src/slugify.js
@@ -0,0 +1,10 @@
+// Slugs are URL path segments: lowercase ASCII letters, digits, hyphens.
+// The transliteration package folds unicode (incl. asian scripts) to
+// ASCII and spaces to hyphens; we then drop everything outside the slug
+// charset (including the reserved leading "_"/".", slashes, backslashes,
+// dots).
+import { slugify as transliterate } from 'transliteration'
+
+export function slugify(s) {
+ return transliterate(s).replace(/[^a-z0-9-]/g, '').replace(/-{2,}/g, '-')
+}
diff --git a/pagerite/app.py b/pagerite/app.py
index 6e35a13..502e435 100644
--- a/pagerite/app.py
+++ b/pagerite/app.py
@@ -349,10 +349,25 @@ def _is_reserved(path: str) -> bool:
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",
+}
+
+
def _check_reserved(path: str) -> None:
- """Reject configured slugs beginning with "_" or "."."""
+ """Reject configured slugs beginning with "_" or ".", and reserved
+ file names at the root."""
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')
@app.websocket("/_/api/ws/editor")
@@ -497,8 +512,8 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
first child page in menu order.
"""
path = path.strip("/")
- if path and _is_reserved(path):
- # Reserved slug shape: never content — don't even look it up.
+ if path and (_is_reserved(path) or path in RESERVED_FILES):
+ # Reserved slug shape or file name: 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