Недавно я решил завести собственный блог. Сначала посмотрел в сторону 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-серверов и некоторых особенностях фронтэнда, с которым я работаю заметно реже, чем с бэкендом.
























