惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
人人都是产品经理
人人都是产品经理
Cisco Talos Blog
Cisco Talos Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
V
V2EX
博客园 - 三生石上(FineUI控件)
Martin Fowler
Martin Fowler
WordPress大学
WordPress大学
D
Docker
S
SegmentFault 最新的问题
博客园 - 聂微东
美团技术团队
Apple Machine Learning Research
Apple Machine Learning Research
月光博客
月光博客
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Last Week in AI
Last Week in AI
M
MIT News - Artificial intelligence
F
Fortinet All Blogs
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
The GitHub Blog
The GitHub Blog
GbyAI
GbyAI
L
LangChain Blog
Vercel News
Vercel News
博客园 - 叶小钗
MongoDB | Blog
MongoDB | Blog
Stack Overflow Blog
Stack Overflow Blog
H
Help Net Security
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
The Cloudflare Blog
Engineering at Meta
Engineering at Meta
T
Threat Research - Cisco Blogs
T
Threatpost
Scott Helme
Scott Helme
T
Tailwind CSS Blog
Latest news
Latest news
Stack Overflow Blog
Stack Overflow Blog
Blog — PlanetScale
Blog — PlanetScale
The Register - Security
The Register - Security
罗磊的独立博客
P
Proofpoint News Feed
腾讯CDC
S
Schneier on Security
雷峰网
雷峰网
A
About on SuperTechFans
T
Tenable Blog
F
Full Disclosure
Cyberwarzone
Cyberwarzone
博客园_首页
有赞技术团队
有赞技术团队
K
Kaspersky official blog

Все публикации подряд на Хабре

AI для PHP-разработчиков. Часть 7: Экосистема AI-агентов в PHP – от простых вызовов OpenAI до мультиагентных платформ Clean Agile: что стоит знать об Agile каждому руководителю проекта Динамические квоты и лимиты: как не завалить очередь в highload Критерии выживания и случайность — 5 Удалить фон, заменить лицо и убрать лишнее с фото: разбор лучших ИИ редакторов 2026 года Другая сторона медали Парсил zakupki.gov.ru без API — расскажу что узнал Виртуальный кулак. О боевых искусствах в играх Рентген в машине: правда или вымысел? Эволюция транзисторных архитектур чипов и переход к обратной подаче питания Промты для ИИ фотосессии в 2026: анатомия рабочего промпта и 20 готовых примеров для Nano Banana, FLUX 2 и ChatGPT Как устроена любая игра изнутри Как мы интегрировали AI агентов с T-FLEX: отказ от абстракций и самопроверка моделей Почему ваш Parallel.ForEach впустую сжигает CPU — ускоряем обработку данных до 600+ раз Искусственный интеллект в образовании: цифровые профили, аватары и персональные траектории Welder AI: виральные Shorts, Reels и TikTok на автопилоте — без лица, камеры и монтажа: С 0 до 1 000 000 просмотров OpenBSD 7.9: поддержка Wi-Fi 6, USB4 и 255 ядер. Основные изменения в ОС Командная разработка на 1С через EDT и Git: пошаговая настройка проекта Дешёвая электрогитара Rockdale Stars HT HSS От favicon до криптографии: как мы уместили 167 рабочих инструментов в одном сервисе Как создать видео из фото нейросетью в 2026: обзор моделей image-to-video и сервисов с доступом из России Zero Trust для AI-агентов: как безопасно давать LLM доступ к инструментам, данным и действиям А-12 и его родственники Как построить эпюры Q и M в многопролётной балке: следующий шаг после построения линий влияния Q и М Разбираемся в ML без воды: от базы до Attention. Часть 7: SVM и SGD Как DPI вычисляет MTProto-прокси: технический разбор детекции протоколов по сигнатурам 800 серверов, четыре названия, два брата: как Stark Industries уходил от санкций ЕС Ваш PostgreSQL болеет молча. Десяток запросов, чтобы это увидеть «Ах, как хочется вернуться… в альма-матер»: почему успешные предприниматели, бросившие занятия, решают (до)учиться Новый конкурент The Sims, демо-версия Requiem, высокие оценки LEGO Batman: Дайджест игровых новостей на 30 мая Как я заставил AI-агента писать нормальный код на Spring Обработка фото нейросетью в 2026 году: какой ИИ редактор фото выбрать под улучшение качества, реставрацию и ретушь Opus 4.8: что Anthropic дал в этом релизе и зачем это всё Я сделал Vite-плагин, который сохраняет изменения CSS прямо в исходники Самодельный контроллер для гоночного руля «Формулы-1» на базе Raspberry Pi Telegram Mini App для ресторанов: бронирования, IIKO, CRM, Grafana и Telegram API в одной системе Волшебство естественного языка и практическое применение Почему не взлетели дирижабли? Часть 23: ОКБВ, атомные мечты и проекты позднесоветской эпохи Скрытые издержки гемблинга и 18+ проектов Золото в вашем смартфоне и ноутбуке. Или про современный урбан майнинг Пять мини-ПК мая 2026 года: Panther Lake, RTX 5080 и поддержка внешних видеокарт Ralph Wiggum простыми словами: цикл в Claude Code, который не останавливается Агентные фреймворки: обещали революцию, что осталось в 2026 Самые ожидаемые эксклюзивы PlayStation в 2026–27 гг Возрождение классической игры для Unix: 20-летний процесс археологии ПО Дешёвая модерация анонимной стены: 3-слойный каскад и ROT13-джейлбрейк в проде Как СССР научил Голливуд снимать космос Как я собрал LLM-печку на 4 GPU, и на что она способна Как один зажёванный лист в принтере Xerox привел к созданию GNU Linux и всей философии Open Source Как систематизировать бизнес без бюрократии: рабочая схема для малого и среднего бизнеса Почему б / у или поддержанный ThinkPad порой лучше чем любой игровой ноут для программиста Как я стал Middle Python Developer к 22 годам и зачем пошёл учить C++ «Китайская угроза» или новые партнеры? Регистрация товарного знака для товаров из КНР под российскими брендами Как безопасно проводить сделки в USDT — опыт EscroWallet.io FIRE: когда Цифра становится ответом на вопрос, который человек не может себе задать Написание телеграм бота для проверки паролей по кибербезопасности(или же их генерация) Вайбаналитика: как я учил LLM описывать бизнес-процессы, а не имитировать их Нейросеть для ИИ-обложки: ТОП-12 ИИ моделей как быстро сделать картинку для статьи, поста или превью в 2026 году А есть ли бесплатные API нейросетей? Как Я сделал Своего Бота Телеграм Для Сканирования Портов И IP адресов Сколько стоит войти в IT в 2026 НЕТОЛОГИЯ (NETOLOGY) промокоды июнь 2026: промокод Нетология скидка 5% на все курсы Как руководителю работать с сотрудниками с РАС HackTheBox. Прохождение Mini Pro Lab Unintended Как создать видео ИИ через нейросеть: ТОП-15 моделей ИИ для видео Шасси Cisco на прокачку: как мой товарищ ударился в DIY Как сгенерировать видео для рекламного ролика: ТОП-3 лучших ИИ, которые помогут создать видео рекламу в 2026 году Требует ли мышление наличия чувств и сенсорики? От чистых мыслителей к большим языковым моделям Механика добровольной ликвидации ООО: как закрыть бизнес без перехода в банкротство и субсидиарную ответственность Ограничения вопросов на собеседовании # Bare-metal Kubernetes на 5 VM: Calico IPIP + MetalLB + GitOps — честный опыт с граблями Что мы можем получить, отказавшись от бесконечности? IT очищается от случайных людей. И это хорошо ёPRSTCON: как я навайбил конфу Как мы ускорили расчёт факторов ранжирования в поиске Ozon с помощью динамической компиляции Последний мейнтейнер Полноценный гайд по CLAUDE CODE для новичка. Обучение по CLAUDE CODE с нуля Когда реклама — искусство на стыке форм. Три истории о том, как консоли продавали через угрозы, сюрреализм и метафоры Вы не умеете интервьюировать Сколько стоит потерянный клиент: как посчитать потери из-за пропущенных обращений Удобная компоновка (в расте) Более 50 лет назад выдвинули гипотезу о Языке Мышления. Мы досконально разобрались с ней – и вам советуем. Это лучше ИИ Моделирование угроз для тех, у кого лапки (и ручки) Как избежать 7 критических ошибок при переходе на микросервисы UAV Human Detector Как я полгода вайбкодил ИИ-платформу для создания контента Почему GearUP Booster быстрее VPN и как вообще работают подобные игровые бустеры Programmatic-аукцион на пальцах: как работают RTB и Header Bidding, и где паблишер теряет деньги Автоскейлинг StarRocks в Kubernetes: как я довел его до предела Диванный инвестор #3. +88% годовых на бектесте AI-интегратор: профессия, которой нет в учебнике — я собрал её руками на n8n Подготовка к ЕГЭ и ОГЭ через нейросеть — как школьники используют Kampus AI для подготовки к экзаменам Альтман против Паркинсона Что SVG-пеликаны говорят о способностях ИИ-моделей? Вайб-кодинг здорового человека: как мы научили ИИ писать код по нашим правилам Нетипичные методы карьерного развития или как переоткрыть себя На чем учить детей робототехнике в 2026 году? Пишем свой веб-симулятор на замену Tinkercad Оптимизация сетевой обработки в высоконагруженных системах Мы настроили динамические окружения на ArgoCD под каждую фичу Видеонаблюдение на базе Android устройства за 3 недели
Пишем движок для блога на Rust
Ertanic · 2026-05-31 · via Все публикации подряд на Хабре

Недавно я решил завести собственный блог. Сначала посмотрел в сторону SSG, но они показались мне не слишком удобными для того сценария, который я хотел получить. Затем попробовал несколько CMS, однако быстро упёрся в другую проблему: мой сервер оказался слишком слабым для большинства современных решений.

В итоге ни одно из готовых решений так и не смогло закрыть все мои требования одновременно. Так и появилась идея сделать небольшую файловую CMS на Rust, которая не требует базы данных, не потребляет много памяти и при этом остаётся достаточно гибкой для повседневного использования.

Со временем идея небольшого блогового движка разрослась в полноценную CMS с SSR, виртуальной файловой системой, поддержкой локализации, визуальным редактором статей и горячей перезагрузкой контента. В этой статье я постараюсь показать, как всё это устроено изнутри.

Как это будет работать

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

А чтобы переопределять темы и страницы, будем использовать механизм затемнения файлов. Для этого будем использовать VFS. К сожалению, я не нашёл ничего работающего так, как мне того хочется, поэтому напишем обёртку над уже существующим крейтом для VFS.

Плюс к этому какую-никакую расширяемость функционала редактора статей за счёт компонентов. Сначала я думал о том, чтобы сделать систему компонентов за счёт хуков и подгрузку js-скриптов, но потом подумал, а почему бы не попробовать добиться декларативного описания компонентов, что поможет нам добиться SSR. Поэтому все компоненты будем описывать в специальных файликах формата KDL.

К сожалению, ни один высокоуровневый HTTP-фреймворк не позволяет налету менять маршрутизацию, то будем реализовывать самостоятельно свою систему роутинга запросов. Для этого возьмём какой-нибудь Hyper, чтобы ручками не пришлось обрабатывать HTTP-фреймы, и matchit для роутинга.

VFS

Предлагаю начать с ядра нашего сервера, а именно с файловой системы. В дебаг-режиме будет использоваться реальная файловая система как один из слоёв VFS. В релизной же версии будем встраивать директорию в бинарник нашего сервера с помощью крейта rust-embed и затемнять файлы из этой директории из физической файловой системы.

pub type VirtualFS = Arc<AsyncOverlayFS>;

// релизная версия
#[cfg(not(debug_assertions))]
pub async fn init_vfs(root: &Path) -> VirtualFS {
    tokio::fs::create_dir_all(root).await.expect("unable to create vfs dir");
    let embed_fs = AsyncEmbedFS::new();
    let physical_fs = AsyncPhysicalFS::new(root);
    Arc::new(AsyncOverlayFS::new(&[AsyncVfsPath::new(physical_fs), AsyncVfsPath::new(embed_fs)]))
}

// дебаг версия
#[cfg(debug_assertions)]
pub async fn init_vfs(root: &Path) -> VirtualFS {
    let target_content = Path::new(&env::current_exe().unwrap().parent().unwrap()).join("content");
    tokio::fs::create_dir_all(&target_content)
        .await
        .expect("unable to create target content dir");

    let target_content = AsyncPhysicalFS::new(target_content);
    let embed_content = AsyncPhysicalFS::new(root);
    Arc::new(AsyncOverlayFS::new(&[
        AsyncVfsPath::new(target_content),
        AsyncVfsPath::new(embed_content),
    ]))
}

На счёт AsyncPhysicalFS можете почитать в документации крейта, а мы пока реализуем AsyncEmbedFS. Чтобы встроить директорию в бинарник, нужно создать структуру с указанием нужной директории в макросе. Макрос реализует итератор по этой директории.

Однако итератор не даёт никаких метаданных или разделения на папки-файлы, поэтому придётся ручками определять это всё при инициализации файловой AsyncEmbedFS. Чтобы в векторе не хранить нашу файловую систему, будем использовать дерево из крейта tree_ds.

#[cfg(not(debug_assertions))]
#[derive(Embed)]
#[folder = "../content"]
struct Content;

#[cfg(not(debug_assertions))]
#[derive(Clone, PartialEq, Eq, Debug)]
struct DirEntry {
    path: String,
    is_dir: bool,
}

#[cfg(not(debug_assertions))]
#[derive(Debug)]
struct AsyncEmbedFS {
    tree: FilesTree,
}

#[cfg(not(debug_assertions))]
impl AsyncEmbedFS {
    pub fn new() -> Self {
        let mut tree = FilesTree::new();

        for file in Content::iter() {
            tree.add_file(file.as_ref()).expect("failed to add file to tree");
        }

        Self { tree }
    }
}

#[cfg(not(debug_assertions))]
#[async_trait::async_trait]
impl AsyncFileSystem for AsyncEmbedFS {
    async fn read_dir(&self, path: &str) -> VfsResult<Box<dyn Unpin + Stream<Item = String> + Send>> {
        let dir = path.trim_matches('/').to_owned();

        if dir == ".whiteout" {
            return Ok(Box::new(stream::empty()));
        }

        let Some(file) = self.tree.get_meta(dir.clone())
        else {
            return Err(VfsErrorKind::FileNotFound.into());
        };

        if !file.is_dir {
            return Err(VfsErrorKind::Other("not a directory".to_string()).into());
        }

        if let Some(entries) = self.tree.read_dir(dir.clone()) {
            Ok(Box::new(stream::iter(entries.into_iter().map(|e| e.path))))
        }
        else {
            Err(VfsError::from(VfsErrorKind::Other("not a directory".to_string())))
        }
    }

    async fn create_dir(&self, _path: &str) -> VfsResult<()> {
        unimplemented!("embed fs does not support creating directories")
    }

    async fn open_file(&self, path: &str) -> VfsResult<Box<dyn SeekAndRead + Send + Unpin>> {
        let meta = self.tree.get_meta(path.trim_matches('/').to_owned()).ok_or(VfsErrorKind::FileNotFound)?;

        let content = Content::get(&meta.path)
            .map(|c| c.data.to_vec())
            .ok_or(VfsError::from(VfsErrorKind::FileNotFound))?;

        let cursor = Cursor::new(content);
        let buf = BufReader::new(cursor);
        Ok(Box::new(buf))
    }

    async fn create_file(&self, _path: &str) -> VfsResult<Box<dyn AsyncWrite + Send + Unpin>> {
        unimplemented!("embed fs does not support creating files")
    }

    async fn append_file(&self, _path: &str) -> VfsResult<Box<dyn AsyncWrite + Send + Unpin>> {
        unimplemented!("embed fs does not support appending files")
    }

    async fn metadata(&self, path: &str) -> VfsResult<VfsMetadata> {
        let path = path.trim_matches('/').to_owned();
        match path.as_str() {
            // .whiteout - что-то типа мусорки, AsyncOverlayFS переодически делает запросы к ней, 
            // но у нас в дереве нет его, поэтому отдельно обрабатывавем её.
            ".whiteout" => Ok(VfsMetadata {
                file_type: VfsFileType::Directory,
                len: 0,
                created: None,
                modified: None,
                accessed: None,
            }),
            _ => {
                let meta = self.tree.get_meta(path.clone()).ok_or(VfsError::from(VfsErrorKind::FileNotFound))?;

                if meta.is_dir {
                    Ok(VfsMetadata {
                        file_type: VfsFileType::Directory,
                        len: 0,
                        created: None,
                        modified: None,
                        accessed: None,
                    })
                }
                else {
                    Content::get(&meta.path)
                        .map(|c| {
                            let last_modified = c
                                .metadata
                                .last_modified()
                                .map(|t| UNIX_EPOCH.checked_add(Duration::from_secs(t)).unwrap());

                            VfsMetadata {
                                file_type: if meta.is_dir { VfsFileType::Directory } else { VfsFileType::File },
                                len: c.data.len() as u64,
                                created: c.metadata.created().map(|t| UNIX_EPOCH.checked_add(Duration::from_secs(t)).unwrap()),
                                modified: last_modified,
                                accessed: last_modified,
                            }
                        })
                        .ok_or(VfsError::from(VfsErrorKind::FileNotFound))
                }
            }
        }
    }

    async fn exists(&self, path: &str) -> VfsResult<bool> {
        let path = path.trim_matches('/');
        match path {
            "" | ".whiteout" => Ok(true),
            &_ => {
                let Some(entry) = self.tree.get_meta(path.into())
                else {
                    return Ok(false);
                };

                if entry.is_dir {
                    Ok(true)
                }
                else {
                    Ok(Content::get(&entry.path).is_some())
                }
            }
        }
    }

    async fn remove_file(&self, _path: &str) -> VfsResult<()> {
        unimplemented!("embed fs does not support removing files")
    }

    async fn remove_dir(&self, _path: &str) -> VfsResult<()> {
        unimplemented!("embed fs does not support removing directories")
    }
}

#[cfg(not(debug_assertions))]
#[derive(Debug)]
struct FilesTree(Tree<String, DirEntry>);

#[cfg(not(debug_assertions))]
impl FilesTree {
    pub fn new() -> Self {
        let mut tree = Tree::new(None);
        let root = Node::new(
            "".to_owned(),
            Some(DirEntry {
                path: "".to_owned(),
                is_dir: true,
            }),
        );
        tree.add_node(root, None).expect("failed to add root node");
        Self(tree)
    }

    pub fn add_file(&mut self, path: &str) -> tree_ds::prelude::Result<()> {
        let components = path.split('/').collect::<Vec<_>>();

        let mut stack = vec![self.0.get_root_node().unwrap()];
        for (i, _) in components[..components.len()].iter().enumerate() {
            let id = components[..i].join("/");
            if id.is_empty() {
                continue;
            }

            let parent = stack.last().unwrap();
            let node = Node::new(id.clone(), Some(DirEntry { path: id, is_dir: true }));

            self.0.add_node(node.clone(), Some(&parent.get_node_id()?))?;
            stack.push(node);
        }

        let id = components.join("/");
        let node = Node::new(id.clone(), Some(DirEntry { path: id, is_dir: false }));

        let parent = stack.pop().unwrap();
        self.0.add_node(node, Some(&parent.get_node_id()?))?;

        Ok(())
    }

    pub fn get_meta(&self, path: String) -> Option<DirEntry> {
        self.0.get_node_by_id(&path)?.get_value().ok()?
    }

    pub fn read_dir(&self, path: String) -> Option<Vec<DirEntry>> {
        Some(
            self.0
                .get_node_by_id(&path)?
                .get_children_ids()
                .ok()?
                .into_iter()
                .filter_map(|id| self.0.get_node_by_id(&id)?.get_value().ok()?)
                .collect::<Vec<_>>(),
        )
    }
}

Чтобы было удобно взаимодействовать с путями, предлагаю реализовать структуру VfsPath, которая будет дерефаться в строку, когда нужно передавать его в какой-нибудь метод AsyncFileSystem.

#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct VfsPath(String);

impl VfsPath {
    pub fn new(path: impl ToString) -> Self {
        let path = path.to_string();
        let normalized = path.trim_matches('/');
        if !normalized.is_empty() {
            Self("/".to_owned() + normalized)
        }
        else {
            Self(normalized.to_owned())
        }
    }

    pub fn strip_prefix(&self, prefix: VfsPath) -> Option<Self> {
        self.0.strip_prefix(&*prefix).map(|prefix| Self(prefix.to_owned()))
    }

    pub fn starts_with(&self, start: &VfsPath) -> bool {
        self.0.starts_with(&**start)
    }

    pub fn filename(&self) -> String {
        self.0.split('/').next_back().unwrap().to_owned()
    }

    pub fn parent(&self) -> Option<Self> {
        let components = self.0.split('/').collect::<Vec<_>>();
        let count = components.len() - 1;
        Some(Self::new(components.into_iter().take(count).collect::<Vec<_>>().join("/")))
    }

    pub fn is_root(&self) -> bool {
        self.0.is_empty() || self.0 == "/"
    }

    pub fn join(&self, path: impl Into<VfsPath>) -> Self {
        let path = path.into();
        if path.is_root() {
            return self.clone();
        }
        if path.0.starts_with('/') {
            Self(self.0.clone() + &path)
        }
        else {
            Self(self.0.clone() + "/" + &path)
        }
    }

    // as_string because to_string already exists in Display trait
    pub fn as_string(self) -> String {
        self.0
    }
}

impl From<String> for VfsPath {
    fn from(value: String) -> Self {
        Self(value)
    }
}

impl From<&str> for VfsPath {
    fn from(value: &str) -> Self {
        Self(value.to_string())
    }
}

impl<const N: usize> From<[VfsPath; N]> for VfsPath {
    fn from(value: [VfsPath; N]) -> Self {
        Self(
            value
                .into_iter()
                .fold(String::new(), |acc, elem| if acc.is_empty() { elem.0 } else { acc + "/" + &elem }),
        )
    }
}

impl Deref for VfsPath {
    type Target = str;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

impl DerefMut for VfsPath {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.0
    }
}

impl Display for VfsPath {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl AsRef<Path> for VfsPath {
    fn as_ref(&self) -> &Path {
        self.0.as_ref()
    }
}

impl Default for VfsPath {
    fn default() -> Self {
        Self::new("")
    }
}

Даже так мы не будем напрямую работать с VFS, сделаем прослойку в виде ResourceManager, который будет выполнять всю работу по записе или чтению нужной информации из файловой системы. Пока он не такой уж большой, но по мере увеличения функционала сервера будем добавлять новые функции сюда.

pub struct ResourceManager {
    vfs: VirtualFS,
}

impl ResourceManager {
    pub fn new(vfs: VirtualFS) -> Self {
        Self { vfs }
    }
}

Сервер

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

У вас может возникнуть вопрос, что за Bulldozer? Об этом поговорим далее. А пока расскажу, что происходит. Тут мы делаем что-то типа Builder-а, но я думал поменять это всё на метод build, в котором блочим все ресурсы сервера и конфигурируем его как нужно, чтобы вызывать единожды .await вместо того, чтобы прописывать его после каждого вызова методов.

Помимо прочего мы тут сразу реализуем поддержку HTTPS, используя крейт tokio-rustls. В остальном тут ничего особо интересного, поэтому предлагаю двинуться дальше.

Вы можете заметить структуру SecurityContext в свойствах Server. Это структура-модель конфига, о котором поговорим в конце.

pub type ArcBulldozer = Arc<Bulldozer>;

pub struct Server {
    bulldozer: ArcBulldozer,
    addr: String,
    port: Option<u16>,
    sec: Option<SecurityContext>,
    root: PathBuf,
}

impl Server {
    pub fn new(root: PathBuf, addr: String, port: Option<u16>, app_context: Arc<AppContext>) -> Self {
        let bulldozer = Arc::new(Bulldozer::new(Arc::clone(&app_context)));
        Self {
            bulldozer,
            sec: None,
            root,
            addr,
            port,
        }
    }

    pub async fn routes(&mut self, builder: impl FnOnce(&mut MethodRouter)) -> &mut Self {
        self.bulldozer.routes(builder).await;
        self
    }

    pub async fn load_public(&mut self) -> &mut Self {
        self.bulldozer.load_public().await;
        self
    }

    pub async fn load_pages(&mut self) -> &mut Self {
        self.bulldozer.load_pages().await;
        self
    }

    pub async fn add_hook(&mut self, hook: impl Fn(&HookContext) -> Option<Response> + 'static + Send + Sync + Clone) -> &mut Self {
        self.bulldozer.add_hook(Box::new(hook)).await;
        self
    }

    pub fn set_security(&mut self, sec: Option<SecurityContext>) -> &mut Self {
        self.sec = sec;
        self
    }

    pub async fn serve(&self) {
        let port = self
            .port
            .unwrap_or(if self.sec.is_some() { DEFAULT_HTTPS_PORT } else { DEFAULT_HTTP_PORT });
        let addr: SocketAddr = format!("{}:{port}", self.addr).parse().expect("invalid address");

        let listener = TcpListener::bind(addr).await.expect("failed to bind");

        if let Some(sec) = &self.sec {
            let certs_folder = self.root.join(CERTS_FOLDER);
            let cert_path = if sec.tls.cert.is_absolute() {
                sec.tls.cert.clone()
            }
            else {
                certs_folder.join(&sec.tls.cert)
            };

            let cert_key_path = if sec.tls.key.is_absolute() {
                sec.tls.key.clone()
            }
            else {
                certs_folder.join(&sec.tls.key)
            };

            let cert = CertificateDer::pem_file_iter(cert_path)
                .expect("unable to load certificate")
                .collect::<Result<Vec<_>, _>>()
                .expect("unable to parse cert");

            let key = PrivateKeyDer::from_pem_file(cert_key_path).expect("unable to parse private key");

            let tls_config = ServerConfig::builder().with_no_client_auth().with_single_cert(cert, key).unwrap();
            let tls_acceptor = TlsAcceptor::from(Arc::new(tls_config));

            info!("TLS is enabled");
            info!("listening on https://{}", addr);

            loop {
                let tcp_stream = match listener.accept().await {
                    Ok((stream, _)) => stream,
                    Err(err) => {
                        error!("failed to accept client: {err:#}");
                        continue;
                    }
                };

                let tls_acceptor = tls_acceptor.clone();
                let bulldozer = self.bulldozer.clone();

                tokio::task::spawn(async move {
                    let tls_stream = match tls_acceptor.accept(tcp_stream).await {
                        Ok(tls_stream) => tls_stream,
                        Err(err) => {
                            error!("failed to perform tls handshake: {err:#}");
                            return;
                        }
                    };

                    if let Err(err) = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new())
                        .serve_connection(TokioIo::new(tls_stream), bulldozer)
                        .await
                    {
                        error!("error serving connection: {}", err);
                    }
                });
            }
        }
        else {
            info!("listening on http://{}", addr);
            loop {
                let (tcp_stream, _) = listener.accept().await.expect("failed to accept client");
                let io = TokioIo::new(tcp_stream);

                let bulldozer = self.bulldozer.clone();

                tokio::task::spawn(async move {
                    if let Err(err) = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new())
                        .serve_connection(io, bulldozer)
                        .await
                    {
                        eprintln!("Error serving connection: {}", err);
                    }
                });
            }
        }
    }
}

Сертификаты

Так как мы работаем сразу с HTTPS, можем сгенерировать самоподписанные сертификаты. Для генерации сертификатов напишу небольшой PowerShell скриптик. Скрипт будет генерировать все сертификаты на 100 лет в папку certs/.

$SUBJ = "/C=RU/ST=Test/L=Test/O=Selecit/OU=/CN=localhost/emailAddress="
$CERTS_DIR = "certs"

if (-Not (Test-Path -Path $CERTS_DIR)) {
    New-Item -ItemType Directory -Path $CERTS_DIR
}

# CA
openssl req -x509 -newkey rsa:4096 -days 36500 -keyout $CERTS_DIR/ca-key.pem -out $CERTS_DIR/ca-cert.pem -nodes -subj $SUBJ

# CSR
openssl req -newkey rsa:4096 -keyout $CERTS_DIR/server-key.pem -out $CERTS_DIR/server-req.pem -subj $SUBJ -nodes

# server cert
openssl x509 -req -in $CERTS_DIR/server-req.pem -CA $CERTS_DIR/ca-cert.pem -CAkey $CERTS_DIR/ca-key.pem -CAcreateserial -out $CERTS_DIR/server-cert.pem -extfile localhost.ext

Чтобы openssl знал альтернативные имена сервера, создадим файл localhost.ext.

authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
subjectAltName = @alt_names

[alt_names]
DNS.1 = localhost
IP.1 = 127.0.0.1

Обработчик запросов

Hyper на вход принимает сервис, реализующий трейт Service, который будет обрабатывать входящие запросы. Вроде как, можно использовать какой-нибудь Tower с готовыми сервисами или написать свой, используя service_fn.

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

pub struct Bulldozer {
    // router: Arc<RwLock<MethodRouter>>,
    config: ArcConfig,
    vfs: VirtualFS,
    resources: Arc<RwLock<ResourceManager>>,
    hooks: Arc<RwLock<Vec<RequestHook>>>,
    lang_manager: LangManager,
}

impl Bulldozer {
    pub fn new(ctx: Arc<AppContext>) -> Self {
        Self {
            lang_manager: ctx.lang_manager.clone(),
            router: ctx.router.clone().clone(),
            config: ctx.config.clone(),
            vfs: ctx.vfs.clone(),
            resources: ctx.resources.clone(),
            hooks: Default::default(),
        }
    }

    pub async fn routes(&self, builder: impl FnOnce(&mut MethodRouter)) {
        builder(&mut *self.router.write().await);
    }

    pub async fn load_public(&self) {
        self.resources.read().await.load_public(&mut *self.router.write().await).await
    }

    pub async fn load_pages(&self) {
        self.resources.read().await.load_pages(&mut *self.router.write().await).await
    }

    pub async fn add_hook(&self, hook: RequestHook) {
        self.hooks.write().await.push(hook);
    }
}

impl Service<Request<Incoming>> for Bulldozer {
    type Response = Response;
    type Error = std::io::Error;
    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;

    fn call(&self, req: Request<Incoming>) -> Self::Future {
        trace!(
            "getting request {} \"{}\"",
            req.method(),
            req.uri().path_and_query().map(|a| a.as_str()).unwrap_or_else(|| req.uri().path())
        );

        let hooks = Arc::clone(&self.hooks);
        let lang_manager = self.lang_manager.clone();
        let resources = Arc::clone(&self.resources);
        let vfs = Arc::clone(&self.vfs);
        // let router = Arc::clone(&self.router);
        let config = Arc::clone(&self.config);
        let method = req.method().clone();
        let uri = req.uri();
        let path = uri.path().to_owned();
        let query = uri
            .query()
            .map(|s| {
                UrlEncodedData::parse_str(s)
                    .iter()
                    .map(|p| (p.0.to_string(), p.1.to_string()))
                    .collect::<HashMap<String, String>>()
            })
            .unwrap_or_default();

        let result = async move {
          // ...
        };

        Box::pin(result)
    }
}

Наш сервис будет хранить и обрабатывать несколько разных подсервисов, назовём их так. Представлять они будут простое перечисление ResourceRefType. Page отличается от File тем, что первый будет содержать шаблоны, а второй пути до файлов в папке content/public/. А Contentбудет просто отдавать статический контент, который мы ручками можем прописать. Про Api, наверное, нет смысла разглагольствовать, и так понятно, что это будут эндпоинты с некоторой логикой.

type ApiCallback = Box<dyn Fn(ApiContext) -> BoxFuture<'static, Response> + Sync + Send>;

pub struct ApiContext {
    pub params: HashMap<String, String>,
    pub request: Request<Incoming>,
    pub query: HashMap<String, String>,
    pub cookies: HashMap<String, String>,
    pub config: ArcConfig,
    pub resources: Arc<RwLock<ResourceManager>>,
    pub jwt: Option<JwtPayload>,
}

pub enum ResourceRefType {
    File(VfsPath),
    Content(Bytes),
    Page { index: VfsPath, layouts: Vec<PageLayout> },
    Api(ApiCallback),
}

Я бы предложил написать обработку и роутинг запросов, но у нас ещё нет роутера, чтобы это делать, поэтому давайте с него начнём. В силу того, что роутер не различает типы запросов (GET, POST и т.д.), придётся написать небольшую обёртку в виде MethodRouter, который будет просто распихивать маршруты по нужным роутерам.

pub fn get(path: &str) -> MethodRoute<'_> {
    MethodRoute::new(Method::GET, path)
}

pub fn post(path: &str) -> MethodRoute<'_> {
    MethodRoute::new(Method::POST, path)
}

pub fn delete(path: &str) -> MethodRoute<'_> {
    MethodRoute::new(Method::DELETE, path)
}

pub fn patch(path: &str) -> MethodRoute<'_> {
    MethodRoute::new(Method::PATCH, path)
}

#[derive(Debug)]
pub struct MethodRoute<'a> {
    method: Method, // enum из Hyper
    path: &'a str,
}

impl<'a> MethodRoute<'a> {
    pub fn new(method: Method, path: &'a str) -> Self {
        Self { method, path }
    }
}

#[derive(Default)]
pub struct MethodRouter {
    get: Router<ResourceRefType>,
    post: Router<ResourceRefType>,
    delete: Router<ResourceRefType>,
    patch: Router<ResourceRefType>,
}

impl MethodRouter {
    const INSERT_ERROR: &'static str = "failed to insert route";
    pub fn add(&mut self, route: MethodRoute, resource: ResourceRefType) -> &mut Self {
        self.try_add(route, resource).expect(Self::INSERT_ERROR);
        self
    }

    pub fn try_add(&mut self, route: MethodRoute, resource: ResourceRefType) -> Result<(), InsertError> {
        match route.method {
            Method::GET => self.get.insert(route.path, resource),
            Method::POST => self.post.insert(route.path, resource),
            Method::DELETE => self.delete.insert(route.path, resource),
            Method::PATCH => self.patch.insert(route.path, resource),
            _ => Err(InsertError::Conflict {
                with: format!("{} method not supported", route.method),
            }),
        }
    }

    pub fn at<'a>(&'a self, route: MethodRoute<'a>) -> Result<Match<'a, 'a, &'a ResourceRefType>, matchit::MatchError> {
        match route.method {
            Method::GET => self.get.at(route.path),
            Method::POST => self.post.at(route.path),
            Method::DELETE => self.delete.at(route.path),
            Method::PATCH => self.patch.at(route.path),
            _ => Err(matchit::MatchError::NotFound),
        }
    }

    pub fn remove(&mut self, route: MethodRoute) {
        let _ = match route.method {
            Method::GET => self.get.remove(route.path),
            Method::POST => self.post.remove(route.path),
            Method::DELETE => self.delete.remove(route.path),
            Method::PATCH => self.patch.remove(route.path),
            _ => None,
        };
    }
}

Теперь можем роутить запрос, получать нужный тип запроса и обрабатывать его.

impl Service<Request<Incoming>> for Bulldozer {
    type Response = Response;
    type Error = std::io::Error;
    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;

    fn call(&self, req: Request<Incoming>) -> Self::Future {
        // ...
        
        let router = Arc::clone(&self.router);
        
        // ...

        let result = async move {
            let path_clone = path.clone();
            let router = router.read().await;
            let result = router.at(MethodRoute::new(method.clone(), &path_clone));

            // ...

            let result = match result {
                Ok(r) => r,
                Err(_) => {
                    trace!("route not found, sending 404");
                    return Ok(make_not_found());
                }
            };

            match result.value {
                ResourceRefType::Content(content) => {}
                ResourceRefType::File(path) => {}
                ResourceRefType::Page { index, layouts } => {}
                ResourceRefType::Api(callback) => {}
            }
        };

        Box::pin(result)
    }
}

Предлагаю начать с самых простых типов обработчиков: Api и Content. Даже не знаю, нужны ли тут какие-нибудь объяснения, поэтому предлагаю перейти к более тяжёлым типам.

match result.value {
    ResourceRefType::Content(content) => {
        trace!("found content resource ref");
        let content = content.clone();
        let stream = stream::once(async { Ok::<Frame<Bytes>, std::io::Error>(Frame::data(content)) });
        let stream: BoxStream = Box::pin(stream);
        let body = StreamBody::new(stream);
        Ok(Response::new(body))
    }
    ResourceRefType::Api(callback) => {
        let cookies = (**cookies).clone();
        let jwt = (**auth).clone();
        let params = result.params.iter().map(|(k, v)| (k.to_owned(), v.to_owned())).collect::<HashMap<_, _>>();
        let ctx = ApiContext {
            params,
            request: req,
            query,
            cookies,
            config,
            resources,
            jwt,
        };
        let result = callback(ctx).await;
        Ok(result)
    }
    ResourceRefType::File(path) => {}
    ResourceRefType::Page { index, layouts } => {}
}

Тут сложность была в том, что AsyncOverlayFS возвращает ридер, реализующий трейт из async-std, а нам нужен ридер, реализующий трейт AsyncRead из tokio. К счастью, в tokio_util имеется конвертер.

match result.value {
    ResourceRefType::Content(content) => {
        // ...
    }
    ResourceRefType::Api(callback) => {
        // ...
    }
    ResourceRefType::File(path) => {
        trace!("found file resource ref: {path:?}");
        let file = match vfs.open_file(path).await {
            Ok(f) => f,
            Err(err) => {
                error!("failed to open {path} file because {err}");
                return Ok(make_not_found());
            }
        };

        // convert async-std `Read` to tokio `AsyncRead`
        let file = tokio_util::compat::FuturesAsyncReadCompatExt::compat(file);

        let reader = ReaderStream::new(file);
        let stream: BoxStream = Box::pin(StreamBody::new(reader.filter_map(|buf| Some(buf.map(Frame::data)))));
        let body = StreamBody::new(stream);
        let mut response = Response::new(body);

        if let Some(mime) = mime_guess2::from_path(path).first() {
            response
                .headers_mut()
                .insert("Content-Type", HeaderValue::from_str(mime.as_ref()).expect("mime error"));
        }

        Ok(response)
    }
    ResourceRefType::Page { index, layouts } => {}
}

Наступила очередь страниц. Но перед тем, как отрендерить страницу, проходимся по хукам. Вообще можно было бы сделать разные хуки, чтобы вызывались в разных местах, но лично у меня такой потребности не было, поэтому будет вот так.

Ладно, хуки не прошли проверку, значит, переходим далее, а именно к инициализации движка upon. Если честно, я уже и не помню, почему предпочёл его вместо привычного всем Tera. Из минусов upon можно вынести абсолютное отсутствие стандартных функций в движке, поэтому нужно всё с нуля ручками реализовывать и регистрировать. Там даже нет оператора and или or в условных операторах, пришлось через функции это реализовывать. А может, я просто плохо искал. Но в целом с движком довольно приятно работать, поэтому оставляем.

Вообще было бы славно, наверное, кэшировать движок и скомпилированный шаблон, чтобы при каждом запросе его не инициализировать, но в данный момент мне слишком лень это делать, как-нибудь в другой раз. Вот так и растёт технический долг...

Вы могли заметить, что уже во второй раз у нас идёт прямое чтение из файловой системы, хотя мы, вроде как, хотели всё делать через ResourceManager. Но я тут почесал репу и подумал, что это будет через чур, поэтому через ResourceManager будем делать write-операции и десериализацию данных, то есть приводить сырые данные в нужный нам вид в среднем слое, вместо того, чтобы делать это в верхнем.

А ещё могли заметить, что функция auth регистрируется тут же вместо того, чтобы делать это в функции register_functions. Это связано с тем, что функция использует LazyCell и при передачи данного объекта нужно указывать дополнительно сигнатуру функции, а dyn Fn так просто не всунешь без Box.

match result.value {
    ResourceRefType::Content(content) => {
        // ...
    }
    ResourceRefType::Api(callback) => {
        // ...
    }
    ResourceRefType::File(path) => {
        // ...
    }
    ResourceRefType::Page { index, layouts } => {
        trace!("found page resource ref: {index:?}");

        let hook_ctx = HookContext {
            request: req,
            config: config.clone(),
            resources: resources.clone(),
            jwt: (**auth).clone(),
        };

        for hook in &*hooks.read().await {
            if let Some(result) = hook(&hook_ctx) {
                return Ok(result);
            }
        }

        trace!("init template engine...");
        let mut engine = upon::Engine::new();
          
        // регистрируем шаблоны
        for layout in layouts {
            let mut content = String::new();

            let mut file = match vfs.open_file(&layout).await {
                Ok(f) => f,
                Err(err) => {
                    error!("failed to open {layout} file because {err}");
                    continue;
                }
            };

            if let Err(err) = file.read_to_string(&mut content).await {
                error!("unable to read {layout} file because {err}");
                continue;
            }

            // приводим название шаблона из 'content/public/hello/world/header.html'
            // в вид по типу 'header'
            let filename = layout.split('/').next_back().unwrap();
            let filename_components = filename.split('.').collect::<Vec<_>>();
            let name = filename_components[..filename_components.len() - 1].join(".");
            match engine.add_template(name.clone(), content) {
                Ok(_) => {
                    trace!("layout {name} has been registered in template engine");
                }
                Err(err) => {
                    warn!("failed to add layout {name} ({layout}) in template engine because {err}");
                }
            }
        }

        engine.add_function("auth", {
            let auth = Arc::clone(&auth);
            move || (**auth).as_ref().map(|auth| upon::to_value(auth).unwrap())
        });

        register_functions(&mut engine, resources, lang_manager);

        // подгружаем основной файл index.html
        let mut content = String::new();
        let mut file = match vfs.open_file(index).await {
            Ok(f) => f,
            Err(err) => {
                error!("failed to open {index} file because {err}");
                return Ok(make_not_found());
            }
        };
        if let Err(err) = file.read_to_string(&mut content).await {
            error!("failed to read {index} file because {err}");
            return Ok(make_internal_error());
        }

        let template = match engine.compile(&content) {
            Ok(t) => t,
            Err(err) => {
                error!("failed to compile {index:?} because {err:#}");
                return Ok(make_internal_error());
            }
        };

        let params = result.params.iter().collect::<HashMap<_, _>>();

        let rendered = match template
            .render(
                &engine,
                // передаём как глобальные константы
                upon::value! {
                    query: query,
                    params: params
                },
            )
            .to_string()
        {
            Ok(r) => r,
            Err(err) => {
                error!("failed to render {index:?} because {err:#}");
                return Ok(make_internal_error());
            }
        };

        let stream = stream::once(async { Ok::<Frame<Bytes>, std::io::Error>(Frame::data(Bytes::from(rendered))) });
        let stream: BoxStream = Box::pin(stream);
        let body = StreamBody::new(stream);
        Ok(Response::new(body))
    }
}
Если кому интересна реализация register_functions

В силу того, что у нас сервер асинхронный, включая все ресурсы, а движок upon работает исключительно синхронно, то в половине функций, работающих с ресурсами сервера, ставим блок, ловим рантайм tokio и выполняем асинхронный код.

pub fn register_functions(engine: &mut Engine, resources: Arc<RwLock<ResourceManager>>, lang_manager: LangManager) {
    trace!("registering templates functions...");

    engine.add_function("all_posts", {
        let resources = Arc::clone(&resources);
        move || {
            tokio::task::block_in_place(|| {
                let runtime = Handle::current();
                let result = runtime.block_on(async { resources.read().await.get_posts(GetPostsRequest::Full).await });
                posts_to_upon_values(runtime, result)
            })
        }
    });

    engine.add_function("posts", {
        let resources = Arc::clone(&resources);
        move |count: usize, offset: usize| {
            tokio::task::block_in_place(|| {
                let runtime = Handle::current();
                let result = runtime.block_on(async { resources.read().await.get_posts(GetPostsRequest::Chunk { count, offset }).await });
                posts_to_upon_values(runtime, result)
            })
        }
    });

    engine.add_function("get_post_by_id", {
        let resources = Arc::clone(&resources);
        move |id: &str| {
            tokio::task::block_in_place(|| {
                let runtime = Handle::current();
                let result = match runtime.block_on(async { resources.read().await.get_post(id).await }) {
                    Ok(post) => post,
                    Err(err) => {
                        error!("unable to get post {id} because {err}");
                        return None;
                    }
                };
                upon::to_value(result).ok()
            })
        }
    });

    engine.add_function("get_components", {
        let resources = Arc::clone(&resources);
        move || {
            tokio::task::block_in_place(|| {
                let runtime = Handle::current();
                let result = match runtime.block_on(async { resources.read().await.load_components().await }) {
                    Ok(comps) => runtime.block_on(async { comps.map(|comp| (comp.name.clone(), comp)).collect::<HashMap<_, _>>().await }),
                    Err(err) => {
                        error!("unable to load components because {err}");
                        return None;
                    }
                };
                upon::to_value(result).ok()
            })
        }
    });

    engine.add_function("get_component", |name: &str, components: &Value| {
        if let Value::Map(map) = components {
            map.get(name).cloned()
        }
        else {
            None
        }
    });

    engine.add_function("is_map", |val: &Value| matches!(val, Value::Map(_)));

    engine.add_function("flat_input", |val: &Value| {
        if let Value::Map(map) = val {
            if let Some((_, map)) = map.first_key_value() {
                Some(map.clone())
            }
            else {
                None
            }
        }
        else {
            None
        }
    });

    engine.add_function("len", |list: &[Value]| list.len() as i64);

    engine.add_function("date", |timestamp: i64, format: &str| {
        let timestamp = jiff::Timestamp::from_second(timestamp).ok()?;
        Some(timestamp.strftime(format).to_string())
    });

    engine.add_function("eq", |first: &Value, second: &Value| *first == *second);

    engine.add_function("and", |first: &Value, second: &Value| {
        if let (Value::Bool(first), Value::Bool(second)) = (first, second) {
            *first && *second
        }
        else {
            !matches!(first, Value::None) && !matches!(second, Value::None)
        }
    });

    engine.add_function("get_pages", {
        let resources = Arc::clone(&resources);
        move || {
            tokio::task::block_in_place(|| {
                let runtime = Handle::current();
                let result = match runtime.block_on(async { resources.read().await.get_pages().await }) {
                    Ok(pages) => runtime.block_on(async { pages.collect::<Vec<_>>().await }),
                    Err(err) => {
                        error!("unable to get pages list because {err}");
                        return None;
                    }
                };
                upon::to_value(result).ok()
            })
        }
    });

    engine.add_function("get_resources", {
        let resources = Arc::clone(&resources);
        move |path: &Value| {
            tokio::task::block_in_place(|| {
                let runtime = Handle::current();
                let path = if let Value::String(path) = path { path.as_str() } else { "/" };
                let result = match runtime.block_on(async { resources.read().await.get_resources_in_folder(path).await }) {
                    Ok(resources) => runtime.block_on(async { resources.collect::<Vec<_>>().await }),
                    Err(err) => {
                        error!("unable to get resources list because {err}");
                        return upon::to_value(Vec::<ResourceInfo>::new()).ok();
                    }
                };
                upon::to_value(result).ok()
            })
        }
    });

    engine.add_function(
        "default",
        |current: &Value, def: &Value| if matches!(current, Value::None) { def.clone() } else { current.clone() },
    );

    engine.add_function("split_path", |path: &str| {
        let path = path.trim_matches('/');

        if path.is_empty() {
            return upon::to_value(Vec::<(String, String)>::new()).ok();
        }

        let components = path.split('/').collect::<Vec<_>>();
        let mut result = Vec::with_capacity(components.len());
        for (i, comp) in components.iter().enumerate() {
            let mut link = String::new();
            let mut j = 0;
            while j < i {
                link.push('/');
                link.push_str(components[j]);
                j += 1;
            }
            link.push('/');
            link.push_str(comp);
            result.push((comp, link));
        }
        upon::to_value(result).ok()
    });

    engine.add_function("take", |vec: &[Value], count: usize| {
        let max = vec.len().min(count);
        Some(vec[..max].to_vec())
    });

    engine.add_function("lang", move |key: &str| {
        tokio::task::block_in_place(|| {
            let runtime = Handle::current();
            runtime.block_on(async { lang_manager.try_get_message(key).await })
        })
    });

    engine.add_function("render_component", render_component);
}

fn posts_to_upon_values(runtime: Handle, stream_result: VfsResult<impl Stream<Item = Post>>) -> Option<Vec<Value>> {
    let posts = match stream_result {
        Ok(posts) => posts.filter_map(async |p| upon::to_value(p).ok()),
        Err(err) => {
            error!("unable to get posts list because {err}");
            return None;
        }
    };

    Some(runtime.block_on(posts.collect::<Vec<_>>()))
}

Слежка

В целом сервер уже способен принимать запросы к content/public/ и возвращать результат. Давайте теперь реализуем наблюдатель за файлами. Для этого будем использовать кросс-платформенный крейт notify.

Так как мы будем наблюдать сразу за несколькими разными файлами в папке content/, то будем плодить поток на каждый слушатель. А чтобы как-то различать, откуда и какой тип события произошёл, будем использовать собственные события ContentEventKind. Генерировать их будем с помощью фабрик, чтобы наш match был читаемым.

type ArcFabric = Arc<dyn EventFabric + Send + Sync + 'static>;

pub trait EventFabric {
    fn created(&self) -> ContentEventKind;
    fn modified(&self) -> ContentEventKind;
    fn removed(&self) -> ContentEventKind;
}

pub struct PublicEventFabric;

impl EventFabric for PublicEventFabric {
    fn created(&self) -> ContentEventKind {
        ContentEventKind::NewPublic
    }

    fn modified(&self) -> ContentEventKind {
        ContentEventKind::NewPublic
    }

    fn removed(&self) -> ContentEventKind {
        ContentEventKind::RemovePublic
    }
}

pub struct PagesEventFabric;

impl EventFabric for PagesEventFabric {
    fn created(&self) -> ContentEventKind {
        ContentEventKind::NewPage
    }

    fn modified(&self) -> ContentEventKind {
        ContentEventKind::NewPage
    }

    fn removed(&self) -> ContentEventKind {
        ContentEventKind::RemovePage
    }
}

#[derive(Debug, PartialEq)]
pub enum ContentEventKind {
    NewConfig,
    NewPublic,
    NewPage,
    NewLang,
    RemovePublic,
    RemovePage,
    RemoveLang,
}

impl ContentEventKind {
    pub fn is_public(&self) -> bool {
        matches!(self, ContentEventKind::NewPublic) || matches!(self, ContentEventKind::RemovePublic)
    }
}

#[derive(Debug)]
pub enum ContentEvent {
    Change { kind: ContentEventKind, path: VfsPath },
    None,
}

impl ContentEvent {
    pub fn is_none(&self) -> bool {
        matches!(self, Self::None)
    }

    pub fn unwrap_path(&self) -> &VfsPath {
        match self {
            ContentEvent::Change { path, .. } => path,
            ContentEvent::None => panic!("unwrap_path() called on ContentEvent::None"),
        }
    }
}

pub type FilesListener = crossbeam_channel::Receiver<notify::Result<Event>>;

pub struct FilesWatcherBuilder(Arc<AppContext>, PathBuf);

impl FilesWatcherBuilder {
    pub fn watch<F>(&self, fabric: F, path: &Path) -> &Self
    where
        F: EventFabric + Send + Sync + 'static,
    {
        let path = &self.1.join(path);

        let (tx, rx) = crossbeam_channel::unbounded();

        let mut watcher = match notify::recommended_watcher(tx) {
            Ok(w) => w,
            Err(err) => {
                error!(
                    "failed to initialize files watcher in {} because {err}, changes to files will not be applied until the server is restarted",
                    path.display()
                );
                return self;
            }
        };

        match watcher.watch(path, RecursiveMode::Recursive) {
            Ok(_) => {
                info!("watcher initialized at {}", path.display());
                let watcher = FilesWatcher {
                    root: path.to_owned(),
                    _watcher: Arc::new(watcher),
                    listener: rx,
                    fabric: Arc::new(fabric),
                };

                let ctx = Arc::clone(&self.0);

                tokio::spawn(async move {
                    watch_content(watcher, ctx).await;
                });
            }
            Err(err) => {
                error!("failed to initialize watcher on {} because {err}", path.display());
            }
        }

        self
    }
}

pub struct FilesWatcher {
    _watcher: Arc<dyn Watcher + Send + Sync>,
    listener: FilesListener,
    root: PathBuf,
    fabric: ArcFabric,
}

impl FilesWatcher {
    pub fn init(ctx: Arc<AppContext>, root: PathBuf) -> FilesWatcherBuilder {
        FilesWatcherBuilder(ctx, root)
    }
}

impl Iterator for &mut FilesWatcher {
    type Item = ContentEvent;

    fn next(&mut self) -> Option<Self::Item> {
        let event = self.listener.recv().ok()?.ok()?;
        let event = notify_event_to_content_event(self.fabric.clone(), &self.root, event);

        if event.is_none() { None } else { Some(event) }
    }
}

fn notify_event_to_content_event(fabric: ArcFabric, root: &Path, e: Event) -> ContentEvent {
    let Some(path) = e.paths.into_iter().filter_map(|p| real_path_to_vfs(root, &p)).next()
    else {
        warn!("none path in filesystem event");
        return ContentEvent::None;
    };

    match e.kind {
        EventKind::Create(kind) => {
            trace!("file {path} created, kind: {kind:?}");
            ContentEvent::Change {
                kind: fabric.created(),
                path,
            }
        }
        EventKind::Remove(kind) => {
            trace!("file {path} removed, kind: {kind:?}");
            ContentEvent::Change {
                kind: fabric.removed(),
                path,
            }
        }
        EventKind::Modify(kind) => match kind {
            ModifyKind::Name(rename_mode) => {
                debug!("rename mode: {rename_mode:?}");
                match rename_mode {
                    RenameMode::From => ContentEvent::Change {
                        kind: fabric.removed(),
                        path,
                    },
                    RenameMode::To => ContentEvent::Change {
                        kind: fabric.created(),
                        path,
                    },
                    _ => ContentEvent::None,
                }
            }
            ModifyKind::Data(_) => {
                debug!("file {path} modified, kind: {kind:?}");
                ContentEvent::Change {
                    kind: fabric.modified(),
                    path,
                }
            }
            _ => ContentEvent::None,
        },
        _ => ContentEvent::None,
    }
}

fn real_path_to_vfs(root: &Path, path: &Path) -> Option<VfsPath> {
    path.strip_prefix(root).map(|p| VfsPath::new(p.to_string_lossy().replace('\\', "/"))).ok()
}

Теперь мы слушаем события от файловой системы, но мы не обрабатываем наши преобразованные события. Давайте это исправим, реализовав функцию watch_content.

Всё, что она делает, так это в бесконечном цикле слушает события и в зависимости от типа удаляет или добавляет маршруты в наш роутер и обновляет кэш в памяти таких вещей, как новые шаблоны или новый конфиг.

async fn watch_content(mut watcher: FilesWatcher, context: Arc<AppContext>) {
    loop {
        for event in &mut watcher {
            if let ContentEvent::Change { path, kind } = event {
                match kind {
                    ContentEventKind::NewPublic => {
                        let mut router = context.router.write().await;
                        let resource = ResourceRefType::File(VfsPath::new(PUBLIC_FOLDER).join(path.clone()));
                        if let Err(err) = router.try_add(get(&path), resource) {
                            error!("unable to update router with route {path} because {err}");
                        }
                    }
                    ContentEventKind::NewPage => {
                        let mut router = context.router.write().await;
                        context
                            .resources
                            .read()
                            .await
                            .load_page(&mut router, VfsPath::new(PAGES_FOLDER).join(path))
                            .await
                    }
                    ContentEventKind::RemovePublic | ContentEventKind::RemovePage => {
                        let mut router = context.router.write().await;
                        router.remove(get(&path))
                    }
                    ContentEventKind::NewLang | ContentEventKind::RemoveLang => {
                        let file = VfsPath::new(LANG_FOLDER).join(path);

                        if file.filename().starts_with(LANG_META_FILE) {
                            info!("language meta {file} has been changed");

                            let new_meta = match context.resources.read().await.load_lang_meta(&VfsPath::new(LANG_FOLDER)).await {
                                Ok(meta) => meta,
                                Err(err) => {
                                    error!("unable to load language meta {file} because {err}");
                                    continue;
                                }
                            };
                            context.lang_manager.replace_meta(new_meta).await;
                        }
                        else {
                            info!("language {file} has been changed");

                            let mut path = file;
                            while let Some(parent) = path.parent()
                                && parent.filename() != LANG_FOLDER
                            {
                                path = parent;
                            }

                            let bundle = match context.resources.read().await.load_lang(&path).await {
                                Ok(bundle) => bundle,
                                Err(err) => {
                                    error!("unable to load language {path} because {err}");
                                    continue;
                                }
                            };

                            context.lang_manager.replace_lang(path.filename(), bundle).await;
                        }
                    }
                    ContentEventKind::NewConfig => {
                        info!("config has been changed");
                        let new_config = match context.resources.read().await.try_load_config().await {
                            Ok(c) => c,
                            Err(err) => {
                                error!("unable to load config because {err}");
                                continue;
                            }
                        };
                        debug!("config loaded, try set new lang");
                        context.lang_manager.set_current(new_config.lang.current.clone()).await;
                        debug!("try set new default lang");
                        context.lang_manager.set_default(new_config.lang.default.clone()).await;
                        debug!("try set new config");
                        *context.config.write().await = new_config;
                        info!("new config loaded");
                    }
                }
            }
        }
    }
}

watch_content использует какие-то неведомые нам методы, поэтому предлагаю рассмотреть теперь их.

После загрузки конфигурации код переходит к регистрации страниц и статических ресурсов. Метод load_pages выполняет рекурсивный обход каталога страниц, находя в каждой директории файл index и набор шаблонов оформления. При переходе к вложенным каталогам шаблоны наследуются от родительских директорий, благодаря чему можно определить общий макет для целого раздела сайта. После обработки очередной страницы для неё формируется маршрут, который добавляется в роутер.

Метод load_public работает похожим образом, но предназначен для статических файлов. Он обходит каталог публичных ресурсов, регистрируя отдельный маршрут для каждого найденного файла. Это позволяет автоматически публиковать изображения, таблицы стилей, скрипты и другие ресурсы без необходимости вручную описывать их в конфигурации маршрутов.

Оставшаяся часть методов отвечает за загрузку локализации. Сначала проверяется существование каталога с языковыми ресурсами и при необходимости он создаётся. Затем из метафайла считывается информация о доступных языках, после чего каждый языковой пакет загружается отдельно. Для этого код рекурсивно обходит директорию языка, читает все Fluent-файлы и объединяет содержащиеся в них переводы в единый набор сообщений. После завершения загрузки все языковые пакеты собираются в общий менеджер локализаций, который используется приложением для выбора текущего языка и поиска переводов.

impl ResourceManager {
    pub fn new(vfs: VirtualFS) -> Self {
        Self { vfs }
    }

    pub async fn load_config(&self) -> Config {
        self.try_load_config().await.expect("load config failed")
    }

    pub async fn try_load_config(&self) -> VfsResult<Config> {
        let filepath = VfsPath::new(CONFIG_FILENAME);
        let mut buf = String::new();

        self.vfs.open_file(&filepath).await?.read_to_string(&mut buf).await?;

        let config = toml::from_str(&buf).map_err(|e| VfsErrorKind::Other(e.to_string()))?;

        info!("config {filepath} has been loaded");

        Ok(config)
    }

    pub async fn load_pages(&self, router: &mut MethodRouter) {
        let mut pages = vec![PageDir::new(VfsPath::new(PAGES_FOLDER))];

        while let Some(page) = pages.pop() {
            self._load_page(router, &mut pages, page).await
        }
    }

    async fn _load_page(&self, router: &mut MethodRouter, pages: &mut Vec<PageDir>, page: PageDir) {
        let mut children = vec![];
        let mut index_file = None;
        let mut layouts = vec![];

        let mut page_reader = match self.vfs.read_dir(&page.path).await {
            Ok(r) => r,
            Err(err) => {
                warn!("unable to read folder {} because {err}, skipping", page.path);
                return;
            }
        };

        while let Some(entry) = page_reader.next().await {
            let entry = VfsPath::from([page.path.clone(), entry.into()]);

            let filetype = match self.vfs.metadata(&entry).await {
                Ok(t) => t,
                Err(err) => {
                    warn!("unable to get file type {entry} because {err}, skipping");
                    continue;
                }
            };

            if filetype.file_type == VfsFileType::Directory {
                children.push(PageDir::new(entry));
            }
            else {
                let filename = entry.split('/').next_back().unwrap().to_owned();
                if filename == INDEX_FILENAME {
                    index_file = Some(entry);
                }
                else {
                    let layout = PageLayout::new(entry);
                    layouts.push(layout);
                }
            }
        }

        while let Some(mut child) = children.pop() {
            child.inherited_layouts.extend(page.inherited_layouts.iter().cloned());
            child.inherited_layouts.extend(layouts.iter().cloned());
            pages.push(child);
        }

        if index_file.is_none() {
            warn!("{INDEX_FILENAME} not found in page folder {:?}", page.path);
            return;
        }

        layouts.extend(page.inherited_layouts);

        let resource = ResourceRefType::Page {
            index: index_file.unwrap(),
            layouts,
        };

        let normalized = normalize_route(&page.path.strip_prefix(VfsPath::new(PAGES_FOLDER)).unwrap());

        match router.try_add(get(&normalized), resource) {
            Ok(_) => {
                debug!("page route {normalized} has been registered");
            }
            Err(err) => {
                warn!("page route {normalized} is not registered because {err}");
            }
        }
    }

    pub async fn load_page(&self, router: &mut MethodRouter, path: VfsPath) {
        if let Some(parent) = path.parent() {
            let mut pages = vec![PageDir::new(parent)];

            while let Some(page) = pages.pop() {
                self._load_page(router, &mut pages, page.clone()).await;
                debug!("page {page:?} has been registered");
            }
        }
        else {
            warn!("unable to load page {path}");
        }
    }

    pub async fn load_public(&self, router: &mut MethodRouter) {
        let mut dirs = vec![VfsPath::new(PUBLIC_FOLDER)];

        while let Some(dir) = dirs.pop() {
            let mut folder_reader = match self.vfs.read_dir(&dir).await {
                Ok(reader) => reader,
                Err(err) => {
                    warn!("unable to read folder {dir} because {err}, skipping");
                    continue;
                }
            };

            while let Some(entry) = folder_reader.next().await {
                let entry = VfsPath::from([dir.clone(), entry.into()]);

                let filetype = match self.vfs.metadata(&entry).await {
                    Ok(t) => t,
                    Err(err) => {
                        warn!("unable to get file type of {entry} because {err}, skipping");
                        continue;
                    }
                };

                if filetype.file_type == VfsFileType::Directory {
                    dirs.push(entry);
                }
                else {
                    let normalized = normalize_route(&entry.strip_prefix(VfsPath::new(PUBLIC_FOLDER)).unwrap());

                    match router.try_add(get(&normalized), ResourceRefType::File(VfsPath::new(entry))) {
                        Ok(_) => {
                            debug!("route \"{normalized}\" has been registered");
                        }
                        Err(err) => {
                            warn!("route \"{normalized}\" is not registered because {err}");
                        }
                    }
                }
            }
        }
    }

    async fn ensure_posts_folder(&self) -> VfsResult<VfsPath> {
        let folder = VfsPath::new(POSTS_FOLDER);

        if let Ok(exists) = self.vfs.exists(&folder).await
            && !exists
        {
            self.vfs.create_dir(&folder).await?;
        }

        Ok(folder)
    }

    async fn ensure_lang_folder(&self) -> VfsResult<VfsPath> {
        let folder = VfsPath::new(LANG_FOLDER);

        if let Ok(exists) = self.vfs.exists(&folder).await
            && !exists
        {
            self.vfs.create_dir(LANG_FOLDER).await?;
        }

        Ok(folder)
    }

    pub async fn load_lang_meta(&self, lang_folder: &VfsPath) -> VfsResult<LangMeta> {
        let meta = lang_folder.join(LANG_META_FILE);
        let mut meta_content = String::new();
        self.vfs.open_file(&meta).await?.read_to_string(&mut meta_content).await?;
        toml::from_str(&meta_content).map_err(|err| VfsErrorKind::Other(err.to_string()).into())
    }

    pub async fn load_lang(&self, lang_folder: &VfsPath) -> VfsResult<LangBundle> {
        let lang_id = match lang_folder.filename().parse::<LanguageIdentifier>() {
            Ok(lang_id) => lang_id,
            Err(err) => {
                error!("unable to parse language identifier {} because {err}", lang_folder.filename());
                return Err(VfsErrorKind::Other(err.to_string()).into());
            }
        };

        let stack = Arc::new(Mutex::new(vec![lang_folder.clone()]));
        let bundle = Arc::new(RwLock::new(FluentBundle::new_concurrent(vec![lang_id])));

        while let Some(path) = stack.lock().await.pop() {
            match self.vfs.read_dir(&path).await {
                Ok(reader) => reader.for_each(|e| {
                    let bundle = bundle.clone();
                    let stack = stack.clone();
                    let path = path.clone();

                    async move {
                        let entry = path.join(e);

                        if let Ok(meta) = self.vfs.metadata(&entry).await
                            && matches!(meta.file_type, VfsFileType::Directory)
                        {
                            stack.lock().await.push(entry);
                            return;
                        }

                        let mut content = String::new();
                        let mut file = match self.vfs.open_file(&entry).await {
                            Ok(file) => file,
                            Err(err) => {
                                error!("unable to open file {entry} because {err}");
                                return;
                            }
                        };

                        match file.read_to_string(&mut content).await {
                            Ok(_) => {}
                            Err(err) => {
                                error!("unable to read file {entry} because {err}");
                                return;
                            }
                        }

                        let resource = match FluentResource::try_new(content) {
                            Ok(resource) => resource,
                            Err(err) => {
                                error!("unable to parse resource {entry} because {:?}", err.1);
                                return;
                            }
                        };

                        match bundle.write().await.add_resource(resource) {
                            Ok(_) => {}
                            Err(err) => {
                                error!("unable to add resource {entry} because {err:?}");
                            }
                        };
                    }
                }),
                Err(err) => {
                    error!("unable to read directory {path} because {err}");
                    return Err(err);
                }
            }
            .await;
        }

        Ok(bundle)
    }

    pub async fn load_langs(&self, default_lang: String, current_lang: String) -> VfsResult<LangManager> {
        let folder = self.ensure_lang_folder().await?;
        let meta = self.load_lang_meta(&folder).await?;

        let map = self
            .vfs
            .read_dir(&folder)
            .await?
            .fold(DashMap::new(), |map, entry| async {
                if entry == LANG_META_FILE {
                    return map;
                }

                let bundle = match self.load_lang(&folder.join(VfsPath::new(&entry))).await {
                    Ok(bundle) => bundle,
                    Err(err) => {
                        error!("unable to load language {entry} because {err}");
                        return map;
                    }
                };

                map.insert(entry, bundle);

                map
            })
            .await;

        Ok(LangManager::new(current_lang, default_lang, map, meta.lang))
    }
}

Теперь посмотрим, что из себя представляет LangManager. Его основная задача — хранить загруженные языковые пакеты и предоставлять удобный интерфейс для получения локализованных сообщений. Для этого менеджер содержит список доступных языков, набор загруженных FluentBundle, а также информацию о текущем и резервном языках. Все данные обёрнуты в потокобезопасные примитивы синхронизации, что позволяет безопасно использовать менеджер из разных асинхронных задач.

Метод try_get_message пытается найти сообщение по ключу сначала в текущем языковом пакете, а если перевод отсутствует — в пакете языка по умолчанию. Такой механизм позволяет избежать ошибок при неполном переводе интерфейса и гарантирует, что пользователь увидит хотя бы резервный вариант текста. Если сообщение найдено, оно дополнительно обрабатывается средствами Fluent, которые подставляют параметры и выполняют форматирование строки.

pub type LangBundle = Arc<RwLock<FluentBundle<FluentResource>>>;

#[derive(Deserialize)]
pub struct LangMetaEntry {
    pub code: String,
    pub name: String,
}

#[derive(Deserialize)]
pub struct LangMeta {
    pub lang: Vec<LangMetaEntry>,
}

struct LangManagerInner {
    langs: Mutex<Vec<LangMetaEntry>>,
    bundles: DashMap<String, LangBundle>,
    current_lang: RwLock<String>,
    default_lang: RwLock<String>,
}

#[derive(Clone)]
pub struct LangManager(Arc<LangManagerInner>);

impl LangManager {
    pub fn new(current_lang: String, default_lang: String, bundles: DashMap<String, LangBundle>, langs: Vec<LangMetaEntry>) -> Self {
        Self(Arc::new(LangManagerInner {
            langs: Mutex::new(langs),
            bundles,
            current_lang: RwLock::new(current_lang),
            default_lang: RwLock::new(default_lang),
        }))
    }

    pub async fn try_get_message(&self, key: &str) -> Option<String> {
        let bundle = self
            .0
            .bundles
            .get(&*self.0.current_lang.read().await)
            .async_or_else(|| async { self.0.bundles.get(&*self.0.default_lang.read().await) })
            .await;

        if let Some(bundle) = bundle {
            let bundle = bundle.read().await;

            let default_bundle = self
                .0
                .bundles
                .get(&*self.0.default_lang.read().await)
                .expect("no default lang bundle found");
            let default_bundle = default_bundle.read().await;

            let mut errors = vec![];
            let message = match bundle.get_message(key) {
                None => {
                    warn!(
                        "message not found into {} bundle: '{}', search into default bundle",
                        self.0.current_lang.read().await,
                        key
                    );

                    match default_bundle.get_message(key) {
                        Some(message) => message,
                        None => {
                            warn!("message not found into default bundle: '{}'", key);
                            return None;
                        }
                    }
                }
                Some(message) => message,
            };

            let val = message.value()?;
            let message = bundle.format_pattern(val, None, &mut errors);

            if !errors.is_empty() {
                warn!("in message {key} errors: {errors:?}");
            }

            Some(message.to_string())
        }
        else {
            None
        }
    }

    pub async fn replace_meta(&self, meta: LangMeta) {
        *self.0.langs.lock().await = meta.lang;
    }

    pub async fn set_current(&self, lang: String) {
        *self.0.current_lang.write().await = lang;
    }

    pub async fn set_default(&self, lang: String) {
        *self.0.default_lang.write().await = lang;
    }

    pub async fn replace_lang(&self, lang: String, bundle: LangBundle) {
        self.0.bundles.insert(lang, bundle);
    }
}

Предлагаю перейти к функции main и собрать воедино всё, что было подготовлено ранее. Сначала приложение настраивает определяет корневой каталог и инициализирует виртуальную файловую систему для папки с контентом. Затем создаются ResourceManager и конфиг, который сразу загружается из файловой системы. После этого из конфига берутся параметры локализации, и на их основе инициализируется LangManager.

Далее формируется общее состояние приложения AppContext. Сразу после этого запускается FilesWatcher, который следит за изменениями в публичных файлах, страницах, языковых ресурсах и конфиге, чтобы приложение могло подхватывать обновления без перезапуска.

После подготовки контекста приложение получает параметры сервера и запускает его. Перед стартом добавляется хук для страниц админ-панели: если пользователь пытается открыть /admin, но не авторизован, его автоматически перенаправляют на страницу входа. Затем сервер загружает страницы и публичные ресурсы, регистрирует API-роуты и, наконец, начинает обслуживать запросы.

pub trait BoxedApiCallback {
    fn boxed(self) -> Box<dyn Fn(ApiContext) -> BoxFuture<'static, Response> + Send + Sync + 'static>;
}

impl<F> BoxedApiCallback for F
where
    F: Fn(ApiContext) -> BoxFuture<'static, Response> + Send + Sync + 'static,
{
    fn boxed(self) -> Box<dyn Fn(ApiContext) -> BoxFuture<'static, Response> + Send + Sync + 'static> {
        Box::new(self)
    }
}

// функция для удобства и больше читаемости кода
pub fn api(callback: impl BoxedApiCallback) -> ResourceRefType {
    ResourceRefType::Api(callback.boxed())
}

struct AppContext {
    pub router: Arc<RwLock<MethodRouter>>,
    pub config: ArcConfig,
    pub vfs: VirtualFS,
    pub resources: Arc<RwLock<ResourceManager>>,
    pub lang_manager: LangManager,
}

#[tokio::main]
async fn main() {
    setup_logger().expect("unable to start logs");

    let root = get_root();
    let content_folder = root.join(CONTENT_FOLDER);
    let vfs = init_vfs(&content_folder).await;

    let resources = Arc::new(RwLock::new(ResourceManager::new(Arc::clone(&vfs))));
    let config = Arc::new(RwLock::new(resources.read().await.load_config().await));

    let lang_manager = {
        let config = config.read().await;
        debug!("current lang: {}, default: {}", config.lang.current, config.lang.default);
        resources
            .read()
            .await
            .load_langs(config.lang.default.clone(), config.lang.current.clone())
            .await
            .expect("unable to load languages")
    };

    let ctx = Arc::new(AppContext {
        router: Default::default(),
        resources: Arc::clone(&resources),
        config: Arc::clone(&config),
        vfs,
        lang_manager: lang_manager.clone(),
    });

    FilesWatcher::init(Arc::clone(&ctx), content_folder)
        .watch(PublicEventFabric, PUBLIC_FOLDER.as_ref())
        .watch(PagesEventFabric, PAGES_FOLDER.as_ref())
        .watch(LangEventFabric, LANG_FOLDER.as_ref())
        .watch(ConfigEventFabric, CONFIG_FILENAME.as_ref());

    // избегаем потенциального дедлока потоков, просто клонируя значения, чтобы гуард RwLock-а дропнулся.
    let sec = config.read().await.sec.clone();
    let addr = config.read().await.server.host.clone();
    let port = config.read().await.server.port;

    Server::new(root, addr, port, ctx)
        .add_hook(|ctx| {
            // если путь начинается на `/admin`, то это 100% системный путь, который требует авторизации, поэтому перекидываем пользователя на страницу авторизации.
            let path = ctx.request.uri().path();
            if path != "/admin/login" && path.starts_with("/admin") && ctx.jwt.is_none() {
                Some(make_see_other(&format!("/admin/login?next={path}")))
            }
            else {
                None
            }
        })
        .await
        .load_pages()
        .await
        .load_public()
        .await
        .routes(|r| {
            r.add(post("/admin/login"), api(login))
                .add(get("/admin/logout"), api(logout))
                .add(get("/health"), ResourceRefType::Content(Bytes::from("ok")))
                // обычные CRUD операции для постов, не будем их рассматривать
                .add(post("/api/posts"), api(create_post))
                .add(delete("/api/posts"), api(delete_post))
                .add(get("/api/posts"), api(get_posts))
                .add(patch("/api/posts"), api(update_post))
                .add(get("/components"), api(get_components))
                .add(get("/api/resources"), api(get_resources_in_folder))
                // отличается от `create_post` тем, что создаёт новый пост и перекидывает пользователя на страницу редактора статей.
                .add(get("/admin/posts/new"), api(new_post));
        })
        .await
        .set_security(sec)
        .serve()
        .await;
}

Api

Пройдёмся по списку эндпоинтов API? Не будем рассматривать эндпоинты для CRUD постов, если кому будет интересно — посмотрите в репозитории. Предлагаю начать с авторизации и выхода.

Авторизация

Вся работа тут будет строиться на JWT-токенах. Насколько можете помнить, JWT у нас парсится ещё в Bulldozer, поэтому тут мы проверяем по минимуму. Не протух ли токен (имеется в виду истечение срока действия токена), если да, то удаляем его из куки и перекидываем пользователя на страницу авторизации.

Хотя я не помню, но, вроде, крейт jsonwebtoken сам проверяет, не протух ли токен. Но это не точно.

Иначе потом итерируемся по пользователям, чтобы убедиться, что такой пользователь ещё существует; если нету такого пользователя, то просто возвращаем 404. Хотя тут, наверное, более правильно вернуть код 401, но мне показалось так логичнее, так как у нас реально не найден пользователь. Тут можете смело закидывать меня тапками в комментариях.

Если пользователь найден, то формируем новый токен, заносим его в куки с помощью заголовка Set-Cookie и перенаправляем пользователя на другую страницу, если это необходимо.

#[derive(Deserialize, Clone)]
pub struct AuthContext {
    pub secret: String,
}

#[derive(Serialize, Deserialize, Clone)]
pub struct JwtPayload {
    pub username: String,
    pub exp: usize,
}

#[derive(Deserialize, PartialEq, Clone)]
pub struct UserCredentials {
    login: String,
    password: String,
}

#[callback]
pub fn login(mut ctx: ApiContext) -> BoxFuture<'static, Response> {
    if let Some(jwt) = ctx.jwt {
        trace!("found jwt");
        if jwt.exp < jiff::Timestamp::now().as_second() as usize {
            let cookie = Cookie::build((AUTH_COOKIE_NAME, "_")).max_age(Duration::seconds(0)).build();
            let val = HeaderValue::from_str(&cookie.to_string()).expect("unable to build header value");
            ctx.request.headers_mut().insert("Set-Cookie", val);
            return make_see_other("/admin/login");
        }
    }

    // будем принимать запрос только из формы
    if ctx.request.headers().get("Content-Type") != Some(&"application/x-www-form-urlencoded".parse().unwrap()) {
        return make_bad_request();
    }

    let body = match ctx.request.into_body().collect().await {
        Ok(b) => String::from_utf8_lossy(&b.to_bytes()).to_string(),
        Err(err) => {
            error!("failed to collect request body because {err}");
            return make_internal_error();
        }
    };

    // парсим тело запроса, то есть данные для входа
    let credentials = UrlEncodedData::parse_str(&body);
    let username = credentials
        .get("login")
        .map(|p| p.first().map(ToString::to_string).unwrap_or_default())
        .unwrap_or_default();
    let password = credentials
        .get("password")
        .map(|p| p.first().map(ToString::to_string).unwrap_or_default())
        .unwrap_or_default();
    let credentials = UserCredentials { login: username, password };

    for user in &ctx.config.read().await.users {
        if *user != credentials {
            continue;
        }

        let now = jiff::Timestamp::now().as_second() as usize;
        let exp = now + AUTH_EXP;
        let payload = JwtPayload {
            username: user.login.to_string(),
            exp,
        };
        let encode_key = EncodingKey::from_secret(ctx.config.read().await.auth.secret.as_bytes());

        // составляем новый токен
        let jwt = jsonwebtoken::encode(&Header::default(), &payload, &encode_key).expect("failed to encode jwt");

        // заносим его в куки
        let cookie = Cookie::build((AUTH_COOKIE_NAME, jwt))
            .path("/")
            .http_only(true)
            .max_age(Duration::seconds(AUTH_EXP as i64))
            .build();

        // смотрим на query запроса, нужно ли перекинуть пользователя на другую страницу
        let mut response = if let Some(next) = ctx.query.get("next") {
            make_see_other(next)
        }
        else {
            make_response(StatusCode::OK, "authorized")
        };

        // устанавливаем новый заголовок
        let val = HeaderValue::from_str(&cookie.to_string()).expect("unable to build header value");
        response.headers_mut().insert("Set-Cookie", val);

        return response;
    }

    make_not_found()
}

В свою очередь, эндпоинт logout супер простой: просто убираем из куки токен, установив время его жизни в нуль.

#[callback]
pub fn logout(ctx: ApiContext) -> BoxFuture<'static, Response> {
    let mut response = if let Some(next) = ctx.query.get("next") {
        make_see_other(next)
    }
    else {
        make_response(StatusCode::OK, "logged out")
    };

    let cookie = Cookie::build((AUTH_COOKIE_NAME, "_"))
        .max_age(Duration::seconds(0))
        .path("/")
        .http_only(true)
        .build();
    let val = HeaderValue::from_str(&cookie.to_string()).expect("unable to build header value");
    response.headers_mut().insert("Set-Cookie", val);

    response
}

Остальное

Остальные эндпоинты предлагаю просмотреть поверхностно, ведь всё, что они делают, — это сериализуют структуры в JSON и отправляют их пользователю. Кроме new_post, он создаёт пост и перенаправляет пользователя на страницу редактора этого самого поста.

#[callback]
pub fn get_components(ctx: ApiContext) -> BoxFuture<'static, Response> {
    let resources = ctx.resources.read().await;
    let comps = match resources.load_components().await {
        Ok(comps) => {
            comps
                .map(|c| ComponentProperties {
                    name: c.name,
                    html: c.html,
                    container: c.container,
                    properties: c.properties,
                })
                .collect::<Vec<_>>()
                .await
        }
        Err(err) => {
            error!("unable to load components list because {err}");
            return make_internal_error();
        }
    };

    let content = serde_json::to_string(&comps).unwrap();

    make_json_response(content.as_str())
}

#[callback]
pub fn get_resources_in_folder(ctx: ApiContext) -> BoxFuture<'static, Response> {
    let path = ctx.query.get("path");
    if path.is_none() {
        return make_bad_request();
    }

    let folder = path.unwrap();
    let resources = match ctx.resources.read().await.get_resources_in_folder(folder).await {
        Ok(resources) => serde_json::to_string(&resources.collect::<Vec<_>>().await).unwrap(),
        Err(err) => {
            return match err.kind() {
                VfsErrorKind::FileNotFound => {
                    error!("{folder} is not found");
                    make_not_found()
                }
                VfsErrorKind::InvalidPath => {
                    error!("{folder} is not a folder");
                    make_bad_request()
                }
                _ => {
                    error!("unable to get resources in folder {folder} because {err}");
                    make_internal_error()
                }
            };
        }
    };

    make_json_response(&resources)
}

#[callback]
pub fn new_post(ctx: ApiContext) -> BoxFuture<'static, Response> {
    if let Some(jwt) = ctx.jwt {
        let id = SmallUid::new().to_string();
        let content = PostBody {
            title: format!("Post {id}"),
            created_at: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(),
            draft: true,
            author: jwt.username,
            content: vec![],
            updated_at: None,
        };
        let post = Post { id: id.clone(), content };

        if let Err(err) = ctx.resources.write().await.save_post(&post).await {
            error!("unable to save {} post file because {err}", id);
            make_internal_error()
        }
        else {
            info!("new post {id} has been saved successfully, redirecting to edit page");
            make_see_other(&format!("/admin/posts/edit?id={id}"))
        }
    }
    else {
        make_unauthorized()
    }
}

Макросы

Вас, наверное, интересует, что за атрибут callback висит над функциями? Да и могли обратить внимание, что мы используем .await в синхронной функции. Всё дело в том, что функции должны возвращать BoxFuture, то есть Box::pin(async {}). Чтобы было более эстетично и читаемо, я создал крейт для макросов и описал вот такой очень простой макрос, который создаёт точно такую же функцию, но всё её содержимое помещает в BoxFuture.

#[proc_macro_attribute]
pub fn callback(_attr: TokenStream, input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as ItemFn);
    let fn_name = &input.sig.ident;
    let vis = &input.vis;
    let body = &input.block;
    let args = input.sig.inputs;

    let expanded = quote! {
        #vis fn #fn_name(#args) -> BoxFuture<'static, Response> {
            Box::pin(async move #body)
        }
    };

    TokenStream::from(expanded)
}

Компоненты

Чуть не забыл, давайте посмотрим на реализацию компонентов. Для этого нужно взглянуть на модель описания компонентов.

#[derive(Decode, Serialize)]
pub struct HtmlAttr {
    #[knus(argument, str)]
    pub name: String,
    #[knus(argument)]
    pub value: String,
}

#[derive(Decode, Serialize)]
#[serde(tag = "type", content = "content", rename_all = "snake_case")]
pub enum HtmlType {
    Content(#[knus(argument)] String),
    Html(Html),
}

#[skip_serializing_none]
#[derive(Decode, Serialize, Default)]
pub struct Html {
    #[knus(argument)]
    pub element: String,
    #[knus(children(name = "attr"))]
    pub attrs: Option<Vec<HtmlAttr>>,
    #[knus(child, unwrap(children))]
    pub children: Option<Vec<HtmlType>>,
}

#[derive(Decode, Serialize, Default)]
pub enum ComponentPropertyType {
    #[default]
    String,
    Number,
    Boolean,
}

impl FromStr for ComponentPropertyType {
    type Err = Box<dyn std::error::Error + Send + Sync + 'static>;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "string" => Ok(ComponentPropertyType::String),
            "number" => Ok(ComponentPropertyType::Number),
            "boolean" => Ok(ComponentPropertyType::Boolean),
            _ => Err("invalid type".into()),
        }
    }
}

#[skip_serializing_none]
#[derive(Decode, Serialize, Default)]
pub struct ComponentProperty {
    #[knus(argument)]
    pub name: String,
    #[knus(child, unwrap(argument))]
    pub lang_key: String,
    #[knus(child, unwrap(argument))]
    pub default: Option<String>,
    #[knus(child, unwrap(argument))]
    pub max: Option<String>,
    #[knus(child, unwrap(argument))]
    pub min: Option<String>,
    #[knus(type_name)]
    pub type_name: Option<ComponentPropertyType>,
}

#[skip_serializing_none]
#[derive(Decode, Serialize, Default)]
pub struct ComponentContainerProperties {
    #[knus(child, unwrap(argument))]
    pub row: Option<String>,
    #[knus(child, unwrap(argument))]
    pub col: Option<String>,
    #[knus(child, unwrap(argument))]
    pub classes: Option<String>,
}

#[skip_serializing_none]
#[derive(Decode, Serialize, Default)]
pub struct Component {
    #[knus(argument)]
    pub name: String,
    #[knus(child, unwrap(argument))]
    pub lang_key: String,
    #[knus(child)]
    pub html: Html,
    #[knus(child)]
    pub container: Option<ComponentContainerProperties>,
    #[knus(children(name = "property"))]
    pub properties: Vec<ComponentProperty>,
}

#[derive(Decode)]
pub struct Document {
    #[knus(child)]
    pub component: Component,
}

С такой моделью можем распарсить что-то типа этого:

component "Name" {
    lang-key "components-name"

    (url)property "url" {
        lang-key "components-name-property-url"
        default "/some-url.html"
    }

    (string)property "text" {
        lang-key "components-name-property-title"
        default "Some text"
    }

    (number)property "number" {
        lang-key "components-name-property-number"
        default "1"
        min "1"
        max "6"
    }

    html "div" {
        attr "class" "component-name"
        children {
            html "h#number" {
                children {
                    content "#text"
                }
            }
            html "ul" {
                children {
                    html "li" {
                        children {
                            content "First <a href=\"#url\">link</a>"
                        }
                    }
                    html "li" {
                        children {
                            content "#text"
                        }
                    }
                }
            }
        }
    }
}

Могли заметить, что у нас используется какой-то странный синтаксис по типу h#number. Чтобы как-то подставлять значения, соответствовать декларативному стилю и особо не прибегать к скриптам, был за пять минут придуман синтаксис по типу #name или #(name), который парсится у нас регулярными выражениями, на место которых подставляются переменные с такими именами.

То есть если берём пример выше, то h#number получится h1. Это работает как для тэгов, так и для атрибутов, так и для содержимого, то есть текстовых нод.

К сожалению, в силу того, что content интерпретируется исключительно как текстовый узел, то html не получится там прописывать, но над этим я позже поработаю. Так же, как и над тем, чтобы можно было использовать списки. Или придумаю какой-нибудь микро-DSL с переменными, циклами, условиями и т.д. Но для такого, наверное, нужно будет подумать над биндами для WASM, чтобы не переписывать один и тот же код как для фронта, так и для бэка, ведь у нас компоненты генерируются как там, так и тут.

Давайте посмотрим на функцию, которая парсит и рекурсивно генерирует нам HTML-код на сервере. Всё в upon держится на словарях, поэтому будет куча if let.

use upon::Value;

pub fn render_component(comps: &Value, data: &Value, comp_name: &str) -> Option<String> {
    if let Value::Map(comps) = comps
        && let Some(comp) = comps.get(comp_name)
        && let Value::Map(comp_fields) = comp
        && let Some(html) = comp_fields.get("html")
    {
        let result = render_html(html, data);

        let styles = if let Value::Map(data) = data
            && let (Some(Value::String(col)), Some(Value::String(row))) = (data.get("col"), data.get("row"))
        {
            format!("style=\"grid-row: span {row}; grid-column: span {col}\"")
        }
        else {
            "style=\"grid-row: span 1; grid-column: span 12\"".to_owned()
        };

        let data = if let Value::Map(data) = data {
            data.iter()
                .filter_map(|(k, v)| if let Value::String(v) = v { Some(format!(" data-{k}=\"{v}\"")) } else { None })
                .collect::<String>()
        }
        else {
            Default::default()
        };

        result.map(|html| format!("<div class=\"grid-block\" {styles} {data} data-type=\"{comp_name}\">{html}</div>"))
    }
    else {
        None
    }
}

fn render_html(html: &Value, data: &Value) -> Option<String> {
    if let Value::Map(html) = html {
        if let Some(Value::String(element)) = html.get("element") {
            let attrs = if let Some(attrs) = html.get("attrs")
                && let Value::List(attrs) = attrs
            {
                attrs
                    .iter()
                    .filter_map(|attr| {
                        if let Value::Map(attr) = attr
                            && let (Some(Value::String(key)), Some(Value::String(val))) = (attr.get("name"), attr.get("value"))
                        {
                            Some((parse_binding(key, data), parse_binding(val, data)))
                        }
                        else {
                            None
                        }
                    })
                    .map(|(key, val)| format!(" {key}=\"{val}\""))
                    .collect::<String>()
            }
            else {
                Default::default()
            };

            let children = if let Some(children) = html.get("children")
                && let Value::List(children) = children
            {
                children
                    .iter()
                    .filter_map(|child| {
                        if let Value::Map(child) = child {
                            if let Some(Value::String(ty)) = child.get("type") {
                                match ty.as_str() {
                                    "content" if let Some(Value::String(content)) = child.get("content") => Some(parse_binding(content, data)),
                                    "html" if let Some(html) = child.get("html") => render_html(html, data),
                                    &_ => None,
                                }
                            }
                            else {
                                None
                            }
                        }
                        else {
                            None
                        }
                    })
                    .collect::<String>()
            }
            else {
                Default::default()
            };

            let element = parse_binding(element, data);
            Some(format!("<{element}{attrs}>{children}</{element}>"))
        }
        else if let Some(content) = html.get("content")
            && let Value::String(content) = content
        {
            Some(parse_binding(content, data))
        }
        else {
            None
        }
    }
    else {
        None
    }
}

fn parse_binding(source: &str, data: &Value) -> String {
    if source.contains('#') {
        let pattern = regex::Regex::new(r"#\(?(\w+)\)?").unwrap();
        if let Some(caps) = pattern.captures(source) {
            let rep_pattern = regex::Regex::new(r"(#\(?\w+\)?)").unwrap();
            let mut result = String::new();
            for cap in caps.iter() {
                if let Some(cap) = cap
                    && let Value::Map(val) = data
                    && let Some(val) = val.get(cap.as_str())
                    && let Value::String(str) = val
                {
                    result = rep_pattern.replace(source, str).into();
                }
            }
            result
        }
        else {
            source.to_string()
        }
    }
    else {
        source.to_owned()
    }
}

Фронтэнд

В целом, бэкенд готов и может принимать и обрабатывать запросы. Осталось реализовать фронт. Я, к сожалению, не дизайнер, поэтому я воспользовался готовым очень простым CSS-фреймворком Picnic и вооружился ИИ, которые наверстали мне админку. Предлагаю просто посмотреть на результат, а на разметку можете посмотреть в репозитории, если будет интересно.

раздел постов в админ-панели

раздел постов в админ-панели

редактор статей

редактор статей

главная страница

главная страница

Редактор

Больше всего Typescript-кода было настрочено для редактора, немного дублируя логику генерации HTML. Но перед этим я создал директорию web/, в которую поместил все JS/TS проекты, и уже с помощью скрипта build.rs билдил их и копировал результат в нужные папки в content/public/. Для инициализации и билда проекта использую Bun. А в релиз-версии наши скрипты будут ещё и миницифироваться.

А, и да, компоненты у меня лежат не в content/components, а в components/, а потом копируются в нужное место. Это осталось после того, как компоненты были реализованы через JS-хуки, в будущем поправлю это. Наверное.

use std::path::{Path, PathBuf};

const COMPONENT_FILE: &str = "component.kdl";

fn main() {
    let project = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).parent().unwrap().to_owned();
    let components_folder = project.join("components");
    let web_folder = project.join("web");
    let content_folder = project.join("content");
    let components_out = content_folder.join("components");

    println!("cargo:rerun-if-changed={}/*", web_folder.display());
    println!("cargo:rerun-if-changed={}/*", components_folder.display());

    for entry in std::fs::read_dir(&components_folder).unwrap() {
        let entry = entry.unwrap();
        process_component(&entry.path(), &components_out.join(entry.file_name()));
    }

    let release = !cfg!(debug_assertions);

    for entry in std::fs::read_dir(web_folder).unwrap() {
        let entry = entry.unwrap();
        let outdir = content_folder.join("public").join(entry.file_name());
        build_ts(release, true, &entry.path(), &outdir, &["common"]);
    }
}

fn build_ts(release: bool, subdir: bool, path: &Path, out_path: &Path, exclude: &[&str]) {
    if exclude.contains(&path.file_name().unwrap().to_str().unwrap()) {
        return;
    }

    let bun = std::process::Command::new("bun").args(["--version"]).output().unwrap().status.success();
    if !bun {
        panic!("no bun found");
    }

    let build_name = if release { "build-prod" } else { "build" };

    let out = std::process::Command::new("bun")
        .args(["run", build_name])
        .current_dir(path)
        .output()
        .unwrap();

    if !out.status.success() {
        println!("cargo:warning=bun stderr: {}", String::from_utf8(out.stderr).unwrap());
        println!("cargo:warning=bun stdout: {}", String::from_utf8(out.stdout).unwrap());
        panic!("failed to build {}", path.display());
    }

    let dest_folder = path.join("out");
    let js_out_path = if subdir { out_path.join("js") } else { out_path.to_path_buf() };

    std::fs::create_dir_all(&js_out_path).unwrap();

    let js_file = dest_folder.join("index.js");
    let dest_file = js_out_path.join("index.js");
    std::fs::copy(&js_file, &dest_file).unwrap();

    let css_file = dest_folder.join("index.css");
    if css_file.exists() {
        let dest_folder = if subdir { out_path.join("css") } else { out_path.to_path_buf() };
        let dest_file = dest_folder.join("index.css");
        std::fs::create_dir_all(&dest_folder).unwrap();
        std::fs::copy(&css_file, &dest_file).unwrap();
    }

    let meta_file = path.join("meta.ron");
    if meta_file.exists() {
        let dest_file = out_path.join("meta.ron");
        std::fs::copy(&meta_file, &dest_file).unwrap();
    }
}

fn process_component(from: &Path, to: &Path) {
    std::fs::create_dir_all(to).unwrap();

    let component = from.join(COMPONENT_FILE);
    if !component.exists() {
        println!("cargo:warning=component.kdl not found in {}", from.display());
        return;
    }

    std::fs::copy(component, to.join(COMPONENT_FILE)).expect("failed to copy component file");
}

Давайте посмотрим на точку входа в работу скриптов в src/get-components.ts, где мы получаем список компонентов и их схематичное описание HTML-структуры, после чего инициализируем другие системы, такие, как, например, drag-n-drop, назначаем события для кнопок и инициализируем окно свойств; помимо прочего запускаем таймер, по срабатыванию которого будем проверять, есть ли какие-нибудь изменения в структуре поста.

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

export const componentsCache = new Map<string, Component>();

fetch('/components').then((response: Response) => {
    if (!response.ok) {
        throw new Error("Failed to fetch components");
    }
    return response.json();
}).then((data: Component[]) => {
    for (const comp of data) {
        componentsCache.set(comp.name, comp);
    }

    initDrag();
    initDeleteButton();
    document.querySelectorAll('.grid-block').forEach(b => {
        const block = b as HTMLDivElement;
        const compName = block.dataset.type;
        if (!compName) return;
        const comp = componentsCache.get(compName);
        if (!comp) return;
        initProperties(block, comp, null);
    });

    setInterval(() => {
        if (!markedDirty()) return;

        saveContent();
        unmarkDirty();
    }, 5000);
});

Часть с drag-n-drop и генерацией компонента самая сложная тут, как по-мне, ведь здесь уже появляется довольно много событий и логики, связанной с перемещением элементов. Для начала я храню ссылку на текущий перетаскиваемый блок в переменной dragged. Это позволяет в любой момент понимать, какой именно элемент пользователь сейчас перемещает по рабочей области.

Самая интересная часть находится в функции getDragAfterElement. Её задача — определить, перед каким элементом нужно вставить перетаскиваемый блок. Для этого я перебираю все элементы, получаю их координаты через getBoundingClientRect и вычисляю расстояние между курсором и серединой каждого блока. Затем выбирается ближайший элемент, который находится ниже курсора. Благодаря этому при перемещении компонента по странице он вставляется именно в то место, которое ожидает пользователь, а не просто переносится в конец списка.

Функция initBlockDrag отвечает за подготовку уже существующих блоков к перетаскиванию. Во время начала перетаскивания элемент получает CSS-класс dragging, а после завершения операции этот класс удаляется и редактор помечается как изменённый. Это позволяет как визуально выделять перемещаемый элемент, так и отслеживать необходимость сохранения изменений.

Когда пользователь начинает перетаскивать компонент из боковой панели, в объект DataTransfer записывается его тип. Затем обработчик события drop считывает этот тип, создаёт новый контейнер блока и находит описание компонента в кэше. После этого происходит генерация HTML-разметки через функцию renderHTML, настройка размеров блока в сетке и инициализация панели свойств. В результате достаточно просто перетащить компонент на холст, чтобы он был создан, отрисован и сразу стал доступен для дальнейшего редактирования.

import {markDirty} from "./save-content.ts";
import {canvas, componentsCache} from "./common.ts";
import {initProperties} from "./properties.ts";
import {renderHTML} from "./render.ts";

let dragged: HTMLElement | null = null;

type ClosestElement = {
    offset: number;
    element: HTMLElement | null;
};

function getDragAfterElement(container: HTMLDivElement, y: number) {
    const items: HTMLElement[] = [];
    container.querySelectorAll(".grid-block:not(.dragging)").forEach(el => items.push(el as HTMLElement));

    let closest: ClosestElement = {offset: Number.NEGATIVE_INFINITY, element: null};

    for (const el of items) {
        const box = el.getBoundingClientRect();
        const offset = y - (box.top + box.height / 2);

        if (offset < 0 && offset > closest.offset) {
            closest = {offset, element: el};
        }
    }

    return closest.element;
}

function initBlockDrag(block: HTMLDivElement) {
    block.draggable = true;

    block.addEventListener("dragstart", () => {
        dragged = block;
        block.classList.add("dragging");
    });

    block.addEventListener("dragend", () => {
        block.classList.remove("dragging");
        dragged = null;
        markDirty();
    });
}

document.querySelectorAll(".component").forEach(c => {
    const el = c as HTMLElement;
    c.addEventListener("dragstart", e => {
        const event = e as DragEvent;
        const type = el.dataset.type;

        if (!type) {
            console.error("no type found for component");
            return;
        }

        event.dataTransfer?.setData("type", type);
    });
});

export function initDrag() {
    if (!canvas) {
        console.error("no canvas found");
        return;
    }

    canvas.addEventListener("dragover", e => {
        e.preventDefault();

        const after = getDragAfterElement(canvas as HTMLDivElement, e.clientY);

        if (!dragged) return;

        if (after == null) {
            canvas?.appendChild(dragged);
        } else {
            canvas?.insertBefore(dragged, after);
        }
    });

    canvas.addEventListener("drop", e => {
        e.preventDefault();

        const event = e as DragEvent;

        const type = event.dataTransfer?.getData("type");

        if (type) {
            const block = document.createElement("div");
            block.classList.add("grid-block");
            block.dataset.type = type;

            const comp = componentsCache.get(type);

            if (!comp) {
                console.error("no component found for type: " + type);
                return;
            }

            if (comp.container && comp.container.classes) {
                block.classList.add(...comp.container.classes.split(" ").map(c => c.trim()));
            }

            const {html, state} = renderHTML(comp.html, comp.properties);
            block.appendChild(html);


            if (comp.container && comp.container.col) {
                block.style.gridColumn = "span " + comp.container.col;
                block.dataset.col = comp.container.col;
            } else {
                block.style.gridColumn = "span 12";
                block.dataset.col = "12";
            }

            if (comp.container && comp.container.row) {
                block.style.gridRow = "span " + comp.container.row;
                block.dataset.row = comp.container.row;
            } else {
                block.dataset.row = "1";
            }

            initBlockDrag(block);
            initProperties(block, comp, state);

            canvas?.appendChild(block);

            markDirty();
        }
    });

    document.querySelectorAll(".grid-block").forEach(el => initBlockDrag(el as HTMLDivElement));
}

Предлагаю теперь рассмотреть функцию renderHTML. Тут код схож с тем, что мы делали на бэкенде, за исключением того, что подключаются биндинги. Ещё мне нейронка подсказала, что с помощью ref.current можно получить мутабельную ссылку на элемент, но это не точно, поэтому прошу более опытных в JavaScript подсказать, есть ли разница между просто переменной и полем в структуре.

export type Binding = {
    type: "interactive" | "content",
    content: string,
    properties?: string[]
}

export function renderHTML(html: ComponentHtml, props: ComponentProperty[]): { html: Node, state: ReactiveState } {
    const state = initProps(props, defaultReactiveState(), null);

    const el = renderElement(html, state);

    return {html: el, state};
}

function renderElement(html: ComponentHtml, state: ReactiveState): Node {
    const binding = parseBinding(html.element, state);

    const element = document.createElement(binding.content);
    let ref = {
        current: element
    };

    const children = (html.children ?? []).map(child => {
        if (child.type === "content") {
            return document.createTextNode(child.content);
        } else {
            return renderElement(child.content, state);
        }
    });

    ref.current.append(...children);

    html.children?.forEach((child, i) => initChildrenBindings(child, ref, state))

    initAttributeBindings(html, ref, state);

    if (binding.type === "interactive") {
        initTagBinding(html, ref, state, binding);
    }

    return ref.current;
}

export function parseBinding(value: string, state: ReactiveState): Binding {
    const matches = value.match(/\#\(?(\w+)\)?/);
    if (matches) {
        let result = value;
        const props = [];
        for (const match of matches) {
            if (state[match]) {
                result = result.replace(/(\#\(?\w+\)?)/, state[match].getValue());
                props.push(match);
            }
        }
        return {type: 'interactive', content: result, properties: props};
    } else {
        return {type: "content", content: value};
    }
}

Следующая часть отвечает за реактивность компонентов. Реализуем это с помощью BehaviorSubject из RxJS. Вся идея заключается в том, что каждое свойство компонента представлено отдельным реактивным объектом, который хранит текущее значение и уведомляет подписчиков при его изменении.

Функции defaultReactiveState и initProps занимаются подготовкой состояния компонента. Первая создаёт набор стандартных свойств, необходимых для работы редактора, например размеры блока в сетке. Вторая добавляет пользовательские свойства, описанные в компоненте, и инициализирует их значениями из атрибутов HTML-элемента либо значениями по умолчанию. В результате все параметры компонента оказываются доступны через единый объект реактивного состояния.

Основная работа начинается в функции initReactiveHTML. Она получает описание компонента и запускает рекурсивный обход дерева разметки через функцию walk. Во время обхода система анализирует привязки, содержащиеся в тегах, атрибутах и текстовом содержимом узлов, а затем подписывается на изменения соответствующих свойств. Если какое-либо свойство изменяется, связанная часть интерфейса автоматически обновляется без необходимости полностью пересоздавать компонент.

В отличие от атрибутов и текстового содержимого, имя HTML-тега невозможно изменить напрямую через DOM API. Поэтому при изменении такого свойства в initTagBinding создаётся новый элемент нужного типа, переношу в него дочерние узлы и атрибуты старого элемента, а затем заменяю один узел другим в дереве документа. Благодаря этому компонент может динамически менять структуру своей разметки, оставаясь при этом частью общей реактивной системы.

type Ref = {
    current: HTMLElement
}

export type ReactiveState = {
    [k: string]: BehaviorSubject<any>
}

export function defaultReactiveState(): ReactiveState {
    return {
        col: new BehaviorSubject("12"),
        row: new BehaviorSubject("1"),
    };
}

export function initProps(props: ComponentProperty[], state: ReactiveState, block: HTMLElement | null): ReactiveState {
    for (const prop of props) {
        state[prop.name] = new BehaviorSubject(block?.dataset[prop.name] ?? prop.default ?? "");
    }
    return state;
}

export function initReactiveHTML(html: ComponentHtml, state: ReactiveState, block: HTMLDivElement) {
    if (!block.firstElementChild) {
        console.warn("no children in ", block);
        return;
    }

    const ref = {
        current: block.firstElementChild as HTMLElement
    };

    walk(html, ref, state)
}

export function initTagBinding(html: ComponentHtml, ref: Ref, state: ReactiveState, binding: Binding) {
    for (const prop of binding?.properties ?? []) {
        state[prop]?.subscribe(_ => {
            console.log("prop: ", prop, state[prop]);

            const old = ref.current;

            const newEl = document.createElement(parseBinding(html.element, state).content);

            newEl.append(...Array.from(old.childNodes));

            for (let attr of old.getAttributeNames()) {
                newEl.setAttribute(attr, old.getAttribute(attr) ?? "");
            }

            const parent = old.parentNode;
            if (parent) {
                console.log(parent)
                parent.insertBefore(newEl, old);
                parent.removeChild(old);
            }

            ref.current = newEl;
        });
    }
}

export function initAttributeBindings(html: ComponentHtml, ref: Ref, state: ReactiveState) {
    for (const {name, value} of html.attrs ?? []) {
        if (!value) continue;

        const binding = parseBinding(value, state);
        if (binding.type === "content") {
            ref.current.setAttribute(name, binding.content);
        } else {
            ref.current.setAttribute(name, binding.content);
            for (const prop of binding.properties ?? []) {
                state[prop]?.subscribe(v => ref.current.setAttribute(name, parseBinding(value, state).content));
            }
        }
    }
}

export function initChildrenBindings(html: ComponentHtmlChild, ref: Ref, state: ReactiveState) {
    console.log("child: ", html, ref.current)
    if (ref.current instanceof HTMLElement && html.type === "html") {
        walk(html.content, ref, state);
    } else if (html.type === "content") {
        const bindings = parseBinding(html.content, state);
        for (const prop of bindings.properties ?? []) {
            state[prop]?.subscribe(v => ref.current.textContent = parseBinding(html.content, state).content);
        }
    }
}

function walk(
    html: ComponentHtml,
    ref: Ref,
    state: ReactiveState
) {
    initTagBinding(html, ref, state, parseBinding(html.element, state));
    initAttributeBindings(html, ref, state);

    html.children?.forEach((child, i) => {
        // создаём новую ссылку на дочерний элемент
        const childRef = {current: ref.current.childNodes[i]! as HTMLElement};
        initChildrenBindings(child, childRef, state);
    });
}

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

При выборе блока функция сначала снимает выделение со всех остальных элементов и помечает текущий компонент как активный. Затем очищается содержимое панели свойств и определяется описание компонента. Если информация о компоненте или его реактивном состоянии не была передана заранее, они восстанавливаются автоматически на основе данных, сохранённых в HTML-атрибутах блока. Это позволяет редактору корректно работать даже после повторной загрузки страницы.

Дальше начинается генерация элементов управления. Для каждого свойства из описания компонента создаётся соответствующее поле ввода. На данный момент я использую два типа редакторов: обычное текстовое поле для строковых значений и числовое поле для чисел. Но это пока. При изменении значения происходит сразу несколько действий: новое значение записывается в реактивное состояние, сохраняется в атрибутах блока и помечает документ как изменённый. Благодаря реактивной системе, которую я показал ранее, все связанные части интерфейса обновляются автоматически.

В конце дополнительно создаются настройки размеров блока в сетке редактора. Пользователь может изменить количество занимаемых колонок и строк, после чего значения сразу применяются через свойства gridColumn и gridRow. Таким образом панель свойств не только позволяет настраивать параметры конкретного компонента, но и управляет его расположением и размерами внутри страницы.

export function initProperties(block: HTMLDivElement, comp: Component | null = null, state: ReactiveState | null) {
    block.addEventListener("click", () => {
        document.querySelectorAll(".grid-block.selected")
            .forEach(el => el.classList.remove("selected"));

        block.classList.add("selected");

        if (!propertiesBody) {
            console.error("no properties body found");
            return;
        }

        propertiesBody.innerHTML = "";

        if (!comp) {
            const type = block.dataset.type;
            if (!type) {
                console.error("no block type found")
                return;
            }

            const component = componentsCache.get(type);
            if (!component) {
                console.error("no component found for type: " + block.dataset.type);
                return;
            }
            comp = component;
        }

        if (!state) {
            state = initProps(comp.properties, defaultReactiveState(), block);
            initReactiveHTML(comp.html, state, block);
        }

        for (const prop of comp.properties) {
            switch (prop.type_name) {
                case "Number":
                    createNumber(
                        propertiesBody,
                        prop.name,
                        block.dataset[prop.name] ?? prop.default ?? "0",
                        prop.min,
                        prop.max,
                        value => {
                            state![prop.name]?.next(value);
                            block.dataset[prop.name] = value;
                            markDirty();
                        }
                    )
                    break;
                case null:
                case undefined:
                case "String":
                    createText(
                        propertiesBody,
                        prop.name,
                        block.dataset[prop.name] ?? prop.default ?? "",
                        value => {
                            state![prop.name]?.next(value);
                            block.dataset[prop.name] = value;
                            markDirty();
                        }
                    )
                    break;
            }
        }

        /* GRID WIDTH */
        createNumber(
            propertiesBody,
            "Width (columns)",
            block.dataset.col ?? "12",
            "1", "12",
            value => {
                block.dataset.col = value;
                block.style.gridColumn = `span ${value}`;
            }
        );

        /* GRID HEIGHT */
        createNumber(
            propertiesBody,
            "Height (rows)",
            block.dataset.row ?? "1",
            "1", "",
            value => {
                block.dataset.row = value;
                block.style.gridRow = `span ${value}`;
            }
        );
    });
}

function createText(panel: HTMLElement, label: string, defaultVal: string, onChange: (value: string) => void) {
    const wrap = document.createElement("div");
    wrap.className = "property";

    const l = document.createElement("label");
    l.textContent = label;

    const ta = document.createElement("textarea");
    ta.value = defaultVal;

    ta.addEventListener("input", e => {
        onChange((e.currentTarget as HTMLTextAreaElement).value);
        markDirty();
    });

    wrap.append(l, ta);
    panel.appendChild(wrap);
}

function createNumber(panel: HTMLElement, label: string, defaultVal: string | undefined, min: string | undefined, max: string | undefined, onChange: (value: string) => void) {
    const wrap = document.createElement("div");
    wrap.className = "property";

    const l = document.createElement("label");
    l.textContent = label;

    const ta = document.createElement("input");
    ta.type = "number";
    ta.min = min ?? "";
    ta.max = max ?? "";
    ta.value = defaultVal ?? "0";

    ta.addEventListener("input", e => {
        onChange((e.currentTarget as HTMLTextAreaElement).value);
        markDirty();
    });

    wrap.append(l, ta);
    panel.appendChild(wrap);
}

Осталось показать лишь то, как происходит автосохранение контента. Для этого используется ранее показанный таймер, который дёргает функцию saveContent. В свою очередь, эта функция собирает всю информацию из блоков и отправляет запрос типа PATCH на сервер. На странице ещё есть небольшой индикатор о том, что изменения были сохранены, вот его также показывает saveContent в случае успешного успеха.

let hasChanges = false;
const saveIndicator = document.getElementById("save-indicator");
let saveIndicatorTimeout: NodeJS.Timeout | null = null;

type Block = {
    name: string;
    data: Record<string, string>;
}

export function markDirty() {
    hasChanges = true;
}

export function markedDirty(): boolean {
    return hasChanges;
}

export function unmarkDirty() {
    hasChanges = false;
}

function showSaved() {
    saveIndicator?.classList.add("visible");

    if (saveIndicatorTimeout) {
        clearTimeout(saveIndicatorTimeout);
        return;
    }

    saveIndicatorTimeout = setTimeout(() => {
        saveIndicator?.classList.remove("visible");
    }, 2000);
}

function collectContent() {
    const blocks: Block[] = [];

    document.querySelectorAll(".grid-block").forEach((el: Element) => {
        const block = el as HTMLDivElement;

        let data: Record<string, string> = {
            col: block.dataset.col ?? "12",
            row: block.dataset.row ?? "1",
        }

        const type = block.dataset.type;
        if (!type) return;

        const comp = componentsCache.get(type);

        if (comp) {
            const propsData: Record<string, string> = {};
            for (const prop of comp.properties) {
                propsData[prop.name] = <string>block.dataset[prop.name] ?? prop.default;
            }

            data = {...data, ...propsData};
        }

        blocks.push({
            name: type,
            data,
        });
    });

    return blocks;
}

export function saveContent() {
    const query = get_query();
    const post_id = query["id"];

    if (!post_id) {
        console.error("No post_id found in query");
        return;
    }

    const new_content = collectContent();
    const body = JSON.stringify({
        post_id,
        new_content,
    });

    fetch('/api/posts', {
        method: 'PATCH',
        headers: {
            'Content-Type': 'application/json',
        },
        body,
    }).then(
        res => {
            if (res.ok) {
                console.log('changes saved');
                showSaved();
            } else {
                console.error(res);
            }
        },
        err => console.error(err)
    );
}

После всего этого мы получаем следующую картину:

тестируем редактор статей

тестируем редактор статей

Итого

У нас получился достаточно интересный эксперимент. Изначально я просто хотел получить лёгкий блоговый движок, который не будет упираться в ограничения моего сервера и не потребует запуска базы данных, а по пути пришлось написать собственную VFS, систему маршрутизации, механизм горячей перезагрузки контента, локализацию, редактор статей и даже декларативную систему компонентов.

Конечно, проект пока нельзя назвать законченным. В коде хватает мест, которые можно оптимизировать или переработать. Например, было бы неплохо кэшировать шаблоны upon, реализовать нормальную систему патчей вместо периодической отправки всего содержимого статьи, добавить более богатый DSL для компонентов и избавиться от некоторых исторических решений, которые остались после ранних экспериментов с архитектурой. Да и в целом местами код явно писал человек, который хотел поскорее увидеть результат, а не довести всё до идеала.

Тем не менее уже сейчас движок умеет то, что я от него хотел: хранит контент в обычных файлах, поддерживает SSR, автоматически подхватывает изменения без перезапуска, работает с локализациями и предоставляет визуальный редактор статей с расширяемыми компонентами. При этом вся система остаётся достаточно лёгкой и может работать даже на очень скромном железе.

Если вам интересно посмотреть на полный исходный код, найти мои архитектурные ошибки или предложить улучшения, то добро пожаловать в репозиторий. Лично для меня этот проект оказался хорошей возможностью глубже разобраться в асинхронном Rust, устройстве HTTP-серверов и некоторых особенностях фронтэнда, с которым я работаю заметно реже, чем с бэкендом.