Compare commits

...

2 Commits

Author SHA1 Message Date
d9d17bb971 image generation is now always wanted
Some checks failed
test / cargo test (push) Failing after 1m4s
2024-10-07 15:42:54 +02:00
11cc9f6d0a change the whole parsing of markdown functionality 2024-10-07 15:35:22 +02:00
24 changed files with 349 additions and 318 deletions

View File

@ -25,6 +25,7 @@ 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"]

View File

@ -1,10 +1,5 @@
---
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

View File

@ -1,20 +0,0 @@
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)
}

170
src/filters/markdown.rs Normal file
View File

@ -0,0 +1,170 @@
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)
}

6
src/filters/mod.rs Normal file
View File

@ -0,0 +1,6 @@
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;

View File

@ -0,0 +1,7 @@
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)
}

View File

@ -0,0 +1,18 @@
// 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)
}

View File

@ -55,6 +55,5 @@ async fn main() {
// - fotos
// THINK deploy to alula? rather then katelyn? can be change whenever
//
// TODO 404 page
// TODO view page transitions
// TODO cookbook

View File

@ -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, true);
let parse_post = parse_post::<BlogPostMetadata>(&path);
let parsed = parse_post.await?;
let segment = if original_uri.to_string().starts_with("/blog") {
"blog"

View File

@ -5,6 +5,7 @@ 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;

46
src/pages/portfolio.rs Normal file
View File

@ -0,0 +1,46 @@
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,
})
}

View File

@ -3,6 +3,7 @@ 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,
};

View File

@ -13,7 +13,6 @@ 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();
@ -29,7 +28,6 @@ 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);
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))
@ -49,7 +47,7 @@ pub fn generate_image_with_src(
tracing::error!("Error: {}", e);
}
});
}
let path_to_generated = Arc::clone(&path_to_generated_arc);
let image_path = get_image_path(

View File

@ -20,7 +20,6 @@ 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 {
@ -55,7 +54,6 @@ pub fn generate_picture_markup(
let exported_formats_arc = Arc::new(exported_formats);
let exported_formats_clone = Arc::clone(&exported_formats_arc);
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))
@ -66,14 +64,12 @@ pub fn generate_picture_markup(
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)
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);
@ -314,14 +310,7 @@ fn test_generate_picture_markup() {
</picture>"#,
};
assert_eq!(
generate_picture_markup(
orig_img_path,
width,
height,
"Testing image alt",
None,
false
)
generate_picture_markup(orig_img_path, width, height, "Testing image alt", None,)
.expect("picture markup has to be generated"),
result
);

View File

@ -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, false).await?;
let post = parse_post::<Metadata>(file_path_str).await?;
posts.push(post);
}

View File

@ -1,22 +1,10 @@
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
@ -41,7 +29,6 @@ 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
@ -56,8 +43,6 @@ 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)?
@ -66,173 +51,8 @@ pub async fn parse_post<'de, Metadata: DeserializeOwned>(
.to_owned();
Ok(ParseResult {
body,
body: metadata.content,
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
}

View File

@ -4,7 +4,8 @@ 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,
project_list::render_projects_list, showcase::egg_fetcher::render_egg_fetcher,
portfolio::render_portfolio, project_list::render_projects_list,
showcase::egg_fetcher::render_egg_fetcher,
},
};
use axum::{extract::MatchedPath, http::Request, routing::get, Router};
@ -23,6 +24,7 @@ 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(

View File

@ -64,11 +64,6 @@ 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
@ -85,37 +80,7 @@ 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: 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: Thumbnail, name: thumbnail, widget: image, required: false }
- label: Education
name: education
widget: list
@ -128,6 +93,7 @@ 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

View File

@ -662,6 +662,11 @@ video {
margin-right: 0.5rem;
}
.mx-3 {
margin-left: 0.75rem;
margin-right: 0.75rem;
}
.mx-4 {
margin-left: 1rem;
margin-right: 1rem;
@ -702,11 +707,6 @@ video {
margin-bottom: 1.25rem;
}
.mx-3 {
margin-left: 0.75rem;
margin-right: 0.75rem;
}
.mb-1 {
margin-bottom: 0.25rem;
}
@ -1027,6 +1027,11 @@ video {
line-height: 1;
}
.text-9xl {
font-size: 8rem;
line-height: 1;
}
.text-base {
font-size: 1rem;
line-height: 1.5rem;
@ -1047,16 +1052,6 @@ 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;
}

View File

@ -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", true).unwrap_or("thumbnail not found".to_string())|safe %}
{% let src = crate::picture_generator::image_src_generator::generate_image_with_src(img, 1200, 630, "_og").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|escape("none")}}
{{body|parse_markdown|safe}}
</section>
</article>

View File

@ -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, true).unwrap_or("thumbnail not found".to_string())|safe }}
{{ crate::picture_generator::picture_markup_generator::generate_picture_markup(orig_path, 180, 240, "Article thumbnail", None).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|description_filter|safe}}</section>
<section class="text-base leading-5 text-slate-800 md:text-xl text-justify">{{post.body|truncate_md(2)|parse_markdown|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 %}

View File

@ -11,14 +11,14 @@
{% endmatch %}
</h2>
<section class="description text-slate-800 my-2 md:text-xl text-justify">
{{project.body|safe}}
{{project.body|parse_markdown|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]"), true).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]")).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,7 +28,6 @@
{% when None %}
{{picture|safe}}
{% endmatch %}
<!-- TODO <figure> generate_image -->
</figure>
{% when None %}
{% endmatch %}

View File

@ -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"), true).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")).unwrap_or("thumbnail not found".to_string())|safe }}
</a>
{% endmacro %}

38
templates/portfolio.html Normal file
View File

@ -0,0 +1,38 @@
{% 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 %}