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