Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fcf0bd45d | |||
| ff5dd27618 | |||
| 5768df96e5 | |||
| b925da1b97 | |||
| 8a0d8632bb | |||
| c396a9e3ff | |||
| 4c84b84f1b | |||
| 198996d763 | |||
| 8c9c008c77 | |||
| 2cbe8b82f4 | |||
| 36bd8e429c | |||
| 6581ab1da7 | |||
| d360c65e4f | |||
| d448e0beb9 | |||
| 362fe390a8 | |||
| d55eae4b6d | |||
| bb45b29bd0 | |||
| bd77a96000 | |||
| f32e2a28e2 | |||
| 0124adfc3a | |||
| e4fb9e030c | |||
| 15af020e94 | |||
| b1bc1f4701 | |||
| 8e3e8952a6 | |||
| b381ef1335 | |||
| db2ff53ed5 | |||
| 8e67deb0d7 | |||
| 2e04a19cf3 | |||
| 6a5a9c890f | |||
| 5ba193cd56 | |||
| 95b105762d | |||
| 38e26ebf1f | |||
| fa9b104f60 | |||
| b47e8e18d0 | |||
| 546bf4400d | |||
| 134159f79c | |||
| 489156fe87 | |||
| 83a24557bd | |||
| facb304a52 | |||
| 1e8f48b6fe | |||
| 1473b676f4 | |||
| 74f875460a | |||
| cd639a830b | |||
| cde3faa3c6 | |||
| e8f9ecc241 | |||
| 1a059a005a | |||
| ff087b0577 | |||
| 13820f58eb | |||
| f7eb6cc95d | |||
| fec60900f5 | |||
| 96ead1a38f | |||
| 0509565c2b | |||
| 0b3da60cad | |||
| 0228429ad0 | |||
| 39596cb39c | |||
| 55c8f29b24 | |||
| 289763baec | |||
| 5338dcd0ba | |||
| 921b2a493f | |||
| b0e49a904a | |||
| 355a4c4d04 | |||
| e44b4a099c | |||
| e1384d1812 | |||
| 2ec4ed6c4e | |||
| 39b976b182 | |||
| 34b67d9ac1 | |||
| 2ff39c6c1d | |||
| 9dca2bfac1 | |||
| 8341320b23 | |||
| 85e86b28bb | |||
| a2fdec755b | |||
| 086ec33d7b | |||
| d9d17bb971 | |||
| 11cc9f6d0a | |||
| fb6ca6c245 | |||
| 2e47c91985 | |||
| 08050baf98 | |||
| e04e4f6491 | |||
| 2a043ae823 | |||
| ceb3f4b89d | |||
| 2979e21285 | |||
| 4f09373df3 | |||
| 8c72e7b440 | |||
| 49f830cd44 | |||
| da78b80587 | |||
| 1ce8ccfdd5 | |||
| f42ebc7044 |
@@ -1,27 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -13,9 +13,9 @@ jobs:
|
|||||||
name: release
|
name: release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: node:18
|
image: node:24
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- name: cache
|
- name: cache
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
working-directory: axum_server
|
working-directory: axum_server
|
||||||
- name: Server log
|
- name: Server log
|
||||||
run: cat axum_server/server.log
|
run: cat axum_server/server.log
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
path: axum_server/dist/
|
path: axum_server/dist/
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ jobs:
|
|||||||
name: cargo test
|
name: cargo test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
- run: cd axum_server && cargo test --all-features
|
- run: cd axum_server && cargo test --all-features
|
||||||
|
|||||||
+23
-20
@@ -1,26 +1,29 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
/node_modules/
|
|
||||||
/src/node_modules/@sapper/
|
|
||||||
yarn-error.log
|
|
||||||
/cypress/screenshots/
|
|
||||||
/__sapper__/
|
|
||||||
|
|
||||||
/.svelte-kit
|
# `dist` folder with the export of SSG
|
||||||
/.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/
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
.aider*
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
description: Create a new blog post with today's date and proper front matter. Takes a title/topic as argument.
|
||||||
|
---
|
||||||
|
Create a new blog post file for this project.
|
||||||
|
|
||||||
|
**Input:** $@ - The title/topic for the blog post
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
1. Generate a URL-friendly slug from the title:
|
||||||
|
- Lowercase
|
||||||
|
- Replace spaces and special characters with hyphens
|
||||||
|
- Remove consecutive hyphens
|
||||||
|
- Strip leading/trailing hyphens
|
||||||
|
|
||||||
|
2. Create the file at `_posts/blog/YYYY-MM-DD-{slug}.md` where YYYY-MM-DD is today's date
|
||||||
|
|
||||||
|
3. Use this front matter template:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
layout: blog
|
||||||
|
title: {Original Title}
|
||||||
|
segments:
|
||||||
|
- blog
|
||||||
|
published: false
|
||||||
|
date: {YYYY-MM-DD}T12:00:00.000Z
|
||||||
|
thumbnail:
|
||||||
|
tags:
|
||||||
|
- News
|
||||||
|
---
|
||||||
|
|
||||||
|
## {First section heading}
|
||||||
|
|
||||||
|
{Leave space for content}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Important:** The date in the filename (YYYY-MM-DD) MUST match the date in the front matter's `date` field
|
||||||
|
|
||||||
|
5. Confirm the file was created with the full path
|
||||||
|
|
||||||
|
## Available Segments
|
||||||
|
- `blog` (default)
|
||||||
|
- `featured`
|
||||||
|
- `broadcasts`
|
||||||
|
- `cookbook`
|
||||||
|
|
||||||
|
## Common Tags
|
||||||
|
- News
|
||||||
|
- Development
|
||||||
|
- Programming
|
||||||
|
- Rust
|
||||||
|
- JavaScript
|
||||||
|
- TypeScript
|
||||||
|
- Keyboards
|
||||||
|
- Tutorial
|
||||||
|
- Personal
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
name: review-article
|
||||||
|
description: Review blog posts for authenticity, structure, clarity, and improvements while preserving the author's unique voice. Provides suggestions only - no edits.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Article Review Assistant
|
||||||
|
|
||||||
|
You are a thoughtful editorial reviewer who helps the author improve their blog posts while preserving their authentic voice and personal style.
|
||||||
|
|
||||||
|
## Author's Voice Profile
|
||||||
|
|
||||||
|
Based on analysis of previous writing, the author's style includes:
|
||||||
|
|
||||||
|
- **Personal & conversational tone** - Uses first person ("I", "I've"), shares personal experiences and stories
|
||||||
|
- **Authentic storytelling** - Often starts with personal context before diving into technical content
|
||||||
|
- **Strategic emphasis** - Uses **bold** for key concepts and _italics_ for subtle emphasis
|
||||||
|
- **Practical examples** - Includes code snippets, real-world scenarios, actionable takeaways
|
||||||
|
- **Honest vulnerability** - Shares struggles, mistakes, and learning moments openly
|
||||||
|
- **Structured flow** - Uses headers, lists, blockquotes, and code blocks effectively
|
||||||
|
- **Conversational closings** - Often ends with summaries, personal reflections, or invites reader engagement
|
||||||
|
|
||||||
|
## Review Process
|
||||||
|
|
||||||
|
**Input:** Path to a markdown blog post file.
|
||||||
|
|
||||||
|
1. **Read the article** - Understand the full content, message, and intent
|
||||||
|
2. **Analyze against voice profile** - Check for authenticity and consistency
|
||||||
|
3. **Identify improvement opportunities** - Structure, clarity, emphasis, spelling
|
||||||
|
4. **Provide actionable suggestions** - Specific, not vague
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Review Output Format
|
||||||
|
|
||||||
|
### 📋 Article Summary
|
||||||
|
Brief 1-2 sentence summary of what the article is about and its main message.
|
||||||
|
|
||||||
|
### ✅ Strengths
|
||||||
|
What's working well in this article (authentic moments, great explanations, effective structure).
|
||||||
|
|
||||||
|
### 🎯 Topic & Story Fit
|
||||||
|
- Does the topic align with the author's typical subjects (development, personal projects, tutorials, tech insights)?
|
||||||
|
- Does the narrative flow naturally?
|
||||||
|
- Any disconnects between the opening story and the main content?
|
||||||
|
|
||||||
|
### 📝 Structure Suggestions
|
||||||
|
Specific recommendations for:
|
||||||
|
- Header hierarchy and organization
|
||||||
|
- Paragraph flow and transitions
|
||||||
|
- Section ordering or grouping
|
||||||
|
- Opening hook effectiveness
|
||||||
|
- Closing impact
|
||||||
|
|
||||||
|
### 💡 Emphasis Opportunities
|
||||||
|
Suggest specific places where **bold** or _italics_ could strengthen the message:
|
||||||
|
- Key concepts that deserve highlighting
|
||||||
|
- Important takeaways readers should remember
|
||||||
|
- Subtle points that could use gentle emphasis
|
||||||
|
|
||||||
|
### 🔍 Spelling & Grammar
|
||||||
|
List any typos, grammatical issues, or awkward phrasings found (quote the exact text).
|
||||||
|
|
||||||
|
### 🎨 Clarity Improvements
|
||||||
|
- Sentences or paragraphs that could be clearer
|
||||||
|
- Technical terms that need brief explanation
|
||||||
|
- Jargon that might alienate readers
|
||||||
|
- Places where an example would help
|
||||||
|
|
||||||
|
### ⚠️ Authenticity Check
|
||||||
|
- Any moments that feel inauthentic or forced?
|
||||||
|
- Does the voice stay consistent throughout?
|
||||||
|
- Are there places where the author's personality could shine through more?
|
||||||
|
|
||||||
|
### 📌 Suggested Improvements (Without Changing Core Concept)
|
||||||
|
Numbered list of specific, actionable suggestions that:
|
||||||
|
- Enhance clarity without altering meaning
|
||||||
|
- Strengthen the narrative without changing the story
|
||||||
|
- Improve flow without losing the personal touch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Important Guidelines
|
||||||
|
|
||||||
|
- **NEVER make edits** - Only provide suggestions
|
||||||
|
- **Preserve the author's voice** - Don't suggest changes that would make it sound generic
|
||||||
|
- **Be specific** - Quote exact text when suggesting changes
|
||||||
|
- **Explain why** - Give reasoning for each suggestion
|
||||||
|
- **Respect the core message** - Never suggest changes that alter the main concept or story
|
||||||
|
- **Celebrate authenticity** - Highlight moments where the author's genuine voice shines
|
||||||
|
- **Consider the audience** - The blog appears to target developers and tech-curious readers
|
||||||
|
|
||||||
|
## What NOT to Suggest
|
||||||
|
|
||||||
|
- Removing personal anecdotes or stories
|
||||||
|
- Making the tone more "professional" or formal
|
||||||
|
- Generic corporate language
|
||||||
|
- Changes that would alter the fundamental message
|
||||||
|
- Rewriting in a different voice
|
||||||
Vendored
@@ -0,0 +1,26 @@
|
|||||||
|
layout {
|
||||||
|
default_tab_template {
|
||||||
|
children
|
||||||
|
pane size=2 borderless=true {
|
||||||
|
plugin location="zellij:status-bar"
|
||||||
|
}
|
||||||
|
pane size=1 borderless=true {
|
||||||
|
plugin location="zellij:tab-bar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pane_template name="just" {
|
||||||
|
command "just"
|
||||||
|
}
|
||||||
|
|
||||||
|
tab split_direction="vertical" focus=true {
|
||||||
|
pane {
|
||||||
|
just { args "server_dev"; }
|
||||||
|
just { args "test"; }
|
||||||
|
}
|
||||||
|
pane {
|
||||||
|
just { args "tailwind"; }
|
||||||
|
just { args "decap_server"; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tab
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Personal website and blog hosted at https://michalvanko.dev. A static site generator (SSG) built with Rust using the Axum web framework. During development, content is served via HTTP with SSR; for production, the site is exported to static HTML files using wget crawling.
|
||||||
|
|
||||||
|
**Technology Stack:**
|
||||||
|
- **Backend:** Rust, Axum web framework, Tokio async runtime
|
||||||
|
- **Templating:** Askama (compile-time template engine)
|
||||||
|
- **Styling:** Tailwind CSS v4
|
||||||
|
- **Content:** Markdown with YAML front matter (gray_matter), parsed with pulldown-cmark
|
||||||
|
- **CMS:** Decap CMS (formerly Netlify CMS) for content management
|
||||||
|
- **Build Tool:** Just (command runner, similar to make)
|
||||||
|
- **Deployment:** Caddy reverse proxy, rsync to remote server
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
├── src/ # Rust source code (~40 files, ~2090 lines)
|
||||||
|
│ ├── main.rs # Entry point, sets up Axum server with static file serving
|
||||||
|
│ ├── router.rs # Route definitions and HTTP layer
|
||||||
|
│ ├── pages/ # Page handlers (index, blog, portfolio, etc.)
|
||||||
|
│ ├── blog_posts/ # Blog post model and metadata
|
||||||
|
│ ├── projects/ # Project model and featured projects
|
||||||
|
│ ├── components/ # Reusable UI components (site_header)
|
||||||
|
│ ├── filters/ # Askama template filters (markdown, date, truncate)
|
||||||
|
│ ├── post_utils/ # Post parsing, listing, segments, tags
|
||||||
|
│ ├── picture_generator/ # Responsive image generation (multiple sizes/formats)
|
||||||
|
│ └── feed.rs # RSS feed generation
|
||||||
|
├── templates/ # Askama HTML templates
|
||||||
|
│ ├── base.html # Base template with head, header, footer
|
||||||
|
│ ├── components/ # Reusable template partials
|
||||||
|
│ ├── sections/ # Page section templates
|
||||||
|
│ └── icons/ # SVG icon sprite
|
||||||
|
├── styles/
|
||||||
|
│ ├── input.css # Tailwind source with custom theme
|
||||||
|
│ └── output.css # Generated CSS (gitignored)
|
||||||
|
├── static/ # Static assets served directly
|
||||||
|
│ ├── images/ # Site images
|
||||||
|
│ ├── fonts/ # Custom web fonts (Baloo2)
|
||||||
|
│ ├── svg/ # SVG icons
|
||||||
|
│ └── resources/ # Decap CMS config
|
||||||
|
├── _posts/blog/ # Blog posts (Markdown with front matter)
|
||||||
|
├── _projects/ # Showcase projects (Markdown with front matter)
|
||||||
|
├── _pages/ # Static pages (portfolio.md)
|
||||||
|
├── generated_images/ # Auto-generated responsive images (gitignored)
|
||||||
|
├── dist/ # SSG output folder (gitignored)
|
||||||
|
└── target/ # Rust build artifacts (gitignored)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Entry Points
|
||||||
|
|
||||||
|
- `src/main.rs` - Server startup, static file routing, livereload (debug only)
|
||||||
|
- `src/router.rs` - All route definitions, maps URLs to page handlers
|
||||||
|
- `src/pages/index.rs` - Homepage, demonstrates async data loading pattern
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
1. **Content Loading:** Markdown files in `_posts/`, `_projects/`, `_pages/` are parsed at runtime
|
||||||
|
2. **Front Matter:** YAML metadata extracted via `gray_matter` crate
|
||||||
|
3. **Template Rendering:** Askama templates receive structs with data
|
||||||
|
4. **Image Generation:** Images auto-generated in multiple sizes on first request
|
||||||
|
5. **SSG Export:** `wget` crawls running server, saves HTML to `dist/`
|
||||||
|
|
||||||
|
## Code Conventions
|
||||||
|
|
||||||
|
### Rust Patterns
|
||||||
|
|
||||||
|
- **Module Organization:** Each domain has a `mod.rs` with submodules
|
||||||
|
- **Async Handlers:** Page handlers use `async fn` returning `Result<impl IntoResponse, StatusCode>`
|
||||||
|
- **Template Structs:** Each page has a corresponding `#[derive(Template)]` struct
|
||||||
|
- **Error Handling:** Uses `anyhow` for errors, `StatusCode` for HTTP responses
|
||||||
|
- **Parallel Loading:** `tokio::try_join!` for concurrent data fetching
|
||||||
|
|
||||||
|
### Template Patterns
|
||||||
|
|
||||||
|
- **Inheritance:** Templates extend `base.html` using `{% block content %}`
|
||||||
|
- **Includes:** Reusable partials via `{% include "component.html" %}`
|
||||||
|
- **Filters:** Custom filters in `src/filters/` (e.g., `{{ content|markdown }}`)
|
||||||
|
- **Configuration:** `askama.toml` sets template directory and whitespace handling
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
|
||||||
|
- **Files:** snake_case for Rust files, kebab-case for templates
|
||||||
|
- **Routes:** kebab-case URLs (`/blog`, `/showcase/m-logo-svg`)
|
||||||
|
- **Front Matter:** snake_case fields in YAML
|
||||||
|
|
||||||
|
### Configuration Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `Cargo.toml` | Rust dependencies, package metadata |
|
||||||
|
| `askama.toml` | Template directory config |
|
||||||
|
| `.prettierrc` | JS/JSON formatting (trailing commas, 2-space, single quotes) |
|
||||||
|
| `.nvmrc` | Node.js version: `lts/*` |
|
||||||
|
| `.npmrc` | npm config: `engine-strict=true` |
|
||||||
|
| `justfile` | Build commands and deployment scripts |
|
||||||
|
| `renovate.json` | Dependency update automation |
|
||||||
|
| `static/resources/config.yml` | Decap CMS configuration |
|
||||||
|
|
||||||
|
## Setup Requirements
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **Rust:** Stable toolchain (uses edition 2021)
|
||||||
|
- **Node.js:** LTS version (for Tailwind CSS, Decap CMS)
|
||||||
|
- **Just:** Command runner (`cargo install just` or system package)
|
||||||
|
- **cargo-watch:** For development hot reload (`cargo install cargo-watch`)
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
- `PORT` - Server port (default: 3080)
|
||||||
|
- `RUST_LOG` - Logging level (default: `axum_server=debug,tower_http=debug`)
|
||||||
|
- `TARGET` - Build target mode (used in `just prod`)
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Run development server (starts all services in parallel)
|
||||||
|
just dev
|
||||||
|
# This runs: server_dev, tailwind watch, decap_server
|
||||||
|
|
||||||
|
# Or run individually:
|
||||||
|
just server_dev # Rust server with hot reload
|
||||||
|
just tailwind # CSS watch mode
|
||||||
|
just decap_server # Local CMS backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just test # Run Rust tests
|
||||||
|
just test_watch # Run tests with watch mode
|
||||||
|
just prod # Run server in release mode
|
||||||
|
just export # Generate static site to dist/
|
||||||
|
just deploy # rsync dist/ to remote server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Files
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
- `Cargo.toml` - Rust dependencies (axum, askama, pulldown-cmark, etc.)
|
||||||
|
- `justfile` - All build/dev/deploy commands
|
||||||
|
- `static/resources/config.yml` - Decap CMS collections and fields
|
||||||
|
- `styles/input.css` - Tailwind theme customization (colors, fonts, spacing)
|
||||||
|
|
||||||
|
### CI/CD
|
||||||
|
|
||||||
|
- `.gitea/workflows/test.yaml` - Runs `cargo test` on push/PR
|
||||||
|
- `.gitea/workflows/release.yaml` - Builds release, runs SSG export, uploads `dist/` artifact
|
||||||
|
|
||||||
|
### Content Structure
|
||||||
|
|
||||||
|
- `_posts/blog/` - Blog posts with YAML front matter (title, segments, published, date, thumbnail, tags)
|
||||||
|
- `_projects/` - Showcase projects (title, displayed, cover_image, link, classification, tags, featured)
|
||||||
|
- `_pages/portfolio.md` - Portfolio page content (work history, education)
|
||||||
|
|
||||||
|
### Key Dependencies
|
||||||
|
|
||||||
|
**Rust:**
|
||||||
|
- `axum` - Web framework
|
||||||
|
- `askama` - Compile-time templates
|
||||||
|
- `pulldown-cmark` - Markdown parsing
|
||||||
|
- `gray_matter` - YAML front matter extraction
|
||||||
|
- `tokio` - Async runtime
|
||||||
|
- `tower-http` - HTTP middleware (tracing, static files)
|
||||||
|
- `tower-livereload` - Development hot reload
|
||||||
|
- `image` - Image processing for responsive images
|
||||||
|
- `syntect` - Syntax highlighting
|
||||||
|
|
||||||
|
**Node.js:**
|
||||||
|
- `tailwindcss` v4 - CSS framework
|
||||||
|
- `@tailwindcss/cli` - Tailwind CLI
|
||||||
|
|
||||||
|
## Notes for Agents
|
||||||
|
|
||||||
|
1. **Template Changes:** Askama templates are compiled into Rust code. After template changes, the project recompiles automatically with `cargo-watch`.
|
||||||
|
|
||||||
|
2. **Adding Routes:** Add handler in `src/pages/`, register in `src/router.rs`, create template in `templates/`.
|
||||||
|
|
||||||
|
3. **Content Model:** All content uses YAML front matter. See `static/resources/config.yml` for field definitions.
|
||||||
|
|
||||||
|
4. **Image Handling:** Images are auto-generated in multiple sizes. The `picture_generator` module creates responsive `<picture>` elements.
|
||||||
|
|
||||||
|
5. **Debug vs Release:** Debug builds include livereload. Release builds are optimized for production.
|
||||||
|
|
||||||
|
6. **SSG Process:** The static site is generated by running the server and crawling with `wget`. All linked content must be discoverable from the homepage.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
: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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
[package]
|
||||||
|
name = "axum_server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
askama = { version = "0.15" }
|
||||||
|
axum = "0.8.0"
|
||||||
|
chrono = { version = "0.4.31", features = ["serde"] }
|
||||||
|
pulldown-cmark = { version = "0.13" }
|
||||||
|
gray_matter = "0.3.0"
|
||||||
|
rss = "2.0.7"
|
||||||
|
serde = "1.0.195"
|
||||||
|
serde_json = "1.0.111"
|
||||||
|
tokio = { version = "1.35.1", features = ["full"] }
|
||||||
|
tower-http = { version = "0.6.0", features = ["trace", "fs"] }
|
||||||
|
tower-livereload = "0.10.0"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
image = "0.25.6"
|
||||||
|
anyhow = "1.0.86"
|
||||||
|
rayon = "1.10.0"
|
||||||
|
syntect = { version = "5.3.0", default-features = false, features = ["default-fancy"] }
|
||||||
|
indoc = "2.0.5"
|
||||||
|
askama_escape = "0.15.0"
|
||||||
|
mime_guess = "2.0.5"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
pretty_assertions = "1"
|
||||||
@@ -1,88 +1,44 @@
|
|||||||
# sapper-template
|
# michalvanko.dev site
|
||||||
|
|
||||||
The default [Sapper](https://github.com/sveltejs/sapper) template, with branches for Rollup and webpack. To clone it and get started:
|
This is the repository for my own site hosted at https://michalvanko.dev
|
||||||
|
|
||||||
```bash
|
Feel free to use and ammend to code to your needs.
|
||||||
# for Rollup
|
Respect the [Creative Commons BY-NC-ND 4.0 License](https://creativecommons.org/licenses/by-nc-nd/4.0/?ref=chooser-v1) for the content of the site.
|
||||||
npx degit "sveltejs/sapper-template#rollup" my-app
|
|
||||||
# for webpack
|
|
||||||
npx degit "sveltejs/sapper-template#webpack" my-app
|
|
||||||
cd my-app
|
|
||||||
npm install # or yarn!
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Open up [localhost:3000](http://localhost:3000) and start clicking around.
|
## Architecture
|
||||||
|
|
||||||
Consult [sapper.svelte.dev](https://sapper.svelte.dev) for help getting started.
|
The site is hosted as a static generated HTML files on the server via [Caddy](https://caddyserver.com/) reverse proxy. There is an [example Caddyfile](./Caddyfile-preview) that can be used for deployment on server.
|
||||||
|
During development the axum web framework serves content as a HTTP server in a classic SSR HTML.
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
## Structure
|
Look at the [justfile](./justfile) for the available commands that are being used for development and deployment.
|
||||||
|
|
||||||
Sapper expects to find two directories in the root of your project — `src` and `static`.
|
Use `just server_dev` or `just dev` for running the server for development purpose.
|
||||||
|
|
||||||
|
### Tools and libraries used for generating the content
|
||||||
|
|
||||||
### src
|
- [Decap CMS](https://decapcms.org/)
|
||||||
|
- [Rust](https://www.rust-lang.org/)
|
||||||
|
- [axum web framework](https://github.com/tokio-rs/axum)
|
||||||
|
- [tailwind](https://tailwindcss.com/)
|
||||||
|
- [wget](https://www.gnu.org/software/wget/) for SSG
|
||||||
|
|
||||||
The [src](src) directory contains the entry points for your app — `client.js`, `server.js` and (optionally) a `service-worker.js` — along with a `template.html` file and a `routes` directory.
|
### Deployment
|
||||||
|
|
||||||
|
Deployment requires these steps:
|
||||||
|
|
||||||
#### src/routes
|
1. Ensure all images are generated
|
||||||
|
1.1 Run server in either `dev` or `production` mode `just prod`
|
||||||
|
1.2 Crawl the site with `just ssg` command to ensure all routes are being hit to indicate that all images have to be generated.
|
||||||
|
1.3 Wait till the server stops generating images. Monitor the CPU load until it drops. Takes few minutes.
|
||||||
|
2. `just export` will start the server in `production` mode and use `wget` to recursively crawl the site. Remember, content has to be linked somewhere on the site to be discovered by `wget`.
|
||||||
|
3. `just deploy` will synchronise the `/dist` folder with the server with `rsync`
|
||||||
|
|
||||||
This is the heart of your Sapper app. There are two kinds of routes — *pages*, and *server routes*.
|
### Image generation
|
||||||
|
|
||||||
**Pages** are Svelte components written in `.svelte` files. When a user first visits the application, they will be served a server-rendered version of the route in question, plus some JavaScript that 'hydrates' the page and initialises a client-side router. From that point forward, navigating to other pages is handled entirely on the client for a fast, app-like feel. (Sapper will preload and cache the code for these subsequent pages, so that navigation is instantaneous.)
|
I want all images to be served to users optimally.
|
||||||
|
All images that are used are generated in several sizes so they are optimized for different displays sizes.
|
||||||
|
Browsers will pick and download the appropriate size.
|
||||||
|
|
||||||
**Server routes** are modules written in `.js` files, that export functions corresponding to HTTP methods. Each function receives Express `request` and `response` objects as arguments, plus a `next` function. This is useful for creating a JSON API, for example.
|
I'd love to link some references for this problem, but I haven't found the exact use case that I was trying to solve.
|
||||||
|
|
||||||
There are three simple rules for naming the files that define your routes:
|
|
||||||
|
|
||||||
* A file called `src/routes/about.svelte` corresponds to the `/about` route. A file called `src/routes/blog/[slug].svelte` corresponds to the `/blog/:slug` route, in which case `params.slug` is available to the route
|
|
||||||
* The file `src/routes/index.svelte` (or `src/routes/index.js`) corresponds to the root of your app. `src/routes/about/index.svelte` is treated the same as `src/routes/about.svelte`.
|
|
||||||
* Files and directories with a leading underscore do *not* create routes. This allows you to colocate helper modules and components with the routes that depend on them — for example you could have a file called `src/routes/_helpers/datetime.js` and it would *not* create a `/_helpers/datetime` route
|
|
||||||
|
|
||||||
|
|
||||||
### static
|
|
||||||
|
|
||||||
The [static](static) directory contains any static assets that should be available. These are served using [sirv](https://github.com/lukeed/sirv).
|
|
||||||
|
|
||||||
In your [service-worker.js](app/service-worker.js) file, you can import these as `files` from the generated manifest...
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { files } from '@sapper/service-worker';
|
|
||||||
```
|
|
||||||
|
|
||||||
...so that you can cache them (though you can choose not to, for example if you don't want to cache very large files).
|
|
||||||
|
|
||||||
|
|
||||||
## Bundler config
|
|
||||||
|
|
||||||
Sapper uses Rollup or webpack to provide code-splitting and dynamic imports, as well as compiling your Svelte components. With webpack, it also provides hot module reloading. As long as you don't do anything daft, you can edit the configuration files to add whatever plugins you'd like.
|
|
||||||
|
|
||||||
|
|
||||||
## Production mode and deployment
|
|
||||||
|
|
||||||
To start a production version of your app, run `npm run build && npm start`. This will disable live reloading, and activate the appropriate bundler plugins.
|
|
||||||
|
|
||||||
You can deploy your application to any environment that supports Node 8 or above. As an example, to deploy to [Now](https://zeit.co/now), run these commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -g now
|
|
||||||
now
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## Using external components
|
|
||||||
|
|
||||||
When using Svelte components installed from npm, such as [@sveltejs/svelte-virtual-list](https://github.com/sveltejs/svelte-virtual-list), Svelte needs the original component source (rather than any precompiled JavaScript that ships with the component). This allows the component to be rendered server-side, and also keeps your client-side app smaller.
|
|
||||||
|
|
||||||
Because of that, it's essential that the bundler doesn't treat the package as an *external dependency*. You can either modify the `external` option under `server` in [rollup.config.js](rollup.config.js) or the `externals` option in [webpack.config.js](webpack.config.js), or simply install the package to `devDependencies` rather than `dependencies`, which will cause it to get bundled (and therefore compiled) with your app:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -D @sveltejs/svelte-virtual-list
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## Bugs and feedback
|
|
||||||
|
|
||||||
Sapper is in early development, and may have the odd rough edge here and there. Please be vocal over on the [Sapper issue tracker](https://github.com/sveltejs/sapper/issues).
|
|
||||||
|
|||||||
+27
-307
@@ -1,25 +1,16 @@
|
|||||||
---
|
---
|
||||||
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.
|
||||||
|
|
||||||
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.
|
I've worked there as a **tech lead and an Engineering manager**.
|
||||||
|
|
||||||
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.
|
||||||
@@ -28,6 +19,7 @@ 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
|
||||||
@@ -44,14 +36,9 @@ work_history:
|
|||||||
zipcode: 040 18
|
zipcode: 040 18
|
||||||
city: Košice
|
city: Košice
|
||||||
country: Slovakia
|
country: Slovakia
|
||||||
- description: >-
|
- description: I worked in *localhost* as a **Senior front end developer**. I was
|
||||||
_localhost.company_ was a small company that had around 40 developers
|
focused on delivering a high-quality product and teaching and helping
|
||||||
while most of them were students. I worked in _localhost_ as a **Senior
|
other colleagues grow in expertise.
|
||||||
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:
|
||||||
@@ -60,15 +47,9 @@ work_history:
|
|||||||
zipcode: 040 01
|
zipcode: 040 01
|
||||||
city: Košice
|
city: Košice
|
||||||
country: Slovakia
|
country: Slovakia
|
||||||
- description: >
|
thumbnail: /images/uploads/LH-symbol-with-borders-WHITE-RED-BGb.png
|
||||||
I worked in _Ness_ as a Senior front-end developer. In _Ness_, I've been
|
- description: I worked in *Ness* as a Senior front-end developer. In *Ness*, I've
|
||||||
able to work for different clients on various projects.
|
been able to work for different clients on various projects for 6 years.
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
@@ -77,15 +58,9 @@ work_history:
|
|||||||
zipcode: 040 11
|
zipcode: 040 11
|
||||||
city: Košice
|
city: Košice
|
||||||
country: Slovakia
|
country: Slovakia
|
||||||
- description: >-
|
thumbnail: /images/uploads/nesskosicelogo.svg
|
||||||
I worked in _eSOLUTIONS_ as a **web developer** part-time while I was
|
- description: I worked for *eSOLUTIONS* as a part-time **web developer** while I
|
||||||
studying in high school and at University.
|
was 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:
|
||||||
@@ -94,285 +69,30 @@ work_history:
|
|||||||
zipcode: 040 01
|
zipcode: 040 01
|
||||||
city: Košice
|
city: Košice
|
||||||
country: Slovakia
|
country: Slovakia
|
||||||
projects:
|
thumbnail: /images/uploads/esolutions_logo.svg
|
||||||
- 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_
|
||||||
|
|
||||||
Dates: 1\. 9\. 2010 - 20\. 6\. 2013
|
Year of 2013
|
||||||
|
|
||||||
Title of qualification awarded: Bachelor (Bc.)
|
Title of qualification: Bachelor (Bc.)
|
||||||
displayed: true
|
displayed: true
|
||||||
name: Technická univerzita Košice
|
name: Technická univerzita Košice
|
||||||
- description: >-
|
thumbnail: /images/uploads/tuke_znak_b_cmyk.png
|
||||||
|
- description: |-
|
||||||
_SPŠ Elektrotechnická, Komenského 22, Košice_
|
_SPŠ Elektrotechnická, Komenského 22, Košice_
|
||||||
|
|
||||||
|
|
||||||
Electrotechnics, Telecommunication
|
Electrotechnics, Telecommunication
|
||||||
|
|
||||||
|
Year of 2010
|
||||||
Dates: 1\. 9\. 2005 - 1\. 7\. 2010
|
Full secondary school education with leaving examination
|
||||||
|
|
||||||
|
|
||||||
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**.
|
||||||
|
|
||||||
## Personal Information
|
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.
|
||||||
|
|
||||||
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 🚗.
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ layout: blog
|
|||||||
title: Error handling with Either<Type>
|
title: Error handling with Either<Type>
|
||||||
segments:
|
segments:
|
||||||
- blog
|
- blog
|
||||||
- featured
|
|
||||||
published: true
|
published: true
|
||||||
date: 2022-02-28T11:30:54.195Z
|
date: 2022-02-28T11:30:54.195Z
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
---
|
---
|
||||||
segments:
|
segments:
|
||||||
- blog
|
- blog
|
||||||
- featured
|
|
||||||
notes: ""
|
notes: ""
|
||||||
layout: blog
|
layout: blog
|
||||||
title: "Our attempt at Rusty game jam - Weekly #25-2022"
|
title: "Our attempt at Rusty game jam - Weekly #25-2022"
|
||||||
@@ -28,7 +27,7 @@ This week I've attended a [Rusty game jam #2](https://itch.io/jam/rusty-jam-2).
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
[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
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
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!"*
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
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>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: My first published Cargo crate
|
||||||
|
segments:
|
||||||
|
- blog
|
||||||
|
- featured
|
||||||
|
published: true
|
||||||
|
date: 2024-11-08T17:09:00.000Z
|
||||||
|
thumbnail: /images/uploads/aperture-icon.svg
|
||||||
|
tags:
|
||||||
|
- News
|
||||||
|
- Development
|
||||||
|
- Rust
|
||||||
|
---
|
||||||
|
I've become obsessed with cameras, during our Tenerife trip, where I got to use a borrowed mirrorless camera for the first time. After that, I researched which camera I'd buy for myself for five months. While watching all the reviews, I was interested in the **exposure settings** for each picture presented. I've displayed many photographs in our [Tenerife vlog with my wife](https://www.youtube.com/watch?v=tEpoVHQW4Qs&list=PLjUl8tFKyR8rCsckLn93PAwQg6tf0cyBl). I've decided that I'd like to show those exposure settings in my other videos as they might **inspire other photographers**.
|
||||||
|
|
||||||
|
I've tried many image editors and tools, but not one was able to **fulfill my demands** on how I'd like to display those settings underneath each picture. Many **tools supported** some kind of **framing**, however it was always **in a limited way**.
|
||||||
|
|
||||||
|
## Hello `metaframer`
|
||||||
|
|
||||||
|
I knew straight away that this would be a cool and **small side-project** that I could create. I could use my **favorite technologies** and have good **content for my streams**.
|
||||||
|
|
||||||
|
At first, I thought, that I would have to **render the text** right **into the image**. It will however be in some aspect a **deformation to the quality** of the original image. There was also a question of **how should the text be displayed** in a way that for **any image size**, the text will be of a similar or determinable size.
|
||||||
|
|
||||||
|
Instead of **rendering pixels** into every different format I've opted for creating an **SVG file** that sits **alongside the original image**. This way the text also **scales with the image** itself. The complexity was lowered instantly. Rendering SVG is just like rendering HTML. I've used [handlebars template parser](https://handlebarsjs.com/). There are more favorable template parsers in Rust like [Askama](https://djc.github.io/askama/askama.html). But I wanted to allow users to **create their own templates** so the templates are not part of the executables and they can be stored in the user's `.config` folder. I had to test the **support of my video editing software** (I use [Kdenlive](https://kdenlive.org/) BTW) and the decision for SVG was set in stone (a.k.a. [README](https://github.com/michalvankodev/metaframer)).
|
||||||
|
|
||||||
|
There were still complications along the way. **SVG** is still a much **less capable format than HTML** and **positioning elements** in a `space-around` fashion **has to be calculated**. There are also many different options and resolutions that I had to consider and I put all of them into the CLI arguments with [clap](https://docs.rs/clap/latest/clap/). [Clap](https://docs.rs/clap/latest/clap/) is a fantastic **command line argument parser** for Rust. I had to do many calculations on the resolutions and whether the image was a portrait or not. In the end, It wasn't as small of a side-project as I thought. I'm still very proud of it.
|
||||||
|
|
||||||
|
I got to experience the ease of publishing crates on [crates.io](https://crates.io/) with [cargo](https://doc.rust-lang.org/cargo/guide/).
|
||||||
|
|
||||||
|
This week, I've **automatized the [release process](https://github.com/michalvankodev/metaframer/blob/main/.github/workflows/release.yml)** and published binaries for multiple platforms.
|
||||||
|
|
||||||
|
## On to the next one
|
||||||
|
|
||||||
|
I can't wait to use [`metaframer`](https://github.com/michalvankodev/metaframer) in my new videos. I've already **ordered my first mirrorless camera** and it is on the way. **Be ready for new content**. What should be my next project?
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
title: Auto-commit & push git repositories
|
||||||
|
segments:
|
||||||
|
- blog
|
||||||
|
published: true
|
||||||
|
date: 2024-11-23T15:23:00.000Z
|
||||||
|
thumbnail: /images/uploads/git-icon-logo-svg-vector.svg
|
||||||
|
tags:
|
||||||
|
- News
|
||||||
|
- Development
|
||||||
|
- Rust
|
||||||
|
notes: ""
|
||||||
|
---
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
I've been **writing my notes** for over 4 years in **markdown**. I write my notes in similar style of a [Zettelkasten method](https://zettelkasten.de/introduction/). I was **inspired by [foambubble](https://github.com/foambubble/foam) project** and was using it for quite some time. The goal of foambubble was to re-use as much **free technology** for writing and **keeping second brain**. The author [Jani Eväkallio](https://jevakallio.dev/) has chosen to write **a plugin for VSCode** and **utilize git workflow**.
|
||||||
|
This approach as complicated for average human was very appealing to me as I was already user of certain tools.
|
||||||
|
|
||||||
|
Since then, I stopped using VSCode as my code editor and therefore, as there wasn't support for *foam* outside of VSCode I've **migrated to a simpler workflow** using [zk](https://github.com/zk-org/zk).
|
||||||
|
|
||||||
|
Since then, I have struggled a lot with **forgetting to commit** my changes as I often write updates, mark completed tasks, or write new TODOs throughout the day.
|
||||||
|
I use a computer and a laptop when on the road and it is miserable when you forget to push your changes. I'm still trying to find a perfect workflow for **synchronizing notes with my mobile phone**. I've used many different applications, but they **all failed** me as soon as there was a **git conflict**. I think I have a solution for this thought through.
|
||||||
|
|
||||||
|
A very easy solution would be to run a [script generated with GPT](https://letmegpt.com/?q=I'd%20like%20to%20automatically%20commit%20and%20push%20to%20my%20git%20repository%20after%205%20minutes%20of%20inactivity%20on%20my%20changes.) to **auto-commit**. It has the downsides of always having to remember to start it and it would not be easy to **share and apply to other repositories**. So, I came up with another **Rust crate**.
|
||||||
|
|
||||||
|
## Welcome `git_afk`
|
||||||
|
|
||||||
|
`git_afk` might stand for *get always forgotten knowledge*. It doesn't. I've **chosen Rust** again. As I use it more frequently, my appreciation for it grows. I feel like Rust just makes you manage the state in the place where the state belongs. Working on this project taught me how to keep and share asynchronous callbacks in a way that makes sense but isn't so obvious at first glance. The **compiler is always very helpful**. While in other languages you would need to keep `unsubscribe` handles all over the place to **avoid memory leaks**. Rust makes you think differently about how to structure the code and data around it.
|
||||||
|
|
||||||
|
The application is a **CLI app** that has a basic ability to add repositories to the watch list and then run `watch` command.
|
||||||
|
Anytime there is a change in the configuration file it will reload watchers and start watching all files in the repositories for changes. It uses [`inotify`](https://www.man7.org/linux/man-pages/man7/inotify.7.html) a kernel interface for watching. You can find the [source code on GitHub](https://github.com/michalvankodev/git_afk).
|
||||||
|
|
||||||
|
As the Rust ecosystem is so simple you can install `git_afk` with [`cargo`](https://crates.io/) `cargo install git_afk`. Submitting packages is easy. Installing them as well. **Automatic documentation generation** is awesome. Every package has the same style of documentation. Very easy to navigate. Rust is growing up on me. I'd very much like to use it as my main tool for my job.
|
||||||
|
|
||||||
|
Now I can safely *git away from keyboard*.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
layout: blog
|
||||||
|
title: Somewhere in the sky
|
||||||
|
segments:
|
||||||
|
- blog
|
||||||
|
published: true
|
||||||
|
date: 2025-06-11T15:50:45.527Z
|
||||||
|
thumbnail: /images/uploads/dscf4399.jpg
|
||||||
|
tags:
|
||||||
|
- News
|
||||||
|
- Personal
|
||||||
|
- Travel
|
||||||
|
---
|
||||||
|
## Realization
|
||||||
|
|
||||||
|
What can you do on an airplane when you cannot move as your son is sleeping on your lap? You can **write a new blog post**, "obviously". Why can't I find more time to write more posts? I lack the motivation, to be honest. Now that anyone can just ask an LLM to spit out an original blog post on demand, who will search for original, honest content written by a human?
|
||||||
|
|
||||||
|
I've set expectations for my wife that this year we won't be going on a vacation, and she has overruled and found a one-week trip to Mallorca. I can confirm that we will be **coming back to Mallorca** for further exploration. This week was very nice. I haven't made any commitments on what I'm going to do with my career. I've already **accomplished** that since this year, **I will work on Rust projects**.
|
||||||
|
|
||||||
|
## Drift
|
||||||
|
|
||||||
|
In my latest vlog, I mentioned that I should talk more about burning out, and that I don't feel like I am burning out. Since then, I stopped streaming and stopped producing videos. **Did I jinx myself?** I don't know. Being active on social media is not something for me. I enjoy streaming, but I just had to put my family above my fulfillment. **I enjoy putting my son to sleep** much more. He is growing so fast. I expect that in a few years **we will start streaming together** as he explores the world of computers.
|
||||||
|
|
||||||
|
I expect to find **more time for streaming** when the sun falls in earlier hours.
|
||||||
|
|
||||||
|
You should've seen him. He was able to fill his bucket and pour it into a hole in the sand more than 100 times in a row. Where he finds the energy to do this is unbelievable. He is such an extraordinary individual.
|
||||||
|
|
||||||
|
## Inertia
|
||||||
|
|
||||||
|
*What about the game progress?*
|
||||||
|
|
||||||
|
**The game is in a terrible state**. I realized that the **whole animation system has to be rewritten** as it is unreliable. And that's almost everything that we have been working on. I've started to **write the game design document** so I don't lose my thoughts on mechanics and game setting. I should find more time to code as well, but currently, **I have just enough work and studies to keep me occupied**.
|
||||||
|
|
||||||
|
## Sight
|
||||||
|
|
||||||
|
Here are some photos from the trip:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Some of them are taken with my *Fuji X-Michal* and I have to say I've fallen in love with the machine. I wish some features were more accessible, and I hope with the new Fuji X-E5 announcement that the *Film simulation recipes* will be included in a future firmware upgrade. The current configuration is very misleading and not user-friendly.
|
||||||
|
|
||||||
|
I've also ordered a new Sigma 16-300, hoping it will be delivered before the vacation. I have already **been waiting for it for 2 months**, and still no indication of when it will arrive. I am pretty new to the hobby, and it looks like when ordering a new gear, there is a **guarantee of a long waiting time**, or it is out of stock pretty quickly.
|
||||||
|
|
||||||
|
I'd like to **post more pictures** here on the blog. I'm planning on releasing albums. I don't know if I should also make some changes to the visualization, or if the current blog page rendering system is fine as it is. **Let me know if you have some cool ideas**.
|
||||||
|
|
||||||
|
I'm going to keep it short. We've landed successfully. The vacation was amazing. We are definitely coming back to Mallorca. Expect more content (soon).
|
||||||
|
|
||||||
|
Happy summer.
|
||||||
|
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
layout: blog
|
||||||
|
title: Week with my-pi-agent
|
||||||
|
segments:
|
||||||
|
- blog
|
||||||
|
- featured
|
||||||
|
published: true
|
||||||
|
date: 2026-04-01T12:00:00.000Z
|
||||||
|
thumbnail: /images/uploads/pi-logo.svg
|
||||||
|
tags:
|
||||||
|
- News
|
||||||
|
- AI
|
||||||
|
- Agents
|
||||||
|
- Development
|
||||||
|
---
|
||||||
|
|
||||||
|
Last week I've set a goal for myself to go 100% without writing a single line of code manually. I've been trying agentic workflows for a year now. My first agentic tool I've tried was [aider](https://aider.chat/) and it was pretty cool experience at that time. For the last few months I've been using [OpenCode](https://opencode.ai/) but I haven't been able to let it control 100% of my results. Mainly because it hasn't been able to earn my trust. I've been watching different tools for a long time and decided that it was time to try out something minimal. Something that I can make work for me. Make it work with my workflows and tools I use during work.
|
||||||
|
|
||||||
|
## Meet Pi
|
||||||
|
|
||||||
|
<!--  -->
|
||||||
|
<figure>
|
||||||
|
<img src="/images/uploads/pi-logo.svg" alt="Pi agent logo" class="max-w-[280px] h-[280px] bg-black">
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
[Pi](https://pi.dev) is a **bare bones CLI agent** similar to Claude Code, Codex CLI and OpenCode.
|
||||||
|
Pi itself is very simple. It might be a turnoff for anyone who wants to just download and start using it. You have to spend some time tinkering it.
|
||||||
|
There is a lot of [packages](https://pi.dev/packages), that can bring the functionality of other CLIs to Pi.
|
||||||
|
The community around Pi is very fresh and thriving. We are still in the early adopters era of agentic programming. I'd consider every **package as a potential threat** and **cautiously review** them before installing.
|
||||||
|
|
||||||
|
I discovered Pi when [OpenClaw](https://openclaw.ai/) was released. I was curious about what is powering _OpenClaw_. Back then I wasn't really interested in Pi.
|
||||||
|
But after few weeks with [OpenCode](https://opencode.ai/), I wasn't feeling like the tool is working with my workflow.
|
||||||
|
After trying out Pi I got hooked immediately when I prompted it to generate a custom plugin that is customized to my workflow and environment.
|
||||||
|
I use a nerdish Desktop environment:
|
||||||
|
- [Fedora Linux](https://fedoraproject.org/)
|
||||||
|
- [Noctalia shell](https://noctalia.dev/)
|
||||||
|
- [Niri Window manager](https://niri-wm.github.io/niri/) (special kind of tiling window manager)
|
||||||
|
- [fish](https://fishshell.com/) as a shell
|
||||||
|
- [ZelliJ](https://zellij.dev/) as a terminal multiplexer
|
||||||
|
|
||||||
|
I don't know anyone who uses the same tools I do.
|
||||||
|
|
||||||
|
Other agentic CLIs are like taxi cars, you hop-in, you can tell the driver where you want to go. But it is out of your hands how you get there. You are often restricted in the capabilities of the CLI.
|
||||||
|
The features you leverage right now can be pulled out whenever the author of the CLI consider them not useful. With Pi you can **inspect every single line of code**. Why, and what gets executed and when.
|
||||||
|
With Pi, you build your own ride. Do you need to just get some mail from post office? You build a bicycle. Do you need a team of X "engineers", feel free to burn your tokens however you like - build an airplane.
|
||||||
|
Want to go the to moon? Build yourself a rocketship. There are no limits.
|
||||||
|
|
||||||
|
With no other CLI agent I was ever **motivated to create my own plugins, commands, workflows**. I was always trying to just search for stuff that "just works", but it never worked for me.
|
||||||
|
|
||||||
|
First plugin I've created was that I wanted to have a [`/commit` command](https://github.com/michalvankodev/my-pi-agent/blob/main/agent/extensions/commit.ts).
|
||||||
|
|
||||||
|
- This command should not do `git commit` for me.
|
||||||
|
- It should just generate a commit message and open an editor in a new _ZelliJ_ pane.
|
||||||
|
- I can not only review but edit if I am not satisfied with the output. I can even cancel the commit if I opt to.
|
||||||
|
|
||||||
|
One, two prompts and voilà - I have my first custom plugin. Nothing ground-breaking but it is working just how I like it.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## What works for me
|
||||||
|
|
||||||
|
In my experience a simple approach turned out to have the best results considering getting stuff done, cheaper and reliable.
|
||||||
|
I've tried to set up interactive sub-agents that talk between each other and I was stunned for a moment.
|
||||||
|
After few minutes the output of 4 agents working together was very little and I almost ran out of available tokens.
|
||||||
|
Then I continued with just one main agent and it was able to finish whole task much quicker.
|
||||||
|
The main workflow for me (for backend tasks) is:
|
||||||
|
1. Prompt agent with what are we going to do. "Gather information about existing code". Write a specification file and split work into tasks.
|
||||||
|
We are going to update the specification after each task gets done.
|
||||||
|
2. [`/clarify`](https://github.com/michalvankodev/my-pi-agent/blob/main/agent/prompts/clarify.md) This is the **most powerful prompt** in my workflow. It asks agent to ask clarifying questions. This prevents the agent to invent wheels that already exists or rail off from what we want to accomplish.
|
||||||
|
3. Start working on tasks one by one
|
||||||
|
|
||||||
|
When the context-window gets too big, there is a decision to make. With `/tree` command you can traverse your conversation to a point (after the 2nd step for example) where the agent has enough context about all the tasks that you can continue without starting with fresh context window. Or you can create a `/new` session and prompt it to gather information about the tasks with the link to the spec.
|
||||||
|
|
||||||
|
`/tree` is very useful feature. I use it to fork my current session into multiple new ones and you can parallelize your work (I don't recommend it every time).
|
||||||
|
|
||||||
|
Two other features I use constantly:
|
||||||
|
- Steering - When you see the agent doing something you want to immediately fix, you steer it. Your message
|
||||||
|
arrives as soon as the current tool call finishes.
|
||||||
|
- Follow up - When you want to tell the agent something or request additional work, you follow up. That
|
||||||
|
message gets queued after the agent completes all pending work, instead of waiting for your next prompt.
|
||||||
|
|
||||||
|
There's still more to learn. This is just the experience I've had with Pi during the last week.
|
||||||
|
|
||||||
|
For front-end task I'd choose a different workflow. I'd incorporate `agent-browser` into the loop. Having constant feedback loop is very powerful and allows the agent to run for longer time (do more without constant supervision)
|
||||||
|
|
||||||
|
Another pleasant use experience I had was with **debugging memory issues**. This is a task that if you don't do very often during development, but from time to time it happens that unexpected errors happen due to different resource availability on production systems. I've said to the agent what happens and what I think was the issue (I was way way off).
|
||||||
|
It created multiple testing scenario applications in `/tmp` directory to just test off hypothesis.
|
||||||
|
He dug out every single call and investigated deep hierarchy of code deep down to every single dependency in the chain.
|
||||||
|
We haven't been able to find the issue there. So I've used [`heaptrack`](https://github.com/KDE/heaptrack) to provide actual information about the memory allocations and then I just passed the output to the agent. It just came out with the actual issue and provided fix in few seconds.
|
||||||
|
|
||||||
|
It is able to create `bash` commands and execute them in a loop until it discovers significant information. It does so at such velocity that I have to admit I **can't imagine now working without it**. Once you experience this kind of boost **it's unthinkable to go back**.
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# 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/
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
layout {
|
|
||||||
pane split_direction="vertical" focus=true {
|
|
||||||
pane edit="src/main.rs"
|
|
||||||
pane split_direction="horizontal" size=60 {
|
|
||||||
just { args "server_dev"; }
|
|
||||||
just { args "test"; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pane_template name="just" {
|
|
||||||
command "just"
|
|
||||||
start_suspended true
|
|
||||||
}
|
|
||||||
floating_panes {
|
|
||||||
pane {
|
|
||||||
command "just"
|
|
||||||
args "tailwind"
|
|
||||||
}
|
|
||||||
pane {
|
|
||||||
command "just"
|
|
||||||
args "decap_server"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pane size=2 borderless=true {
|
|
||||||
plugin location="zellij:status-bar"
|
|
||||||
}
|
|
||||||
pane size=1 borderless=true {
|
|
||||||
plugin location="zellij:tab-bar"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "axum_server"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
askama = { version = "0.12", features = ["with-axum", "mime", "mime_guess"] }
|
|
||||||
askama_axum = "0.4.0"
|
|
||||||
axum = "0.7.3"
|
|
||||||
chrono = { version = "0.4.31", features = ["serde"] }
|
|
||||||
pulldown-cmark = { version = "0.10" }
|
|
||||||
gray_matter = "0.2.6"
|
|
||||||
rss = "2.0.7"
|
|
||||||
serde = "1.0.195"
|
|
||||||
serde_json = "1.0.111"
|
|
||||||
tokio = { version = "1.35.1", features = ["full"] }
|
|
||||||
tower-http = { version = "0.5.0", features = ["trace", "fs"] }
|
|
||||||
tower-livereload = "0.9.2"
|
|
||||||
tracing = "0.1"
|
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|
||||||
image = "0.25.2"
|
|
||||||
anyhow = "1.0.86"
|
|
||||||
rayon = "1.10.0"
|
|
||||||
syntect = "5.2.0"
|
|
||||||
indoc = "2.0.5"
|
|
||||||
|
|
||||||
[build]
|
|
||||||
rustflags = ["-Z", "threads=8"]
|
|
||||||
|
|
||||||
# [target.x86_64-unknown-linux-gnu]
|
|
||||||
# rustflags = [
|
|
||||||
# "-C", "link-arg=-fuse-ld=lld"
|
|
||||||
# ]
|
|
||||||
|
|
||||||
[profile.dev]
|
|
||||||
debug = true
|
|
||||||
opt-level = 0
|
|
||||||
# codegen-units = 16
|
|
||||||
# lto = "thin"
|
|
||||||
panic = "unwind"
|
|
||||||
strip = false
|
|
||||||
incremental = true
|
|
||||||
|
|
||||||
[profile.dev.package.askama_derive]
|
|
||||||
opt-level = 3
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
pub mod blog_post_model;
|
|
||||||
pub mod featured_blog_posts;
|
|
||||||
pub mod tag_list;
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
use chrono::{DateTime, Utc};
|
|
||||||
use tracing::debug;
|
|
||||||
|
|
||||||
// This filter does not have extra arguments
|
|
||||||
pub fn pretty_date(date_time: &DateTime<Utc>) -> ::askama::Result<String> {
|
|
||||||
let formatted = format!("{}", date_time.format("%e %B %Y"));
|
|
||||||
Ok(formatted)
|
|
||||||
}
|
|
||||||
|
|
||||||
// This filter does not have extra arguments
|
|
||||||
pub fn description_filter(body: &str) -> ::askama::Result<String> {
|
|
||||||
let description = body
|
|
||||||
.lines()
|
|
||||||
.filter(|line| line.starts_with("<p>"))
|
|
||||||
.take(2)
|
|
||||||
.collect::<Vec<&str>>()
|
|
||||||
.join("\n");
|
|
||||||
debug!(description);
|
|
||||||
Ok(description)
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
use askama::Template;
|
|
||||||
|
|
||||||
#[derive(Template)]
|
|
||||||
#[template(path = "admin.html")]
|
|
||||||
pub struct AdminPageTemplate {}
|
|
||||||
|
|
||||||
pub async fn render_admin() -> AdminPageTemplate {
|
|
||||||
AdminPageTemplate {}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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(),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
pub mod admin;
|
|
||||||
pub mod blog_post_list;
|
|
||||||
pub mod blog_post_page;
|
|
||||||
pub mod contact;
|
|
||||||
pub mod index;
|
|
||||||
pub mod project_list;
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
use std::{fs::create_dir_all, path::Path};
|
|
||||||
|
|
||||||
use image::{imageops::FilterType, DynamicImage};
|
|
||||||
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
|
|
||||||
use tracing::{debug, error};
|
|
||||||
|
|
||||||
use super::export_format::ExportFormat;
|
|
||||||
|
|
||||||
pub fn generate_images(
|
|
||||||
image: &DynamicImage,
|
|
||||||
path_to_generated: &Path,
|
|
||||||
resolutions: &[(u32, u32, f32)],
|
|
||||||
formats: &[ExportFormat],
|
|
||||||
) -> Result<(), anyhow::Error> {
|
|
||||||
formats.par_iter().for_each(|format| {
|
|
||||||
resolutions.par_iter().for_each(|resolution| {
|
|
||||||
let (width, height, _) = *resolution;
|
|
||||||
// let image = image.clone();
|
|
||||||
let resized = image.resize_to_fill(width, height, FilterType::Triangle);
|
|
||||||
let file_name = path_to_generated.file_name().unwrap().to_str().unwrap();
|
|
||||||
let save_path = Path::new("./")
|
|
||||||
.join(path_to_generated.strip_prefix("/").unwrap())
|
|
||||||
.with_file_name(format!("{file_name}_{width}x{height}"))
|
|
||||||
.with_extension(format.get_extension());
|
|
||||||
|
|
||||||
if save_path.exists() {
|
|
||||||
debug!("Skip generating {save_path:?} - Already exists");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let parent_dir = save_path.parent().unwrap();
|
|
||||||
if !parent_dir.exists() {
|
|
||||||
create_dir_all(parent_dir).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = resized.save_with_format(&save_path, format.get_image_format());
|
|
||||||
match result {
|
|
||||||
Err(err) => {
|
|
||||||
error!("Failed to generate {:?} - {:?}", &save_path, err);
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
debug!("Generated image {:?}", &save_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,111 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
content: ["./templates/**/**.html"],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
fontFamily: {
|
|
||||||
sans: [
|
|
||||||
"Baloo2",
|
|
||||||
"Comfortaa",
|
|
||||||
"ui-sans-serif",
|
|
||||||
"system-ui",
|
|
||||||
"sans-serif",
|
|
||||||
"Apple Color Emoji",
|
|
||||||
"Segoe UI Emoji",
|
|
||||||
"Segoe UI Symbol",
|
|
||||||
"Noto Color Emoji",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
spacing: {
|
|
||||||
note: "60rem",
|
|
||||||
read: "64rem",
|
|
||||||
image: "min(70rem, 95vw)",
|
|
||||||
maxindex: "100rem",
|
|
||||||
},
|
|
||||||
width: {
|
|
||||||
note: "60rem",
|
|
||||||
read: "64rem",
|
|
||||||
image: "min(70rem, 95vw)",
|
|
||||||
maxindex: "100rem",
|
|
||||||
},
|
|
||||||
fontSize: {
|
|
||||||
readxl: [
|
|
||||||
"1.75rem",
|
|
||||||
{
|
|
||||||
lineHeight: "2.25rem",
|
|
||||||
letterSpacing: "-0.015em",
|
|
||||||
fontWeight: "400",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
colors: {
|
|
||||||
// blue: {
|
|
||||||
// 50: "#ecf6fe",
|
|
||||||
// 100: "#d9edfc",
|
|
||||||
// 200: "#b3dbf9",
|
|
||||||
// 300: "#8ecaf6",
|
|
||||||
// 400: "#68b8f3",
|
|
||||||
// 500: "#42a6f0",
|
|
||||||
// 600: "#3585c0",
|
|
||||||
// 700: "#286490",
|
|
||||||
// 800: "#1F4E71",
|
|
||||||
// 900: "#173A54",
|
|
||||||
// 950: "#0F2637",
|
|
||||||
// },
|
|
||||||
blue: {
|
|
||||||
50: "#f1f7fe",
|
|
||||||
100: "#e1effd",
|
|
||||||
200: "#bddefa",
|
|
||||||
300: "#82c3f7",
|
|
||||||
400: "#42a6f0",
|
|
||||||
500: "#1789e0",
|
|
||||||
600: "#0a6cbf",
|
|
||||||
700: "#0a569a",
|
|
||||||
800: "#0c4980",
|
|
||||||
900: "#103e6a",
|
|
||||||
950: "#0b2746",
|
|
||||||
},
|
|
||||||
// pink: {
|
|
||||||
// 50: "#FFFBFE",
|
|
||||||
// 100: "#FFE4F9",
|
|
||||||
// 200: "#FECEF4",
|
|
||||||
// 300: "#FEB8EF",
|
|
||||||
// 400: "#fea6eb",
|
|
||||||
// 500: "#D38AC3",
|
|
||||||
// 600: "#B476A7",
|
|
||||||
// 700: "#96628B",
|
|
||||||
// 800: "#774E6E",
|
|
||||||
// 900: "#593A52",
|
|
||||||
// 950: "#3A2636",
|
|
||||||
// },
|
|
||||||
pink: {
|
|
||||||
50: "#fff4fd",
|
|
||||||
100: "#ffe7fb",
|
|
||||||
200: "#ffcff7",
|
|
||||||
300: "#fea6eb",
|
|
||||||
400: "#fc76dd",
|
|
||||||
500: "#f342ca",
|
|
||||||
600: "#d722a9",
|
|
||||||
700: "#b31889",
|
|
||||||
800: "#92166e",
|
|
||||||
900: "#771859",
|
|
||||||
950: "#500238",
|
|
||||||
},
|
|
||||||
purple: {
|
|
||||||
50: "#F8F5FC",
|
|
||||||
100: "#D5C2ED",
|
|
||||||
200: "#B28EDE",
|
|
||||||
300: "#8F5BCF",
|
|
||||||
400: "#6D30B9",
|
|
||||||
500: "#5F2AA2",
|
|
||||||
600: "#52248A",
|
|
||||||
700: "#441E73",
|
|
||||||
800: "#36185C",
|
|
||||||
900: "#281244",
|
|
||||||
950: "#1A0C2D",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"baseUrl": "http://localhost:3000",
|
|
||||||
"video": false
|
|
||||||
}
|
|
||||||
@@ -2,21 +2,18 @@ port := env_var_or_default('PORT', '3080')
|
|||||||
|
|
||||||
# Tailwind in watch mode
|
# Tailwind in watch mode
|
||||||
tailwind:
|
tailwind:
|
||||||
npx tailwindcss -i ./styles/input.css -o ./styles/output.css --watch
|
npx @tailwindcss/cli -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 ../static/svg/icons-sprite.svg ../src/svg/**.svg
|
npx svgstore -o templates/icons/sprite.svg static/svg/input/*.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:
|
||||||
cd .. && npx decap-server
|
npx decap-server
|
||||||
|
|
||||||
# Run dev server in watch mode
|
# Run dev server in watch mode
|
||||||
dev:
|
dev:
|
||||||
@@ -33,7 +30,7 @@ test_watch:
|
|||||||
cargo watch -x test
|
cargo watch -x test
|
||||||
|
|
||||||
# Run server in production mode
|
# Run server in production mode
|
||||||
prod:
|
prod $TARGET="PROD" $RUST_LOG="info":
|
||||||
cargo run --release
|
cargo run --release
|
||||||
|
|
||||||
# Wait for port to listen to connections
|
# Wait for port to listen to connections
|
||||||
@@ -55,11 +52,13 @@ 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 -P dist/svg 127.0.0.1:{{port}}/svg/icons-sprite.svg
|
- wget --no-convert-links --content-on-error -p -E -P dist --no-host-directories 127.0.0.1:{{port}}/not-found
|
||||||
|
- 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:
|
||||||
npx http-server dist
|
caddy run --config Caddyfile-preview
|
||||||
|
|
||||||
# SSG export of production server
|
# SSG export of production server
|
||||||
export: clean
|
export: clean
|
||||||
Generated
-7319
File diff suppressed because it is too large
Load Diff
+2
-52
@@ -1,56 +1,6 @@
|
|||||||
{
|
{
|
||||||
"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": {
|
"dependencies": {
|
||||||
"@vanilla-extract/css": "^1.9.2",
|
"@tailwindcss/cli": "^4.1.10",
|
||||||
"@vanilla-extract/sprinkles": "^1.5.1",
|
"tailwindcss": "^4.1.10"
|
||||||
"@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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:recommended"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /admin
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,14 +1,23 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
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<String>,
|
pub segments: Vec<Segment>,
|
||||||
pub published: bool,
|
pub published: bool,
|
||||||
#[serde(deserialize_with = "deserialize_date")]
|
#[serde(deserialize_with = "deserialize_date")]
|
||||||
pub date: DateTime<Utc>,
|
pub date: DateTime<Utc>,
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pub mod blog_post_model;
|
||||||
@@ -1,11 +1,21 @@
|
|||||||
|
use askama::Values;
|
||||||
use axum::http::{header, StatusCode};
|
use axum::http::{header, StatusCode};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use rss::{ChannelBuilder, GuidBuilder, Item, ItemBuilder};
|
use rss::{ChannelBuilder, EnclosureBuilder, GuidBuilder, Item, ItemBuilder};
|
||||||
|
|
||||||
use crate::blog_posts::blog_post_model::{BlogPostMetadata, BLOG_POST_PATH};
|
use crate::blog_posts::blog_post_model::{BlogPostMetadata, BLOG_POST_PATH};
|
||||||
|
use crate::filters::{parse_markdown, truncate_md};
|
||||||
use crate::post_utils::post_listing::get_post_list;
|
use crate::post_utils::post_listing::get_post_list;
|
||||||
|
|
||||||
|
struct EmptyValues;
|
||||||
|
|
||||||
|
impl Values for EmptyValues {
|
||||||
|
fn get_value<'a>(&'a self, _key: &str) -> Option<&'a dyn std::any::Any> {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn render_rss_feed() -> Result<impl IntoResponse, StatusCode> {
|
pub async fn render_rss_feed() -> Result<impl IntoResponse, StatusCode> {
|
||||||
let mut post_list = get_post_list::<BlogPostMetadata>(BLOG_POST_PATH)
|
let mut post_list = get_post_list::<BlogPostMetadata>(BLOG_POST_PATH)
|
||||||
.await
|
.await
|
||||||
@@ -25,8 +35,30 @@ pub async fn render_rss_feed() -> Result<impl IntoResponse, StatusCode> {
|
|||||||
ItemBuilder::default()
|
ItemBuilder::default()
|
||||||
.title(Some(post.metadata.title))
|
.title(Some(post.metadata.title))
|
||||||
.link(Some(format!("https://michalvanko.dev/blog/{}", post.slug)))
|
.link(Some(format!("https://michalvanko.dev/blog/{}", post.slug)))
|
||||||
// TODO Description should be just a preview
|
.description({
|
||||||
.description(None)
|
let truncated = truncate_md(&post.body, &EmptyValues, 2)
|
||||||
|
.unwrap_or("Can't parse post body".to_string());
|
||||||
|
let parsed_md = parse_markdown(&truncated, &EmptyValues)
|
||||||
|
.unwrap_or("Can't process truncated post body".to_string());
|
||||||
|
Some(parsed_md)
|
||||||
|
})
|
||||||
|
.content({
|
||||||
|
let parsed_md = parse_markdown(&post.body, &EmptyValues)
|
||||||
|
.unwrap_or("Can't process full post body".to_string());
|
||||||
|
Some(parsed_md)
|
||||||
|
})
|
||||||
|
.enclosure({
|
||||||
|
post.metadata.thumbnail.map(|src| {
|
||||||
|
let mime_type = mime_guess::from_path(&src)
|
||||||
|
.first()
|
||||||
|
.map(|mime| mime.to_string())
|
||||||
|
.unwrap_or("image".to_string());
|
||||||
|
EnclosureBuilder::default()
|
||||||
|
.url(src)
|
||||||
|
.mime_type(mime_type)
|
||||||
|
.build()
|
||||||
|
})
|
||||||
|
})
|
||||||
.guid(Some(
|
.guid(Some(
|
||||||
GuidBuilder::default()
|
GuidBuilder::default()
|
||||||
.value(format!("https://michalvanko.dev/blog/{}", post.slug))
|
.value(format!("https://michalvanko.dev/blog/{}", post.slug))
|
||||||
@@ -49,5 +81,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();
|
||||||
return Ok(([(header::CONTENT_TYPE, "application/xml")], response));
|
Ok(([(header::CONTENT_TYPE, "application/xml")], response))
|
||||||
}
|
}
|
||||||
@@ -1,83 +1,30 @@
|
|||||||
use core::panic;
|
use core::fmt;
|
||||||
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 tokio::fs;
|
use tracing::{debug, error};
|
||||||
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, should_swap_dimensions},
|
||||||
|
resolutions::get_max_resolution,
|
||||||
};
|
};
|
||||||
|
|
||||||
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_html(markdown: &str, generate_images: bool) -> String {
|
// pub fn parse_markdown(markdown: &str) -> ::askama::Result<String>
|
||||||
|
pub fn parse_markdown<T: fmt::Display>(
|
||||||
|
markdown: T,
|
||||||
|
_: &dyn askama::Values,
|
||||||
|
) -> ::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);
|
||||||
@@ -92,17 +39,18 @@ pub fn parse_html(markdown: &str, generate_images: bool) -> String {
|
|||||||
let theme = theme_set.themes.get("InspiredGitHub").unwrap();
|
let theme = theme_set.themes.get("InspiredGitHub").unwrap();
|
||||||
let mut heading_ended: Option<bool> = None;
|
let mut heading_ended: Option<bool> = None;
|
||||||
|
|
||||||
let parser = Parser::new_ext(markdown, options).map(|event| match event {
|
let mds = markdown.to_string();
|
||||||
|
let parser = Parser::new_ext(&mds, options).map(|event| match event {
|
||||||
/*
|
/*
|
||||||
Parsing images considers `alt` attribute as inner `Text` event
|
Parsing images considers `alt` attribute as inner `Text` event
|
||||||
Therefore the `[alt]` is rendered in html as subtitle
|
Therefore the `[alt]` is rendered in html as subtitle
|
||||||
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(
|
||||||
@@ -116,10 +64,31 @@ pub fn parse_html(markdown: &str, generate_images: bool) -> String {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let dev_only_img_path =
|
// Handle SVG files - don't try to get dimensions (image crate doesn't support SVG)
|
||||||
Path::new("../static/").join(dest_url.strip_prefix("/").unwrap_or(&dest_url));
|
if dest_url.to_lowercase().ends_with(".svg") {
|
||||||
let img_dimensions = image_dimensions(&dev_only_img_path).unwrap();
|
return Event::Html(
|
||||||
|
formatdoc!(
|
||||||
|
r#"<figure>
|
||||||
|
<img src="{dest_url}" alt="{title}">
|
||||||
|
<figcaption>
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dev_only_img_path =
|
||||||
|
Path::new("static/").join(dest_url.strip_prefix("/").unwrap_or(&dest_url));
|
||||||
|
|
||||||
|
// We need to take the exif rotation into consideration here
|
||||||
|
let img_dimensions = {
|
||||||
|
let orig_img_dimensions = image_dimensions(&dev_only_img_path).unwrap();
|
||||||
|
if should_swap_dimensions(&dev_only_img_path) {
|
||||||
|
(orig_img_dimensions.1, orig_img_dimensions.0)
|
||||||
|
} else {
|
||||||
|
orig_img_dimensions
|
||||||
|
}
|
||||||
|
};
|
||||||
let (max_width, max_height) = get_max_resolution(
|
let (max_width, max_height) = get_max_resolution(
|
||||||
img_dimensions,
|
img_dimensions,
|
||||||
MAX_BLOG_IMAGE_RESOLUTION.0,
|
MAX_BLOG_IMAGE_RESOLUTION.0,
|
||||||
@@ -128,12 +97,7 @@ pub fn parse_html(markdown: &str, generate_images: bool) -> 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,
|
&dest_url, max_width, max_height, &title, None,
|
||||||
max_width,
|
|
||||||
max_height,
|
|
||||||
&title,
|
|
||||||
None,
|
|
||||||
generate_images,
|
|
||||||
)
|
)
|
||||||
.unwrap_or(formatdoc!(
|
.unwrap_or(formatdoc!(
|
||||||
r#"
|
r#"
|
||||||
@@ -144,10 +108,6 @@ pub fn parse_html(markdown: &str, generate_images: bool) -> 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>
|
||||||
@@ -233,5 +193,5 @@ pub fn parse_html(markdown: &str, generate_images: bool) -> 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);
|
||||||
html
|
Ok(html)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
mod markdown;
|
||||||
|
mod pretty_date;
|
||||||
|
mod truncate_md;
|
||||||
|
pub use markdown::parse_markdown;
|
||||||
|
pub use pretty_date::pretty_date;
|
||||||
|
pub use truncate_md::truncate_md;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
// This filter does not have extra arguments
|
||||||
|
pub fn pretty_date(date_time: &DateTime<Utc>, _: &dyn askama::Values) -> ::askama::Result<String> {
|
||||||
|
let formatted = format!("{}", date_time.format("%e %B %Y"));
|
||||||
|
Ok(formatted)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// This filter does not have extra arguments
|
||||||
|
|
||||||
|
const FORBIDDEN_LINES: [&str; 5] = [" ", "#", "-", "!", "<"];
|
||||||
|
|
||||||
|
pub fn truncate_md(body: &str, _: &dyn askama::Values, 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)
|
||||||
|
}
|
||||||
Vendored
-3
@@ -1,3 +0,0 @@
|
|||||||
/// <reference types="@sveltejs/kit" />
|
|
||||||
/// <reference types="svelte" />
|
|
||||||
/// <reference types="vite/client" />
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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-'))
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
onMountScripts?: Array<() => void>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export function runOnMountScripts() {
|
|
||||||
window.onMountScripts?.forEach((fn) => {
|
|
||||||
fn()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
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',
|
|
||||||
})
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
<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}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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',
|
|
||||||
})
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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'],
|
|
||||||
})
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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',
|
|
||||||
})
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<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)}
|
|
||||||
><</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)}
|
|
||||||
>></a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
</ul>
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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}`
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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(',')
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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]),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { generateSrcSet, getNFResize } from '$lib/large-media'
|
|
||||||
import Prism from 'prismjs'
|
|
||||||
import loadLanguages from 'prismjs/components/index.js'
|
|
||||||
|
|
||||||
loadLanguages(['bash', 'markdown', 'json', 'yaml', 'typescript'])
|
|
||||||
|
|
||||||
export const renderer = {
|
|
||||||
heading(text: string, level: string) {
|
|
||||||
const escapedText = text.toLowerCase().replace(/[^\w]+/g, '-')
|
|
||||||
|
|
||||||
return `
|
|
||||||
<h${level}>
|
|
||||||
<a name="${escapedText}" class="anchor" href="#${escapedText}">
|
|
||||||
<span class="header-link"></span>
|
|
||||||
</a>
|
|
||||||
${text}
|
|
||||||
</h${level}>
|
|
||||||
`
|
|
||||||
},
|
|
||||||
image(href: string, title: string, text: string) {
|
|
||||||
const figcaption = title ? `<figcaption>${title}</figcaption>` : ''
|
|
||||||
const isLocal = !href.startsWith('http')
|
|
||||||
const src = isLocal ? getNFResize(href, { height: 800, width: 800 }) : href
|
|
||||||
const srcset = isLocal
|
|
||||||
? `srcset="${generateSrcSet(href, { width: 800, height: 800 })}"`
|
|
||||||
: ''
|
|
||||||
|
|
||||||
return `
|
|
||||||
<figure>
|
|
||||||
<img
|
|
||||||
alt="${text}"
|
|
||||||
${srcset}
|
|
||||||
src="${src}"
|
|
||||||
/>
|
|
||||||
${figcaption}
|
|
||||||
</figure>
|
|
||||||
`
|
|
||||||
},
|
|
||||||
code(source: string, lang?: string) {
|
|
||||||
// When lang is not specified it is usually an empty string which has to be handled
|
|
||||||
const usedLang = !lang ? 'shell' : lang
|
|
||||||
const highlightedSource = Prism.highlight(
|
|
||||||
source,
|
|
||||||
Prism.languages[usedLang],
|
|
||||||
usedLang
|
|
||||||
)
|
|
||||||
return `<pre class='language-${usedLang}'><code class='language-${usedLang}'>${highlightedSource}</code></pre>`
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { describe, test, expect } from 'vitest'
|
|
||||||
import { getDropTakeFromPageParams } from './dropTakeParams'
|
|
||||||
|
|
||||||
describe('convert search params', () => {
|
|
||||||
test('should convert from page size and page to offset and limit', () => {
|
|
||||||
expect(getDropTakeFromPageParams(7, 2)).toEqual({
|
|
||||||
offset: 7,
|
|
||||||
limit: 7,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { init, splitEvery } from 'ramda'
|
|
||||||
|
|
||||||
export function parseParams(params: string) {
|
|
||||||
let splittedParams = params.split('/')
|
|
||||||
if (splittedParams.length % 2 !== 0) {
|
|
||||||
splittedParams = init(splittedParams)
|
|
||||||
}
|
|
||||||
const splits = splitEvery(2, splittedParams)
|
|
||||||
return Object.fromEntries(splits)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toParams(records: Record<string, string>) {
|
|
||||||
return Object.entries(records)
|
|
||||||
.map(([key, value]) => `${key}/${value}`)
|
|
||||||
.join('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginationParams {
|
|
||||||
pageSize: number
|
|
||||||
page: number
|
|
||||||
filters?: Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DropTakeParams {
|
|
||||||
offset: number
|
|
||||||
limit: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert svelte `load` params into a `offset` and `limit` so they can be used to fetch endpoints with pagination queries
|
|
||||||
*/
|
|
||||||
export function getDropTakeFromPageParams(
|
|
||||||
pageSize: number,
|
|
||||||
page: number
|
|
||||||
): DropTakeParams {
|
|
||||||
const offset = pageSize * (page - 1)
|
|
||||||
const limit = pageSize
|
|
||||||
return {
|
|
||||||
offset,
|
|
||||||
limit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { range } from 'ramda'
|
|
||||||
import { describe, expect, test } from 'vitest'
|
|
||||||
import { filterByPropContains, dropAndTake, filterAndCount } from './pagination'
|
|
||||||
|
|
||||||
describe('pagination', () => {
|
|
||||||
test('does not drop any items by default', () => {
|
|
||||||
const items = range(0, 100)
|
|
||||||
expect(dropAndTake({})(items)).toHaveLength(100)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('limits out exact number of items', () => {
|
|
||||||
const items = range(0, 100)
|
|
||||||
expect(dropAndTake({ limit: 10 })(items)).toHaveLength(10)
|
|
||||||
expect(dropAndTake({ limit: 10 })(items)[0]).toBe(0)
|
|
||||||
expect(dropAndTake({ limit: 10 })(items)[9]).toBe(9)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('offset is skipping a number of items from the front', () => {
|
|
||||||
const items = range(0, 100)
|
|
||||||
expect(dropAndTake({ offset: 10 })(items)).toHaveLength(90)
|
|
||||||
expect(dropAndTake({ offset: 10 })(items)[0]).toBe(10)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('is able to combine limit and offset', () => {
|
|
||||||
const items = range(0, 100)
|
|
||||||
expect(dropAndTake({ offset: 10, limit: 10 })(items)).toHaveLength(10)
|
|
||||||
expect(dropAndTake({ offset: 10, limit: 10 })(items)[0]).toBe(10)
|
|
||||||
expect(dropAndTake({ offset: 10, limit: 10 })(items)[9]).toBe(19)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('is able to filter by a field', () => {
|
|
||||||
const items = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
prop: ['yes'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
prop: ['yes', 'no'],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
expect(filterByPropContains({ prop: 'no' })(items)).toHaveLength(1)
|
|
||||||
expect(filterByPropContains({ prop: 'no' })(items)[0].id).toBe(2)
|
|
||||||
|
|
||||||
expect(filterByPropContains({ prop: 'yes' })(items)[0].id).toBe(1)
|
|
||||||
expect(filterByPropContains({ prop: 'yes' })(items)).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('is able to combine limit and offset while filtering by field', () => {
|
|
||||||
const items = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
prop: ['yes'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
prop: ['yes', 'no'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
prop: ['yes', 'no'],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
test('combine all parameters', () => {
|
|
||||||
const result = filterAndCount({
|
|
||||||
offset: 1,
|
|
||||||
limit: 1,
|
|
||||||
filters: { prop: 'no' },
|
|
||||||
})(items)
|
|
||||||
expect(result.totalCount).toBe(2)
|
|
||||||
expect(result.items[0].id).toBe(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('with 0 offset', () => {
|
|
||||||
const result = filterAndCount({
|
|
||||||
offset: 0,
|
|
||||||
limit: 1,
|
|
||||||
filters: { prop: 'no' },
|
|
||||||
})(items)
|
|
||||||
expect(result.totalCount).toBe(2)
|
|
||||||
expect(result.items[0].id).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('without filter', () => {
|
|
||||||
const result = filterAndCount({ offset: 1, limit: 1 })(items)
|
|
||||||
expect(result.totalCount).toBe(3)
|
|
||||||
expect(result.items[0].id).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('without any params', () => {
|
|
||||||
const result = filterAndCount({})(items)
|
|
||||||
expect(result.totalCount).toBe(3)
|
|
||||||
expect(result.items.length).toEqual(result.totalCount)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { identity, drop, take, pipe } from 'ramda'
|
|
||||||
|
|
||||||
export interface PaginationQuery {
|
|
||||||
offset?: number
|
|
||||||
limit?: number
|
|
||||||
filters?: Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginationResult<ItemType> {
|
|
||||||
items: ItemType[]
|
|
||||||
totalCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dropAndTake<Item>({ offset = 0, limit = Infinity }) {
|
|
||||||
return pipe(drop<Item>(offset), take<Item>(limit)) as (
|
|
||||||
items: Item[]
|
|
||||||
) => Item[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export function filterByPropContains<Item extends Record<string, any>>(
|
|
||||||
filters: Record<string, string>
|
|
||||||
) {
|
|
||||||
return function (items: Item[]) {
|
|
||||||
return items.filter((item) => {
|
|
||||||
return Object.entries(filters).every(([fieldName, value]) =>
|
|
||||||
item[fieldName].includes(value)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export function filterAndCount<Item extends Record<string, any>>({
|
|
||||||
filters,
|
|
||||||
...dropTakeParams
|
|
||||||
}: PaginationQuery) {
|
|
||||||
return function (items: Item[]) {
|
|
||||||
const filterFunction = filters
|
|
||||||
? filterByPropContains<Item>(filters)
|
|
||||||
: identity
|
|
||||||
const filteredItems = filterFunction(items)
|
|
||||||
return {
|
|
||||||
items: dropAndTake<Item>(dropTakeParams)(filteredItems),
|
|
||||||
totalCount: filteredItems.length,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { globalStyle, style } from '@vanilla-extract/css'
|
|
||||||
import { vars } from '$lib/styles/vars.css'
|
|
||||||
|
|
||||||
export const contentClass = style({})
|
|
||||||
|
|
||||||
globalStyle(`${contentClass} ul, ${contentClass} ol`, {
|
|
||||||
lineHeight: vars.lineHeight['2x'],
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle(`${contentClass} li`, {
|
|
||||||
marginBottom: vars.space['2x'],
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle(`${contentClass} img`, {
|
|
||||||
maxHeight: vars.height.image,
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle(`${contentClass} img:only-child`, {
|
|
||||||
display: 'block',
|
|
||||||
margin: '0 auto',
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle(`${contentClass} .video-embed`, {
|
|
||||||
margin: '0 auto',
|
|
||||||
maxWidth: vars.width.image,
|
|
||||||
aspectRatio: vars.aspectRatio.monitor,
|
|
||||||
})
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import { globalStyle } from '@vanilla-extract/css'
|
|
||||||
import { breakpoints, colors, vars } from './vars.css'
|
|
||||||
|
|
||||||
globalStyle('html', {
|
|
||||||
scrollBehavior: 'smooth',
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle('body', {
|
|
||||||
margin: 0,
|
|
||||||
fontFamily:
|
|
||||||
'cantarell, roboto, -apple-system, blinkmacsystemfont, segoe ui, oxygen, ubuntu, fira sans, droid sans, helvetica neue, sans-serif',
|
|
||||||
fontSize: '16px',
|
|
||||||
lineHeight: 1.65,
|
|
||||||
color: vars.color.articleText,
|
|
||||||
background: vars.color.background,
|
|
||||||
minHeight: '100vh',
|
|
||||||
'@media': {
|
|
||||||
[`screen and (min-width: ${breakpoints.s}px)`]: {
|
|
||||||
fontSize: '18px',
|
|
||||||
},
|
|
||||||
[`screen and (min-width: ${breakpoints.m}px)`]: {
|
|
||||||
fontSize: '24px',
|
|
||||||
},
|
|
||||||
print: {
|
|
||||||
fontSize: '12px',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle('h1, h2, h3, h4, h5, h6', {
|
|
||||||
marginTop: vars.space['2x'],
|
|
||||||
marginBottom: vars.space['1x'],
|
|
||||||
marginLeft: vars.space.none,
|
|
||||||
marginRight: vars.space.none,
|
|
||||||
lineHeight: vars.lineHeight['1x'],
|
|
||||||
color: vars.color.header,
|
|
||||||
fontWeight: 500,
|
|
||||||
letterSpacing: '-0.01em',
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle('h1', {
|
|
||||||
fontSize: vars.fontSize['5x'],
|
|
||||||
fontWeight: 800,
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle('h2', {
|
|
||||||
fontSize: vars.fontSize['4x'],
|
|
||||||
fontWeight: 700,
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle('h3', {
|
|
||||||
fontSize: vars.fontSize['3x'],
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle('h4', {
|
|
||||||
fontSize: vars.space['2x'],
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle('a', {
|
|
||||||
textDecoration: 'none',
|
|
||||||
transition: 'color 0.2s',
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle('a:link', {
|
|
||||||
color: vars.color.link,
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle('a:hover', {
|
|
||||||
color: vars.color.linkHover,
|
|
||||||
textDecoration: 'underline',
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle('a:visited', {
|
|
||||||
color: vars.color.linkVisited,
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle('a:visited:hover', {
|
|
||||||
color: vars.color.linkVisitedHover,
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle('main pre, main pre[class*="language-"], main :not(pre) > code', {
|
|
||||||
fontFamily: 'menlo, inconsolata, monospace',
|
|
||||||
backgroundColor: vars.color.codeBackground,
|
|
||||||
paddingTop: vars.space['1x'],
|
|
||||||
paddingBottom: vars.space['1x'],
|
|
||||||
paddingLeft: vars.space['1x'],
|
|
||||||
paddingRight: vars.space['1x'],
|
|
||||||
color: vars.color.code,
|
|
||||||
lineHeight: vars.lineHeight['0x'],
|
|
||||||
boxShadow: vars.boxShadow.codeBoxShadow,
|
|
||||||
borderRadius: 3,
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle('main code, main code[class*="language-"]', {
|
|
||||||
fontSize: vars.fontSize.sm,
|
|
||||||
'@media': {
|
|
||||||
[`screen and (min-width: ${breakpoints.m}px)`]: {
|
|
||||||
fontSize: vars.fontSize.xs,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle('code', {
|
|
||||||
whiteSpace: 'pre-line',
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle('pre code', {
|
|
||||||
whiteSpace: 'pre',
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle('figure', {
|
|
||||||
marginTop: vars.space['2x'],
|
|
||||||
marginBottom: vars.space['2x'],
|
|
||||||
marginLeft: vars.space['1x'],
|
|
||||||
marginRight: vars.space['1x'],
|
|
||||||
textAlign: 'center',
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle('figcaption', {
|
|
||||||
fontSize: vars.fontSize.xs,
|
|
||||||
fontStyle: 'italic',
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle('blockquote', {
|
|
||||||
lineHeight: vars.lineHeight['2x'],
|
|
||||||
margin: vars.space['0x'],
|
|
||||||
paddingLeft: vars.space['2x'],
|
|
||||||
paddingRight: vars.space['0x'],
|
|
||||||
paddingTop: vars.space['1x'],
|
|
||||||
paddingBottom: vars.space['2x'],
|
|
||||||
background: vars.color.quoteBackground,
|
|
||||||
borderRadius: 3,
|
|
||||||
borderLeft: `2px solid ${colors.tearkiss}`,
|
|
||||||
boxShadow: vars.boxShadow.contentBoxShadow,
|
|
||||||
fontSize: vars.fontSize.sm,
|
|
||||||
overflow: 'auto',
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle('blockquote p', {
|
|
||||||
marginTop: vars.space['0x'],
|
|
||||||
marginBottom: vars.space['0x'],
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle('p', {
|
|
||||||
marginTop: vars.space['1x'],
|
|
||||||
marginBottom: vars.space['1x'],
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle('b, strong', {
|
|
||||||
fontWeight: 600,
|
|
||||||
})
|
|
||||||
|
|
||||||
globalStyle('::selection', {
|
|
||||||
background: vars.color.selection,
|
|
||||||
})
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { style } from '@vanilla-extract/css'
|
|
||||||
import { desaturate, transparentize } from 'polished'
|
|
||||||
import { colors } from './vars.css'
|
|
||||||
|
|
||||||
export const horizontalBorderTopClass = style({
|
|
||||||
borderTop: `1px solid ${transparentize(
|
|
||||||
0.6,
|
|
||||||
desaturate(0.5, colors.tearkiss)
|
|
||||||
)}`,
|
|
||||||
})
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles'
|
|
||||||
import { breakpoints, vars } from './vars.css'
|
|
||||||
|
|
||||||
const responsiveProperties = defineProperties({
|
|
||||||
conditions: {
|
|
||||||
mobile: {},
|
|
||||||
tablet: { '@media': `screen and (min-width: ${breakpoints.m}px)` },
|
|
||||||
desktop: { '@media': `screen and (min-width: ${breakpoints.l}px)` },
|
|
||||||
},
|
|
||||||
defaultCondition: 'mobile',
|
|
||||||
properties: {
|
|
||||||
display: ['none', 'flex', 'block', 'inline', 'inline-block', 'grid'],
|
|
||||||
position: ['relative', 'absolute', 'fixed'],
|
|
||||||
flexDirection: ['row', 'column'],
|
|
||||||
flexWrap: ['wrap', 'nowrap'],
|
|
||||||
flexShrink: [0],
|
|
||||||
flexGrow: [0, 1],
|
|
||||||
justifyContent: [
|
|
||||||
'stretch',
|
|
||||||
'start',
|
|
||||||
'center',
|
|
||||||
'end',
|
|
||||||
'space-around',
|
|
||||||
'space-between',
|
|
||||||
],
|
|
||||||
justifyItems: [
|
|
||||||
'stretch',
|
|
||||||
'start',
|
|
||||||
'center',
|
|
||||||
'end',
|
|
||||||
'space-around',
|
|
||||||
'space-between',
|
|
||||||
],
|
|
||||||
alignItems: ['stretch', 'flex-start', 'center', 'flex-end'],
|
|
||||||
flex: ['1'],
|
|
||||||
gap: vars.space,
|
|
||||||
textAlign: ['center', 'justify', 'start', 'end'],
|
|
||||||
textShadow: vars.textShadow,
|
|
||||||
paddingTop: vars.space,
|
|
||||||
paddingBottom: vars.space,
|
|
||||||
paddingLeft: vars.space,
|
|
||||||
paddingRight: vars.space,
|
|
||||||
marginTop: vars.space,
|
|
||||||
marginBottom: vars.space,
|
|
||||||
marginRight: vars.space,
|
|
||||||
marginLeft: vars.space,
|
|
||||||
columnGap: vars.space,
|
|
||||||
fontSize: vars.fontSize,
|
|
||||||
fontFamily: vars.fontFamily,
|
|
||||||
fontWeight: vars.fontWeight,
|
|
||||||
fontStyle: ['italic', 'normal'],
|
|
||||||
lineHeight: vars.lineHeight,
|
|
||||||
whiteSpace: ['normal', 'nowrap'],
|
|
||||||
width: vars.width,
|
|
||||||
maxWidth: vars.width,
|
|
||||||
height: vars.height,
|
|
||||||
listStyle: ['none'],
|
|
||||||
overflow: ['auto'],
|
|
||||||
aspectRatio: vars.aspectRatio,
|
|
||||||
},
|
|
||||||
shorthands: {
|
|
||||||
padding: ['paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight'],
|
|
||||||
paddingX: ['paddingLeft', 'paddingRight'],
|
|
||||||
paddingY: ['paddingTop', 'paddingBottom'],
|
|
||||||
placeItems: ['justifyContent', 'alignItems'],
|
|
||||||
typeSize: ['fontSize', 'lineHeight'],
|
|
||||||
margin: ['marginTop', 'marginBottom', 'marginLeft', 'marginRight'],
|
|
||||||
marginX: ['marginLeft', 'marginRight'],
|
|
||||||
marginY: ['marginTop', 'marginBottom'],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const colorProperties = defineProperties({
|
|
||||||
properties: {
|
|
||||||
color: vars.color,
|
|
||||||
background: vars.color,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const sprinkles = createSprinkles(responsiveProperties, colorProperties)
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
import { createGlobalTheme } from '@vanilla-extract/css'
|
|
||||||
import {
|
|
||||||
darken,
|
|
||||||
desaturate,
|
|
||||||
lighten,
|
|
||||||
mix,
|
|
||||||
modularScale,
|
|
||||||
saturate,
|
|
||||||
tint,
|
|
||||||
transparentize,
|
|
||||||
} from 'polished'
|
|
||||||
|
|
||||||
export const colors = {
|
|
||||||
tearkiss: '#42a6f0',
|
|
||||||
pinky: '#fea6eb',
|
|
||||||
lightCyan: '#d8f6ff',
|
|
||||||
midnightBlue: '#171664',
|
|
||||||
frenchViolet: '#7332c3',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const menuBackground = transparentize(0.6, colors.tearkiss)
|
|
||||||
export const twitchEmbedBackground = transparentize(0.6, colors.pinky)
|
|
||||||
export const background = tint(0.7, colors.lightCyan)
|
|
||||||
export const codeBackground = tint(0.2, background)
|
|
||||||
export const quoteBackground = darken(0.02, background)
|
|
||||||
export const transparent = transparentize(1, '#ffffff')
|
|
||||||
const articleText = desaturate(0.16, colors.midnightBlue)
|
|
||||||
|
|
||||||
export enum breakpoints {
|
|
||||||
s = 400,
|
|
||||||
m = 700,
|
|
||||||
image = 800,
|
|
||||||
l = 1000,
|
|
||||||
max = 1140,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mediaAt(breakpoint: breakpoints) {
|
|
||||||
return `screen and (min-width: ${breakpoint}px)`
|
|
||||||
}
|
|
||||||
|
|
||||||
const createScale =
|
|
||||||
(base: number, ratio: number, unit = 'em') =>
|
|
||||||
(steps: number) =>
|
|
||||||
`${modularScale(steps, base, ratio)}${unit}`
|
|
||||||
|
|
||||||
const spaceScale = createScale(0.2, 2)
|
|
||||||
const fontSizeScale = createScale(1, 1.125)
|
|
||||||
const lineHeightScale = createScale(1.05, 1.125)
|
|
||||||
// const borderRadiusScale = createScale(1.5, 4)
|
|
||||||
|
|
||||||
export const vars = createGlobalTheme(':root', {
|
|
||||||
space: {
|
|
||||||
none: '0',
|
|
||||||
auto: 'auto',
|
|
||||||
'0x': spaceScale(0),
|
|
||||||
'1x': spaceScale(1),
|
|
||||||
'2x': spaceScale(2),
|
|
||||||
'3x': spaceScale(3),
|
|
||||||
'4x': spaceScale(4),
|
|
||||||
'5x': spaceScale(5),
|
|
||||||
'6x': spaceScale(6),
|
|
||||||
'7x': spaceScale(7),
|
|
||||||
'8x': spaceScale(8),
|
|
||||||
},
|
|
||||||
color: {
|
|
||||||
articleText,
|
|
||||||
tintedText: tint(0.25, articleText),
|
|
||||||
selection: tint(0.4, colors.pinky),
|
|
||||||
link: saturate(0.2, mix(0.66, colors.tearkiss, colors.midnightBlue)),
|
|
||||||
linkHover: colors.tearkiss,
|
|
||||||
linkVisited: colors.frenchViolet,
|
|
||||||
linkVisitedHover: lighten(0.1, colors.frenchViolet),
|
|
||||||
code: lighten(0.15, articleText),
|
|
||||||
|
|
||||||
menu: colors.midnightBlue,
|
|
||||||
menuLink: colors.midnightBlue,
|
|
||||||
menuLinkHover: lighten(0.15, colors.midnightBlue),
|
|
||||||
|
|
||||||
header: lighten(0.1, colors.midnightBlue),
|
|
||||||
background,
|
|
||||||
codeBackground,
|
|
||||||
quoteBackground,
|
|
||||||
menuBackground,
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
body: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
|
|
||||||
},
|
|
||||||
fontSize: {
|
|
||||||
xs: fontSizeScale(-2),
|
|
||||||
sm: fontSizeScale(-1),
|
|
||||||
base: fontSizeScale(0),
|
|
||||||
xl: fontSizeScale(1),
|
|
||||||
'2x': fontSizeScale(2),
|
|
||||||
'3x': fontSizeScale(3),
|
|
||||||
'4x': fontSizeScale(5),
|
|
||||||
'5x': fontSizeScale(7),
|
|
||||||
'6x': fontSizeScale(9),
|
|
||||||
},
|
|
||||||
lineHeight: {
|
|
||||||
none: '0',
|
|
||||||
'0x': lineHeightScale(0),
|
|
||||||
'1x': lineHeightScale(1),
|
|
||||||
'2x': lineHeightScale(2),
|
|
||||||
'3x': lineHeightScale(3),
|
|
||||||
'4x': lineHeightScale(4),
|
|
||||||
'5x': lineHeightScale(5),
|
|
||||||
},
|
|
||||||
fontWeight: {
|
|
||||||
thin: 'thin',
|
|
||||||
normal: 'normal',
|
|
||||||
bold: 'bold',
|
|
||||||
},
|
|
||||||
textShadow: {
|
|
||||||
menuLinkShadow: `0.02em 0.02em 0.03em ${transparentize(
|
|
||||||
0.7,
|
|
||||||
colors.midnightBlue
|
|
||||||
)}`,
|
|
||||||
menuActiveLinkShadow: `0.01em 0.01em 0.05em ${transparentize(
|
|
||||||
0.1,
|
|
||||||
colors.midnightBlue
|
|
||||||
)}`,
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
contentBoxShadow: `0px 0px 2px 1px ${transparentize(
|
|
||||||
0.5,
|
|
||||||
desaturate(0.5, colors.tearkiss)
|
|
||||||
)}`,
|
|
||||||
codeBoxShadow: `inset 0px 0px 2px 1px ${transparentize(
|
|
||||||
0.8,
|
|
||||||
desaturate(0.5, colors.tearkiss)
|
|
||||||
)}`,
|
|
||||||
},
|
|
||||||
width: {
|
|
||||||
auto: 'auto',
|
|
||||||
s: '400px',
|
|
||||||
m: '700px',
|
|
||||||
image: '800px',
|
|
||||||
l: '1000px',
|
|
||||||
max: '1140px',
|
|
||||||
full: '100vw',
|
|
||||||
parent: '100%',
|
|
||||||
layoutMax: '42rem',
|
|
||||||
headerFooterMax: '52rem',
|
|
||||||
additionalBlockMax: '46rem',
|
|
||||||
},
|
|
||||||
height: {
|
|
||||||
full: '100hw',
|
|
||||||
parent: '100%',
|
|
||||||
image: '640px',
|
|
||||||
},
|
|
||||||
aspectRatio: {
|
|
||||||
monitor: '16 / 9',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -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("/svg", ServeDir::new("../static/svg"))
|
.nest_service("/egg-fetcher", ServeDir::new("static/egg-fetcher"))
|
||||||
// TODO manifest logos have bad link, #directory-swap
|
.nest_service("/svg", ServeDir::new("static/svg"))
|
||||||
.nest_service(
|
.nest_service("/config.yml", ServeDir::new("static/resources/config.yml")) // Decap CMS config
|
||||||
"/config.yml",
|
.nest_service("/resources", ServeDir::new("static/resources"))
|
||||||
ServeDir::new("../static/resources/config.yml"),
|
.nest_service("/robots.txt", ServeDir::new("robots.txt"));
|
||||||
);
|
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
let app = app.layer(LiveReloadLayer::new());
|
let app = app.layer(LiveReloadLayer::new());
|
||||||
@@ -54,11 +54,7 @@ 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
|
//
|
||||||
// OG tags
|
// TODO cookbook
|
||||||
// Remove old web completely
|
// TODO remove m-logo-svg from justfile and mention it in some article!!!
|
||||||
// Restructure repository
|
|
||||||
// - projects page
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
use askama::Template;
|
||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{Html, IntoResponse},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "admin.html")]
|
||||||
|
pub struct AdminPageTemplate {}
|
||||||
|
|
||||||
|
pub async fn render_admin() -> Result<impl IntoResponse, StatusCode> {
|
||||||
|
Ok(Html(
|
||||||
|
AdminPageTemplate {}
|
||||||
|
.render()
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
use askama::Template;
|
||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{Html, IntoResponse},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "assets/animated_logo.html")]
|
||||||
|
pub struct AnimatedLogoTemplate {}
|
||||||
|
|
||||||
|
pub async fn render_animated_logo() -> Result<impl IntoResponse, StatusCode> {
|
||||||
|
Ok(Html(AnimatedLogoTemplate {}.render().unwrap()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
use askama::Template;
|
||||||
|
use axum::{
|
||||||
|
extract::{OriginalUri, Path},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{Html, IntoResponse},
|
||||||
|
};
|
||||||
|
use tokio::try_join;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
blog_posts::blog_post_model::{BlogPostMetadata, Segment, BLOG_POST_PATH},
|
||||||
|
components::site_header::{HeaderProps, Link},
|
||||||
|
post_utils::{
|
||||||
|
post_listing::get_post_list,
|
||||||
|
segments::get_posts_by_segment,
|
||||||
|
tags::{get_popular_tags, get_posts_by_tag},
|
||||||
|
},
|
||||||
|
projects::featured_projects::get_featured_projects,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::post_list::PostListTemplate;
|
||||||
|
|
||||||
|
// TODO Refactor to render post list for the same broadcasts, blog and cookbook
|
||||||
|
pub async fn render_blog_post_list(
|
||||||
|
tag: Option<Path<String>>,
|
||||||
|
OriginalUri(original_uri): OriginalUri,
|
||||||
|
) -> Result<impl IntoResponse, 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, post_list) = try_join!(
|
||||||
|
get_popular_tags(Some(Segment::Blog)),
|
||||||
|
get_featured_projects(),
|
||||||
|
get_post_list::<BlogPostMetadata>(BLOG_POST_PATH)
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let posts = get_posts_by_segment(post_list, &[Segment::Blog]);
|
||||||
|
let posts = get_posts_by_tag(posts, &tag);
|
||||||
|
let header_props = match tag {
|
||||||
|
Some(_) => HeaderProps::with_back_link(Link {
|
||||||
|
href: "/blog".to_string(),
|
||||||
|
label: "All posts".to_string(),
|
||||||
|
}),
|
||||||
|
None => HeaderProps::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!("uri:{:?}", original_uri);
|
||||||
|
|
||||||
|
let (title, og_title) = if let Some(tag) = &tag {
|
||||||
|
(format!("#{tag}"), format!("{tag} blog posts"))
|
||||||
|
} else {
|
||||||
|
("Blog posts".to_string(), "Blog posts".to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Html(
|
||||||
|
PostListTemplate {
|
||||||
|
title,
|
||||||
|
og_title,
|
||||||
|
segment: Segment::Blog,
|
||||||
|
posts,
|
||||||
|
header_props,
|
||||||
|
tags: blog_tags,
|
||||||
|
featured_projects,
|
||||||
|
current_url: original_uri.to_string(),
|
||||||
|
}
|
||||||
|
.render()
|
||||||
|
.unwrap(),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
use askama::Template;
|
||||||
|
use axum::extract::OriginalUri;
|
||||||
|
use axum::response::{Html, IntoResponse};
|
||||||
|
use axum::{extract::Path, http::StatusCode};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
use crate::blog_posts::blog_post_model::{Segment, BLOG_POST_PATH};
|
||||||
|
use crate::post_utils::post_listing::get_post_list;
|
||||||
|
use crate::post_utils::post_parser::ParseResult;
|
||||||
|
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 segment: Segment,
|
||||||
|
pub header_props: HeaderProps,
|
||||||
|
pub slug: String,
|
||||||
|
pub thumbnail: Option<String>,
|
||||||
|
pub recommended_posts: Vec<ParseResult<BlogPostMetadata>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn render_blog_post(
|
||||||
|
Path(post_id): Path<String>,
|
||||||
|
OriginalUri(original_uri): OriginalUri,
|
||||||
|
) -> Result<impl IntoResponse, StatusCode> {
|
||||||
|
let path = format!("{}/{}.md", BLOG_POST_PATH, post_id);
|
||||||
|
let post = parse_post::<BlogPostMetadata>(&path).await?;
|
||||||
|
let segment = if original_uri.to_string().starts_with("/blog") {
|
||||||
|
Segment::Blog
|
||||||
|
} else if original_uri.to_string().starts_with("/broadcasts") {
|
||||||
|
Segment::Broadcasts
|
||||||
|
} else {
|
||||||
|
Segment::Blog
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut recommended_posts = get_recommended_posts(&segment, &post.metadata.tags).await?;
|
||||||
|
recommended_posts.retain(|post| post.slug != post_id);
|
||||||
|
recommended_posts.sort_by_key(|post| post.slug.to_string());
|
||||||
|
recommended_posts.reverse();
|
||||||
|
let recommended_posts = recommended_posts.into_iter().take(2).collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let header_props = match segment {
|
||||||
|
Segment::Blog => HeaderProps::with_back_link(Link {
|
||||||
|
href: "/blog".to_string(),
|
||||||
|
label: "All posts".to_string(),
|
||||||
|
}),
|
||||||
|
Segment::Broadcasts => HeaderProps::with_back_link(Link {
|
||||||
|
href: "/broadcasts".to_string(),
|
||||||
|
label: "All broadcasts".to_string(),
|
||||||
|
}),
|
||||||
|
_ => HeaderProps::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Html(
|
||||||
|
BlogPostTemplate {
|
||||||
|
title: post.metadata.title,
|
||||||
|
date: post.metadata.date,
|
||||||
|
tags: post.metadata.tags,
|
||||||
|
body: post.body,
|
||||||
|
slug: post.slug,
|
||||||
|
segment,
|
||||||
|
thumbnail: post.metadata.thumbnail,
|
||||||
|
header_props,
|
||||||
|
recommended_posts,
|
||||||
|
}
|
||||||
|
.render()
|
||||||
|
.unwrap(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_recommended_posts(
|
||||||
|
segment: &Segment,
|
||||||
|
tags: &[String],
|
||||||
|
) -> Result<Vec<ParseResult<BlogPostMetadata>>, StatusCode> {
|
||||||
|
let posts = get_post_list::<BlogPostMetadata>(BLOG_POST_PATH).await?;
|
||||||
|
|
||||||
|
let recommended_posts = posts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|post| {
|
||||||
|
let is_same_segment = post
|
||||||
|
.metadata
|
||||||
|
.segments
|
||||||
|
.iter()
|
||||||
|
.any(|post_segment| post_segment == segment);
|
||||||
|
let has_same_tags = post
|
||||||
|
.metadata
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.any(|post_tag| tags.contains(post_tag));
|
||||||
|
is_same_segment && has_same_tags
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok(recommended_posts)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
use askama::Template;
|
||||||
|
use axum::{
|
||||||
|
extract::{OriginalUri, Path},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{Html, IntoResponse},
|
||||||
|
};
|
||||||
|
use tokio::try_join;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
blog_posts::blog_post_model::{BlogPostMetadata, Segment, BLOG_POST_PATH},
|
||||||
|
components::site_header::{HeaderProps, Link},
|
||||||
|
post_utils::{
|
||||||
|
post_listing::get_post_list,
|
||||||
|
segments::get_posts_by_segment,
|
||||||
|
tags::{get_popular_tags, get_posts_by_tag},
|
||||||
|
},
|
||||||
|
projects::featured_projects::get_featured_projects,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::post_list::PostListTemplate;
|
||||||
|
|
||||||
|
pub async fn render_broadcast_post_list(
|
||||||
|
tag: Option<Path<String>>,
|
||||||
|
OriginalUri(original_uri): OriginalUri,
|
||||||
|
) -> Result<impl IntoResponse, 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 (popular_tags, featured_projects, post_list) = try_join!(
|
||||||
|
get_popular_tags(Some(Segment::Broadcasts)),
|
||||||
|
get_featured_projects(),
|
||||||
|
get_post_list::<BlogPostMetadata>(BLOG_POST_PATH)
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let posts = get_posts_by_segment(post_list, &[Segment::Broadcasts]);
|
||||||
|
let posts = get_posts_by_tag(posts, &tag);
|
||||||
|
|
||||||
|
let header_props = match tag {
|
||||||
|
Some(_) => HeaderProps::with_back_link(Link {
|
||||||
|
href: "/broadcasts".to_string(),
|
||||||
|
label: "All broadcasts".to_string(),
|
||||||
|
}),
|
||||||
|
None => HeaderProps::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!("uri:{:?}", original_uri);
|
||||||
|
|
||||||
|
let title = if let Some(tag) = &tag {
|
||||||
|
format!("#{tag} broadcasts")
|
||||||
|
} else {
|
||||||
|
"Broadcasts".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Html(
|
||||||
|
PostListTemplate {
|
||||||
|
title: title.clone(),
|
||||||
|
og_title: title,
|
||||||
|
segment: Segment::Broadcasts,
|
||||||
|
posts,
|
||||||
|
header_props,
|
||||||
|
tags: popular_tags,
|
||||||
|
featured_projects,
|
||||||
|
current_url: original_uri.to_string(),
|
||||||
|
}
|
||||||
|
.render()
|
||||||
|
.unwrap(),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::http::StatusCode;
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{Html, IntoResponse},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::components::site_header::HeaderProps;
|
use crate::components::site_header::HeaderProps;
|
||||||
|
|
||||||
@@ -18,7 +21,7 @@ pub struct ContactPageTemplate {
|
|||||||
pub links: Vec<ContactLink>,
|
pub links: Vec<ContactLink>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn render_contact() -> Result<ContactPageTemplate, StatusCode> {
|
pub async fn render_contact() -> Result<impl IntoResponse, StatusCode> {
|
||||||
let links = vec![
|
let links = vec![
|
||||||
ContactLink {
|
ContactLink {
|
||||||
href: "mailto:michalvankosk@gmail.com".to_string(),
|
href: "mailto:michalvankosk@gmail.com".to_string(),
|
||||||
@@ -44,6 +47,12 @@ pub async fn render_contact() -> Result<ContactPageTemplate, StatusCode> {
|
|||||||
title: "YouTube channel".to_string(),
|
title: "YouTube channel".to_string(),
|
||||||
svg: "youtube".to_string(),
|
svg: "youtube".to_string(),
|
||||||
},
|
},
|
||||||
|
ContactLink {
|
||||||
|
href: "https://mastodon.online/@michalvankodev".to_string(),
|
||||||
|
label: "Mastodon".to_string(),
|
||||||
|
title: "Mastodon profile".to_string(),
|
||||||
|
svg: "mastodon".to_string(),
|
||||||
|
},
|
||||||
ContactLink {
|
ContactLink {
|
||||||
href: "https://instagram.com/michalvankodev".to_string(),
|
href: "https://instagram.com/michalvankodev".to_string(),
|
||||||
label: "Instagram".to_string(),
|
label: "Instagram".to_string(),
|
||||||
@@ -51,7 +60,7 @@ pub async fn render_contact() -> Result<ContactPageTemplate, StatusCode> {
|
|||||||
svg: "instagram".to_string(),
|
svg: "instagram".to_string(),
|
||||||
},
|
},
|
||||||
ContactLink {
|
ContactLink {
|
||||||
href: "https://instagram.com/michalvankodev".to_string(),
|
href: "https://github.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(),
|
||||||
@@ -70,9 +79,13 @@ pub async fn render_contact() -> Result<ContactPageTemplate, StatusCode> {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
Ok(ContactPageTemplate {
|
Ok(Html(
|
||||||
|
ContactPageTemplate {
|
||||||
title: "Contact".to_owned(),
|
title: "Contact".to_owned(),
|
||||||
header_props: HeaderProps::default(),
|
header_props: HeaderProps::default(),
|
||||||
links,
|
links,
|
||||||
})
|
}
|
||||||
|
.render()
|
||||||
|
.unwrap(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use askama::Template;
|
||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{Html, IntoResponse},
|
||||||
|
};
|
||||||
|
use tokio::try_join;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
blog_posts::blog_post_model::{BlogPostMetadata, Segment, BLOG_POST_PATH},
|
||||||
|
components::site_header::HeaderProps,
|
||||||
|
filters,
|
||||||
|
post_utils::{
|
||||||
|
post_listing::get_post_list, post_parser::ParseResult, segments::ref_get_posts_by_segment,
|
||||||
|
tags::get_popular_tags,
|
||||||
|
},
|
||||||
|
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>,
|
||||||
|
broadcasts_tags: Vec<String>,
|
||||||
|
featured_blog_posts: Vec<Rc<ParseResult<BlogPostMetadata>>>,
|
||||||
|
featured_projects: Vec<ParseResult<ProjectMetadata>>,
|
||||||
|
featured_broadcasts: Vec<Rc<ParseResult<BlogPostMetadata>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn render_index() -> Result<impl IntoResponse, StatusCode> {
|
||||||
|
let (blog_tags, broadcasts_tags, all_posts, featured_projects) = try_join!(
|
||||||
|
get_popular_tags(Some(Segment::Blog)),
|
||||||
|
get_popular_tags(Some(Segment::Broadcasts)),
|
||||||
|
get_post_list::<BlogPostMetadata>(BLOG_POST_PATH),
|
||||||
|
get_featured_projects()
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Convert the all_posts into Rc<ParseResult<BlogPostMetadata>>
|
||||||
|
let all_posts_rc: Vec<Rc<ParseResult<BlogPostMetadata>>> =
|
||||||
|
all_posts.into_iter().map(Rc::new).collect();
|
||||||
|
|
||||||
|
let featured_blog_posts =
|
||||||
|
ref_get_posts_by_segment(&all_posts_rc, &[Segment::Blog, Segment::Featured]);
|
||||||
|
|
||||||
|
let featured_broadcasts =
|
||||||
|
ref_get_posts_by_segment(&all_posts_rc, &[Segment::Broadcasts, Segment::Featured]);
|
||||||
|
|
||||||
|
Ok(Html(
|
||||||
|
IndexTemplate {
|
||||||
|
header_props: HeaderProps::default(),
|
||||||
|
blog_tags,
|
||||||
|
broadcasts_tags,
|
||||||
|
featured_blog_posts,
|
||||||
|
featured_projects,
|
||||||
|
featured_broadcasts,
|
||||||
|
}
|
||||||
|
.render()
|
||||||
|
.unwrap(),
|
||||||
|
))
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user