заведён канон общих конвенций для личных проектов

- 13 конвенций по осям arch / lang / stack / common; репозитории берут
  оттуда копии в свой docs/conventions/ и коммитят их у себя
- conv — синхронизация копий: add / status / diff / pull / push, локальные
  регионы исключены из сравнения, поэтому расхождение не даёт шума
This commit is contained in:
av
2026-07-25 18:18:18 +03:00
commit 4a59c71737
15 changed files with 2142 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
---
status: рекомендуемая
extends: arch/app-directories.md
---
# Категории директорий: реализация в Ansible
Как `arch/app-directories.md` раскладывается на сервере плейбуком.
## Переменные и создание
- Директория объявляется переменной плейбука внутри `base_dir`, имя
переменной оканчивается на `_dir`. Для случая «одна директория на
категорию» это `config_dir`, `data_dir`, `cache_dir`; когда категория
состоит из нескольких, имя даётся по содержимому (`media_dir`,
`uploads_dir`, `dumps_dir`), а категория читается из списка бэкапа.
- Директории создаются **одной задачей циклом по списку**: список и есть
декларация того, что приложение пишет на диск. Разнесение по нескольким
задачам прячет эту декларацию.
- Владелец — пользователь, от имени которого работает приложение. Модель
выбирается на репозиторий: выделенный пользователь на приложение
(`app_owner_uid == app_owner_gid`) или общий `primary_user`. Какая модель
принята — фиксируется ниже.
<!-- local:модель-владельца -->
<!-- /local -->
## Список бэкапа
Плейбук кладёт в `base_dir` файл `backup-targets` — его читает оркестратор
бэкапов. Строки списка собираются из **тех же** переменных `*_dir`, что и
задача создания директорий: тогда переименование или перенос директории не
может разойтись с бэкапом.
В список идут директории категории «данные», включая директорию дампов, и
не идут конфигурация и кеш.
## Монтирование в контейнер
- Конфигурация — `:ro`, где приложение это позволяет. Приложение, которое
переписывает свой конфиг, монтируется на запись — это отступление, и оно
записывается.
- Данные и кеш — на запись.
- `docker-compose.yml` остаётся в корне `base_dir`: туда смотрит
`project_src` модуля `docker_compose_v2`.
## Секреты
Секреты приходят из vault-переменных и рендерятся шаблоном. Два способа, в
порядке предпочтения:
1. **В файл конфигурации** (роль `secrets`) — предпочтительный: секрет
лежит под `0600` у пользователя приложения, не наследуется дочерними
процессами и не виден в `docker inspect`.
2. **В `environment:` compose-файла** — когда приложение не умеет читать
секреты из файла. Задача рендера идёт с `no_log: true`.
Второй способ — вынужденный: он кладёт секрет в метаданные контейнера и в
файл compose на диске. Приложение, умеющее файловые секреты, переводится на
первый способ при ближайшем касании.
<!-- local:отступления -->
<!-- /local -->
## Связано
<!-- local:связано -->
<!-- /local -->
+211
View File
@@ -0,0 +1,211 @@
---
status: рекомендуемая
---
# Веб-UI на htmx
Как пишется код веб-UI: частичный своп фрагментов, поллинг живых
обновлений, обработчики действий, деградация без JS, ошибки. Что именно UI
показывает и какие действия обязан поддерживать — в спеках, не здесь.
Логирование запросов — `lang/go/logging.md` (HTTP-поля, рутинно-частое на
`DEBUG`). Трансляция доменных ошибок наружу — `lang/go/errors.md`
(приватный канал = логи, публичный = сообщение плюс корреляционный ключ).
Здесь — только специфика htmx-транспорта, без дублирования.
Утверждения о поведении htmx относятся к **2.x**: дефолты обработки
ответов между мажорами менялись.
## Стек и границы
htmx-first: роутер + серверные шаблоны + htmx. **Без шага сборки, без Node
и бандлера, без реактивных фреймворков.** htmx вендорится и самохостится,
без CDN.
- Свой JS сведён к минимуму: только то, что серверу знать не нужно
(например, копирование в буфер обмена). **Клиентского пересчёта доменного
состояния нет** — состояние считает сервер, клиент свопит присланную
разметку.
- Реактивный слой (Alpine.js и подобное) не вводим до появления виджета,
которому он действительно нужен, и вводим отдельным решением, а не
попутно.
## Единый источник разметки: партиал = страница = фрагмент
Переиспользуемый кусок — это именованный шаблон в `partials/`. Тот же
шаблон рендерится **и** инлайн на странице, **и** как ответ-фрагмент того
же обработчика. Отдельной разметки для фрагмента не заводим — иначе она
дрейфует от страницы.
**Инвариант: корень шаблона — элемент с целевым `id`.**
`hx-swap="outerHTML"` заменяет весь корневой узел; если ответный фрагмент не
несёт тот же корневой `id`, следующее действие или поллер не найдёт таргет.
Разметку и `id` держим в одном партиале.
Сборку view выносим в переиспользуемую функцию и зовём её и на полной
странице, и во фрагменте — чтобы htmx-ветка не копипастила сборку.
## Обработчик действия: ветвление htmx / редирект
htmx-запрос определяем по заголовку `HX-Request: true`. Обработчик зовёт
доменную операцию **одинаково** в обеих ветках и ветвится только после:
```go
actionErr := fn(r.Context(), id) // доменный вызов идентичен для htmx и не-htmx
if !isHTMX(r) {
redirect(w, r, id, msg) // без htmx — обычный PRG-редирект (303)
return
}
data, _ := s.deps.Read(r.Context(), id) // перечитать актуальное состояние
view := buildView(id, data, "") // тем же view-builder'ом
if actionErr != nil {
view.BlockError = userErr(r, actionErr, id)
}
s.render(w, "source_block", view) // фрагмент = тот же шаблон
```
`render` собирает именованный шаблон **в буфер** и только затем пишет
ответ — при ошибке шаблона клиент не получит «полустраницу».
## Одно действие — два региона: `hx-swap-oob`
Когда действие меняет не только свой регион (сменился выбор — обновилась и
панель действий), второй регион едет **тем же ответом** через
`hx-swap-oob="true"`. Оба фрагмента — обычные именованные партиалы с теми
же `id`, что и на странице; отдельной разметки под oob не заводим по тому
же правилу, что и для основного свопа.
Альтернатива — второй запрос с клиента — вводит гонку между двумя ответами
и лишний раунд-трип; `HX-Trigger` с последующим `hx-get` уместен только
если второй регион обновляется реже, чем происходит действие.
## Graceful degradation
Формы действий остаются обычными `<form method="post" action="…">`;
`hx-post`/`hx-target`/`hx-swap` лишь **накладываются сверху** на ту же
форму. Без JS действие работает через POST и редирект. `action` формы —
рабочий фолбэк, а не декорация.
Фильтр, поиск и пагинация списка — **серверные**, через GET-параметры, тоже
без JS. Клиентской фильтрации нет намеренно.
Требование распространяется на **действия и навигацию**. Интерактивный
виджет выбора, у которого нет осмысленного не-JS поведения, может требовать
JS — но это отступление, и оно записывается, а не подразумевается.
## Ошибки на htmx-пути: HTTP 200 плюс фрагмент
В htmx 2.x ответы 4xx/5xx по умолчанию **не свопят DOM**. Это настраивается
(`htmx.config.responseHandling`, расширение `response-targets`, слушатель
`htmx:responseError`), но любая настройка — это свой JS-конфиг на клиенте,
что противоречит разделу «Стек и границы». Поэтому сознательно берём
**200 с фрагментом**, несущим сообщение, и доменную ошибку на htmx-пути
**не** транслируем в HTTP-статус — в отличие от REST API и no-JS редиректа
с `?err=`.
- Сообщение — нейтральный текст публичного канала; сырой `err.Error()`
наружу не идёт.
- Ошибку кладём в **отдельное поле** под ошибку действия, не перегружая
доменные поля: у них может быть своё непустое значение, которое сообщение
перекроет.
- **При ошибке активное состояние не меняем** — перечитанный view
показывает прежний выбор плюс сообщение.
- Цена: в логе доступа провалившееся действие выглядит как `200`. Искать
его надо по доменной записи об исходе операции (`lang/go/logging.md`), а
не по коду ответа.
## Живой поллинг
Фрагмент-эндпоинт под `/fragments/…` плюс в разметке `hx-get`,
`hx-trigger="every Ns"`, `hx-swap="outerHTML"`:
```html
{{define "progress"}}<div id="item-live-{{.ID}}"
{{if .Active}} hx-get="/fragments/items/{{.ID}}/progress"
hx-trigger="every 3s" hx-swap="outerHTML"{{end}}>
...
</div>{{end}}
```
- **Поллер самозавершается.** Когда состояние выходит из «живого»,
фрагмент возвращается **без `hx-*`** — htmx больше не опрашивает. Условие
живости ведёт собственное состояние приложения, а не внешний сервис.
(Встроенная альтернатива — ответ со статусом 286 — не используется: она
не совместима с инвариантом «партиал = страница», свежезагруженная
страница тоже должна рендериться без поллера.)
- **`outerHTML`-своп всего фрагмента** удаляет старый узел вместе с его
поллером и инициализирует новый — двойного опроса нет **при условии
совпадения корневого `id`**.
- **Поллер не свопит контейнер с активными полями ввода.** Своп поддерева
теряет фокус, выделение и незасабмиченный текст внутри него: живость
включается только в состояниях, где редактировать нечего.
- **Инвариант: браузер не опрашивает внешний сервис напрямую** — только
свой сервер.
- Если тик **проксирует состояние внешнего сервиса**, данные берутся из
in-memory снимка, обновляемого воркером, без сети на каждый тик; контракт
снимка узкий и не зависит от способа доставки (путь к SSE остаётся
изолированным). Тик, показывающий **собственное** состояние приложения,
читает своё хранилище — это нормально и снимка не требует.
### Поллинг полной страницы
Когда живой фрагмент — это почти вся страница, отдельный
`/fragments/…`-роут дублировал бы обработчик. Тогда допустимо опрашивать
сам URL страницы и вырезать нужный узел на клиенте:
```html
hx-get="/item/{{.ID}}" hx-trigger="every 3s"
hx-select="#item-main" hx-swap="outerHTML"
```
Инвариант корневого `id` действует и здесь: `hx-select` должен выбирать тот
же узел, который свопится.
## Своп сохраняет контекст; выход — навигация
`hx-swap="outerHTML"` не сбрасывает прокрутку и не трогает серверные
фильтр, поиск и пагинацию (они в query). Внутри свопаемого поддерева
контекст **не** сохраняется — фокус, выделение и введённый текст теряются
(см. правило про поллер выше).
Действие **не должно уводить** пользователя со страницы, если предмет
остаётся на ней — своп на месте. Действие, после которого предмет
**покидает** страницу, остаётся обычной POST-формой **без `hx-*`** → полная
навигация. Маркер «это выход» — форма без htmx-атрибутов; так не нужен
`HX-Redirect`, а «уйти с экрана» выражено самой навигацией.
**Асинхронные действия.** Если доменное действие асинхронно (переводит в
промежуточное состояние, работу доделывает воркер), своп отдаёт
**промежуточное** состояние, а не мнимый результат; итог догоняет
самозавершающийся поллер. Не обещаем в UI мгновенный итог async-операции.
## Различение поверхности одного действия
Если один роут зовут с разных страниц и своп-ответ должен быть разным
фрагментом, различаем **явным скрытым полем формы** (`surface=list|detail`),
а не эвристикой по `HX-Target` или `Referer`: поле самодокументируемо и не
зависит от резолва таргета.
## Статика, вендоринг, кэш
Раздел не про htmx — это упаковка любого server-rendered приложения;
разъедется в языковой слой, когда понадобится там.
- Ассеты встроены в бинарь (`go:embed`) и отдаются с длинным иммутабельным
кэшем (`Cache-Control: public, max-age=31536000, immutable`).
- Меняемые ассеты (css/js) версионируются через `?v=<hash>` — короткий
sha256 содержимого; URL строит хелпер шаблона. Свежий деплой не отдаёт
устаревший файл.
- Вендор адресуется по **неизменному имени файла** и в `?v=` не нуждается.
В git его не коммитим: идемпотентная задача добывает его по манифесту
(`путь url sha256`) с проверкой контрольной суммы, и сборка от неё
зависит.
- Шрифты и скрипты — **self-hosted**, без внешних хостов: бинарь
самодостаточен, внешних ресурсов времени выполнения нет.
<!-- local:эталоны -->
<!-- /local -->
<!-- local:отступления -->
<!-- /local -->