Compare commits

...

31 Commits

Author SHA1 Message Date
53e62fcb68 I just bumped the version so I could test.
All checks were successful
Build Tavernworks / Explore-Gitea-Actions (push) Successful in 3m22s
I needed a fast test to make sure the Gitea server repository was
working.
2025-10-21 12:17:38 -04:00
04c2182705 The logos for the project.
All checks were successful
Build Tavernworks / Explore-Gitea-Actions (push) Successful in 2m41s
2025-10-17 10:29:41 -04:00
f023f36558 Adjusted the responsive layout targets.
All checks were successful
Build Tavernworks / Explore-Gitea-Actions (push) Successful in 3m11s
2025-10-16 18:47:20 -04:00
62a2c4f5bc Fixed a spelling mistake in the blog css.
All checks were successful
Build Tavernworks / Explore-Gitea-Actions (push) Successful in 3m11s
2025-10-16 18:32:01 -04:00
87bb1459b8 Bumped the bard version for publishing.
All checks were successful
Build Tavernworks / Explore-Gitea-Actions (push) Successful in 3m49s
2025-10-16 13:58:36 -04:00
8dd20cb0cb Fixed responsiveness of the blog.
All checks were successful
Build Tavernworks / Explore-Gitea-Actions (push) Successful in 2m44s
Also added the publish date to a post.
2025-10-16 13:56:58 -04:00
55f1ac22b0 Changed the target dir tot he home directory bin.
All checks were successful
Build Tavernworks / Explore-Gitea-Actions (push) Successful in 2m37s
2025-10-11 20:43:47 -04:00
c3ae332b0b Changed the target directory to place executables.
Some checks failed
Build Tavernworks / Explore-Gitea-Actions (push) Failing after 2m38s
2025-10-11 20:34:23 -04:00
dede2e9072 Fixed the build script to use this repository.
Some checks failed
Build Tavernworks / Explore-Gitea-Actions (push) Failing after 2m42s
2025-10-11 20:27:26 -04:00
4c03aea947 Fixing the build script for gitea.
Some checks failed
Build Tavernworks / Explore-Gitea-Actions (push) Failing after 1m49s
2025-10-11 20:19:40 -04:00
ee82498161 Adding the build testing script.
Some checks failed
Build Tavernworks / Explore-Gitea-Actions (push) Failing after 7s
This will also install loreweaver for use by the blog.
2025-10-11 18:43:17 -04:00
d822ecdf84 Fixed up the OpenGraph meta. 2025-10-11 18:38:46 -04:00
e8a4c19fdb Post page now is completely done in SSR.
This was done to make loading time faster as it's all static information
and to make it so blog posts can be linked to and get OpenGraph data.
2025-10-10 11:19:14 -04:00
1a57f3d143 openGraph image now set properly to default image. 2025-10-10 01:24:41 -04:00
1a82e856d9 use_resource is the correct thing to use.
The top components can use_resource and be properly setup during
hydration. The sub components need to use_server_resource so that they
can be hydrated later.
2025-10-10 00:26:17 -04:00
e49ecbac4d The TagSelector needed to stay a use_resource. 2025-10-10 00:01:31 -04:00
bca3e9f939 Switching all server function calls.
Now server function calls use, use_server_future. It seems to be working
now, unlike in 0.6.3
2025-10-09 23:49:35 -04:00
3fe3d874ca Added the ability to specify images.
The blog title can now be set to be an image. If not it will default to
the text. If no text it will just be "Blog".

A Post has now been given a default post image that will be used for all
posts for their openGraph sharing. This can later be expanded to allow
a blog post to have a desired image.
2025-10-09 17:00:56 -04:00
725824003b Updated to use the new Dioxus v0.7.0-rc.1
The system is using the nightly toolchain now due to Dioxus needing it.
2025-10-08 15:17:48 -04:00
ce80af94ee Cleaned up some Author related displays.
It was determined to use the Author's desired name and no handle. The
handle will only be used for unique linking.

Also, the CyberMages LLC was dropped from the Author metadata until a
company can be specified per Author. Not all users will be associated
with CyberMages.
2025-10-07 11:31:07 -04:00
cba2e95290 Doing some overall warning cleanup. 2025-09-28 20:25:19 -04:00
468b9449b2 Client side settings can now be specified.
Here we use a context provider on the app itself to handle passing the
settings to our library. This way even the layout can use them.
2025-09-28 15:12:03 -04:00
6efec6bd22 TagList and TagItem are now used in the post nav.
The TagItem is now used properly in the post nav area. Only the
ToggleTags are different now.

Bumped the patch version.
2025-09-27 14:22:26 -04:00
3f4440ec2f Switching to a single stylesheet.
Also bumped the bard version.
2025-09-27 13:29:08 -04:00
e7969c8050 Fixed the TagSelector reactivity.
use_server_future was not reliably re-running when url_tag changed during
direct URL navigation, causing tags to remain unselected. use_resource
provides consistent reactivity across all navigation methods and handles
both data fetching and selection logic atomically.
2025-09-27 13:21:56 -04:00
c73c99bf48 Added that tavern is from the cybermages registry. 2025-09-25 11:45:04 -04:00
40b211d06c Bumped the version for tavern. 2025-09-25 11:43:31 -04:00
fd51c7c0bb Bumping all versions to the same minor version. 2025-09-25 11:39:56 -04:00
c5c433edb5 Adding initial README files. 2025-09-25 11:38:05 -04:00
54dc751dca Fixing the Router for the exmaple. 2025-09-25 11:37:36 -04:00
655dc8e90f Changing loremaster to loreweaver. 2025-09-25 11:36:59 -04:00
42 changed files with 2481 additions and 1415 deletions

View File

@ -0,0 +1,41 @@
name: Build Tavernworks
run-name: Building on Silverymoon by ${{ gitea.actor }}.
on:
push:
branches:
- main
jobs:
Explore-Gitea-Actions:
runs-on: FreeBSD-14.1
env:
SOURCE_DIR: tavernworks
TARGET_DIR: /home/gitea/bin
steps:
- name: Setup Workspace
run: |
rm -rf ${{ github.workspace }}/*
echo "Source Dir: $SOURCE_DIR"
- name: Clone Repo
uses: https://gitea.com/actions/checkout@v4
with:
repository: CyberMages/tavernworks
token: ${{ secrets.CM_GIT_TOKEN }}
path: ${{ env.SOURCE_DIR }}
- name: Build Repo
run: |
pwd
ls -la
cd "$SOURCE_DIR"
echo "Current Shell: $SHELL"
echo "PATH=$PATH"
. ~/.profile
echo "PATH=$PATH"
echo "Building release."
cargo build --release
echo "Installing loreweaver."
cp ./target/release/loreweaver "$TARGET_DIR"
- name: Clean Workspace
run: |
rm -rf ${{ github.workspace }}/*
ls -la

2062
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
members = [
"tavern",
"bard",
"loremaster",
"loreweaver",
"blog_test"]
resolver = "2" # Enables modern dependency resolution

49
README.md Normal file
View File

@ -0,0 +1,49 @@
# Tavernworks
Tavernworks is a modular blog system written in Rust. It is designed to parse,
compile, and serve structured blog content using a cohesive set of themed
libraries. The system is centered around the `tavern` content engine and is
supported by a publisher CLI and a rendering frontend.
## Overview
TavernWorks consists of the following components:
### tavern
The core library that defines the data model, parses content, and provides the
API to read and write blog data. It is responsible for turning a repository of
Markdown files and metadata into an in-memory structure or database.
### loreweaver
A command-line tool that uses `tavern` to compile a blog repository into a
SQLite database. It reads a `Tavern.toml` metadata file and outputs a portable
database that can be served or inspected.
### bard
A Dioxus-based frontend library that renders blog content from the database.
It provides components to create a full blog UI using data produced by
`loreweaver`.
### blog_test
A test project that verifies the full system from content to rendering. It is
used to validate that blog repositories are parsed, compiled, and rendered
correctly across all components.
---
## Copyright & License
Copyright 2025 CyberMages LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this library except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

View File

@ -1,6 +1,6 @@
[package]
name = "bard"
version = "0.0.23"
version = "0.3.17"
edition = "2024"
description = "Dioxus components that will display a Tavern blogging system Blog."
repository = "/CyberMages/tavern"
@ -14,12 +14,12 @@ name = "blog"
path = "examples/blog.rs"
[dependencies]
dioxus = { version = "*", features = ["router", "fullstack"] }
tavern = { version = "0.2.9", path = "../tavern", optional = true}
dioxus = { version = "=0.7.0-rc.1", features = ["router", "fullstack"] }
tavern = { version = "0.3.0", path = "../tavern", registry="cybermages", optional = true}
tokio = { version = "1.0", features = ["rt", "macros"], optional = true }
[features]
default = ["web"]
default = ["tavern"]
web = ["tavern", "dioxus/web"]
server = ["tavern/database", "dioxus/server", "tokio"]

View File

@ -1,7 +1,72 @@
# Tavern
# Bard
A blogging system that will allow you to write your blog in Markdown and then
display it in HTML using Dioxus.
Bard is the frontend rendering library for the Tavernworks blog system.
It provides Dioxus components for rendering blog content that has been compiled
into a SQLite database by Loreweaver and parsed by the Tavern engine.
Bard supports both server-side and client-side rendering and is designed to be
integrated into Dioxus applications.
## Features
- Dioxus components for routing and rendering blog pages
- Works with content stored in a SQLite database
- Compatible with `dioxus-web` and `dioxus-desktop`
- Optional server integration via Axum for local hosting
## Usage
To use Bard in a Dioxus application, you must:
1. Initialize the database using `bard::init_database`.
2. Add the `Router::<bard::Page>` component to your app.
3. Optionally, define routes that include Bard under your own layout.
### Example
```rust
#[component]
fn App() -> Element {
rsx! {
Router::<Page> {}
}
}
#[derive(Routable, Clone, PartialEq)]
pub enum Page {
#[layout(Navbar)]
#[route("/")]
Home {},
#[child("/blog")]
Bard
{
child: bard::Page
},
#[route("/:..route")]
PageNotFound { route: Vec<String> }
}
```
### Server-side Initialization
To serve the blog using Axum and load the database:
```rust
tokio::runtime::Runtime::new()
.unwrap()
.block_on(async move {
bard::init_database("path/to/tavern.db").await;
});
```
## Features
Enable the following Cargo features as needed:
- `server`: Enables Axum support for database-backed serving
- `web`: Enables Dioxus web support
---

View File

@ -1,3 +1,16 @@
.visually_hidden
{
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
border: 0 !important;
white-space: nowrap !important;
}
.blog_style
{
display: flex;
@ -17,3 +30,257 @@
container-type: inline-size;
}
}
.blog_list
{
display: flex;
flex-direction: column;
width: 100%;
order: 5;
.blog_title
{
margin-top: 0px;
color: var(--accent-color);
}
.blog_title_image
{
width: 23rem;
align-self: center;
margin-bottom: 25px;
}
.blog_item
{
margin-bottom: 30px;
h2
{
margin: 0px;
color: var(--accent-color);
a:link, a:visited
{
color: var(--accent-color);
}
a:hover
{
color: var(--text-color);
}
}
p
{
margin: 5px 0px 0px 0px;
display: block;
}
}
}
.blog_post
{
order: 5;
h1
{
margin: 0px;
color: var(--accent-color);
}
h4
{
margin-top: 10px;
margin-bottom: 30px;
}
li
{
margin-bottom: 5px;
}
p
{
margin-top: 0px;
margin-bottom: 20px;
}
.blog_post_tale
{
pre
{
padding-left: 20px;
}
.embedded_video
{
margin-top: 50px;
display: flex;
align-content: center;
justify-content: center;
iframe
{
box-shadow: 0 0 80px var(--accent-color);
}
}
}
}
/* Tags will have a class of "tag_{tag_name}" that allows specific
* customization.
*
* .tag_easy
* {
* background-color: #248721;
* }
*
* */
.blog_tag
{
display: flex;
flex-direction: row;
.tag_list
{
display: flex;
flex-direction: row;
}
}
.blog_nav
{
order: 1;
display: flex;
flex-direction: column;
margin-top: 185px;
margin-right: 70px;
position:sticky;
top: 150px;
height: 50vh;
fieldset
{
border: none;
padding: 0;
margin: 0;
min-inline-size: unset;
}
legend
{
min-width: 130px;
padding: 0;
margin: 0;
font-weight: bold;
}
.tag_list
{
display: flex;
flex-direction: column;
}
.toggle_button {
display: inline-block;
margin-top: 5px;
margin-right: 5px;
padding: 0px 6px;
border: 2px solid var(--text-color);
border-radius: 5px;
color: var(--text-color);
cursor: pointer;
user-select: none;
transition: 0.1s ease-in;
}
.toggle_button:hover {
color: var(--accent-color);
border-color: var(--accent-color);
transform: translateY(-2px);
}
.toggle_button input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
/* Checked state using :has() — modern browsers only */
.toggle_button:has(input:checked) {
color: var(--accent-color);
border-color: var(--accent-color);
}
@container site (max-width: 1230px)
{
display: none;
}
}
.tag_list
{
list-style: none;
padding: 0px;
margin: 0px;
.tag_item
{
margin-top: 5px;
margin-right: 5px;
a
{
display: inline-block;
padding: 0px 4px;
text-decoration: none;
border: 2px solid var(--text-color);
border-radius: 5px;
color: var(--text-color);
transition: 0.1s ease-in;
&:hover
{
color: var(--accent-color);
border: 2px solid var(--accent-color);
transform: translateY(-2px);
}
}
}
}
.video_link
{
display: none;
width: 100%;
justify-content: center;
}
@media screen and (max-width: 1000px)
{
.blog_post .blog_post_tale
{
.video_link
{
display: flex;
}
.embedded_video
{
display: none;
}
}
}

View File

@ -1,230 +0,0 @@
.blog_style
{
display: flex;
width: 100%;
height: 100%;
background: var(--bg-color);
padding: 50px 0px;
justify-content: center;
container-name: site;
container-type: inline-size;
.page_content
{
display: flex;
width: 67%;
container-name: page;
container-type: inline-size;
}
}
.blog_nav
{
width: 15%;
padding: 0px 20px;
h3
{
margin-bottom: 10px;
}
ul
{
list-style: none;
li
{
}
}
@container site (max-width: 1230px)
{
display: none;
}
.social
{
/* Hide .social until the font is loaded */
display: flex;
align-items: center;
gap: 15px;
margin: 25px 0;
font-family: cm_social;
a
{
color: var(--text-color);
font-size: 46px;
text-decoration: none;
background: transparent;
width: 65px;
height: 65px;
border-radius: 50%;
display: flex;
align-items: flex-end;
justify-content: center;
transition: 0.4s ease;
border: 4px solid var(--text-color);
&:hover
{
color: var(--accent-color);
border: 4px solid var(--accent-color);
transform: translateY(-7px);
}
}
}
}
}
.blog_item_area
{
.blog_list
{
list-style: none;
.blog_item
{
margin-bottom: 50px;
h1
{
color: var(--accent-color);
}
h4
{
margin: 10px 0px;
}
p
{
display: block;
}
}
}
}
.blog_article
{
h1
{
color: var(--accent-color);
}
h4
{
margin-top: 10px;
margin-bottom: 30px;
}
p
{
margin-bottom: 20px;
}
.embeded_video
{
margin-top: 50px;
display: flex;
align-content: center;
justify-content: center;
iframe
{
box-shadow: 0 0 80px var(--accent-color);
}
}
}
.tag_style
{
display: block;
.tag_list
{
list-style: none;
.tag_item
{
display: inline-block;
margin: 5px 5px 0px 0px;
a
{
display: block;
padding: 0px 4px;
text-decoration: none;
border: 2px solid var(--text-color);
border-radius: 5px;
color: var(--text-color);
transition: 0.1s ease-in;
&:hover
{
color: var(--accent-color);
border: 2px solid var(--accent-color);
transform: translateY(-2px);
}
}
}
}
}
.easy
{
background-color: #248721;
}
.medium
{
background-color: #d6a318;
}
.hard
{
background-color: #d92121;
}
.social
{
background: transparent;
font-family: cm_social;
margin: 15px 0px;
a
{
color: var(--text-color);
font-size: 30px;
text-decoration: none;
background: transparent;
width: 45px;
height: 45px;
border-radius: 50%;
display: flex;
align-items: flex-end;
justify-content: center;
transition: 0.4s ease;
border: 4px solid var(--text-color);
&:hover
{
color: var(--accent-color);
border: 4px solid var(--accent-color);
transform: translateY(-7px);
}
}
}

View File

@ -1,27 +0,0 @@
.blog_list_style
{
.blog_list
{
list-style: none;
.blog_item
{
margin-bottom: 50px;
h1
{
color: var(--accent-color);
}
h4
{
margin: 10px 0px;
}
p
{
display: block;
}
}
}
}

View File

@ -1,117 +0,0 @@
.blog_style
{
display: flex;
width: 100%;
height: 100%;
background: var(--bg-color);
padding: 50px 0px;
justify-content: center;
container-name: site;
container-type: inline-size;
.page_content
{
display: flex;
width: 67%;
container-name: page;
container-type: inline-size;
}
}
.blog_nav_style
{
width: 15%;
padding: 0px 20px;
order: 5;
h3
{
margin-bottom: 10px;
}
ul
{
list-style: none;
li
{
}
}
@container site (max-width: 1230px)
{
display: none;
}
.social
{
/* Hide .social until the font is loaded */
display: flex;
align-items: center;
gap: 15px;
margin: 25px 0;
font-family: cm_social;
a
{
color: var(--text-color);
font-size: 22px;
text-decoration: none;
background: transparent;
width: 35px;
height: 35px;
border-radius: 50%;
display: flex;
align-items: flex-end;
justify-content: center;
transition: 0.4s ease;
border: 4px solid var(--text-color);
&:hover
{
color: var(--accent-color);
border: 4px solid var(--accent-color);
transform: translateY(-7px);
}
}
}
.tag_style
{
display: block;
.tag_list
{
display: inline-flex;
flex-direction: column;
list-style: none;
.tag_item
{
display: inline-block;
margin: 5px 5px 0px 0px;
a
{
display: inline-flex;
padding: 0px 4px;
text-decoration: none;
border: 2px solid var(--text-color);
border-radius: 5px;
color: var(--text-color);
transition: 0.1s ease-in;
&:hover
{
color: var(--accent-color);
border: 2px solid var(--accent-color);
transform: translateY(-2px);
}
}
}
}
}
}

View File

@ -1,119 +0,0 @@
.blog_post_style
{
order: 1;
h1
{
color: var(--accent-color);
}
h4
{
margin-top: 10px;
margin-bottom: 30px;
}
p
{
margin-bottom: 20px;
}
.embeded_video
{
margin-top: 50px;
display: flex;
align-content: center;
justify-content: center;
iframe
{
box-shadow: 0 0 80px var(--accent-color);
}
}
}
.tag_style
{
display: block;
.tag_list
{
list-style: none;
.tag_item
{
display: inline-block;
margin: 5px 5px 0px 0px;
a
{
display: block;
padding: 0px 4px;
text-decoration: none;
border: 2px solid var(--text-color);
border-radius: 5px;
color: var(--text-color);
transition: 0.1s ease-in;
&:hover
{
color: var(--accent-color);
border: 2px solid var(--accent-color);
transform: translateY(-2px);
}
}
}
}
}
.easy
{
background-color: #248721;
}
.medium
{
background-color: #d6a318;
}
.hard
{
background-color: #d92121;
}
.social
{
background: transparent;
font-family: cm_social;
margin: 15px 0px;
a
{
color: var(--text-color);
font-size: 30px;
text-decoration: none;
background: transparent;
width: 45px;
height: 45px;
border-radius: 50%;
display: flex;
align-items: flex-end;
justify-content: center;
transition: 0.4s ease;
border: 4px solid var(--text-color);
&:hover
{
color: var(--accent-color);
border: 4px solid var(--accent-color);
transform: translateY(-7px);
}
}
}

View File

@ -1,54 +0,0 @@
.blog_tag_style
{
display: block;
.tag_list
{
list-style: none;
.tag_item
{
display: inline-block;
margin: 5px 5px 0px 0px;
a
{
display: block;
padding: 0px 4px;
text-decoration: none;
border: 2px solid var(--text-color);
border-radius: 5px;
color: var(--text-color);
transition: 0.1s ease-in;
&:hover
{
color: var(--accent-color);
border: 2px solid var(--accent-color);
transform: translateY(-2px);
}
}
}
}
.none
{
background-color: var(--bg-color);
}
.easy
{
background-color: #248721;
}
.medium
{
background-color: #d6a318;
}
.hard
{
background-color: #d92121;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

View File

@ -1,47 +1,41 @@
use std::borrow::Borrow;
use std::collections::HashSet;
use std::hash::Hash;
use dioxus::prelude::*;
use tavern::{Adventurer, Legend, Lore, Tale};
use crate::togglable::Togglable;
use crate::page::Page;
use crate::server::*;
use crate::settings::{BardSettings};
use super::tags::*;
#[component]
pub fn BlogAuthor() -> Element
{
rsx!
{
section
{
class: "blog_author_style",
}
}
}
#[component]
pub fn BlogItem(title: String, slug: String, author: String, summary: String,
tags: Vec<String>)
-> Element
{
println!("Blog Item: {title} -- [{tags:?}]");
let author_future = use_server_future(move ||
{
let handle: String = author.clone();
async move { get_author(handle).await }
})?;
rsx!
{
li
article
{
key: "{slug}",
class: "blog_item",
Link
h2
{
to: Page::Post { slug: slug.clone() },
h1 { "{title}" }
Link
{
to: Page::Post { slug: slug.clone() },
"{title}"
}
}
TagList
@ -55,7 +49,24 @@ pub fn BlogItem(title: String, slug: String, author: String, summary: String,
}
}
h4 { "Author: {author}" }
if let Some(Ok(Some(adventurer))) = (author_future.value())()
{
p
{
b
{
"Author: "
a
{
href: "{adventurer.legend.profile}", "{adventurer.name}"
}
}
}
}
else
{
p { "Loading author..." }
}
p { "{summary}" }
}
@ -66,54 +77,106 @@ pub fn BlogItem(title: String, slug: String, author: String, summary: String,
#[component]
pub fn BlogList(tags: Signal<HashSet<String>>, children: Element) -> Element
{
let list = use_server_future(move || {
let categories = tags().iter().cloned().collect();
// Retrieve the provided settings from context.
let settings = use_context::<BardSettings>();
let list = use_resource(move ||
{
let tags = tags();
let categories: Vec<String> = tags.iter().cloned().collect();
async move { get_blog_list(categories).await }
})?;
});
rsx!
{
section
{
class: "blog_list_style",
ul
{
class: "blog_list",
class: "blog_list",
if let Some(Ok(lores)) = &*list.read()
match settings.blog_image
{
Some(image) =>
{
for lore in lores
match settings.blog_name
{
BlogItem
Some(title) =>
{
title: lore.title.clone(),
slug: lore.slug.clone(),
author: lore.author.clone(),
summary: lore.summary.clone(),
tags: lore.tags.clone()
rsx!
{
h1 { class: "blog_title visually_hidden", "{title}" }
img { class: "blog_title_image", alt: "{title} blog logo", src: image }
}
}
None =>
{
rsx!
{
h1 { class: "blog_title visually_hidden", "Blog" }
img { class: "blog_title_image", alt: "Blog logo", src: image }
}
}
}
}
else if let Some(Err(e)) = &*list.read()
{
p { "Unable to show post header." }
p { "{e}" }
}
else
{
p { "Loading..." }
}
{children}
None =>
{
match settings.blog_name
{
Some(title) =>
{
rsx!
{
h1 { class: "blog_title", "{title}" }
}
}
None =>
{
rsx!
{
h1 { class: "blog_title visually_hidden", "Blog" }
}
}
}
}
}
if let Some(Ok(lores)) = &*list.read()
{
for lore in lores
{
BlogItem
{
key: "{lore.slug}",
title: lore.title.clone(),
slug: lore.slug.clone(),
author: lore.author.clone(),
summary: lore.summary.clone(),
tags: lore.tags.clone()
}
}
}
else if let Some(Err(_e)) = &*list.read()
{
p { "Please choose a category to see related blog posts." }
}
else
{
p { "Loading..." }
}
{children}
}
}
}
#[component]
pub fn ToggleTag(tag: String, toggled_tags: Signal<HashSet<String>>) -> Element
{
let is_checked = toggled_tags.read().is_toggled(&tag);
rsx!
{
label
@ -123,72 +186,158 @@ pub fn ToggleTag(tag: String, toggled_tags: Signal<HashSet<String>>) -> Element
input
{
r#type: "checkbox",
checked: toggled_tags.read().is_toggled(&tag),
checked: is_checked,
onchange: move |_|
{
toggled_tags.write().toggle(&tag.clone());
},
}
span { "{tag}" }
"{tag}"
}
}
}
// Using use_resource instead of use_server_future for URL-dependent tag selection
//
// While use_server_future should theoretically be reactive when reading signals in the
// closure (per Dioxus docs), in practice it doesn't reliably re-run when the url_tag
// signal changes, especially during direct URL navigation (typing URLs in browser).
//
// use_resource provides more reliable reactivity for this use case because:
// 1. It explicitly depends on url_tag and consistently re-runs when it changes
// 2. It handles both async data fetching AND selection logic in a single atomic operation
// 3. It works consistently across all navigation methods (links, direct URLs, etc.)
// 4. It avoids timing coordination issues between separate hooks
//
// This approach combines fetching available tags from the server with determining
// which tags should be selected based on the current URL, returning both pieces
// of data together for clean state management.
#[component]
pub fn TagSelector(show_all: Signal<bool>, toggled_tags: Signal<HashSet<String>>) -> Element
pub fn TagSelector(url_tag: ReadSignal<String>,
toggled_tags: Signal<HashSet<String>>) -> Element
{
let toggle_all: bool = show_all();
println!("Tag Selector toggled tags: {:?}", toggled_tags());
let tags_future = use_server_future(move || async move { get_tags().await })?;
use_effect(move ||
// Use use_resource to handle both fetching tags AND initializing selection
let tags_and_selection = use_resource(move ||
{
if let Some(Ok(tags)) = &*tags_future.read()
let current_url = url_tag();
async move
{
if toggle_all
match get_tags().await
{
for tag in tags
Ok(available_tags) =>
{
toggled_tags.write().insert(tag.clone());
// Determine what should be selected based on URL
let should_show_all = current_url.is_empty() || current_url == "all";
let selected_tags: HashSet<String> = if should_show_all
{
available_tags.iter().cloned().collect()
}
else
{
let url_categories = convert_categories(&current_url);
let filtered: HashSet<String> = url_categories.into_iter()
.filter(|tag| available_tags.contains(tag))
.collect();
filtered
};
Ok((available_tags, selected_tags))
}
Err(e) =>
{
eprintln!("RESOURCE - Error: {}", e);
Err(e)
}
}
}
});
// Separate effect to update toggled_tags when resource completes
// This separates reading the resource from writing to toggled_tags
use_effect(move ||
{
if let Some(Ok((_, selected_tags))) = &*tags_and_selection.read()
{
toggled_tags.set(selected_tags.clone());
}
});
rsx!
{
section
{
class: "blog_nav_style",
div
class: "blog_nav",
h2 { class: "visually_hidden", "Filters and Navigation" }
fieldset
{
class: "tag_style",
h2 { "Categories" }
legend { "Category Filter" }
ul
{
class: "tag_list",
if let Some(Ok(tags)) = tags_future()
match &*tags_and_selection.read()
{
for tag in tags
Some(Ok((available_tags, _))) =>
{
li
rsx!
{
key: "selector-{tag}",
class: "tag_item",
ToggleTag
for tag in available_tags
{
tag: &tag,
toggled_tags: toggled_tags
li
{
key: "selector-{tag}",
class: "tag_item",
ToggleTag
{
tag: tag.clone(),
toggled_tags: toggled_tags
}
}
}
}
}
Some(Err(_)) =>
{
rsx!
{
li { "Error loading tags" }
}
}
None =>
{
rsx!
{
li { "Loading tags..." }
}
}
}
}
}
}
}
}
fn convert_categories(categories: &str) -> HashSet<String>
{
categories
.split('+')
.filter(|s| !s.is_empty() && *s != "all")
.map(str::to_string)
.collect()
}

View File

@ -1,12 +1,9 @@
use std::borrow::Borrow;
use std::collections::HashSet;
use std::hash::Hash;
use tavern::{Adventurer, Tale};
use dioxus::prelude::*;
use tavern::{Adventurer, Legend, Lore, Tale};
use crate::togglable::Togglable;
use crate::page::Page;
use crate::settings::BardSettings;
use crate::server::*;
use super::tags::*;
@ -26,30 +23,87 @@ pub fn BlogAuthor() -> Element
#[component]
pub fn PostHeaderAuthor(adventurer: Adventurer) -> Element
pub fn PostHeaderAuthor(author: Option<Adventurer>, publish_date: String) -> Element
{
rsx!
match author
{
h4
Some(adventurer) =>
{
"Author: ",
a { href: "{adventurer.legend.profile}", "{adventurer.name} @{adventurer.handle}" }
rsx!
{
document::Meta
{
name: "author",
content: "{adventurer.name}"
}
i
{
"Published: {publish_date}"
}
p
{
b
{
"Author: ",
a { href: "{adventurer.legend.profile}", "{adventurer.name}" }
}
}
}
}
None=>
{
rsx!
{
document::Meta
{
name: "author",
content: "Unknown"
}
i
{
"Published: {publish_date}"
}
p
{
b
{
"Author: Unknown"
}
}
}
}
}
}
#[component]
pub fn PostHeader(tale: Tale) -> Element
pub fn PostHeader(tale: Tale, adventurer: Option<Adventurer>) -> Element
{
let author_future = use_server_future(move ||
{
let handle: String = tale.lore.author.clone();
// Get the pages URL.
let url: Page = use_route();
async move { get_author(handle).await }
})?;
// Get the blog's image to use as a placeholder if there isn't one for the
// blog post.
// Retrieve the provided settings from context.
let settings = use_context::<BardSettings>();
rsx!
{
// Open Graph (used by LinkedIn, Bluesky, Discord, etc.)
document::Meta { property: "og:locale", content: "en_US" }
document::Meta { name: "title", property: "og:title", content: "{tale.lore.title}" }
document::Meta { name: "description", property: "og:description", content: "{tale.lore.summary}" }
document::Meta { property: "og:type", content: "article" }
document::Meta { property: "og:url", content: "{url}" }
if let Some(image) = settings.default_post_image
{
document::Meta { name: "image", property: "og:image", content: "{image}" }
}
h1 { {tale.lore.title} }
TagList
@ -63,13 +117,10 @@ pub fn PostHeader(tale: Tale) -> Element
}
}
if let Some(Ok(Some(adventurer))) = (author_future.value())()
PostHeaderAuthor
{
PostHeaderAuthor { adventurer: adventurer }
}
else
{
p { "Loading author..." }
author: adventurer,
publish_date: tale.lore.publish_date.format("%m/%d/%Y").to_string()
}
}
}
@ -79,7 +130,12 @@ pub fn Story(text: String) -> Element
{
rsx!
{
div { dangerous_inner_html: "{text}" }
div
{
class: "blog_post_tale",
dangerous_inner_html: "{text}"
}
}
}
@ -92,20 +148,26 @@ pub fn BlogPost(slug: Signal<String>, children: Element) -> Element
// Make this reactive so that as the page changes it should rerun this.
let url_slug = slug();
async move { get_blog_post(url_slug).await }
async move
{
let post = get_blog_post(url_slug).await?;
let author = get_author(post.lore.author.clone()).await?;
Ok::<_, ServerFnError>((post, author))
}
})?;
rsx!
{
article
{
class: "blog_post_style",
class: "blog_post",
if let Some(Ok(tale)) = (post_future.value())()
if let Some(Ok((tale, adventurer))) = (post_future.value())()
{
PostHeader
{
tale: tale.clone()
tale: tale.clone(),
adventurer: adventurer.clone()
}
Story { text: tale.story }
@ -137,42 +199,35 @@ pub fn TagNav() -> Element
{
section
{
class: "blog_nav_style",
div
class: "blog_nav",
fieldset
{
class: "tag_style",
h2 { "Categories" }
ul
class: "tag_list",
legend { "Category Filter" }
if let Some(Ok(categories)) = &*tags.read()
{
class: "tag_list",
if let Some(Ok(categories)) = &*tags.read()
TagList
{
for tag in categories
{
li
TagItem
{
key: "{tag}",
class: "tag_item",
Link
{
to: Page::Blog { tag: tag.clone() },
"{tag}"
}
//a { href: "/blog/{tag}", "{tag}" }
tag: tag.clone()
}
}
}
else if let Some(Err(e)) = &*tags.read()
{
p { "Unable to show desired post." }
p { "{e}" }
}
else
{
p { "Loading..." }
}
}
else if let Some(Err(e)) = &*tags.read()
{
p { "Unable to load tags." }
p { "{e}" }
}
else
{
p { "Loading..." }
}
}
}

View File

@ -22,7 +22,7 @@ pub fn TagItem(tag: String, style: Option<String>) -> Element
Link
{
class: "{tag_style}",
class: "{tag_style} tag_{tag}",
to: Page::Blog { tag: tag.clone() },
@ -37,15 +37,10 @@ pub fn TagList(children: Element) -> Element
{
rsx!
{
h5
ul
{
class: "blog_tag_style",
ul
{
class: "tag_list",
{children}
}
class: "blog_tag tag_list",
{children}
}
}
}

View File

@ -6,6 +6,7 @@ mod components;
mod page;
mod pages;
mod server;
mod settings;
mod togglable;
@ -14,5 +15,6 @@ pub use crate::components::*;
pub use crate::info::{get_name, get_version};
pub use crate::page::Page;
pub use crate::pages::*;
pub use crate::settings::*;
#[cfg(feature = "server")]
pub use crate::server::*;

View File

@ -1,6 +1,7 @@
use dioxus::prelude::*;
use crate::pages::{Blog, Post, Root};
use crate::settings::{BardSettings, StylesheetBehavior};
@ -11,9 +12,38 @@ const BLOG_CSS: Asset = asset!("/assets/css/blog.css");
#[component]
fn BlogLayout() -> Element
{
// Retrieve the provided settings from context.
let settings = use_context::<BardSettings>();
rsx!
{
document::Stylesheet { href: BLOG_CSS }
match settings.stylesheet
{
StylesheetBehavior::Override(asset) =>
{
rsx!
{
document::Stylesheet { href: asset }
}
}
StylesheetBehavior::Extend(asset) =>
{
rsx!
{
document::Stylesheet { href: BLOG_CSS }
document::Stylesheet { href: asset }
}
}
StylesheetBehavior::None =>
{
rsx!
{
document::Stylesheet { href: BLOG_CSS }
}
}
}
Outlet::<Page> {}
}

View File

@ -3,38 +3,15 @@ use std::collections::HashSet;
use dioxus::prelude::*;
use crate::components::{BlogList, TagSelector};
use crate::page::Page;
fn convert_categories(categories: &str) -> HashSet<String>
{
categories
.split('+')
.filter(|s| !s.is_empty() && *s != "all")
.map(str::to_string)
.collect()
}
/// Blog page
#[component]
pub fn Blog(tag: ReadOnlySignal<String>) -> Element
pub fn Blog(tag: ReadSignal<String>) -> Element
{
let mut show_all: Signal<bool> =
use_signal(|| tag().is_empty() || tag() == "all");
let mut categories: Signal<HashSet<String>> =
use_signal(|| convert_categories(&tag()));
use_effect(move ||
{
let new_tags = convert_categories(&tag());
categories.set(new_tags);
});
println!("Blog Categories: {:?}", categories());
let categories: Signal<HashSet<String>> =
use_signal(|| HashSet::new());
rsx!
{
@ -47,13 +24,13 @@ pub fn Blog(tag: ReadOnlySignal<String>) -> Element
BlogList
{
tags: categories.clone()
tags: categories
}
TagSelector
{
show_all: show_all.clone(),
toggled_tags: categories.clone()
url_tag: tag,
toggled_tags: categories
}
}
}

View File

@ -1,16 +1,15 @@
use dioxus::prelude::*;
use crate::components::{BlogPost, TagNav};
use crate::page::Page;
/// Blog page
#[component]
pub fn Post(slug: ReadOnlySignal<String>) -> Element
pub fn Post(slug: ReadSignal<String>) -> Element
{
// Create a copy of the current slug to detect changes.
let mut url_slug = use_signal(|| slug());
let url_slug = use_signal(|| slug());
rsx!
{

View File

@ -1,8 +1,5 @@
use std::collections::HashSet;
use dioxus::prelude::*;
use crate::page::Page;
use crate::pages::Blog;

46
bard/src/settings.rs Normal file
View File

@ -0,0 +1,46 @@
use dioxus::prelude::*;
#[derive(Copy, Clone)]
pub enum StylesheetBehavior
{
/// Overrides the default stylesheet.
Override(Asset),
/// Extends the default stylesheet. The library will load both.
Extend(Asset),
/// Uses the library's default stylesheet.
None
}
#[derive(Clone, Default)]
pub struct BardSettings
{
/// The name to use for the blog.
/// If None, then the name will be Blog, but made invisible so that
/// it is still available for screen readers.
pub blog_name: Option<String>,
/// The image to use for the blog title instead of just the name.
pub blog_image: Option<Asset>,
/// The image to use for the default post image in open graph. This is what
/// is shown when sharing the article.
pub default_post_image: Option<Asset>,
/// A user defined stylesheet and how the library should include it.
pub stylesheet: StylesheetBehavior
}
impl Default for StylesheetBehavior
{
fn default() -> Self
{
StylesheetBehavior::None
}
}

View File

@ -1,23 +1,22 @@
[package]
name = "blog_test"
version = "0.1.0"
version = "0.3.2"
authors = ["Myrddin Dundragon <myrddin@cybermages.tech>"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
axum = { version = "0.7.0", optional = true }
axum-server = { version = "0.7.1", optional = true }
dioxus = { version = "*", features = ["router", "fullstack"] }
dioxus-cli-config = { version = "*", optional = true }
axum = { version = "0.8.4", optional = true }
axum-server = { version = "0.7.2", optional = true }
dioxus = { version = "=0.7.0-rc.1", features = ["router", "fullstack"] }
bard = { version = "*", path="../bard", optional = true }
tokio = { version = "1.0", optional = true }
[features]
default = ["web"]
web = ["dioxus/web", "bard"]
server = ["dioxus/server", "axum", "axum-server", "tokio/rt-multi-thread", "tokio/macros", "dioxus-cli-config", "bard/server"]
default = ["bard"]
web = ["dioxus/web", "bard/web"]
server = ["dioxus/server", "axum", "axum-server", "tokio/rt-multi-thread", "tokio/macros", "bard/server"]
[profile.wasm-dev]
inherits = "dev"

45
blog_test/assets/blog.css Normal file
View File

@ -0,0 +1,45 @@
:root
{
--text-color: #000000;
--bg-color: #FFFFFF;
--primary-color: #ffffff;
--secondary-color: #000000;
--accent-color: #7dfdfe; /* Tron grid or try 00a8ff 00eaff 26b4ca 59b4c7 5584AC*/
--mobile-color: #363636;
--desktop-size: 1230px;
}
.toggle_button {
display: inline-block;
margin-top: 5px;
margin-right: 5px;
padding: 0px 6px;
border: 2px solid var(--text-color);
border-radius: 5px;
color: var(--text-color);
cursor: pointer;
user-select: none;
transition: 0.1s ease-in;
}
.toggle_button:hover {
color: var(--accent-color);
border-color: var(--accent-color);
transform: translateY(-2px);
}
.toggle_button input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
/* Checked state using :has() — modern browsers only */
.toggle_button:has(input:checked) {
color: var(--accent-color);
border-color: var(--accent-color);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1019 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

32
blog_test/blog.css Normal file
View File

@ -0,0 +1,32 @@
.toggle_button {
display: inline-block;
margin-top: 5px;
margin-right: 5px;
padding: 0px 6px;
border: 2px solid var(--text-color);
border-radius: 5px;
color: var(--text-color);
cursor: pointer;
user-select: none;
transition: 0.1s ease-in;
}
.toggle_button:hover {
color: var(--accent-color);
border-color: var(--accent-color);
transform: translateY(-2px);
}
.toggle_button input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
/* Checked state using :has() — modern browsers only */
.toggle_button:has(input:checked) {
color: var(--accent-color);
border-color: var(--accent-color);
}

View File

@ -1,27 +1,15 @@
use std::net::SocketAddr;
use dioxus::prelude::*;
#[cfg(feature = "server")]
use axum::Router;
#[cfg(feature = "server")]
use axum::ServiceExt;
#[cfg(feature = "server")]
use axum::extract::{Extension, Host};
#[cfg(feature = "server")]
use axum::http::uri::{Parts, Uri};
#[cfg(feature = "server")]
use axum::http::StatusCode;
#[cfg(feature = "server")]
use axum::response::{IntoResponse, Redirect};
#[cfg(feature = "server")]
use axum::routing::get;
use bard::*;
const FAVICON: Asset = asset!("/assets/favicon.ico");
const BLOG: Asset = asset!("/assets/blog.css");
const BLOG_IMAGE: Asset = asset!("/assets/runes_and_ramblings_text.png",
AssetOptions::builder().with_hash_suffix(false));
const POST_IMAGE: Asset = asset!("/assets/runes_and_ramblings_logo.png",
AssetOptions::builder().with_hash_suffix(false));
@ -29,18 +17,30 @@ fn main()
{
#[cfg(feature = "server")]
{
tokio::runtime::Runtime::new()
.unwrap()
.block_on(async move { bard::init_database("/home/myrddin/cybermages/website/tavern.db").await });
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async
{
let db_path = "/home/myrddin/cybermages/website/tavern.db";
let _ = bard::init_database(db_path).await;
});
}
#[cfg(feature = "web")]
dioxus::launch(App);
LaunchBuilder::new().launch(App);
}
#[component]
fn App() -> Element
{
let custom_settings = BardSettings
{
blog_name: Some(String::from("Blog Test")),
blog_image: Some(BLOG_IMAGE),
default_post_image: Some(POST_IMAGE),
stylesheet: StylesheetBehavior::Extend(BLOG),
};
provide_context(custom_settings);
rsx!
{
document::Link { rel: "icon", href: FAVICON }
@ -52,8 +52,7 @@ fn App() -> Element
#[component]
fn Home() -> Element
{
rsx!
{
rsx! {
h1 { "Blog Test" }
}
}
@ -62,8 +61,7 @@ fn Home() -> Element
#[component]
pub fn PageNotFound(route: Vec<String>) -> Element
{
rsx!
{
rsx! {
h1 { "Page not found" }
p { "We are terribly sorry, but the page you requested doesn't exist." }
pre { color: "red", "log:\nattemped to navigate to: {route:?}" }
@ -94,7 +92,6 @@ fn Navbar() -> Element
#[derive(Debug, Clone, Routable, PartialEq)]
#[rustfmt::skip]
pub enum Page
@ -103,13 +100,11 @@ pub enum Page
#[route("/")]
Home {},
// #[nest("/blog")]
#[child("/blog")]
Bard
{
child: bard::Page
},
// #[end_nest]
#[child("/blog")]
Bard
{
child: bard::Page
},
#[route("/:..route")]
PageNotFound { route: Vec<String> }

View File

@ -1,18 +0,0 @@
# Loremaster
Converts a blog repository into an SQLite database using the Tavern blog system.
---
## Copyright & License
Copyright 2025 CyberMages LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this library except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS

View File

@ -1,6 +1,6 @@
[package]
name = "loremaster"
version = "0.1.0"
name = "loreweaver"
version = "0.3.2"
edition = "2024"
description = "Converts a blog repository into an SQLite database using the Tavern blog system."
repository = "/CyberMages/tavern"
@ -11,5 +11,5 @@ license = "Apache-2.0"
[dependencies]
clap = { version = "4.5.46", features = ["derive"] }
tavern = { version = "0.2.4", path = "../tavern", registry = "cybermages", features = ["publisher"] }
tavern = { version = "0.3.0", path = "../tavern", registry = "cybermages", features = ["publisher"] }
tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros"] }

42
loreweaver/README.md Normal file
View File

@ -0,0 +1,42 @@
# Loreweaver
Loreweaver is a command-line tool that compiles a blog repository into a SQLite
database using the Tavernworks blog system. It acts as a publisher that reads
a metadata configuration file and processes the blog content via the `tavern`
engine.
## Features
- Reads a `Tavern.toml` metadata file to configure the compilation
- Converts Markdown blog repositories into portable SQLite databases
- Simple CLI interface with configurable input and output paths
- Built on top of the `tavern` library for content parsing and data handling
## Usage
Run Loreweaver with the default configuration file and output:
```bash
loreweaver --config Tavern.toml --output tavern.db
```
Specify custom paths for config or output files:
```bash
loreweaver --config path/to/config.toml --output path/to/output.db
```
---
## Copyright & License
Copyright 2025 CyberMages LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this library except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

View File

@ -3,12 +3,15 @@
/// The environment variable defined by Cargo for the name.
#[allow(dead_code)]
const NAME: Option<&str> = option_env!("CARGO_PKG_NAME");
/// The environment variable defined by Cargo for the version.
#[allow(dead_code)]
const VERSION: Option<&str> = option_env!("CARGO_PKG_VERSION");
/// The string to display if a value is not defined during compile time.
#[allow(dead_code)]
const NOT_DEFINED: &'static str = "UNDEFINED";
@ -17,6 +20,7 @@ const NOT_DEFINED: &'static str = "UNDEFINED";
/// set at compile time and comes from the Cargo.toml file.
///
/// If a value is not found, then it will return the not defined value.
#[allow(dead_code)]
pub fn get_name() -> &'static str
{
NAME.unwrap_or(NOT_DEFINED)
@ -27,6 +31,7 @@ pub fn get_name() -> &'static str
/// This is set at compile time and comes from the Cargo.toml file.
///
/// If a value is not found, then it will return the not defined value.
#[allow(dead_code)]
pub fn get_version() -> &'static str
{
VERSION.unwrap_or(NOT_DEFINED)

2
rust-toolchain.toml Normal file
View File

@ -0,0 +1,2 @@
[toolchain]
channel = "nightly"

View File

@ -1,6 +1,6 @@
[package]
name = "tavern"
version = "0.2.9"
version = "0.3.0"
edition = "2024"
description = "A blogging system that will allow you to write your blog in Markdown and then display it in HTML using Dioxus."
repository = "/CyberMages/tavern"

View File

@ -1,7 +1,28 @@
# Tavern
A blogging system that will allow you to write your blog in Markdown and then
display it in HTML using Dioxus.
Tavern is the core content engine of the Tavernworks blog system.
It provides the data model, parsing logic, and database interaction necessary
to convert a Markdown-based blog repository into a structured, queryable form.
## Features
- Parses blog content from Markdown files and metadata.
- Converts content into an in-memory model or stores it in SQLite.
- Provides an async API to query and manipulate blog data.
- Designed to be extensible and integrated into CLI tools or frontends.
## Usage
As a library, `tavern` can be embedded in Rust applications or CLI tools
to handle blog content processing and storage.
```rust
use tavern::{Tavern, Database};
let tavern = Tavern::from_config_file("Tavern.toml");
let database = Database::open("blog.db").await?;
database.insert_tavern(&tavern).await?;
```
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB