Article
从博客到技术平台的最小升级路径(四):Astro + Content Collections 实战指南
把前三篇文章的设计理念转化为代码。这篇是完整的技术实现指南,包含项目结构、Schema 设计、动态路由、搜索集成等全部代码。
写作声明 本文基于笔者使用 Astro 构建博客的完整源码。所有代码均来自生产环境,可直接参考或修改使用。
开头:把设计转化为代码的最小可行方案
前三篇文章讨论了专题化、标签体系、首页设计的理念。这篇文章解决一个问题:如何用 Astro 完整实现这些设计?
我将从空项目开始,一步步构建出具备以下功能的博客系统:
- 专题(Topics)内容管理
- 文章(Posts)组织与分类
- 指南(Guides)序列
- 标签(Tags)系统
- 平台型首页
- 基础搜索
技术栈:Astro 5.x + TypeScript + Tailwind CSS
第一步:项目初始化与依赖
1.1 创建 Astro 项目
npm create astro@latest my-blog
cd my-blog
选择:
- Template: Empty
- TypeScript: Yes
- Dependencies: Install
1.2 安装必要依赖
# 核心依赖
npm install @astrojs/tailwindcss @astrojs/mdx
# 搜索功能(可选)
npm install pagefind
# 工具库
npm install date-fns
1.3 配置文件
// astro.config.mjs
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://yourdomain.com',
output: 'static',
integrations: [
tailwind(),
mdx(),
sitemap(),
],
});
第二步:Content Collections Schema 设计
这是整个系统的核心。我们定义三个内容集合:topics、blog、guides。
2.1 配置内容集合
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
// 专题集合
const topics = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
order: z.number().default(999),
featured: z.boolean().default(false),
subtopics: z.array(z.string()).optional(),
recommendedReading: z.array(z.string()).optional(),
}),
});
// 博客文章集合
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
topic: z.string(), // 所属专题
tags: z.array(z.string()).default([]),
category: z.enum(['interpretation', 'guide', 'tutorial', 'reference']),
featured: z.boolean().default(false),
series: z.object({
name: z.string(),
order: z.number(),
}).optional(),
}),
});
// 指南集合
const guides = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
topic: z.string(),
order: z.number(),
featured: z.boolean().default(false),
tags: z.array(z.string()).default([]),
}),
});
export const collections = { topics, blog, guides };
2.2 目录结构
src/
├── content/
│ ├── blog/
│ │ ├── post-1.md
│ │ └── post-2.mdx
│ ├── guides/
│ │ ├── guide-1.md
│ │ └── guide-2.md
│ └── topics/
│ ├── topic-1.md
│ └── topic-2.md
├── lib/
│ └── content.ts # 内容查询工具
├── pages/
│ ├── index.astro # 首页
│ ├── blog/
│ │ ├── index.astro
│ │ └── [slug].astro
│ ├── guides/
│ │ ├── index.astro
│ │ └── [slug].astro
│ ├── topics/
│ │ ├── index.astro
│ │ └── [slug].astro
│ └── tags/
│ ├── index.astro
│ └── [tag].astro
└── components/
├── ContentCard.astro
├── TopicCard.astro
└── SectionHeader.astro
第三步:内容查询工具库
3.1 基础查询函数
// src/lib/content.ts
import { getCollection, type CollectionEntry } from 'astro:content';
// 类型导出
export type Post = CollectionEntry<'blog'>;
export type Guide = CollectionEntry<'guides'>;
export type Topic = CollectionEntry<'topics'>;
// 获取所有文章(按时间倒序)
export async function getAllPosts(): Promise<Post[]> {
return (await getCollection('blog'))
.filter(post => !post.id.startsWith('_drafts/'))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
}
// 获取最新文章
export async function getRecentPosts(limit = 5): Promise<Post[]> {
return (await getAllPosts()).slice(0, limit);
}
// 获取精选文章
export async function getFeaturedPosts(limit = 6): Promise<Post[]> {
return (await getAllPosts())
.filter(post => post.data.featured)
.slice(0, limit);
}
// 获取专题下文章
export async function getPostsByTopic(topicSlug: string): Promise<Post[]> {
return (await getAllPosts())
.filter(post => normalizeSlug(post.data.topic) === topicSlug);
}
// 获取所有指南
export async function getAllGuides(): Promise<Guide[]> {
return (await getCollection('guides'))
.sort((a, b) => (a.data.order ?? 999) - (b.data.order ?? 999));
}
// 获取所有专题
export async function getAllTopics(): Promise<Topic[]> {
return (await getCollection('topics'))
.sort((a, b) => (a.data.order ?? 999) - (b.data.order ?? 999));
}
// 专题标题映射
export async function getTopicTitleMap(): Promise<Map<string, string>> {
const topics = await getAllTopics();
return new Map(topics.map(t => [normalizeSlug(t.id), t.data.title]));
}
// 辅助函数:标准化 slug
function normalizeSlug(id: string): string {
return id.replace(/\.md$/, '');
}
3.2 标签统计工具
// src/lib/taxonomy.ts
import { getAllPosts, type Post } from './content';
export interface TagCount {
tag: string;
count: number;
}
// 统计标签使用频率
export async function getTagCounts(): Promise<TagCount[]> {
const posts = await getAllPosts();
const counts = new Map<string, number>();
posts.forEach(post => {
post.data.tags?.forEach(tag => {
counts.set(tag, (counts.get(tag) || 0) + 1);
});
});
return Array.from(counts.entries())
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count);
}
// 获取文章的所有标签
export function getPostTags(post: Post): string[] {
return post.data.tags || [];
}
// 格式化标签显示
export function formatTagLabel(tag: string): string {
return tag
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
第四步:动态路由实现
4.1 博客文章详情页
---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content';
import { getTopicTitleById } from '../../lib/content';
import BaseLayout from '../../layouts/BaseLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map(post => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
const topicTitle = await getTopicTitleById(post.data.topic);
---
<BaseLayout
title={post.data.title}
description={post.data.description}
>
<article class="max-w-3xl mx-auto">
<!-- 文章头部 -->
<header class="mb-8">
<div class="flex items-center gap-2 text-sm text-slate-500 mb-4">
<a href={`/topics/${post.data.topic}`} class="hover:text-slate-700">
{topicTitle}
</a>
<span>•</span>
<time>{post.data.pubDate.toLocaleDateString('zh-CN')}</time>
</div>
<h1 class="text-3xl font-bold mb-4">{post.data.title}</h1>
<p class="text-lg text-slate-600">{post.data.description}</p>
<!-- 标签 -->
<div class="flex flex-wrap gap-2 mt-4">
{post.data.tags.map(tag => (
<a
href={`/tags/${tag}`}
class="px-3 py-1 text-sm bg-slate-100 rounded-full hover:bg-slate-200"
>
{tag}
</a>
))}
</div>
</header>
<!-- 文章内容 -->
<div class="prose prose-slate max-w-none">
<Content />
</div>
<!-- 文章底部导航 -->
<footer class="mt-12 pt-8 border-t">
<a href="/blog" class="text-slate-600 hover:text-slate-900">
← 返回文章列表
</a>
</footer>
</article>
</BaseLayout>
4.2 专题详情页
---
// src/pages/topics/[slug].astro
import { getCollection } from 'astro:content';
import { getPostsByTopic, getGuidesByTopic } from '../../lib/content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import ContentCard from '../../components/ContentCard.astro';
export async function getStaticPaths() {
const topics = await getCollection('topics');
return topics.map(topic => ({
params: { slug: topic.slug },
props: { topic },
}));
}
const { topic } = Astro.props;
const { Content } = await topic.render();
const [posts, guides] = await Promise.all([
getPostsByTopic(topic.slug),
getGuidesByTopic(topic.slug),
]);
---
<BaseLayout
title={topic.data.title}
description={topic.data.description}
>
<div class="max-w-4xl mx-auto">
<!-- 专题头部 -->
<header class="mb-12">
<h1 class="text-4xl font-bold mb-4">{topic.data.title}</h1>
<p class="text-xl text-slate-600">{topic.data.description}</p>
<div class="mt-4 text-sm text-slate-500">
{posts.length} 篇文章 · {guides.length} 篇指南
</div>
</header>
<!-- 专题介绍(可选) -->
<div class="prose prose-slate mb-12">
<Content />
</div>
<!-- 指南列表 -->
{guides.length > 0 && (
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">指南</h2>
<div class="space-y-4">
{guides.map(guide => (
<ContentCard
href={`/guides/${guide.slug}`}
title={guide.data.title}
description={guide.data.description}
meta={`指南 ${guide.data.order}`}
/>
))}
</div>
</section>
)}
<!-- 文章列表 -->
<section>
<h2 class="text-2xl font-bold mb-6">文章</h2>
<div class="space-y-6">
{posts.map(post => (
<ContentCard
href={`/blog/${post.slug}`}
title={post.data.title}
description={post.data.description}
meta={post.data.pubDate.toLocaleDateString('zh-CN')}
tags={post.data.tags}
/>
))}
</div>
</section>
</div>
</BaseLayout>
4.3 标签页
---
// src/pages/tags/[tag].astro
import { getCollection } from 'astro:content';
import { formatTagLabel, getTagCounts } from '../../lib/taxonomy';
import BaseLayout from '../../layouts/BaseLayout.astro';
import ContentCard from '../../components/ContentCard.astro';
export async function getStaticPaths() {
const tagCounts = await getTagCounts();
// 只生成有 2 篇以上文章的标签页
return tagCounts
.filter(({ count }) => count >= 2)
.map(({ tag }) => ({
params: { tag },
props: { tag },
}));
}
const { tag } = Astro.props;
const posts = await getCollection('blog', post =>
post.data.tags?.includes(tag)
);
// 按时间倒序
posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
<BaseLayout title={`标签:${formatTagLabel(tag)}`}>
<div class="max-w-4xl mx-auto">
<header class="mb-8">
<h1 class="text-3xl font-bold">{formatTagLabel(tag)}</h1>
<p class="text-slate-600 mt-2">{posts.length} 篇文章</p>
</header>
<div class="space-y-6">
{posts.map(post => (
<ContentCard
href={`/blog/${post.slug}`}
title={post.data.title}
description={post.data.description}
meta={post.data.pubDate.toLocaleDateString('zh-CN')}
/>
))}
</div>
</div>
</BaseLayout>
第五步:平台型首页实现
---
// src/pages/index.astro
import BaseLayout from '../layouts/BaseLayout.astro';
import {
getRecentPosts,
getFeaturedPosts,
getAllTopics,
getTopicTitleMap,
} from '../lib/content';
import { getTagCounts, formatTagLabel } from '../lib/taxonomy';
import ContentCard from '../components/ContentCard.astro';
import TopicCard from '../components/TopicCard.astro';
import SectionHeader from '../components/SectionHeader.astro';
const [
recentPosts,
featuredPosts,
topics,
tagCounts,
topicTitleMap,
] = await Promise.all([
getRecentPosts(4),
getFeaturedPosts(6),
getAllTopics(),
getTagCounts(),
getTopicTitleMap(),
]);
---
<BaseLayout title="首页">
<div class="space-y-16">
<!-- Hero 区域 -->
<section class="hero">
<h1>技术专题化知识库</h1>
<p>涵盖后端工程、AI 工程化、分布式系统等领域</p>
<div class="actions">
<a href="/topics" class="btn-primary">浏览专题</a>
<a href="/blog" class="btn-secondary">查看文章</a>
</div>
</section>
<!-- 精选内容 -->
<section>
<SectionHeader
title="精选内容"
description="优先阅读这些代表性内容"
actionHref="/blog"
actionLabel="浏览全部"
/>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{featuredPosts.map(post => (
<ContentCard
href={`/blog/${post.slug}`}
title={post.data.title}
description={post.data.description}
topicLabel={topicTitleMap.get(post.data.topic)}
tags={post.data.tags.slice(0, 3)}
/>
))}
</div>
</section>
<!-- 专题入口 -->
<section>
<SectionHeader
title="专题入口"
description="按技术领域浏览内容"
actionHref="/topics"
actionLabel="查看全部"
/>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{topics.map(topic => (
<TopicCard
href={`/topics/${topic.slug}`}
title={topic.data.title}
description={topic.data.description}
/>
))}
</div>
</section>
<!-- 最新更新 -->
<section>
<SectionHeader
title="最近更新"
description="最新发布的内容"
/>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{recentPosts.map(post => (
<ContentCard
href={`/blog/${post.slug}`}
title={post.data.title}
description={post.data.description}
meta={post.data.pubDate.toLocaleDateString('zh-CN')}
topicLabel={topicTitleMap.get(post.data.topic)}
/>
))}
</div>
</section>
<!-- 快速索引 -->
<section>
<h2>快速索引</h2>
<div class="flex flex-wrap gap-2">
{tagCounts.slice(0, 15).map(({ tag, count }) => (
<a
href={`/tags/${tag}`}
class="px-4 py-2 bg-slate-100 rounded-full hover:bg-slate-200"
>
{formatTagLabel(tag)} ({count})
</a>
))}
</div>
<a href="/tags" class="text-slate-600 hover:text-slate-900 mt-4 inline-block">
查看全部标签 →
</a>
</section>
</div>
</BaseLayout>
第六步:搜索功能集成(可选)
6.1 安装 Pagefind
npm install pagefind
6.2 构建脚本配置
// package.json
{
"scripts": {
"build": "astro build && pagefind --site dist",
"dev": "astro dev"
}
}
6.3 搜索页面
---
// src/pages/search.astro
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="搜索">
<div class="max-w-2xl mx-auto">
<h1 class="text-3xl font-bold mb-8">搜索</h1>
<!-- Pagefind 搜索框 -->
<div id="search" class="pagefind-ui"></div>
</div>
<script is:inline>
// Pagefind 会自动注入
</script>
</BaseLayout>
第七步:部署到 GitHub Pages
7.1 GitHub Actions 配置
# .github/workflows/deploy.yml
name: Deploy to GitHub Pages
on:
push:
branches: [main]
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: withastro/action@v2
with:
node-version: 20
package-manager: npm
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v3
7.2 Astro 配置适配
// astro.config.mjs
export default defineConfig({
site: 'https://yourusername.github.io',
base: '/your-repo-name', // 如果使用项目页面
// ...
});
性能优化建议
图片优化
使用 Astro 的 Image 组件:
---
import { Image } from 'astro:assets';
import myImage from '../assets/image.jpg';
---
<Image src={myImage} alt="描述" width={800} height={400} />
部分水合
对交互组件使用 client:* 指令:
<SearchComponent client:load />
预加载关键页面
<link rel="prefetch" href="/blog" />
<link rel="prefetch" href="/topics" />
结语:从代码到内容
完成技术实现后,你的博客已经具备了技术平台的全部基础设施:
- ✅ 专题化内容组织
- ✅ 标签分类系统
- ✅ 平台型首页
- ✅ 内容发现路径
- ✅ 搜索功能
但记住:技术只是载体,内容才是价值。
这个系列讨论的不仅是如何搭建一个博客系统,更是如何重新思考内容组织方式——从”文件堆”到”知识库”,从”时间流”到”主题空间”。
希望这个系列对你的博客升级有所帮助。完整的源码可以在 GitHub 找到,欢迎参考和交流。
系列文章
- 从”文件堆”到”专题化”——博客内容组织的第一性原理
- 标签与专题的设计艺术——如何构建不混乱的内容分类学
- 构建平台型首页——让读者从”看到”到”发现”
- Astro + Content Collections 实战指南 ← 本文
参考资源
Series context
你正在阅读:从博客到技术平台的最小升级路径
当前为第 4 / 4 篇。阅读进度只写入此浏览器的 localStorage,用于回到系列页时定位继续阅读入口。
Series Path
当前系列章节
点击章节会在此浏览器记录本地阅读进度;刷新后可继续阅读。
- 从博客到技术平台的最小升级路径(一):从'文件堆'到'专题化' 当你的博客文章超过20篇,读者开始迷失在时间里。这篇文章分享一个实战经验:为什么专题化是博客升级的第一步,以及如何判断你是否已经到了需要升级的时刻。
- 从博客到技术平台的最小升级路径(二):标签与专题的设计艺术 专题和标签有什么区别?为什么标签多了反而更难找内容?这篇文章拆解内容分类学中最常见的三个误区,并分享一个实用的'三层标签体系'设计方法。
- 从博客到技术平台的最小升级路径(三):构建平台型首页——让读者从'看到'到'发现' 专题化解决了内容归属,但读者打开首页时应该看到什么?这篇文章分享如何设计一个'内容发现型'首页,而不是简单的时间流列表。
- 从博客到技术平台的最小升级路径(四):Astro + Content Collections 实战指南 把前三篇文章的设计理念转化为代码。这篇是完整的技术实现指南,包含项目结构、Schema 设计、动态路由、搜索集成等全部代码。
Reading path
继续沿这条专题路径阅读
按推荐顺序继续阅读 内容平台工程 相关内容,而不是只看同专题的随机文章。
Next step
继续深入这个专题
如果这篇内容对你有帮助,下一步可以回到专题页继续系统阅读,或者订阅后续更新。
正在加载评论...
评论与讨论
使用 GitHub 账号登录参与讨论,评论将同步至 GitHub Discussions