ПОЧЕМУ стало ключевым словом, язык поднят до версии 2

- метка обоснования пишется заглавными и вошла в словарь набора: скелет
  правила теперь целиком из ключевых слов, а не смесь `**ДОЛЖЕН.**` и
  `**Почему.**`; в переводе на другой язык метка меняется как остальные слова
  (ПОЧЕМУ / WHY), 235 вхождений заменены
- метки правила выделены из шкалы в отдельный перечень: ПОЧЕМУ и
  МЕХАНИЗИРОВАНО обязательности не задают, а размечают части, и стандартом не
  даются ни в одном языке — раньше МЕХАНИЗИРОВАНО висело строкой в таблице
  модальности
- версия языка поднята до 2, потому что изменение формы меняет чтение уже
  написанного текста; строка о версии в двенадцати конвенциях перечисляет
  теперь и метки, а служебные слова сценария в неё по-прежнему не входят
This commit is contained in:
av
2026-07-26 14:28:37 +03:00
parent c8071dc438
commit 72d77d74bf
16 changed files with 346 additions and 318 deletions
+38 -38
View File
@@ -8,9 +8,9 @@ prefix: HTMX
обработчики действий, деградация без JS, ошибки. Что именно UI показывает и
какие действия поддерживает — в спеках, не здесь.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка
МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 тогда и
только тогда, когда написаны заглавными.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2
тогда и только тогда, когда написаны заглавными.
Логирование запросов — конвенция `logging` (HTTP-поля, рутинно-частое на
`DEBUG`). Трансляция доменных ошибок наружу — конвенция `errors`
@@ -30,7 +30,7 @@ prefix: HTMX
**ДОЛЖЕН.** UI собирается из серверных шаблонов и htmx — без шага сборки,
без Node и бандлера, без реактивного фреймворка.
**Почему.** Шаг сборки — это второй язык, второй менеджер зависимостей и
**ПОЧЕМУ.** Шаг сборки — это второй язык, второй менеджер зависимостей и
артефакт, который расходится с исходником; приложению, где разметку целиком
отдаёт сервер, он не покупает ничего. Реактивный фреймворк добавляет вторую
модель состояния рядом с серверной (HTMX-2), и дальше на каждом экране
@@ -44,7 +44,7 @@ prefix: HTMX
(копирование в буфер обмена и подобное); доменное состояние считает сервер,
клиент свопит присланную разметку.
**Почему.** Пересчёт на клиенте — вторая реализация той же логики, которую
**ПОЧЕМУ.** Пересчёт на клиенте — вторая реализация той же логики, которую
никто не сверяет: расходится она тихо, а проявляется как «на экране одно, в
базе другое». Вдобавок клиентский пересчёт по определению не работает в
деградированном режиме (HTMX-11, HTMX-12) — значит, серверную версию того же
@@ -56,7 +56,7 @@ prefix: HTMX
только когда есть виджет, которому он действительно нужен, и отдельным
решением.
**Почему.** Реактивный слой, попавший в проект ради одного выпадающего
**ПОЧЕМУ.** Реактивный слой, попавший в проект ради одного выпадающего
списка, немедленно доступен всему остальному коду — и граница HTMX-1/HTMX-2
перестаёт держаться сама собой. Отдельное решение — единственный момент,
когда цену видно целиком: она не в килобайтах, а в том, что дальше на
@@ -70,7 +70,7 @@ prefix: HTMX
`partials/`, и он же рендерится инлайн на странице и как ответ-фрагмент
обработчика; отдельной разметки под фрагмент нет.
**Почему.** Две копии одной разметки расходятся молча: правку вносят в ту,
**ПОЧЕМУ.** Две копии одной разметки расходятся молча: правку вносят в ту,
что открыта, и страница начинает выглядеть иначе, чем результат свопа того
же региона. Заметно это становится только на глаз и только тому, кто открыл
оба пути подряд.
@@ -80,7 +80,7 @@ prefix: HTMX
**ДОЛЖЕН.** Корневой узел шаблона несёт тот `id`, по которому адресуют
регион, и ответный фрагмент несёт тот же `id`.
**Почему.** `hx-swap="outerHTML"` заменяет корневой узел целиком, вместе с
**ПОЧЕМУ.** `hx-swap="outerHTML"` заменяет корневой узел целиком, вместе с
его атрибутами. Если пришедший фрагмент несёт другой `id` или не несёт его
вовсе, первый своп проходит успешно, а следующее действие и поллер уже не
находят таргет: регион застывает без единой ошибки — ни в консоли, ни в
@@ -91,7 +91,7 @@ prefix: HTMX
**СЛЕДУЕТ.** Один view-builder зовут и обработчик полной страницы, и
htmx-ветка.
**Почему.** Общий шаблон (HTMX-4) гарантирует одинаковую разметку, но не
**ПОЧЕМУ.** Общий шаблон (HTMX-4) гарантирует одинаковую разметку, но не
одинаковые данные: скопированная сборка view расходится по набору полей, и
фрагмент начинает показывать не то, что показала бы страница. Это ровно тот
класс расхождений, который HTMX-4 закрывает для разметки.
@@ -124,7 +124,7 @@ if actionErr != nil {
s.render(w, "source_block", view) // фрагмент = тот же шаблон
```
**Почему.** Ветвление до вызова даёт две реализации одного действия, и
**ПОЧЕМУ.** Ветвление до вызова даёт две реализации одного действия, и
дальше дефект воспроизводится только на одной поверхности — причём
деградированный путь (HTMX-11) открывают реже, то есть чинить будут не тот.
Перечитанное состояние в htmx-ветке нужно потому, что своп заменяет регион
@@ -136,7 +136,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** Именованный шаблон собирается целиком в буфер, и только затем
буфер пишется в ответ.
**Почему.** Прямая запись в ответ отправляет клиенту статус и часть
**ПОЧЕМУ.** Прямая запись в ответ отправляет клиенту статус и часть
разметки раньше, чем шаблон дошёл до ошибки: сообщить об отказе уже нечем,
а htmx свопит в DOM полученный обрывок. Внешне это «исчезла половина
региона», и причина по такому симптому не читается.
@@ -149,7 +149,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
отдаётся в том же ответе с `hx-swap-oob="true"` — обычным именованным
партиалом с тем же `id`, что и на странице (HTMX-4, HTMX-5).
**Почему.** Второй запрос с клиента вводит гонку: два ответа считают
**ПОЧЕМУ.** Второй запрос с клиента вводит гонку: два ответа считают
состояние в разные моменты и приезжают в произвольном порядке, поэтому
панель действий может отразить состояние до действия. Плюс лишний
раунд-трип на каждое действие.
@@ -159,7 +159,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОПУСКАЕТСЯ.** `HX-Trigger` в ответе плюс отдельный `hx-get`, если второй
регион меняется не на каждое действие.
**Почему.** Явное разрешение нужно, чтобы HTMX-9 не читался как запрет любого
**ПОЧЕМУ.** Явное разрешение нужно, чтобы HTMX-9 не читался как запрет любого
второго запроса. Когда регион обновляется редко, oob-ветка гоняет
одинаковую разметку на каждое действие и связывает два шаблона там, где
связи нет; гонка же тем менее наблюдаема, чем реже обновление.
@@ -172,7 +172,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
`hx-post`/`hx-target`/`hx-swap` накладываются сверху; `action` ведёт на
рабочий обработчик.
**Почему.** htmx может не загрузиться — ошибка вендоринга, блокировщик,
**ПОЧЕМУ.** htmx может не загрузиться — ошибка вендоринга, блокировщик,
медленная сеть, — и без рабочего `action` форма в этот момент не отправляет
ничего, молча. Тот же `action` — единственное, что делает действие
проверяемым без браузера с JS.
@@ -182,7 +182,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** Отбор списка задаётся GET-параметрами и выполняется на сервере;
клиентской фильтрации загруженной разметки нет.
**Почему.** Клиент видит только текущую страницу списка, поэтому клиентский
**ПОЧЕМУ.** Клиент видит только текущую страницу списка, поэтому клиентский
фильтр отвечает по неполным данным и делает это молча — результат выглядит
валидным. Вдобавок состояние отбора в query переживает своп (HTMX-25) и
перезагрузку, его можно послать ссылкой и увидеть в логе.
@@ -196,7 +196,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
| HTMX-13.1 | действия и навигация | работают полностью (HTMX-11, HTMX-12) |
| HTMX-13.2 | интерактивный виджет выбора, у которого нет осмысленного не-JS поведения | может не работать; записывается в отступления |
**Почему.** Без явной границы правило деградации читается как запрет на
**ПОЧЕМУ.** Без явной границы правило деградации читается как запрет на
любой JS-виджет — и тогда его либо тихо нарушают, либо отказываются от
виджета, который был нужен. Запись в отступления держит список честным:
видно, какие именно места ломаются с выключенным JS, а не «где-то
@@ -209,7 +209,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** Провалившееся действие отдаёт статус 200 и фрагмент с
сообщением; доменная ошибка на htmx-пути не транслируется в HTTP-статус.
**Почему.** В htmx 2.x ответы 4xx/5xx по умолчанию не свопят DOM — то есть
**ПОЧЕМУ.** В htmx 2.x ответы 4xx/5xx по умолчанию не свопят DOM — то есть
пользователь не увидит ничего. Своп ошибочных ответов настраивается
(`htmx.config.responseHandling`, расширение `response-targets`), но любая
такая настройка — свой JS-конфиг на клиенте, и платится она из HTMX-1 и HTMX-2.
@@ -228,7 +228,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
ошибочных ответов в целевые регионы (`htmx.config.responseHandling`,
`response-targets`) не настраивается.
**Почему.** HTMX-14 закрывает доменный отказ, до которого обработчик дошёл.
**ПОЧЕМУ.** HTMX-14 закрывает доменный отказ, до которого обработчик дошёл.
Паника, сбой шаблона и обрыв сети отдают 5xx или ничего, htmx 2.x такое не
свопит — регион не меняется, интерфейс замирает без единого признака сбоя,
и пользователь повторяет действие, которое могло уже примениться. Слушатель
@@ -244,7 +244,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** Во фрагмент попадает нейтральный текст публичного канала;
`err.Error()` в разметку не рендерится.
**Почему.** htmx-фрагмент выглядит внутренней деталью приложения, и на нём
**ПОЧЕМУ.** htmx-фрагмент выглядит внутренней деталью приложения, и на нём
легче всего забыть, что это тот же публичный канал, что и страница:
разметка уезжает в браузер пользователя целиком. Статус 200 (HTMX-14)
дополнительно снимает ощущение «это ошибочный ответ, его никто не увидит».
@@ -254,7 +254,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** У view есть поле под ошибку действия; доменные поля под
сообщение не переиспользуются.
**Почему.** У доменного поля может быть своё непустое значение, и сообщение
**ПОЧЕМУ.** У доменного поля может быть своё непустое значение, и сообщение
его перекроет: пользователь получит текст ошибки вместо данных, а шаблон —
необходимость угадывать, что сейчас лежит в поле. Отдельное поле делает оба
состояния — данные и ошибку — выразимыми одновременно, а это ровно то, чего
@@ -265,7 +265,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**НЕ ДОЛЖЕН.** Фрагмент, отданный после неудачного действия, показывает
прежний выбор плюс сообщение.
**Почему.** Своп заменяет регион целиком, поэтому фрагмент — единственное,
**ПОЧЕМУ.** Своп заменяет регион целиком, поэтому фрагмент — единственное,
что пользователь узнает о состоянии. Показав намеренное состояние вместо
фактического, интерфейс расходится с сервером, и следующее действие человек
делает по ложной картине — на сервере оно применится к другому объекту.
@@ -288,7 +288,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** Когда состояние вышло из «живого», фрагмент возвращается без
`hx-*`-атрибутов.
**Почему.** Иначе опрос не прекращается никогда: каждая открытая вкладка
**ПОЧЕМУ.** Иначе опрос не прекращается никогда: каждая открытая вкладка
держит постоянный поток запросов за неизменными данными, и закрывает его
только пользователь. Условие остановки живёт в разметке ответа, потому что
это единственный канал, которым сервер управляет поллером.
@@ -302,7 +302,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** Признак «живо ли ещё» вычисляется по состоянию, которым владеет
приложение, а не по ответу внешнего сервиса.
**Почему.** Внешний сервис отвечает не всегда и не одинаково: на его
**ПОЧЕМУ.** Внешний сервис отвечает не всегда и не одинаково: на его
недоступности поллер либо останавливается, пока работа идёт, либо не
останавливается никогда. Приложение — единственный участник, который знает
про операцию всё и может ответить на каждом тике.
@@ -312,7 +312,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** Тик заменяет весь фрагмент (`hx-swap="outerHTML"`), а не его
содержимое.
**Почему.** `outerHTML` удаляет старый узел вместе с его `hx-trigger` и
**ПОЧЕМУ.** `outerHTML` удаляет старый узел вместе с его `hx-trigger` и
инициализирует новый — так поллер живёт ровно в одном экземпляре и так же
выключается (HTMX-18). Своп содержимого оставил бы старый узел с его таймером,
и через несколько обновлений опрос шёл бы в несколько потоков. Работает это
@@ -323,7 +323,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**НЕ ДОЛЖЕН.** Живость включается только в тех состояниях фрагмента, где
редактировать нечего.
**Почему.** Своп поддерева теряет фокус, выделение и незасабмиченный текст
**ПОЧЕМУ.** Своп поддерева теряет фокус, выделение и незасабмиченный текст
внутри него. У поллера это происходит по таймеру, то есть в момент, который
пользователь не выбирал: текст исчезает посреди набора и воспроизводится
как «приложение стирает мой ввод».
@@ -332,7 +332,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**НЕ ДОЛЖЕН.** Поллинг и прочие запросы страницы идут на свой сервер.
**Почему.** Прямой запрос из браузера выносит наружу адрес и учётные данные
**ПОЧЕМУ.** Прямой запрос из браузера выносит наружу адрес и учётные данные
внешнего сервиса и делает страницу заложником его CORS-политики. Вдобавок
контракт внешнего сервиса протекает в разметку: его смена перестаёт быть
серверным изменением.
@@ -346,7 +346,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
| HTMX-23.1 | состояние внешнего сервиса | in-memory снимок, обновляемый воркером |
| HTMX-23.2 | собственное состояние приложения | своё хранилище; снимок не требуется |
**Почему.** Тик умножается на число открытых вкладок, поэтому сеть на
**ПОЧЕМУ.** Тик умножается на число открытых вкладок, поэтому сеть на
каждом тике превращает интерфейс в генератор нагрузки на внешний сервис — и
его недоступность становится недоступностью страницы. Снимок разрывает эту
связь: частоту обращений к внешнему сервису задаёт воркер, а не
@@ -365,7 +365,7 @@ hx-get="/item/{{.ID}}" hx-trigger="every 3s"
hx-select="#item-main" hx-swap="outerHTML"
```
**Почему.** Отдельный `/fragments/…`-роут в этом случае дублирует
**ПОЧЕМУ.** Отдельный `/fragments/…`-роут в этом случае дублирует
обработчик страницы целиком — вместе с перечитыванием состояния и сборкой
view, — и дальше два обработчика расходятся по тому же сценарию, что и две
копии разметки (HTMX-4).
@@ -379,7 +379,7 @@ view, — и дальше два обработчика расходятся п
**НЕ ДОЛЖЕН.** Такое действие свопит свой регион на месте.
**Почему.** Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и
**ПОЧЕМУ.** Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и
пагинацию — они в query (HTMX-12). Полная навигация ради изменения одного
региона возвращает пользователя в начало списка и стоит перерисовки всей
страницы. Не сохраняется при свопе только контекст внутри самого
@@ -390,7 +390,7 @@ view, — и дальше два обработчика расходятся п
**ДОЛЖЕН.** Действие, после которого предмет покидает страницу, остаётся
обычной POST-формой без htmx-атрибутов, то есть полной навигацией.
**Почему.** Своп для такого действия оставил бы на месте регион,
**ПОЧЕМУ.** Своп для такого действия оставил бы на месте регион,
описывающий объект, которого на странице больше нет. Отсутствие
htmx-атрибутов при этом само работает маркером «это выход»: намерение видно
прямо в разметке, и не нужен `HX-Redirect` — то есть ещё один способ
@@ -401,7 +401,7 @@ htmx-атрибутов при этом само работает маркеро
**ДОЛЖЕН.** Если работу доделывает воркер, ответ на действие показывает
промежуточное состояние, а итог догоняет самозавершающийся поллер (HTMX-18).
**Почему.** Мнимый результат расходится с сервером до следующего тика, и
**ПОЧЕМУ.** Мнимый результат расходится с сервером до следующего тика, и
всё это время пользователь принимает решения по несуществующему исходу —
включая повтор действия, которое на самом деле выполняется. Промежуточное
состояние вдобавок объясняет, почему регион продолжает обновляться сам.
@@ -414,7 +414,7 @@ htmx-атрибутов при этом само работает маркеро
фрагментом, поверхность передаётся явным скрытым полем
(`surface=list|detail`), а не выводится из `HX-Target` или `Referer`.
**Почему.** `HX-Target` — свойство разметки вызывающей страницы, `Referer`
**ПОЧЕМУ.** `HX-Target` — свойство разметки вызывающей страницы, `Referer`
может не прийти вовсе; и то и другое меняется без участия обработчика, и
ответ начинает приходить не тем фрагментом. Поле же видно в форме рядом с
действием, поэтому связь «эта страница → этот фрагмент» читается там, где
@@ -426,7 +426,7 @@ htmx-атрибутов при этом само работает маркеро
400, когда поля `surface` в запросе нет; поверхность по умолчанию не
выбирается.
**Почему.** Поле кладёт в форму наш же шаблон, поэтому его отсутствие —
**ПОЧЕМУ.** Поле кладёт в форму наш же шаблон, поэтому его отсутствие —
дефект формы, а не вход пользователя. Поверхность по умолчанию маскирует
такой дефект молча неверным фрагментом: своп с чужим `id` проходит, после
чего регион перестаёт находиться таргетом (HTMX-5), и ошибка воспроизводится
@@ -446,7 +446,7 @@ htmx-атрибутов при этом само работает маркеро
**ДОЛЖЕН.** Статика подключается через `go:embed` и отдаётся с
`Cache-Control: public, max-age=31536000, immutable`.
**Почему.** Встроенные ассеты делают деплой одним артефактом: нет второго
**ПОЧЕМУ.** Встроенные ассеты делают деплой одним артефактом: нет второго
шага раскладки файлов, который может отстать от бинаря и оставить новую
разметку со старым css. Иммутабельный кэш безопасен ровно потому, что URL
меняется вместе с содержимым (HTMX-30, HTMX-31); без этого условия год кэша
@@ -457,7 +457,7 @@ htmx-атрибутов при этом само работает маркеро
**ДОЛЖЕН.** css и js адресуются с `?v=<короткий sha256 содержимого>`, и URL
строит хелпер шаблона.
**Почему.** Хеш содержимого — единственная версия, которую невозможно
**ПОЧЕМУ.** Хеш содержимого — единственная версия, которую невозможно
забыть обновить: она меняется от самой правки. Ручной номер и дата сборки
от этого не защищают, а цена промаха при иммутабельном кэше (HTMX-29) —
устаревший файл у пользователя до ручной очистки кэша. Хелпер нужен, чтобы
@@ -468,7 +468,7 @@ htmx-атрибутов при этом само работает маркеро
**ДОПУСКАЕТСЯ.** Вендор адресуется по неизменному имени файла, без
параметра версии.
**Почему.** Содержимое под этим именем не меняется: обновление вендора
**ПОЧЕМУ.** Содержимое под этим именем не меняется: обновление вендора
приходит новым именем файла, то есть новым URL. Кэш-бастер защищает от
подмены содержимого под тем же адресом, а такой ситуации здесь нет — и
явное разрешение снимает вопрос, не нарушает ли это HTMX-30.
@@ -479,7 +479,7 @@ htmx-атрибутов при этом само работает маркеро
(`путь url sha256`) с проверкой контрольной суммы; сборка зависит от этой
задачи.
**Почему.** Манифест делает версию и происхождение ассета видимыми в
**ПОЧЕМУ.** Манифест делает версию и происхождение ассета видимыми в
diff'е — у закоммиченного минифицированного файла обновление выглядит
стеной непрозрачных изменений, и подмену в ней не разглядеть. Sha256 —
единственная проверка, что скачали то же самое, что проверяли; зависимость
@@ -489,7 +489,7 @@ diff'е — у закоммиченного минифицированного
**ДОЛЖЕН.** Внешних хостов во время выполнения нет.
**Почему.** Каждый внешний хост — это чужой аптайм внутри своей страницы и
**ПОЧЕМУ.** Каждый внешний хост — это чужой аптайм внутри своей страницы и
третья сторона, видящая каждый запрос пользователя. Самодостаточный бинарь
вдобавок разворачивается в сети без выхода наружу, где CDN просто не
отвечает.