Slug input filtering, title-derived slugs, reserved file names

Slug inputs in the site editor now filter as you type via slugify.js
(built on the transliteration npm package: unicode incl. asian scripts
folds to ASCII, spaces become hyphens, anything outside [a-z0-9-] is
dropped) instead of erroring on commit. A new page left with an empty
slug gets one derived from its title. Server-side, _check_reserved
additionally rejects reserved root file names (robots.txt, ads.txt,
sitemap.xml, openapi.json, favicon.ico, site.webmanifest) with a
human-readable reason, which the editor surfaces in its error line so
the row can be edited and committed again; those URLs are also never
looked up as content when serving.
This commit is contained in:
2026-08-16 16:45:57 +00:00
parent 37af433704
commit e38063a522
8 changed files with 90 additions and 28 deletions
+9 -3
View File
@@ -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. - 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 may not begin with `_` or `.` — `/favicon.ico` from the build. Slugs are lowercase ASCII `[a-z0-9-]`
validated in the site editor, rejected by the API, and such URLs are (the site editor filters input live via `slugify.js`, built on the
never looked up as content when serving. `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 - 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.
+5 -2
View File
@@ -32,8 +32,11 @@ 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 may not begin with `_` or `.` anywhere in the path — such URLs Slugs are lowercase ASCII (`[a-z0-9-]`; input is transliterated and
are never looked up as content. 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. - **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
+14
View File
@@ -14,6 +14,7 @@
"@codemirror/view": "^6.43.8", "@codemirror/view": "^6.43.8",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"transliteration": "^2.6.1",
"vue": "^3.5.26", "vue": "^3.5.26",
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0"
}, },
@@ -2528,6 +2529,19 @@
"node": ">=6" "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": { "node_modules/unplugin-utils": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.2.tgz", "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.2.tgz",
+1
View File
@@ -17,6 +17,7 @@
"@codemirror/view": "^6.43.8", "@codemirror/view": "^6.43.8",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"transliteration": "^2.6.1",
"vue": "^3.5.26", "vue": "^3.5.26",
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0"
}, },
+17 -19
View File
@@ -17,6 +17,7 @@ import { EditorState, Compartment } from '@codemirror/state'
import { placeholder } from '@codemirror/view' import { placeholder } from '@codemirror/view'
import { html } from '@codemirror/lang-html' import { html } from '@codemirror/lang-html'
import { cmHighlight, cmTheme } from './cmtheme' import { cmHighlight, cmTheme } from './cmtheme'
import { slugify } from './slugify'
const props = defineProps({ const props = defineProps({
pagePath: { type: String, default: '' }, pagePath: { type: String, default: '' },
@@ -196,14 +197,11 @@ function discardPending() {
async function commitPending() { async function commitPending() {
const node = pending.value const node = pending.value
if (!node) return 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) { if (!slug) {
return 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 loc = locatePending(tree.value, '')
const parentPath = loc?.parentPath ?? '' const parentPath = loc?.parentPath ?? ''
const newPath = parentPath ? `${parentPath}/${slug}` : slug const newPath = parentPath ? `${parentPath}/${slug}` : slug
@@ -217,7 +215,9 @@ async function commitPending() {
}), }),
}) })
if (!res.ok) { 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 return
} }
// Place it exactly where the row was dropped: a fresh order key halfway // 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 */ } } 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) { async function postStructure(op) {
const res = await fetch('/_/api/structure', { const res = await fetch('/_/api/structure', {
method: 'POST', method: 'POST',
@@ -358,7 +365,7 @@ async function postStructure(op) {
saveError.value = '' saveError.value = ''
loadPlain(path.value) // refresh menus and content from the server loadPlain(path.value) // refresh menus and content from the server
} else { } else {
saveError.value = '⚠️ changes could not be saved' saveError.value = `⚠️ ${await errorDetail(res)}`
} }
await refreshPages() await refreshPages()
return res.ok return res.ok
@@ -398,20 +405,11 @@ function onTitleInput(node, ev) {
}) })
} }
// Slugs may not begin with _ or . (reserved for the /_/ machinery and // The slug inputs are filtered as you type (StructureTree onSlugInput,
// dot-paths); enforced on every commit, and again server-side. // see slugify.js); the server re-validates and its reason is shown.
function invalidSlug(slug) {
return slug.startsWith('_') || slug.startsWith('.')
}
async function commitSlug(node, ev) { 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 (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('/') const parent = node.path.split('/').slice(0, -1).join('/')
// Empty slug at top level = the front page (path ""). // Empty slug at top level = the front page (path "").
const moveTo = parent ? (slug ? `${parent}/${slug}` : parent) : slug const moveTo = parent ? (slug ? `${parent}/${slug}` : parent) : slug
+16 -1
View File
@@ -17,6 +17,7 @@
// forwarding. // forwarding.
import { inject } from 'vue' import { inject } from 'vue'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import { slugify } from './slugify'
defineOptions({ name: 'StructureTree' }) defineOptions({ name: 'StructureTree' })
const props = defineProps({ const props = defineProps({
@@ -27,6 +28,18 @@ const props = defineProps({
const handlers = inject('structureHandlers') 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. // Focus the title input of a fresh pending row.
const vFocus = { mounted: (el) => el.focus() } const vFocus = { mounted: (el) => el.focus() }
@@ -92,7 +105,8 @@ function onEnd() {
<input <input
v-model="element.slug" v-model="element.slug"
class="edit slug-edit" class="edit slug-edit"
placeholder="slug" placeholder="slug — empty: derived from the title"
@input="onPendingSlugInput(element, $event)"
@keyup.enter="handlers.commitPending()" @keyup.enter="handlers.commitPending()"
@keyup.esc="handlers.discardPending()" @keyup.esc="handlers.discardPending()"
/> />
@@ -115,6 +129,7 @@ function onEnd() {
:value="element.slug" :value="element.slug"
placeholder="front page" placeholder="front page"
title="Slug (last path segment) — renames move the whole subtree. Empty at top level = 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)" @change="handlers.commitSlug(element, $event)"
/> />
<span class="acts"> <span class="acts">
+10
View File
@@ -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, '-')
}
+18 -3
View File
@@ -349,10 +349,25 @@ def _is_reserved(path: str) -> bool:
return any(seg.startswith(("_", ".")) for seg in path.split("/")) 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: 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): if _is_reserved(path):
raise HTTPException(400, 'slugs cannot begin with "_" or "."') 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") @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. first child page in menu order.
""" """
path = path.strip("/") path = path.strip("/")
if path and _is_reserved(path): if path and (_is_reserved(path) or path in RESERVED_FILES):
# Reserved slug shape: never content — don't even look it up. # Reserved slug shape or file name: 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