Кучеручная

Как собрать блог на .NET 10 силами одной LLM

content

Как собрать блог на .NET 10 силами одной LLM

Это учебный-исследовательский проект. Задачей было выкатить в продакшн готовое приложение, пользуясь только LLM (в основном — GPT-5.2). Для решения задачи использовались приёмы, наработанные в прошлых проектах.

Проект: self−hosted блог‑движок под одного автора, с очень жёсткими рамками: один бинарник, SQLite как единственная БД, public SSR без обязательного JS, admin без SPA, темы CSS−only через HTML Contract v1, комментарии только через Telegram Login Widget, и Telegram Instant View в R1 только через t.me/iv?…&rhash=…

TL; DR

  • Фреймворк и язык — вторичны; первичны инварианты. Жёсткие запреты со старта экономят кучу итераций и «LLM‑галлюцинаций».
  • Две самые дорогие зоны в таком продукте: SQLite‑конкурентность/бэкапы и AOT/trim‑совместимость.
  • Скорость разработки LLM легко превращается в хаос, если нет «цемента»: ADR, тестов на контракты, ограничителей на рост памяти, и запрета на магию.
  • По цифрам видно, что это был именно «инженерный» проект: 74.7% churn в src/, 16.1% — в tests/, и это за 637 коммитов за 8 дней.

Исходные инварианты (то, что определило всю архитектуру)

Если бы нужно было выделить один «секрет», почему проект вообще собрался, то он здесь: правила в AGENTS.md и спецификация в techspec.md / prd.md как механизм управления моделью.

  • Один бинарник, без обязательных внешних сервисов.
  • AOT‑готовность − обязательный элемент «boring ops»
  • SQLite — единственная БД
  • Public UI без JS (JS — только улучшения).
  • Admin UI без SPA.
  • Темы CSS−only, а публичный HTML — контракт (HTML Contract v1), который нельзя ломать без версии.
  • Telegram−only комментарии (Login Widget) и Instant View через t.me/iv + rhash.

Статистика: куда ушло сколько усилий

Общая «теплокарта» churn по репозиторию

Churn − это грубая метрика «сколько кода перелопатили»: сумма добавленных + удалённых строк за выбранный период по истории git.

За всю историю (на момент анализа):

  • Коммитов: 637
  • Окно разработки: неделя с 2 026‑01‑03 по 2 026‑01‑10
  • Общий churn: 118 251 строк (добавления+удаления)

Разбивка по верхним директориям:

  • src/: 88 389 строк (74.7%)
  • tests/: 19 042 строк (16.1%)
  • docs/: 5 680 строк (4.8%)

Интерпретация: быстро строим систему → потом много фиксируем тестами и ADR, чтобы система перестала «плыть» при каждом рефакторинге.

Где именно «горело» в src/Blog. Web

Топ‑зоны по churn («где было сложно/дорого»):

  • Admin UI и endpoints: src/Blog. Web/Endpoints/Admin/* — 17 833
  • Точка сборки/маршрутизация: src/Blog. Web/Program.cs — 10 680
  • SQLite слой: src/Blog. Web/Storage/Repositories/* — 9 705
  • Импорт: src/Blog. Web/Importing/Sources/* — 6 146
  • Рендеринг разметки: src/Blog. Web/Rendering/Markup.cs — 4 533
  • Переезд HTML из C# в Razor Slices: src/Blog. Web/Slices/Admin/* — 3 138
  • Telegram: src/Blog. Web/Integrations/Telegram/* — 770
  • L10n (ресурсы и ключи): src/Blog. Web/L10n/* — ~2 000 суммарно

Что «цементировали» в тестах

Топ‑направления в tests/Blog. Web. Tests:

  • Admin endpoints и страницы: tests/Blog. Web. Tests/Endpoints/Admin/* и крупные интеграционные тесты админки
  • HTML Contract regression: ContractRegressionTests.cs — фиксируем публичную разметку как API
  • Импорт: тесты телеграм‑каналов, пайплайна импорт‑джоб, источников
  • Backup: тесты на параллельные записи, стриминг, регрессии database is locked
  • Security/infra: guardrails forwarded headers, rate limiting, CSRF, Open Redirect на /lang
  • Telegram Login/notifications: отдельные тесты verifier«а и уведомлений

Смысл: тесты здесь чтобы заморозить контракт и поведение, чтобы LLM могла рефакторить без „тихих“ поломок.

Хронология разработки (как реально шёл процесс)

Хронологически проект выглядит как серия „инженерных волн“. Судя по распределению коммитов, основные „боевые дни“ — 5–9 января.

2 026‑01‑03 — рамки и скелет

  • Старт репо.
  • Формализация целей в prd.md и docs/techspec.md.
  • Включение дисциплины ADR (docs/adr/*) как обязательного реестра решений.

2 026‑01‑05 — базовая архитектура и „SQLite как нервная система“

  • Большой пласт по разбиению Program.cs на модули, первичная декомпозиция.
  • Переход к scoped SQLite connection per request + WAL (это сразу снижает класс ошибок и делает поведение предсказуемым под нагрузкой).

2 026‑01‑06 — безопасность и "production realism"

  • Включение системных мер: CSRF/antiforgery, forwarded headers, ограничения на выдачу SVG (CSP sandbox), hardening на пути/ассеты, подготовка к AOT через JSON source generation.
  • Переход с самописного Markdown на Markdig

2 026‑01‑07 — стабилизация: очереди, кэши, разделение ответственности

  • Уведомления Telegram становятся best‑effort (уходят в фоновую очередь), чтобы не ломать создание комментария.
  • SSR начинает жить по правилам HTTP‑кэширования (ETag/Last‑Modified, инвалидация кэша по изменению медиа и runtime‑настроек).
  • SQLite lease становится async‑only

2 026‑01‑08 — „полировка продукта“

  • Локализация через. resx.
  • Runtime UI settings в SQLite.
  • Custom pages как отдельный kind (страницы навигации без комментариев).

2 026‑01‑09 — крупнейший рефактор UI: Razor Slices в админке и отказ от склеивания строк

Судя по CHANGELOG.md и docs/changelog/*, это была целая серия миграций:

  • Админские страницы и ошибки перестают собираться строками в C# и переезжают в. cshtml (Razor Slices).
  • Убираются остатки $$"""...""" и в public SSR, без нарушения HTML Contract v1.

Этим пришлось заниматься потому что LLM поначалу решил, что выдавать на выход склеенные строки — отличная идея.

2 026‑01‑10 — финальный рывок: NativeAOT/trim‑готовность

  • Минимальные API‑эндпоинты переведены на более AOT‑дружелюбные handler и [FromServices].
  • JSON сериализация централизована через source‑generated контекст.
  • SSR fragment cache уходит с HybridCache на IMemoryCache, потому что trimming‑warnings «снаружи» ломают AOT‑сборку.
  • Появляются./build−linux и./build−macos для удобства AOT‑сборок под разные RID.

Что вылезло и пришлось исправлять

Если смотреть на docs/adr/README.md, там есть важный маркер «мы пробовали и откатывали»: 3 Superseded ADR из 48.

1) Бэкапы SQLite: «VACUUM INTO» не тот инструмент

  • Изначально: «сделаем консистентный snapshot через VACUUM INTO».
  • Реальность: жёсткие блокировки (особенно под нагрузкой), плюс есть тонкие security footguns с правами и временными файлами.
  • Итог: переход на SQLite Online Backup API

2) Конкурентность scoped‑соединения: сначала «починим re-entrancy», потом «уберём sync»

Сначала появляются решения вида:

  • «сделаем Enter() re−entrant»
  • «запретим смешивать Enter() и EnterAsync()»

это лечение симптомов, нормальное решение:

  • делаем lease API async‑only
  • и сериализуем всё через один механизм, чтобы не плодить режимы.

3) AOT/trim: зависимость может убить сборку

Даже если код «почти AOT‑safe», может прилететь откуда угодно:

  • предупреждения ILLinker«а,
  • reflection‑магия в библиотеке,
  • генератор, который»не туда«сгенерировал.

Поэтому часть функциональности (например, кэш SSR‑фрагментов) пришлось реализовать через более»скучные«и надёжные компоненты.

Самые проблемные зоны

1) SQLite под нагрузкой: concurrency, транзакции, бэкапы

Получилась самая дорогая часть движка, пришлось искать и править, чтобы

  • не упасть на дедлоках,
  • не получить «database is locked» при бэкапе,
  • не съесть thread pool из−за sync I/O,
  • и не»сломать атомарность«на nested transactions.

2) SSR + HTTP caching: ETag/Last‑Modified и „липкие 304“

Любая мелочь, меняющая SSR HTML, должна быть учтена в ETag/LM — иначе можно получить „застывшие страницы“.
В репо видно, что этому уделено много внимания: инвалидация по media.updated_utc, runtime settings с устойчивым ETag‑part, централизованные хелперы.

3) Security „по краям“: не одна большая дыра, а 50 маленьких

Самые показательные классы фиксов:

  • CSRF на state−changing POST,
  • open redirect,
  • forwarded headers spoofing guardrails,
  • SVG as HTML/XSS и CSP sandbox,
  • path traversal в теме/uploads,
  • hard caps на in−memory трекеры (защита от OOM через proxy‑rotation).

Сравнение с конкурентами

Ghost (динамическая платформа публикаций)

Официальная документация описывает Ghost как „современное decoupled веб‑приложение“ с:

  • core JSON API
  • admin client app
  • front‑end theme layer

а также упоминает, что по умолчанию используется ORM слой и что SQLite — default в dev, а MySQL рекомендуется для production (см. Ghost Architecture).

Сравнение с нашим проектом:

  • у Ghost больше продуктовых возможностей (memberships/newsletters и т. п.), но и больше сложность/зависимости.
  • у нас наоборот: минимум surface area, SQLite‑only, и максимально»boring ops«(один бинарник + папка data).

Hugo (статический генератор)

На главной Hugo прямо написано: open-source static site generator, и что он написан на Go

Сравнение:

  • Hugo выигрывает в»нулевой эксплуатации«: нет БД, нет рантайм‑слоя, почти не за что ломаться.
  • наш движок выигрывает в»живом продукте«: редактор/админка, комментарии, импорт, runtime‑настройки; но за это платим сложностью.

Jekyll (статические сайты/блоги)

Jekyll прямо продаёт идею: „No more databases…“ и «static sites come out ready for deployment»

Сравнение:

  • Jekyll — „контент в git/файлах“, зато редактор/комментарии обычно внешние.
  • наш движок — „контент в SQLite + uploads“, зато единый UX и self−contained runtime.

Orchard Core (большой. NET‑комбайн)

Orchard Core на официальных доках формулируется очень ясно: open-source modular, multi-tenant application framework and CMS for ASP. NET Core (см. docs.orchardcore.net).
Сравнение:

  • Orchard — это»платформа для всего«(модульность, multi−tenant), то есть другая лига сложности.
  • наш движок — „моно‑продукт для одного автора“, где контракты и минимализм важнее расширяемости.

Miniblog.Core (лёгкий ASP. NET Core блог)

В README проекта указано «An ASP. NET Core blogging engine» и перечислены фичи (RSS/ATOM, comments, SEO, service worker, etc.) (см. Miniblog. Core).

Сравнение:

  • ближе всего по классу «не монстр−CMS, а блог‑движок».
  • Miniblog.Core прекрасный блог который пытается сделать „как у взрослых“ минимальными силами.
  • Наш проект − выкидывает фичи в угоду простоте. Например, поддерживать полноценный емэйл в 2 026 году это просто головная боль − значит email просто не будет.

Как выглядел процесс разработки

  • Много маленьких коммитов (637 за 8 дней)
  • ADR как «память проекта»: 44 принятых решения + фиксация откатов (Superseded).
  • Тесты как «стоп−кран»: особенно контрактные тесты публичного HTML и регрессии по безопасности/кэшированию/бэкапам.
  • Нулевая терпимость к warnings и фокус на AOT‑совместимость

Итог: что получилось и где границы

В рамках R1 получился движок, который концептуально можно описать так:

  • «Ультимативная простота эксплуатации»: один бинарник + data/ (SQLite + uploads + темы + бэкапы).
  • Public — SSR и живёт без JS.
  • Admin — SSR, но современный UX через улучшения (без SPA).
  • Темы — CSS−only, а HTML — контракт.
  • Комментарии/социальность — через Telegram, без email.
  • Архитектура — с прицелом на NativeAOT/trim.

Комментарии закрыты.