Skip to content
 
 

Repository files navigation

winow-view

telegram chat Ask DeepWiki

Тонкий фронт-слой для веб-сервера winow: интерактивный интерфейс без сборщика JS.

htmx и Alpine.js поставляются вместе с пакетом и раздаются как статика, поэтому в проекте не появляется ни node_modules, ни шага сборки. Сервер отдаёт HTML - htmx подставляет его в нужный элемент страницы, Alpine.js отвечает за локальное состояние вроде раскрытого меню. Всё, что нужно от сервера: понять, что запрос пришёл от htmx, сформировать фрагмент разметки и выставить пару заголовков.

sequenceDiagram
  participant Browser as браузер
  participant Winow as winow
  participant View as winow-view

  Browser->>Winow: hx-post /задачи
  Winow->>View: запрос
  View-->>Browser: фрагмент HTML<br/>HX-Trigger: задачаДобавлена
Loading

Установка

opm install winow-view

Использование

Фрагмент на hx-запрос, страница на обычный переход

Один обработчик обслуживает оба случая - это основной приём htmx: ссылку можно открыть в новой вкладке, и она отдаст полноценную страницу.

#Использовать winow
#Использовать winow-view

Перем ViewRenderer;

&Контроллер("/задачи")
Процедура ПриСозданииОбъекта()
	ViewRenderer = View.СоздатьОтображения("шаблоны", Новый Структура("Макет", "макет"));
КонецПроцедуры

&ТочкаМаршрута("список")
Процедура Список(Запрос, Ответ) Экспорт

	Модель = Новый Структура("Задачи", ПрочитатьЗадачи());

	Ответ.УстановитьТипКонтента("html");

	Если View.ЭтоHxЗапрос(Запрос.Заголовки) Тогда
		Ответ.ТелоТекст = ViewRenderer.Фрагмент("задачи/список", Модель);
	Иначе
		Ответ.ТелоТекст = ViewRenderer.Страница("задачи/список", Модель);
	КонецЕсли;

КонецПроцедуры

Аннотаций HTTP-метода в winow нет: метод запроса приходит параметром МетодЗапроса, поэтому обработка формы живёт в той же точке маршрута.

&ТочкаМаршрута("список")
Процедура Список(Запрос, Ответ, МетодЗапроса) Экспорт

	Если МетодЗапроса = "POST" Тогда
		Задача = ДобавитьЗадачу(Запрос.ПараметрыИменные["название"]);

		Ответ.ТелоТекст = ViewRenderer.Фрагмент("задачи/строка", Задача);

		// Клиент узнает о добавлении и сам обновит счётчик
		View.УстановитьТриггер(Ответ.Заголовки, "задачаДобавлена",
			Новый Структура("всего", КоличествоЗадач()));

		Возврат;
	КонецЕсли;

	// ... вывод списка
КонецПроцедуры

Заголовки ответа возвращают то же соответствие, что получили, поэтому вызовы складываются в цепочку:

View.ПереопределитьЦель(View.ПереопределитьСпособЗамены(Ответ.Заголовки, "outerHTML"), "#ошибки");

Чтения заголовков нечувствительны к регистру имени - это не мелочь: HTTP/2 передаёт имена только в нижнем регистре, а прокси нормализуют их по-своему.

Рендеринг

Шаблоны - синтаксис JinjOS: {{ Модель.Поле }} подставляет значение, {% ... %} вставляет код OneScript.

шаблоны/задачи/список.html:

<ul id="задачи">
{% Для Каждого Задача Из Модель.Задачи Цикл %}
	<li>{{ Задача.Название }}</li>
{% КонецЦикла; %}
</ul>

шаблоны/макет.html - общая обёртка, содержимое встаёт на место @Контент:

<!doctype html>
<html>
<head>
	<title>{{ Модель.Заголовок }}</title>
	<!-- сюда подставятся теги htmx и Alpine.js -->
</head>
<body>@Контент</body>
</html>

Макет получает ту же модель, что и содержимое, поэтому заголовок страницы и данные пользователя доступны в нём напрямую.

ViewRenderer = View.СоздатьОтображения("шаблоны", Новый Структура("Макет", "макет"));

ViewRenderer.Фрагмент("задачи/список", Модель);                 // без макета
ViewRenderer.Страница("задачи/список", Модель);                 // в макете по умолчанию
ViewRenderer.Страница("задачи/список", Модель, "печать");       // в другом макете
ViewRenderer.ОтрендеритьТекст("<b>{{ Модель.Имя }}</b>", Модель);  // без файла

Расширение в имени можно опустить, подкаталоги разрешены. На время разработки отключите кэш, чтобы правки шаблонов подхватывались без перезапуска:

ViewRenderer = View.СоздатьОтображения("шаблоны", Новый Структура("КэшироватьШаблоны", Ложь));

Отсутствующий шаблон сообщает и имя, и путь, по которому его искали:

winow-view: шаблон задачи/строка не найден, искали файл C:\app\шаблоны\задачи\строка.html

Экранирование

Шаблонизатор подставляет значения как есть, поэтому всё, что пришло от пользователя, экранируется явно:

<li title="{{ Модель.Подсказка }}">{{ Модель.Название }}</li>
Модель = Новый Структура();
Модель.Вставить("Название", View.ЭкранироватьHtml(Задача.Название));
Модель.Вставить("Подсказка", View.ЭкранироватьАтрибут(Задача.Комментарий));

ЭкранироватьАтрибут дополнительно снимает обратную кавычку - в атрибуте без кавычек она работает ограничителем значения в некоторых браузерах. Значение атрибута всё равно берите в кавычки.

Статика: htmx и Alpine.js

Каталог с библиотеками достаточно зарегистрировать в winow как каталог статичных файлов, указав View.ПутьКСтатике(). Если файлы раздаёт сам обработчик:

&ТочкаМаршрута("static/{ИмяФайла}")
Процедура Библиотека(ИмяФайла, Ответ) Экспорт

	Ответ.УстановитьТипКонтента("js");
	Ответ.ТелоТекст = View.СодержимоеБиблиотеки(ИмяФайла);

КонецПроцедуры

Имя проверяется по закрытому списку: СодержимоеБиблиотеки не прочитает ничего, кроме двух поставляемых файлов, даже если в имени приедет ../.

Теги для макета готовит ТегиПодключения:

Модель.Вставить("Скрипты", View.ТегиПодключения("/static"));
<script src="/static/htmx.min.js"></script>
<script src="/static/alpine.min.js" defer></script>

Alpine.js подключается с defer не для скорости: без него библиотека инициализируется раньше, чем разобран документ, и не находит разметку.

Защита форм от CSRF

Токен не хранится на сервере: это метка времени и HMAC-SHA256 от секрета по паре «метка времени + идентификатор сессии». Поэтому схема переживает перезапуск приложения и работает при нескольких рабочих процессах без общего хранилища.

Защита = View.СоздатьЗащитуCSRF(ПолучитьПеременнуюСреды("APP_SECRET"));

// при выводе формы
Модель.Вставить("ПолеCSRF", Защита.ПолеФормы(Запрос.Сессия.Идентификатор()));
<form hx-post="/задачи/список">
	{{ Модель.ПолеCSRF }}
	<input name="название">
</form>
// при обработке
Токен = Запрос.ПараметрыИменные[Защита.ИмяПоля()];

Если Не Защита.ПроверитьТокен(Токен, Запрос.Сессия.Идентификатор()) Тогда
	Ответ.УстановитьСостояние(403);
	Возврат;
КонецЕсли;

Для запросов htmx токен удобно передавать заголовком сразу для всех форм страницы:

<body hx-headers='{"X-CSRF-Token": "{{ Модель.ТокенCSRF }}"}'>
Токен = Защита.ТокенИзЗаголовков(Запрос.Заголовки);

ПроверитьТокен не выбрасывает исключений: любое неожиданное значение - это просто непройденная проверка. Подписи сравниваются за постоянное время, поэтому по длительности проверки подпись не подобрать.

Публичный API

Модуль View

Заголовки запроса:

Метод Возвращает Заголовок
ЭтоHxЗапрос(Заголовки) Булево HX-Request
ЭтоHxBoosted(Заголовки) Булево HX-Boosted
ЦельHx(Заголовки) Строка HX-Target
ТриггерHx(Заголовки) Строка HX-Trigger
ИмяТриггераHx(Заголовки) Строка HX-Trigger-Name
ТекущийАдресHx(Заголовки) Строка HX-Current-URL
ЭтоЗапросИсторииHx(Заголовки) Булево HX-History-Restore-Request
ЗапросПодтвержденияHx(Заголовки) Строка HX-Prompt
ЗначениеЗаголовка(Заголовки, Имя, ЗначениеПоУмолчанию = "") Строка любой

Заголовки ответа - все возвращают то же Соответствие, что получили (Неопределено - создать новое):

Метод Заголовок
УстановитьТриггер(Заголовки, Имя, Данные = Неопределено) HX-Trigger
УстановитьТриггерПослеЗамены(Заголовки, Имя, Данные = Неопределено) HX-Trigger-After-Swap
УстановитьТриггерПослеУстановки(Заголовки, Имя, Данные = Неопределено) HX-Trigger-After-Settle
Перенаправить(Заголовки, Адрес) HX-Redirect
Обновить(Заголовки) HX-Refresh
ЗаменитьАдрес(Заголовки, Адрес) HX-Replace-Url
ПротолкнутьАдрес(Заголовки, Адрес) HX-Push-Url
ПереопределитьЦель(Заголовки, Селектор) HX-Retarget
ПереопределитьСпособЗамены(Заголовки, Способ) HX-Reswap
ПереопределитьВыборку(Заголовки, Селектор) HX-Reselect

Событие без данных отправляется простым именем (HX-Trigger: задачаДобавлена). Как только появляются данные или второе событие, заголовок становится объектом JSON: {"первое":null,"второе":{"всего":3}}.

Остальное:

Метод Возвращает Описание
ЭкранироватьHtml(Текст) Строка Экранирование для текста элемента
ЭкранироватьАтрибут(Текст) Строка Экранирование для значения атрибута
ПутьКСтатике() Строка Каталог с htmx и Alpine.js
ФайлБиблиотеки(Имя) Строка Путь к файлу библиотеки
СодержимоеБиблиотеки(Имя) Строка Текст файла библиотеки
ТегиПодключения(Префикс = "/static") Строка Теги <script>
ВерсииБиблиотек() Соответствие Версии htmx и Alpine.js
СоздатьОтображения(КаталогШаблонов, Параметры = Неопределено) ViewRenderer Объект рендеринга
СоздатьЗащитуCSRF(Секрет, СрокЖизни = 3600) CsrfGuard Защита форм

Класс ViewRenderer

Отрендерить(ИмяШаблона, Модель), Фрагмент(ИмяШаблона, Модель), Страница(ИмяШаблона, Модель, Макет = Неопределено), ОтрендеритьТекст(ТекстШаблона, Модель), Существует(ИмяШаблона), ПутьШаблона(ИмяШаблона), ОчиститьКэш(); свойства КаталогШаблонов, МакетПоУмолчанию.

Фрагмент - это Отрендерить под именем, читаемым в коде обработчика htmx: возвращается разметка без макета.

Параметр По умолчанию Описание
КэшироватьШаблоны Истина Хранить скомпилированные шаблоны в памяти
Расширение ".html" Дописывается к имени без расширения
Макет - Макет по умолчанию для Страница
ПлейсхолдерКонтента "@Контент" Место вставки содержимого в макет
ПутьКJinjOS - Путь к src/Классы/Шаблон.os пакета JinjOS

Класс CsrfGuard

СоздатьТокен(ИдентификаторСессии, МеткаВремени = Неопределено), ПроверитьТокен(Токен, ИдентификаторСессии, ТекущаяМетка = Неопределено), ПолеФормы(ИдентификаторСессии), ИмяПоля(), ИмяЗаголовка(), ТокенИзЗаголовков(Заголовки); свойство СрокЖизниТокена.

Явная метка времени делает выдачу и проверку воспроизводимыми - это нужно в тестах и при разборе инцидентов.

Поставляемые библиотеки

Библиотека Версия Лицензия Источник
htmx 2.0.10 0BSD unpkg.com/htmx.org@2.0.10/dist/htmx.min.js
Alpine.js 3.15.12 MIT cdn.jsdelivr.net/npm/alpinejs@3.15.12/dist/cdn.min.js

Файлы лежат в src/static без изменений, ровно как их отдаёт CDN. Версия проверяется тестом: объявленная в ВерсииБиблиотек() должна совпадать с версией внутри файла, иначе README и то, что реально уходит браузеру, разъедутся.

Обе лицензии разрешают распространение в составе пакета; 0BSD не требует даже сохранения текста лицензии.

Особенности

Синтаксис JinjOS строже, чем кажется. Внутри {% %} операторы завершаются точкой с запятой ({% КонецЦикла; %}), выражение в {{ }} не может содержать }, а блок {% %} - знак процента. Плейсхолдер макета не должен выглядеть как подстановка: {{{контент}}} шаблонизатор попытается вычислить.

Имя класса Шаблон в экосистеме занято дважды - в JinjOS и в winow, - и достаётся библиотеке, подключённой первой. В приложении с #Использовать winow выражение Новый Шаблон(...) вернёт класс winow, рассчитанный на внедрение зависимостей контейнером autumn. Поэтому пакет не полагается на глобальное имя: файл шаблонизатора подключается напрямую и регистрируется под своим. Если JinjOS установлен нестандартно, укажите путь к нему параметром ПутьКJinjOS.

Имя шаблона и имя библиотеки нередко приходят из запроса. Имена с .., двоеточием и ведущим слешем отвергаются, файлы статики ограничены закрытым списком: через них нельзя прочитать произвольный файл на диске.

Секрет CSRF задаётся извне. Один и тот же у всех рабочих процессов, разный в разных окружениях, в исходный код не попадает. Токен привязан к сессии, поэтому сессии нужны включёнными.

Тесты

opm install -l
oneunit execute -d ./tests

Лицензия

MIT

About

Фронт для winow без сборщика JS: htmx, Alpine.js, фрагменты JinjOS, CSRF

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages