Table of Contents
背景与概念
目标:内容按 src/content/posts/<年>/<月>/... 存放,但线上路由固定 /posts/<slug>,不夹带目录。
什么是 slug?
- URL 中简短、可读、URL 安全的标识(常用小写、连字符),如
my-first-post。 - 作用:让链接稳定可读,脱离目录耦合,便于 SSG/RSS/OG,且可控唯一性(文件名或 frontmatter)。
关键改动
1)内容层统一 slug 生成(入口)
- 位置:
src/content.config.ts - 规则:前置 frontmatter
slug;否则取路径末段,若是index则回退上一段;最后 slug 化。 - 结果:文件可放多级目录,但生成的 slug 始终是单段。
// src/content.config.ts(节选)slug: ({ defaultSlug, data }) => { const frontmatterSlug = (data as any).slug if (typeof frontmatterSlug === 'string' && frontmatterSlug.trim()) { return slugify(frontmatterSlug.trim()) } const segments = defaultSlug.split('/').filter(Boolean) let candidate = segments.pop() || defaultSlug || 'post' if (candidate.toLowerCase() === 'index' && segments.length > 0) { candidate = segments.pop()! } return slugify(candidate || 'post')}2)工具函数统一出口(运行时兜底)
- 位置:
src/utils.ts#getPostSlug - 逻辑:如果
post.slug已是单段直接用;若包含/或为空,则取post.id末段(跳过index)再 slug 化。 - 结果:任何入口都能拿到单段 slug,彻底避免
/posts/2025/12/...。
// src/utils.ts(节选)export function getPostSlug(post: CollectionEntry<'posts'>): string { const rawSlug = post.slug const hasPath = rawSlug?.includes('/') if (rawSlug && !hasPath) return rawSlug
const parts = post.id.split('/').filter(Boolean) let candidate = parts.pop() || post.id if (candidate.toLowerCase() === 'index' && parts.length > 0) { candidate = parts.pop()! } return slug(candidate)}3)路由/链接全面调用 getPostSlug
- 详情页路径与上下篇:
src/pages/posts/[slug].astro - 列表与时间线:
src/components/PostPreview.astro、src/components/TimelineSection.astro - RSS / 社交卡片:
src/pages/rss.xml.ts、src/pages/social-cards/[slug].png.ts
<!-- src/pages/posts/[slug].astro(节选) -->export const getStaticPaths = (async () => { const posts = await getSortedPosts() return posts.map((post) => { const slug = getPostSlug(post) const { prev, next } = getPostSequenceContext(post, posts) return { params: { slug }, props: { post, prev, next, slug } } })}) satisfies GetStaticPaths...<h1 id={slug} class="mb-4 text-[1.75rem] text-heading1 font-semibold"> # {postData.title}</h1>...{nextPostInSeries && ( <a href={`/posts/${getPostSlug(nextPostInSeries)}`} class="button ..."> <span>Next: {nextPostInSeries.data.title}</span> </a>)}<!-- src/components/PostPreview.astro(节选) -->const slug = getPostSlug(post)const samePage = Astro.url.pathname === `/posts/${slug}`const articleLink = samePage ? `#${slug}` : `/posts/${slug}`<!-- src/components/TimelineSection.astro(节选) --><a href={`/posts/${getPostSlug(post)}`} class="block text-foreground/90 hover:text-accent transition-colors group"> ...</a>// src/pages/rss.xml.ts(节选)items: posts.map((post) => ({ title: post.data.title, pubDate: post.data.published, link: `/posts/${getPostSlug(post)}`,}))// src/pages/social-cards/[slug].png.ts(节选)return posts.map((post) => ({ params: { slug: getPostSlug(post) }, props: { pubDate: post.data.published ? dateString(post.data.published) : undefined, title: post.data.title, author: post.data.author || siteConfig.author, },}))实现原理(代码路径)
- 内容层 slug 压平
src/content.config.ts:为 posts 集合定义 slug 规则,frontmatter 优先;否则取路径末段(跳过index)并 slug 化,确保得到单段 slug。 - 统一 slug 出口
src/utils.ts#getPostSlug:如果post.slug自带多级路径或为空,则用post.id的末段(跳过index)再 slug 化,杜绝/posts/2025/12/...。 - 所有路由与链接只用单段 slug
- 详情页静态路径/上下篇:
src/pages/posts/[slug].astro - 列表与时间线:
src/components/PostPreview.astro、src/components/TimelineSection.astro - RSS / 社交卡片:
src/pages/rss.xml.ts、src/pages/social-cards/[slug].png.ts
以上都调用getPostSlug,保证跳转与生成资源时路由为/posts/<slug>。
- 详情页静态路径/上下篇:
- Cesium 插件简化
astro.config.mjs移除多余的vite-plugin-cesium,仅保留自定义集成,避免构建期路径被拼成重复盘符。
怎么用
- 放文档:
src/content/posts/<年>/<月>/,文件名默认决定 slug;若文件名为index.md(x),会回退用父目录名。 - 想自定义:frontmatter 写
slug: your-slug,访问/posts/your-slug。 - 验证:
npm run dev或npm run build。
注意
- 确保 slug 唯一(不同目录同名会冲突)。
- 若路由带目录层级,检查自定义
slug是否含/,或文件名是否重复用index。 - RSS、社交卡片、时间线、预览都走
getPostSlug,无需额外配置。