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)Content Collections — схема
Заголовок раздела «Content Collections — схема»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 };Получение и сортировка постов
Заголовок раздела «Получение и сортировка постов»---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))];---Страница поста со временем чтения
Заголовок раздела «Страница поста со временем чтения»---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>RSS-лента
Заголовок раздела «RSS-лента»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, })), });}Sitemap
Заголовок раздела «Sitemap»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>