Часть 08. Движок блога: статический генератор и админка
Часть 08. Движок блога: статический генератор и админка
«Дом построен, дверь открыта, табличка блестит. Осталось наполнить комнаты мебелью. Наш блог — это дом, где каждая глава — комната, а каждая серия — этаж.» — Воланд
Глава 1. Что такое статический генератор и зачем он нужен
Наш блог работает по принципу статического генератора. В отличие от WordPress или Grav CMS, где страницы собираются на лету из базы данных, статический генератор создаёт готовые HTML-файлы заранее.
Как это работает:
- Вы пишете текст в Markdown-файлах с метаданными (YAML frontmatter)
- Запускаете скрипт рендеринга — он читает Markdown, подставляет в шаблоны и создаёт HTML
- Nginx отдаёт готовые HTML-файлы — никакой базы данных, никакого PHP на каждом запросе
Почему это хорошо:
- Скорость — HTML отдаётся напрямую Nginx, без загрузки PHP
- Надёжность — нет базы данных, которую можно потерять
- Бэкапы — весь сайт это папка с файлами, скопировал — сохранил
- Простота — для написания нужен только текстовый редактор и Markdown
Наш стек:
- league/commonmark — библиотека для парсинга Markdown в HTML
- PHP 8.3 CLI — скрипты сборки запускаются из командной строки
- Шаблоны — простые PHP-шаблоны с экранированием (без Twig/Smarty)
Бегемот, раскладывая бумаги:
«Статический генератор — как печатный станок. Написал, нажал кнопку — получил стопку готовых страниц. Не нужно каждый раз переписывать книгу от руки, как в старые времена.»
Глава 2. Структура проекта
Все файлы блога находятся в /home/sergey/projects/www/. Разберём структуру:
www/
├── admin/ # Административная панель (PHP)
│ ├── index.php # Главная админки (CRUD)
│ ├── login.php # Страница входа
│ ├── logout.php # Выход
│ ├── create.php # Создание новой главы
│ ├── delete.php # Удаление главы
│ ├── import.php # Импорт Markdown-файлов
│ └── lib/ # PHP-классы админки
│ ├── Auth.php # Аутентификация (PBKDF2)
│ ├── Config.php # Константы путей
│ ├── Csrf.php # CSRF-защита
│ ├── FrontMatter.php # Парсер YAML-заголовков
│ ├── Render.php # Запуск рендеринга
│ ├── RenderResult.php # Результат рендеринга
│ ├── Slug.php # Генерация URL-имён
│ └── Validator.php # Валидация ввода
├── config/ # Конфигурация (пусто, файл credentials.php вне репозитория)
├── data/ # Исходные файлы контента
│ ├── chapters/ # Markdown-файлы глав
│ └── series/ # YAML-файлы серий
├── public/ # Сгенерированные HTML-файлы (корень Nginx)
│ ├── index.html
│ ├── archive/
│ ├── series/
│ └── ... (папки серий)
├── scripts/ # CLI-скрипты сборки
│ ├── render.php # Точка входа: full | single <slug> | index
│ └── lib/
│ ├── Builder.php # Генератор HTML
│ ├── Config.php # Константы для сборщика
│ ├── Index.php # Чтение и сортировка контента
│ └── Parser.php # Парсинг Markdown + frontmatter
├── templates/ # PHP-шаблоны
│ ├── layout.php # Главный шаблон (терминальный стиль)
│ ├── home.php # Главная страница
│ ├── chapter.php # Страница главы
│ ├── series.php # Список серий
│ ├── series-single.php # Страница одной серии
│ └── archive.php # Архив всех глав
├── vendor/ # Composer-зависимости
├── composer.json
├── composer.lock
└── dev-server.php # Встроенный PHP-сервер для разработки
Коровьев, расставляя папки:
«У каждой вещи — своё место. Исходники — в data, шаблоны — в templates, результат — в public. Админка следит за порядком, скрипты делают работу. Никто не путается.»
Глава 3. Формат контента: YAML + Markdown
Каждая глава — это обычный текстовый файл в data/chapters/. Имя файла формируется по шаблону: slug-серии--slug-главы.md.
Пример: istoriya-unix--glava-i-predystoriya-gibel-multics.md
Файл состоит из двух частей:
YAML Frontmatter (метаданные)
Между --- и --- — метаданные в формате YAML:
---
title: "Глава I. Предыстория: гибель Multics"
date: "2026-07-23T12:00:00Z"
slug: "08-dvizhok-bloga"
series: "istoriya-unix"
order: 1
tags: ["unix", "multics", "bell-labs"]
status: published
---
Поля:
title— заголовок главыdate— дата публикации (ISO 8601)slug— уникальный идентификатор (используется в URL)series— к какой серии относится главаorder— порядковый номер в серииtags— меткиstatus—published(опубликована) илиdraft(черновик)
Markdown (содержимое)
Всё, что после второго ---, — тело главы в Markdown:
## От B к C: рождение нового языка
Если Unix — тело, то язык C — душа. Их история неразрывна. В начале 1970-х Unix был написан на ассемблере PDP-11...
### Заголовок третьего уровня
Обычный текст с **жирным** и *курсивным* выделением.
- Элемент списка
- Ещё элемент
1. Нумерованный список
2. Второй пункт
Парсер конвертирует Markdown в HTML с помощью библиотеки league/commonmark (установлена через Composer).
Файлы серий
В data/series/ лежат YAML-файлы, описывающие серии статей:
title: "История Unix"
slug: "08-dvizhok-bloga"
description: "Полная история Unix — от провала Multics до современности."
published: true
order: 1
Бегемот, заправляя перьевую ручку:
«Пишете в Markdown — получаете красивый HTML. Frontmatter — как паспорт главы: имя, дата рождения, серия, статус. Без паспорта ни одна глава не попадёт на сайт.»
Глава 4. Установка зависимостей (Composer)
Проект использует менеджер пакетов Composer с единственной зависимостью — библиотекой для парсинга Markdown.
Проверяем, что Composer установлен:
composer --version
Ожидаемый вывод:
Composer version 2.7.x ...
Если не установлен — ставим:
sudo apt install -y composer
Устанавливаем зависимости из composer.json:
cd /home/sergey/projects/www && composer install
Ожидаемый вывод:
Installing dependencies from lock file (including require-dev)
Verifying lock file contents can be installed on current platform.
Package operations: 1 install, 0 updates, 0 removals
- Installing league/commonmark (2.5.x)
Generating autoload files
После установки в проекте появляется:
vendor/— директория с библиотекамиvendor/autoload.php— автозагрузчик Composer (подключается во всех PHP-скриптах)
Все PHP-классы проекта используют PSR-4 автозагрузку (прописана в composer.json):
"autoload": {
"psr-4": {
"App\\Admin\\": "admin/lib/",
"App\\Scripts\\": "scripts/lib/"
}
}
Это значит, что класс App\Admin\Auth лежит в файле admin/lib/Auth.php и подгружается автоматически.
Воланд, принимая ключи:
«Одна зависимость — одна библиотека. Никаких фреймворков на сотню мегабайт. Минимализм — это не скупость, это точность. Каждая строчка кода должна быть оправдана.»
Глава 5. Система аутентификации
Административная панель защищена паролем. Вход через форму логина, аутентификация на основе PBKDF2, защита от CSRF и фиксации сессии.
5.1. Файл с учётными данными
Учётные данные хранятся в /etc/notebook/credentials.php — вне директории сайта. Это гарантирует, что даже при ошибке Nginx файл не будет прочитан через веб.
<?php
return [
'username' => 'admin',
'hash' => 'BASE64_SALT$BASE64_HASH',
];
Пароль не хранится в открытом виде. Хранится только PBKDF2-хеш с солью.
5.2. PBKDF2 — проверка пароля
Класс Auth::login() делает следующее:
- Загружает файл
/etc/notebook/credentials.php - Сравнивает имя пользователя
- Разделяет
hashна соль и хеш (разделитель$) - Декодирует соль из Base64
- Вычисляет PBKDF2-SHA256 с 10 000 итераций
- Сравнивает результат с сохранённым хешем через
hash_equals()(защита от timing attack)
$salt = base64_decode($parts[0]);
$computed = hash_pbkdf2('sha256', $password, $salt, 10000, 32, true);
if (!hash_equals(base64_decode($hash), $computed)) {
return false;
}
5.3. Сессия и CSRF
После успешного входа:
- Сессия сохраняет
auth_user,auth_ip,auth_ua - Новая сессия генерируется через
session_regenerate_id(true)— защита от фиксации сессии - Параметры cookie:
httponly=true,secure=true,samesite=Strict
CSRF-токен генерируется случайным образом и проверяется в каждой POST-форме:
$token = $_POST['_csrf'] ?? '';
if (!Csrf::validate($token)) {
$error = 'Неверный CSRF-токен';
}
5.4. Блокировка по IP
Auth::isLoggedIn() проверяет не только наличие сессии, но и совпадение IP-адреса:
if ($_SESSION['auth_ip'] !== $_SERVER['REMOTE_ADDR']) {
return false;
}
Если злоумышленник украдёт cookie сессии — с его IP войти не получится. Fail2ban (настроенный в части 05) дополнительно блокирует IP после 3 неудачных попыток за 5 минут.
Воланд, запирая сейф:
«Три замка на двери: PBKDF2 — пароль не прочитать даже с суперкомпьютером, привязка к IP — сессия бесполезна с чужого адреса, CSRF — внешняя форма не отправит данные. А снаружи Fail2ban ждёт неудачников.»
Глава 6. Административная панель
Админка доступна по адресу https://psj-notes.ru/admin/.
6.1. Вход (/admin/login.php)
Терминал-стилизованная страница с двумя полями: login и password. После успешного входа — редирект на /admin/index.php.
Стиль страницы соответствует основному сайту: жёлтый терминал на тёмном фоне, зелёный текст, мигающий курсор.
6.2. Главная админки (/admin/index.php)
После входа видно:
- Список всех глав — таблица с заголовком, серией, статусом (published/draft), датой
- Действия — создать новую главу, отредактировать, удалить, перерендерить сайт
- Кнопка перерендеринга — после редактирования контента нужно нажать «Render» чтобы применить изменения
6.3. Создание главы (/admin/create.php)
Форма с полями:
- Заголовок
- Slug (генерируется автоматически из заголовка, можно изменить)
- Серия (выбор из существующих)
- Номер главы в серии
- Статус (published/draft)
- Теги
- Содержимое в Markdown (textarea)
После сохранения:
- Создаётся файл в
data/chapters/ - Если выбран статус
published— запускается рендеринг
6.4. Импорт (/admin/import.php)
Позволяет загрузить готовые Markdown-файлы с YAML-заголовками. Удобно, если вы пишете главы локально в редакторе и потом загружаете на сервер.
6.5. Рендеринг
После изменения контента нужно обновить статические HTML-файлы. Кнопка «Render» в админке вызывает:
Render::trigger($seriesSlug);
Это запускает CLI-скрипт php render.php single <slug> через proc_open():
php /home/sergey/projects/www/scripts/render.php single istoriya-unix
Вывод возвращается на страницу. Если ошибка — текст ошибки показывается администратору.
Коровьев, нажимая красную кнопку:
«Написал — сохранил. Нажал «Render» — получил HTML. Как фотопечать: негатив проявлен, отпечаток готов. Можно вешать на стену (публиковать на сайте).»
Глава 7. Скрипты сборки
Сердце статического генератора — скрипты в scripts/.
7.1. Точка входа: render.php
CLI-скрипт, запускается из командной строки:
# Собрать всё (все серии + главная + архив)
php scripts/render.php full
# Собрать одну серию
php scripts/render.php single istoriya-unix
# Собрать только индексные страницы (главная, архив, список серий)
php scripts/render.php index
7.2. Парсер: Parser.php
Читает Markdown-файл и разделяет на frontmatter и тело:
$parsed = Parser::parse($filePath);
// $parsed->frontMatter = ['title' => '...', 'series' => '...', ...]
// $parsed->bodyHtml = '<h2>От B к C...</h2><p>...'
Frontmatter парсится регулярным выражением, тело конвертируется из Markdown в HTML через league/commonmark.
7.3. Индексатор: Index.php
Собирает все главы из data/chapters/, сортирует по сериям и порядку. Возвращает:
$allSeries = Index::getSeries(); // [['slug'=>'istoriya-unix','title'=>'...'], ...]
$chapters = Index::getChapters('istoriya-unix'); // все главы серии
$allChapters = Index::getAllPublished(); // все опубликованные главы
7.4. Генератор: Builder.php
Создаёт HTML-файлы в public/:
$builder->buildSeries($slug, $series, $chapters);
// → public/istoriya-unix/index.html
// → public/istoriya-unix/glava-i-predystoriya-gibel-multics/index.html
// → ...
$builder->buildIndexPages($allSeries, $allChapters);
// → public/index.html
// → public/archive/index.html
// → public/series/index.html
Каждая страница собирается так:
- Парсится содержимое главы (Markdown → HTML)
- Выбирается шаблон из
templates/(chapter.php, home.php, ...) - Шаблон рендерится: заголовок, содержимое, меню, футер
- Результат оборачивается в
layout.php(весь HTML-каркас с терминальным дизайном) - Записывается в
public/в соответствующую директорию
7.5. Шаблоны
Все шаблоны — это PHP-файлы с минимальной логикой (экранирование, условные операторы, циклы).
layout.php — основной каркас:
<body>
<div class="terminal">
<div class="terminal-header">...</div>
<div class="terminal-menu">
<a href="/">~/home</a>
<a href="/series">~/series</a>
<a href="/archive">~/archive</a>
</div>
<div class="terminal-body">
<?= $pageContent ?? '' ?>
</div>
<div class="terminal-footer">
<span class="prompt">sergey@srv02-Helsinki:~/<nav> $</span>
<span class="cursor"></span>
</div>
</div>
</body>
Шаблоны используют только htmlspecialchars() для экранирования — без Twig-подобных конструкций. Этого достаточно, поскольку логика сборки минимальна.
Бегемот, настраивая конвейер:
«Парсер чистит картошку (Markdown). Индексатор сортирует по размеру. Генератор нарезает и фасует. На выходе — готовые банки (HTML-файлы). Ручной труд не требуется.»
Глава 8. Структура сгенерированного сайта
После выполнения php scripts/render.php full в public/ появляются:
public/
├── index.html # Главная страница
├── archive/ # Архив всех глав
│ └── index.html
├── series/ # Список серий
│ └── index.html
├── istoriya-unix/ # Серия "История Unix"
│ ├── index.html # Страница серии
│ ├── glava-i-predystoriya-gibel-multics/ # Глава 1
│ │ └── index.html
│ ├── glava-ii-rozhdenie-unix-na-pdp-7/ # Глава 2
│ │ └── index.html
│ ├── ... # Остальные главы
│ └── zaklyuchenie/ # Заключение
│ └── index.html
├── nastroika-vds-servera-s-vpn/ # Серия "Настройка VDS"
│ ├── index.html
│ ├── ot-vds-do-ssh/
│ │ └── index.html
│ ├── ssh-klyuchi-sudo-i-zsh/
│ │ └── index.html
│ └── ...
└── assets/ # CSS, шрифты, изображения
Каждая глава — это папка с index.html. URL выглядят как:
https://psj-notes.ru/— главнаяhttps://psj-notes.ru/series/— список серийhttps://psj-notes.ru/archive/— архив всех главhttps://psj-notes.ru/istoriya-unix/— страница серииhttps://psj-notes.ru/istoriya-unix/glava-i-predystoriya-gibel-multics/— конкретная глава
Nginx просто отдаёт файл index.html из соответствующей папки.
Коровьев, расставляя книги на полке:
«Каждая глава — отдельная книга с собственным корешком (папкой). Nginx — библиотекарь: гость просит «/istoriya-unix/glava-iii/», библиотекарь идёт на полку istoriya-unix, берёт книгу glava-iii/index.html и отдаёт читателю. Быстро и без суеты.»
Глава 9. Первая публикация
Пошаговый процесс публикации новой главы:
- Создаём Markdown-файл локально или через админку:
nano /home/sergey/projects/www/data/chapters/moya-seriya--nazvanie-glavy.md
- Пишем frontmatter:
---
title: "Название моей главы"
date: "2026-07-27T12:00:00Z"
slug: "08-dvizhok-bloga"
series: "moya-seriya"
order: 1
tags: ["тег1", "тег2"]
status: published
---
-
Пишем содержимое в Markdown.
-
Запускаем сборку:
cd /home/sergey/projects/www && php scripts/render.php full
- Проверяем результат:
curl -s https://psj-notes.ru/moya-seriya/nazvanie-glavy/ | head -5
- Если через админку: логинимся, нажимаем «Create», заполняем форму, сохраняем. Рендеринг запустится автоматически.
Воланд, перелистывая свежий оттиск:
«Слово стало HTML. Текст обращён в страницу. Ещё одна глава — ещё одна комната в доме. Читатели уже стучатся в дверь.»
Финал части: Резюме Воланда
| Параметр | Статус |
|---|---|
| Структура проекта организована | ✅ |
| Формат контента: YAML frontmatter + Markdown | ✅ |
| Composer установлен, зависимости загружены | ✅ |
| Аутентификация: PBKDF2 + CSRF + привязка к IP | ✅ |
| Сессии: httponly, secure, samesite=Strict | ✅ |
| Файл credentials.php вне корня сайта | ✅ |
| Административная панель (CRUD) | ✅ |
| Импорт Markdown-файлов | ✅ |
| CLI-рендеринг: full / single / index | ✅ |
| Статические HTML-файлы в public/ | ✅ |
| ЧПУ через папки с index.html | ✅ |
| График публикаций | 📅 |
Воланд подводит итог всей серии:
«Сервер поднят, дверь заперта, фаервол включён, VPN проложен. Nginx встречает гостей, PHP-FPM готовит страницы, Let's Encrypt ручается за честность. Админка позволяет управлять контентом, Fail2ban отгоняет злоумышленников, Tailscale соединяет с домом. Блог живёт — статические страницы летят к читателям быстрее ветра. Финал.»
Комментарии
Комментарии пока не подключены.