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
11 changed files with 361 additions and 39 deletions
Showing only changes of commit d5292adeb0 - Show all commits

37
Cargo.lock generated
View File

@ -327,6 +327,15 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "getopts"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
dependencies = [
"unicode-width",
]
[[package]] [[package]]
name = "gimli" name = "gimli"
version = "0.27.3" version = "0.27.3"
@ -1032,6 +1041,18 @@ dependencies = [
"wasm-bindgen-futures", "wasm-bindgen-futures",
] ]
[[package]]
name = "pulldown-cmark"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77a1a2f1f0a7ecff9c31abbe177637be0e97a0aef46cf8738ece09327985d998"
dependencies = [
"bitflags 1.3.2",
"getopts",
"memchr",
"unicase",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.33" version = "1.0.33"
@ -1276,6 +1297,7 @@ dependencies = [
"gray_matter", "gray_matter",
"include_dir", "include_dir",
"log", "log",
"pulldown-cmark",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
@ -1546,6 +1568,15 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed"
[[package]]
name = "unicase"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89"
dependencies = [
"version_check",
]
[[package]] [[package]]
name = "unicode-bidi" name = "unicode-bidi"
version = "0.3.13" version = "0.3.13"
@ -1573,6 +1604,12 @@ dependencies = [
"tinyvec", "tinyvec",
] ]
[[package]]
name = "unicode-width"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b"
[[package]] [[package]]
name = "unsafe-libyaml" name = "unsafe-libyaml"
version = "0.2.9" version = "0.2.9"

View File

@ -34,6 +34,7 @@ yew = { version = "0.20" }
yew-hooks = { version = "0.2" } yew-hooks = { version = "0.2" }
yew-router = { version = "0.17" } yew-router = { version = "0.17" }
log = { version = "0.4" } log = { version = "0.4" }
pulldown-cmark = { version = "0.9" }
reqwest = { version = "0.11", features = ["json"] } reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0" } serde_json = { version = "1.0" }

View File

@ -1,2 +1,4 @@
pub mod blog; pub mod blog;
pub mod content;
pub mod layout; pub mod layout;
pub mod markdown;

View File

@ -1,5 +1,5 @@
use time::{format_description::FormatItem, macros::format_description}; use time::{format_description::FormatItem, macros::format_description};
use yew::{function_component, html, use_context, Html, Properties}; use yew::{classes, function_component, html, use_context, Html, Properties};
use yew_icons::{Icon, IconId}; use yew_icons::{Icon, IconId};
use yew_router::prelude::Link; use yew_router::prelude::Link;
@ -25,7 +25,7 @@ fn post_card_image(slug: &str, image: &Option<String>) -> Html {
} }
} }
fn post_card_details(info: &PostInfo, tags: &TagsContext) -> Html { pub fn post_card_details(horizontal: bool, info: &PostInfo, tags: &TagsContext) -> Html {
let mut facts = let mut facts =
Intersperse::new(html! { <Icon class="text-gray-500" icon_id={IconId::BootstrapDot} /> }); Intersperse::new(html! { <Icon class="text-gray-500" icon_id={IconId::BootstrapDot} /> });
@ -57,7 +57,12 @@ fn post_card_details(info: &PostInfo, tags: &TagsContext) -> Html {
); );
html! { html! {
<div class="flex flex-col uppercase text-sm"> <div class={classes!("flex", "uppercase", "text-sm",
if horizontal {
"flex-row justify-between"
} else {
"flex-col"
})}>
<div class="flex flex-row"> <div class="flex flex-row">
{facts.finish()} {facts.finish()}
</div> </div>
@ -79,7 +84,7 @@ fn post_card_description(info: &PostInfo, tags: &TagsContext) -> Html {
} }
</div> </div>
</Link<Route>> </Link<Route>>
{post_card_details(info, tags)} {post_card_details(false, info, tags)}
</div> </div>
} }
} }

View File

@ -7,6 +7,7 @@ pub fn post_card_list() -> Html {
let posts = use_context::<PostsContext>().expect("PostsContext to be provided"); let posts = use_context::<PostsContext>().expect("PostsContext to be provided");
html! { html! {
<div class="container mx-auto">
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-x-10 lg:gap-y-20 my-10"> <div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-x-10 lg:gap-y-20 my-10">
{for posts.iter().enumerate().map(|(index, post)| { {for posts.iter().enumerate().map(|(index, post)| {
html! { html! {
@ -14,5 +15,6 @@ pub fn post_card_list() -> Html {
} }
})} })}
</div> </div>
</div>
} }
} }

46
src/components/content.rs Normal file
View File

@ -0,0 +1,46 @@
use yew::{function_component, html, use_context, Html, Properties};
use crate::{
components::{blog::post_card::post_card_details, markdown::markdown},
model::{source::TagsContext, Post},
};
#[derive(Properties, PartialEq)]
pub struct PostContentProps {}
#[function_component(PostContent)]
pub fn post_content(_: &PostContentProps) -> Html {
let tags = use_context::<TagsContext>().expect("TagsContext to be provided");
let post = use_context::<Post>().expect("Post to be provided");
let style = if let Some(cover_image) = &post.info.cover_image {
format!(
"background-image: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.6)), url({})",
cover_image
)
} else {
"background-color: #000".to_string()
};
html! {
<article>
<header class="bg-[50%] bg-no-repeat bg-cover bg-fixed pt-20" {style}>
<div class="container mx-auto">
<h1 class="text-5xl font-bold text-center text-white">
{ &post.info.doc_info.title }
</h1>
if let Some(excerpt) = &post.info.doc_info.excerpt {
<p class="font-text text-2xl text-white text-center mt-5">
{ excerpt }
</p>
}
<div class="mt-12 pt-8 px-16 bg-white dark:bg-zinc-900 rounded-t">
{post_card_details(true, &post.info, &tags)}
</div>
</div>
</header>
<div class="container mx-auto">
{markdown(&post.content)}
</div>
</article>
}
}

View File

@ -15,9 +15,7 @@ pub fn layout(props: &LayoutProps) -> Html {
html! { html! {
<div class="flex flex-col"> <div class="flex flex-col">
<navigation::Navigation /> <navigation::Navigation />
<div class="container mx-auto">
{props.children.clone()} {props.children.clone()}
</div>
<footer::Footer /> <footer::Footer />
</div> </div>
} }

194
src/components/markdown.rs Normal file
View File

@ -0,0 +1,194 @@
use std::{collections::HashMap, fmt::Write};
use pulldown_cmark::{CodeBlockKind, CowStr, Event, HeadingLevel, Options, Parser, Tag};
use yew::{
virtual_dom::{VList, VNode, VTag, VText},
Html,
};
struct Writer<'a, I> {
tokens: I,
output: Vec<VNode>,
stack: Vec<VTag>,
footnotes: HashMap<CowStr<'a>, usize>,
}
impl<'a, I> Writer<'a, I>
where
I: Iterator<Item = Event<'a>>,
{
fn new(tokens: I) -> Self {
Self {
tokens,
output: Vec::new(),
stack: Vec::new(),
footnotes: HashMap::new(),
}
}
fn start_tag(&mut self, tag: Tag) {
match tag {
Tag::Paragraph => self.stack.push(VTag::new("p")),
Tag::Heading(level, ident, classes) => {
let mut tag = VTag::new(match level {
HeadingLevel::H1 => "h1",
HeadingLevel::H2 => "h2",
HeadingLevel::H3 => "h3",
HeadingLevel::H4 => "h4",
HeadingLevel::H5 => "h5",
HeadingLevel::H6 => "h6",
});
if let Some(ident) = ident {
tag.add_attribute("id", ident.to_string());
}
if !classes.is_empty() {
tag.add_attribute("class", classes.join(" "));
}
self.stack.push(tag);
}
Tag::BlockQuote => self.stack.push(VTag::new("blockquote")),
Tag::CodeBlock(kind) => {
let mut pre = VTag::new("pre");
let mut code = VTag::new("code");
if let CodeBlockKind::Fenced(language) = kind {
if !language.is_empty() {
code.add_attribute("class", format!("lang-{language}"));
}
}
pre.add_child(code.into());
self.stack.push(pre);
}
Tag::List(ordered) => {
let mut tag = VTag::new(if ordered.is_some() { "ol" } else { "ul" });
if let Some(start) = ordered {
tag.add_attribute("start", start.to_string());
}
self.stack.push(tag);
}
Tag::Item => self.stack.push(VTag::new("li")),
Tag::FootnoteDefinition(_) => todo!(),
Tag::Table(_) => todo!(),
Tag::TableHead => todo!(),
Tag::TableRow => todo!(),
Tag::TableCell => todo!(),
Tag::Emphasis => self.stack.push(VTag::new("em")),
Tag::Strong => self.stack.push(VTag::new("strong")),
Tag::Strikethrough => self.stack.push(VTag::new("s")),
Tag::Link(_, href, title) => {
let mut tag = VTag::new("a");
tag.add_attribute("href", href.to_string());
tag.add_attribute("title", title.to_string());
self.stack.push(tag);
}
Tag::Image(_, href, title) => {
let mut tag = VTag::new("img");
tag.add_attribute("src", href.to_string());
tag.add_attribute("title", title.to_string());
if let Ok(alt) = self.raw_text() {
tag.add_attribute("alt", alt);
}
self.stack.push(tag);
}
}
}
fn end_tag(&mut self, tag: Tag) {
let top = self.stack.pop().unwrap_or_else(|| {
panic!("Expected stack to have an element at end of tag: {tag:?}");
});
self.output.push(top.into());
}
fn raw_text(&mut self) -> Result<String, std::fmt::Error> {
let mut output = String::new();
let mut nest = 0;
for event in self.tokens.by_ref() {
match event {
Event::Start(_) => nest += 1,
Event::End(tag) => {
log::info!("raw_text event.end: {tag:?}");
if nest == 0 {
break;
}
nest -= 1;
}
Event::Html(text) | Event::Code(text) | Event::Text(text) => {
write!(&mut output, "{text}")?
}
Event::SoftBreak | Event::HardBreak | Event::Rule => write!(&mut output, " ")?,
Event::FootnoteReference(name) => {
let next = self.footnotes.len() + 1;
let footnote = *self.footnotes.entry(name).or_insert(next);
write!(&mut output, "[{footnote}]")?;
}
Event::TaskListMarker(true) => write!(&mut output, "[x]")?,
Event::TaskListMarker(false) => write!(&mut output, "[ ]")?,
}
}
Ok(output)
}
fn run(mut self) -> Html {
while let Some(event) = self.tokens.next() {
match event {
Event::Start(tag) => self.start_tag(tag),
Event::End(tag) => self.end_tag(tag),
Event::Text(text) => {
if let Some(top) = self.stack.last_mut() {
let text = VText::new(text.to_string());
top.add_child(text.into());
}
}
Event::Code(text) => {
if let Some(top) = self.stack.last_mut() {
let text = VText::new(text.to_string());
let mut code = VTag::new("code");
code.add_child(text.into());
top.add_child(code.into());
}
}
Event::Html(_) => {
log::info!("Ignoring html: {event:?}")
}
Event::FootnoteReference(_) => todo!(),
Event::SoftBreak => {}
Event::HardBreak => {}
Event::Rule => {}
Event::TaskListMarker(_) => todo!(),
}
}
// debug_assert!(
// self.stack.is_empty(),
// "Stack is not empty: {:?}",
// self.stack
// );
VList::with_children(self.output, None).into()
}
}
pub fn markdown(content: &str) -> Html {
let parser = Parser::new_ext(content, Options::all());
Writer::new(parser).run()
}

View File

@ -2,7 +2,7 @@ use include_dir::{include_dir, Dir, File};
use std::collections::HashMap; use std::collections::HashMap;
use yew::{function_component, html, Children, ContextProvider, Html, Properties}; use yew::{function_component, html, Children, ContextProvider, Html, Properties};
use super::{frontmatter::parse_front_matter, PostInfo, Tag}; use super::{frontmatter::parse_front_matter, Post, PostInfo, Tag};
pub type TagsContext = HashMap<String, Tag>; pub type TagsContext = HashMap<String, Tag>;
pub type PostsContext = Vec<PostInfo>; pub type PostsContext = Vec<PostInfo>;
@ -24,22 +24,6 @@ pub fn provide_tags(props: &ProvideTagsProps) -> Html {
} }
} }
pub fn get_tags() -> TagsContext {
let file = CONTENT_DIR.get_file("tags.yaml").expect("tags.yaml");
let tags = match serde_yaml::from_slice::<Vec<Tag>>(file.contents()) {
Ok(tags) => tags
.into_iter()
.map(|tag| (tag.slug.clone(), tag))
.collect::<TagsContext>(),
Err(err) => {
panic!("Failed to parse tags.yaml: {err}");
}
};
log::info!("Loaded {} tags", tags.len());
tags
}
#[derive(Properties, PartialEq)] #[derive(Properties, PartialEq)]
pub struct ProvidePostsProps { pub struct ProvidePostsProps {
#[prop_or_default] #[prop_or_default]
@ -57,6 +41,41 @@ pub fn provide_posts(props: &ProvidePostsProps) -> Html {
} }
} }
#[derive(Properties, PartialEq)]
pub struct ProvidePostProps {
pub slug: String,
#[prop_or_default]
pub children: Children,
}
#[function_component(ProvidePost)]
pub fn provide_post(props: &ProvidePostProps) -> Html {
let post = get_post(&props.slug)
.unwrap_or_else(|| panic!("Failed to find post with slug: '{}'", props.slug));
html! {
<ContextProvider<Post> context={post}>
{props.children.clone()}
</ContextProvider<Post>>
}
}
pub fn get_tags() -> TagsContext {
let file = CONTENT_DIR.get_file("tags.yaml").expect("tags.yaml");
let tags = match serde_yaml::from_slice::<Vec<Tag>>(file.contents()) {
Ok(tags) => tags
.into_iter()
.map(|tag| (tag.slug.clone(), tag))
.collect::<TagsContext>(),
Err(err) => {
panic!("Failed to parse tags.yaml: {err}");
}
};
log::info!("Loaded {} tags", tags.len());
tags
}
pub fn get_posts() -> PostsContext { pub fn get_posts() -> PostsContext {
let files = CONTENT_DIR.get_dir("posts").expect("posts dir").files(); let files = CONTENT_DIR.get_dir("posts").expect("posts dir").files();
let mut posts = files.filter_map(load_post_info).collect::<Vec<_>>(); let mut posts = files.filter_map(load_post_info).collect::<Vec<_>>();
@ -65,7 +84,16 @@ pub fn get_posts() -> PostsContext {
posts posts
} }
pub fn get_post(slug: &str) -> Option<Post> {
let file = CONTENT_DIR.get_file(format!("posts/{slug}.md"))?;
load_post(file)
}
fn load_post_info(file: &File) -> Option<PostInfo> { fn load_post_info(file: &File) -> Option<PostInfo> {
load_post(file).map(|post| post.info)
}
fn load_post(file: &File) -> Option<Post> {
let (Some(front_matter), matter) = parse_front_matter(file.contents()) else { let (Some(front_matter), matter) = parse_front_matter(file.contents()) else {
return None; return None;
}; };
@ -80,12 +108,10 @@ fn load_post_info(file: &File) -> Option<PostInfo> {
let reading_time = words_count::count(&matter.content).words / 200; let reading_time = words_count::count(&matter.content).words / 200;
Some(PostInfo::from_front_matter( Some(Post {
slug, info: PostInfo::from_front_matter(slug, Some(reading_time), matter.excerpt, front_matter),
Some(reading_time), content: matter.content,
matter.excerpt, })
front_matter,
))
} }
static CONTENT_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/content"); static CONTENT_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/content");

View File

@ -15,13 +15,13 @@ pub enum Route {
About, About,
#[at("/blog")] #[at("/blog")]
Blog, Blog,
#[at("/blog/{slug}")] #[at("/blog/:slug")]
BlogPost { slug: String }, BlogPost { slug: String },
#[at("/disclaimer")] #[at("/disclaimer")]
Disclaimer, Disclaimer,
#[at("/tags")] #[at("/tags")]
Tags, Tags,
#[at("/tags/{slug}")] #[at("/tags/:slug")]
Tag { slug: String }, Tag { slug: String },
} }

View File

@ -1,5 +1,10 @@
use yew::{function_component, html, Html, Properties}; use yew::{function_component, html, Html, Properties};
use crate::{
components::content::PostContent,
model::source::{ProvidePost, ProvideTags},
};
#[derive(Properties, PartialEq)] #[derive(Properties, PartialEq)]
pub struct PageProps { pub struct PageProps {
pub slug: String, pub slug: String,
@ -7,5 +12,11 @@ pub struct PageProps {
#[function_component(Page)] #[function_component(Page)]
pub fn page(props: &PageProps) -> Html { pub fn page(props: &PageProps) -> Html {
html! { <h1>{format!("Blog post '{}'", props.slug)}</h1> } html! {
<ProvideTags>
<ProvidePost slug={props.slug.clone()}>
<PostContent />
</ProvidePost>
</ProvideTags>
}
} }