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

Николай Носков 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/
│   ├── пост-как-папка/
│   │   └── index.md
│   └── сайт-как-окно-редактора/
│       ├── index.md
│       └── loop.mp4
└── notes/
    ├── цвет-и-ascii/
    │   └── index.md
    └── черновик-про-relative-number/   ← черновик
        └── index.md
  • Адрес поста повторяет путь папки: /posts/dev/пост-как-папка/.
  • Категория — это просто папка. Новая категория появляется в дереве сама, настраивать нечего.
  • Теги живут не в папках, а в фронтматтере: каждый тег получает страницу /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 открывается как обычный пост, а в продакшен-сборке его страница не создаётся вовсе.

Только ASCII

Растровых картинок на сайте нет: любая схема, палитра или иллюстрация рисуется символами прямо в markdown. Блок ascii понимает подпись для тех, кто его не видит, и умеет анимацию:

```ascii alt="Что нарисовано"
схема символами
```

alt превращает блок в картинку с подписью, без него блок читается как декоративный. Между кадрами ставится строка %%, а мета wave, pulse, blink или fps=3 задаёт анимацию. Кадры обязаны совпадать по числу строк — иначе блок ломается.

Так выглядит палитра из статьи про слои:

Видео

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