按年月归档但保持 `/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 始终是单段。
ts
// 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/...
ts
// 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
astro
<!-- 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>
)}
astro
<!-- src/components/PostPreview.astro(节选) -->
const slug = getPostSlug(post)
const samePage = Astro.url.pathname === `/posts/${slug}`
const articleLink = samePage ? `#${slug}` : `/posts/${slug}`
astro
<!-- src/components/TimelineSection.astro(节选) -->
<a
href={`/posts/${getPostSlug(post)}`}
class="block text-foreground/90 hover:text-accent transition-colors group"
>
...
</a>
ts
// src/pages/rss.xml.ts(节选)
items: posts.map((post) => ({
title: post.data.title,
pubDate: post.data.published,
link: `/posts/${getPostSlug(post)}`,
}))
ts
// 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

感谢你读到这里。

这座小站更像一份持续维护的“终端笔记”:记录我解决问题的过程,也记录走过的弯路。

如果这篇内容对你有一点点帮助:

  • 点个赞 / 收藏一下,方便你下次回来继续翻
  • 欢迎在评论区补充你的做法(或者指出我的疏漏)
  • 想持续收到更新:可以订阅 RSS(在页面底部)

我们下篇见。


More Posts

评论

评论 (0)

请先登录后再发表评论

加载中...