Пост как папка

Николай Носков 2 мин

Каждый пост здесь — отдельная папка. Внутри лежит index.md и всё, что к нему относится: картинки, видео, исходники. Удалить пост — значит удалить одну папку, и ничего не останется висеть в public/.

Новый пост за три шага

  1. Создать папку внутри категории: src/content/posts/<категория>/<слаг>/.
  2. Положить в неё index.md с фронтматтером (шаблон ниже).
  3. Запустить npm run dev и открыть http://localhost:4321 — пост появится в ленте, в дереве слева и в RSS.
---
title: "Заголовок поста"
date: 2026-09-24
description: "Одна-две фразы для ленты, RSS и поисковиков."
tags: [astro, notes]
draft: false
---

Первый абзац. Заголовок первого уровня писать не нужно:
он берётся из title.

## Первый раздел

Структура и адреса

Дерево в сайдбаре строится рекурсивным обходом src/content/posts, поэтому в нём видны настоящие папки и файлы, включая картинки:

src/content/posts/
├── dev/
│   ├── content-collections/
│   │   └── index.md
│   └── neovim-as-a-blog/
│       ├── index.md
│       ├── palette.png
│       └── loop.mp4
└── notes/
    ├── ascii-art/
    │   └── index.md
    └── draft-idea/          ← черновик
        └── index.md
  • Адрес поста повторяет путь папки: /posts/dev/content-collections/.
  • Категория — это просто папка. Новая категория появляется в дереве сама, настраивать нечего.
  • Теги живут не в папках, а в фронтматтере: каждый тег получает страницу /tags/<тег>/.
  • Удаление — удалить папку. Ни список постов, ни навигацию править не нужно.

Фронтматтер

Поля проверяются zod-схемой при сборке. Ошибка роняет npm run build с указанием файла и поля, а не выкатывает страницу с undefined в заголовке.

const posts = defineCollection({
  loader: glob({ pattern: '**/index.md', base: './src/content/posts' }),
  schema: z.object({
    title: z.string().min(1),
    date: z.coerce.date(),
    description: z.string().min(1),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
  }).strict(),
});
  • title — заголовок. Обязательно.
  • date — дата публикации, по ней сортируется лента. Обязательно.
  • description — описание для ленты, RSS и <meta>. Обязательно.
  • tags — список тегов, по умолчанию пустой.
  • draft — true для черновика, по умолчанию false.

Схема строгая: лишнее поле — ошибка, поэтому опечатка вроде titel не проскочит молча. Если в строке есть двоеточие, её нужно взять в кавычки, поэтому title и description проще всегда писать в кавычках.

Черновики

draft: true делает пост черновиком:

  • в дереве он помечен ~, как несохранённый файл;
  • в ленту, RSS и на страницы тегов не попадает;
  • в npm run dev открывается как обычный пост, а в продакшен-сборке его страница не создаётся вовсе.

Картинки

Картинка кладётся в папку поста и вставляется относительным путём:

![Подпись к картинке](./screenshot.png)

Подходят png, jpg, webp, avif, gif. При сборке картинки сжимаются в webp, получают размеры и ленивую загрузку, поэтому исходник можно класть большим. Текст в квадратных скобках — описание для тех, кто картинку не видит, оставлять его пустым не стоит. Картинка занимает ширину колонки текста.

Видео

Видео лучше гифок: весит в разы меньше и не портит цвета. Файл кладётся в папку поста, вставляется HTML-тегом:

<figure>
  <video src="./loop.mp4" autoplay loop muted playsinline width="960" height="540"></video>
  <figcaption>Подпись под видео</figcaption>
</figure>
  • autoplay работает только вместе с muted, а playsinline нужен для iPhone.
  • width и height — исходный размер ролика: браузер заранее оставит под него место, и текст не будет прыгать при загрузке.
  • Поддерживаются mp4 и webm. Короткий зацикленный ролик без звука собирается так:
ffmpeg -i in.mov -t 4 -an -c:v libx264 -crf 28 -movflags +faststart loop.mp4