Compare commits

..

No commits in common. "main" and "rust-rework" have entirely different histories.

208 changed files with 11156 additions and 2164 deletions

27
.eslintrc.cjs Normal file
View File

@ -0,0 +1,27 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
],
plugins: ['svelte3', '@typescript-eslint'],
ignorePatterns: ['*.cjs'],
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
settings: {
'svelte3/typescript': require('typescript'),
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 2019,
},
env: {
browser: true,
es2017: true,
node: true,
},
rules: {
'@typescript-eslint/explicit-module-boundary-types': 0,
},
}

39
.gitignore vendored
View File

@ -1,25 +1,26 @@
.DS_Store .DS_Store
/node_modules/
/src/node_modules/@sapper/
yarn-error.log
/cypress/screenshots/
/__sapper__/
# `dist` folder with the export of SSG /.svelte-kit
/.svelte/
/build/
/functions/
/static/build/
#amplify
amplify/\#current-cloud-backend
amplify/.config/local-*
amplify/backend/amplify-meta.json
amplify/backend/awscloudformation
dist/ dist/
node_modules/
aws-exports.js
awsconfiguration.json
/static/**/optimized/
# Local Netlify folder # Local Netlify folder
.netlify .netlify
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# Image generator
generated_images/

0
.vscode/settings.json vendored Normal file
View File

View File

@ -1,31 +0,0 @@
:3081 {
root * dist
# Match requests with trailing slashes and rewrite them
@trailing_slash {
path_regexp strip_slash ^(.+)/$ # Match paths ending with /
}
rewrite @trailing_slash {re.strip_slash.1} # Remove the trailing slash
try_files {path}.html {path}/index.html {path}
encode zstd gzip
file_server
# Cache images, videos, fonts, etc. for 1 year (365 days)
@static_assets {
path_regexp static_assets \.(jpg|jpeg|png|gif|svg|ico|mp4|webm|woff|woff2|eot|ttf|otf|js)$
}
header @static_assets Cache-Control "public, max-age=31536000, immutable"
# Optionally, you can set a fallback for other files
header ?Cache-Control "public, max-age=3600"
handle_errors {
@404 {
expression {http.error.status_code} == 404
}
rewrite @404 /not-found.html
file_server
}
}

View File

@ -1,16 +1,25 @@
--- ---
title: Portfolio - Michal Vanko 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: work_history:
- displayed: true
name: Freelancer
thumbnail: /images/uploads/m-logo.svg
description: Since 2022, I have worked as a self-employed freelancer. I provide
consultations and build safe and robust web applications for clients.
- description: >- - description: >-
*sudolabs* is a company focused on building products and software _sudolabs_ is a company focused on building products and software
solutions for start-ups & other clients. solutions for start-ups & other clients.
I've worked there as a **tech lead and an Engineering manager**. The company has its own product lab focused on product research and product discovery and an engineering lab focused on the development of high-quality software solutions.
At _sudolabs_, I've managed to proceed from senior software engineer to **tech lead and an Engineering manager**.
I am part of one of the most successful projects at _sudolabs_, _The Expert_. I am responsible for the architecture and quality of the proposed feature solutions and for the development processes used.
As an Engineering manager, I try to **represent people in my team** and help them **achieve the best of their potential**.
I've started to work for _sudolabs_ in October 2019.
name: sudolabs s.r.o. name: sudolabs s.r.o.
address: address:
name: Sudo Labs. s.r.o. name: Sudo Labs. s.r.o.
@ -19,7 +28,6 @@ work_history:
city: Košice city: Košice
country: Slovakia country: Slovakia
displayed: true displayed: true
thumbnail: /images/uploads/6708e31603754bd2b4c6ed11_sudo_logo.svg
- description: >- - description: >-
_**Croptech**_ is a start-up company located in Košice focused on smart _**Croptech**_ is a start-up company located in Košice focused on smart
automation of hydroponic systems. Founded in 2014 they've won automation of hydroponic systems. Founded in 2014 they've won
@ -36,9 +44,14 @@ work_history:
zipcode: 040 18 zipcode: 040 18
city: Košice city: Košice
country: Slovakia country: Slovakia
- description: I worked in *localhost* as a **Senior front end developer**. I was - description: >-
focused on delivering a high-quality product and teaching and helping _localhost.company_ was a small company that had around 40 developers
other colleagues grow in expertise. while most of them were students. I worked in _localhost_ as a **Senior
front end developer**. I was focused on delivering a high-quality product
and to teach and helping other colleagues grow in expertise.
I've started to work there in October 2018 and left after a year.
name: localhost.company s.r.o. name: localhost.company s.r.o.
displayed: true displayed: true
address: address:
@ -47,9 +60,15 @@ work_history:
zipcode: 040 01 zipcode: 040 01
city: Košice city: Košice
country: Slovakia country: Slovakia
thumbnail: /images/uploads/LH-symbol-with-borders-WHITE-RED-BGb.png - description: >
- description: I worked in *Ness* as a Senior front-end developer. In *Ness*, I've I worked in _Ness_ as a Senior front-end developer. In _Ness_, I've been
been able to work for different clients on various projects for 6 years. able to work for different clients on various projects.
I joined Ness as a Junior front-end developer and I've been able to quickly proceed to Senior level with my engagement.
I started to work in March 2013 and left in October 2018.
name: Ness KE s.r.o. name: Ness KE s.r.o.
displayed: true displayed: true
address: address:
@ -58,9 +77,15 @@ work_history:
zipcode: 040 11 zipcode: 040 11
city: Košice city: Košice
country: Slovakia country: Slovakia
thumbnail: /images/uploads/nesskosicelogo.svg - description: >-
- description: I worked for *eSOLUTIONS* as a part-time **web developer** while I I worked in _eSOLUTIONS_ as a **web developer** part-time while I was
was in high school and at University. studying in high school and at University.
I was building front-end and back-end services for websites and web applications. Mostly using _PHP_, _MySQL_, _JavaScript_, _HTML_, _CSS_.
I started to work in 2007 and stopped in 2012.
name: eSOLUTIONS s.r.o. name: eSOLUTIONS s.r.o.
displayed: true displayed: true
address: address:
@ -69,30 +94,285 @@ work_history:
zipcode: 040 01 zipcode: 040 01
city: Košice city: Košice
country: Slovakia country: Slovakia
thumbnail: /images/uploads/esolutions_logo.svg projects:
- description: >-
_The Expert_ is a digital platform that connects clients to interior
designers around the world. For experts, it allows **managing** their
**portfolio and profile** and **schedule** in which they are open for
**consultations**. Clients are able to view their profiles and book
consultations.
The project is **expanding with an e-commerce platform** that should enable these experts to recommend and sell high-value goods.
We have built this platform from scratch as the only technical team. We also managed to design the product itself in-house.
I am part of the engineering team. I work as an **Engineering manager**. I am also **responsible for the technical architecture** and **development processes** that are being used to deliver new features.
_The Expert_ has been **featured in** numerous **worldwide media** such as [_The New York Times_](https://www.nytimes.com/2021/10/19/business/home-design-zoom.html?smid=url-share), [_Wall Street Journal_](https://www.wsj.com/articles/how-you-can-afford-to-hire-your-dream-interior-designer-11619627877), [_Forbes_](https://www.forbes.com/sites/emmareynolds/2021/02/08/introducing-the-expert-a-new-digital-platform-for-interior-design-founded-by-designer-jake-arnold/?sh=33b274934471), [_TechCrunch_](https://techcrunch.com/2021/04/19/gwyneth-paltrow-invests-in-the-expert-a-video-marketplace-for-high-end-interior-designers/?guccounter=2), and many more.
displayed: true
name: The Expert
image:
source: /images/uploads/the-expert-logo.svg
image_description: The Expert logo
- description: >-
*Panoramic* is a company focused on building a web application for data
scientists to be able to create and share models and graphs in between
each other.
I've been working as a **team lead** of a small team that was focused on building a **rich-text collaboration** feature into the web application.
The application was built on top of modern web technologies such as **React, node.js with GraphQL, and TypeScript**. We have successfully built this feature by integrating *[Mattermost](https://mattermost.com/)* into the existing back-end. After that, we have been helping another team by enhancing existing features for importing data sources.
displayed: true
name: Panoramic
- description: >-
_Manualogic_ is a **single-page application** for product manual creators.
It contains **custom web editor** and management system of **translatable
pages, books** and **products.** Its main goal is to enable customers to
get manuals of their products in digital form.
It's built on top of modern web technologies such as **Angular**, **RxJS**, and the content is synchronized through **Websockets**. The back-end part is built on top of **Spring framework**.
The team consisted of 3-4 front-end developers, 2-3 back-end developers, and 2 testers.
displayed: true
name: Manualogic
- description: >-
_**responzIO**_ is smart, easy to use monitoring and automation system.
The ultimate tool for various applications such as hydroponics, aquariums
and gardens.
I worked on _responzIO_ as architect and full-stack developer. I've designed complete software solution for an already created platform and a series of sensors, sockets and other hardware. Designed solution consist of varios parts: Deployment, Microservices, Stateful core back-end application, progressive web application, auto-update over web...
This project is built with modern web technologies including: **CycleJS**, **Reactive Streams**, **D3**, **Jest**, **Webpack**.
displayed: true
image:
image_description: ' responzIO main controller'
source: /images/uploads/responzio.png
name: responzIO
- description: >-
_Signal Hub_ is an end-to-end **Big Data analytics platform** for large
enterprises. It accelerates the process of extracting insights and
intelligence from large volumes of data, including data of different types
and in different formats.
I worked on _Signal Hub_ as a front-end developer. Working on the part of the platform called _Workbench_. _Workbench_ is an **IDE for data analyst**. It allows to create and tweak various models and transformations which are then deployed to other parts of the platform. Users are able to create workflows and see the results of his work with pleasant visuals.
This project is built with modern web technologies including: **AngularJS**, **React**, **D3**, **Highcharts**, **Jest**, **Webpack**.
I was part of a large distributed team of ~20 developers & ~6 testers which were working on the whole platform.
displayed: true
name: Signal Hub Manager
- description: >-
_SHIP_ is a web application for **editors** who actively **track trades
offers and bids** on the commodity market.
It was built for a price reporting company _S&P Global Platts_. This company is a provider of energy and commodities information and a source of benchmark price assessments in the _physical energy markets_.
_SHIP_ was suited for a very easy and quick workflow for editors.
Team consisted of 3 front-end and 3 back-end developers. I was a **leader of the front-end** part of the project.
The application was built with **AngularJS and Redux**. The challenging part was working with **Cassandra database**.
displayed: true
name: SHIP (Structured heard input process)
- description: >-
[www.caplin.com/business/fx-sales.com](http://www.caplin.com/business/fx-sales)
The main function of _Caplin FX Sales_ is to allow sales users to <string>trade on behalf of their clients</string>. This needs to be an efficient workflow providing all the relevant information to the sales user.
Main technologies used: **Websockets**, Custom Front-end JavaScript MVVM Framework (based on Knockout, BRJS)
We worked on this project in a team of 6 developers and 3 testers
displayed: true
name: FX Salestrader
- description: >-
_Skosy_ is a web application which purpose is to **automate writing of
integration tests** for web sites.
I worked on a _proof of concept_ front-end portion of this application, which was able to create a list of steps for testing.
I worked on this project in a team of 2 front-end developers.
displayed: false
name: Skosy
- description: >-
[www.livesport.tv](http://www.livesport.tv)
_Livesport.tv_ is a network of premium online sports channels, featuring all the top sports competitions from around the world.
I worked on this project as a front-end developer. I maintained all livesport channels and I worked on new features and enhancements of websites.
Project was built on top of **Freemarker** templating language.
The team consisted of 2 developers and 1 tester.
displayed: true
name: Livesport.tv
- description: >-
_beIN Sports_ is a global network of sports channels jointly owned and
operated by _Qatari Sports Investments_, an affiliate of _Al Jazeera Media
Networks_
I worked on this project as a front-end developer. I maintained official websites of beIN SPORTS France and USA ([www.beinsports.fr](http://www.beinsports.fr), [www.beinsports.tv](http://www.beinsports.tv)).
Project was built on top of **Freemarker** templating language.
I worked on this project in a team consisting of 7 members.
displayed: true
name: beIN SPORTS France & beIN SPORTS USA
- description: >-
I worked on this project as a front-end developer. I maintained official
websites of all Norweigian Football teams - NTF Network
([www.toppfotball.no](http://www.toppfotball.no))
Project was built on top of **Freemarker** templating language.
I worked on this project in a team consisting of 4 members.
displayed: false
name: Norsk Toppfotball
- description: >-
[www.kosicemarathon.com](http://www.kosicemarathon.com/)
_Košice Peace Marathon_ is the oldest marathon in Europe and the third-oldest in the world.
I worked on this project as a front-end and an back-end developer. I've built a **custom CMS** for website and for registration of participants. Also created an application for actual event for signing participants.
The application was able to create a start list of all participants and provide various statistics about them.
I've built this project alone and then one maintainer joined me.
displayed: true
name: Košice Peace Marathon
- description: >-
[www.ckvive.sk](http://www.ckvive.sk)
_CK Vive_ is a travel agency.
I worked on this project as a front-end and an back-end developer. I've created new **custom CMS** for adding destinations and apartments into offering.
I've built this project on top of **PHP**, **MySQL** and **JavaScript**.
I worked on this project alone.
displayed: false
name: CK Vive
presentations:
- displayed: true
link: https://michalvankodev.github.io/unstoppable-growth-of-frontend-frameworks/
name: Unstoppable growth of front-end frameworks
description: >
A simple summary of the web front-end evolution, which will try to
describe how and why tools improve & why there is still something to
explore.
- displayed: true
name: WebAssembly
link: https://michalvankodev.github.io/presentation-webassembly/#/
description: A presentation about what WebAssembly is about and how it might
affect the future of the world.
- displayed: true
name: Spreading the web
description:
A presentation about the rising number of use cases for utilizing
web technologies outside of the web platform such as native mobile
applications and robotics.
link: https://michalvankodev.github.io/spreading-the-web/#/
- displayed: true
name: Docker
link: http://michalvankodev.github.io/dockerpresentation/#/
description:
An introduction to Docker containerization technology and how it
differs from virtualization.
education: education:
- description: |- - description: |-
Faculty of Electrical Engineering and Informatics - Intelligent Systems Faculty of Electrical Engineering and Informatics - Intelligent Systems
_Artificial Intelligence, Neural Networks, Fuzzy Systems_ _Artificial Intelligence, Neural Networks, Fuzzy Systems_
Year of 2013 Dates: 1\. 9\. 2010 - 20\. 6\. 2013
Title of qualification: Bachelor (Bc.) Title of qualification awarded: Bachelor (Bc.)
displayed: true displayed: true
name: Technická univerzita Košice name: Technická univerzita Košice
thumbnail: /images/uploads/tuke_znak_b_cmyk.png - description: >-
- description: |-
_SPŠ Elektrotechnická, Komenského 22, Košice_ _SPŠ Elektrotechnická, Komenského 22, Košice_
Electrotechnics, Telecommunication Electrotechnics, Telecommunication
Year of 2010
Full secondary school education with leaving examination Dates: 1\. 9\. 2005 - 1\. 7\. 2010
Title of qualification awarded: Full secondary school education with leaving examination
displayed: true displayed: true
name: Secondary school of Electrical Engineering in Košice name: Secondary school of Electrical Engineering in Košice
thumbnail: /images/uploads/logo_spse_far.png
--- ---
As a passionate software developer with over half my life spent coding professionally, I've developed a strong affinity for building **highly scalable** and **proficient systems**. With extensive experience in **web application development**, I've honed my skills across various technological stacks, leveraging expertise to drive results. When not crafting complex solutions, you can find me indulging in **game development** - a creative outlet that allows me to bring fun and innovation to the table. As a **tech lead and mentor**, I thrive in environments where **quality software, tests, and collaboration** come together to **create innovative solutions**.
In addition to my technical expertise, I bring a wealth of **experience as an Engineering manager**, where I've honed my **leadership skills** and developed a talent for **mentoring teams**. When not coding or leading teams, you can find me exploring the world through **streaming, building an online community** around programming, **making music**, and spending quality time with my family as a **proud father**. I'm also an avid board game enthusiast! As a thought leader, I aim to leverage my diverse experience and skills to **provide exceptional service** to my customers and make a positive impact on the tech industry. ## Personal Information
I was born in Košice, Slovakia and I still live here. I like to drink tea and cook my own recipes.
### Hobbies
I enjoy playing basketball with my friends. I also like to play other team sports like football and hockey. I also play squash and table tennis. Once I've won a competition in squash at my university. During summer, I love to go water skiing or swimming in a nearby lake.
I am very passionate about music. I've also tried to use some digital workstations to compose my own music.
I enjoy playing board games with my friends and family.
### Interests
I like to explore new technologies and _Open-source_ software.
I am interested in modern software architecture and _reactive programming_.
I've attended several **tech conferences and hackathons**, where I can meet interesting people and explore fascinating ideas.
I've given presentations on various topics related to _web development_. You can [take a look at some of them here](#presentations).
I enjoy **teaching and explaining** how technologies and techniques work to my colleagues or students for their better understanding.
I take advantage of **test-driven development**.
## Skills
Slovak is my mother tongue and I've learned English as my second language. I speak English on an advanced level.
I'm an experienced _Linux Desktop_ user. I prefer to use open-source libraries and technologies while I develop solutions.
I'm in good command of Office Tools and I have experience with image manipulation programs like _GIMP_ and _Inkscape_.
I can also compose music and sounds on _digital audio workstation_.
I'm passionate about _software architecture_. My goal is not only to be able to design a suitable solution for any kind of product but also to be able to architect the processes behind building a team. From small _presentation sites_, _IOT devices_, to large _enterprise applications running on cloud_.
I practice **public speaking** and I am not afraid to share my knowledge and passion about technology.
I'm an advanced user of source code management tools _git_ and _svn_.
I've got a driving license for category B 🚗.

View File

@ -3,7 +3,6 @@ layout: blog
title: Introduction to JavaScript Application testing title: Introduction to JavaScript Application testing
segments: segments:
- blog - blog
- featured
published: true published: true
date: 2021-05-07T14:44:57.102Z # update date accordingly date: 2021-05-07T14:44:57.102Z # update date accordingly
tags: tags:

View File

@ -28,7 +28,7 @@ This week I've attended a [Rusty game jam #2](https://itch.io/jam/rusty-jam-2).
![Egg fetcher game preview](/images/uploads/screenshot-from-2022-06-26-22-37-16.png "Egg fetcher game preview") ![Egg fetcher game preview](/images/uploads/screenshot-from-2022-06-26-22-37-16.png "Egg fetcher game preview")
[You can check the result built with WASM here.](/showcase/egg-fetcher) [You can check the result built with WASM here.](/showcase/egg-fetcher/)
## What's up with the weeklys ## What's up with the weeklys

View File

@ -3,7 +3,6 @@ layout: blog
title: "DevBreak #5 Joys and concerns of Engineering manager with Pavol Dudrík" title: "DevBreak #5 Joys and concerns of Engineering manager with Pavol Dudrík"
segments: segments:
- broadcasts - broadcasts
- featured
published: true published: true
date: 2023-02-04T20:22:21.191Z date: 2023-02-04T20:22:21.191Z
thumbnail: /images/uploads/devbreak.jpeg thumbnail: /images/uploads/devbreak.jpeg

View File

@ -1,76 +0,0 @@
---
title: This site has been rewritten again
segments:
- blog
- featured
published: true
date: 2024-11-05T11:26:00.000Z
thumbnail: /images/uploads/m-logo.svg
tags:
- News
- Development
- Rust
notes: Bu
---
Hey world,
After a few months of work, I can finally present to you a new style for this site. The style isn't the only thing that changed.
I've **completely rewritten** how this site is produced.
## Motivation
With the front-end tech world spinning too fast I've noticed this cool little library called *[HTMX](https://htmx.org/)* that takes front-end development back 10 years and provides a completely different look at how web development could've been done.
I've also done some experiments with [Qwik](https://qwik.dev/) before, but I'm glad that I've abandoned this route as I would like to write less and less *TypeScript* `||` *Javascript* in the future.
After doing last year's [Advent of Code](https://adventofcode.com/) in *OCaml* I've figured that I'd like to use tools that I think make me productive and also provide me help on the path of producing the most safe, predictable and performant code.
That's the reason **I've chosen *Rust*** for this site and most of my side projects in the last couple of years.
## What has changed
### Tech stack
I've mentioned *HTMX*, but it only inspired me and I took it a few steps back and I haven't opted for any *JavaScript* included by default. You can find some *JavaScript* on this site but it is only inlined without any build step. However, there still has to be a build step. Instead of setting up *[Vite](https://vite.dev/)* for 5th time. I opted for something classic but still new. I've just found the [just](https://github.com/casey/just) command runner. It is very similar to how many *Makefiles* look, but the API is modern and **written in Rust BTW**. So if any developer wants to look at what commands are being used during the development or deployment, they are all located in [`.justfile`](https://github.com/michalvankodev/michalvankodev-site/blob/main/justfile). All commands are just `bash` commands and some `bash` scripts with few instructions for `just` to combine commands by their dependencies.
### Templates
During development, the site is being hosted with [axum Rust web framework](https://github.com/tokio-rs/axum). I want to write my templates in HTML so they can be as easily reused in any other web project with any tech stack. For Rust, there is a **type-safe** Jinja like template parser [Askama](https://github.com/djc/askama) and even though I'm not very confident whether I would use it again, I can still easily migrate to any other template parser there is.
### Styling
I've completely redesigned the site. Dodging *TypeScript-based* solutions for <abbr title="Cascade style sheets">CSS</abbr>. Opting for [tailwind](https://tailwindcss.com/) running *just* in the background. The experience with *tailwind* is something that I've never felt previously with any other <abbr title="Cascade style sheets">CSS</abbr> framework before. I'm satisfied with it although it has some downsides, **the productivity hasn't been matched**.
## What stayed the same
### All the content
I haven't even moved the files to a different folder. I've just slightly adjusted the models in the [DecapCMS](https://decapcms.org/) config for [/showcase](/showcase) page. DecapCMS is pretty much just the same Netlify CMS that it has been before the rebranding.
I'd like to recommend it as *THE CMS*, but I can't. I like the way how the content is managed, I also like *git-based* workflow for managing content. However, there are many struggles to set it up for use with clients that are not programmers. With more changes to the models you also start to **miss the capability of running migrations** on all files/records. Everyone would be better off just hosting *[Strapi](https://strapi.io/)*. The ecosystem has started to get healthy. Setting up a custom media library is also a thing that has to be considered.
### Media library hosting
[Netlify has deprecated](https://answers.netlify.com/t/large-media-feature-deprecated-but-not-removed/100804) its [Large Media](https://docs.netlify.com/git/large-media/setup/) service. I don't mind tracking media with `git` LFS plugin. What I do mind is where I store these assets. \[Github's] free quota is pretty low considering sharing photo galleries. For now, I've moved all assets on my *"powered by"* [selfhosted](https://awesome-selfhosted.net/index.html) server, running my own [Gitea](https://gitea.katelyn.michalvanko.dev/) instance.
### Static site generation
The site is still distributed as <abbr title="Static site generation">SSG</abbr> HTML files. You could argue that the logic for generating every page is just like any other <abbr title="Server Side Rendered">SSR</abbr> website.
For <abbr title="Server side generation">SSG</abbr> I've come up with a `wget` command that downloads all the necessary dependencies for all pages. It is capable of recursively crawling the whole site following all links. It was pretty hard to come up with the correct set of parameters for the `wget` command to be able to produce the same routing capabilities as with the `SSR` running server used during the development. Here I can **praise all the generative AI tools**. You could find multiple prompts asking for an explanation of all the options for `wget` and how I should use them for the desired output in my history.
The final `wget` command for generating this site looks like this:
```sh
wget --no-convert-links -r -p -E -P dist --no-host-directories localhost:3080
```
You can also [prompt for the explanation](https://lmgpthat.com/render.html?search=wget%20--no-convert-links%20-r%20-p%20-E%20-P%20dist%20--no-host-directories%20localhost%3A3080).
## Aim for future
I still have to **write articles**.
Many many articles that I haven't yet written down. I did some but haven't published them, and probably I never will. But so many things happened in the meantime. How do you call an update that is old? *"Outofdate"*?
It would also be handy to have some article recommendations when you finish reading this article, right?
I am still gathering feedback for the new design. I am considering many of the suggestions that I get. Our [Discord server](https://discord.gg/2cGg7kwZEh) is getting warm.
I was also thinking about what would I use for CMS and having a *[SQLite](https://www.sqlite.org/docs.html)* database saved in the repository, it would still count as **git-based management**.
*'Let your ambition carry you!"*

View File

@ -1,22 +0,0 @@
---
title: Renaissance of Hypermedia Systems
segments:
- broadcasts
- featured
published: true
date: 2024-11-06T16:20:00.000Z
thumbnail: /images/uploads/screenshot-from-2024-08-06-19-01-03.png
tags:
- Development
- HTMX
- Hyperview
---
My goal for this year was to make a presentation. I've told this to my friend Lukáš Orgován and he offered me an opportunity to come present at their workplace with him. This was a great event and I'm so glad I could attend.
I've chosen a topic very close to my heart and wanted to show a different approach to building mobile and web applications.
The presentation is in Slovak with English subtitles.
<div class="video-embed">
<iframe height="100%" width="100%" src="https://www.youtube.com/embed/hn7rR9wIfIY" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
</div>

18
appveyor.yml Normal file
View File

@ -0,0 +1,18 @@
version: "{build}"
shallow_clone: true
init:
- git config --global core.autocrlf false
build: off
environment:
matrix:
# node.js
- nodejs_version: stable
install:
- ps: Install-Product node $env:nodejs_version
- npm install cypress
- npm install

20
axum_server/.gitignore vendored Normal file
View File

@ -0,0 +1,20 @@
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# `dist` folder with the export of SSG
dist/
# Image generator
generated_images/

View File

@ -25,7 +25,6 @@ anyhow = "1.0.86"
rayon = "1.10.0" rayon = "1.10.0"
syntect = "5.2.0" syntect = "5.2.0"
indoc = "2.0.5" indoc = "2.0.5"
askama_escape = "0.10.3"
[build] [build]
rustflags = ["-Z", "threads=8"] rustflags = ["-Z", "threads=8"]

View File

@ -5,15 +5,18 @@ tailwind:
npx tailwindcss -i ./styles/input.css -o ./styles/output.css --watch npx tailwindcss -i ./styles/input.css -o ./styles/output.css --watch
# svg sprite creation # svg sprite creation
# TODO change route on svetle project deletion
# TODO #directory-swap
svgstore: svgstore:
npx svgstore -o templates/icons/sprite.svg static/svg/input/*.svg npx svgstore -o ../static/svg/icons-sprite.svg ../src/svg/**.svg
server_dev: server_dev:
cargo watch -x run cargo watch -x run
# CMS server for local dev # CMS server for local dev
# TODO #directory-swap
decap_server: decap_server:
npx decap-server cd .. && npx decap-server
# Run dev server in watch mode # Run dev server in watch mode
dev: dev:
@ -30,7 +33,7 @@ test_watch:
cargo watch -x test cargo watch -x test
# Run server in production mode # Run server in production mode
prod $TARGET="PROD" $RUST_LOG="info": prod:
cargo run --release cargo run --release
# Wait for port to listen to connections # Wait for port to listen to connections
@ -52,13 +55,11 @@ clean:
# SSG # SSG
ssg: ssg:
- wget --no-convert-links -r -p -E -P dist --no-host-directories 127.0.0.1:{{port}} - wget --no-convert-links -r -p -E -P dist --no-host-directories 127.0.0.1:{{port}}
- wget --no-convert-links --content-on-error -p -E -P dist --no-host-directories 127.0.0.1:{{port}}/not-found - wget -P dist/svg 127.0.0.1:{{port}}/svg/icons-sprite.svg
- wget --no-convert-links -p -E -P dist --no-host-directories 127.0.0.1:{{port}}/showcase/m-logo-svg
find generated_images/ -name "*_og*" -exec cp --parents {} dist/ \;
# Preview server # Preview server
preview: preview:
caddy run --config Caddyfile-preview npx http-server dist
# SSG export of production server # SSG export of production server
export: clean export: clean

View File

@ -1,23 +1,14 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::Deserialize;
use crate::post_utils::post_parser::deserialize_date; use crate::post_utils::post_parser::deserialize_date;
pub const BLOG_POST_PATH: &str = "_posts/blog"; pub const BLOG_POST_PATH: &str = "../_posts/blog";
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")] // Optional, this converts enum variants to lowercase
pub enum Segment {
Blog,
Broadcasts,
Featured,
Cookbook,
}
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct BlogPostMetadata { pub struct BlogPostMetadata {
pub title: String, pub title: String,
pub segments: Vec<Segment>, pub segments: Vec<String>,
pub published: bool, pub published: bool,
#[serde(deserialize_with = "deserialize_date")] #[serde(deserialize_with = "deserialize_date")]
pub date: DateTime<Utc>, pub date: DateTime<Utc>,

View File

@ -0,0 +1,15 @@
use axum::http::StatusCode;
use crate::post_utils::{post_listing::get_post_list, post_parser::ParseResult};
use super::blog_post_model::{BlogPostMetadata, BLOG_POST_PATH};
pub async fn get_featured_blog_posts() -> Result<Vec<ParseResult<BlogPostMetadata>>, StatusCode> {
let mut post_list = get_post_list::<BlogPostMetadata>(BLOG_POST_PATH).await?;
post_list.retain(|post| post.metadata.segments.contains(&"featured".to_string()));
post_list.retain(|post| post.metadata.published);
post_list.sort_by_key(|post| post.metadata.date);
post_list.reverse();
Ok(post_list)
}

View File

@ -0,0 +1,3 @@
pub mod blog_post_model;
pub mod featured_blog_posts;
pub mod tag_list;

View File

@ -0,0 +1,38 @@
use axum::http::StatusCode;
use std::collections::HashMap;
use tracing::debug;
use crate::{
blog_posts::blog_post_model::{BlogPostMetadata, BLOG_POST_PATH},
post_utils::post_listing::get_post_list,
};
pub async fn get_popular_blog_tags() -> Result<Vec<String>, StatusCode> {
const TAGS_LENGTH: usize = 7;
let post_list = get_post_list::<BlogPostMetadata>(BLOG_POST_PATH).await?;
let tags_sum = post_list
.into_iter()
.flat_map(|post| post.metadata.tags)
.fold(HashMap::new(), |mut acc, tag| {
*acc.entry(tag).or_insert(0) += 1;
acc
});
let mut sorted_tags_by_count: Vec<_> = tags_sum.into_iter().collect();
sorted_tags_by_count.sort_by_key(|&(_, count)| std::cmp::Reverse(count));
// Log the counts
for (tag, count) in &sorted_tags_by_count {
debug!("Tag: {}, Count: {}", tag, count);
}
let popular_blog_tags = sorted_tags_by_count
.into_iter()
.map(|tag_count| tag_count.0)
.filter(|tag| tag != "dev")
.take(TAGS_LENGTH)
.collect::<Vec<String>>();
Ok(popular_blog_tags)
}

View File

@ -49,5 +49,5 @@ pub async fn render_rss_feed() -> Result<impl IntoResponse, StatusCode> {
.build(); .build();
let response = feed_builder.to_string(); let response = feed_builder.to_string();
Ok(([(header::CONTENT_TYPE, "application/xml")], response)) return Ok(([(header::CONTENT_TYPE, "application/xml")], response));
} }

View 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)
}

View File

@ -31,15 +31,15 @@ async fn main() {
// build our application with a single route // build our application with a single route
let app = router::get_router() let app = router::get_router()
.nest_service("/styles", ServeDir::new("styles")) .nest_service("/styles", ServeDir::new("styles"))
.nest_service("/images", ServeDir::new("static/images")) .nest_service("/images", ServeDir::new("../static/images"))
.nest_service("/fonts", ServeDir::new("static/fonts")) .nest_service("/fonts", ServeDir::new("../static/fonts"))
.nest_service("/files", ServeDir::new("static/files"))
.nest_service("/generated_images", ServeDir::new("generated_images")) .nest_service("/generated_images", ServeDir::new("generated_images"))
.nest_service("/egg-fetcher", ServeDir::new("static/egg-fetcher")) .nest_service("/svg", ServeDir::new("../static/svg"))
.nest_service("/svg", ServeDir::new("static/svg")) // TODO manifest logos have bad link, #directory-swap
.nest_service("/config.yml", ServeDir::new("static/resources/config.yml")) // Decap CMS config .nest_service(
.nest_service("/resources", ServeDir::new("static/resources")) "/config.yml",
.nest_service("/robots.txt", ServeDir::new("robots.txt")); ServeDir::new("../static/resources/config.yml"),
);
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
let app = app.layer(LiveReloadLayer::new()); let app = app.layer(LiveReloadLayer::new());
@ -54,8 +54,11 @@ async fn main() {
// TODO Socials // TODO Socials
// - fotos // - fotos
// background gradient color
// TODO Change DNS system
// THINK deploy to alula? rather then katelyn? can be change whenever // THINK deploy to alula? rather then katelyn? can be change whenever
// // TODO after release
// TODO view page transitions // OG tags
// TODO cookbook // Remove old web completely
// TODO remove m-logo-svg from justfile and mention it in some article!!! WRITE SOME NEW ARTICLES // Restructure repository
// - projects page

View File

@ -0,0 +1,74 @@
use askama::Template;
use axum::{extract::Path, http::StatusCode};
use tokio::try_join;
use crate::{
blog_posts::{
blog_post_model::{BlogPostMetadata, BLOG_POST_PATH},
tag_list::get_popular_blog_tags,
},
components::site_header::{HeaderProps, Link},
filters,
post_utils::{post_listing::get_post_list, post_parser::ParseResult},
projects::{featured_projects::get_featured_projects, project_model::ProjectMetadata},
};
#[derive(Template)]
#[template(path = "blog_post_list.html")]
pub struct PostListTemplate {
pub title: String,
pub posts: Vec<ParseResult<BlogPostMetadata>>,
pub tag: Option<String>,
pub header_props: HeaderProps,
pub blog_tags: Vec<String>,
pub featured_projects: Vec<ParseResult<ProjectMetadata>>,
}
pub async fn render_blog_post_list(
tag: Option<Path<String>>,
) -> Result<PostListTemplate, StatusCode> {
// I will forget what happens here in a week. But essentially it's pattern matching and shadowing
let tag = tag.map(|Path(tag)| tag);
let (blog_tags, featured_projects, mut post_list) = try_join!(
get_popular_blog_tags(),
get_featured_projects(),
get_post_list::<BlogPostMetadata>(BLOG_POST_PATH)
)?;
post_list.sort_by_key(|post| post.metadata.date);
post_list.retain(|post| post.metadata.published);
post_list.reverse();
let posts = match &tag {
Some(tag) => post_list
.into_iter()
.filter(|post| {
post.metadata
.tags
.iter()
.map(|post_tag| post_tag.to_lowercase())
.collect::<String>()
.contains(&tag.to_lowercase())
})
.collect(),
None => post_list,
};
let header_props = match tag {
Some(_) => HeaderProps::with_back_link(Link {
href: "/blog".to_string(),
label: "All posts".to_string(),
}),
None => HeaderProps::default(),
};
Ok(PostListTemplate {
title: "Blog posts".to_owned(),
posts,
tag,
header_props,
blog_tags,
featured_projects,
})
}

View File

@ -0,0 +1,37 @@
use askama::Template;
use axum::{extract::Path, http::StatusCode};
use chrono::{DateTime, Utc};
use crate::{
blog_posts::blog_post_model::BlogPostMetadata, components::site_header::Link, filters,
post_utils::post_parser::parse_post,
};
use crate::components::site_header::HeaderProps;
#[derive(Template)]
#[template(path = "blog_post.html")]
pub struct BlogPostTemplate {
pub title: String,
pub body: String,
pub date: DateTime<Utc>,
pub tags: Vec<String>,
pub header_props: HeaderProps,
}
pub async fn render_blog_post(Path(post_id): Path<String>) -> Result<BlogPostTemplate, StatusCode> {
let path = format!("../_posts/blog/{}.md", post_id);
let parse_post = parse_post::<BlogPostMetadata>(&path, true);
let parsed = parse_post.await?;
Ok(BlogPostTemplate {
title: parsed.metadata.title,
date: parsed.metadata.date,
tags: parsed.metadata.tags,
body: parsed.body,
header_props: HeaderProps::with_back_link(Link {
href: "/blog".to_string(),
label: "All posts".to_string(),
}),
})
}

View File

@ -51,7 +51,7 @@ pub async fn render_contact() -> Result<ContactPageTemplate, StatusCode> {
svg: "instagram".to_string(), svg: "instagram".to_string(),
}, },
ContactLink { ContactLink {
href: "https://github.com/michalvankodev".to_string(), href: "https://instagram.com/michalvankodev".to_string(),
label: "GitHub".to_string(), label: "GitHub".to_string(),
title: "Github profile".to_string(), title: "Github profile".to_string(),
svg: "github".to_string(), svg: "github".to_string(),

View File

@ -0,0 +1,38 @@
use askama::Template;
use axum::http::StatusCode;
use tokio::try_join;
use crate::{
blog_posts::{
blog_post_model::BlogPostMetadata, featured_blog_posts::get_featured_blog_posts,
tag_list::get_popular_blog_tags,
},
components::site_header::HeaderProps,
filters,
post_utils::post_parser::ParseResult,
projects::{featured_projects::get_featured_projects, project_model::ProjectMetadata},
};
#[derive(Template)]
#[template(path = "index.html")]
pub struct IndexTemplate {
header_props: HeaderProps,
blog_tags: Vec<String>,
featured_blog_posts: Vec<ParseResult<BlogPostMetadata>>,
featured_projects: Vec<ParseResult<ProjectMetadata>>,
}
pub async fn render_index() -> Result<IndexTemplate, StatusCode> {
let (blog_tags, featured_blog_posts, featured_projects) = try_join!(
get_popular_blog_tags(),
get_featured_blog_posts(),
get_featured_projects()
)?;
Ok(IndexTemplate {
header_props: HeaderProps::default(),
blog_tags,
featured_blog_posts,
featured_projects,
})
}

View File

@ -0,0 +1,6 @@
pub mod admin;
pub mod blog_post_list;
pub mod blog_post_page;
pub mod contact;
pub mod index;
pub mod project_list;

View File

@ -3,7 +3,6 @@ use axum::http::StatusCode;
use crate::{ use crate::{
components::site_header::HeaderProps, components::site_header::HeaderProps,
filters,
post_utils::{post_listing::get_post_list, post_parser::ParseResult}, post_utils::{post_listing::get_post_list, post_parser::ParseResult},
projects::project_model::ProjectMetadata, projects::project_model::ProjectMetadata,
}; };
@ -17,7 +16,7 @@ pub struct ProjectListTemplate {
} }
pub async fn render_projects_list() -> Result<ProjectListTemplate, StatusCode> { pub async fn render_projects_list() -> Result<ProjectListTemplate, StatusCode> {
let mut project_list = get_post_list::<ProjectMetadata>("_projects").await?; let mut project_list = get_post_list::<ProjectMetadata>("../_projects").await?;
project_list.sort_by_key(|post| post.slug.to_string()); project_list.sort_by_key(|post| post.slug.to_string());
project_list.retain(|project| project.metadata.displayed); project_list.retain(|project| project.metadata.displayed);

View File

@ -23,6 +23,5 @@ It should be used from the templates as well
pub mod export_format; pub mod export_format;
pub mod image_generator; pub mod image_generator;
pub mod image_src_generator;
pub mod picture_markup_generator; pub mod picture_markup_generator;
pub mod resolutions; pub mod resolutions;

View File

@ -20,6 +20,7 @@ pub fn generate_picture_markup(
height: u32, height: u32,
alt_text: &str, alt_text: &str,
class_name: Option<&str>, class_name: Option<&str>,
generate_image: bool,
) -> Result<String, anyhow::Error> { ) -> Result<String, anyhow::Error> {
let exported_formats = get_export_formats(orig_img_path); let exported_formats = get_export_formats(orig_img_path);
let class_attr = if let Some(class) = class_name { let class_attr = if let Some(class) = class_name {
@ -41,8 +42,9 @@ pub fn generate_picture_markup(
} }
let path_to_generated = get_generated_file_name(orig_img_path); let path_to_generated = get_generated_file_name(orig_img_path);
// TODO This should get removed when we move the project structure #directory-swap
let disk_img_path = let disk_img_path =
Path::new("static/").join(orig_img_path.strip_prefix("/").unwrap_or(orig_img_path)); Path::new("../static/").join(orig_img_path.strip_prefix("/").unwrap_or(orig_img_path));
let orig_img_dimensions = image_dimensions(&disk_img_path)?; let orig_img_dimensions = image_dimensions(&disk_img_path)?;
let resolutions = get_resolutions(orig_img_dimensions, width, height); let resolutions = get_resolutions(orig_img_dimensions, width, height);
@ -54,6 +56,7 @@ pub fn generate_picture_markup(
let exported_formats_arc = Arc::new(exported_formats); let exported_formats_arc = Arc::new(exported_formats);
let exported_formats_clone = Arc::clone(&exported_formats_arc); let exported_formats_clone = Arc::clone(&exported_formats_arc);
if generate_image {
rayon::spawn(move || { rayon::spawn(move || {
let orig_img = ImageReader::open(&disk_img_path) let orig_img = ImageReader::open(&disk_img_path)
.with_context(|| format!("Failed to read instrs from {:?}", &disk_img_path)) .with_context(|| format!("Failed to read instrs from {:?}", &disk_img_path))
@ -64,12 +67,14 @@ pub fn generate_picture_markup(
let resolutions = resolutions_clone.as_ref(); let resolutions = resolutions_clone.as_ref();
let exported_formats = exported_formats_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()); .with_context(|| "Failed to generate images".to_string());
if let Err(e) = result { if let Err(e) = result {
tracing::error!("Error: {}", e); tracing::error!("Error: {}", e);
} }
}); });
}
let exported_formats = Arc::clone(&exported_formats_arc); let exported_formats = Arc::clone(&exported_formats_arc);
let path_to_generated = Arc::clone(&path_to_generated_arc); let path_to_generated = Arc::clone(&path_to_generated_arc);
@ -115,7 +120,7 @@ pub fn generate_picture_markup(
Ok(result) Ok(result)
} }
pub fn get_image_path(path: &Path, resolution: &(u32, u32, f32), format: &ExportFormat) -> String { fn get_image_path(path: &Path, resolution: &(u32, u32, f32), format: &ExportFormat) -> String {
let path_name = path.to_str().expect("Image has to have a valid path"); let path_name = path.to_str().expect("Image has to have a valid path");
let (width, height, _) = resolution; let (width, height, _) = resolution;
let extension = format.get_extension(); let extension = format.get_extension();
@ -193,7 +198,7 @@ fn strip_prefixes(path: &Path) -> &Path {
parent_path parent_path
} }
pub fn get_generated_file_name(orig_img_path: &str) -> PathBuf { fn get_generated_file_name(orig_img_path: &str) -> PathBuf {
let path = Path::new(&orig_img_path); let path = Path::new(&orig_img_path);
// let parent = path // let parent = path
// .parent() // .parent()
@ -243,7 +248,7 @@ fn generate_srcset(path: &Path, format: &ExportFormat, resolutions: &[(u32, u32,
.join(", ") .join(", ")
} }
pub fn get_export_formats(orig_img_path: &str) -> Vec<ExportFormat> { fn get_export_formats(orig_img_path: &str) -> Vec<ExportFormat> {
let path = Path::new(&orig_img_path) let path = Path::new(&orig_img_path)
.extension() .extension()
.and_then(|ext| ext.to_str()); .and_then(|ext| ext.to_str());
@ -310,7 +315,14 @@ fn test_generate_picture_markup() {
</picture>"#, </picture>"#,
}; };
assert_eq!( assert_eq!(
generate_picture_markup(orig_img_path, width, height, "Testing image alt", None,) generate_picture_markup(
orig_img_path,
width,
height,
"Testing image alt",
None,
false
)
.expect("picture markup has to be generated"), .expect("picture markup has to be generated"),
result result
); );

View File

@ -1,4 +1,2 @@
pub mod post_listing; pub mod post_listing;
pub mod post_parser; pub mod post_parser;
pub mod segments;
pub mod tags;

View File

@ -8,6 +8,7 @@ use super::post_parser::{parse_post, ParseResult};
pub async fn get_post_list<'de, Metadata: DeserializeOwned>( pub async fn get_post_list<'de, Metadata: DeserializeOwned>(
path: &str, path: &str,
) -> Result<Vec<ParseResult<Metadata>>, StatusCode> { ) -> Result<Vec<ParseResult<Metadata>>, StatusCode> {
// let path = "../_posts/blog/";
let mut dir = read_dir(path) let mut dir = read_dir(path)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@ -21,7 +22,7 @@ pub async fn get_post_list<'de, Metadata: DeserializeOwned>(
let file_path = file.path(); let file_path = file.path();
let file_path_str = file_path.to_str().unwrap(); let file_path_str = file_path.to_str().unwrap();
info!(":{}", file_path_str); 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); posts.push(post);
} }

View File

@ -1,10 +1,16 @@
use core::panic;
use std::path::Path; use std::path::Path;
use axum::http::StatusCode;
use chrono::{DateTime, Utc};
use gray_matter::{engine::YAML, Matter};
use image::image_dimensions; use image::image_dimensions;
use indoc::formatdoc; use indoc::formatdoc;
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; 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 syntect::{highlighting::ThemeSet, html::highlighted_html_for_string, parsing::SyntaxSet};
use tracing::{debug, error}; use tokio::fs;
use tracing::{debug, error, info};
use crate::picture_generator::{ use crate::picture_generator::{
picture_markup_generator::generate_picture_markup, resolutions::get_max_resolution, picture_markup_generator::generate_picture_markup, resolutions::get_max_resolution,
@ -12,14 +18,66 @@ use crate::picture_generator::{
pub const MAX_BLOG_IMAGE_RESOLUTION: (u32, u32) = (1280, 860); pub const MAX_BLOG_IMAGE_RESOLUTION: (u32, u32) = (1280, 860);
pub fn deserialize_date<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
where
D: Deserializer<'de>,
{
let date_str = String::deserialize(deserializer)?;
match DateTime::parse_from_rfc3339(&date_str) {
Ok(datetime) => Ok(datetime.with_timezone(&Utc)),
Err(err) => Err(serde::de::Error::custom(format!(
"Error parsing date: {}",
err
))),
}
}
pub struct ParseResult<Metadata> {
pub body: String,
pub metadata: Metadata,
pub slug: String,
}
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
// TODO Proper reasoning for an error
.map_err(|_| StatusCode::NOT_FOUND)?;
let matter = Matter::<YAML>::new();
let metadata = matter
.parse_with_struct::<Metadata>(&file_contents)
.ok_or_else(|| {
tracing::error!("Failed to parse metadata");
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)?
.to_str()
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?
.to_owned();
Ok(ParseResult {
body,
metadata: metadata.data,
slug: filename,
})
}
enum TextKind { enum TextKind {
Text, Text,
Heading(Option<String>), Heading(Option<String>),
Code(String), Code(String),
} }
// pub fn parse_markdown(markdown: &str) -> ::askama::Result<String> pub fn parse_html(markdown: &str, generate_images: bool) -> String {
pub fn parse_markdown(markdown: &str) -> ::askama::Result<String> {
let mut options = Options::empty(); let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES); options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_FOOTNOTES); options.insert(Options::ENABLE_FOOTNOTES);
@ -41,10 +99,10 @@ pub fn parse_markdown(markdown: &str) -> ::askama::Result<String> {
and the `[](url "title")` `title` is rendered as `alt` attribute and the `[](url "title")` `title` is rendered as `alt` attribute
*/ */
Event::Start(Tag::Image { Event::Start(Tag::Image {
link_type: _, link_type,
dest_url, dest_url,
title, title,
id: _, id,
}) => { }) => {
if !dest_url.starts_with("/") { if !dest_url.starts_with("/") {
return Event::Html( return Event::Html(
@ -59,7 +117,7 @@ pub fn parse_markdown(markdown: &str) -> ::askama::Result<String> {
} }
let dev_only_img_path = let dev_only_img_path =
Path::new("static/").join(dest_url.strip_prefix("/").unwrap_or(&dest_url)); Path::new("../static/").join(dest_url.strip_prefix("/").unwrap_or(&dest_url));
let img_dimensions = image_dimensions(&dev_only_img_path).unwrap(); let img_dimensions = image_dimensions(&dev_only_img_path).unwrap();
let (max_width, max_height) = get_max_resolution( let (max_width, max_height) = get_max_resolution(
@ -70,7 +128,12 @@ pub fn parse_markdown(markdown: &str) -> ::askama::Result<String> {
// Place image into the content with scaled reso to a boundary // Place image into the content with scaled reso to a boundary
let picture_markup = generate_picture_markup( let picture_markup = generate_picture_markup(
&dest_url, max_width, max_height, &title, None, &dest_url,
max_width,
max_height,
&title,
None,
generate_images,
) )
.unwrap_or(formatdoc!( .unwrap_or(formatdoc!(
r#" r#"
@ -81,6 +144,10 @@ pub fn parse_markdown(markdown: &str) -> ::askama::Result<String> {
alt = title, alt = title,
src = dest_url, src = dest_url,
)); ));
debug!(
"Image link_type: {:?} url: {} title: {} id: {}",
link_type, dest_url, title, id
);
Event::Html( Event::Html(
formatdoc!( formatdoc!(
r#"<figure> r#"<figure>
@ -166,5 +233,5 @@ pub fn parse_markdown(markdown: &str) -> ::askama::Result<String> {
// Write to String buffer // Write to String buffer
let mut html = String::new(); let mut html = String::new();
pulldown_cmark::html::push_html(&mut html, parser); pulldown_cmark::html::push_html(&mut html, parser);
Ok(html) html
} }

View File

@ -5,7 +5,7 @@ use crate::post_utils::{post_listing::get_post_list, post_parser::ParseResult};
use super::project_model::ProjectMetadata; use super::project_model::ProjectMetadata;
pub async fn get_featured_projects() -> Result<Vec<ParseResult<ProjectMetadata>>, StatusCode> { pub async fn get_featured_projects() -> Result<Vec<ParseResult<ProjectMetadata>>, StatusCode> {
let project_list = get_post_list::<ProjectMetadata>("_projects").await?; let project_list = get_post_list::<ProjectMetadata>("../_projects").await?;
let featured_projects = project_list let featured_projects = project_list
.into_iter() .into_iter()

View File

@ -1,11 +1,9 @@
use crate::{ use crate::{
feed::render_rss_feed, feed::render_rss_feed,
pages::{ pages::{
admin::render_admin, animated_logo::render_animated_logo, admin::render_admin, blog_post_list::render_blog_post_list,
blog_post_list::render_blog_post_list, blog_post_page::render_blog_post, blog_post_page::render_blog_post, contact::render_contact, index::render_index,
broadcast_list::render_broadcast_post_list, contact::render_contact, index::render_index, project_list::render_projects_list,
not_found::render_not_found, portfolio::render_portfolio,
project_list::render_projects_list, showcase::egg_fetcher::render_egg_fetcher,
}, },
}; };
use axum::{extract::MatchedPath, http::Request, routing::get, Router}; use axum::{extract::MatchedPath, http::Request, routing::get, Router};
@ -18,14 +16,8 @@ pub fn get_router() -> Router {
.route("/blog", get(render_blog_post_list)) .route("/blog", get(render_blog_post_list))
.route("/blog/tags/:tag", get(render_blog_post_list)) .route("/blog/tags/:tag", get(render_blog_post_list))
.route("/blog/:post_id", get(render_blog_post)) .route("/blog/:post_id", get(render_blog_post))
.route("/broadcasts", get(render_broadcast_post_list))
.route("/broadcasts/tags/:tag", get(render_broadcast_post_list))
.route("/broadcasts/:post_id", get(render_blog_post))
.route("/contact", get(render_contact)) .route("/contact", get(render_contact))
.route("/showcase", get(render_projects_list)) .route("/showcase", get(render_projects_list))
.route("/showcase/m-logo-svg", get(render_animated_logo))
.route("/showcase/:project_slug", get(render_egg_fetcher))
.route("/portfolio", get(render_portfolio))
.route("/admin", get(render_admin)) .route("/admin", get(render_admin))
.route("/feed.xml", get(render_rss_feed)) .route("/feed.xml", get(render_rss_feed))
.layer( .layer(
@ -45,5 +37,4 @@ pub fn get_router() -> Router {
) )
}), }),
) )
.fallback(render_not_found)
} }

View File

@ -3,46 +3,68 @@
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
@font-face {
font-family: 'Comfortaa';
font-style: normal;
font-display: swap;
font-weight: 300;
src:
local('Comfortaa Light'),
local('Comfortaa'),
url(/fonts/comfortaa/Comfortaa-Light.woff2) format('woff2'),
url(/fonts/comfortaa/Comfortaa-Light.ttf) format('truetype');
}
@font-face {
font-family: 'Comfortaa';
font-style: normal;
font-display: swap;
font-weight: 400;
src:
local('Comfortaa Regular'),
local('Comfortaa'),
url(/fonts/comfortaa/Comfortaa-Regular.woff2) format('woff2'),
url(/fonts/comfortaa/Comfortaa-Regular.ttf) format('truetype');
}
@font-face {
font-family: 'Comfortaa';
font-style: normal;
font-display: swap;
font-weight: 500;
src:
local('Comfortaa Medium'),
local('Comfortaa'),
url(/fonts/comfortaa/Comfortaa-Medium.woff2) format('woff2'),
url(/fonts/comfortaa/Comfortaa-Medium.ttf) format('truetype');
}
@font-face {
font-family: 'Comfortaa';
font-style: normal;
font-display: swap;
font-weight: 600;
src:
local('Comfortaa SemiBold'),
local('Comfortaa'),
url(/fonts/comfortaa/Comfortaa-SemiBold.woff2) format('woff2'),
url(/fonts/comfortaa/Comfortaa-SemiBold.ttf) format('truetype');
}
@font-face {
font-family: 'Comfortaa';
font-style: normal;
font-display: swap;
font-weight: 700;
src:
local('Comfortaa Bold'),
local('Comfortaa'),
url(/fonts/comfortaa/Comfortaa-Bold.woff2) format('woff2'),
url(/fonts/comfortaa/Comfortaa-Bold.ttf) format('truetype');
}
@font-face { @font-face {
font-family: 'Baloo2'; font-family: 'Baloo2';
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
src: src:
local('Baloo2'), local('Baloo2'),
url(/fonts/baloo2/Baloo2-Latin-Variable-wght.woff2) format('woff2'); url(/fonts/baloo2/Baloo2-VariableFont_wght.ttf) format('truetype');
}
/* latin-ext */
@font-face {
font-family: 'Baloo 2';
font-style: normal;
font-weight: 400 800;
font-display: swap;
src: url(/fonts/baloo2/Baloo2-Latin-Variable-ext-wght.woff2) format('woff2');
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF,
U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Baloo2 Fallback';
font-style: normal;
font-weight: 400;
src: local('BlinkMacSystemFont'), local('Segoe UI'), local('Helvetica Neue'),
local('Arial');
ascent-override: 111.2%;
descent-override: 54.05%;
line-gap-override: 0%;
size-adjust: 96.95%;
}
@font-face {
font-family: 'Baloo2 Noto Fallback';
font-style: normal;
font-weight: 400;
src: local('Noto Sans');
ascent-override: 88%;
descent-override: none;
line-gap-override: 0%;
size-adjust: 92%;
} }
} }
@ -266,18 +288,3 @@ article a {
transform: translate(0, 0); transform: translate(0, 0);
} }
} }
@view-transition {
navigation: auto;
}
/* Define the animation for persistent elements (like the header and title) */
/* ::view-transition-group(*) { */
/* transition: transform 3.4s ease-in-out; */
/* } */
/* ::view-transition-group(blog_post_preview) { */
/* animation-duration: 5.5s; */
/* transition: transform 5.4s ease-in-out; */
/* opacity: 1; */
/* } */

View File

@ -107,7 +107,7 @@
} }
/* /*
! tailwindcss v3.4.14 | MIT License | https://tailwindcss.com ! tailwindcss v3.4.13 | MIT License | https://tailwindcss.com
*/ */
/* /*
@ -154,7 +154,7 @@ html,
-o-tab-size: 4; -o-tab-size: 4;
tab-size: 4; tab-size: 4;
/* 3 */ /* 3 */
font-family: Baloo2, Baloo2 Noto Fallback, Baloo2 Fallback, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; font-family: Baloo2, Comfortaa, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
/* 4 */ /* 4 */
font-feature-settings: normal; font-feature-settings: normal;
/* 5 */ /* 5 */
@ -550,10 +550,90 @@ video {
/* Make elements with the HTML hidden attribute stay hidden by default */ /* Make elements with the HTML hidden attribute stay hidden by default */
[hidden]:where(:not([hidden="until-found"])) { [hidden] {
display: none; display: none;
} }
@font-face {
font-family: 'Comfortaa';
font-style: normal;
font-display: swap;
font-weight: 300;
src:
local('Comfortaa Light'),
local('Comfortaa'),
url(/fonts/comfortaa/Comfortaa-Light.woff2) format('woff2'),
url(/fonts/comfortaa/Comfortaa-Light.ttf) format('truetype');
}
@font-face {
font-family: 'Comfortaa';
font-style: normal;
font-display: swap;
font-weight: 400;
src:
local('Comfortaa Regular'),
local('Comfortaa'),
url(/fonts/comfortaa/Comfortaa-Regular.woff2) format('woff2'),
url(/fonts/comfortaa/Comfortaa-Regular.ttf) format('truetype');
}
@font-face {
font-family: 'Comfortaa';
font-style: normal;
font-display: swap;
font-weight: 500;
src:
local('Comfortaa Medium'),
local('Comfortaa'),
url(/fonts/comfortaa/Comfortaa-Medium.woff2) format('woff2'),
url(/fonts/comfortaa/Comfortaa-Medium.ttf) format('truetype');
}
@font-face {
font-family: 'Comfortaa';
font-style: normal;
font-display: swap;
font-weight: 600;
src:
local('Comfortaa SemiBold'),
local('Comfortaa'),
url(/fonts/comfortaa/Comfortaa-SemiBold.woff2) format('woff2'),
url(/fonts/comfortaa/Comfortaa-SemiBold.ttf) format('truetype');
}
@font-face {
font-family: 'Comfortaa';
font-style: normal;
font-display: swap;
font-weight: 700;
src:
local('Comfortaa Bold'),
local('Comfortaa'),
url(/fonts/comfortaa/Comfortaa-Bold.woff2) format('woff2'),
url(/fonts/comfortaa/Comfortaa-Bold.ttf) format('truetype');
}
@font-face { @font-face {
font-family: 'Baloo2'; font-family: 'Baloo2';
@ -563,65 +643,7 @@ video {
src: src:
local('Baloo2'), local('Baloo2'),
url(/fonts/baloo2/Baloo2-Latin-Variable-wght.woff2) format('woff2'); url(/fonts/baloo2/Baloo2-VariableFont_wght.ttf) format('truetype');
}
/* latin-ext */
@font-face {
font-family: 'Baloo 2';
font-style: normal;
font-weight: 400 800;
font-display: swap;
src: url(/fonts/baloo2/Baloo2-Latin-Variable-ext-wght.woff2) format('woff2');
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF,
U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Baloo2 Fallback';
font-style: normal;
font-weight: 400;
src: local('BlinkMacSystemFont'), local('Segoe UI'), local('Helvetica Neue'),
local('Arial');
ascent-override: 111.2%;
descent-override: 54.05%;
line-gap-override: 0%;
size-adjust: 96.95%;
}
@font-face {
font-family: 'Baloo2 Noto Fallback';
font-style: normal;
font-weight: 400;
src: local('Noto Sans');
ascent-override: 88%;
descent-override: none;
line-gap-override: 0%;
size-adjust: 92%;
}
.visible {
visibility: visible;
} }
.col-span-2 { .col-span-2 {
@ -644,10 +666,6 @@ video {
margin: 0.25rem; margin: 0.25rem;
} }
.m-10 {
margin: 2.5rem;
}
.m-4 { .m-4 {
margin: 1rem; margin: 1rem;
} }
@ -670,11 +688,6 @@ video {
margin-right: 0.5rem; margin-right: 0.5rem;
} }
.mx-3 {
margin-left: 0.75rem;
margin-right: 0.75rem;
}
.mx-4 { .mx-4 {
margin-left: 1rem; margin-left: 1rem;
margin-right: 1rem; margin-right: 1rem;
@ -695,11 +708,6 @@ video {
margin-right: auto; margin-right: auto;
} }
.my-1 {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.my-2 { .my-2 {
margin-top: 0.5rem; margin-top: 0.5rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
@ -752,10 +760,6 @@ video {
margin-top: 0.75rem; margin-top: 0.75rem;
} }
.mt-4 {
margin-top: 1rem;
}
.block { .block {
display: block; display: block;
} }
@ -796,10 +800,6 @@ video {
height: 240px; height: 240px;
} }
.h-\[320px\] {
height: 320px;
}
.h-auto { .h-auto {
height: auto; height: auto;
} }
@ -828,14 +828,6 @@ video {
width: 180px; width: 180px;
} }
.w-\[320px\] {
width: 320px;
}
.max-w-\[24rem\] {
max-width: 24rem;
}
.max-w-\[32rem\] { .max-w-\[32rem\] {
max-width: 32rem; max-width: 32rem;
} }
@ -852,14 +844,6 @@ video {
max-width: 64rem; max-width: 64rem;
} }
.flex-1 {
flex: 1 1 0%;
}
.shrink-0 {
flex-shrink: 0;
}
.flex-grow { .flex-grow {
flex-grow: 1; flex-grow: 1;
} }
@ -886,10 +870,6 @@ video {
flex-direction: column; flex-direction: column;
} }
.flex-wrap {
flex-wrap: wrap;
}
.place-content-center { .place-content-center {
place-content: center; place-content: center;
} }
@ -914,18 +894,10 @@ video {
justify-content: space-between; justify-content: space-between;
} }
.justify-around {
justify-content: space-around;
}
.gap-2 { .gap-2 {
gap: 0.5rem; gap: 0.5rem;
} }
.gap-4 {
gap: 1rem;
}
.gap-6 { .gap-6 {
gap: 1.5rem; gap: 1.5rem;
} }
@ -962,11 +934,6 @@ video {
border-width: 2px; border-width: 2px;
} }
.border-blue-300 {
--tw-border-opacity: 1;
border-color: rgb(130 195 247 / var(--tw-border-opacity));
}
.border-blue-500 { .border-blue-500 {
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(23 137 224 / var(--tw-border-opacity)); border-color: rgb(23 137 224 / var(--tw-border-opacity));
@ -1014,10 +981,6 @@ video {
padding: 0.125rem; padding: 0.125rem;
} }
.p-2 {
padding: 0.5rem;
}
.p-3 { .p-3 {
padding: 0.75rem; padding: 0.75rem;
} }
@ -1057,10 +1020,6 @@ video {
text-align: justify; text-align: justify;
} }
.font-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.text-2xl { .text-2xl {
font-size: 1.5rem; font-size: 1.5rem;
line-height: 2rem; line-height: 2rem;
@ -1081,11 +1040,6 @@ video {
line-height: 1; line-height: 1;
} }
.text-9xl {
font-size: 8rem;
line-height: 1;
}
.text-base { .text-base {
font-size: 1rem; font-size: 1rem;
line-height: 1.5rem; line-height: 1.5rem;
@ -1134,10 +1088,6 @@ video {
line-height: 1.25rem; line-height: 1.25rem;
} }
.leading-tight {
line-height: 1.25;
}
.text-blue-500 { .text-blue-500 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(23 137 224 / var(--tw-text-opacity)); color: rgb(23 137 224 / var(--tw-text-opacity));
@ -1158,11 +1108,6 @@ video {
color: rgb(11 39 70 / var(--tw-text-opacity)); color: rgb(11 39 70 / var(--tw-text-opacity));
} }
.text-pink-900 {
--tw-text-opacity: 1;
color: rgb(119 24 89 / var(--tw-text-opacity));
}
.text-pink-950 { .text-pink-950 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(80 2 56 / var(--tw-text-opacity)); color: rgb(80 2 56 / var(--tw-text-opacity));
@ -1971,28 +1916,6 @@ article a:visited {
} }
} }
@view-transition {
navigation: auto;
}
/* Define the animation for persistent elements (like the header and title) */
/* ::view-transition-group(*) { */
/* transition: transform 3.4s ease-in-out; */
/* } */
/* ::view-transition-group(blog_post_preview) { */
/* animation-duration: 5.5s; */
/* transition: transform 5.4s ease-in-out; */
/* opacity: 1; */
/* } */
.visited\:text-blue-950:visited { .visited\:text-blue-950:visited {
color: rgb(11 39 70 ); color: rgb(11 39 70 );
} }
@ -2063,24 +1986,11 @@ article a:visited {
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.md\:mx-6 {
margin-left: 1.5rem;
margin-right: 1.5rem;
}
.md\:my-8 { .md\:my-8 {
margin-top: 2rem; margin-top: 2rem;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.md\:block {
display: block;
}
.md\:grid {
display: grid;
}
.md\:h-16 { .md\:h-16 {
height: 4rem; height: 4rem;
} }
@ -2093,10 +2003,6 @@ article a:visited {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.md\:grid-cols-\[1fr_auto\] {
grid-template-columns: 1fr auto;
}
.md\:grid-rows-\[masonry\] { .md\:grid-rows-\[masonry\] {
grid-template-rows: masonry; grid-template-rows: masonry;
} }
@ -2129,11 +2035,6 @@ article a:visited {
line-height: 2.5rem; line-height: 2.5rem;
} }
.md\:text-5xl {
font-size: 3rem;
line-height: 1;
}
.md\:text-6xl { .md\:text-6xl {
font-size: 3.75rem; font-size: 3.75rem;
line-height: 1; line-height: 1;
@ -2153,10 +2054,6 @@ article a:visited {
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.75rem; line-height: 1.75rem;
} }
.md\:leading-tight {
line-height: 1.25;
}
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
@ -2235,10 +2132,6 @@ article a:visited {
grid-column: auto; grid-column: auto;
} }
.xl\:col-span-2 {
grid-column: span 2 / span 2;
}
.xl\:row-span-2 { .xl\:row-span-2 {
grid-row: span 2 / span 2; grid-row: span 2 / span 2;
} }
@ -2247,22 +2140,10 @@ article a:visited {
grid-row-start: auto; grid-row-start: auto;
} }
.xl\:mb-4 {
margin-bottom: 1rem;
}
.xl\:mt-10 {
margin-top: 2.5rem;
}
.xl\:block { .xl\:block {
display: block; display: block;
} }
.xl\:grid {
display: grid;
}
.xl\:grid-cols-3 { .xl\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
@ -2270,36 +2151,9 @@ article a:visited {
.xl\:grid-cols-\[1fr_2fr\] { .xl\:grid-cols-\[1fr_2fr\] {
grid-template-columns: 1fr 2fr; grid-template-columns: 1fr 2fr;
} }
.xl\:gap-8 {
gap: 2rem;
}
.xl\:gap-x-32 {
-moz-column-gap: 8rem;
column-gap: 8rem;
}
.xl\:text-4xl {
font-size: 2.25rem;
line-height: 2.5rem;
}
.xl\:text-7xl {
font-size: 4.5rem;
line-height: 1;
}
.xl\:font-medium {
font-weight: 500;
}
} }
@media print { @media print {
.print\:inline {
display: inline;
}
.print\:hidden { .print\:hidden {
display: none; display: none;
} }

View File

@ -6,8 +6,7 @@ module.exports = {
fontFamily: { fontFamily: {
sans: [ sans: [
"Baloo2", "Baloo2",
"Baloo2 Noto Fallback", "Comfortaa",
"Baloo2 Fallback",
"ui-sans-serif", "ui-sans-serif",
"system-ui", "system-ui",
"sans-serif", "sans-serif",

View File

@ -16,23 +16,15 @@
href="/feed.xml" href="/feed.xml"
/> />
{% block og_meta %}
<meta property="og:title" content="{% block title %} {{ title }} {% endblock %} @michalvankodev" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://michalvanko.dev" />
<meta property="og:image" content="https://michalvanko.dev/images/m-logo.svg" />
{% endblock %}
<!-- Tailwind output file --> <!-- Tailwind output file -->
<link rel="stylesheet" href="/styles/output.css" /> <link rel="stylesheet" href="/styles/output.css" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/svg+xml" href="/images/m-logo.svg" /> <link rel="icon" type="image/svg+xml" href="/images/m-logo.svg" />
<link rel="icon" type="image/png" href="/images/m-logo-192.png" /> <link rel="icon" type="image/png" href="/images/m-logo-192.png" />
</head> </head>
<body class="bg-blue-50"> <body class="bg-blue-50">
<div class="preload-assets" style="display: none">
{% include "icons/sprite.svg" %}
</div>
{% include "site_header.html" %} {% include "site_header.html" %}
{% block content %} Placeholder {% endblock %} {% block content %} Placeholder {% endblock %}
{% include "site_footer.html" %} {% include "site_footer.html" %}

View File

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}{{title}}{% endblock %}
{% block content %}
<article class="mb-6">
<header class="px-4 max-w-read mx-auto">
<h1 class="text-3xl md:text-4xl lg:text-6xl lg:mt-20 text-blue-900 mb-3 font-bold">{{title}}</h1>
<aside class="flex justify-between flex-row">
{% include "post_tag_list.html" %}
<section class="created-at m-1 text-right text-sm text-slate-600 md:text-lg">
<span>Published on</span>
<time datetime="{date}"> {{date|pretty_date}} </time>
</section>
</aside>
</header>
<section class="article-body">
{{body|escape("none")}}
</section>
</article>
<!-- TODO: Next recommendations for reading -->
<!-- TODO: Bact to all posts -->
{# footer #}
{% endblock %}

View File

@ -1,29 +1,25 @@
{%- import "components/social_card.html" as sc -%} {%- import "components/social_card.html" as sc -%}
{% extends "base.html" %} {% extends "base.html" %}
{% block og_meta %}
<meta property="og:title" content="{{og_title}} @michalvankodev" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://michalvanko.dev{{current_url}}" />
<meta property="og:image" content="https://michalvanko.dev/images/m-logo.svg" />
{% endblock %}
{% block content %} {% block content %}
<section id="post-container" class="lg:grid lg:grid-cols-[2fr_1fr] lg:grid-rows-[min-content_1fr] xl:gap-x-32 max-w-maxindex mx-auto"> <section id="blog-container" class="lg:grid lg:grid-cols-[2fr_1fr] lg:grid-rows-[min-content_1fr] lg:gap-x-32 max-w-maxindex mx-auto">
<section id="post-list" class="lg:row-span-2"> <section id="blog-list" class="lg:row-span-2">
{% if posts.len() == 0 %} {% if posts.len() == 0 %}
<p class="no-posts">You've found void in the space.</p> <p class="no-posts">You've found void in the space.</p>
{% else %} {% else %}
<h1 class="m-5 text-4xl text-blue-950 font-extrabold md:text-6xl"> <h1 class="m-5 text-4xl text-blue-950 font-extrabold md:text-6xl">
{{title}} {% if let Some(t) = tag %}
#{{t}}
{% else %}
Blog posts
{% endif %}
</h1> </h1>
<section id="tags-list"> <section id="blog-tags">
<ul class="mx-5"> <ul class="mx-5">
{% for tag in tags %} {% for tag in blog_tags %}
<li class="inline-block mx-0.5 p-0.5 md:text-xl"> <li class="inline-block mx-0.5 p-0.5 md:text-xl">
<a href="/{{segment}}/tags/{{tag}}" class="text-pink-950">#{{tag|capitalize}}</a> <a href="/blog/tags/{{tag}}" class="text-pink-950">#{{tag|capitalize}}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@ -40,7 +36,7 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
</section> <!-- /#post-list --> </section> <!-- /#blog-list -->
<section id="socials" class="hidden lg:block"> <section id="socials" class="hidden lg:block">
{% include "sections/social.html" %} {% include "sections/social.html" %}
</section> <!-- /#socials --> </section> <!-- /#socials -->
@ -59,5 +55,5 @@
<a href="/showcase">check out more projects</a> <a href="/showcase">check out more projects</a>
</section> </section>
</section> <!-- /#showcase --> </section> <!-- /#showcase -->
</section> <!-- /#post-list-container --> </section> <!-- /#blog-container -->
{% endblock %} {% endblock %}

View File

@ -0,0 +1,30 @@
<article class="sm:grid sm:grid-cols-[max-content_1fr] sm:grid-rows-[max-content_1fr_max-content] sm:grid-flow-col sm:gap-4 md:gap-x-8 break-inside-avoid clear-both sm:clear-none">
<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 }}
{% when None %}
<div>
{% include "components/blog_post_default_thumbnail.html" %}
</div>
{% endmatch %}
</aside>
<header>
<h3 class="text-lg font-bold mb-1 md:text-3xl">
<a rel="prefetch" href="/blog/{{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>
<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 %}
<li class="inline-block">
<a href="/blog/tags/{{tag}}" class="text-pink-950 no-underline">#{{tag|capitalize}}</a>
</li>
{% endfor %}
</ul>
|
<time datetime="{{post.metadata.date}}" class="text-pink-950"> {{post.metadata.date|pretty_date}} </time>
</footer>
</article>

View File

@ -1,4 +1,4 @@
<section class="border rounded-md bg-white p-4 break-inside-avoid" style="view-transition-name: project_preview_{{project.slug}};"> <section class="border rounded-md bg-white p-4 break-inside-avoid">
<header class="px-4 mb-3"> <header class="px-4 mb-3">
<h2 class="text-xl font-semibold text-blue-900 md:text-2xl"> <h2 class="text-xl font-semibold text-blue-900 md:text-2xl">
{% match project.metadata.link %} {% match project.metadata.link %}
@ -10,15 +10,15 @@
{{project.metadata.title}} {{project.metadata.title}}
{% endmatch %} {% endmatch %}
</h2> </h2>
<section class="description text-slate-800 my-2 md:text-xl text-justify leading-tight md:leading-tight"> <section class="description text-slate-800 my-2 md:text-xl text-justify">
{{project.body|parse_markdown|safe}} {{project.body|safe}}
</section> </section>
</header> </header>
<!-- <hr class="border-blue-950 my-5"> --> <!-- <hr class="border-blue-950 my-5"> -->
{% match project.metadata.cover_image %} {% match project.metadata.cover_image %}
{% when Some with (source) %} {% 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"> <figure class="mx-4 my-2 flex justify-center">
{% match project.metadata.link %} {% match project.metadata.link %}
{% when Some with (href) %} {% when Some with (href) %}
@ -28,6 +28,7 @@
{% when None %} {% when None %}
{{picture|safe}} {{picture|safe}}
{% endmatch %} {% endmatch %}
<!-- TODO <figure> generate_image -->
</figure> </figure>
{% when None %} {% when None %}
{% endmatch %} {% endmatch %}

View File

@ -2,13 +2,13 @@
<a href="{{url}}" class="block no-underline border rounded-md bg-pink-200 m-4 p-4 max-w-[392px] {{class}}"> <a href="{{url}}" class="block no-underline border rounded-md bg-pink-200 m-4 p-4 max-w-[392px] {{class}}">
<header class="flex text-center justify-center items-center gap-2 mb-2"> <header class="flex text-center justify-center items-center gap-2 mb-2">
<svg role="img" aria-label="{{svg}} icon" aria-hidden="true" class="h-7 w-7 fill-blue-950"> <svg aria-hidden="true" class="h-7 w-7 fill-blue-950">
<use href="#{{svg}}" /> <use xlink:href="/svg/icons-sprite.svg#{{svg}}" />
</svg> </svg>
<h3 class="text-lg font-medium mb-1 text-blue-950 visited:text-blue-950">{{heading|safe}}</h3> <h3 class="text-lg font-medium mb-1 text-blue-950 visited:text-blue-950">{{heading|safe}}</h3>
</header> </header>
{% let alt_text = format!("{svg} thumbnail") %} {% 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> </a>
{% endmacro %} {% endmacro %}

View File

@ -0,0 +1,18 @@
{% macro talent_card(svg, heading, description) %}
<section class="flex border rounded bg-white m-4 p-3 max-w-[32rem]">
<aside class="flex justify-center items-center pr-3">
<svg aria-hidden="true" class="fill-blue-950 h-12 w-12 md:h-16 md:w-16">
<use xlink:href="/svg/icons-sprite.svg#{{svg}}" />
</svg>
</aside>
<section>
<header>
<h3 class="text-lg font-medium mb-1 md:text-2xl">{{heading}}</h3>
</header>
<p class="text-sm leading-5 text-slate-800 md:text-lg">{{description|safe}}</p>
</section>
</section>
{% endmacro %}

View File

Before

Width:  |  Height:  |  Size: 1012 B

After

Width:  |  Height:  |  Size: 1012 B

View File

@ -15,7 +15,7 @@
title="{{link.title}}" title="{{link.title}}"
> >
<svg aria-hidden="true" class="h-6 w-6 mx-2 self-start"> <svg aria-hidden="true" class="h-6 w-6 mx-2 self-start">
<use xlink:href="#{{link.svg}}" /> <use xlink:href="/svg/icons-sprite.svg#{{link.svg}}" />
</svg> </svg>
<span class="text-lg font-semibold">{{link.label}}</span> <span class="text-lg font-semibold">{{link.label}}</span>
</a> </a>

View File

@ -33,21 +33,20 @@
<em> <a href="https://en.wikipedia.org/wiki/Programmer">programmer</a> </em> <em> <a href="https://en.wikipedia.org/wiki/Programmer">programmer</a> </em>
. I am developing software for more than half of my life and <strong>I love it!</strong> Sometimes I stream working on my side projects and building a <a href="https://discord.gg/2cGg7kwZEh">community of like minded people</a>. Here you can find blogs of my thoughts and journeys, as well as links to my socials where you can see other content.</p> . I am developing software for more than half of my life and <strong>I love it!</strong> Sometimes I stream working on my side projects and building a <a href="https://discord.gg/2cGg7kwZEh">community of like minded people</a>. Here you can find blogs of my thoughts and journeys, as well as links to my socials where you can see other content.</p>
<section id="talent-cards" class="my-4 mx-auto flex flex-col items-center gap-4 max-w-[32rem] px-4"> <section id="talent-cards" class="flex flex-col items-center">
{% call tc::talent_card("code", "Web development", "Extensive expertise in creating performant, live web applications and websites.") %} {% call tc::talent_card("code", "Web development", "Extensive expertise in creating performant, live web applications and websites") %}
{% call tc::talent_card("gamepad", "Game development", "Creating games with passion. Capable of producing each aspect of a good game with profesional sound and creative mechanics. I consider games as multidisciplinary art.") %} {% call tc::talent_card("gamepad", "Game development", "Extensive expertise in creating performant, live web applications and websites") %}
{% call tc::talent_card("person-chalkboard", "Mentoring & Consulting", "I offer consulting sessions to assist you in developing <strong>higher-quality software</strong> and share insights from crafting robust, professional web applications. <a href=\"https://calendly.com/michalvankosk/30min\">Schedule a session with me</a> and elevate your projects together.") %} {% call tc::talent_card("person-chalkboard", "Mentoring & Consulting", "I offer consulting sessions to assist you in developing <strong>higher-quality software</strong> and share insights from crafting robust, professional web applications. <a href=\"https://calendly.com/michalvankosk/30min\">Schedule a session with me</a> and elevate your projects together.") %}
</section> </section>
</section> </section>
<section id="blog" class="lg:col-span-2 lg:row-start-2 xl:col-auto xl:row-start-auto xl:row-span-2"> <section id="blog" class="lg:col-span-2 lg:row-start-2 xl:col-auto xl:row-start-auto xl:row-span-2">
{% let segment = "blog".to_string() %}
<h2 class="text-blue-950 font-bold text-2xl md:text-4xl m-5"><a href="/blog" class="text-blue-950 no-underline">Blog</a></h2> <h2 class="text-blue-950 font-bold text-2xl md:text-4xl m-5"><a href="/blog" class="text-blue-950 no-underline">Blog</a></h2>
<section id="blog-tags"> <section id="blog-tags">
<ul class="mx-5"> <ul class="mx-5">
{% for tag in blog_tags %} {% for tag in blog_tags %}
<li class="inline-block mx-0.5 p-0.5 md:text-xl"> <li class="inline-block mx-0.5 p-0.5 md:text-xl">
<a href="/{{segment}}/tags/{{tag}}" class="text-pink-950">#{{tag|capitalize}}</a> <a href="/blog/tags/{{tag}}" class="text-pink-950">#{{tag|capitalize}}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@ -76,33 +75,6 @@
<section id="showcase" class="col-span-2"> <section id="showcase" class="col-span-2">
{% include "sections/showcase.html" %} {% include "sections/showcase.html" %}
</section> </section>
<section id="broadcasts" class="col-span-2">
{% let segment = "broadcasts".to_string() %}
<h2 class="text-blue-950 font-bold text-2xl md:text-4xl m-5"><a href="/broadcasts" class="text-blue-950 no-underline">Broadcasts</a></h2>
<section id="broadcast-tags">
<ul class="mx-5">
{% for tag in broadcasts_tags %}
<li class="inline-block mx-0.5 p-0.5 md:text-xl">
<a href="/{{segment}}/tags/{{tag}}" class="text-pink-950">#{{tag|capitalize}}</a>
</li>
{% endfor %}
</ul>
</section>
<hr class="border-slate-300 m-5">
<ul class="mx-5">
{% for post in featured_broadcasts %}
<li>
{% include "components/blog_post_preview.html" %}
<hr class="border-slate-300 my-5 md:my-8">
</li>
{% endfor %}
</ul>
<section class="text-center my-3 md:text-lg">
<a href="/broadcasts">see all broadcasts</a>
</section>
</section>
</section> <!-- /.index-container --> </section> <!-- /.index-container -->

View File

@ -1,9 +1,9 @@
<section class="article-tags text-base"> <section class="article-tags text-base">
{% if tags.len() > 0 %} {% if tags.len() > 0 %}
<ul class="inline" style="view-transition-name: post_tags_{{slug}}"> <ul class="inline">
{% for tag in tags %} {% for tag in tags %}
<li class="inline italic text-blue-700 md:text-lg"> <li class="inline italic text-blue-700 md:text-lg">
<a href="/{{segment}}/tags/{{tag}}">#{{tag}}</a> <a href="/blog/tags/{{tag}}">#{{tag}}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@ -1,12 +1,4 @@
{% extends "base.html" %} {% 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 %} {% block content %}
<section id="project-list-container" class="max-w-maxindex mx-auto"> <section id="project-list-container" class="max-w-maxindex mx-auto">
@ -29,9 +21,4 @@
</section> <!-- /#project-list --> </section> <!-- /#project-list -->
</section> <!-- /#project-list-container --> </section> <!-- /#project-list-container -->
<section class="text-center my-3 md:text-lg">
<a href="/portfolio">see complete portfolio</a>
</section>
{% endblock %} {% endblock %}

View File

@ -1,8 +1,7 @@
<footer class="my-4" view-transition-name="site_footer"> <footer class="my-4">
<hr class="mb-4 border-slate-300 mx-5"> <hr class="mb-4 border-slate-300 mx-5">
<section class="flex justify-around">
<p <p
class="text-center flex-1" class="text-center"
xmlns:cc="http://creativecommons.org/ns#" xmlns:cc="http://creativecommons.org/ns#"
xmlns:dct="http://purl.org/dc/terms/" xmlns:dct="http://purl.org/dc/terms/"
> >
@ -26,33 +25,28 @@
rel="license noopener noreferrer" rel="license noopener noreferrer"
style="display: inline-block" style="display: inline-block"
>CC BY-NC-ND 4.0<img >CC BY-NC-ND 4.0<img
src="/images/creative-commons/cc.svg" src="https://mirrors.creativecommons.org/presskit/icons/cc.svg?ref=chooser-v1"
alt="cc" alt="cc"
class="inline-block h-6 mx-0.5" class="inline-block h-6 mx-0.5"
height="24" height="24"
width="24" /><img width="24" /><img
src="/images/creative-commons/by.svg" src="https://mirrors.creativecommons.org/presskit/icons/by.svg?ref=chooser-v1"
alt="by" alt="by"
class="inline-block h-6 mx-0.5" class="inline-block h-6 mx-0.5"
height="24" height="24"
width="24" /><img width="24" /><img
src="/images/creative-commons/nc.svg" src="https://mirrors.creativecommons.org/presskit/icons/nc.svg?ref=chooser-v1"
alt="nc" alt="nc"
class="inline-block h-6 mx-0.5" class="inline-block h-6 mx-0.5"
height="24" height="24"
width="24" /><img width="24" /><img
src="/images/creative-commons/nd.svg" src="https://mirrors.creativecommons.org/presskit/icons/nd.svg?ref=chooser-v1"
alt="nd" alt="nd"
class="inline-block h-6 mx-0.5" class="inline-block h-6 mx-0.5"
height="24" height="24"
width="24" width="24"
/></a> /></a>
<!-- TODO Display link to feed with icon -->
<a href="/feed.xml" class="hidden">RSS feed</a>
</p> </p>
<aside class="mx-3">
<a href="/feed.xml">
<svg role="img" aria-hidden="true" aria-label="RSS feed" class="fill-blue-950 h-6 w-6">
<use href="#rss" />
</svg>
</a>
</aside>
</footer> </footer>

View File

@ -1,4 +1,4 @@
<header class="min-h-full bg-blue-50 mb-5" view-transition-name="site_header"> <header class="min-h-full bg-blue-50 mb-5">
<nav class="flex"> <nav class="flex">
{% match header_props.back_link %} {% match header_props.back_link %}
{% when Some with (link) %} {% when Some with (link) %}

4
cypress.json Normal file
View File

@ -0,0 +1,4 @@
{
"baseUrl": "http://localhost:3000",
"video": false
}

7319
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

56
package.json Normal file
View File

@ -0,0 +1,56 @@
{
"name": "michalvankodev",
"description": "My personal website with blog",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"prebuild": "npm run svgstore",
"build": "vite build",
"preview": "vite preview",
"start": "svelte-kit start",
"test": "vitest",
"svgstore": "svgstore -o src/svg/build/icons-sprite.svg src/svg/**.svg",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write ."
},
"dependencies": {
"@vanilla-extract/css": "^1.9.2",
"@vanilla-extract/sprinkles": "^1.5.1",
"@vanilla-extract/vite-plugin": "^3.7.0",
"classnames": "^2.3.2",
"date-fns": "^2.29.3",
"feed": "^4.2.2",
"front-matter": "^4.0.2",
"marked": "^3.0.8",
"modern-normalize": "^1.1.0",
"polished": "^4.2.2",
"prismjs": "^1.29.0",
"ramda": "^0.28.0"
},
"devDependencies": {
"@sveltejs/adapter-static": "^1.0.4",
"@sveltejs/kit": "^1.1.1",
"@tsconfig/svelte": "^3.0.0",
"@types/classnames": "^2.3.1",
"@types/node": "^18.11.18",
"@types/ramda": "^0.28.21",
"@typescript-eslint/eslint-plugin": "^5.48.2",
"@typescript-eslint/parser": "^5.48.2",
"eslint": "^8.32.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-svelte3": "^4.0.0",
"prettier": "~2.8.3",
"prettier-plugin-svelte": "^2.9.0",
"svelte": "^3.55.1",
"svelte-preprocess": "^5.0.0",
"svgstore-cli": "^2.0.1",
"tslib": "^2.4.1",
"typescript": "^4.9.4",
"vite": "^4.0.4",
"vitest": "^0.27.2",
"vitest-svelte-kit": "^0.0.6"
}
}

View File

@ -1,6 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}

View File

@ -1,2 +0,0 @@
User-agent: *
Disallow: /admin

31
src/app.html Normal file
View File

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<meta name="theme-color" content="#333333" />
<meta name="description" content="Personal website of @michalvankodev" />
<meta name="keywords" content="personal, blog, webdev, tech, programming" />
<meta name="robots" content="index, follow" />
<link rel="alternate" type="application/rss+xml" title="RSS feed for latest posts" href="https://michalvanko.dev/feed.xml" />
<link rel="alternate" title="JSON feed for latest posts" type="application/json" href="https://michalvanko.dev/feed.json" />
<link rel="stylesheet" href="/print.css" media="print" />
<link rel="stylesheet" href="/fonts.css" />
<link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/prism.css" />
<link rel="icon" type="image/svg+xml" href="/m-logo.svg" />
<link rel="icon" type="image/png" href="/m-logo-192.png" />
<!-- This contains the contents of the <svelte:head> component, if
the current page has one -->
%sveltekit.head%
</head>
<body>
<div style="display: contents">
%sveltekit.body%
</div>
</body>
</html>

View File

@ -1 +0,0 @@
pub mod blog_post_model;

View File

@ -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;

View File

@ -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)
}

View File

@ -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)
}

3
src/global.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
/// <reference types="@sveltejs/kit" />
/// <reference types="svelte" />
/// <reference types="vite/client" />

View File

@ -0,0 +1,38 @@
import { error } from '@sveltejs/kit'
import fm from 'front-matter'
import { readFile } from 'fs'
import { parseField } from '$lib/markdown/parse-markdown'
import { promisify } from 'util'
export interface ArticleAttributes {
slug: string
layout: string
segments: string[]
title: string
published: boolean
date: string
thumbnail: string
tags: string[]
body: string
}
export async function getArticleContent(slug: string) {
let postSource: string
try {
postSource = await promisify(readFile)(`_posts/blog/${slug}.md`, 'utf-8')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
if (e.code === 'ENOENT') {
throw error(404, 'Post not found \n' + e.toString())
}
throw e
}
const parsedPost = fm<ArticleAttributes>(postSource)
const post = parseField<ArticleAttributes>('body')({
...parsedPost.attributes,
body: parsedPost.body,
})
return post
}

View File

@ -0,0 +1,58 @@
import { readdir, readFile } from 'fs'
import { promisify } from 'util'
import { basename } from 'path'
import { pipe, prop, sortBy, reverse, filter } from 'ramda'
import fm from 'front-matter'
import marked from 'marked'
import {
filterAndCount,
type PaginationQuery,
} from '$lib/pagination/pagination'
import type { ArticleAttributes } from './articleContent'
export interface ArticlePreviewAttributes extends ArticleAttributes {
preview: string
}
const { NODE_ENV } = process.env
export async function getBlogListing(paginationQuery: PaginationQuery) {
const files = await promisify(readdir)(`_posts/blog/`, 'utf-8')
const filteredFiles = filterDevelopmentFiles(files)
const contents = await Promise.all(
filteredFiles.map(async (file) => {
const fileContent = await promisify(readFile)(
`_posts/blog/${file}`,
'utf-8'
)
const parsedAttributes = fm<ArticleAttributes>(fileContent)
const lineOfTextRegExp = /^(?:\w|\[).+/gm
const lines = parsedAttributes.body
.match(lineOfTextRegExp)
.slice(0, 2)
.join('\n')
const preview = marked(lines)
return {
...parsedAttributes.attributes,
preview,
slug: basename(file, '.md'),
}
})
)
const filteredContents = pipe(
sortBy<ArticlePreviewAttributes>(prop('date')),
(items) => reverse(items),
filter<(typeof contents)[0]>((article) => article.published),
filterAndCount(paginationQuery)
)(contents)
return filteredContents
}
function filterDevelopmentFiles(files: string[]) {
return NODE_ENV !== 'production'
? files
: files.filter((file) => !file.startsWith('dev-'))
}

View File

@ -0,0 +1,11 @@
declare global {
interface Window {
onMountScripts?: Array<() => void>
}
}
export function runOnMountScripts() {
window.onMountScripts?.forEach((fn) => {
fn()
})
}

View File

@ -0,0 +1,169 @@
import { globalStyle, style } from '@vanilla-extract/css'
import { radialGradient, rgba, transparentize } from 'polished'
import { sprinkles } from '$lib/styles/sprinkles.css'
import {
breakpoints,
colors,
mediaAt,
menuBackground,
transparent,
vars,
} from '$lib/styles/vars.css'
export const siteFooterClass = style([
sprinkles({
fontSize: { mobile: 'base', desktop: 'sm' },
paddingX: '2x',
paddingTop: '1x',
color: 'menuLink',
}),
radialGradient({
colorStops: [
`${menuBackground} 56%`,
`${transparentize(0.4, menuBackground)} 100%`,
],
extent: '160% 100% at 100% 100%',
fallback: transparent,
}),
{
'@media': {
[mediaAt(breakpoints.m)]: radialGradient({
colorStops: [
`${menuBackground} 48%`,
`${transparentize(1, menuBackground)} 100%`,
],
extent: '140% 100% at 100% 100%',
fallback: transparent,
}),
},
},
])
export const headerClass = sprinkles({
fontWeight: 'bold',
fontSize: 'base',
color: 'menuLink',
margin: 'none',
lineHeight: '3x',
marginBottom: '1x',
})
export const sectionListsClass = style([
sprinkles({
display: 'grid',
justifyItems: { mobile: 'center', desktop: 'start' },
textAlign: { mobile: 'center', desktop: 'start' },
maxWidth: 'max',
columnGap: '3x',
margin: 'auto',
}),
{
'@media': {
[mediaAt(breakpoints.l)]: {
gridTemplateColumns: 'auto auto auto',
},
},
},
])
export const sectionListSectionClass = sprinkles({
marginY: '3x',
})
export const listUlClass = sprinkles({
listStyle: 'none',
padding: 'none',
margin: 'none',
})
export const listLiClass = sprinkles({
marginLeft: '1x',
})
export const nestedListLiClass = style([
listLiClass,
sprinkles({
fontSize: 'sm',
}),
])
export const socialLinkLabelClass = sprinkles({
paddingX: '1x',
})
export const svgClass = style({
fill: vars.color.menuLink,
height: '1em',
width: '1em',
})
export const strokeSvgClass = style([
svgClass,
{
stroke: vars.color.menuLink,
strokeWidth: '2px',
},
])
export const socialLinkClass = sprinkles({
display: 'flex',
alignItems: 'center',
justifyContent: {
mobile: 'center',
desktop: 'start',
},
})
export const bottomLineClass = sprinkles({
display: 'flex',
justifyContent: 'space-between',
marginX: 'auto',
paddingBottom: '1x',
marginTop: '2x',
maxWidth: 'max',
})
export const dateClass = sprinkles({
fontSize: 'xs',
whiteSpace: 'nowrap',
})
export const boldClass = sprinkles({
fontWeight: 'bold',
})
export const hrClass = style([
sprinkles({
marginY: '2x',
marginX: '1x',
}),
{
color: rgba(colors.midnightBlue, 0.14),
borderWidth: '1px 0 0',
},
])
export const licenceText = sprinkles({
textAlign: 'center',
width: 'parent',
fontSize: 'xs',
})
export const latestPostsClass = style({})
globalStyle(`${siteFooterClass} a`, {
color: vars.color.menuLink,
})
globalStyle(`${headerClass} a:link, ${headerClass} a:visited`, {
color: vars.color.menuLink,
})
globalStyle(`${siteFooterClass} a:hover`, {
color: vars.color.menuLinkHover,
})
globalStyle(`${latestPostsClass} li a:visited:not(:hover)`, {
color: vars.color.linkVisited,
})

View File

@ -0,0 +1,199 @@
<script lang="ts">
import { format } from 'date-fns'
import type { ArticlePreviewAttributes } from '$lib/articleContent/articleContentListing'
import SvgIcon from './SvgIcon.svelte'
import {
boldClass,
bottomLineClass,
dateClass,
headerClass,
hrClass,
latestPostsClass,
listLiClass,
listUlClass,
nestedListLiClass,
sectionListsClass,
sectionListSectionClass,
siteFooterClass,
socialLinkClass,
socialLinkLabelClass,
strokeSvgClass,
svgClass,
licenceText,
} from './Footer.css'
export let latestPosts: ArticlePreviewAttributes[]
</script>
<footer class="site-footer navigation-theme {siteFooterClass}">
<div class="lists {sectionListsClass}">
<section class="site-map {sectionListSectionClass}">
<ul class={listUlClass}>
<li class={listLiClass}>
<a href="/">Introduction</a>
</li>
<li class={listLiClass}>
<a href="/portfolio">Portfolio</a>
<ul class={listUlClass}>
<li class={nestedListLiClass}>
<a href="/portfolio#personal-information">About</a>
</li>
<li class={nestedListLiClass}>
<a href="/portfolio#skills">Skills</a>
</li>
<li class={nestedListLiClass}>
<a href="/portfolio#work-history">Work History</a>
</li>
<li class={nestedListLiClass}>
<a href="/portfolio#projects">Projects</a>
</li>
<li class={nestedListLiClass}>
<a href="/portfolio#education">Education</a>
</li>
</ul>
</li>
</ul>
</section>
<section class="latest-posts {sectionListSectionClass} {latestPostsClass}">
<h3 class={headerClass}>
<a href="/blog">Latest posts</a>
</h3>
<ul class={listUlClass}>
{#each latestPosts as post}
<li class={listLiClass}>
<a rel="prefetch" href="/{post.segments[0]}/{post.slug}">
<span>{post.title}</span>
<time class="date {dateClass}" datetime={post.date}>
- {format(new Date(post.date), 'do MMM, yyyy')}
</time>
</a>
</li>
{/each}
</ul>
<hr class={hrClass} />
<section class="subscribe {boldClass}">
<a href="/feed.xml" rel="external" title="RSS feed" class="rss">
Subscribe
<SvgIcon name="rss" className={svgClass} />
</a>
<a
href="/feed.json"
rel="external"
title="JSON feed"
class="json-feed"
aria-label="Subscribe with JSON feed"
>
<SvgIcon name="json-feed" className={svgClass} />
</a>
</section>
</section>
<section class="socials {sectionListSectionClass}">
<h3 class={headerClass}>Contact</h3>
<ul class="social-links {listUlClass}">
<li class="email {listLiClass}">
<a
class={socialLinkClass}
href="mailto: michalvankosk@gmail.com"
title="E-mail address"
>
<SvgIcon name="mail" className={svgClass} />
<span class={socialLinkLabelClass}>michalvankosk@gmail.com</span>
</a>
</li>
<li class="twitter {listLiClass}">
<a
class={socialLinkClass}
href="https://twitter.com/michalvankodev"
title="Twitter profile"
>
<SvgIcon name="twitter" className={strokeSvgClass} />
<span class={socialLinkLabelClass}>Twitter</span>
</a>
</li>
<li class="github {listLiClass}">
<a
class={socialLinkClass}
href="https://github.com/michalvankodev"
title="Github profile"
>
<SvgIcon name="github" className={strokeSvgClass} />
<span class={socialLinkLabelClass}>Github</span>
</a>
</li>
<li class="linkedin {listLiClass}">
<a
class={socialLinkClass}
href="https://www.linkedin.com/in/michal-vanko-dev/"
title="LinkedIn profile"
>
<SvgIcon name="linkedin" className={svgClass} />
<span class={socialLinkLabelClass}>LinkedIn</span>
</a>
</li>
<li class="twitch {listLiClass}">
<a
class={socialLinkClass}
href="https://twitch.tv/michalvankodev"
title="Twitch profile"
>
<SvgIcon name="twitch" className={svgClass} />
<span class={socialLinkLabelClass}>Twitch</span>
</a>
</li>
<li class="instagram {listLiClass}">
<a
class={socialLinkClass}
href="https://www.instagram.com/michalvankodev/"
title="Instagram profile"
>
<SvgIcon name="instagram" className={svgClass} />
<span class={socialLinkLabelClass}>Instagram</span>
</a>
</li>
</ul>
</section>
</div>
<footer class={bottomLineClass}>
<p
class={licenceText}
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dct="http://purl.org/dc/terms/"
>
<a
property="dct:title"
rel="cc:attributionURL"
href="https://michalvanko.dev/">michalvanko.dev</a
>
by
<a
rel="cc:attributionURL dct:creator"
property="cc:attributionName"
href="https://michalvanko.dev/">Michal Vanko</a
>
is licensed under
<a
href="http://creativecommons.org/licenses/by-nc-nd/4.0/?ref=chooser-v1"
target="_blank"
rel="license noopener noreferrer"
style="display:inline-block;"
>CC BY-NC-ND 4.0<img
style="height:22px!important;margin-left:3px;vertical-align:text-bottom;"
src="https://mirrors.creativecommons.org/presskit/icons/cc.svg?ref=chooser-v1"
alt="cc"
/><img
style="height:22px!important;margin-left:3px;vertical-align:text-bottom;"
src="https://mirrors.creativecommons.org/presskit/icons/by.svg?ref=chooser-v1"
alt="by"
/><img
style="height:22px!important;margin-left:3px;vertical-align:text-bottom;"
src="https://mirrors.creativecommons.org/presskit/icons/nc.svg?ref=chooser-v1"
alt="nc"
/><img
style="height:22px!important;margin-left:3px;vertical-align:text-bottom;"
src="https://mirrors.creativecommons.org/presskit/icons/nd.svg?ref=chooser-v1"
alt="nd"
/></a
>
</p>
</footer>
</footer>

View File

@ -0,0 +1,85 @@
import { globalStyle, style } from '@vanilla-extract/css'
import { radialGradient, transparentize } from 'polished'
import { menuBackground, transparent, vars } from '$lib/styles/vars.css'
import { sprinkles } from '$lib/styles/sprinkles.css'
export const navigationClass = style([
sprinkles({
paddingTop: '1x',
paddingBottom: '2x',
paddingX: '1x',
color: 'menu',
textShadow: 'menuLinkShadow',
}),
radialGradient({
colorStops: [
`${menuBackground} 0%`,
`${transparentize(1, menuBackground)} 100%`,
],
extent: '120% 100% at 0% 0%',
fallback: transparent,
}),
])
export const navigationContentClass = sprinkles({
display: 'flex',
maxWidth: 'max',
marginY: 'none',
marginX: 'auto',
})
export const navigationLinksClass = sprinkles({
listStyle: 'none',
margin: 'none',
padding: 'none',
display: 'flex',
flex: '1',
flexWrap: 'wrap',
})
export const logoSectionClass = sprinkles({
lineHeight: 'none',
})
export const logoLinkClass = sprinkles({
padding: 'none',
display: 'block',
})
globalStyle(`${navigationClass} a:not(${logoLinkClass})`, {
color: vars.color.menuLink,
padding: vars.space['1x'],
})
globalStyle(`${navigationClass} a:hover`, {
color: vars.color.menuLinkHover,
})
export const logoImgClass = style({
height: vars.space['3x'],
})
export const selectedClass = sprinkles({
textShadow: 'menuActiveLinkShadow',
})
export const portfolioPageNavigation = style({
position: 'sticky',
top: '0px',
zIndex: 1,
width: '100%',
fontSize: vars.fontSize.sm,
padding: vars.space['1x'],
background: vars.color.background,
boxShadow: `0px 0.5em 0.5em ${vars.color.background}`,
})
export const portfolioPageNavigationLinksClass = sprinkles({
maxWidth: 'l',
marginX: 'auto',
marginY: 'none',
})
export const portfolioPageNavigationLinkClass = sprinkles({
padding: '1x',
})

View File

@ -0,0 +1,94 @@
<script>
import classNames from 'classnames'
import {
logoImgClass,
logoLinkClass,
logoSectionClass,
navigationClass,
navigationContentClass,
navigationLinksClass,
portfolioPageNavigation,
portfolioPageNavigationLinkClass,
portfolioPageNavigationLinksClass,
selectedClass,
} from './Nav.css'
import { page } from '$app/stores'
$: segment = $page.url.pathname
let links = [
{
label: 'Introduction',
url: '/',
},
{
label: 'Blog',
url: '/blog',
},
{
label: 'Broadcasts',
url: '/broadcasts',
},
// {
// label: "Dev's Cookery",
// url: '/cookery',
// },
{
label: 'Portfolio',
url: '/portfolio',
},
]
</script>
<nav class={navigationClass}>
<section class={navigationContentClass}>
<ul class={navigationLinksClass}>
{#each links as link}
<li>
<a
rel="prefetch"
class={classNames({ [selectedClass]: segment === link.url })}
href={link.url}
>
{link.label}
</a>
</li>
{/each}
</ul>
<aside class="logo-section {logoSectionClass}">
<a class="logo {logoLinkClass}" href=".">
<img
class={logoImgClass}
src="/m-logo.svg"
alt="m logo"
width="44px"
height="44px"
/>
</a>
</aside>
</section>
</nav>
{#if segment === '/portfolio'}
<section class="page-navigation {portfolioPageNavigation}">
<div class={portfolioPageNavigationLinksClass}>
<a
class={portfolioPageNavigationLinkClass}
href="/portfolio#personal-information">About</a
>
<a class={portfolioPageNavigationLinkClass} href="/portfolio#skills"
>Skills</a
>
<a class={portfolioPageNavigationLinkClass} href="/portfolio#work-history"
>Work History</a
>
<a class={portfolioPageNavigationLinkClass} href="/portfolio#projects"
>Projects</a
>
<a class={portfolioPageNavigationLinkClass} href="/portfolio#education"
>Education</a
>
</div>
</section>
{/if}

View File

@ -0,0 +1,9 @@
<script lang="ts">
import svgSprite from '../../svg/build/icons-sprite.svg'
export let className: string
export let name: string
</script>
<svg aria-hidden="true" class={className}>
<use xlink:href={`${svgSprite}#${name}`} />
</svg>

View File

@ -0,0 +1,18 @@
<script lang="ts">
interface ArticleDetails {
title: string
slug: string
preview: string
}
export let segment: string
export let article: ArticleDetails
</script>
<article>
<header>
<h2>
<a rel="prefetch" href={`/${segment}/${article.slug}`}>{article.title}</a>
</h2>
</header>
{@html article.preview}
</article>

View File

@ -0,0 +1,30 @@
import { sprinkles } from '$lib/styles/sprinkles.css'
export const tagsListClass = sprinkles({
listStyle: 'none',
margin: 'none',
padding: 'none',
display: 'inline',
})
export const tagsListLiClass = sprinkles({
display: 'inline',
fontStyle: 'italic',
})
export const publishedClass = sprinkles({
whiteSpace: 'nowrap',
fontStyle: 'italic',
})
export const publishedLabelClass = sprinkles({
color: 'tintedText',
})
export const footerClass = sprinkles({
display: 'flex',
fontSize: 'sm',
justifyContent: 'space-between',
paddingTop: '1x',
marginTop: '2x',
})

View File

@ -0,0 +1,37 @@
<script lang="ts">
import { horizontalBorderTopClass } from '$lib/styles/scoops.css'
import { format } from 'date-fns'
import type { ArticleContent } from '$lib/content/articleContentListing'
import {
footerClass,
publishedClass,
publishedLabelClass,
tagsListClass,
tagsListLiClass,
} from './ArticlePreviewFooter.css'
export let segment: string
export let article: ArticleContent
</script>
<footer class="{footerClass} {horizontalBorderTopClass}">
<div class="article-tags">
{#if article.tags.length > 0}
<span class="lighten">Tags:</span>
<ul class={tagsListClass}>
{#each article.tags as tag}
<li class={tagsListLiClass}>
<a href="/{segment}/tags/{tag}">{tag}</a>
</li>
{/each}
</ul>
{/if}
</div>
<div class="created-at">
<span class={publishedLabelClass}>Published on</span>
<time datetime={article.date} class={publishedClass}>
{format(new Date(article.date), "do MMMM',' y")}
</time>
</div>
</footer>

View File

@ -0,0 +1,20 @@
import { globalStyle } from '@vanilla-extract/css'
import { vars } from '$lib/styles/vars.css'
import { sprinkles } from '$lib/styles/sprinkles.css'
export const postListClass = sprinkles({
padding: 'none',
lineHeight: '3x',
listStyle: 'none',
})
export const seeAllClass = sprinkles({
textAlign: 'end',
width: 'parent',
maxWidth: 'max',
margin: 'auto',
})
globalStyle(`${postListClass} > li:not(:last-child)`, {
marginBottom: vars.space['4x'],
})

View File

@ -0,0 +1,41 @@
<script lang="ts">
import ArticleFooter from '$lib/components/articles/ArticlePreviewFooter/ArticlePreviewFooter.svelte'
import Paginator from '$lib/components/paginator/Paginator.svelte'
import { postListClass } from './ArticlePreviewList.css'
import ArticlePreviewCard from '$lib/components/articles/ArticlePreviewCard/ArticlePreviewCard.svelte'
import type { PaginationResult } from '$lib/pagination/pagination'
import type { ArticleContent } from '$lib/content/articleContentListing'
export let page: number
export let pageSize: number
export let filters: Record<string, string>
export let posts: PaginationResult<ArticleContent>
export let segment: string
</script>
<header>
<Paginator
{segment}
{page}
{pageSize}
{filters}
totalCount={posts.totalCount}
/>
</header>
<ul class="post-list {postListClass}">
{#each posts.items as article (article.slug)}
<li>
<ArticlePreviewCard {article} {segment} />
<ArticleFooter {article} {segment} />
</li>
{/each}
</ul>
<footer>
<Paginator
{segment}
{page}
{pageSize}
{filters}
totalCount={posts.totalCount}
/>
</footer>

View File

@ -0,0 +1,21 @@
import { sprinkles } from '$lib/styles/sprinkles.css'
export const listClass = sprinkles({
listStyle: 'none',
display: 'flex',
justifyContent: 'center',
})
export const listItemClass = sprinkles({
paddingX: '1x',
})
export const activePage = sprinkles({
//fontStyle: 'italic',
fontWeight: 'bold',
paddingX: '2x',
})
export const pageLinkClass = sprinkles({
paddingX: '1x',
})

View File

@ -0,0 +1,50 @@
<script lang="ts">
import {
activePage,
listClass,
listItemClass,
pageLinkClass,
} from './Paginator.css'
import { getPaginatorPages, createHref } from './paginatorUtils'
export const Divider = 'divider'
export let segment: string
export let page: number
export let pageSize: number
export let totalCount: number
export let filters: Record<string, string>
$: paginatorPages = getPaginatorPages({ page, pageSize, totalCount })
</script>
<ul class={listClass}>
{#if page !== 1}
<li class="{listItemClass} ">
<a class={pageLinkClass} href={createHref(segment, filters, page - 1)}
>&lt;</a
>
</li>
{/if}
{#each paginatorPages as pageNumber}
{#if pageNumber === Divider}
<li class={listItemClass}>...</li>
{:else if page === pageNumber}
<li class="{listItemClass} {activePage}">{pageNumber}</li>
{:else}
<li class="{listItemClass} ">
<a class={pageLinkClass} href={createHref(segment, filters, pageNumber)}
>{pageNumber}</a
>
</li>
{/if}
{/each}
{#if page !== paginatorPages.length}
<li class="{listItemClass} ">
<a class={pageLinkClass} href={createHref(segment, filters, page + 1)}
>&gt;</a
>
</li>
{/if}
</ul>

View File

@ -0,0 +1,52 @@
import { describe, expect, test } from 'vitest'
import { Divider, getPaginatorPages } from './paginatorUtils'
describe('Paginator component', () => {
describe('Paginator generates feasable pages to display', () => {
test('Page: 1/5', () => {
expect(
getPaginatorPages({ page: 1, totalCount: 5, pageSize: 1 })
).toEqual([1, 2, 3, 4, 5])
})
test('Page: 4/7', () => {
expect(
getPaginatorPages({ page: 4, totalCount: 7, pageSize: 1 })
).toEqual([1, 2, 3, 4, 5, 6, 7])
})
test('Page: 4/8', () => {
expect(
getPaginatorPages({ page: 4, totalCount: 8, pageSize: 1 })
).toEqual([1, 2, 3, 4, 5, 6, Divider, 8])
})
test('Page: 1/10', () => {
expect(
getPaginatorPages({ page: 1, totalCount: 10, pageSize: 1 })
).toEqual([1, 2, 3, 4, 5, 6, Divider, 10])
})
test('Page: 2/10', () => {
expect(
getPaginatorPages({ page: 2, totalCount: 10, pageSize: 1 })
).toEqual([1, 2, 3, 4, 5, 6, Divider, 10])
})
test('Page: 5/10', () => {
expect(
getPaginatorPages({ page: 5, totalCount: 10, pageSize: 1 })
).toEqual([1, Divider, 3, 4, 5, 6, 7, Divider, 10])
})
test('Page: 7/10', () => {
expect(
getPaginatorPages({ page: 7, totalCount: 10, pageSize: 1 })
).toEqual([1, Divider, 5, 6, 7, 8, 9, 10])
})
test('Page: 8/10', () => {
expect(
getPaginatorPages({ page: 8, totalCount: 10, pageSize: 1 })
).toEqual([1, Divider, 5, 6, 7, 8, 9, 10])
})
test('Page: 10/10', () => {
expect(
getPaginatorPages({ page: 10, totalCount: 10, pageSize: 1 })
).toEqual([1, Divider, 5, 6, 7, 8, 9, 10])
})
})
})

View File

@ -0,0 +1,51 @@
import { toParams } from '$lib/pagination/dropTakeParams'
import { last, range } from 'ramda'
export const Divider = 'divider'
export function getPaginatorPages({
page,
pageSize,
totalCount,
}: {
page: number
pageSize: number
totalCount: number
}): (number | typeof Divider)[] {
const maxLinksLength = 7
const linksAroundActive = 2
const totalPages = Math.ceil(totalCount / pageSize)
const shownPages = range(1, totalPages + 1).reduce<
(number | typeof Divider)[]
>((acc, link) => {
const isFirst = link === 1
const isLast = link === totalPages
const isPageOnStart = page <= 3 && link < maxLinksLength
const isPageOnEnd =
page >= totalPages - 3 && link > totalPages - maxLinksLength + 1
if ([isFirst, isLast, isPageOnStart, isPageOnEnd].some((value) => value)) {
return [...acc, link]
}
if (link < page - linksAroundActive || link > page + linksAroundActive) {
if (last(acc) === Divider) {
return acc
}
return [...acc, Divider]
}
return [...acc, link]
}, [])
return shownPages
}
export function createHref(
href: string,
filters: Record<string, string>,
pageNumber: number
) {
const filtersPath = toParams(filters)
return `/${href}/${filtersPath ? filtersPath + '/' : ''}page/${pageNumber}`
}

42
src/lib/large-media.ts Normal file
View File

@ -0,0 +1,42 @@
import { map, multiply } from 'ramda'
export interface ImageOptions {
width?: number
height?: number
}
/**
* Get the URL for resource with specified parameters for Netlify Large Media trasformation
*
* @see https://docs.netlify.com/large-media/transform-images/
*/
export function getNFResize(href: string, { width, height }: ImageOptions) {
return `${href}?nf_resize=fit${height ? `&h=${height}` : ''}${
width ? `&w=${width}` : ''
}`
}
export const PIXEL_DENSITIES = [1, 1.5, 2, 3, 4]
function multiplyImageOptions(
multiplier,
imageOptions: ImageOptions
): ImageOptions {
return map(multiply(multiplier), imageOptions)
}
/**
* Generate `srcset` attribute for all `PIXEL_DENSITIES` to serve images in appropriate quality
* for each device with specific density
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/srcset
*/
export function generateSrcSet(href: string, imageOptions: ImageOptions) {
return PIXEL_DENSITIES.map(
(density) =>
`${getNFResize(
href,
multiplyImageOptions(density, imageOptions)
)} ${density}x`
).join(',')
}

View File

@ -0,0 +1,11 @@
import marked from 'marked'
import { renderer } from './renderer-extension'
marked.use({ renderer })
export function parseField<T>(field: keyof T) {
return (item: T): T => ({
...item,
[field]: marked(item[field]),
})
}

Some files were not shown because too many files have changed in this diff Show More