# 按年月归档但保持 `/posts/<slug>` 路由的实现笔记

4 min read
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.astrosrc/components/TimelineSection.astro
  • RSS / 社交卡片:src/pages/rss.xml.tssrc/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,
},
}))

实现原理(代码路径)

  1. 内容层 slug 压平
    src/content.config.ts:为 posts 集合定义 slug 规则,frontmatter 优先;否则取路径末段(跳过 index)并 slug 化,确保得到单段 slug。
  2. 统一 slug 出口
    src/utils.ts#getPostSlug:如果 post.slug 自带多级路径或为空,则用 post.id 的末段(跳过 index)再 slug 化,杜绝 /posts/2025/12/...
  3. 所有路由与链接只用单段 slug
    • 详情页静态路径/上下篇:src/pages/posts/[slug].astro
    • 列表与时间线:src/components/PostPreview.astrosrc/components/TimelineSection.astro
    • RSS / 社交卡片:src/pages/rss.xml.tssrc/pages/social-cards/[slug].png.ts
      以上都调用 getPostSlug,保证跳转与生成资源时路由为 /posts/<slug>
  4. Cesium 插件简化
    astro.config.mjs 移除多余的 vite-plugin-cesium,仅保留自定义集成,避免构建期路径被拼成重复盘符。

怎么用

  • 放文档:src/content/posts/<年>/<月>/,文件名默认决定 slug;若文件名为 index.md(x),会回退用父目录名。
  • 想自定义:frontmatter 写 slug: your-slug,访问 /posts/your-slug
  • 验证:npm run devnpm run build

注意

  • 确保 slug 唯一(不同目录同名会冲突)。
  • 若路由带目录层级,检查自定义 slug 是否含 /,或文件名是否重复用 index
  • RSS、社交卡片、时间线、预览都走 getPostSlug,无需额外配置。
My avatar

Thanks for reading my blog post! Feel free to check out my other posts or contact me via the social links in the footer.


More Posts

Comments