Перейти к содержимому

35. Реальный проект: Блог

Этот урок — финальный проект: мы разберём архитектуру настоящего блога с content collections, RSS-лентой, тёмной темой и поиском.

src/
├── content/
│ ├── config.ts ← Схемы коллекций
│ └── blog/
│ ├── first-post.md
│ └── second-post.mdx
├── layouts/
│ ├── BaseLayout.astro ← HTML-обёртка
│ ├── BlogLayout.astro ← Шапка + навигация
│ └── PostLayout.astro ← Разметка поста
├── pages/
│ ├── index.astro ← Главная
│ ├── blog/
│ │ ├── index.astro ← Список постов
│ │ └── [slug].astro ← Страница поста
│ └── rss.xml.ts ← RSS-лента
└── components/
├── PostCard.astro
├── TagCloud.astro
└── SearchBar.astro ← (React, client:load)
src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.date(),
updatedDate: z.date().optional(),
heroImage: z.string().optional(),
tags: z.array(z.string()).default([]),
author: z.string().default('Редакция'),
draft: z.boolean().default(false),
readingTime: z.number().optional(),
}),
});
export const collections = { blog };
src/pages/blog/index.astro
---
import { getCollection } from 'astro:content';
const allPosts = await getCollection('blog', ({ data }) => {
return import.meta.env.PROD ? !data.draft : true;
});
const posts = allPosts.sort((a, b) =>
b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
const tags = [...new Set(posts.flatMap(p => p.data.tags))];
---
src/pages/blog/[slug].astro
---
import { getCollection, getEntry } from 'astro:content';
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, headings } = await post.render();
// Подсчёт времени чтения
const words = post.body.split(/\s+/).length;
const readingTime = Math.ceil(words / 200); // ~200 слов/мин
---
---
const currentIndex = posts.findIndex(p => p.slug === post.slug);
const prevPost = posts[currentIndex + 1] ?? null;
const nextPost = posts[currentIndex - 1] ?? null;
---
<nav class="post-navigation">
{prevPost && (
<a href={'/blog/' + prevPost.slug} class="prev">
← {prevPost.data.title}
</a>
)}
{nextPost && (
<a href={'/blog/' + nextPost.slug} class="next">
{nextPost.data.title} →
</a>
)}
</nav>
src/pages/rss.xml.ts
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
export async function GET(context) {
const posts = await getCollection('blog');
return rss({
title: 'Мой Astro блог',
description: 'Статьи о веб-разработке',
site: context.site,
items: posts.map(post => ({
title: post.data.title,
pubDate: post.data.pubDate,
description: post.data.description,
link: '/blog/' + post.slug,
})),
});
}
astro.config.mjs
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://my-blog.com',
integrations: [sitemap()],
});
// Генерирует /sitemap-index.xml и /sitemap-0.xml автоматически
<script is:inline>
const theme = localStorage.getItem('theme')
?? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
</script>