Довідник із замком: markdown → nginx, без CMS
Задача проста на словах: тримати власні статті, частину віддавати всім, частину лишати за паролем, і щоб нова стаття коштувала рівно один файл. Канонічна відповідь на це — статичний генератор плюс хостинг. Коштує вона більше, ніж на око: конфіг, теми, плагіни, білд, деплой — і кожне з п'яти треба спершу вивчити, а вже потім воно почне економити.
Замість цього під цим самим сайтом живе приблизно 200 рядків Python і десяток рядків nginx. Розписую не бо воно унікальне, а бо повторюване: схема тримається на будь-якому наборі текстів, де є межа «своє / публічне».
Принцип
Джерело правди — markdown на диску. Згенерований HTML не несе нічого свого: його можна знести цілком і зібрати наново коли завгодно. Це те рішення, з якого випливає решта, — і єдине, яке тут справді треба ухвалити свідомо.
Кожен файл тягне фронтметр:
---
title: slug-статті
desc: Одне речення — іде в meta description і в картку на індексі.
locked: true # true → /ai/<секція>/, false → /<секція>/
order: 3 # позиція в списку
section: claude # у який розділ
---
Один прапорець locked вирішує все. Генератор розкидає статті по двох коренях, і кожен корінь дістає власний індекс, який знає тільки про свої статті. Закритий індекс у публічному дереві не існує взагалі — це не приховані посилання, це фізично інша тека. Різниця тут критична: приховане посилання можна вгадати, теки, якої нема, — ні.
Генератор
Три функції тягнуть усю роботу:
def parse(path):
"""markdown + фронтметр → dict з мета і тілом"""
def build_root(arts, out, base, ...):
"""список статей → тека з index.html і підтеками статей"""
def main():
"""розкласти по секціях × (locked / public), зібрати кожен корінь"""
Два місця, де підставитись найлегше:
build_root робить rmtree перед збіркою. Навмисно: інакше видалена стаття лишається жити в дереві вічно. Ціна — усе, що покладено в цільову теку руками, зникає на наступному рендері. Звідси правило: кастомний файл у вихідній теці — це баг, а не фіча; будь-яка потрібна поведінка мусить жити в генераторі.
Права на файли. Після збірки все дерево перепризначається на користувача веб-сервера. Забув chown — і дістаєш 403 на випадкових сторінках, рівно там, куди щойно приїхали нові файли. Перший раз це читається як містика, поки не згадуєш про власника.
Секції без коду
Розділи описані словником, а не гілками в коді:
SECTIONS = {
"claude": {"title": "Claude", "lead": "…"},
"tools": {"title": "Tools", "lead": "…"},
}
Далі генератор просто ітерується по ньому. Новий розділ — чотири рядки конфігу і один location у nginx, без жодного рефакторингу. Той рідкісний випадок, коли «винести в конфіг» окупається того ж дня: розділи я додаю частіше, ніж чіпаю сам рендер.
Замок
Уся приватність тримається на двох речах: basic auth і те, що закриті статті лежать в іншому префіксі:
location /ai {
root /root/cdn;
auth_basic "restricted";
auth_basic_user_file /etc/nginx/.htpasswd-ai;
try_files $uri $uri/ =404;
}
location /claude/ {
root /root/cdn;
try_files $uri $uri/ =404;
}
Що дає і чого не дає:
| Дає | Не дає |
|---|---|
| відсікає випадкового відвідувача й пошуковики | захисту від того, кому дали пароль |
| нульову вартість підтримки | пер-юзерних прав |
| нуль коду в застосунку | аудиту, хто що читав, поза логами nginx |
Для особистого довідника цього рівно досить. Для чогось, де витік має реальну ціну, — ні; і вдавати, що досить, було б найгіршим із варіантів. Це замок від чесної людини, а не від зловмисника, — і я не продаю його як більше.
Таблиця рішень
| Ситуація | Дія |
|---|---|
| Нова стаття | файл у articles/, прогнати генератор |
| Перекинути статтю з закритих у публічні | змінити locked:, прогнати генератор |
| Новий розділ | запис у SECTIONS + location у nginx |
| Треба змінити вигляд усіх сторінок | правити тему, не HTML |
| Стаття зникла з сайту | перевірити section: — невідома секція валить генератор |
| 403 на новій сторінці | пропущений chown на дереві |
| Треба редирект зі старої адреси | location = з return 301, вище за загальний блок |
Антипатерни
- Правити згенерований HTML. Найдешевший спосіб злити роботу: вона зникне на наступному рендері, ще й мовчки.
- Класти кастомні файли у вихідну теку. Те саме відро:
rmtreeдозволу не питає. - Тримати стан у HTML. Потрібен сторінці фільтр, прогрес чи збережений вибір — це вже застосунок, і генератор статей йому не рідня. Змішувати два режими в одному пайплайні виходить дорого.
- Робити
lockedза замовчуваннямfalse. Дефолт має бути закритим: помилитись у бік приватності дешевше, ніж навпаки. - Хардкодити кольори в темі. Один шар токенів зверху — і перефарбувати весь сайт стає правкою десяти рядків, а не пошуком по всіх hex.
Чекліст
- Джерело правди — markdown, HTML одноразовий
lockedза замовчуваннямtrue- Закриті статті лежать у фізично іншій теці, а не просто без посилань
- Після рендеру дерево належить користувачу веб-сервера
- Невідома секція у фронтметрі валить збірку, а не мовчки з'їдається
- Кольори теми — через токени, не hex по всьому файлу
- Старі адреси після переїзду віддають 301, а не 404
Найдорожче в статичних генераторах — не сам генератор, а те, що його треба тримати в голові. Конфіг, конвенції тек, синтаксис шаблонів, набір плагінів і три способи зробити одну й ту саму річ (обов'язково три, ніколи один).
Двісті рядків власного скрипта не вміють майже нічого — і саме тому їх читаєш цілком за десять хвилин, а потім міняєш будь-що за п'ять. Через півроку виявляється, що це була єдина метрика, яка щось важила.
Правило звідси звучить неприємно для інженерського его: якщо задача влазить у скрипт, який читається за один присід, — фреймворк тут не економія, а борг у розстрочку. Я його теж не люблю. Він теж правий.