feat: add custom slug validation and handling for posts

- Enhance content configuration with slug validation to ensure only valid characters are used
- Update post routing to support custom slugs with fallback to default slug
- Implement slug duplication check to prevent conflicts across different languages
- Modify various page components to use custom or default slugs in URL generation
This commit is contained in:
radishzzz 2025-01-25 03:14:52 +00:00
parent ee35006f7c
commit a26031d490
10 changed files with 67 additions and 11 deletions

View file

@ -11,7 +11,19 @@ const postsCollection = defineCollection({
image: z.string().optional().default(''), image: z.string().optional().default(''),
// Extended Settings // Extended Settings
lang: z.string().optional().default(''), lang: z.string().optional().default(''),
slug: z.string().optional().default(''), slug: z.string()
.optional()
.default('')
.refine(
(slug) => {
if (!slug)
return true
return /^[\w\-]*$/.test(slug)
},
{
message: 'Slug can only contain letters, numbers, hyphens and underscores',
},
),
toc: z.boolean().optional().default(false), toc: z.boolean().optional().default(false),
pin: z.boolean().optional().default(false), pin: z.boolean().optional().default(false),
draft: z.boolean().optional().default(false), draft: z.boolean().optional().default(false),

View file

@ -21,7 +21,7 @@ const posts = await getPosts()
<ul> <ul>
{pinnedPosts.map(post => ( {pinnedPosts.map(post => (
<li> <li>
<a href={`/${lang}/posts/${post.slug}/`}> <a href={`/${lang}/posts/${post.data.slug || post.slug}/`}>
{post.data.title} {post.data.title}
<time>({post.data.published.toISOString().split('T')[0]})</time> <time>({post.data.published.toISOString().split('T')[0]})</time>
</a> </a>
@ -35,7 +35,7 @@ const posts = await getPosts()
<ul> <ul>
{posts.map(post => ( {posts.map(post => (
<li> <li>
<a href={`/${lang}/posts/${post.slug}/`}> <a href={`/${lang}/posts/${post.data.slug || post.slug}/`}>
{post.data.title} {post.data.title}
<time>({post.data.published.toISOString().split('T')[0]})</time> <time>({post.data.published.toISOString().split('T')[0]})</time>
</a> </a>

View file

@ -1,13 +1,23 @@
--- ---
import { themeConfig } from '@/config' import { themeConfig } from '@/config'
import Layout from '@/layouts/Layout.astro' import Layout from '@/layouts/Layout.astro'
import { checkSlugDuplication } from '@/utils/content.config'
import { getCollection } from 'astro:content' import { getCollection } from 'astro:content'
export async function getStaticPaths() { export async function getStaticPaths() {
const posts = await getCollection('posts') const posts = await getCollection('posts')
const duplicates = await checkSlugDuplication(posts)
if (duplicates.length > 0) {
throw new Error(`Slug conflicts found:\n${duplicates.join('\n')}`)
}
return themeConfig.global.moreLocale.flatMap(lang => return themeConfig.global.moreLocale.flatMap(lang =>
posts.map(post => ({ posts.map(post => ({
params: { lang, slug: post.slug }, params: {
lang,
slug: post.data.slug || post.slug,
},
props: { post }, props: { post },
})), })),
) )

View file

@ -65,7 +65,7 @@ export async function GET({ params }: APIContext) {
pubDate: post.data.published, pubDate: post.data.published,
description: post.data.description || getExcerpt(post.body), description: post.data.description || getExcerpt(post.body),
// Generate absolute URL with language prefix // Generate absolute URL with language prefix
link: new URL(`${lang}/posts/${post.slug}/`, url).toString(), link: new URL(`${lang}/posts/${post.data.slug || post.slug}/`, url).toString(),
// Convert markdown content to HTML, allowing img tags // Convert markdown content to HTML, allowing img tags
content: post.body content: post.body
? sanitizeHtml(parser.render(post.body), { ? sanitizeHtml(parser.render(post.body), {

View file

@ -32,7 +32,7 @@ const allTags = await getAllTags()
<ul> <ul>
{posts.map(post => ( {posts.map(post => (
<li> <li>
<a href={`/${lang}/posts/${post.slug}/`}>{post.data.title}</a> <a href={`/${lang}/posts/${post.data.slug || post.slug}/`}>{post.data.title}</a>
</li> </li>
))} ))}
</ul> </ul>

View file

@ -13,7 +13,7 @@ const pinnedPosts = await getPinnedPosts()
<ul> <ul>
{pinnedPosts.map(post => ( {pinnedPosts.map(post => (
<li> <li>
<a href={`/posts/${post.slug}/`}> <a href={`/posts/${post.data.slug || post.slug}/`}>
{post.data.title} {post.data.title}
<time>({post.data.published.toISOString().split('T')[0]})</time> <time>({post.data.published.toISOString().split('T')[0]})</time>
</a> </a>
@ -27,7 +27,7 @@ const pinnedPosts = await getPinnedPosts()
<ul> <ul>
{posts.map(post => ( {posts.map(post => (
<li> <li>
<a href={`/posts/${post.slug}/`}> <a href={`/posts/${post.data.slug || post.slug}/`}>
{post.data.title} {post.data.title}
<time>({post.data.published.toISOString().split('T')[0]})</time> <time>({post.data.published.toISOString().split('T')[0]})</time>
</a> </a>

View file

@ -1,11 +1,20 @@
--- ---
import Layout from '@/layouts/Layout.astro' import Layout from '@/layouts/Layout.astro'
import { checkSlugDuplication } from '@/utils/content.config'
import { getCollection } from 'astro:content' import { getCollection } from 'astro:content'
export async function getStaticPaths() { export async function getStaticPaths() {
const posts = await getCollection('posts') const posts = await getCollection('posts')
const duplicates = await checkSlugDuplication(posts)
if (duplicates.length > 0) {
throw new Error(`Slug conflicts found:\n${duplicates.join('\n')}`)
}
return posts.map(post => ({ return posts.map(post => ({
params: { slug: post.slug }, params: {
slug: post.data.slug || post.slug,
},
props: { post }, props: { post },
})) }))
} }

View file

@ -42,7 +42,7 @@ export async function GET() {
pubDate: post.data.published, pubDate: post.data.published,
description: post.data.description || getExcerpt(post.body), description: post.data.description || getExcerpt(post.body),
// Generate absolute URL for post // Generate absolute URL for post
link: new URL(`posts/${post.slug}/`, url).toString(), link: new URL(`posts/${post.data.slug || post.slug}/`, url).toString(),
// Convert markdown content to HTML, allowing img tags // Convert markdown content to HTML, allowing img tags
content: post.body content: post.body
? sanitizeHtml(parser.render(post.body), { ? sanitizeHtml(parser.render(post.body), {

View file

@ -28,7 +28,7 @@ const allTags = await getAllTags()
<ul> <ul>
{posts.map(post => ( {posts.map(post => (
<li> <li>
<a href={`/posts/${post.slug}/`}>{post.data.title}</a> <a href={`/posts/${post.data.slug || post.slug}/`}>{post.data.title}</a>
</li> </li>
))} ))}
</ul> </ul>

View file

@ -7,6 +7,31 @@ export type Post = CollectionEntry<'posts'>
export type PostData = Post['data'] export type PostData = Post['data']
export type PostsGroupByYear = Map<number, Post[]> export type PostsGroupByYear = Map<number, Post[]>
// Check if the slug is duplicated under the same language.
export async function checkSlugDuplication(posts: Post[]): Promise<string[]> {
const slugMap = new Map<string, Set<string>>() // Map<lang, Set<slug>>
const duplicates: string[] = []
posts.forEach((post) => {
const lang = post.data.lang || ''
const slug = post.data.slug || post.slug
if (!slugMap.has(lang)) {
slugMap.set(lang, new Set())
}
const slugSet = slugMap.get(lang)!
if (slugSet.has(slug)) {
duplicates.push(`Duplicate slug "${slug}" found in language "${lang || 'default'}"`)
}
else {
slugSet.add(slug)
}
})
return duplicates
}
// Get all posts except drafts (include pinned) // Get all posts except drafts (include pinned)
export async function getPosts(lang?: string) { export async function getPosts(lang?: string) {
const defaultLocale = themeConfig.global.locale const defaultLocale = themeConfig.global.locale