From 4ff9579cffbdc2b2303a7b254b2d633f97a3a9fb Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 9 May 2026 09:59:27 +0200 Subject: [PATCH] seperated url and title + escape --- backend/src/handlers/posts.rs | 4 ++ backend/src/models.rs | 8 +++ frontend/src/components/PostCard.astro | 8 ++- .../src/components/react/admin/Dashboard.tsx | 2 +- .../src/components/react/admin/Editor.tsx | 55 ++++++++++++++++--- frontend/src/lib/api.ts | 1 + frontend/src/lib/types.ts | 1 + frontend/src/pages/feed.xml.ts | 9 ++- frontend/src/pages/index.astro | 2 + frontend/src/pages/posts/[slug].astro | 5 +- 10 files changed, 78 insertions(+), 17 deletions(-) diff --git a/backend/src/handlers/posts.rs b/backend/src/handlers/posts.rs index d1110c5..95b2539 100644 --- a/backend/src/handlers/posts.rs +++ b/backend/src/handlers/posts.rs @@ -193,6 +193,7 @@ pub async fn create_post( let meta = PostMeta { date: payload.date.unwrap_or_else(|| Utc::now().date_naive()), + title: payload.title.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()), summary: payload.summary.filter(|s| !s.trim().is_empty()), tags: payload.tags, draft: payload.draft, @@ -204,6 +205,7 @@ pub async fn create_post( Ok(Json(PostDetail { slug, date: meta.date, + title: meta.title, summary: meta.summary, tags: meta.tags, draft: meta.draft, @@ -272,6 +274,7 @@ pub async fn list_posts( posts.push(PostInfo { slug: slug.to_string(), date: meta.date, + title: meta.title.clone(), summary: meta.summary.clone(), tags: meta.tags.clone(), draft: meta.draft, @@ -305,6 +308,7 @@ pub async fn get_post( Ok(Json(PostDetail { slug, date: meta.date, + title: meta.title, summary: meta.summary, tags: meta.tags, draft: meta.draft, diff --git a/backend/src/models.rs b/backend/src/models.rs index dde8744..6ebe68d 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -33,6 +33,8 @@ impl Default for SiteConfig { pub struct PostMeta { pub date: NaiveDate, #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub summary: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tags: Vec, @@ -45,6 +47,8 @@ pub struct PostInfo { pub slug: String, pub date: NaiveDate, #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, pub tags: Vec, pub draft: bool, @@ -57,6 +61,8 @@ pub struct PostDetail { pub slug: String, pub date: NaiveDate, #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, pub tags: Vec, pub draft: bool, @@ -70,6 +76,8 @@ pub struct CreatePostRequest { #[serde(default)] pub old_slug: Option, #[serde(default)] + pub title: Option, + #[serde(default)] pub date: Option, #[serde(default)] pub summary: Option, diff --git a/frontend/src/components/PostCard.astro b/frontend/src/components/PostCard.astro index 447bc20..03fe162 100644 --- a/frontend/src/components/PostCard.astro +++ b/frontend/src/components/PostCard.astro @@ -2,6 +2,7 @@ interface Props { slug: string; date: string; + title?: string; excerpt?: string; tags?: string[]; draft?: boolean; @@ -9,7 +10,8 @@ interface Props { formatSlug: (slug: string) => string; } -const { slug, date, excerpt, tags = [], draft = false, readingTime, formatSlug } = Astro.props; +const { slug, date, title, excerpt, tags = [], draft = false, readingTime, formatSlug } = Astro.props; +const displayTitle = title || formatSlug(slug); const formattedDate = new Date(date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' @@ -31,10 +33,10 @@ const formattedDate = new Date(date).toLocaleDateString('en-US', { )}

- {formatSlug(slug)} + {displayTitle}

- {excerpt || `Read more about ${formatSlug(slug)}...`} + {excerpt || `Read more about ${displayTitle}...`}

{tags.length > 0 && (
diff --git a/frontend/src/components/react/admin/Dashboard.tsx b/frontend/src/components/react/admin/Dashboard.tsx index 5fbcda7..df44f5f 100644 --- a/frontend/src/components/react/admin/Dashboard.tsx +++ b/frontend/src/components/react/admin/Dashboard.tsx @@ -74,7 +74,7 @@ export default function Dashboard() { {posts.map(post => (
-

{post.slug}

+

{post.title || post.slug}

/posts/{post.slug}

diff --git a/frontend/src/components/react/admin/Editor.tsx b/frontend/src/components/react/admin/Editor.tsx index ee00ed3..d328e61 100644 --- a/frontend/src/components/react/admin/Editor.tsx +++ b/frontend/src/components/react/admin/Editor.tsx @@ -59,6 +59,15 @@ const narlblogTheme = EditorView.theme({ // Compartment for hot-swapping vim mode without recreating the editor const vimCompartment = new Compartment(); +function clientSlugify(s: string): string { + return s + .toLowerCase() + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + export default function Editor({ editSlug }: Props) { const editorRef = useRef(null); const viewRef = useRef(null); @@ -66,7 +75,9 @@ export default function Editor({ editSlug }: Props) { const previewTimerRef = useRef | null>(null); const updatePreviewRef = useRef<() => void>(() => {}); const today = new Date().toISOString().slice(0, 10); + const [title, setTitle] = useState(''); const [slug, setSlug] = useState(editSlug || ''); + const [slugTouched, setSlugTouched] = useState(!!editSlug); const [date, setDate] = useState(today); const [summary, setSummary] = useState(''); const [tagsInput, setTagsInput] = useState(''); @@ -146,6 +157,7 @@ export default function Editor({ editSlug }: Props) { useEffect(() => { if (!editSlug) return; getPost(editSlug).then(post => { + if (post.title) setTitle(post.title); if (post.summary) setSummary(post.summary); if (post.date) setDate(post.date); if (post.tags?.length) setTagsInput(post.tags.join(', ')); @@ -158,6 +170,12 @@ export default function Editor({ editSlug }: Props) { }).catch(() => showAlertMsg('Failed to load post.', 'error')); }, [editSlug]); + // Auto-derive slug from title until user edits the slug field + useEffect(() => { + if (slugTouched) return; + setSlug(clientSlugify(title)); + }, [title, slugTouched]); + useEffect(() => { if (showPreview) updatePreview(); }, [showPreview, updatePreview]); @@ -209,8 +227,8 @@ export default function Editor({ editSlug }: Props) { async function handleSave() { const content = viewRef.current?.state.doc.toString() || ''; - if (!slug || !content) { - showAlertMsg('Title and content are required.', 'error'); + if (!title.trim() || !slug || !content) { + showAlertMsg('Title, slug, and content are required.', 'error'); return; } const tags = tagsInput @@ -221,6 +239,7 @@ export default function Editor({ editSlug }: Props) { const saved = await savePost({ slug, old_slug: originalSlug || null, + title: title.trim(), date, summary: summary || null, tags, @@ -228,7 +247,10 @@ export default function Editor({ editSlug }: Props) { content, }); showAlertMsg('Post saved!', 'success'); - if (saved?.slug && saved.slug !== slug) setSlug(saved.slug); + if (saved?.slug && saved.slug !== slug) { + setSlug(saved.slug); + setSlugTouched(true); + } setOriginalSlug(saved?.slug ?? slug); } catch (e) { showAlertMsg(e instanceof ApiError ? `Error: ${e.message}` : 'Failed to connect to server.', 'error'); @@ -287,17 +309,17 @@ export default function Editor({ editSlug }: Props) {
- {/* Slug + Date */} + {/* Title + Date */}
- + setSlug(e.target.value)} + value={title} + onChange={e => setTitle(e.target.value)} required - placeholder="my-awesome-post" - className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors font-mono" + placeholder="My Awesome Post" + className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors" />
@@ -311,6 +333,21 @@ export default function Editor({ editSlug }: Props) {
+ {/* Slug */} +
+ + { setSlug(e.target.value); setSlugTouched(true); }} + required + placeholder="my-awesome-post" + className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors font-mono" + /> +
+ {/* Tags + Draft */}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5baf014..8a1980f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -43,6 +43,7 @@ export const getPost = (slug: string) => apiFetch(`/posts/${encodeURICompo export const savePost = (data: { slug: string; old_slug?: string | null; + title?: string | null; date?: string; summary?: string | null; tags?: string[]; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index b16959d..60b9989 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -2,6 +2,7 @@ export interface Post { slug: string; date: string; content: string; + title?: string; summary?: string; excerpt?: string; tags: string[]; diff --git a/frontend/src/pages/feed.xml.ts b/frontend/src/pages/feed.xml.ts index de35796..bd3188b 100644 --- a/frontend/src/pages/feed.xml.ts +++ b/frontend/src/pages/feed.xml.ts @@ -3,6 +3,7 @@ import type { APIRoute } from 'astro'; interface PostInfo { slug: string; date: string; + title?: string; summary?: string; excerpt?: string; tags: string[]; @@ -14,8 +15,12 @@ interface SiteConfig { subtitle: string; } +// Strip C0/DEL control chars that are illegal in XML 1.0 (allow tab, LF, CR). +// eslint-disable-next-line no-control-regex +const XML_INVALID = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g; + function escapeXml(s: string): string { - return s.replace(/[<>&'"]/g, c => ({ + return s.replace(XML_INVALID, '').replace(/[<>&'"]/g, c => ({ '<': '<', '>': '>', '&': '&', @@ -56,7 +61,7 @@ export const GET: APIRoute = async ({ site }) => { const pubDate = new Date(p.date).toUTCString(); const categories = p.tags.map(t => ` ${escapeXml(t)}`).join('\n'); return ` - ${escapeXml(formatSlug(p.slug))} + ${escapeXml(p.title || formatSlug(p.slug))} ${escapeXml(url)} ${escapeXml(url)} ${pubDate} diff --git a/frontend/src/pages/index.astro b/frontend/src/pages/index.astro index 17f7f4b..7c42e84 100644 --- a/frontend/src/pages/index.astro +++ b/frontend/src/pages/index.astro @@ -7,6 +7,7 @@ const API_URL = process.env.PUBLIC_API_URL || 'http://localhost:3000'; interface Post { slug: string; date: string; + title?: string; excerpt?: string; tags: string[]; draft: boolean; @@ -78,6 +79,7 @@ function formatSlug(slug: string) { @@ -72,7 +73,7 @@ const isAdmin = Astro.cookies.get('admin_session')?.value === '1';

- {formatSlug(post.slug)} + {post.title || formatSlug(post.slug)}