Compare commits
No commits in common. "d9d17bb971fdbb025381ac08b46c3b2a8b17851f" and "fb6ca6c245f712ced2c9f8472958fe0e4d28b4c4" have entirely different histories.
d9d17bb971
...
fb6ca6c245
@ -25,7 +25,6 @@ anyhow = "1.0.86"
|
||||
rayon = "1.10.0"
|
||||
syntect = "5.2.0"
|
||||
indoc = "2.0.5"
|
||||
askama_escape = "0.10.3"
|
||||
|
||||
[build]
|
||||
rustflags = ["-Z", "threads=8"]
|
||||
|
@ -1,5 +1,10 @@
|
||||
---
|
||||
title: Portfolio - Michal Vanko
|
||||
work_history_prelude: >-
|
||||
I've started learning web development when I was 14 years old. My very first
|
||||
website was a presentation site for my own _Counter-Strike_ clan.
|
||||
|
||||
Then I had an opportunity to create a registration system for marathon runners for Europe's oldest marathon event. That basically started off my career as a web developer. I've worked on some projects while I was studying in high school and university. After that, I've started to work full-time as a web developer and gained more experience in developing real-time web applications.
|
||||
work_history:
|
||||
- description: >-
|
||||
_sudolabs_ is a company focused on building products and software
|
||||
|
20
src/filters.rs
Normal file
20
src/filters.rs
Normal file
@ -0,0 +1,20 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use tracing::debug;
|
||||
|
||||
// This filter does not have extra arguments
|
||||
pub fn pretty_date(date_time: &DateTime<Utc>) -> ::askama::Result<String> {
|
||||
let formatted = format!("{}", date_time.format("%e %B %Y"));
|
||||
Ok(formatted)
|
||||
}
|
||||
|
||||
// This filter does not have extra arguments
|
||||
pub fn description_filter(body: &str) -> ::askama::Result<String> {
|
||||
let description = body
|
||||
.lines()
|
||||
.filter(|line| line.starts_with("<p>"))
|
||||
.take(2)
|
||||
.collect::<Vec<&str>>()
|
||||
.join("\n");
|
||||
debug!(description);
|
||||
Ok(description)
|
||||
}
|
@ -1,170 +0,0 @@
|
||||
use std::path::Path;
|
||||
|
||||
use image::image_dimensions;
|
||||
use indoc::formatdoc;
|
||||
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
|
||||
use syntect::{highlighting::ThemeSet, html::highlighted_html_for_string, parsing::SyntaxSet};
|
||||
use tracing::{debug, error};
|
||||
|
||||
use crate::picture_generator::{
|
||||
picture_markup_generator::generate_picture_markup, resolutions::get_max_resolution,
|
||||
};
|
||||
|
||||
pub const MAX_BLOG_IMAGE_RESOLUTION: (u32, u32) = (1280, 860);
|
||||
|
||||
enum TextKind {
|
||||
Text,
|
||||
Heading(Option<String>),
|
||||
Code(String),
|
||||
}
|
||||
|
||||
// pub fn parse_markdown(markdown: &str) -> ::askama::Result<String>
|
||||
pub fn parse_markdown(markdown: &str) -> ::askama::Result<String> {
|
||||
let mut options = Options::empty();
|
||||
options.insert(Options::ENABLE_TABLES);
|
||||
options.insert(Options::ENABLE_FOOTNOTES);
|
||||
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
options.insert(Options::ENABLE_TASKLISTS);
|
||||
options.insert(Options::ENABLE_SMART_PUNCTUATION);
|
||||
options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
|
||||
|
||||
let mut text_kind = TextKind::Text;
|
||||
let syntax_set = SyntaxSet::load_defaults_newlines();
|
||||
let theme_set = ThemeSet::load_defaults();
|
||||
let theme = theme_set.themes.get("InspiredGitHub").unwrap();
|
||||
let mut heading_ended: Option<bool> = None;
|
||||
|
||||
let parser = Parser::new_ext(markdown, options).map(|event| match event {
|
||||
/*
|
||||
Parsing images considers `alt` attribute as inner `Text` event
|
||||
Therefore the `[alt]` is rendered in html as subtitle
|
||||
and the `[](url "title")` `title` is rendered as `alt` attribute
|
||||
*/
|
||||
Event::Start(Tag::Image {
|
||||
link_type: _,
|
||||
dest_url,
|
||||
title,
|
||||
id: _,
|
||||
}) => {
|
||||
if !dest_url.starts_with("/") {
|
||||
return Event::Html(
|
||||
formatdoc!(
|
||||
r#"<img
|
||||
alt="{title}"
|
||||
src="{dest_url}"
|
||||
/>"#
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let dev_only_img_path =
|
||||
Path::new("static/").join(dest_url.strip_prefix("/").unwrap_or(&dest_url));
|
||||
let img_dimensions = image_dimensions(&dev_only_img_path).unwrap();
|
||||
|
||||
let (max_width, max_height) = get_max_resolution(
|
||||
img_dimensions,
|
||||
MAX_BLOG_IMAGE_RESOLUTION.0,
|
||||
MAX_BLOG_IMAGE_RESOLUTION.1,
|
||||
);
|
||||
|
||||
// Place image into the content with scaled reso to a boundary
|
||||
let picture_markup = generate_picture_markup(
|
||||
&dest_url, max_width, max_height, &title, None,
|
||||
)
|
||||
.unwrap_or(formatdoc!(
|
||||
r#"
|
||||
<img
|
||||
alt="{alt}"
|
||||
src="{src}"
|
||||
/>"#,
|
||||
alt = title,
|
||||
src = dest_url,
|
||||
));
|
||||
Event::Html(
|
||||
formatdoc!(
|
||||
r#"<figure>
|
||||
{picture_markup}
|
||||
<figcaption>
|
||||
"#,
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang))) => {
|
||||
text_kind = TextKind::Code(lang.to_string());
|
||||
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang)))
|
||||
}
|
||||
Event::Text(text) => match &text_kind {
|
||||
TextKind::Code(lang) => {
|
||||
// TODO Check https://github.com/trishume/syntect/pull/535 for typescript support
|
||||
let lang = if ["ts".to_string(), "typescript".to_string()].contains(lang) {
|
||||
"javascript"
|
||||
} else {
|
||||
lang
|
||||
};
|
||||
let syntax_reference = syntax_set
|
||||
.find_syntax_by_token(lang)
|
||||
.unwrap_or(syntax_set.find_syntax_plain_text());
|
||||
let highlighted =
|
||||
highlighted_html_for_string(&text, &syntax_set, syntax_reference, theme)
|
||||
.unwrap();
|
||||
Event::Html(highlighted.into())
|
||||
}
|
||||
TextKind::Heading(provided_id) => {
|
||||
let heading_id = provided_id.clone().unwrap_or({
|
||||
text.to_lowercase()
|
||||
.replace(|c: char| !c.is_alphanumeric(), "-")
|
||||
});
|
||||
debug!("heading_id: {}", heading_id.clone());
|
||||
match heading_ended {
|
||||
None => {
|
||||
error!("Heading should have set state");
|
||||
panic!("Heading should have set state");
|
||||
}
|
||||
Some(true) => Event::Html(text),
|
||||
Some(false) => {
|
||||
heading_ended = Some(true);
|
||||
Event::Html(
|
||||
formatdoc!(
|
||||
r##"id="{heading_id}">
|
||||
{text}"##
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => Event::Text(text),
|
||||
},
|
||||
Event::Start(Tag::Heading {
|
||||
level,
|
||||
id,
|
||||
classes: _,
|
||||
attrs: _,
|
||||
}) => {
|
||||
let id_str = id.map(|id| id.to_string());
|
||||
debug!("heading_start: {:?}, level: {}", &id_str, level);
|
||||
text_kind = TextKind::Heading(id_str);
|
||||
heading_ended = Some(false);
|
||||
Event::Html(format!("<{level} ").into())
|
||||
}
|
||||
Event::Start(_) => event,
|
||||
Event::End(TagEnd::Image) => Event::Html("</figcaption></figure>".into()),
|
||||
Event::End(TagEnd::CodeBlock) => {
|
||||
text_kind = TextKind::Text;
|
||||
Event::End(TagEnd::CodeBlock)
|
||||
}
|
||||
Event::End(TagEnd::Heading(heading_level)) => {
|
||||
text_kind = TextKind::Text;
|
||||
heading_ended = None;
|
||||
Event::End(TagEnd::Heading(heading_level))
|
||||
}
|
||||
_ => event,
|
||||
});
|
||||
|
||||
// Write to String buffer
|
||||
let mut html = String::new();
|
||||
pulldown_cmark::html::push_html(&mut html, parser);
|
||||
Ok(html)
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
mod markdown;
|
||||
mod pretty_date;
|
||||
mod truncate_md;
|
||||
pub use markdown::parse_markdown;
|
||||
pub use pretty_date::pretty_date;
|
||||
pub use truncate_md::truncate_md;
|
@ -1,7 +0,0 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
// This filter does not have extra arguments
|
||||
pub fn pretty_date(date_time: &DateTime<Utc>) -> ::askama::Result<String> {
|
||||
let formatted = format!("{}", date_time.format("%e %B %Y"));
|
||||
Ok(formatted)
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
// This filter does not have extra arguments
|
||||
|
||||
const FORBIDDEN_LINES: [&str; 5] = [" ", "#", "-", "!", "<"];
|
||||
|
||||
pub fn truncate_md(body: &str, rows: usize) -> ::askama::Result<String> {
|
||||
let description = body
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
!FORBIDDEN_LINES
|
||||
.iter()
|
||||
.any(|forbidden| line.starts_with(forbidden))
|
||||
&& !line.is_empty()
|
||||
})
|
||||
.take(rows)
|
||||
.collect::<Vec<&str>>()
|
||||
.join("\n");
|
||||
Ok(description)
|
||||
}
|
@ -55,5 +55,6 @@ async fn main() {
|
||||
// - fotos
|
||||
// THINK deploy to alula? rather then katelyn? can be change whenever
|
||||
//
|
||||
// TODO 404 page
|
||||
// TODO view page transitions
|
||||
// TODO cookbook
|
||||
|
@ -29,7 +29,7 @@ pub async fn render_blog_post(
|
||||
OriginalUri(original_uri): OriginalUri,
|
||||
) -> Result<BlogPostTemplate, StatusCode> {
|
||||
let path = format!("{}/{}.md", BLOG_POST_PATH, post_id);
|
||||
let parse_post = parse_post::<BlogPostMetadata>(&path);
|
||||
let parse_post = parse_post::<BlogPostMetadata>(&path, true);
|
||||
let parsed = parse_post.await?;
|
||||
let segment = if original_uri.to_string().starts_with("/blog") {
|
||||
"blog"
|
||||
|
@ -5,7 +5,6 @@ pub mod broadcast_list;
|
||||
pub mod contact;
|
||||
pub mod index;
|
||||
pub mod not_found;
|
||||
pub mod portfolio;
|
||||
pub mod post_list;
|
||||
pub mod project_list;
|
||||
pub mod showcase;
|
||||
|
@ -1,46 +0,0 @@
|
||||
use askama::Template;
|
||||
use axum::http::StatusCode;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
components::site_header::HeaderProps,
|
||||
filters,
|
||||
post_utils::{
|
||||
post_listing::get_post_list,
|
||||
post_parser::{parse_post, ParseResult},
|
||||
},
|
||||
projects::project_model::ProjectMetadata,
|
||||
};
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct PortfolioPageModel {
|
||||
pub title: String,
|
||||
// TODO work_history
|
||||
// TODO education
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "portfolio.html")]
|
||||
pub struct PortfolioTemplate {
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub project_list: Vec<ParseResult<ProjectMetadata>>,
|
||||
pub header_props: HeaderProps,
|
||||
}
|
||||
|
||||
pub async fn render_portfolio() -> Result<PortfolioTemplate, StatusCode> {
|
||||
let portfolio = parse_post::<PortfolioPageModel>("_pages/portfolio.md").await?;
|
||||
|
||||
let mut project_list = get_post_list::<ProjectMetadata>("_projects").await?;
|
||||
|
||||
project_list.sort_by_key(|post| post.slug.to_string());
|
||||
project_list.retain(|project| project.metadata.displayed);
|
||||
project_list.reverse();
|
||||
|
||||
Ok(PortfolioTemplate {
|
||||
title: "Portfolio".to_owned(),
|
||||
body: portfolio.body,
|
||||
header_props: HeaderProps::default(),
|
||||
project_list,
|
||||
})
|
||||
}
|
@ -3,7 +3,6 @@ use axum::http::StatusCode;
|
||||
|
||||
use crate::{
|
||||
components::site_header::HeaderProps,
|
||||
filters,
|
||||
post_utils::{post_listing::get_post_list, post_parser::ParseResult},
|
||||
projects::project_model::ProjectMetadata,
|
||||
};
|
||||
|
@ -13,6 +13,7 @@ pub fn generate_image_with_src(
|
||||
width: u32,
|
||||
height: u32,
|
||||
suffix: &str,
|
||||
generate_image: bool,
|
||||
) -> Result<String, anyhow::Error> {
|
||||
let path_to_generated = get_generated_file_name(orig_img_path);
|
||||
let file_stem = path_to_generated.file_stem().unwrap().to_str().unwrap();
|
||||
@ -28,26 +29,27 @@ pub fn generate_image_with_src(
|
||||
let path_to_generated_arc = Arc::new(path_to_generated);
|
||||
let path_to_generated_clone = Arc::clone(&path_to_generated_arc);
|
||||
|
||||
rayon::spawn(move || {
|
||||
let orig_img = ImageReader::open(&disk_img_path)
|
||||
.with_context(|| format!("Failed to read instrs from {:?}", &disk_img_path))
|
||||
.unwrap()
|
||||
.decode()
|
||||
.unwrap();
|
||||
let path_to_generated = path_to_generated_clone.as_ref();
|
||||
|
||||
let result = generate_images(
|
||||
&orig_img,
|
||||
path_to_generated,
|
||||
&resolutions,
|
||||
&[exported_format],
|
||||
)
|
||||
.with_context(|| "Failed to generate images".to_string());
|
||||
if let Err(e) = result {
|
||||
tracing::error!("Error: {}", e);
|
||||
}
|
||||
});
|
||||
if generate_image {
|
||||
rayon::spawn(move || {
|
||||
let orig_img = ImageReader::open(&disk_img_path)
|
||||
.with_context(|| format!("Failed to read instrs from {:?}", &disk_img_path))
|
||||
.unwrap()
|
||||
.decode()
|
||||
.unwrap();
|
||||
let path_to_generated = path_to_generated_clone.as_ref();
|
||||
|
||||
let result = generate_images(
|
||||
&orig_img,
|
||||
path_to_generated,
|
||||
&resolutions,
|
||||
&[exported_format],
|
||||
)
|
||||
.with_context(|| "Failed to generate images".to_string());
|
||||
if let Err(e) = result {
|
||||
tracing::error!("Error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
let path_to_generated = Arc::clone(&path_to_generated_arc);
|
||||
|
||||
let image_path = get_image_path(
|
||||
|
@ -20,6 +20,7 @@ pub fn generate_picture_markup(
|
||||
height: u32,
|
||||
alt_text: &str,
|
||||
class_name: Option<&str>,
|
||||
generate_image: bool,
|
||||
) -> Result<String, anyhow::Error> {
|
||||
let exported_formats = get_export_formats(orig_img_path);
|
||||
let class_attr = if let Some(class) = class_name {
|
||||
@ -54,22 +55,25 @@ pub fn generate_picture_markup(
|
||||
let exported_formats_arc = Arc::new(exported_formats);
|
||||
let exported_formats_clone = Arc::clone(&exported_formats_arc);
|
||||
|
||||
rayon::spawn(move || {
|
||||
let orig_img = ImageReader::open(&disk_img_path)
|
||||
.with_context(|| format!("Failed to read instrs from {:?}", &disk_img_path))
|
||||
.unwrap()
|
||||
.decode()
|
||||
.unwrap();
|
||||
let path_to_generated = path_to_generated_clone.as_ref();
|
||||
let resolutions = resolutions_clone.as_ref();
|
||||
let exported_formats = exported_formats_clone.as_ref();
|
||||
if generate_image {
|
||||
rayon::spawn(move || {
|
||||
let orig_img = ImageReader::open(&disk_img_path)
|
||||
.with_context(|| format!("Failed to read instrs from {:?}", &disk_img_path))
|
||||
.unwrap()
|
||||
.decode()
|
||||
.unwrap();
|
||||
let path_to_generated = path_to_generated_clone.as_ref();
|
||||
let resolutions = resolutions_clone.as_ref();
|
||||
let exported_formats = exported_formats_clone.as_ref();
|
||||
|
||||
let result = generate_images(&orig_img, path_to_generated, resolutions, exported_formats)
|
||||
.with_context(|| "Failed to generate images".to_string());
|
||||
if let Err(e) = result {
|
||||
tracing::error!("Error: {}", e);
|
||||
}
|
||||
});
|
||||
let result =
|
||||
generate_images(&orig_img, path_to_generated, resolutions, exported_formats)
|
||||
.with_context(|| "Failed to generate images".to_string());
|
||||
if let Err(e) = result {
|
||||
tracing::error!("Error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let exported_formats = Arc::clone(&exported_formats_arc);
|
||||
let path_to_generated = Arc::clone(&path_to_generated_arc);
|
||||
@ -310,8 +314,15 @@ fn test_generate_picture_markup() {
|
||||
</picture>"#,
|
||||
};
|
||||
assert_eq!(
|
||||
generate_picture_markup(orig_img_path, width, height, "Testing image alt", None,)
|
||||
.expect("picture markup has to be generated"),
|
||||
generate_picture_markup(
|
||||
orig_img_path,
|
||||
width,
|
||||
height,
|
||||
"Testing image alt",
|
||||
None,
|
||||
false
|
||||
)
|
||||
.expect("picture markup has to be generated"),
|
||||
result
|
||||
);
|
||||
}
|
||||
|
@ -21,7 +21,7 @@ pub async fn get_post_list<'de, Metadata: DeserializeOwned>(
|
||||
let file_path = file.path();
|
||||
let file_path_str = file_path.to_str().unwrap();
|
||||
info!(":{}", file_path_str);
|
||||
let post = parse_post::<Metadata>(file_path_str).await?;
|
||||
let post = parse_post::<Metadata>(file_path_str, false).await?;
|
||||
posts.push(post);
|
||||
}
|
||||
|
||||
|
@ -1,10 +1,22 @@
|
||||
use core::panic;
|
||||
use std::path::Path;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use chrono::{DateTime, Utc};
|
||||
use gray_matter::{engine::YAML, Matter};
|
||||
use image::image_dimensions;
|
||||
use indoc::formatdoc;
|
||||
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
|
||||
use serde::{de::DeserializeOwned, Deserialize, Deserializer};
|
||||
use syntect::{highlighting::ThemeSet, html::highlighted_html_for_string, parsing::SyntaxSet};
|
||||
use tokio::fs;
|
||||
use tracing::{debug, error};
|
||||
|
||||
use crate::picture_generator::{
|
||||
picture_markup_generator::generate_picture_markup, resolutions::get_max_resolution,
|
||||
};
|
||||
|
||||
pub const MAX_BLOG_IMAGE_RESOLUTION: (u32, u32) = (1280, 860);
|
||||
|
||||
pub fn deserialize_date<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
|
||||
where
|
||||
@ -29,6 +41,7 @@ pub struct ParseResult<Metadata> {
|
||||
|
||||
pub async fn parse_post<'de, Metadata: DeserializeOwned>(
|
||||
path: &str,
|
||||
generate_images: bool,
|
||||
) -> Result<ParseResult<Metadata>, StatusCode> {
|
||||
let file_contents = fs::read_to_string(path)
|
||||
.await
|
||||
@ -43,6 +56,8 @@ pub async fn parse_post<'de, Metadata: DeserializeOwned>(
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let body = parse_html(&metadata.content, generate_images);
|
||||
|
||||
let filename = Path::new(path)
|
||||
.file_stem()
|
||||
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
@ -51,8 +66,173 @@ pub async fn parse_post<'de, Metadata: DeserializeOwned>(
|
||||
.to_owned();
|
||||
|
||||
Ok(ParseResult {
|
||||
body: metadata.content,
|
||||
body,
|
||||
metadata: metadata.data,
|
||||
slug: filename,
|
||||
})
|
||||
}
|
||||
|
||||
enum TextKind {
|
||||
Text,
|
||||
Heading(Option<String>),
|
||||
Code(String),
|
||||
}
|
||||
|
||||
pub fn parse_html(markdown: &str, generate_images: bool) -> String {
|
||||
let mut options = Options::empty();
|
||||
options.insert(Options::ENABLE_TABLES);
|
||||
options.insert(Options::ENABLE_FOOTNOTES);
|
||||
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
options.insert(Options::ENABLE_TASKLISTS);
|
||||
options.insert(Options::ENABLE_SMART_PUNCTUATION);
|
||||
options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
|
||||
|
||||
let mut text_kind = TextKind::Text;
|
||||
let syntax_set = SyntaxSet::load_defaults_newlines();
|
||||
let theme_set = ThemeSet::load_defaults();
|
||||
let theme = theme_set.themes.get("InspiredGitHub").unwrap();
|
||||
let mut heading_ended: Option<bool> = None;
|
||||
|
||||
let parser = Parser::new_ext(markdown, options).map(|event| match event {
|
||||
/*
|
||||
Parsing images considers `alt` attribute as inner `Text` event
|
||||
Therefore the `[alt]` is rendered in html as subtitle
|
||||
and the `[](url "title")` `title` is rendered as `alt` attribute
|
||||
*/
|
||||
Event::Start(Tag::Image {
|
||||
link_type,
|
||||
dest_url,
|
||||
title,
|
||||
id,
|
||||
}) => {
|
||||
if !dest_url.starts_with("/") {
|
||||
return Event::Html(
|
||||
formatdoc!(
|
||||
r#"<img
|
||||
alt="{title}"
|
||||
src="{dest_url}"
|
||||
/>"#
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let dev_only_img_path =
|
||||
Path::new("static/").join(dest_url.strip_prefix("/").unwrap_or(&dest_url));
|
||||
let img_dimensions = image_dimensions(&dev_only_img_path).unwrap();
|
||||
|
||||
let (max_width, max_height) = get_max_resolution(
|
||||
img_dimensions,
|
||||
MAX_BLOG_IMAGE_RESOLUTION.0,
|
||||
MAX_BLOG_IMAGE_RESOLUTION.1,
|
||||
);
|
||||
|
||||
// Place image into the content with scaled reso to a boundary
|
||||
let picture_markup = generate_picture_markup(
|
||||
&dest_url,
|
||||
max_width,
|
||||
max_height,
|
||||
&title,
|
||||
None,
|
||||
generate_images,
|
||||
)
|
||||
.unwrap_or(formatdoc!(
|
||||
r#"
|
||||
<img
|
||||
alt="{alt}"
|
||||
src="{src}"
|
||||
/>"#,
|
||||
alt = title,
|
||||
src = dest_url,
|
||||
));
|
||||
debug!(
|
||||
"Image link_type: {:?} url: {} title: {} id: {}",
|
||||
link_type, dest_url, title, id
|
||||
);
|
||||
Event::Html(
|
||||
formatdoc!(
|
||||
r#"<figure>
|
||||
{picture_markup}
|
||||
<figcaption>
|
||||
"#,
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang))) => {
|
||||
text_kind = TextKind::Code(lang.to_string());
|
||||
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang)))
|
||||
}
|
||||
Event::Text(text) => match &text_kind {
|
||||
TextKind::Code(lang) => {
|
||||
// TODO Check https://github.com/trishume/syntect/pull/535 for typescript support
|
||||
let lang = if ["ts".to_string(), "typescript".to_string()].contains(lang) {
|
||||
"javascript"
|
||||
} else {
|
||||
lang
|
||||
};
|
||||
let syntax_reference = syntax_set
|
||||
.find_syntax_by_token(lang)
|
||||
.unwrap_or(syntax_set.find_syntax_plain_text());
|
||||
let highlighted =
|
||||
highlighted_html_for_string(&text, &syntax_set, syntax_reference, theme)
|
||||
.unwrap();
|
||||
Event::Html(highlighted.into())
|
||||
}
|
||||
TextKind::Heading(provided_id) => {
|
||||
let heading_id = provided_id.clone().unwrap_or({
|
||||
text.to_lowercase()
|
||||
.replace(|c: char| !c.is_alphanumeric(), "-")
|
||||
});
|
||||
debug!("heading_id: {}", heading_id.clone());
|
||||
match heading_ended {
|
||||
None => {
|
||||
error!("Heading should have set state");
|
||||
panic!("Heading should have set state");
|
||||
}
|
||||
Some(true) => Event::Html(text),
|
||||
Some(false) => {
|
||||
heading_ended = Some(true);
|
||||
Event::Html(
|
||||
formatdoc!(
|
||||
r##"id="{heading_id}">
|
||||
{text}"##
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => Event::Text(text),
|
||||
},
|
||||
Event::Start(Tag::Heading {
|
||||
level,
|
||||
id,
|
||||
classes: _,
|
||||
attrs: _,
|
||||
}) => {
|
||||
let id_str = id.map(|id| id.to_string());
|
||||
debug!("heading_start: {:?}, level: {}", &id_str, level);
|
||||
text_kind = TextKind::Heading(id_str);
|
||||
heading_ended = Some(false);
|
||||
Event::Html(format!("<{level} ").into())
|
||||
}
|
||||
Event::Start(_) => event,
|
||||
Event::End(TagEnd::Image) => Event::Html("</figcaption></figure>".into()),
|
||||
Event::End(TagEnd::CodeBlock) => {
|
||||
text_kind = TextKind::Text;
|
||||
Event::End(TagEnd::CodeBlock)
|
||||
}
|
||||
Event::End(TagEnd::Heading(heading_level)) => {
|
||||
text_kind = TextKind::Text;
|
||||
heading_ended = None;
|
||||
Event::End(TagEnd::Heading(heading_level))
|
||||
}
|
||||
_ => event,
|
||||
});
|
||||
|
||||
// Write to String buffer
|
||||
let mut html = String::new();
|
||||
pulldown_cmark::html::push_html(&mut html, parser);
|
||||
html
|
||||
}
|
||||
|
@ -4,8 +4,7 @@ use crate::{
|
||||
admin::render_admin, blog_post_list::render_blog_post_list,
|
||||
blog_post_page::render_blog_post, broadcast_list::render_broadcast_post_list,
|
||||
contact::render_contact, index::render_index, not_found::render_not_found,
|
||||
portfolio::render_portfolio, project_list::render_projects_list,
|
||||
showcase::egg_fetcher::render_egg_fetcher,
|
||||
project_list::render_projects_list, showcase::egg_fetcher::render_egg_fetcher,
|
||||
},
|
||||
};
|
||||
use axum::{extract::MatchedPath, http::Request, routing::get, Router};
|
||||
@ -24,7 +23,6 @@ pub fn get_router() -> Router {
|
||||
.route("/contact", get(render_contact))
|
||||
.route("/showcase", get(render_projects_list))
|
||||
.route("/showcase/:project_slug", get(render_egg_fetcher))
|
||||
.route("/portfolio", get(render_portfolio))
|
||||
.route("/admin", get(render_admin))
|
||||
.route("/feed.xml", get(render_rss_feed))
|
||||
.layer(
|
||||
|
@ -64,6 +64,11 @@ collections:
|
||||
fields:
|
||||
- { label: Title, name: title, widget: string }
|
||||
- { label: Body, name: body, widget: markdown }
|
||||
- {
|
||||
label: Work history prelude,
|
||||
name: work_history_prelude,
|
||||
widget: markdown,
|
||||
}
|
||||
- label: Work history
|
||||
name: work_history
|
||||
widget: list
|
||||
@ -80,7 +85,37 @@ collections:
|
||||
- { label: City, name: city, widget: string, required: false }
|
||||
- { label: Country, name: country, widget: string, required: false }
|
||||
- { label: Displayed, name: displayed, widget: boolean, default: true }
|
||||
- { label: Thumbnail, name: thumbnail, widget: image, required: false }
|
||||
- label: Projects
|
||||
name: projects
|
||||
widget: list
|
||||
fields:
|
||||
- { label: Project name, name: name, widget: string }
|
||||
- {
|
||||
label: Displayed,
|
||||
name: displayed,
|
||||
widget: boolean,
|
||||
default: true,
|
||||
}
|
||||
- { label: Description, name: body, widget: markdown }
|
||||
- {
|
||||
label: Cover image,
|
||||
name: cover_image,
|
||||
widget: image,
|
||||
required: false,
|
||||
}
|
||||
- label: Presentations
|
||||
name: presentations
|
||||
widget: list
|
||||
fields:
|
||||
- { label: Name, name: name, widget: string }
|
||||
- {
|
||||
label: Displayed,
|
||||
name: displayed,
|
||||
widget: boolean,
|
||||
default: true,
|
||||
}
|
||||
- { label: Description, name: description, widget: markdown }
|
||||
- { label: Link, name: link, widget: string }
|
||||
- label: Education
|
||||
name: education
|
||||
widget: list
|
||||
@ -93,7 +128,6 @@ collections:
|
||||
default: true,
|
||||
}
|
||||
- { label: Description, name: description, widget: markdown }
|
||||
- { label: Thumbnail, name: thumbnail, widget: image, required: false }
|
||||
- name: 'projects' # Used in routes, e.g., /admin/collections/blog
|
||||
label: 'Showcase projects' # Used in the UI
|
||||
folder: '_projects/' # The path to the folder where the documents are stored
|
||||
|
@ -662,11 +662,6 @@ video {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.mx-3 {
|
||||
margin-left: 0.75rem;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.mx-4 {
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
@ -707,6 +702,11 @@ video {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.mx-3 {
|
||||
margin-left: 0.75rem;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
@ -1027,11 +1027,6 @@ video {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.text-9xl {
|
||||
font-size: 8rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.text-base {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
@ -1052,6 +1047,16 @@ video {
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
|
||||
.text-6xl {
|
||||
font-size: 3.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.text-9xl {
|
||||
font-size: 8rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
@ -6,7 +6,7 @@
|
||||
<meta property="og:url" content="https://michalvanko.dev/{{segment}}/{{slug}}" />
|
||||
{% match thumbnail %}
|
||||
{% when Some with (img) %}
|
||||
{% let src = crate::picture_generator::image_src_generator::generate_image_with_src(img, 1200, 630, "_og").unwrap_or("thumbnail not found".to_string())|safe %}
|
||||
{% let src = crate::picture_generator::image_src_generator::generate_image_with_src(img, 1200, 630, "_og", true).unwrap_or("thumbnail not found".to_string())|safe %}
|
||||
<meta property="og:image" content="https://michalvanko.dev{{src}}" />
|
||||
{% when None %}
|
||||
<meta property="og:image" content="https://michalvanko.dev/images/m-logo.svg" />
|
||||
@ -28,7 +28,7 @@
|
||||
</header>
|
||||
|
||||
<section class="article-body">
|
||||
{{body|parse_markdown|safe}}
|
||||
{{body|escape("none")}}
|
||||
</section>
|
||||
</article>
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
<aside class="row-span-3 self-center float-start sm:float-none mr-3 mb-3 sm:ml-0 sm:mb-0">
|
||||
{% match post.metadata.thumbnail %}
|
||||
{% when Some with (orig_path) %}
|
||||
{{ crate::picture_generator::picture_markup_generator::generate_picture_markup(orig_path, 180, 240, "Article thumbnail", None).unwrap_or("thumbnail not found".to_string())|safe }}
|
||||
{{ crate::picture_generator::picture_markup_generator::generate_picture_markup(orig_path, 180, 240, "Article thumbnail", None, true).unwrap_or("thumbnail not found".to_string())|safe }}
|
||||
{% when None %}
|
||||
<div>
|
||||
{% include "components/blog_post_default_thumbnail.html" %}
|
||||
@ -14,7 +14,7 @@
|
||||
<a rel="prefetch" href="/{{segment}}/{{post.slug}}" class="text-blue-950 visited:text-purple-700 no-underline">{{post.metadata.title}}</a>
|
||||
</h3>
|
||||
</header>
|
||||
<section class="text-base leading-5 text-slate-800 md:text-xl text-justify">{{post.body|truncate_md(2)|parse_markdown|safe}}</section>
|
||||
<section class="text-base leading-5 text-slate-800 md:text-xl text-justify">{{post.body|description_filter|safe}}</section>
|
||||
<footer class="text-sm md:text-base lg:text-lg mt-3 sm:mt-0 clear-both sm:clear-none">
|
||||
<ul class="inline-block">
|
||||
{% for tag in post.metadata.tags %}
|
||||
|
@ -11,14 +11,14 @@
|
||||
{% endmatch %}
|
||||
</h2>
|
||||
<section class="description text-slate-800 my-2 md:text-xl text-justify">
|
||||
{{project.body|parse_markdown|safe}}
|
||||
{{project.body|safe}}
|
||||
</section>
|
||||
</header>
|
||||
<!-- <hr class="border-blue-950 my-5"> -->
|
||||
|
||||
{% match project.metadata.cover_image %}
|
||||
{% when Some with (source) %}
|
||||
{% let picture = crate::picture_generator::picture_markup_generator::generate_picture_markup(source, 420, 236, "Project cover", Some("max-h-[236px]")).unwrap_or("cover not found".to_string()) %}
|
||||
{% let picture = crate::picture_generator::picture_markup_generator::generate_picture_markup(source, 420, 236, "Project cover", Some("max-h-[236px]"), true).unwrap_or("cover not found".to_string()) %}
|
||||
<figure class="mx-4 my-2 flex justify-center">
|
||||
{% match project.metadata.link %}
|
||||
{% when Some with (href) %}
|
||||
@ -28,6 +28,7 @@
|
||||
{% when None %}
|
||||
{{picture|safe}}
|
||||
{% endmatch %}
|
||||
<!-- TODO <figure> generate_image -->
|
||||
</figure>
|
||||
{% when None %}
|
||||
{% endmatch %}
|
||||
|
@ -9,6 +9,6 @@
|
||||
</header>
|
||||
{% let alt_text = format!("{svg} thumbnail") %}
|
||||
|
||||
{{ crate::picture_generator::picture_markup_generator::generate_picture_markup(img, 360, 128, alt_text, Some("h-auto mx-auto rounded-sm")).unwrap_or("thumbnail not found".to_string())|safe }}
|
||||
{{ crate::picture_generator::picture_markup_generator::generate_picture_markup(img, 360, 128, alt_text, Some("h-auto mx-auto rounded-sm"), true).unwrap_or("thumbnail not found".to_string())|safe }}
|
||||
</a>
|
||||
{% endmacro %}
|
||||
|
@ -1,38 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block og_meta %}
|
||||
<meta property="og:title" content="{{title}} @michalvankodev" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://michalvanko.dev/showcase" />
|
||||
<meta property="og:image" content="https://michalvanko.dev/images/m-logo.svg" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<section id="portfolio-body">
|
||||
|
||||
{{ body }}
|
||||
|
||||
</section>
|
||||
|
||||
<section id="project-list-container" class="max-w-maxindex mx-auto">
|
||||
<section id="project-list">
|
||||
{% if project_list.len() == 0 %}
|
||||
<p class="no-posts">You've found void in the space.</p>
|
||||
{% else %}
|
||||
<h1 class="m-5 text-4xl text-blue-950 font-extrabold md:text-6xl">
|
||||
Showcase
|
||||
</h1>
|
||||
|
||||
<ul class="m-6 grid grid-flow-row gap-6 md:grid-cols-2 md:grid-rows-[masonry] md:justify-stretch md:items-stretch xl:grid-cols-3">
|
||||
{% for project in project_list %}
|
||||
<li>
|
||||
{% include "components/project_preview_card.html" %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</section> <!-- /#project-list -->
|
||||
|
||||
</section> <!-- /#project-list-container -->
|
||||
{% endblock %}
|
Loading…
Reference in New Issue
Block a user