- META-31 и META-32: номера идут без пропусков, ссылка обязана разрешаться; обе проверки стали механическими — данных со стороны языка им хватает - заведена метка СНЯТО: заголовок и номер снятого правила сохраняются, норму с обоснованием заменяет блок с датой и причиной, отдельный реестр снятых номеров не нужен - META-9, META-16 и META-26 переписаны из таблицы «Освободившиеся номера» в заглушки; три висячие ссылки, тянувшиеся с утра, закрылись
497 lines
36 KiB
Markdown
497 lines
36 KiB
Markdown
---
|
||
topic: web-ui
|
||
prefix: HTMX
|
||
---
|
||
|
||
# Веб-UI на htmx
|
||
|
||
Как пишется код веб-UI: частичный своп фрагментов, поллинг живых обновлений,
|
||
обработчики действий, деградация без JS, ошибки. Что именно UI показывает и
|
||
какие действия поддерживает — в спеках, не здесь.
|
||
|
||
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
|
||
ПОЧЕМУ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке конвенций
|
||
версии 1 — тогда и только тогда, когда написаны заглавными.
|
||
|
||
Логирование запросов — конвенция `logging` (HTTP-поля, рутинно-частое на
|
||
`DEBUG`). Трансляция доменных ошибок наружу — конвенция `errors`
|
||
(приватный канал = логи, публичный = сообщение плюс корреляционный ключ).
|
||
Здесь — только специфика htmx-транспорта, без дублирования.
|
||
|
||
## Область действия
|
||
|
||
Утверждения о поведении htmx относятся к **2.x**: дефолты обработки ответов
|
||
между мажорами менялись. Правила описывают то, как написан код веб-UI, а не
|
||
то, какие экраны и действия у приложения есть.
|
||
|
||
## Стек и границы
|
||
|
||
### HTMX-1. Стек: роутер, серверные шаблоны, htmx
|
||
|
||
**ДОЛЖЕН.** UI собирается из серверных шаблонов и htmx — без шага сборки,
|
||
без Node и бандлера, без реактивного фреймворка.
|
||
|
||
**ПОЧЕМУ.** Шаг сборки — это второй язык, второй менеджер зависимостей и
|
||
артефакт, который расходится с исходником; приложению, где разметку целиком
|
||
отдаёт сервер, он не покупает ничего. Реактивный фреймворк добавляет вторую
|
||
модель состояния рядом с серверной (HTMX-2), и дальше на каждом экране
|
||
приходится решать, какая из них главная. Сам htmx — вендорный ассет и
|
||
живёт по правилам вендоринга (HTMX-32, HTMX-33): внешний CDN добавил бы к
|
||
аптайму приложения аптайм чужого хоста.
|
||
|
||
### HTMX-2. Клиент не пересчитывает доменное состояние
|
||
|
||
**НЕ ДОЛЖЕН.** Свой JS делает только то, чего серверу знать не нужно
|
||
(копирование в буфер обмена и подобное); доменное состояние считает сервер,
|
||
клиент свопит присланную разметку.
|
||
|
||
**ПОЧЕМУ.** Пересчёт на клиенте — вторая реализация той же логики, которую
|
||
никто не сверяет: расходится она тихо, а проявляется как «на экране одно, в
|
||
базе другое». Вдобавок клиентский пересчёт по определению не работает в
|
||
деградированном режиме (HTMX-11, HTMX-12) — значит, серверную версию того же
|
||
вычисления всё равно придётся держать.
|
||
|
||
### HTMX-3. Реактивный слой вводится отдельным решением
|
||
|
||
**НЕ ДОЛЖЕН.** Alpine.js и подобное не появляется попутно с задачей —
|
||
только когда есть виджет, которому он действительно нужен, и отдельным
|
||
решением.
|
||
|
||
**ПОЧЕМУ.** Реактивный слой, попавший в проект ради одного выпадающего
|
||
списка, немедленно доступен всему остальному коду — и граница HTMX-1/HTMX-2
|
||
перестаёт держаться сама собой. Отдельное решение — единственный момент,
|
||
когда цену видно целиком: она не в килобайтах, а в том, что дальше на
|
||
каждом экране есть выбор между двумя моделями состояния.
|
||
|
||
## Единый источник разметки
|
||
|
||
### HTMX-4. Партиал = страница = фрагмент
|
||
|
||
**ДОЛЖЕН.** Переиспользуемый кусок разметки — именованный шаблон в
|
||
`partials/`, и он же рендерится инлайн на странице и как ответ-фрагмент
|
||
обработчика; отдельной разметки под фрагмент нет.
|
||
|
||
**ПОЧЕМУ.** Две копии одной разметки расходятся молча: правку вносят в ту,
|
||
что открыта, и страница начинает выглядеть иначе, чем результат свопа того
|
||
же региона. Заметно это становится только на глаз и только тому, кто открыл
|
||
оба пути подряд.
|
||
|
||
### HTMX-5. Корень партиала — элемент с целевым `id`
|
||
|
||
**ДОЛЖЕН.** Корневой узел шаблона несёт тот `id`, по которому адресуют
|
||
регион, и ответный фрагмент несёт тот же `id`.
|
||
|
||
**ПОЧЕМУ.** `hx-swap="outerHTML"` заменяет корневой узел целиком, вместе с
|
||
его атрибутами. Если пришедший фрагмент несёт другой `id` или не несёт его
|
||
вовсе, первый своп проходит успешно, а следующее действие и поллер уже не
|
||
находят таргет: регион застывает без единой ошибки — ни в консоли, ни в
|
||
логе.
|
||
|
||
### HTMX-6. Сборку view делает общая функция
|
||
|
||
**СЛЕДУЕТ.** Один view-builder зовут и обработчик полной страницы, и
|
||
htmx-ветка.
|
||
|
||
**ПОЧЕМУ.** Общий шаблон (HTMX-4) гарантирует одинаковую разметку, но не
|
||
одинаковые данные: скопированная сборка view расходится по набору полей, и
|
||
фрагмент начинает показывать не то, что показала бы страница. Это ровно тот
|
||
класс расхождений, который HTMX-4 закрывает для разметки.
|
||
|
||
## Обработчик действия
|
||
|
||
### HTMX-7. Доменный вызов одинаков для htmx и обычного запроса
|
||
|
||
**ДОЛЖЕН.** Обработчик определяет htmx-запрос по заголовку
|
||
`HX-Request: true`, зовёт доменную операцию до ветвления и ветвится только
|
||
на способе ответа:
|
||
|
||
| № | Запрос | Ответ |
|
||
|---|---|---|
|
||
| HTMX-7.1 | `HX-Request: true` | фрагмент тем же партиалом (HTMX-4) по перечитанному состоянию |
|
||
| HTMX-7.2 | обычный | PRG-редирект (303) |
|
||
|
||
```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) // фрагмент = тот же шаблон
|
||
```
|
||
|
||
**ПОЧЕМУ.** Ветвление до вызова даёт две реализации одного действия, и
|
||
дальше дефект воспроизводится только на одной поверхности — причём
|
||
деградированный путь (HTMX-11) открывают реже, то есть чинить будут не тот.
|
||
Перечитанное состояние в htmx-ветке нужно потому, что своп заменяет регион
|
||
целиком: view, собранный из аргументов запроса, покажет намерение, а не
|
||
результат.
|
||
|
||
### HTMX-8. Шаблон рендерится в буфер, потом в ответ
|
||
|
||
**ДОЛЖЕН.** Именованный шаблон собирается целиком в буфер, и только затем
|
||
буфер пишется в ответ.
|
||
|
||
**ПОЧЕМУ.** Прямая запись в ответ отправляет клиенту статус и часть
|
||
разметки раньше, чем шаблон дошёл до ошибки: сообщить об отказе уже нечем,
|
||
а htmx свопит в DOM полученный обрывок. Внешне это «исчезла половина
|
||
региона», и причина по такому симптому не читается.
|
||
|
||
## Одно действие — два региона
|
||
|
||
### HTMX-9. Второй регион едет тем же ответом через `hx-swap-oob`
|
||
|
||
**СЛЕДУЕТ.** Когда действие меняет не только свой регион, второй фрагмент
|
||
отдаётся в том же ответе с `hx-swap-oob="true"` — обычным именованным
|
||
партиалом с тем же `id`, что и на странице (HTMX-4, HTMX-5).
|
||
|
||
**ПОЧЕМУ.** Второй запрос с клиента вводит гонку: два ответа считают
|
||
состояние в разные моменты и приезжают в произвольном порядке, поэтому
|
||
панель действий может отразить состояние до действия. Плюс лишний
|
||
раунд-трип на каждое действие.
|
||
|
||
### HTMX-10. Отдельный запрос за вторым регионом — когда он обновляется реже действия
|
||
|
||
**ДОПУСКАЕТСЯ.** `HX-Trigger` в ответе плюс отдельный `hx-get`, если второй
|
||
регион меняется не на каждое действие.
|
||
|
||
**ПОЧЕМУ.** Явное разрешение нужно, чтобы HTMX-9 не читался как запрет любого
|
||
второго запроса. Когда регион обновляется редко, oob-ветка гоняет
|
||
одинаковую разметку на каждое действие и связывает два шаблона там, где
|
||
связи нет; гонка же тем менее наблюдаема, чем реже обновление.
|
||
|
||
## Graceful degradation
|
||
|
||
### HTMX-11. Форма действия работает без JS
|
||
|
||
**ДОЛЖЕН.** Действие — обычная `<form method="post" action="…">`, на которую
|
||
`hx-post`/`hx-target`/`hx-swap` накладываются сверху; `action` ведёт на
|
||
рабочий обработчик.
|
||
|
||
**ПОЧЕМУ.** htmx может не загрузиться — ошибка вендоринга, блокировщик,
|
||
медленная сеть, — и без рабочего `action` форма в этот момент не отправляет
|
||
ничего, молча. Тот же `action` — единственное, что делает действие
|
||
проверяемым без браузера с JS.
|
||
|
||
### HTMX-12. Фильтр, поиск и пагинация — серверные
|
||
|
||
**ДОЛЖЕН.** Отбор списка задаётся GET-параметрами и выполняется на сервере;
|
||
клиентской фильтрации загруженной разметки нет.
|
||
|
||
**ПОЧЕМУ.** Клиент видит только текущую страницу списка, поэтому клиентский
|
||
фильтр отвечает по неполным данным и делает это молча — результат выглядит
|
||
валидным. Вдобавок состояние отбора в query переживает своп (HTMX-25) и
|
||
перезагрузку, его можно послать ссылкой и увидеть в логе.
|
||
|
||
### HTMX-13. Область обязательной деградации
|
||
|
||
**ДОЛЖЕН.** Требование работать без JS распространяется не на весь UI:
|
||
|
||
| № | Поверхность | Поведение без JS |
|
||
|---|---|---|
|
||
| HTMX-13.1 | действия и навигация | работают полностью (HTMX-11, HTMX-12) |
|
||
| HTMX-13.2 | интерактивный виджет выбора, у которого нет осмысленного не-JS поведения | может не работать; записывается в отступления |
|
||
|
||
**ПОЧЕМУ.** Без явной границы правило деградации читается как запрет на
|
||
любой JS-виджет — и тогда его либо тихо нарушают, либо отказываются от
|
||
виджета, который был нужен. Запись в отступления держит список честным:
|
||
видно, какие именно места ломаются с выключенным JS, а не «где-то
|
||
что-то».
|
||
|
||
## Ошибки на htmx-пути
|
||
|
||
### HTMX-14. Ошибка действия на htmx-пути — 200 с фрагментом
|
||
|
||
**ДОЛЖЕН.** Провалившееся действие отдаёт статус 200 и фрагмент с
|
||
сообщением; доменная ошибка на htmx-пути не транслируется в HTTP-статус.
|
||
|
||
**ПОЧЕМУ.** В htmx 2.x ответы 4xx/5xx по умолчанию не свопят DOM — то есть
|
||
пользователь не увидит ничего. Своп ошибочных ответов настраивается
|
||
(`htmx.config.responseHandling`, расширение `response-targets`), но любая
|
||
такая настройка — свой JS-конфиг на клиенте, и платится она из HTMX-1 и HTMX-2.
|
||
Сообщить о сбое, для которого фрагмента нет вовсе, — отдельная задача, и
|
||
её решает глобальный слушатель (HTMX-34). Для REST API и не-JS редиректа с
|
||
`?err=` статус по-прежнему используется: там его кто-то читает.
|
||
|
||
Цена решения: в логе доступа провалившееся действие выглядит как `200`.
|
||
Искать его надо по доменной записи об исходе операции (конвенция
|
||
`logging`), а не по коду ответа.
|
||
|
||
### HTMX-34. Сбой без ответа-фрагмента показывается глобальным слушателем
|
||
|
||
**ДОЛЖЕН.** Один глобальный слушатель `htmx:responseError` и
|
||
`htmx:sendError` показывает нейтральное сообщение о неудаче запроса; своп
|
||
ошибочных ответов в целевые регионы (`htmx.config.responseHandling`,
|
||
`response-targets`) не настраивается.
|
||
|
||
**ПОЧЕМУ.** HTMX-14 закрывает доменный отказ, до которого обработчик дошёл.
|
||
Паника, сбой шаблона и обрыв сети отдают 5xx или ничего, htmx 2.x такое не
|
||
свопит — регион не меняется, интерфейс замирает без единого признака сбоя,
|
||
и пользователь повторяет действие, которое могло уже примениться. Слушатель
|
||
— несколько строк без доменного состояния, то есть внутри границы HTMX-2, и он
|
||
не спорит с HTMX-14: там настройки отвергнуты как замена фрагменту, который
|
||
обработчик в состоянии отдать, а здесь фрагмента нет по определению. Своп
|
||
тела ошибки в целевой регион стоил бы дороже: страница 500 не несёт
|
||
целевого `id`, и после первого же такого свопа регион перестаёт находиться
|
||
(HTMX-5).
|
||
|
||
### HTMX-15. Наружу идёт сообщение публичного канала
|
||
|
||
**ДОЛЖЕН.** Во фрагмент попадает нейтральный текст публичного канала;
|
||
`err.Error()` в разметку не рендерится.
|
||
|
||
**ПОЧЕМУ.** htmx-фрагмент выглядит внутренней деталью приложения, и на нём
|
||
легче всего забыть, что это тот же публичный канал, что и страница:
|
||
разметка уезжает в браузер пользователя целиком. Статус 200 (HTMX-14)
|
||
дополнительно снимает ощущение «это ошибочный ответ, его никто не увидит».
|
||
|
||
### HTMX-16. Сообщение об ошибке — в отдельном поле view
|
||
|
||
**ДОЛЖЕН.** У view есть поле под ошибку действия; доменные поля под
|
||
сообщение не переиспользуются.
|
||
|
||
**ПОЧЕМУ.** У доменного поля может быть своё непустое значение, и сообщение
|
||
его перекроет: пользователь получит текст ошибки вместо данных, а шаблон —
|
||
необходимость угадывать, что сейчас лежит в поле. Отдельное поле делает оба
|
||
состояния — данные и ошибку — выразимыми одновременно, а это ровно то, чего
|
||
требует HTMX-17.
|
||
|
||
### HTMX-17. При ошибке активное состояние не меняется
|
||
|
||
**НЕ ДОЛЖЕН.** Фрагмент, отданный после неудачного действия, показывает
|
||
прежний выбор плюс сообщение.
|
||
|
||
**ПОЧЕМУ.** Своп заменяет регион целиком, поэтому фрагмент — единственное,
|
||
что пользователь узнает о состоянии. Показав намеренное состояние вместо
|
||
фактического, интерфейс расходится с сервером, и следующее действие человек
|
||
делает по ложной картине — на сервере оно применится к другому объекту.
|
||
|
||
## Живой поллинг
|
||
|
||
Живое обновление устроено как фрагмент-эндпоинт под `/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}}
|
||
```
|
||
|
||
### HTMX-18. Поллер самозавершается
|
||
|
||
**ДОЛЖЕН.** Когда состояние вышло из «живого», фрагмент возвращается без
|
||
`hx-*`-атрибутов.
|
||
|
||
**ПОЧЕМУ.** Иначе опрос не прекращается никогда: каждая открытая вкладка
|
||
держит постоянный поток запросов за неизменными данными, и закрывает его
|
||
только пользователь. Условие остановки живёт в разметке ответа, потому что
|
||
это единственный канал, которым сервер управляет поллером.
|
||
|
||
Встроенная альтернатива — ответ со статусом 286 — не используется: она не
|
||
совместима с HTMX-4, ведь свежезагруженная страница рендерится тем же партиалом
|
||
и тоже без поллера.
|
||
|
||
### HTMX-19. Условие живости ведёт собственное состояние приложения
|
||
|
||
**ДОЛЖЕН.** Признак «живо ли ещё» вычисляется по состоянию, которым владеет
|
||
приложение, а не по ответу внешнего сервиса.
|
||
|
||
**ПОЧЕМУ.** Внешний сервис отвечает не всегда и не одинаково: на его
|
||
недоступности поллер либо останавливается, пока работа идёт, либо не
|
||
останавливается никогда. Приложение — единственный участник, который знает
|
||
про операцию всё и может ответить на каждом тике.
|
||
|
||
### HTMX-20. Поллер свопит фрагмент целиком через `outerHTML`
|
||
|
||
**ДОЛЖЕН.** Тик заменяет весь фрагмент (`hx-swap="outerHTML"`), а не его
|
||
содержимое.
|
||
|
||
**ПОЧЕМУ.** `outerHTML` удаляет старый узел вместе с его `hx-trigger` и
|
||
инициализирует новый — так поллер живёт ровно в одном экземпляре и так же
|
||
выключается (HTMX-18). Своп содержимого оставил бы старый узел с его таймером,
|
||
и через несколько обновлений опрос шёл бы в несколько потоков. Работает это
|
||
при совпадении корневого `id` (HTMX-5).
|
||
|
||
### HTMX-21. Поллер не свопит контейнер с активными полями ввода
|
||
|
||
**НЕ ДОЛЖЕН.** Живость включается только в тех состояниях фрагмента, где
|
||
редактировать нечего.
|
||
|
||
**ПОЧЕМУ.** Своп поддерева теряет фокус, выделение и незасабмиченный текст
|
||
внутри него. У поллера это происходит по таймеру, то есть в момент, который
|
||
пользователь не выбирал: текст исчезает посреди набора и воспроизводится
|
||
как «приложение стирает мой ввод».
|
||
|
||
### HTMX-22. Браузер не ходит во внешний сервис напрямую
|
||
|
||
**НЕ ДОЛЖЕН.** Поллинг и прочие запросы страницы идут на свой сервер.
|
||
|
||
**ПОЧЕМУ.** Прямой запрос из браузера выносит наружу адрес и учётные данные
|
||
внешнего сервиса и делает страницу заложником его CORS-политики. Вдобавок
|
||
контракт внешнего сервиса протекает в разметку: его смена перестаёт быть
|
||
серверным изменением.
|
||
|
||
### HTMX-23. Источник данных для тика
|
||
|
||
**ДОЛЖЕН.** Тик читает данные там, где они уже есть, не ходя в сеть:
|
||
|
||
| № | Что показывает тик | Откуда берёт |
|
||
|---|---|---|
|
||
| HTMX-23.1 | состояние внешнего сервиса | in-memory снимок, обновляемый воркером |
|
||
| HTMX-23.2 | собственное состояние приложения | своё хранилище; снимок не требуется |
|
||
|
||
**ПОЧЕМУ.** Тик умножается на число открытых вкладок, поэтому сеть на
|
||
каждом тике превращает интерфейс в генератор нагрузки на внешний сервис — и
|
||
его недоступность становится недоступностью страницы. Снимок разрывает эту
|
||
связь: частоту обращений к внешнему сервису задаёт воркер, а не
|
||
пользователи. Контракт снимка держат узким, чтобы способ доставки
|
||
(поллинг сегодня, SSE потом) менялся, не задевая остальной код. Для
|
||
собственного состояния той же цены нет: хранилище и так своё, а лишний слой
|
||
кеша добавил бы только рассинхрон.
|
||
|
||
### HTMX-24. Поллинг URL страницы вместо отдельного фрагмент-роута
|
||
|
||
**ДОПУСКАЕТСЯ.** Когда живой фрагмент — почти вся страница, `hx-get` идёт
|
||
на URL самой страницы, а нужный узел вырезается `hx-select`:
|
||
|
||
```html
|
||
hx-get="/item/{{.ID}}" hx-trigger="every 3s"
|
||
hx-select="#item-main" hx-swap="outerHTML"
|
||
```
|
||
|
||
**ПОЧЕМУ.** Отдельный `/fragments/…`-роут в этом случае дублирует
|
||
обработчик страницы целиком — вместе с перечитыванием состояния и сборкой
|
||
view, — и дальше два обработчика расходятся по тому же сценарию, что и две
|
||
копии разметки (HTMX-4).
|
||
|
||
Инвариант корневого `id` (HTMX-5) действует и здесь: `hx-select` выбирает тот
|
||
же узел, который свопится.
|
||
|
||
## Своп и выход со страницы
|
||
|
||
### HTMX-25. Действие не уводит со страницы, если предмет остаётся на ней
|
||
|
||
**НЕ ДОЛЖЕН.** Такое действие свопит свой регион на месте.
|
||
|
||
**ПОЧЕМУ.** Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и
|
||
пагинацию — они в query (HTMX-12). Полная навигация ради изменения одного
|
||
региона возвращает пользователя в начало списка и стоит перерисовки всей
|
||
страницы. Не сохраняется при свопе только контекст внутри самого
|
||
заменяемого поддерева — фокус, выделение, введённый текст (HTMX-21).
|
||
|
||
### HTMX-26. Выход со страницы — форма без `hx-*`
|
||
|
||
**ДОЛЖЕН.** Действие, после которого предмет покидает страницу, остаётся
|
||
обычной POST-формой без htmx-атрибутов, то есть полной навигацией.
|
||
|
||
**ПОЧЕМУ.** Своп для такого действия оставил бы на месте регион,
|
||
описывающий объект, которого на странице больше нет. Отсутствие
|
||
htmx-атрибутов при этом само работает маркером «это выход»: намерение видно
|
||
прямо в разметке, и не нужен `HX-Redirect` — то есть ещё один способ
|
||
сменить страницу, существующий только на htmx-пути.
|
||
|
||
### HTMX-27. Асинхронное действие свопит промежуточное состояние
|
||
|
||
**ДОЛЖЕН.** Если работу доделывает воркер, ответ на действие показывает
|
||
промежуточное состояние, а итог догоняет самозавершающийся поллер (HTMX-18).
|
||
|
||
**ПОЧЕМУ.** Мнимый результат расходится с сервером до следующего тика, и
|
||
всё это время пользователь принимает решения по несуществующему исходу —
|
||
включая повтор действия, которое на самом деле выполняется. Промежуточное
|
||
состояние вдобавок объясняет, почему регион продолжает обновляться сам.
|
||
|
||
## Различение поверхности одного действия
|
||
|
||
### HTMX-28. Поверхность различается скрытым полем формы
|
||
|
||
**ДОЛЖЕН.** Когда один роут зовут с разных страниц и своп-ответ различается
|
||
фрагментом, поверхность передаётся явным скрытым полем
|
||
(`surface=list|detail`), а не выводится из `HX-Target` или `Referer`.
|
||
|
||
**ПОЧЕМУ.** `HX-Target` — свойство разметки вызывающей страницы, `Referer`
|
||
может не прийти вовсе; и то и другое меняется без участия обработчика, и
|
||
ответ начинает приходить не тем фрагментом. Поле же видно в форме рядом с
|
||
действием, поэтому связь «эта страница → этот фрагмент» читается там, где
|
||
её заводят.
|
||
|
||
### HTMX-35. Запрос без поля поверхности получает 400
|
||
|
||
**ДОЛЖЕН.** Обработчик, различающий поверхности (HTMX-28), отвечает статусом
|
||
400, когда поля `surface` в запросе нет; поверхность по умолчанию не
|
||
выбирается.
|
||
|
||
**ПОЧЕМУ.** Поле кладёт в форму наш же шаблон, поэтому его отсутствие —
|
||
дефект формы, а не вход пользователя. Поверхность по умолчанию маскирует
|
||
такой дефект молча неверным фрагментом: своп с чужим `id` проходит, после
|
||
чего регион перестаёт находиться таргетом (HTMX-5), и ошибка воспроизводится
|
||
как «интерфейс иногда застывает». 400 не свопится и всплывает сообщением
|
||
глобального слушателя (HTMX-34) — сразу и на той странице, где форму сломали.
|
||
Вкладка, открытая до появления поля, получает тот же 400 и чинится
|
||
перезагрузкой; это дешевле, чем молча неверный фрагмент в актуальной
|
||
разметке.
|
||
|
||
## Статика, вендоринг, кэш
|
||
|
||
Раздел не про htmx — это упаковка любого server-rendered приложения;
|
||
разъедется в языковой слой, когда понадобится там.
|
||
|
||
### HTMX-29. Ассеты встроены в бинарь и отдаются иммутабельным кэшем
|
||
|
||
**ДОЛЖЕН.** Статика подключается через `go:embed` и отдаётся с
|
||
`Cache-Control: public, max-age=31536000, immutable`.
|
||
|
||
**ПОЧЕМУ.** Встроенные ассеты делают деплой одним артефактом: нет второго
|
||
шага раскладки файлов, который может отстать от бинаря и оставить новую
|
||
разметку со старым css. Иммутабельный кэш безопасен ровно потому, что URL
|
||
меняется вместе с содержимым (HTMX-30, HTMX-31); без этого условия год кэша
|
||
был бы способом навсегда закрепить у пользователя старый файл.
|
||
|
||
### HTMX-30. Меняемые ассеты версионируются хешем содержимого
|
||
|
||
**ДОЛЖЕН.** css и js адресуются с `?v=<короткий sha256 содержимого>`, и URL
|
||
строит хелпер шаблона.
|
||
|
||
**ПОЧЕМУ.** Хеш содержимого — единственная версия, которую невозможно
|
||
забыть обновить: она меняется от самой правки. Ручной номер и дата сборки
|
||
от этого не защищают, а цена промаха при иммутабельном кэше (HTMX-29) —
|
||
устаревший файл у пользователя до ручной очистки кэша. Хелпер нужен, чтобы
|
||
хеш не проставляли в каждом шаблоне руками.
|
||
|
||
### HTMX-31. Вендорный ассет в `?v=` не нуждается
|
||
|
||
**ДОПУСКАЕТСЯ.** Вендор адресуется по неизменному имени файла, без
|
||
параметра версии.
|
||
|
||
**ПОЧЕМУ.** Содержимое под этим именем не меняется: обновление вендора
|
||
приходит новым именем файла, то есть новым URL. Кэш-бастер защищает от
|
||
подмены содержимого под тем же адресом, а такой ситуации здесь нет — и
|
||
явное разрешение снимает вопрос, не нарушает ли это HTMX-30.
|
||
|
||
### HTMX-32. Вендор не коммитится, а добывается по манифесту
|
||
|
||
**ДОЛЖЕН.** Идемпотентная задача скачивает вендорные файлы по манифесту
|
||
(`путь url sha256`) с проверкой контрольной суммы; сборка зависит от этой
|
||
задачи.
|
||
|
||
**ПОЧЕМУ.** Манифест делает версию и происхождение ассета видимыми в
|
||
diff'е — у закоммиченного минифицированного файла обновление выглядит
|
||
стеной непрозрачных изменений, и подмену в ней не разглядеть. Sha256 —
|
||
единственная проверка, что скачали то же самое, что проверяли; зависимость
|
||
сборки от задачи не даёт собраться без ассета в свежем клоне.
|
||
|
||
### HTMX-33. Шрифты и скрипты — self-hosted
|
||
|
||
**ДОЛЖЕН.** Внешних хостов во время выполнения нет.
|
||
|
||
**ПОЧЕМУ.** Каждый внешний хост — это чужой аптайм внутри своей страницы и
|
||
третья сторона, видящая каждый запрос пользователя. Самодостаточный бинарь
|
||
вдобавок разворачивается в сети без выхода наружу, где CDN просто не
|
||
отвечает.
|