заведён реестр префиксов, правила канона перенумерованы

- идентификатор правила теперь `<ПРЕФИКС>-<номер>` вместо `R<номер>`:
  префикс уникален по всему канону, поэтому ссылка больше не требует пути
  к файлу и не зависит от того, на какой оси файл лежит
- префикс выбирается под файл, а не выводится по формуле, и хранится в
  conventions/prefixes.toml вместе с выбывшими; номера сохранены один в
  один вместе с дырами
This commit is contained in:
av
2026-07-25 20:55:37 +03:00
parent 972627f641
commit dcdd92230b
13 changed files with 552 additions and 470 deletions
+15 -14
View File
@@ -1,4 +1,5 @@
---
prefix: ANSD
extends: arch/app-directories.md
---
@@ -15,7 +16,7 @@ extends: arch/app-directories.md
## Правила
### R1. Каждая директория объявлена переменной `*_dir`
### ANSD-1. Каждая директория объявлена переменной `*_dir`
**ДОЛЖЕН.** Директория приложения объявляется переменной плейбука внутри
`base_dir`, имя оканчивается на `_dir`. Для случая «одна директория на
@@ -24,11 +25,11 @@ extends: arch/app-directories.md
`uploads_dir`, `dumps_dir`).
**Почему.** Переменная — единственная ссылка, которую разделяют задача
создания директории и список бэкапа (R4). Литерал пути в одном из этих мест
создания директории и список бэкапа (ANSD-4). Литерал пути в одном из этих мест
означает, что переименование директории молча разойдётся с бэкапом, и
обнаружится это при восстановлении.
### R2. Директории создаются одной задачей циклом по списку
### ANSD-2. Директории создаются одной задачей циклом по списку
**СЛЕДУЕТ.** Список директорий в единственной задаче создания.
@@ -38,7 +39,7 @@ extends: arch/app-directories.md
всего плейбука, а именно этот вопрос задают при заведении бэкапа и при
разборе места на диске.
### R3. Владелец директорий — пользователь, от имени которого работает приложение
### ANSD-3. Владелец директорий — пользователь, от имени которого работает приложение
**ДОЛЖЕН.** Конкретная модель — выделенный пользователь на приложение
(`app_owner_uid == app_owner_gid`) или общий `primary_user` — выбирается на
@@ -55,18 +56,18 @@ extends: arch/app-directories.md
<!-- local:модель-владельца -->
<!-- /local -->
### R4. Список бэкапа собирается из тех же переменных
### ANSD-4. Список бэкапа собирается из тех же переменных
**ДОЛЖЕН.** Плейбук кладёт в `base_dir` файл `backup-targets`, строки
которого ссылаются на переменные `*_dir` из R1, а не на литеральные пути.
которого ссылаются на переменные `*_dir` из ANSD-1, а не на литеральные пути.
**Почему.** Правило вывода списка механическое (R5), но применяет его
**Почему.** Правило вывода списка механическое (ANSD-5), но применяет его
человек или шаблон — то есть ошибиться можно. Общая переменная делает целый
класс ошибок невозможным: переименовал директорию — переименовалось в
обоих местах. Независимо набранный список расходится тихо и проявляется в
единственный момент, когда это уже неисправимо.
### R5. В список бэкапа идут только данные
### ANSD-5. В список бэкапа идут только данные
**ДОЛЖЕН.** Директории категории «данные», включая директорию дампов, — в
списке; конфигурация и кеш — нет.
@@ -75,7 +76,7 @@ extends: arch/app-directories.md
пользы, а конфигурация содержит отрендеренные секреты — бэкап уезжает в
облако, и источником истины для секретов остаётся vault, а не снапшот.
### R6. Конфигурация монтируется только на чтение
### ANSD-6. Конфигурация монтируется только на чтение
**СЛЕДУЕТ.** В compose конфигурация подключается с `:ro`.
@@ -85,7 +86,7 @@ extends: arch/app-directories.md
незаметно. Приложение, которому запись в конфиг нужна по устройству,
монтируется на запись — это отступление, и оно записывается.
### R7. `docker-compose.yml` лежит в корне `base_dir`
### ANSD-7. `docker-compose.yml` лежит в корне `base_dir`
**ДОЛЖЕН.** Файл не переносится во вложенную директорию.
@@ -94,7 +95,7 @@ extends: arch/app-directories.md
порядок» и убрать compose в `config/`, где ему по смыслу категорий было бы
место.
### R8. Секреты рендерятся в файл конфигурации
### ANSD-8. Секреты рендерятся в файл конфигурации
**СЛЕДУЕТ.** Значения приходят из vault-переменных и попадают в файл,
принадлежащий пользователю приложения.
@@ -104,14 +105,14 @@ extends: arch/app-directories.md
довода, по которым базовая конвенция конфигурации выбирает файл вместо
окружения.
### R9. Когда приложение не умеет файловые секреты — `environment` под `no_log`
### ANSD-9. Когда приложение не умеет файловые секреты — `environment` под `no_log`
**ДОПУСКАЕТСЯ.** Задача рендера идёт с `no_log: true`.
**Почему.** Явное разрешение нужно, чтобы R8 не читался как запрет на
**Почему.** Явное разрешение нужно, чтобы ANSD-8 не читался как запрет на
деплой такого приложения. Способ вынужденный: секрет попадает в метаданные
контейнера и в compose-файл на диске. Приложение, научившееся читать
секреты из файла, переводится на R8 при ближайшем касании.
секреты из файла, переводится на ANSD-8 при ближайшем касании.
<!-- local:отступления -->
<!-- /local -->
+79 -75
View File
@@ -1,3 +1,7 @@
---
prefix: HTMX
---
# Веб-UI на htmx
Как пишется код веб-UI: частичный своп фрагментов, поллинг живых
@@ -18,7 +22,7 @@
## Стек и границы
### R1. Стек: роутер, серверные шаблоны, htmx
### HTMX-1. Стек: роутер, серверные шаблоны, htmx
**ДОЛЖЕН.** UI собирается из серверных шаблонов и htmx — без шага сборки,
без Node и бандлера, без реактивного фреймворка.
@@ -26,12 +30,12 @@
**Почему.** Шаг сборки — это второй язык, второй менеджер зависимостей и
артефакт, который расходится с исходником; приложению, где разметку целиком
отдаёт сервер, он не покупает ничего. Реактивный фреймворк добавляет вторую
модель состояния рядом с серверной (R2), и дальше на каждом экране
модель состояния рядом с серверной (HTMX-2), и дальше на каждом экране
приходится решать, какая из них главная. Сам htmx — вендорный ассет и
живёт по правилам вендоринга (R32, R33): внешний CDN добавил бы к аптайму
приложения аптайм чужого хоста.
живёт по правилам вендоринга (HTMX-32, HTMX-33): внешний CDN добавил бы к
аптайму приложения аптайм чужого хоста.
### R2. Клиент не пересчитывает доменное состояние
### HTMX-2. Клиент не пересчитывает доменное состояние
**НЕ ДОЛЖЕН.** Свой JS делает только то, чего серверу знать не нужно
(копирование в буфер обмена и подобное); доменное состояние считает сервер,
@@ -40,24 +44,24 @@
**Почему.** Пересчёт на клиенте — вторая реализация той же логики, которую
никто не сверяет: расходится она тихо, а проявляется как «на экране одно, в
базе другое». Вдобавок клиентский пересчёт по определению не работает в
деградированном режиме (R11, R12) — значит, серверную версию того же
деградированном режиме (HTMX-11, HTMX-12) — значит, серверную версию того же
вычисления всё равно придётся держать.
### R3. Реактивный слой вводится отдельным решением
### HTMX-3. Реактивный слой вводится отдельным решением
**НЕ ДОЛЖЕН.** Alpine.js и подобное не появляется попутно с задачей —
только когда есть виджет, которому он действительно нужен, и отдельным
решением.
**Почему.** Реактивный слой, попавший в проект ради одного выпадающего
списка, немедленно доступен всему остальному коду — и граница R1/R2
списка, немедленно доступен всему остальному коду — и граница HTMX-1/HTMX-2
перестаёт держаться сама собой. Отдельное решение — единственный момент,
когда цену видно целиком: она не в килобайтах, а в том, что дальше на
каждом экране есть выбор между двумя моделями состояния.
## Единый источник разметки
### R4. Партиал = страница = фрагмент
### HTMX-4. Партиал = страница = фрагмент
**ДОЛЖЕН.** Переиспользуемый кусок разметки — именованный шаблон в
`partials/`, и он же рендерится инлайн на странице и как ответ-фрагмент
@@ -68,7 +72,7 @@
же региона. Заметно это становится только на глаз и только тому, кто открыл
оба пути подряд.
### R5. Корень партиала — элемент с целевым `id`
### HTMX-5. Корень партиала — элемент с целевым `id`
**ДОЛЖЕН.** Корневой узел шаблона несёт тот `id`, по которому адресуют
регион, и ответный фрагмент несёт тот же `id`.
@@ -79,19 +83,19 @@
находят таргет: регион застывает без единой ошибки — ни в консоли, ни в
логе.
### R6. Сборку view делает общая функция
### HTMX-6. Сборку view делает общая функция
**СЛЕДУЕТ.** Один view-builder зовут и обработчик полной страницы, и
htmx-ветка.
**Почему.** Общий шаблон (R4) гарантирует одинаковую разметку, но не
**Почему.** Общий шаблон (HTMX-4) гарантирует одинаковую разметку, но не
одинаковые данные: скопированная сборка view расходится по набору полей, и
фрагмент начинает показывать не то, что показала бы страница. Это ровно тот
класс расхождений, который R4 закрывает для разметки.
класс расхождений, который HTMX-4 закрывает для разметки.
## Обработчик действия
### R7. Доменный вызов одинаков для htmx и обычного запроса
### HTMX-7. Доменный вызов одинаков для htmx и обычного запроса
**ДОЛЖЕН.** Обработчик определяет htmx-запрос по заголовку
`HX-Request: true`, зовёт доменную операцию до ветвления и ветвится только
@@ -99,8 +103,8 @@ htmx-ветка.
| № | Запрос | Ответ |
|---|---|---|
| R7.1 | `HX-Request: true` | фрагмент тем же партиалом (R4) по перечитанному состоянию |
| R7.2 | обычный | PRG-редирект (303) |
| HTMX-7.1 | `HX-Request: true` | фрагмент тем же партиалом (HTMX-4) по перечитанному состоянию |
| HTMX-7.2 | обычный | PRG-редирект (303) |
```go
actionErr := fn(r.Context(), id) // доменный вызов идентичен для htmx и не-htmx
@@ -119,12 +123,12 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**Почему.** Ветвление до вызова даёт две реализации одного действия, и
дальше дефект воспроизводится только на одной поверхности — причём
деградированный путь (R11) открывают реже, то есть чинить будут не тот.
деградированный путь (HTMX-11) открывают реже, то есть чинить будут не тот.
Перечитанное состояние в htmx-ветке нужно потому, что своп заменяет регион
целиком: view, собранный из аргументов запроса, покажет намерение, а не
результат.
### R8. Шаблон рендерится в буфер, потом в ответ
### HTMX-8. Шаблон рендерится в буфер, потом в ответ
**ДОЛЖЕН.** Именованный шаблон собирается целиком в буфер, и только затем
буфер пишется в ответ.
@@ -136,30 +140,30 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
## Одно действие — два региона
### R9. Второй регион едет тем же ответом через `hx-swap-oob`
### HTMX-9. Второй регион едет тем же ответом через `hx-swap-oob`
**СЛЕДУЕТ.** Когда действие меняет не только свой регион, второй фрагмент
отдаётся в том же ответе с `hx-swap-oob="true"` — обычным именованным
партиалом с тем же `id`, что и на странице (R4, R5).
партиалом с тем же `id`, что и на странице (HTMX-4, HTMX-5).
**Почему.** Второй запрос с клиента вводит гонку: два ответа считают
состояние в разные моменты и приезжают в произвольном порядке, поэтому
панель действий может отразить состояние до действия. Плюс лишний
раунд-трип на каждое действие.
### R10. Отдельный запрос за вторым регионом — когда он обновляется реже действия
### HTMX-10. Отдельный запрос за вторым регионом — когда он обновляется реже действия
**ДОПУСКАЕТСЯ.** `HX-Trigger` в ответе плюс отдельный `hx-get`, если второй
регион меняется не на каждое действие.
**Почему.** Явное разрешение нужно, чтобы R9 не читался как запрет любого
**Почему.** Явное разрешение нужно, чтобы HTMX-9 не читался как запрет любого
второго запроса. Когда регион обновляется редко, oob-ветка гоняет
одинаковую разметку на каждое действие и связывает два шаблона там, где
связи нет; гонка же тем менее наблюдаема, чем реже обновление.
## Graceful degradation
### R11. Форма действия работает без JS
### HTMX-11. Форма действия работает без JS
**ДОЛЖЕН.** Действие — обычная `<form method="post" action="…">`, на которую
`hx-post`/`hx-target`/`hx-swap` накладываются сверху; `action` ведёт на
@@ -170,24 +174,24 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
ничего, молча. Тот же `action` — единственное, что делает действие
проверяемым без браузера с JS.
### R12. Фильтр, поиск и пагинация — серверные
### HTMX-12. Фильтр, поиск и пагинация — серверные
**ДОЛЖЕН.** Отбор списка задаётся GET-параметрами и выполняется на сервере;
клиентской фильтрации загруженной разметки нет.
**Почему.** Клиент видит только текущую страницу списка, поэтому клиентский
фильтр отвечает по неполным данным и делает это молча — результат выглядит
валидным. Вдобавок состояние отбора в query переживает своп (R25) и
валидным. Вдобавок состояние отбора в query переживает своп (HTMX-25) и
перезагрузку, его можно послать ссылкой и увидеть в логе.
### R13. Область обязательной деградации
### HTMX-13. Область обязательной деградации
**ДОЛЖЕН.** Требование работать без JS распространяется не на весь UI:
| № | Поверхность | Поведение без JS |
|---|---|---|
| R13.1 | действия и навигация | работают полностью (R11, R12) |
| R13.2 | интерактивный виджет выбора, у которого нет осмысленного не-JS поведения | может не работать; записывается в отступления |
| HTMX-13.1 | действия и навигация | работают полностью (HTMX-11, HTMX-12) |
| HTMX-13.2 | интерактивный виджет выбора, у которого нет осмысленного не-JS поведения | может не работать; записывается в отступления |
**Почему.** Без явной границы правило деградации читается как запрет на
любой JS-виджет — и тогда его либо тихо нарушают, либо отказываются от
@@ -197,7 +201,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
## Ошибки на htmx-пути
### R14. Ошибка действия на htmx-пути — 200 с фрагментом
### HTMX-14. Ошибка действия на htmx-пути — 200 с фрагментом
**ДОЛЖЕН.** Провалившееся действие отдаёт статус 200 и фрагмент с
сообщением; доменная ошибка на htmx-пути не транслируется в HTTP-статус.
@@ -205,44 +209,44 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**Почему.** В htmx 2.x ответы 4xx/5xx по умолчанию не свопят DOM — то есть
пользователь не увидит ничего. Своп ошибочных ответов настраивается
(`htmx.config.responseHandling`, расширение `response-targets`), но любая
такая настройка — свой JS-конфиг на клиенте, и платится она из R1 и R2.
такая настройка — свой JS-конфиг на клиенте, и платится она из HTMX-1 и HTMX-2.
Сообщить о сбое, для которого фрагмента нет вовсе, — отдельная задача, и
её решает глобальный слушатель (R34). Для REST API и не-JS редиректа с
её решает глобальный слушатель (HTMX-34). Для REST API и не-JS редиректа с
`?err=` статус по-прежнему используется: там его кто-то читает.
Цена решения: в логе доступа провалившееся действие выглядит как `200`.
Искать его надо по доменной записи об исходе операции (`lang/go/logging.md`),
а не по коду ответа.
### R34. Сбой без ответа-фрагмента показывается глобальным слушателем
### HTMX-34. Сбой без ответа-фрагмента показывается глобальным слушателем
**ДОЛЖЕН.** Один глобальный слушатель `htmx:responseError` и
`htmx:sendError` показывает нейтральное сообщение о неудаче запроса; своп
ошибочных ответов в целевые регионы (`htmx.config.responseHandling`,
`response-targets`) не настраивается.
**Почему.** R14 закрывает доменный отказ, до которого обработчик дошёл.
**Почему.** HTMX-14 закрывает доменный отказ, до которого обработчик дошёл.
Паника, сбой шаблона и обрыв сети отдают 5xx или ничего, htmx 2.x такое не
свопит — регион не меняется, интерфейс замирает без единого признака сбоя,
и пользователь повторяет действие, которое могло уже примениться. Слушатель
— несколько строк без доменного состояния, то есть внутри границы R2, и он
не спорит с R14: там настройки отвергнуты как замена фрагменту, который
— несколько строк без доменного состояния, то есть внутри границы HTMX-2, и он
не спорит с HTMX-14: там настройки отвергнуты как замена фрагменту, который
обработчик в состоянии отдать, а здесь фрагмента нет по определению. Своп
тела ошибки в целевой регион стоил бы дороже: страница 500 не несёт
целевого `id`, и после первого же такого свопа регион перестаёт находиться
(R5).
(HTMX-5).
### R15. Наружу идёт сообщение публичного канала
### HTMX-15. Наружу идёт сообщение публичного канала
**ДОЛЖЕН.** Во фрагмент попадает нейтральный текст по правилам
`lang/go/errors.md`; `err.Error()` в разметку не рендерится.
**Почему.** htmx-фрагмент выглядит внутренней деталью приложения, и на нём
легче всего забыть, что это тот же публичный канал, что и страница:
разметка уезжает в браузер пользователя целиком. Статус 200 (R14)
разметка уезжает в браузер пользователя целиком. Статус 200 (HTMX-14)
дополнительно снимает ощущение «это ошибочный ответ, его никто не увидит».
### R16. Сообщение об ошибке — в отдельном поле view
### HTMX-16. Сообщение об ошибке — в отдельном поле view
**ДОЛЖЕН.** У view есть поле под ошибку действия; доменные поля под
сообщение не переиспользуются.
@@ -251,9 +255,9 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
его перекроет: пользователь получит текст ошибки вместо данных, а шаблон —
необходимость угадывать, что сейчас лежит в поле. Отдельное поле делает оба
состояния — данные и ошибку — выразимыми одновременно, а это ровно то, чего
требует R17.
требует HTMX-17.
### R17. При ошибке активное состояние не меняется
### HTMX-17. При ошибке активное состояние не меняется
**НЕ ДОЛЖЕН.** Фрагмент, отданный после неудачного действия, показывает
прежний выбор плюс сообщение.
@@ -276,7 +280,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
</div>{{end}}
```
### R18. Поллер самозавершается
### HTMX-18. Поллер самозавершается
**ДОЛЖЕН.** Когда состояние вышло из «живого», фрагмент возвращается без
`hx-*`-атрибутов.
@@ -287,10 +291,10 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
это единственный канал, которым сервер управляет поллером.
Встроенная альтернатива — ответ со статусом 286 — не используется: она не
совместима с R4, ведь свежезагруженная страница рендерится тем же партиалом
совместима с HTMX-4, ведь свежезагруженная страница рендерится тем же партиалом
и тоже без поллера.
### R19. Условие живости ведёт собственное состояние приложения
### HTMX-19. Условие живости ведёт собственное состояние приложения
**ДОЛЖЕН.** Признак «живо ли ещё» вычисляется по состоянию, которым владеет
приложение, а не по ответу внешнего сервиса.
@@ -300,18 +304,18 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
останавливается никогда. Приложение — единственный участник, который знает
про операцию всё и может ответить на каждом тике.
### R20. Поллер свопит фрагмент целиком через `outerHTML`
### HTMX-20. Поллер свопит фрагмент целиком через `outerHTML`
**ДОЛЖЕН.** Тик заменяет весь фрагмент (`hx-swap="outerHTML"`), а не его
содержимое.
**Почему.** `outerHTML` удаляет старый узел вместе с его `hx-trigger` и
инициализирует новый — так поллер живёт ровно в одном экземпляре и так же
выключается (R18). Своп содержимого оставил бы старый узел с его таймером,
выключается (HTMX-18). Своп содержимого оставил бы старый узел с его таймером,
и через несколько обновлений опрос шёл бы в несколько потоков. Работает это
при совпадении корневого `id` (R5).
при совпадении корневого `id` (HTMX-5).
### R21. Поллер не свопит контейнер с активными полями ввода
### HTMX-21. Поллер не свопит контейнер с активными полями ввода
**НЕ ДОЛЖЕН.** Живость включается только в тех состояниях фрагмента, где
редактировать нечего.
@@ -321,7 +325,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
пользователь не выбирал: текст исчезает посреди набора и воспроизводится
как «приложение стирает мой ввод».
### R22. Браузер не ходит во внешний сервис напрямую
### HTMX-22. Браузер не ходит во внешний сервис напрямую
**НЕ ДОЛЖЕН.** Поллинг и прочие запросы страницы идут на свой сервер.
@@ -330,14 +334,14 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
контракт внешнего сервиса протекает в разметку: его смена перестаёт быть
серверным изменением.
### R23. Источник данных для тика
### HTMX-23. Источник данных для тика
**ДОЛЖЕН.** Тик читает данные там, где они уже есть, не ходя в сеть:
| № | Что показывает тик | Откуда берёт |
|---|---|---|
| R23.1 | состояние внешнего сервиса | in-memory снимок, обновляемый воркером |
| R23.2 | собственное состояние приложения | своё хранилище; снимок не требуется |
| HTMX-23.1 | состояние внешнего сервиса | in-memory снимок, обновляемый воркером |
| HTMX-23.2 | собственное состояние приложения | своё хранилище; снимок не требуется |
**Почему.** Тик умножается на число открытых вкладок, поэтому сеть на
каждом тике превращает интерфейс в генератор нагрузки на внешний сервис — и
@@ -348,7 +352,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
собственного состояния той же цены нет: хранилище и так своё, а лишний слой
кеша добавил бы только рассинхрон.
### R24. Поллинг URL страницы вместо отдельного фрагмент-роута
### HTMX-24. Поллинг URL страницы вместо отдельного фрагмент-роута
**ДОПУСКАЕТСЯ.** Когда живой фрагмент — почти вся страница, `hx-get` идёт
на URL самой страницы, а нужный узел вырезается `hx-select`:
@@ -361,24 +365,24 @@ hx-select="#item-main" hx-swap="outerHTML"
**Почему.** Отдельный `/fragments/…`-роут в этом случае дублирует
обработчик страницы целиком — вместе с перечитыванием состояния и сборкой
view, — и дальше два обработчика расходятся по тому же сценарию, что и две
копии разметки (R4).
копии разметки (HTMX-4).
Инвариант корневого `id` (R5) действует и здесь: `hx-select` выбирает тот
Инвариант корневого `id` (HTMX-5) действует и здесь: `hx-select` выбирает тот
же узел, который свопится.
## Своп и выход со страницы
### R25. Действие не уводит со страницы, если предмет остаётся на ней
### HTMX-25. Действие не уводит со страницы, если предмет остаётся на ней
**НЕ ДОЛЖЕН.** Такое действие свопит свой регион на месте.
**Почему.** Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и
пагинацию — они в query (R12). Полная навигация ради изменения одного
пагинацию — они в query (HTMX-12). Полная навигация ради изменения одного
региона возвращает пользователя в начало списка и стоит перерисовки всей
страницы. Не сохраняется при свопе только контекст внутри самого
заменяемого поддерева — фокус, выделение, введённый текст (R21).
заменяемого поддерева — фокус, выделение, введённый текст (HTMX-21).
### R26. Выход со страницы — форма без `hx-*`
### HTMX-26. Выход со страницы — форма без `hx-*`
**ДОЛЖЕН.** Действие, после которого предмет покидает страницу, остаётся
обычной POST-формой без htmx-атрибутов, то есть полной навигацией.
@@ -389,10 +393,10 @@ htmx-атрибутов при этом само работает маркеро
прямо в разметке, и не нужен `HX-Redirect` — то есть ещё один способ
сменить страницу, существующий только на htmx-пути.
### R27. Асинхронное действие свопит промежуточное состояние
### HTMX-27. Асинхронное действие свопит промежуточное состояние
**ДОЛЖЕН.** Если работу доделывает воркер, ответ на действие показывает
промежуточное состояние, а итог догоняет самозавершающийся поллер (R18).
промежуточное состояние, а итог догоняет самозавершающийся поллер (HTMX-18).
**Почему.** Мнимый результат расходится с сервером до следующего тика, и
всё это время пользователь принимает решения по несуществующему исходу —
@@ -401,7 +405,7 @@ htmx-атрибутов при этом само работает маркеро
## Различение поверхности одного действия
### R28. Поверхность различается скрытым полем формы
### HTMX-28. Поверхность различается скрытым полем формы
**ДОЛЖЕН.** Когда один роут зовут с разных страниц и своп-ответ различается
фрагментом, поверхность передаётся явным скрытым полем
@@ -413,18 +417,18 @@ htmx-атрибутов при этом само работает маркеро
действием, поэтому связь «эта страница → этот фрагмент» читается там, где
её заводят.
### R35. Запрос без поля поверхности получает 400
### HTMX-35. Запрос без поля поверхности получает 400
**ДОЛЖЕН.** Обработчик, различающий поверхности (R28), отвечает статусом
**ДОЛЖЕН.** Обработчик, различающий поверхности (HTMX-28), отвечает статусом
400, когда поля `surface` в запросе нет; поверхность по умолчанию не
выбирается.
**Почему.** Поле кладёт в форму наш же шаблон, поэтому его отсутствие —
дефект формы, а не вход пользователя. Поверхность по умолчанию маскирует
такой дефект молча неверным фрагментом: своп с чужим `id` проходит, после
чего регион перестаёт находиться таргетом (R5), и ошибка воспроизводится
чего регион перестаёт находиться таргетом (HTMX-5), и ошибка воспроизводится
как «интерфейс иногда застывает». 400 не свопится и всплывает сообщением
глобального слушателя (R34) — сразу и на той странице, где форму сломали.
глобального слушателя (HTMX-34) — сразу и на той странице, где форму сломали.
Вкладка, открытая до появления поля, получает тот же 400 и чинится
перезагрузкой; это дешевле, чем молча неверный фрагмент в актуальной
разметке.
@@ -434,7 +438,7 @@ htmx-атрибутов при этом само работает маркеро
Раздел не про htmx — это упаковка любого server-rendered приложения;
разъедется в языковой слой, когда понадобится там.
### R29. Ассеты встроены в бинарь и отдаются иммутабельным кэшем
### HTMX-29. Ассеты встроены в бинарь и отдаются иммутабельным кэшем
**ДОЛЖЕН.** Статика подключается через `go:embed` и отдаётся с
`Cache-Control: public, max-age=31536000, immutable`.
@@ -442,21 +446,21 @@ htmx-атрибутов при этом само работает маркеро
**Почему.** Встроенные ассеты делают деплой одним артефактом: нет второго
шага раскладки файлов, который может отстать от бинаря и оставить новую
разметку со старым css. Иммутабельный кэш безопасен ровно потому, что URL
меняется вместе с содержимым (R30, R31); без этого условия год кэша был бы
способом навсегда закрепить у пользователя старый файл.
меняется вместе с содержимым (HTMX-30, HTMX-31); без этого условия год кэша
был бы способом навсегда закрепить у пользователя старый файл.
### R30. Меняемые ассеты версионируются хешем содержимого
### HTMX-30. Меняемые ассеты версионируются хешем содержимого
**ДОЛЖЕН.** css и js адресуются с `?v=<короткий sha256 содержимого>`, и URL
строит хелпер шаблона.
**Почему.** Хеш содержимого — единственная версия, которую невозможно
забыть обновить: она меняется от самой правки. Ручной номер и дата сборки
от этого не защищают, а цена промаха при иммутабельном кэше (R29) —
от этого не защищают, а цена промаха при иммутабельном кэше (HTMX-29) —
устаревший файл у пользователя до ручной очистки кэша. Хелпер нужен, чтобы
хеш не проставляли в каждом шаблоне руками.
### R31. Вендорный ассет в `?v=` не нуждается
### HTMX-31. Вендорный ассет в `?v=` не нуждается
**ДОПУСКАЕТСЯ.** Вендор адресуется по неизменному имени файла, без
параметра версии.
@@ -464,9 +468,9 @@ htmx-атрибутов при этом само работает маркеро
**Почему.** Содержимое под этим именем не меняется: обновление вендора
приходит новым именем файла, то есть новым URL. Кэш-бастер защищает от
подмены содержимого под тем же адресом, а такой ситуации здесь нет — и
явное разрешение снимает вопрос, не нарушает ли это R30.
явное разрешение снимает вопрос, не нарушает ли это HTMX-30.
### R32. Вендор не коммитится, а добывается по манифесту
### HTMX-32. Вендор не коммитится, а добывается по манифесту
**ДОЛЖЕН.** Идемпотентная задача скачивает вендорные файлы по манифесту
(`путь url sha256`) с проверкой контрольной суммы; сборка зависит от этой
@@ -478,7 +482,7 @@ diff'е — у закоммиченного минифицированного
единственная проверка, что скачали то же самое, что проверяли; зависимость
сборки от задачи не даёт собраться без ассета в свежем клоне.
### R33. Шрифты и скрипты — self-hosted
### HTMX-33. Шрифты и скрипты — self-hosted
**ДОЛЖЕН.** Внешних хостов во время выполнения нет.