Switch over to WebAssembly, Rust and Yew #35
37
Cargo.lock
generated
37
Cargo.lock
generated
@ -327,6 +327,15 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gimli"
|
||||
version = "0.27.3"
|
||||
@ -1032,6 +1041,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "quote"
|
||||
version = "1.0.33"
|
||||
@ -1276,6 +1297,7 @@ dependencies = [
|
||||
"gray_matter",
|
||||
"include_dir",
|
||||
"log",
|
||||
"pulldown-cmark",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@ -1546,6 +1568,15 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.13"
|
||||
@ -1573,6 +1604,12 @@ dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.9"
|
||||
|
@ -34,6 +34,7 @@ yew = { version = "0.20" }
|
||||
yew-hooks = { version = "0.2" }
|
||||
yew-router = { version = "0.17" }
|
||||
log = { version = "0.4" }
|
||||
pulldown-cmark = { version = "0.9" }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = { version = "1.0" }
|
||||
|
@ -1,2 +1,4 @@
|
||||
pub mod blog;
|
||||
pub mod content;
|
||||
pub mod layout;
|
||||
pub mod markdown;
|
||||
|
@ -1,5 +1,5 @@
|
||||
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_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 =
|
||||
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! {
|
||||
<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">
|
||||
{facts.finish()}
|
||||
</div>
|
||||
@ -79,7 +84,7 @@ fn post_card_description(info: &PostInfo, tags: &TagsContext) -> Html {
|
||||
}
|
||||
</div>
|
||||
</Link<Route>>
|
||||
{post_card_details(info, tags)}
|
||||
{post_card_details(false, info, tags)}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ pub fn post_card_list() -> Html {
|
||||
let posts = use_context::<PostsContext>().expect("PostsContext to be provided");
|
||||
|
||||
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">
|
||||
{for posts.iter().enumerate().map(|(index, post)| {
|
||||
html! {
|
||||
@ -14,5 +15,6 @@ pub fn post_card_list() -> Html {
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
46
src/components/content.rs
Normal file
46
src/components/content.rs
Normal 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>
|
||||
}
|
||||
}
|
@ -15,9 +15,7 @@ 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>
|
||||
}
|
||||
|
194
src/components/markdown.rs
Normal file
194
src/components/markdown.rs
Normal 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()
|
||||
}
|
@ -2,7 +2,7 @@ use include_dir::{include_dir, Dir, File};
|
||||
use std::collections::HashMap;
|
||||
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 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)]
|
||||
pub struct ProvidePostsProps {
|
||||
#[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 {
|
||||
let files = CONTENT_DIR.get_dir("posts").expect("posts dir").files();
|
||||
let mut posts = files.filter_map(load_post_info).collect::<Vec<_>>();
|
||||
@ -65,7 +84,16 @@ pub fn get_posts() -> PostsContext {
|
||||
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> {
|
||||
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 {
|
||||
return None;
|
||||
};
|
||||
@ -80,12 +108,10 @@ fn load_post_info(file: &File) -> Option<PostInfo> {
|
||||
|
||||
let reading_time = words_count::count(&matter.content).words / 200;
|
||||
|
||||
Some(PostInfo::from_front_matter(
|
||||
slug,
|
||||
Some(reading_time),
|
||||
matter.excerpt,
|
||||
front_matter,
|
||||
))
|
||||
Some(Post {
|
||||
info: PostInfo::from_front_matter(slug, Some(reading_time), matter.excerpt, front_matter),
|
||||
content: matter.content,
|
||||
})
|
||||
}
|
||||
|
||||
static CONTENT_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/content");
|
||||
|
@ -15,13 +15,13 @@ pub enum Route {
|
||||
About,
|
||||
#[at("/blog")]
|
||||
Blog,
|
||||
#[at("/blog/{slug}")]
|
||||
#[at("/blog/:slug")]
|
||||
BlogPost { slug: String },
|
||||
#[at("/disclaimer")]
|
||||
Disclaimer,
|
||||
#[at("/tags")]
|
||||
Tags,
|
||||
#[at("/tags/{slug}")]
|
||||
#[at("/tags/:slug")]
|
||||
Tag { slug: String },
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,10 @@
|
||||
use yew::{function_component, html, Html, Properties};
|
||||
|
||||
use crate::{
|
||||
components::content::PostContent,
|
||||
model::source::{ProvidePost, ProvideTags},
|
||||
};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct PageProps {
|
||||
pub slug: String,
|
||||
@ -7,5 +12,11 @@ pub struct PageProps {
|
||||
|
||||
#[function_component(Page)]
|
||||
pub fn page(props: &PageProps) -> Html {
|
||||
html! { <h1>{format!("Blog post '{}'", props.slug)}</h1> }
|
||||
html! {
|
||||
<ProvideTags>
|
||||
<ProvidePost slug={props.slug.clone()}>
|
||||
<PostContent />
|
||||
</ProvidePost>
|
||||
</ProvideTags>
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user