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:
+17
-19
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
<input
|
||||
v-model="element.slug"
|
||||
class="edit slug-edit"
|
||||
placeholder="slug"
|
||||
placeholder="slug — empty: derived from the title"
|
||||
@input="onPendingSlugInput(element, $event)"
|
||||
@keyup.enter="handlers.commitPending()"
|
||||
@keyup.esc="handlers.discardPending()"
|
||||
/>
|
||||
@@ -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)"
|
||||
/>
|
||||
<span class="acts">
|
||||
|
||||
@@ -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, '-')
|
||||
}
|
||||
Reference in New Issue
Block a user