Switch over to WebAssembly, Rust and Yew #35

Merged
BlakeRain merged 87 commits from yew-static into main 2023-08-30 18:01:40 +00:00
25 changed files with 947 additions and 77 deletions
Showing only changes of commit 3730a8236f - Show all commits

144
Cargo.lock generated
View File

@ -32,6 +32,17 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c"
[[package]]
name = "async-trait"
version = "0.1.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc00ceb34980c03614e35a3a4e218276a0a824e911d07651cd0d858a51e8c0f0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.29",
]
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.1.0" version = "1.1.0"
@ -145,6 +156,12 @@ dependencies = [
"termcolor", "termcolor",
] ]
[[package]]
name = "equivalent"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.3.2" version = "0.3.2"
@ -433,12 +450,29 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "gray_matter"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cf2fb99fac0b821a4e61c61abff076324bb0e5c3b4a83815bbc3518a38971ad"
dependencies = [
"serde",
"serde_json",
"yaml-rust",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a"
[[package]] [[package]]
name = "hermit-abi" name = "hermit-abi"
version = "0.3.2" version = "0.3.2"
@ -477,7 +511,26 @@ version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c6ecbd987bb94f1f3c76c6787879756cf4b6f73bfff48d79308e8c56b46f65f" checksum = "7c6ecbd987bb94f1f3c76c6787879756cf4b6f73bfff48d79308e8c56b46f65f"
dependencies = [ dependencies = [
"indexmap", "indexmap 1.9.3",
]
[[package]]
name = "include_dir"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18762faeff7122e89e0857b02f7ce6fcc0d101d5e9ad2ad7846cc01d61b7f19e"
dependencies = [
"include_dir_macros",
]
[[package]]
name = "include_dir_macros"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b139284b5cf57ecfa712bcc66950bb635b31aff41c188e8a4cfc758eca374a3f"
dependencies = [
"proc-macro2",
"quote",
] ]
[[package]] [[package]]
@ -487,7 +540,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"hashbrown", "hashbrown 0.12.3",
]
[[package]]
name = "indexmap"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d"
dependencies = [
"equivalent",
"hashbrown 0.14.0",
] ]
[[package]] [[package]]
@ -522,6 +585,12 @@ version = "0.2.147"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3"
[[package]]
name = "linked-hash-map"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.4.5" version = "0.4.5"
@ -880,6 +949,19 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_yaml"
version = "0.9.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a49e178e4452f45cb61d0cd8cebc1b0fafd3e41929e996cef79aa3aca91f574"
dependencies = [
"indexmap 2.0.0",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]] [[package]]
name = "signal-hook-registry" name = "signal-hook-registry"
version = "1.4.1" version = "1.4.1"
@ -893,13 +975,22 @@ dependencies = [
name = "site" name = "site"
version = "2.0.0" version = "2.0.0"
dependencies = [ dependencies = [
"async-trait",
"env_logger", "env_logger",
"gray_matter",
"include_dir",
"log", "log",
"serde",
"serde_json",
"serde_yaml",
"thiserror",
"time", "time",
"tokio", "tokio",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-logger", "wasm-logger",
"words-count",
"yew", "yew",
"yew-hooks",
"yew-router", "yew-router",
"yew_icons", "yew_icons",
] ]
@ -988,6 +1079,7 @@ checksum = "a79d09ac6b08c1ab3906a2f7cc2e81a0e27c7ae89c63812df75e52bef0751e07"
dependencies = [ dependencies = [
"deranged", "deranged",
"itoa", "itoa",
"js-sys",
"libc", "libc",
"num_threads", "num_threads",
"serde", "serde",
@ -1083,12 +1175,24 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "unicode-blocks"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c84398c527c802fbf222e5145f220382d60f1878e0e6cb4d22a3080949a8ddcd"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.11" version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c"
[[package]]
name = "unsafe-libyaml"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f28467d3e1d3c6586d8f25fa243f544f5800fec42d97032474e17222c2b75cfa"
[[package]] [[package]]
name = "utf8-width" name = "utf8-width"
version = "0.1.6" version = "0.1.6"
@ -1291,6 +1395,24 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "words-count"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c569acc49de9affbc9ace57633381bad6d3e9c648605206d082deb7aa62e8cf5"
dependencies = [
"unicode-blocks",
]
[[package]]
name = "yaml-rust"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
dependencies = [
"linked-hash-map",
]
[[package]] [[package]]
name = "yew" name = "yew"
version = "0.20.0" version = "0.20.0"
@ -1304,7 +1426,7 @@ dependencies = [
"gloo", "gloo",
"html-escape", "html-escape",
"implicit-clone", "implicit-clone",
"indexmap", "indexmap 1.9.3",
"js-sys", "js-sys",
"prokio", "prokio",
"rustversion", "rustversion",
@ -1319,6 +1441,22 @@ dependencies = [
"yew-macro", "yew-macro",
] ]
[[package]]
name = "yew-hooks"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "268e2367720311f19582235f5c021702d6be8ded13b7ee8dcacc71019d055d15"
dependencies = [
"gloo",
"js-sys",
"log",
"serde",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"yew",
]
[[package]] [[package]]
name = "yew-macro" name = "yew-macro"
version = "0.20.0" version = "0.20.0"

View File

@ -23,9 +23,18 @@ required-features = [
name = "site" name = "site"
[dependencies] [dependencies]
async-trait = { version = "0.1" }
gray_matter = { version = "0.2", default-features = false, features = ["yaml"] }
include_dir = { version = "0.7" }
yew = { version = "0.20" } yew = { version = "0.20" }
yew-hooks = { version = "0.2" }
yew-router = { version = "0.17" } yew-router = { version = "0.17" }
log = { version = "0.4" } log = { version = "0.4" }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0" }
serde_yaml = { version = "0.9" }
thiserror = { version = "1.0" }
words-count = { version = "0.1" }
[dependencies.time] [dependencies.time]
version = "0.3" version = "0.3"
@ -34,20 +43,19 @@ features = [
"local-offset", "local-offset",
"macros", "macros",
"parsing", "parsing",
"serde" "serde",
"wasm-bindgen"
] ]
[dependencies.yew_icons] [dependencies.yew_icons]
version = "0.7" version = "0.7"
features = [ features = [
"BootstrapDot",
"BootstrapGithub",
"BootstrapMastodon",
"LucideMenu",
"LucideRss" "LucideRss"
] ]
[target.'cfg(target_arch = "wasm32")']
required-features = [
"time/wasm-bindgen"
]
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = { version = "0.4" } wasm-bindgen-futures = { version = "0.4" }
wasm-logger = { version = "0.2" } wasm-logger = { version = "0.2" }

View File

@ -2,141 +2,118 @@
# tags.yaml # tags.yaml
# #
getting-started: - slug: getting-started
slug: getting-started
name: Getting Started name: Getting Started
visibility: public visibility: public
haskell: - slug: haskell
slug: haskell
name: Haskell name: Haskell
visibility: public visibility: public
description: Posts that feature the Haskell programming language. description: Posts that feature the Haskell programming language.
reviews: - slug: reviews
slug: reviews
name: Reviews name: Reviews
visibility: public visibility: public
description: Reviews of books or other articles description: Reviews of books or other articles
electronics: - slug: electronics
slug: electronics
name: Electronics name: Electronics
visibility: public visibility: public
description: Posts that feature electronic circuits. description: Posts that feature electronic circuits.
javascript: - slug: javascript
slug: javascript
name: JavaScript name: JavaScript
visibility: public visibility: public
description: Posts that feature the JavaScript scripting language. description: Posts that feature the JavaScript scripting language.
python: - slug: python
slug: python
name: Python name: Python
visibility: public visibility: public
description: Posts that feature the Python scripting language description: Posts that feature the Python scripting language
ghost-tag: - slug: ghost-tag
slug: ghost-tag
name: Ghost name: Ghost
visibility: public visibility: public
description: Posts related to my use of the Ghost CMS description: Posts related to my use of the Ghost CMS
gtk: - slug: gtk
slug: gtk
name: GTK name: GTK
visibility: public visibility: public
description: Posts that feature the GTK graphical user interface toolkit. description: Posts that feature the GTK graphical user interface toolkit.
aws: - slug: aws
slug: aws
name: AWS name: AWS
visibility: public visibility: public
description: Posts that relate to Amazon Web Services description: Posts that relate to Amazon Web Services
linux: - slug: linux
slug: linux
name: Linux name: Linux
visibility: public visibility: public
description: Posts that feature the Linux operating system. description: Posts that feature the Linux operating system.
cairo: - slug: cairo
slug: cairo
name: Cairo name: Cairo
visibility: public visibility: public
description: Posts that feature the Cairo vector-based graphics library. description: Posts that feature the Cairo vector-based graphics library.
cpp: - slug: cpp
slug: cpp
name: C++ name: C++
visibility: public visibility: public
description: Posts that feature the C++ programming language. description: Posts that feature the C++ programming language.
yesod: - slug: yesod
slug: yesod
name: Yesod name: Yesod
visibility: public visibility: public
description: description:
Posts which feature the Yesod web application development framework Posts which feature the Yesod web application development framework
for the Haskell programming language. for the Haskell programming language.
ethernet: - slug: ethernet
slug: ethernet
name: Ethernet name: Ethernet
visibility: public visibility: public
description: Posts that relate to Ethernet networking description: Posts that relate to Ethernet networking
react: - slug: react
slug: react
name: React name: React
visibility: public visibility: public
description: Posts that make use of the React JavaScript library. description: Posts that make use of the React JavaScript library.
gc: - slug: gc
slug: gc
name: GC name: GC
visibility: public visibility: public
description: Posts that relate to garbage collection and memory management. description: Posts that relate to garbage collection and memory management.
sqlite: - slug: sqlite
slug: sqlite
name: SQLite name: SQLite
visibility: public visibility: public
description: Posts that feature the SQLite embedded relational database. description: Posts that feature the SQLite embedded relational database.
chakracore: - slug: chakracore
slug: chakracore
name: ChakraCore name: ChakraCore
visibility: public visibility: public
description: Posts that feature the ChakraCore JavaScript engine. description: Posts that feature the ChakraCore JavaScript engine.
pci: - slug: pci
slug: pci
name: PCI name: PCI
visibility: public visibility: public
description: Articles related to programming PCI express devices. description: Articles related to programming PCI express devices.
usb: - slug: usb
slug: usb
name: USB name: USB
visibility: public visibility: public
description: Posts that relate to working with the Universal Serial Bus description: Posts that relate to working with the Universal Serial Bus
intel-i350: - slug: intel-i350
slug: intel-i350
name: Intel i350 name: Intel i350
visibility: public visibility: public
description: Blog posts about programming the Intel i350 NIC and related models. description: Blog posts about programming the Intel i350 NIC and related models.
rust: - slug: rust
slug: rust
name: Rust name: Rust
visibility: public visibility: public
description: Blog posts related to the Rust programming language description: Blog posts related to the Rust programming language
mastodon: - slug: mastodon
slug: mastodon
name: Mastodon name: Mastodon
visibility: public visibility: public
description: Posts that feature the Mastodon social network. description: Posts that feature the Mastodon social network.

View File

@ -4,7 +4,15 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Blake Rain</title> <title>Blake Rain</title>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Serif:wght@400;700&family=Roboto:wght@100;300;400;500;700&family=Source+Code+Pro:wght@400;700&display=swap"
rel="stylesheet"
/>
<link data-trunk rel="rust" data-bin="site" /> <link data-trunk rel="rust" data-bin="site" />
<link data-trunk rel="css" href="target/main.css" /> <link data-trunk rel="css" href="target/main.css" />
<link data-trunk rel="copy-dir" href="public/media" />
<link data-trunk rel="copy-dir" href="public/content" />
</head> </head>
</html> </html>

View File

@ -10,14 +10,24 @@ use yew_router::{
#[cfg(not(feature = "static"))] #[cfg(not(feature = "static"))]
use yew_router::BrowserRouter; use yew_router::BrowserRouter;
use crate::pages::Route; use crate::{
components::layout::Layout,
model::{source::ModelProvider, tags::TagsProvider},
pages::Route,
};
#[function_component(AppContent)] #[function_component(AppContent)]
fn app_content() -> Html { fn app_content() -> Html {
html! { html! {
<ModelProvider>
<TagsProvider>
<Layout>
<main> <main>
<Switch<Route> render={Route::switch} /> <Switch<Route> render={Route::switch} />
</main> </main>
</Layout>
</TagsProvider>
</ModelProvider>
} }
} }

2
src/components.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod blog;
pub mod layout;

2
src/components/blog.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod post_card;
pub mod post_card_list;

View File

@ -0,0 +1,112 @@
use time::{format_description::FormatItem, macros::format_description};
use yew::{function_component, html, use_context, Html, Properties};
use yew_icons::{Icon, IconId};
use yew_router::prelude::Link;
use crate::{
components::layout::intersperse::Intersperse,
model::{tags::TagsContext, PostInfo},
pages::Route,
};
const DATE_FORMAT: &[FormatItem] =
format_description!("[day padding:none] [month repr:short] [year]");
fn post_card_image(slug: &str, image: &Option<String>) -> Html {
html! {
<Link<Route> classes="unstyled" to={Route::BlogPost { slug: slug.to_string() }}>
<div class="relative w-full h-[240px]">
if let Some(cover_image) = image {
<img class="rounded-xl object-cover absolute w-full h-full"
src={cover_image.clone()} />
}
</div>
</Link<Route>>
}
}
fn post_card_details(info: &PostInfo, tags: &TagsContext) -> Html {
let mut facts =
Intersperse::new(html! { <Icon class="text-gray-500" icon_id={IconId::BootstrapDot} /> });
if let Some(published) = info.doc_info.published {
facts.push(html! { <div>{published.format(DATE_FORMAT).expect("valid format")}</div> });
}
if let Some(reading_time) = info.reading_time {
facts.push(html! { <div>{format!("{reading_time} min read")}</div> })
}
let tags = Intersperse::from_iter(
html! { <Icon class="text-gray-500" icon_id={IconId::BootstrapDot} /> },
info.tags.iter().map(|tag| {
if let Some(tag) = tags.get_tag(tag) {
html! {
<Link<Route>
classes="text-sky-500 hover:text-sky-600"
to={Route::Tag { slug: tag.slug }}>{tag.name}</Link<Route>>
}
} else {
html! {
<span class="text-gray-500">{tag}</span>
}
}
}),
);
html! {
<div class="flex flex-col uppercase text-sm">
<div class="flex flex-row">
{facts.finish()}
</div>
<div class="flex flex-row">
{tags.finish()}
</div>
</div>
}
}
fn post_card_description(info: &PostInfo, tags: &TagsContext) -> Html {
html! {
<div class="grow flex flex-col gap-4 justify-between">
<Link<Route> classes="unstyled" to={Route::BlogPost { slug: info.doc_info.slug.clone() }}>
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-bold">{&info.doc_info.title}</h1>
if let Some(excerpt) = &info.doc_info.excerpt {
<p class="text-gray-500 font-text text-xl">{excerpt}</p>
}
</div>
</Link<Route>>
{post_card_details(info, tags)}
</div>
}
}
#[derive(Properties, PartialEq)]
pub struct PostCardProps {
pub first: bool,
pub post: PostInfo,
}
#[function_component(PostCard)]
pub fn post_card(props: &PostCardProps) -> Html {
let tags = use_context::<TagsContext>().expect("TagsContext to be provided");
if props.first {
html! {
<>
{post_card_image(&props.post.doc_info.slug, &props.post.cover_image)}
<div class="col-span-2">
{post_card_description(&props.post, &tags)}
</div>
</>
}
} else {
html! {
<div class="flex flex-col gap-4">
{post_card_image(&props.post.doc_info.slug, &props.post.cover_image)}
{post_card_description(&props.post, &tags)}
</div>
}
}
}

View File

@ -0,0 +1,21 @@
use yew::{function_component, html, Html, Properties};
use crate::{components::blog::post_card::PostCard, model::PostInfo};
#[derive(Properties, PartialEq)]
pub struct PostCardListProps {
pub posts: Vec<PostInfo>,
}
#[function_component(PostCardList)]
pub fn post_card_list(props: &PostCardListProps) -> Html {
html! {
<div class="grid grid-cols-3 gap-x-10 gap-y-20 my-10">
{for props.posts.iter().enumerate().map(|(index, post)| {
html! {
<PostCard post={post.clone()} first={index == 0} />
}
})}
</div>
}
}

24
src/components/layout.rs Normal file
View File

@ -0,0 +1,24 @@
use yew::{function_component, html, Children, Html, Properties};
mod footer;
pub mod intersperse;
mod navigation;
#[derive(Properties, PartialEq)]
pub struct LayoutProps {
#[prop_or_default]
pub children: Children,
}
#[function_component(Layout)]
pub fn layout(props: &LayoutProps) -> Html {
html! {
<div class="flex flex-col">
<navigation::Navigation />
<div class="container mx-auto">
{props.children.clone()}
</div>
<footer::Footer />
</div>
}
}

View File

@ -0,0 +1,46 @@
use time::OffsetDateTime;
use yew::{function_component, html, Html, Properties};
use yew_router::prelude::Link;
use crate::pages::Route;
#[derive(Properties, PartialEq)]
pub struct FooterProps {}
#[function_component(Footer)]
pub fn footer(_: &FooterProps) -> Html {
let year = OffsetDateTime::now_utc().year();
html! {
<div class="bg-primary text-neutral-400 text-sm mt-4">
<div class="container mx-auto flex flex-col gap-4 md:gap-0 md:flex-row md:justify-between px-4 sm:px-0 py-6">
<div>{format!("Blake Rain © {year}")}</div>
<div class="flex flex-col md:flex-row gap-4 md:gap-3">
<Link<Route> classes="hover:text-neutral-50" to={Route::Blog}>
{"Latest Posts"}
</Link<Route>>
<Link<Route> classes="hover:text-neutral-50" to={Route::Tags}>
{"Tags"}
</Link<Route>>
<Link<Route> classes="hover:text-neutral-50" to={Route::Disclaimer}>
{"Disclaimer"}
</Link<Route>>
<a href="https://github.com/BlakeRain"
class="hover:text-neutral-50"
title="GitHub"
target="_blank"
rel="noreferrer">
{"GitHub"}
</a>
<a href="https://mastodonapp.uk/@BlakeRain"
class="hover:text-neutral-50"
title="@BlakeRain@mastodonapp.uk"
target="_blank"
rel="noreferrer">
{"Mastodon"}
</a>
</div>
</div>
</div>
}
}

View File

@ -0,0 +1,40 @@
use yew::Html;
pub struct Intersperse {
inner: Vec<Html>,
separator: Html,
}
impl Intersperse {
pub fn new(separator: Html) -> Self {
Self {
inner: Vec::new(),
separator,
}
}
pub fn from_iter<I>(separator: Html, elements: I) -> Self
where
I: IntoIterator<Item = Html>,
{
let mut v = Self::new(separator);
for element in elements {
v.push(element);
}
v
}
pub fn push(&mut self, element: Html) {
if !self.inner.is_empty() {
self.inner.push(self.separator.clone());
}
self.inner.push(element);
}
pub fn finish(self) -> Html {
self.inner.into_iter().collect()
}
}

View File

@ -0,0 +1,79 @@
use yew::{classes, function_component, html, use_state_eq, AttrValue, Callback, Html, Properties};
use yew_icons::{Icon, IconId};
use yew_router::prelude::Link;
use crate::pages::Route;
fn navigation_link(target: Route, title: AttrValue) -> Html {
html! {
<Link<Route>
classes={classes!(
"px-4", "py-6", "transition-colors",
"hover:bg-primary-dark", "hover:text-neutral-50"
)}
to={target}>
{title}
</Link<Route>>
}
}
#[derive(Properties, PartialEq)]
pub struct NavigationProps {}
#[function_component(Navigation)]
pub fn navigation(_: &NavigationProps) -> Html {
let open = use_state_eq(|| false);
let on_open_click = {
let open = open.clone();
Callback::from(move |_| {
open.set(!*open);
})
};
html! {
<nav class="bg-primary shadow-md text-neutral-200">
<div class="container mx-auto flex flex-col md:flex-row px-4 sm:px-0">
<div class="flex flex-row justify-between items-center my-4 md:my-0">
<Link<Route> classes="block mr-4" to={Route::Home}>
<img class="block"
src="/media/logo-text.png"
width="154"
height="28"
alt="Blake Rain" />
</Link<Route>>
<button type="button" class="md:hidden" onclick={on_open_click}>
<Icon icon_id={IconId::LucideMenu} />
</button>
</div>
<div class={classes!("md:flex", "flex-col", "md:flex-row",
if *open { "flex" } else { "hidden" })}>
{navigation_link(Route::Blog, "Blog".into())}
{navigation_link(Route::About, "About".into())}
</div>
<div class={classes!("hidden", "md:flex", "flex-row",
"items-center", "gap-2", "ml-auto")}>
<a href="https://github.com/BlakeRain"
class="text-neutral-200/75 hover:text-neutral-50"
title="GitHub"
target="_blank"
rel="noreferrer">
<Icon icon_id={IconId::BootstrapGithub} height={"1.1em"} />
</a>
<a href="https://mastodonapp.uk/@BlakeRain"
class="text-neutral-200/75 hover:text-neutral-50"
title="@BlakeRain@mastodonapp.uk"
target="_blank"
rel="me noreferrer">
<Icon icon_id={IconId::BootstrapMastodon} height={"1.1em"} />
</a>
<a href="/feeds/feed.xml"
class="text-neutral-200/75 hover:text-neutral-50"
title="RSS Feed">
<Icon icon_id={IconId::LucideRss} height={"1.1em"} />
</a>
</div>
</div>
</nav>
}
}

View File

@ -1,2 +1,4 @@
pub mod app; pub mod app;
pub mod components;
pub mod model;
pub mod pages; pub mod pages;

87
src/model.rs Normal file
View File

@ -0,0 +1,87 @@
pub mod frontmatter;
pub mod source;
pub mod tags;
use serde::Deserialize;
use time::OffsetDateTime;
use self::frontmatter::FrontMatter;
#[derive(Debug, Clone, PartialEq)]
pub struct DocInfo {
/// The slug used to form the URL for this document.
pub slug: String,
/// The rendered title for the document.
pub title: String,
/// Any given excerpt.
pub excerpt: Option<String>,
/// The date on which this document was published.
pub published: Option<OffsetDateTime>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PostInfo {
/// Document information.
pub doc_info: DocInfo,
/// Tags attached to this post.
pub tags: Vec<String>,
/// The read time (in seconds).
pub reading_time: Option<usize>,
/// The URL to the cover image.
pub cover_image: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Post {
/// Information about the post
pub info: PostInfo,
/// The main content
pub content: String,
}
impl PostInfo {
pub fn from_front_matter(
slug: String,
reading_time: Option<usize>,
excerpt: Option<String>,
FrontMatter {
title,
tags,
published,
cover,
}: FrontMatter,
) -> Self {
PostInfo {
doc_info: DocInfo {
slug,
title,
excerpt,
published,
},
tags,
reading_time,
cover_image: cover,
}
}
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct Tag {
/// The slug of the tag
pub slug: String,
/// The display name of the tag
pub name: String,
/// Whether the tag is visible or not
pub visibility: TagVisibility,
/// A description of the tag
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub enum TagVisibility {
#[serde(rename = "public")]
Public,
#[serde(rename = "private")]
Private,
}

35
src/model/frontmatter.rs Normal file
View File

@ -0,0 +1,35 @@
use gray_matter::{engine::YAML, Matter, ParsedEntity};
use serde::Deserialize;
use time::OffsetDateTime;
use super::source::SourceError;
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct FrontMatter {
pub title: String,
pub tags: Vec<String>,
#[serde(
serialize_with = "time::serde::rfc3339::option::serialize",
deserialize_with = "time::serde::rfc3339::option::deserialize"
)]
pub published: Option<OffsetDateTime>,
pub cover: Option<String>,
}
pub fn parse_front_matter(
content: &[u8],
) -> Result<(Option<FrontMatter>, ParsedEntity), SourceError> {
let content = unsafe { std::str::from_utf8_unchecked(content) };
let matter = Matter::<YAML>::new().parse(content);
let info: Option<FrontMatter> = if let Some(data) = &matter.data {
Some(data.deserialize().map_err(|err| {
log::error!("Failed to parse front matter: {err:?}");
SourceError::InvalidFrontMatter(err.to_string())
})?)
} else {
None
};
Ok((info, matter))
}

86
src/model/source.rs Normal file
View File

@ -0,0 +1,86 @@
use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait;
use thiserror::Error;
use yew::{function_component, html, Children, ContextProvider, Html, Properties};
use super::{PostInfo, Tag};
#[cfg(feature = "hydration")]
mod web;
#[cfg(not(feature = "hydration"))]
mod fs;
#[derive(Debug, Clone, Error)]
pub enum SourceError {
#[error("Invalid front matter: {0}")]
InvalidFrontMatter(String),
#[error("Invalid tags format")]
InvalidTags,
}
#[async_trait]
pub trait ModelSource: Send + Sync {
async fn get_posts(&self) -> Result<Vec<PostInfo>, SourceError>;
async fn get_tags(&self) -> Result<HashMap<String, Tag>, SourceError>;
}
pub fn get_source() -> Box<dyn ModelSource> {
// If we're compiled to use hydration, we want to use the web source.
#[cfg(feature = "hydration")]
return Box::new(self::web::Source::new());
// If we're NOT compiled to use hydration, we want to use the file-system source.
#[cfg(not(feature = "hydration"))]
return Box::new(fs::Source::new());
}
pub struct ModelSourceWrapper {
inner: Arc<Box<dyn ModelSource>>,
}
impl Clone for ModelSourceWrapper {
fn clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
}
}
}
impl PartialEq for ModelSourceWrapper {
fn eq(&self, _: &Self) -> bool {
true
}
}
#[async_trait]
impl ModelSource for ModelSourceWrapper {
async fn get_posts(&self) -> Result<Vec<PostInfo>, SourceError> {
self.inner.get_posts().await
}
async fn get_tags(&self) -> Result<HashMap<String, Tag>, SourceError> {
self.inner.get_tags().await
}
}
#[derive(Properties, PartialEq)]
pub struct ModelProviderProps {
#[prop_or_default]
pub children: Children,
}
#[function_component(ModelProvider)]
pub fn model_provider(props: &ModelProviderProps) -> Html {
let source = get_source();
let source = ModelSourceWrapper {
inner: Arc::new(source),
};
html! {
<ContextProvider<ModelSourceWrapper> context={source}>
{props.children.clone()}
</ContextProvider<ModelSourceWrapper>>
}
}

76
src/model/source/fs.rs Normal file
View File

@ -0,0 +1,76 @@
use std::collections::HashMap;
use async_trait::async_trait;
use include_dir::{include_dir, Dir, File};
use crate::model::{frontmatter::parse_front_matter, PostInfo, Tag};
use super::{ModelSource, SourceError};
pub struct Source {}
impl Source {
pub fn new() -> Self {
Self {}
}
}
#[async_trait]
impl ModelSource for Source {
async fn get_posts(&self) -> Result<Vec<PostInfo>, SourceError> {
let files = get_post_files().map(|file| -> Result<Option<PostInfo>, SourceError> {
let (Some(front_matter), matter) = parse_front_matter(file.contents())? else {
return Ok(None);
};
let slug = file
.path()
.file_stem()
.expect("filename")
.to_str()
.expect("valid file name")
.to_string();
let reading_time = words_count::count(&matter.content).words / 200;
Ok(Some(PostInfo::from_front_matter(
slug,
Some(reading_time),
matter.excerpt,
front_matter,
)))
});
let mut posts = Vec::new();
for file in files {
let file = file?;
if let Some(file) = file {
posts.push(file);
}
}
posts.sort_by(|a, b| b.doc_info.published.cmp(&a.doc_info.published));
Ok(posts)
}
async fn get_tags(&self) -> Result<HashMap<String, Tag>, SourceError> {
let file = CONTENT_DIR.get_file("tags.yaml").expect("tags.yaml");
match serde_yaml::from_slice::<Vec<Tag>>(file.contents()) {
Ok(tags) => Ok(tags
.into_iter()
.map(|tag| (tag.slug.clone(), tag))
.collect()),
Err(err) => {
log::error!("Failed to parse tags.yaml: {err}");
Err(SourceError::InvalidTags)
}
}
}
}
fn get_post_files<'a>() -> impl Iterator<Item = &'a File<'a>> {
CONTENT_DIR.get_dir("posts").expect("posts dir").files()
}
static CONTENT_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/content");

20
src/model/source/web.rs Normal file
View File

@ -0,0 +1,20 @@
use async_trait::async_trait;
use crate::model::PostInfo;
use super::{ModelProvider, SourceError};
pub struct Source {}
impl Source {
pub fn new() -> Self {
Self {}
}
}
#[async_trait]
impl ModelProvider for Source {
async fn get_posts(&self) -> Result<Vec<PostInfo>, SourceError> {
todo!()
}
}

54
src/model/tags.rs Normal file
View File

@ -0,0 +1,54 @@
use std::collections::HashMap;
use yew::{
function_component, html, use_context, use_state, Children, ContextProvider, Html, Properties,
};
use yew_hooks::{use_async_with_options, UseAsyncOptions};
use crate::model::source::{ModelSource, SourceError};
use super::{source::ModelSourceWrapper, Tag};
#[derive(Properties, PartialEq)]
pub struct TagsProviderProps {
#[prop_or_default]
pub children: Children,
}
#[derive(Clone, Default, PartialEq)]
pub struct TagsContext {
tags: HashMap<String, Tag>,
}
impl TagsContext {
fn new(tags: HashMap<String, Tag>) -> Self {
Self { tags }
}
pub fn get_tag<S: AsRef<str>>(&self, slug: S) -> Option<Tag> {
self.tags.get(slug.as_ref()).cloned()
}
}
#[function_component(TagsProvider)]
pub fn tags_provider(props: &TagsProviderProps) -> Html {
let source = use_context::<ModelSourceWrapper>().expect("ModelSource to be provided");
let tags = use_state(TagsContext::default);
{
let tags = tags.clone();
use_async_with_options::<_, (), SourceError>(
async move {
tags.set(TagsContext::new(source.get_tags().await?));
Ok(())
},
UseAsyncOptions::enable_auto(),
);
}
html! {
<ContextProvider<TagsContext> context={(*tags).clone()}>
{props.children.clone()}
</ContextProvider<TagsContext>>
}
}

View File

@ -19,6 +19,10 @@ pub enum Route {
BlogPost { slug: String }, BlogPost { slug: String },
#[at("/disclaimer")] #[at("/disclaimer")]
Disclaimer, Disclaimer,
#[at("/tags")]
Tags,
#[at("/tags/{slug}")]
Tag { slug: String },
} }
impl Route { impl Route {
@ -29,6 +33,8 @@ impl Route {
Self::Blog => html! { <blog::Page /> }, Self::Blog => html! { <blog::Page /> },
Self::BlogPost { slug } => html! { <blog_post::Page slug={slug} /> }, Self::BlogPost { slug } => html! { <blog_post::Page slug={slug} /> },
Self::Disclaimer => html! { <disclaimer::Page /> }, Self::Disclaimer => html! { <disclaimer::Page /> },
Self::Tags => unimplemented!(),
Self::Tag { .. } => unimplemented!(),
} }
} }
} }

View File

@ -1,6 +1,30 @@
use yew::{function_component, html, Html}; use yew::{function_component, html, use_context, use_state, Html};
use yew_hooks::{use_async_with_options, UseAsyncOptions};
use crate::{
components::blog::post_card_list::PostCardList,
model::source::{ModelSource, ModelSourceWrapper, SourceError},
};
#[function_component(Page)] #[function_component(Page)]
pub fn page() -> Html { pub fn page() -> Html {
html! { "Blog" } let source = use_context::<ModelSourceWrapper>().expect("ModelSource to be provided");
let posts = use_state(Vec::new);
{
let posts = posts.clone();
use_async_with_options::<_, (), SourceError>(
async move {
posts.set(source.get_posts().await?);
Ok(())
},
UseAsyncOptions::enable_auto(),
);
}
html! {
<>
<PostCardList posts={(*posts).clone()} />
</>
}
} }

View File

@ -1,22 +1,30 @@
use yew::{function_component, html, use_state, Callback, Html}; use yew::{function_component, html, use_context, use_state, Html};
use yew_router::prelude::Link; use yew_hooks::{use_async_with_options, UseAsyncOptions};
use crate::pages::Route; use crate::{
components::blog::post_card_list::PostCardList,
model::source::{ModelSource, ModelSourceWrapper, SourceError},
};
#[function_component(Page)] #[function_component(Page)]
pub fn page() -> Html { pub fn page() -> Html {
let count = use_state(|| 0); let source = use_context::<ModelSourceWrapper>().expect("ModelSource to be provided");
let posts = use_state(Vec::new);
let button_click = { {
let count = count.clone(); let posts = posts.clone();
Callback::from(move |_| count.set(*count + 1)) use_async_with_options::<_, (), SourceError>(
}; async move {
posts.set(source.get_posts().await?);
Ok(())
},
UseAsyncOptions::enable_auto(),
);
}
html! { html! {
<> <>
<h1>{"Home"}</h1> <PostCardList posts={(*posts).clone()} />
<button type="button" onclick={button_click}>{format!("Clicked {} times", *count)}</button>
<Link<Route> to={Route::About}>{"About"}</Link<Route>>
</> </>
} }
} }

View File

@ -1,4 +1,8 @@
@import "tailwind.css"; @import "tailwind.css";
@layer components { @layer components {
body {
@apply bg-white text-neutral-800;
@apply dark:bg-zinc-900 dark:text-neutral-200;
}
} }

View File

@ -1,12 +1,13 @@
module.exports = { module.exports = {
theme: { theme: {
extend: { extend: {
fontFamily: {
text: ["Noto Serif", "serif"],
},
colors: { colors: {
primary: { primary: {
dark: "#0D1B2A", dark: "#22293D",
DEFAULT: "#1B263B", DEFAULT: "#13304E",
light: "#415A77",
lighter: "#778DA9",
}, },
}, },
}, },