seperated url and title + escape

This commit is contained in:
2026-05-09 09:59:27 +02:00
parent e6cfd61307
commit 4ff9579cff
10 changed files with 78 additions and 17 deletions
+4
View File
@@ -193,6 +193,7 @@ pub async fn create_post(
let meta = PostMeta { let meta = PostMeta {
date: payload.date.unwrap_or_else(|| Utc::now().date_naive()), 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()), summary: payload.summary.filter(|s| !s.trim().is_empty()),
tags: payload.tags, tags: payload.tags,
draft: payload.draft, draft: payload.draft,
@@ -204,6 +205,7 @@ pub async fn create_post(
Ok(Json(PostDetail { Ok(Json(PostDetail {
slug, slug,
date: meta.date, date: meta.date,
title: meta.title,
summary: meta.summary, summary: meta.summary,
tags: meta.tags, tags: meta.tags,
draft: meta.draft, draft: meta.draft,
@@ -272,6 +274,7 @@ pub async fn list_posts(
posts.push(PostInfo { posts.push(PostInfo {
slug: slug.to_string(), slug: slug.to_string(),
date: meta.date, date: meta.date,
title: meta.title.clone(),
summary: meta.summary.clone(), summary: meta.summary.clone(),
tags: meta.tags.clone(), tags: meta.tags.clone(),
draft: meta.draft, draft: meta.draft,
@@ -305,6 +308,7 @@ pub async fn get_post(
Ok(Json(PostDetail { Ok(Json(PostDetail {
slug, slug,
date: meta.date, date: meta.date,
title: meta.title,
summary: meta.summary, summary: meta.summary,
tags: meta.tags, tags: meta.tags,
draft: meta.draft, draft: meta.draft,
+8
View File
@@ -33,6 +33,8 @@ impl Default for SiteConfig {
pub struct PostMeta { pub struct PostMeta {
pub date: NaiveDate, pub date: NaiveDate,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>, pub summary: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>, pub tags: Vec<String>,
@@ -45,6 +47,8 @@ pub struct PostInfo {
pub slug: String, pub slug: String,
pub date: NaiveDate, pub date: NaiveDate,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>, pub summary: Option<String>,
pub tags: Vec<String>, pub tags: Vec<String>,
pub draft: bool, pub draft: bool,
@@ -57,6 +61,8 @@ pub struct PostDetail {
pub slug: String, pub slug: String,
pub date: NaiveDate, pub date: NaiveDate,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>, pub summary: Option<String>,
pub tags: Vec<String>, pub tags: Vec<String>,
pub draft: bool, pub draft: bool,
@@ -70,6 +76,8 @@ pub struct CreatePostRequest {
#[serde(default)] #[serde(default)]
pub old_slug: Option<String>, pub old_slug: Option<String>,
#[serde(default)] #[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub date: Option<NaiveDate>, pub date: Option<NaiveDate>,
#[serde(default)] #[serde(default)]
pub summary: Option<String>, pub summary: Option<String>,
+5 -3
View File
@@ -2,6 +2,7 @@
interface Props { interface Props {
slug: string; slug: string;
date: string; date: string;
title?: string;
excerpt?: string; excerpt?: string;
tags?: string[]; tags?: string[];
draft?: boolean; draft?: boolean;
@@ -9,7 +10,8 @@ interface Props {
formatSlug: (slug: string) => string; 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', { const formattedDate = new Date(date).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric' year: 'numeric', month: 'short', day: 'numeric'
@@ -31,10 +33,10 @@ const formattedDate = new Date(date).toLocaleDateString('en-US', {
)} )}
</div> </div>
<h2 class="text-xl md:text-2xl font-semibold text-lavender group-hover:text-mauve transition-colors mb-2"> <h2 class="text-xl md:text-2xl font-semibold text-lavender group-hover:text-mauve transition-colors mb-2">
{formatSlug(slug)} {displayTitle}
</h2> </h2>
<p class="text-subtext1 text-sm md:text-base leading-relaxed line-clamp-2"> <p class="text-subtext1 text-sm md:text-base leading-relaxed line-clamp-2">
{excerpt || `Read more about ${formatSlug(slug)}...`} {excerpt || `Read more about ${displayTitle}...`}
</p> </p>
{tags.length > 0 && ( {tags.length > 0 && (
<div class="flex flex-wrap gap-2 mt-3"> <div class="flex flex-wrap gap-2 mt-3">
@@ -74,7 +74,7 @@ export default function Dashboard() {
{posts.map(post => ( {posts.map(post => (
<div key={post.slug} className="glass p-6 flex justify-between items-center group hover:bg-surface0/50 transition-colors"> <div key={post.slug} className="glass p-6 flex justify-between items-center group hover:bg-surface0/50 transition-colors">
<div> <div>
<h3 className="font-bold text-lavender text-lg">{post.slug}</h3> <h3 className="font-bold text-lavender text-lg">{post.title || post.slug}</h3>
<p className="text-xs text-subtext0" style={{ color: 'var(--subtext0)' }}>/posts/{post.slug}</p> <p className="text-xs text-subtext0" style={{ color: 'var(--subtext0)' }}>/posts/{post.slug}</p>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
+46 -9
View File
@@ -59,6 +59,15 @@ const narlblogTheme = EditorView.theme({
// Compartment for hot-swapping vim mode without recreating the editor // Compartment for hot-swapping vim mode without recreating the editor
const vimCompartment = new Compartment(); 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) { export default function Editor({ editSlug }: Props) {
const editorRef = useRef<HTMLDivElement>(null); const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null); const viewRef = useRef<EditorView | null>(null);
@@ -66,7 +75,9 @@ export default function Editor({ editSlug }: Props) {
const previewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const previewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const updatePreviewRef = useRef<() => void>(() => {}); const updatePreviewRef = useRef<() => void>(() => {});
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const [title, setTitle] = useState('');
const [slug, setSlug] = useState(editSlug || ''); const [slug, setSlug] = useState(editSlug || '');
const [slugTouched, setSlugTouched] = useState(!!editSlug);
const [date, setDate] = useState(today); const [date, setDate] = useState(today);
const [summary, setSummary] = useState(''); const [summary, setSummary] = useState('');
const [tagsInput, setTagsInput] = useState(''); const [tagsInput, setTagsInput] = useState('');
@@ -146,6 +157,7 @@ export default function Editor({ editSlug }: Props) {
useEffect(() => { useEffect(() => {
if (!editSlug) return; if (!editSlug) return;
getPost(editSlug).then(post => { getPost(editSlug).then(post => {
if (post.title) setTitle(post.title);
if (post.summary) setSummary(post.summary); if (post.summary) setSummary(post.summary);
if (post.date) setDate(post.date); if (post.date) setDate(post.date);
if (post.tags?.length) setTagsInput(post.tags.join(', ')); 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')); }).catch(() => showAlertMsg('Failed to load post.', 'error'));
}, [editSlug]); }, [editSlug]);
// Auto-derive slug from title until user edits the slug field
useEffect(() => {
if (slugTouched) return;
setSlug(clientSlugify(title));
}, [title, slugTouched]);
useEffect(() => { useEffect(() => {
if (showPreview) updatePreview(); if (showPreview) updatePreview();
}, [showPreview, updatePreview]); }, [showPreview, updatePreview]);
@@ -209,8 +227,8 @@ export default function Editor({ editSlug }: Props) {
async function handleSave() { async function handleSave() {
const content = viewRef.current?.state.doc.toString() || ''; const content = viewRef.current?.state.doc.toString() || '';
if (!slug || !content) { if (!title.trim() || !slug || !content) {
showAlertMsg('Title and content are required.', 'error'); showAlertMsg('Title, slug, and content are required.', 'error');
return; return;
} }
const tags = tagsInput const tags = tagsInput
@@ -221,6 +239,7 @@ export default function Editor({ editSlug }: Props) {
const saved = await savePost({ const saved = await savePost({
slug, slug,
old_slug: originalSlug || null, old_slug: originalSlug || null,
title: title.trim(),
date, date,
summary: summary || null, summary: summary || null,
tags, tags,
@@ -228,7 +247,10 @@ export default function Editor({ editSlug }: Props) {
content, content,
}); });
showAlertMsg('Post saved!', 'success'); 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); setOriginalSlug(saved?.slug ?? slug);
} catch (e) { } catch (e) {
showAlertMsg(e instanceof ApiError ? `Error: ${e.message}` : 'Failed to connect to server.', 'error'); showAlertMsg(e instanceof ApiError ? `Error: ${e.message}` : 'Failed to connect to server.', 'error');
@@ -287,17 +309,17 @@ export default function Editor({ editSlug }: Props) {
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
{/* Slug + Date */} {/* Title + Date */}
<div className="grid grid-cols-1 md:grid-cols-[1fr_180px] gap-4"> <div className="grid grid-cols-1 md:grid-cols-[1fr_180px] gap-4">
<div> <div>
<label className="block text-sm font-medium text-subtext1 mb-2">Post Title (URL identifier)</label> <label className="block text-sm font-medium text-subtext1 mb-2">Title</label>
<input <input
type="text" type="text"
value={slug} value={title}
onChange={e => setSlug(e.target.value)} onChange={e => setTitle(e.target.value)}
required required
placeholder="my-awesome-post" 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" className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors"
/> />
</div> </div>
<div> <div>
@@ -311,6 +333,21 @@ export default function Editor({ editSlug }: Props) {
</div> </div>
</div> </div>
{/* Slug */}
<div>
<label className="block text-sm font-medium text-subtext1 mb-2">
Slug <span className="text-overlay0 font-normal">(URL identifier auto-derived from title)</span>
</label>
<input
type="text"
value={slug}
onChange={e => { 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"
/>
</div>
{/* Tags + Draft */} {/* Tags + Draft */}
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-4 md:items-end"> <div className="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-4 md:items-end">
<div> <div>
+1
View File
@@ -43,6 +43,7 @@ export const getPost = (slug: string) => apiFetch<Post>(`/posts/${encodeURICompo
export const savePost = (data: { export const savePost = (data: {
slug: string; slug: string;
old_slug?: string | null; old_slug?: string | null;
title?: string | null;
date?: string; date?: string;
summary?: string | null; summary?: string | null;
tags?: string[]; tags?: string[];
+1
View File
@@ -2,6 +2,7 @@ export interface Post {
slug: string; slug: string;
date: string; date: string;
content: string; content: string;
title?: string;
summary?: string; summary?: string;
excerpt?: string; excerpt?: string;
tags: string[]; tags: string[];
+7 -2
View File
@@ -3,6 +3,7 @@ import type { APIRoute } from 'astro';
interface PostInfo { interface PostInfo {
slug: string; slug: string;
date: string; date: string;
title?: string;
summary?: string; summary?: string;
excerpt?: string; excerpt?: string;
tags: string[]; tags: string[];
@@ -14,8 +15,12 @@ interface SiteConfig {
subtitle: string; 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 { function escapeXml(s: string): string {
return s.replace(/[<>&'"]/g, c => ({ return s.replace(XML_INVALID, '').replace(/[<>&'"]/g, c => ({
'<': '&lt;', '<': '&lt;',
'>': '&gt;', '>': '&gt;',
'&': '&amp;', '&': '&amp;',
@@ -56,7 +61,7 @@ export const GET: APIRoute = async ({ site }) => {
const pubDate = new Date(p.date).toUTCString(); const pubDate = new Date(p.date).toUTCString();
const categories = p.tags.map(t => ` <category>${escapeXml(t)}</category>`).join('\n'); const categories = p.tags.map(t => ` <category>${escapeXml(t)}</category>`).join('\n');
return ` <item> return ` <item>
<title>${escapeXml(formatSlug(p.slug))}</title> <title>${escapeXml(p.title || formatSlug(p.slug))}</title>
<link>${escapeXml(url)}</link> <link>${escapeXml(url)}</link>
<guid isPermaLink="true">${escapeXml(url)}</guid> <guid isPermaLink="true">${escapeXml(url)}</guid>
<pubDate>${pubDate}</pubDate> <pubDate>${pubDate}</pubDate>
+2
View File
@@ -7,6 +7,7 @@ const API_URL = process.env.PUBLIC_API_URL || 'http://localhost:3000';
interface Post { interface Post {
slug: string; slug: string;
date: string; date: string;
title?: string;
excerpt?: string; excerpt?: string;
tags: string[]; tags: string[];
draft: boolean; draft: boolean;
@@ -78,6 +79,7 @@ function formatSlug(slug: string) {
<PostCard <PostCard
slug={post.slug} slug={post.slug}
date={post.date} date={post.date}
title={post.title}
excerpt={post.excerpt} excerpt={post.excerpt}
tags={post.tags} tags={post.tags}
draft={post.draft} draft={post.draft}
+3 -2
View File
@@ -9,6 +9,7 @@ interface PostDetail {
slug: string; slug: string;
date: string; date: string;
content: string; content: string;
title?: string;
summary?: string; summary?: string;
tags: string[]; tags: string[];
draft: boolean; draft: boolean;
@@ -46,7 +47,7 @@ const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
--- ---
<Layout <Layout
title={post ? formatSlug(post.slug) : 'Post'} title={post ? (post.title || formatSlug(post.slug)) : 'Post'}
description={post?.summary} description={post?.summary}
type="article" type="article"
> >
@@ -72,7 +73,7 @@ const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
<div class="flex flex-col md:flex-row md:justify-between md:items-start mt-2 md:mt-4 gap-4"> <div class="flex flex-col md:flex-row md:justify-between md:items-start mt-2 md:mt-4 gap-4">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h1 class="text-3xl md:text-5xl font-extrabold text-mauve mb-3"> <h1 class="text-3xl md:text-5xl font-extrabold text-mauve mb-3">
{formatSlug(post.slug)} {post.title || formatSlug(post.slug)}
</h1> </h1>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-subtext0"> <div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-subtext0">
<time datetime={post.date}>{formatDate(post.date)}</time> <time datetime={post.date}>{formatDate(post.date)}</time>